diff --git a/.travis.yml b/.travis.yml index 62040612a..e127aa499 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,8 @@ +language: python + +python: + - "2.7.13" + git: depth: 10 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index c7d7eeb14..598b7e9b5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -40,7 +40,7 @@ Project maintainers who do not follow or enforce the Code of Conduct in good fai ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 693e7358c..dceaecddb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Atom is intentionally very modular. Nearly every non-editor UI element you inter ![atom-packages](https://cloud.githubusercontent.com/assets/69169/10472281/84fc9792-71d3-11e5-9fd1-19da717df079.png) -To get a sense for the packages that are bundled with Atom, you can go to Settings > Packages within Atom and take a look at the Core Packages section. +To get a sense for the packages that are bundled with Atom, you can go to `Settings` > `Packages` within Atom and take a look at the Core Packages section. Here's a list of the big ones: @@ -80,8 +80,8 @@ Here's a list of the big ones: * [autocomplete-plus](https://github.com/atom/autocomplete-plus) - autocompletions shown while typing. Some languages have additional packages for autocompletion functionality, such as [autocomplete-html](https://github.com/atom/autocomplete-html). * [git-diff](https://github.com/atom/git-diff) - Git change indicators shown in the editor's gutter. * [language-javascript](https://github.com/atom/language-javascript) - all bundled languages are packages too, and each one has a separate package `language-[name]`. Use these for feedback on syntax highlighting issues that only appear for a specific language. -* [one-dark-ui](https://github.com/atom/one-dark-ui) - the default UI styling for anything but the text editor. UI theme packages (i.e. packages with a `-ui` suffix) provide only styling and it's possible that a bundled package is responsible for a UI issue. There are other other bundled UI themes, such as [one-light-ui](https://github.com/atom/one-light-ui). -* [one-dark-syntax](https://github.com/atom/one-dark-syntax) - the default syntax highlighting styles applied for all languages. There are other other bundled syntax themes, such as [solarized-dark-syntax](https://github.com/atom/solarized-dark-syntax). You should use these packages for reporting issues that appear in many languages, but disappear if you change to another syntax theme. +* [one-dark-ui](https://github.com/atom/one-dark-ui) - the default UI styling for anything but the text editor. UI theme packages (i.e. packages with a `-ui` suffix) provide only styling and it's possible that a bundled package is responsible for a UI issue. There are other bundled UI themes, such as [one-light-ui](https://github.com/atom/one-light-ui). +* [one-dark-syntax](https://github.com/atom/one-dark-syntax) - the default syntax highlighting styles applied for all languages. There are other bundled syntax themes, such as [solarized-dark-syntax](https://github.com/atom/solarized-dark-syntax). You should use these packages for reporting issues that appear in many languages, but disappear if you change to another syntax theme. * [apm](https://github.com/atom/apm) - the `apm` command line tool (Atom Package Manager). You should use this repository for any contributions related to the `apm` tool and to publishing packages. * [atom.io](https://github.com/atom/atom.io) - the repository for feedback on the [Atom.io website](https://atom.io) and the [Atom.io package API](https://github.com/atom/atom/blob/master/docs/apm-rest-api.md) used by [apm](https://github.com/atom/apm). diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index b60bb86c9..cf1773856 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -9,8 +9,8 @@ Do you want to ask a question? Are you looking for support? The Atom message boa ### Prerequisites * [ ] Put an X between the brackets on this line if you have done all of the following: - * Reproduced the problem in Safe Mode: http://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode - * Followed all applicable steps in the debugging guide: http://flight-manual.atom.io/hacking-atom/sections/debugging/ + * Reproduced the problem in Safe Mode: https://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode + * Followed all applicable steps in the debugging guide: https://flight-manual.atom.io/hacking-atom/sections/debugging/ * Checked the FAQs on the message board for common solutions: https://discuss.atom.io/c/faq * Checked that your issue isn't already filed: https://github.com/issues?utf8=✓&q=is%3Aissue+user%3Aatom * Checked that there is not already an Atom package that provides the described functionality: https://atom.io/packages diff --git a/LICENSE.md b/LICENSE.md index 5bdf03cde..58684e683 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2011-2017 GitHub Inc. +Copyright (c) 2011-2018 GitHub Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index a578c38ce..a3356809d 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,20 @@ We must be able to understand the design of your change from this description. I +### Verification Process + + + ### Applicable Issues diff --git a/README.md b/README.md index dc4062ea9..8078c179b 100644 --- a/README.md +++ b/README.md @@ -42,27 +42,11 @@ The `.zip` version will not automatically update. Using [Chocolatey](https://chocolatey.org)? Run `cinst Atom` to install the latest version of Atom. -### Debian based (Debian, Ubuntu, Linux Mint) +### Linux Atom is only available for 64-bit Linux systems. -1. Download `atom-amd64.deb` from the [Atom releases page](https://github.com/atom/atom/releases/latest). -2. Run `sudo dpkg --install atom-amd64.deb` on the downloaded package. -3. Launch Atom using the installed `atom` command. - -The Linux version does not currently automatically update so you will need to -repeat these steps to upgrade to future releases. - -### RPM based (Red Hat, openSUSE, Fedora, CentOS) - -Atom is only available for 64-bit Linux systems. - -1. Download `atom.x86_64.rpm` from the [Atom releases page](https://github.com/atom/atom/releases/latest). -2. Run `sudo rpm -i atom.x86_64.rpm` on the downloaded package. -3. Launch Atom using the installed `atom` command. - -The Linux version does not currently automatically update so you will need to -repeat these steps to upgrade to future releases. +Configure your distribution's package manager to install and update Atom by following the [Linux installation instructions](https://flight-manual.atom.io/getting-started/sections/installing-atom/#platform-linux) in the Flight Manual. You will also find instructions on how to install Atom's official Linux packages without using a package repository, though you will not get automatic updates after installing Atom this way. ### Archive extraction diff --git a/apm/package.json b/apm/package.json index b15e4a30f..90093b3d4 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.18.11" + "atom-package-manager": "1.19.0" } } diff --git a/docs/README.md b/docs/README.md index c555306b5..c45e117e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,20 +2,20 @@ ![Atom](https://cloud.githubusercontent.com/assets/72919/2874231/3af1db48-d3dd-11e3-98dc-6066f8bc766f.png) -Most of the Atom user and developer documentation is contained in the [Atom Flight Manual](https://github.com/atom/flight-manual.atom.io) repository. - -In this directory you can only find very specific build and API level documentation. Some of this may eventually move to the Flight Manual as well. +Most of the Atom user and developer documentation is contained in the [Atom Flight Manual](https://github.com/atom/flight-manual.atom.io). ## Build documentation Instructions for building Atom on various platforms from source. -* [macOS](./build-instructions/macOS.md) -* [Windows](./build-instructions/windows.md) -* [Linux](./build-instructions/linux.md) * [FreeBSD](./build-instructions/freebsd.md) +* Moved to [the Flight Manual](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/) + * Linux + * macOS + * Windows -## Other documentation here +## Other documentation -* [apm REST API](./apm-rest-api.md) -* [Tips for contributing to packages](./contributing-to-packages.md) +[Native Profiling on macOS](./native-profiling.md) + +The other documentation that was listed here previously has been moved to [the Flight Manual](https://flight-manual.atom.io). diff --git a/docs/apm-rest-api.md b/docs/apm-rest-api.md index a3c8e5c25..3cc9bf2c7 100644 --- a/docs/apm-rest-api.md +++ b/docs/apm-rest-api.md @@ -1,285 +1,3 @@ # Atom.io package and update API -This guide describes the web API used by [apm](https://github.com/atom/apm) and -Atom. The vast majority of use cases are met by the `apm` command-line tool, -which does other useful things like incrementing your version in `package.json` -and making sure you have pushed your git tag. In fact, Atom itself shells out to -`apm` rather than hitting the API directly. If you're curious about how Atom -uses `apm`, see the [PackageManager class](https://github.com/atom/settings-view/blob/master/lib/package-manager.coffee) -in the `settings-view` package. - -*This API should be considered pre-release and is subject to change (though significant breaking changes are unlikely).* - -### Authorization - -For calls to the API that require authentication, provide a valid token from your -[Atom.io account page](https://atom.io/account) in the `Authorization` header. - -### Media type - -All requests that take parameters require `application/json`. - -# API Resources - -## Packages - -### Listing packages - -#### GET /api/packages - -Parameters: - -- **page** (optional) -- **sort** (optional) - One of `downloads`, `created_at`, `updated_at`, `stars`. Defaults to `downloads` -- **direction** (optional) - `asc` or `desc`. Defaults to `desc`. `stars` can only be ordered `desc` - -Returns a list of all packages in the following format: -```json - [ - { - "releases": { - "latest": "0.6.0" - }, - "name": "thedaniel-test-package", - "repository": { - "type": "git", - "url": "https://github.com/thedaniel/test-package" - } - }, - ... - ] -``` - -Results are paginated 30 at a time, and links to the next and last pages are -provided in the `Link` header: - -``` -Link: ; rel="self", - ; rel="last", - ; rel="next" -``` - -By default, results are sorted by download count, descending. - -### Searching packages - -#### GET /api/packages/search - -Parameters: - -- **q** (required) - Search query -- **page** (optional) -- **sort** (optional) - One of `downloads`, `created_at`, `updated_at`, `stars`. Defaults to the relevance of the search query. -- **direction** (optional) - `asc` or `desc`. Defaults to `desc`. - -Returns results in the same format as [listing packages](#listing-packages). - -### Showing package details - -#### GET /api/packages/:package_name - -Returns package details and versions for a single package - -Parameters: - -- **engine** (optional) - Only show packages with versions compatible with this - Atom version. Must be valid [SemVer](http://semver.org). - -Returns: - -```json - { - "releases": { - "latest": "0.6.0" - }, - "name": "thedaniel-test-package", - "repository": { - "type": "git", - "url": "https://github.com/thedaniel/test-package" - }, - "versions": [ - (see single version output below) - ..., - ] - } -``` - -### Creating a package - -#### POST /api/packages - -Create a new package; requires authentication. - -The name and version will be fetched from the `package.json` -file in the specified repository. The authenticating user *must* have access -to the indicated repository. - -Parameters: - -- **repository** - String. The repository containing the plugin, in the form "owner/repo" - -Returns: - -- **201** - Successfully created, returns created package. -- **400** - Repository is inaccessible, nonexistent, not an atom package. Possible - error messages include: - - That repo does not exist, isn't an atom package, or atombot does not have access - - The package.json at owner/repo isn't valid -- **409** - A package by that name already exists - -### Deleting a package - -#### DELETE /api/packages/:package_name - -Delete a package; requires authentication. - -Returns: - -- **204** - Success -- **400** - Repository is inaccessible -- **401** - Unauthorized - -### Renaming a package - -Packages are renamed by publishing a new version with the name changed in `package.json` -See [Creating a new package version](#creating-a-new-package-version) for details. - -Requests made to the previous name will forward to the new name. - -### Package Versions - -#### GET /api/packages/:package_name/versions/:version_name - -Returns `package.json` with `dist` key added for e.g. tarball download: - -```json - { - "bugs": { - "url": "https://github.com/thedaniel/test-package/issues" - }, - "dependencies": { - "async": "~0.2.6", - "pegjs": "~0.7.0", - "season": "~0.13.0" - }, - "description": "Expand snippets matching the current prefix with `tab`.", - "dist": { - "tarball": "https://codeload.github.com/..." - }, - "engines": { - "atom": "*" - }, - "main": "./lib/snippets", - "name": "thedaniel-test-package", - "publishConfig": { - "registry": "https://...", - }, - "repository": { - "type": "git", - "url": "https://github.com/thedaniel/test-package.git" - }, - "version": "0.6.0" - } -``` - - -### Creating a new package version - -#### POST /api/packages/:package_name/versions - -Creates a new package version from a git tag; requires authentication. If `rename` -is not `true`, the `name` field in `package.json` *must* match the current package -name. - -#### Parameters - -- **tag** - A git tag for the version you'd like to create. It's important to note - that the version name will not be taken from the tag, but from the `version` - key in the `package.json` file at that ref. The authenticating user *must* have - access to the package repository. -- **rename** - Boolean indicating whether this version contains a new name for the package. - -#### Returns - -- **201** - Successfully created. Returns created version. -- **400** - Git tag not found / Repository inaccessible / package.json invalid -- **409** - Version exists - -### Deleting a version - -#### DELETE /api/packages/:package_name/versions/:version_name - -Deletes a package version; requires authentication. - -Note that a version cannot be republished with a different tag if it is deleted. -If you need to delete the latest version of a package for e.g. security reasons, -you'll need to increment the version when republishing. - -Returns 204 No Content - - -## Stars - -### Listing user stars - -#### GET /api/users/:login/stars - -List a user's starred packages. - -Return value is similar to **GET /api/packages** - -#### GET /api/stars - -List the authenticated user's starred packages; requires authentication. - -Return value is similar to **GET /api/packages** - -### Starring a package - -#### POST /api/packages/:name/star - -Star a package; requires authentication. - -Returns a package. - -### Unstarring a package - -#### DELETE /api/packages/:name/star - -Unstar a package; requires authentication. - -Returns 204 No Content. - -### Listing a package's stargazers - -#### GET /api/packages/:name/stargazers - -List the users that have starred a package. - -Returns a list of user objects: - -```json -[ - {"login":"aperson"}, - {"login":"anotherperson"}, -] -``` - -## Atom updates - -### Listing Atom updates - -#### GET /api/updates - -Atom update feed, following the format expected by [Squirrel](https://github.com/Squirrel/). - -Returns: - -```json -{ - "name": "0.96.0", - "notes": "[HTML release notes]", - "pub_date": "2014-05-19T15:52:06.000Z", - "url": "https://www.atom.io/api/updates/download" -} -``` +The information that was here has been moved to [a permanent home inside Atom's Flight Manual.](https://flight-manual.atom.io/atom-server-side-apis/) \ No newline at end of file diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index 7161a8478..6d576f102 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -133,6 +133,8 @@ 'cmd-ctrl-left': 'editor:move-selection-left' 'cmd-ctrl-right': 'editor:move-selection-right' 'cmd-shift-V': 'editor:paste-without-reformatting' + 'alt-up': 'editor:select-larger-syntax-node' + 'alt-down': 'editor:select-smaller-syntax-node' # Emacs 'alt-f': 'editor:move-to-end-of-word' diff --git a/package.json b/package.json index 2af021a4f..12236bcd4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "1.24.0-dev", + "version": "1.25.0-dev", "description": "A hackable text editor for the 21st Century.", "main": "./src/main-process/main.js", "repository": { @@ -12,13 +12,13 @@ "url": "https://github.com/atom/atom/issues" }, "license": "MIT", - "electronVersion": "1.6.15", + "electronVersion": "1.7.10", "dependencies": { "@atom/nsfw": "^1.0.18", "@atom/source-map-support": "^0.3.4", "async": "0.2.6", "atom-keymap": "8.2.8", - "atom-select-list": "^0.1.0", + "atom-select-list": "^0.7.0", "atom-ui": "0.4.1", "babel-core": "5.8.38", "cached-run-in-this-context": "0.4.1", @@ -38,7 +38,7 @@ "fs-plus": "^3.0.1", "fstream": "0.1.24", "fuzzaldrin": "^2.1", - "git-utils": "5.1.0", + "git-utils": "5.2.0", "glob": "^7.1.1", "grim": "1.5.0", "jasmine-json": "~0.0", @@ -70,103 +70,104 @@ "service-hub": "^0.7.4", "sinon": "1.17.4", "temp": "^0.8.3", - "text-buffer": "13.9.2", + "text-buffer": "13.10.1", + "tree-sitter": "^0.8.4", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", "yargs": "^3.23.0" }, "packageDependencies": { - "atom-dark-syntax": "0.28.0", - "atom-dark-ui": "0.53.0", + "atom-dark-syntax": "0.29.0", + "atom-dark-ui": "0.53.1", "atom-light-syntax": "0.29.0", - "atom-light-ui": "0.46.0", + "atom-light-ui": "0.46.1", "base16-tomorrow-dark-theme": "1.5.0", "base16-tomorrow-light-theme": "1.5.0", - "one-dark-ui": "1.10.8", - "one-light-ui": "1.10.8", - "one-dark-syntax": "1.8.0", - "one-light-syntax": "1.8.0", - "solarized-dark-syntax": "1.1.2", - "solarized-light-syntax": "1.1.2", + "one-dark-ui": "1.10.10", + "one-light-ui": "1.10.10", + "one-dark-syntax": "1.8.2", + "one-light-syntax": "1.8.2", + "solarized-dark-syntax": "1.1.4", + "solarized-light-syntax": "1.1.4", "about": "1.7.8", - "archive-view": "0.64.1", - "autocomplete-atom-api": "0.10.5", - "autocomplete-css": "0.17.4", - "autocomplete-html": "0.8.3", - "autocomplete-plus": "2.39.0", + "archive-view": "0.64.2", + "autocomplete-atom-api": "0.10.6", + "autocomplete-css": "0.17.5", + "autocomplete-html": "0.8.4", + "autocomplete-plus": "2.40.0", "autocomplete-snippets": "1.11.2", - "autoflow": "0.29.0", + "autoflow": "0.29.3", "autosave": "0.24.6", "background-tips": "0.27.1", - "bookmarks": "0.45.0", - "bracket-matcher": "0.88.0", + "bookmarks": "0.45.1", + "bracket-matcher": "0.88.3", "command-palette": "0.43.0", "dalek": "0.2.1", "deprecation-cop": "0.56.9", "dev-live-reload": "0.48.1", - "encoding-selector": "0.23.7", + "encoding-selector": "0.23.8", "exception-reporting": "0.42.0", "find-and-replace": "0.215.0", - "fuzzy-finder": "1.7.3", - "github": "0.8.3", - "git-diff": "1.3.6", + "fuzzy-finder": "1.7.5", + "github": "0.9.1", + "git-diff": "1.3.7", "go-to-line": "0.32.1", - "grammar-selector": "0.49.8", + "grammar-selector": "0.49.9", "image-view": "0.62.4", "incompatible-packages": "0.27.3", "keybinding-resolver": "0.38.1", - "line-ending-selector": "0.7.4", + "line-ending-selector": "0.7.5", "link": "0.31.4", - "markdown-preview": "0.159.18", + "markdown-preview": "0.159.19", "metrics": "1.2.6", "notifications": "0.70.2", "open-on-github": "1.3.1", "package-generator": "1.3.0", - "settings-view": "0.253.0", - "snippets": "1.1.9", - "spell-check": "0.72.3", + "settings-view": "0.253.2", + "snippets": "1.2.0", + "spell-check": "0.72.5", "status-bar": "1.8.15", - "styleguide": "0.49.9", - "symbols-view": "0.118.1", + "styleguide": "0.49.10", + "symbols-view": "0.118.2", "tabs": "0.109.1", "timecop": "0.36.2", "tree-view": "0.221.3", - "update-package-dependencies": "0.13.0", + "update-package-dependencies": "0.13.1", "welcome": "0.36.6", "whitespace": "0.37.5", "wrap-guide": "0.40.3", - "language-c": "0.58.1", + "language-c": "0.59.0-3", "language-clojure": "0.22.5", "language-coffee-script": "0.49.3", - "language-csharp": "0.14.3", + "language-csharp": "0.14.4", "language-css": "0.42.8", - "language-gfm": "0.90.2", + "language-gfm": "0.90.3", "language-git": "0.19.1", - "language-go": "0.44.3", - "language-html": "0.48.3", + "language-go": "0.45.0-4", + "language-html": "0.48.5", "language-hyperlink": "0.16.3", "language-java": "0.27.6", - "language-javascript": "0.127.7", + "language-javascript": "0.128.0-4", "language-json": "0.19.1", "language-less": "0.34.1", "language-make": "0.22.3", "language-mustache": "0.14.4", "language-objective-c": "0.15.1", "language-perl": "0.38.1", - "language-php": "0.42.2", + "language-php": "0.43.0", "language-property-list": "0.9.1", - "language-python": "0.45.5", + "language-python": "0.46.0-2", "language-ruby": "0.71.4", - "language-ruby-on-rails": "0.25.2", - "language-sass": "0.61.3", - "language-shellscript": "0.25.4", + "language-ruby-on-rails": "0.25.3", + "language-sass": "0.61.4", + "language-shellscript": "0.26.0-3", "language-source": "0.9.0", - "language-sql": "0.25.8", + "language-sql": "0.25.9", "language-text": "0.7.3", "language-todo": "0.29.3", "language-toml": "0.18.1", - "language-typescript": "0.2.3", + "language-typescript": "0.3.0-3", "language-xml": "0.35.2", "language-yaml": "0.31.1" }, diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 333acdc0a..3004fb6e6 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -46,6 +46,7 @@ module.exports = function (packagedAppPath) { relativePath === path.join('..', 'node_modules', 'less', 'index.js') || relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less', 'fs.js') || relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less-node', 'index.js') || + relativePath === path.join('..', 'node_modules', 'lodash.isequal', 'index.js') || relativePath === path.join('..', 'node_modules', 'node-fetch', 'lib', 'fetch-error.js') || relativePath === path.join('..', 'node_modules', 'superstring', 'index.js') || relativePath === path.join('..', 'node_modules', 'oniguruma', 'src', 'oniguruma.js') || @@ -57,7 +58,8 @@ module.exports = function (packagedAppPath) { relativePath === path.join('..', 'node_modules', 'spelling-manager', 'node_modules', 'natural', 'lib', 'natural', 'index.js') || relativePath === path.join('..', 'node_modules', 'tar', 'tar.js') || relativePath === path.join('..', 'node_modules', 'temp', 'lib', 'temp.js') || - relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') + relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') || + relativePath === path.join('..', 'node_modules', 'tree-sitter', 'index.js') ) } }).then((snapshotScript) => { @@ -85,7 +87,7 @@ module.exports = function (packagedAppPath) { console.log(`Generating startup blob at "${generatedStartupBlobPath}"`) childProcess.execFileSync( path.join(CONFIG.repositoryRootPath, 'script', 'node_modules', 'electron-mksnapshot', 'bin', 'mksnapshot'), - [snapshotScriptPath, '--startup_blob', generatedStartupBlobPath] + ['--no-use_ic', snapshotScriptPath, '--startup_blob', generatedStartupBlobPath] ) let startupBlobDestinationPath diff --git a/script/package.json b/script/package.json index 4cf1bfb8c..78025d223 100644 --- a/script/package.json +++ b/script/package.json @@ -8,15 +8,15 @@ "colors": "1.1.2", "csslint": "1.0.2", "donna": "1.0.16", - "electron-chromedriver": "~1.6", + "electron-chromedriver": "~1.7", "electron-link": "0.1.2", - "electron-mksnapshot": "~1.6", + "electron-mksnapshot": "~1.7", "electron-packager": "7.3.0", "electron-winstaller": "2.6.3", "fs-admin": "^0.1.5", "fs-extra": "0.30.0", "glob": "7.0.3", - "joanna": "0.0.9", + "joanna": "0.0.10", "klaw-sync": "^1.1.2", "legal-eagle": "0.14.0", "lodash.template": "4.4.0", diff --git a/spec/atom-environment-spec.js b/spec/atom-environment-spec.js index e3b7b83e7..70ca9c309 100644 --- a/spec/atom-environment-spec.js +++ b/spec/atom-environment-spec.js @@ -592,7 +592,7 @@ describe('AtomEnvironment', () => { const promise = new Promise((r) => { resolve = r }) envLoaded = () => { resolve() - promise + return promise } atomEnvironment = new AtomEnvironment({ applicationDelegate: atom.applicationDelegate, diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index bcf50c268..090bc7a29 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -106,6 +106,15 @@ describe "Config", -> atom.config.set("foo.bar.baz", 1, scopeSelector: ".source.coffee", source: "some-package") expect(atom.config.get("foo.bar.baz", scope: [".source.coffee"])).toBe 100 + describe "when the first component of the scope descriptor matches a legacy scope alias", -> + it "falls back to properties defined for the legacy scope if no value is found for the original scope descriptor", -> + atom.config.addLegacyScopeAlias('javascript', '.source.js') + atom.config.set('foo', 100, scopeSelector: '.source.js') + atom.config.set('foo', 200, scopeSelector: 'javascript for_statement') + + expect(atom.config.get('foo', scope: ['javascript', 'for_statement', 'identifier'])).toBe(200) + expect(atom.config.get('foo', scope: ['javascript', 'function', 'identifier'])).toBe(100) + describe ".getAll(keyPath, {scope, sources, excludeSources})", -> it "reads all of the values for a given key-path", -> expect(atom.config.set("foo", 41)).toBe true @@ -130,6 +139,20 @@ describe "Config", -> {scopeSelector: '*', value: 40} ] + describe "when the first component of the scope descriptor matches a legacy scope alias", -> + it "includes the values defined for the legacy scope", -> + atom.config.addLegacyScopeAlias('javascript', '.source.js') + + expect(atom.config.set('foo', 41)).toBe true + expect(atom.config.set('foo', 42, scopeSelector: 'javascript')).toBe true + expect(atom.config.set('foo', 43, scopeSelector: '.source.js')).toBe true + + expect(atom.config.getAll('foo', scope: ['javascript'])).toEqual([ + {scopeSelector: 'javascript', value: 42}, + {scopeSelector: '.js.source', value: 43}, + {scopeSelector: '*', value: 41} + ]) + describe ".set(keyPath, value, {source, scopeSelector})", -> it "allows a key path's value to be written", -> expect(atom.config.set("foo.bar.baz", 42)).toBe true diff --git a/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js new file mode 100644 index 000000000..028ee5135 --- /dev/null +++ b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js @@ -0,0 +1 @@ +exports.isFakeTreeSitterParser = true diff --git a/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson new file mode 100644 index 000000000..5eb473456 --- /dev/null +++ b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson @@ -0,0 +1,14 @@ +name: 'Some Language' + +id: 'some-language' + +type: 'tree-sitter' + +parser: './fake-parser' + +fileTypes: [ + 'somelang' +] + +scopes: + 'class > identifier': 'entity.name.type.class' diff --git a/spec/grammar-registry-spec.js b/spec/grammar-registry-spec.js index c51ea03b9..e6d815f8d 100644 --- a/spec/grammar-registry-spec.js +++ b/spec/grammar-registry-spec.js @@ -1,10 +1,13 @@ const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const dedent = require('dedent') const path = require('path') const fs = require('fs-plus') const temp = require('temp').track() const TextBuffer = require('text-buffer') const GrammarRegistry = require('../src/grammar-registry') +const TreeSitterGrammar = require('../src/tree-sitter-grammar') +const FirstMate = require('first-mate') describe('GrammarRegistry', () => { let grammarRegistry @@ -13,8 +16,8 @@ describe('GrammarRegistry', () => { grammarRegistry = new GrammarRegistry({config: atom.config}) }) - describe('.assignLanguageMode(buffer, languageName)', () => { - it('assigns to the buffer a language mode with the given language name', async () => { + describe('.assignLanguageMode(buffer, languageId)', () => { + it('assigns to the buffer a language mode with the given language id', async () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) @@ -34,7 +37,7 @@ describe('GrammarRegistry', () => { expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') }) - describe('when no languageName is passed', () => { + describe('when no languageId is passed', () => { it('makes the buffer use the null grammar', () => { grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) @@ -48,6 +51,36 @@ describe('GrammarRegistry', () => { }) }) + describe('.grammarForId(languageId)', () => { + it('converts the language id to a text-mate language id when `core.useTreeSitterParsers` is false', () => { + atom.config.set('core.useTreeSitterParsers', false) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.grammarForId('javascript') + expect(grammar instanceof FirstMate.Grammar).toBe(true) + expect(grammar.scopeName).toBe('source.js') + + grammarRegistry.removeGrammar(grammar) + expect(grammarRegistry.grammarForId('javascript')).toBe(undefined) + }) + + it('converts the language id to a tree-sitter language id when `core.useTreeSitterParsers` is true', () => { + atom.config.set('core.useTreeSitterParsers', true) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.grammarForId('source.js') + expect(grammar instanceof TreeSitterGrammar).toBe(true) + expect(grammar.id).toBe('javascript') + + grammarRegistry.removeGrammar(grammar) + expect(grammarRegistry.grammarForId('source.js') instanceof FirstMate.Grammar).toBe(true) + }) + }) + describe('.autoAssignLanguageMode(buffer)', () => { it('assigns to the buffer a language mode based on the best available grammar', () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) @@ -78,7 +111,9 @@ describe('GrammarRegistry', () => { expect(buffer.getLanguageMode().getLanguageId()).toBe('source.c') }) - it('updates the buffer\'s grammar when a more appropriate grammar is added for its path', async () => { + it('updates the buffer\'s grammar when a more appropriate text-mate grammar is added for its path', async () => { + atom.config.set('core.useTreeSitterParsers', false) + const buffer = new TextBuffer() expect(buffer.getLanguageMode().getLanguageId()).toBe(null) @@ -87,6 +122,25 @@ describe('GrammarRegistry', () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + }) + + it('updates the buffer\'s grammar when a more appropriate tree-sitter grammar is added for its path', async () => { + atom.config.set('core.useTreeSitterParsers', true) + + const buffer = new TextBuffer() + expect(buffer.getLanguageMode().getLanguageId()).toBe(null) + + buffer.setPath('test.js') + grammarRegistry.maintainLanguageMode(buffer) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('javascript') + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('javascript') }) it('can be overridden by calling .assignLanguageMode', () => { @@ -226,6 +280,32 @@ describe('GrammarRegistry', () => { expect(atom.grammars.selectGrammar('/hu.git/config').name).toBe('Null Grammar') }) + describe('when the grammar has a contentRegExp field', () => { + it('favors grammars whose contentRegExp matches a prefix of the file\'s content', () => { + atom.grammars.addGrammar({ + id: 'javascript-1', + fileTypes: ['js'] + }) + atom.grammars.addGrammar({ + id: 'flow-javascript', + contentRegExp: new RegExp('//.*@flow'), + fileTypes: ['js'] + }) + atom.grammars.addGrammar({ + id: 'javascript-2', + fileTypes: ['js'] + }) + + const selectedGrammar = atom.grammars.selectGrammar('test.js', dedent` + // Copyright EvilCorp + // @flow + + module.exports = function () { return 1 + 1 } + `) + expect(selectedGrammar.id).toBe('flow-javascript') + }) + }) + it("uses the filePath's shebang line if the grammar cannot be determined by the extension or basename", async () => { await atom.packages.activatePackage('language-javascript') await atom.packages.activatePackage('language-ruby') @@ -335,14 +415,38 @@ describe('GrammarRegistry', () => { await atom.packages.activatePackage('language-javascript') expect(atom.grammars.selectGrammar('foo.rb', '#!/usr/bin/env node').scopeName).toBe('source.ruby') }) + + describe('tree-sitter vs text-mate', () => { + it('favors a text-mate grammar over a tree-sitter grammar when `core.useTreeSitterParsers` is false', () => { + atom.config.set('core.useTreeSitterParsers', false) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.selectGrammar('test.js') + expect(grammar.scopeName).toBe('source.js') + expect(grammar instanceof FirstMate.Grammar).toBe(true) + }) + + it('favors a tree-sitter grammar over a text-mate grammar when `core.useTreeSitterParsers` is true', () => { + atom.config.set('core.useTreeSitterParsers', true) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.selectGrammar('test.js') + expect(grammar.id).toBe('javascript') + expect(grammar instanceof TreeSitterGrammar).toBe(true) + }) + }) }) describe('.removeGrammar(grammar)', () => { it("removes the grammar, so it won't be returned by selectGrammar", async () => { - await atom.packages.activatePackage('language-javascript') - const grammar = atom.grammars.selectGrammar('foo.js') + await atom.packages.activatePackage('language-css') + const grammar = atom.grammars.selectGrammar('foo.css') atom.grammars.removeGrammar(grammar) - expect(atom.grammars.selectGrammar('foo.js').name).not.toBe(grammar.name) + expect(atom.grammars.selectGrammar('foo.css').name).not.toBe(grammar.name) }) }) diff --git a/spec/history-manager-spec.js b/spec/history-manager-spec.js index 13a3192fb..cc2a20058 100644 --- a/spec/history-manager-spec.js +++ b/spec/history-manager-spec.js @@ -1,19 +1,14 @@ -/** @babel */ +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') -import {it, fit, ffit, fffit, beforeEach, afterEach} from './async-spec-helpers' -import {Emitter, Disposable, CompositeDisposable} from 'event-kit' - -import {HistoryManager, HistoryProject} from '../src/history-manager' -import StateStore from '../src/state-store' +const {HistoryManager, HistoryProject} = require('../src/history-manager') +const StateStore = require('../src/state-store') describe("HistoryManager", () => { let historyManager, commandRegistry, project, stateStore let commandDisposable, projectDisposable beforeEach(async () => { - // Do not clobber recent project history - spyOn(atom.applicationDelegate, 'didChangeHistoryManager') - commandDisposable = jasmine.createSpyObj('Disposable', ['dispose']) commandRegistry = jasmine.createSpyObj('CommandRegistry', ['add']) commandRegistry.add.andReturn(commandDisposable) @@ -185,11 +180,26 @@ describe("HistoryManager", () => { }) }) - describe("saveState" ,() => { + describe("saveState", () => { + let savedHistory + beforeEach(() => { + // historyManager.saveState is spied on globally to prevent specs from + // modifying the shared project history. Since these tests depend on + // saveState, we unspy it but in turn spy on the state store instead + // so that no data is actually stored to it. + jasmine.unspy(historyManager, 'saveState') + + spyOn(historyManager.stateStore, 'save').andCallFake((name, history) => { + savedHistory = history + return Promise.resolve() + }) + }) + it("saves the state", async () => { await historyManager.addProject(["/save/state"]) await historyManager.saveState() const historyManager2 = new HistoryManager({stateStore, project, commands: commandRegistry}) + spyOn(historyManager2.stateStore, 'load').andCallFake(name => Promise.resolve(savedHistory)) await historyManager2.loadState() expect(historyManager2.getProjects()[0].paths).toEqual(['/save/state']) }) diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js index 7c19efb9c..7818314db 100644 --- a/spec/main-process/atom-application.test.js +++ b/spec/main-process/atom-application.test.js @@ -1,14 +1,13 @@ -/** @babel */ - -import season from 'season' -import dedent from 'dedent' -import electron from 'electron' -import fs from 'fs-plus' -import path from 'path' -import sinon from 'sinon' -import AtomApplication from '../../src/main-process/atom-application' -import parseCommandLine from '../../src/main-process/parse-command-line' -import {timeoutPromise, conditionPromise, emitterEventPromise} from '../async-spec-helpers' +const temp = require('temp').track() +const season = require('season') +const dedent = require('dedent') +const electron = require('electron') +const fs = require('fs-plus') +const path = require('path') +const sinon = require('sinon') +const AtomApplication = require('../../src/main-process/atom-application') +const parseCommandLine = require('../../src/main-process/parse-command-line') +const {timeoutPromise, conditionPromise, emitterEventPromise} = require('../async-spec-helpers') const ATOM_RESOURCE_PATH = path.resolve(__dirname, '..', '..') @@ -17,7 +16,7 @@ describe('AtomApplication', function () { let originalAppQuit, originalShowMessageBox, originalAtomHome, atomApplicationsToDestroy - beforeEach(function () { + beforeEach(() => { originalAppQuit = electron.app.quit originalShowMessageBox = electron.dialog.showMessageBox mockElectronAppQuit() @@ -34,7 +33,7 @@ describe('AtomApplication', function () { atomApplicationsToDestroy = [] }) - afterEach(async function () { + afterEach(async () => { process.env.ATOM_HOME = originalAtomHome for (let atomApplication of atomApplicationsToDestroy) { await atomApplication.destroy() @@ -44,8 +43,8 @@ describe('AtomApplication', function () { electron.dialog.showMessageBox = originalShowMessageBox }) - describe('launch', function () { - it('can open to a specific line number of a file', async function () { + describe('launch', () => { + it('can open to a specific line number of a file', async () => { const filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -53,8 +52,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':3'])) await focusWindow(window) - const cursorRow = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const cursorRow = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getCursorBufferPosition().row) }) }) @@ -62,7 +61,7 @@ describe('AtomApplication', function () { assert.equal(cursorRow, 2) }) - it('can open to a specific line and column of a file', async function () { + it('can open to a specific line and column of a file', async () => { const filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -70,8 +69,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':2:2'])) await focusWindow(window) - const cursorPosition = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const cursorPosition = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getCursorBufferPosition()) }) }) @@ -79,7 +78,7 @@ describe('AtomApplication', function () { assert.deepEqual(cursorPosition, {row: 1, column: 1}) }) - it('removes all trailing whitespace and colons from the specified path', async function () { + it('removes all trailing whitespace and colons from the specified path', async () => { let filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -87,8 +86,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':: '])) await focusWindow(window) - const openedPath = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const openedPath = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -97,7 +96,7 @@ describe('AtomApplication', function () { }) if (process.platform === 'darwin' || process.platform === 'win32') { - it('positions new windows at an offset distance from the previous window', async function () { + it('positions new windows at an offset distance from the previous window', async () => { const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([makeTempDir()])) @@ -115,7 +114,7 @@ describe('AtomApplication', function () { }) } - it('reuses existing windows when opening paths, but not directories', async function () { + it('reuses existing windows when opening paths, but not directories', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirCPath = makeTempDir("c") @@ -127,8 +126,8 @@ describe('AtomApplication', function () { await emitterEventPromise(window1, 'window:locations-opened') await focusWindow(window1) - let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -139,8 +138,8 @@ describe('AtomApplication', function () { const reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath])) assert.equal(reusedWindow, window1) assert.deepEqual(atomApplication.getAllWindows(), [window1]) - activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { + activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + const subscription = atom.workspace.onDidChangeActivePaneItem(textEditor => { sendBackToMainProcess(textEditor.getPath()) subscription.dispose() }) @@ -156,7 +155,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window2), [dirCPath]) }) - it('adds folders to existing windows when the --add option is used', async function () { + it('adds folders to existing windows when the --add option is used', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirCPath = makeTempDir("c") @@ -167,8 +166,8 @@ describe('AtomApplication', function () { const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')])) await focusWindow(window1) - let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -179,8 +178,8 @@ describe('AtomApplication', function () { let reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add'])) assert.equal(reusedWindow, window1) assert.deepEqual(atomApplication.getAllWindows(), [window1]) - activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { + activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + const subscription = atom.workspace.onDidChangeActivePaneItem(textEditor => { sendBackToMainProcess(textEditor.getPath()) subscription.dispose() }) @@ -198,14 +197,14 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirCPath, dirBPath]) }) - it('persists window state based on the project directories', async function () { + it('persists window state based on the project directories', async () => { const tempDirPath = makeTempDir() const atomApplication = buildAtomApplication() const nonExistentFilePath = path.join(tempDirPath, 'new-file') const window1 = atomApplication.launch(parseCommandLine([nonExistentFilePath])) - await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { textEditor.insertText('Hello World!') sendBackToMainProcess(null) }) @@ -217,7 +216,7 @@ describe('AtomApplication', function () { // Restore unsaved state when opening the directory itself const window2 = atomApplication.launch(parseCommandLine([tempDirPath])) await window2.loadedPromise - const window2Text = await evalInWebContents(window2.browserWindow.webContents, function (sendBackToMainProcess) { + const window2Text = await evalInWebContents(window2.browserWindow.webContents, sendBackToMainProcess => { const textEditor = atom.workspace.getActiveTextEditor() textEditor.moveToBottom() textEditor.insertText(' How are you?') @@ -231,13 +230,13 @@ describe('AtomApplication', function () { // Restore unsaved state when opening a path to a non-existent file in the directory const window3 = atomApplication.launch(parseCommandLine([path.join(tempDirPath, 'another-non-existent-file')])) await window3.loadedPromise - const window3Texts = await evalInWebContents(window3.browserWindow.webContents, function (sendBackToMainProcess, nonExistentFilePath) { + const window3Texts = await evalInWebContents(window3.browserWindow.webContents, (sendBackToMainProcess, nonExistentFilePath) => { sendBackToMainProcess(atom.workspace.getTextEditors().map(editor => editor.getText())) }) assert.include(window3Texts, 'Hello World! How are you?') }) - it('shows all directories in the tree view when multiple directory paths are passed to Atom', async function () { + it('shows all directories in the tree view when multiple directory paths are passed to Atom', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirBSubdirPath = path.join(dirBPath, 'c') @@ -250,7 +249,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirBPath]) }) - it('reuses windows with no project paths to open directories', async function () { + it('reuses windows with no project paths to open directories', async () => { const tempDirPath = makeTempDir() const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([])) @@ -261,18 +260,18 @@ describe('AtomApplication', function () { await conditionPromise(async () => (await getTreeViewRootDirectories(reusedWindow)).length > 0) }) - it('opens a new window with a single untitled buffer when launched with no path, even if windows already exist', async function () { + it('opens a new window with a single untitled buffer when launched with no path, even if windows already exist', async () => { const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([])) await focusWindow(window1) - const window1EditorTitle = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const window1EditorTitle = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActiveTextEditor().getTitle()) }) assert.equal(window1EditorTitle, 'untitled') const window2 = atomApplication.openWithOptions(parseCommandLine([])) await focusWindow(window2) - const window2EditorTitle = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const window2EditorTitle = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActiveTextEditor().getTitle()) }) assert.equal(window2EditorTitle, 'untitled') @@ -280,7 +279,7 @@ describe('AtomApplication', function () { assert.deepEqual(atomApplication.getAllWindows(), [window2, window1]) }) - it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async function () { + it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async () => { const configPath = path.join(process.env.ATOM_HOME, 'config.cson') const config = season.readFileSync(configPath) if (!config['*'].core) config['*'].core = {} @@ -294,19 +293,19 @@ describe('AtomApplication', function () { // wait a bit just to make sure we don't pass due to querying the render process before it loads await timeoutPromise(1000) - const itemCount = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const itemCount = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActivePane().getItems().length) }) assert.equal(itemCount, 0) }) - it('opens an empty text editor and loads its parent directory in the tree-view when launched with a new file path', async function () { + it('opens an empty text editor and loads its parent directory in the tree-view when launched with a new file path', async () => { const atomApplication = buildAtomApplication() const newFilePath = path.join(makeTempDir(), 'new-file') const window = atomApplication.launch(parseCommandLine([newFilePath])) await focusWindow(window) - const {editorTitle, editorText} = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (editor) { + const {editorTitle, editorText} = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(editor => { sendBackToMainProcess({editorTitle: editor.getTitle(), editorText: editor.getText()}) }) }) @@ -315,7 +314,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window), [path.dirname(newFilePath)]) }) - it('adds a remote directory to the project when launched with a remote directory', async function () { + it('adds a remote directory to the project when launched with a remote directory', async () => { const packagePath = path.join(__dirname, '..', 'fixtures', 'packages', 'package-with-directory-provider') const packagesDirPath = path.join(process.env.ATOM_HOME, 'packages') fs.mkdirSync(packagesDirPath) @@ -338,13 +337,13 @@ describe('AtomApplication', function () { assert.deepEqual(directories, [{type: 'FakeRemoteDirectory', path: remotePath}]) function getProjectDirectories () { - return evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { + return evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.project.getDirectories().map(d => ({ type: d.constructor.name, path: d.getPath() }))) }) } }) - it('reopens any previously opened windows when launched with no path', async function () { + it('reopens any previously opened windows when launched with no path', async () => { if (process.platform === 'win32') return; // Test is too flakey on Windows const tempDirPath1 = makeTempDir() @@ -372,7 +371,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(app2Window2), [tempDirPath2]) }) - it('does not reopen any previously opened windows when launched with no path and `core.restorePreviousWindowsOnStart` is no', async function () { + it('does not reopen any previously opened windows when launched with no path and `core.restorePreviousWindowsOnStart` is no', async () => { const atomApplication1 = buildAtomApplication() const app1Window1 = atomApplication1.launch(parseCommandLine([makeTempDir()])) await focusWindow(app1Window1) @@ -391,30 +390,136 @@ describe('AtomApplication', function () { assert.deepEqual(app2Window.representedDirectoryPaths, []) }) - describe('when closing the last window', function () { + describe('when the `--wait` flag is passed', () => { + let killedPids, atomApplication, onDidKillProcess + + beforeEach(() => { + killedPids = [] + onDidKillProcess = null + atomApplication = buildAtomApplication({ + killProcess (pid) { + killedPids.push(pid) + if (onDidKillProcess) onDidKillProcess() + } + }) + }) + + it('kills the specified pid after a newly-opened window is closed', async () => { + const window1 = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101'])) + await focusWindow(window1) + + const [window2] = atomApplication.launch(parseCommandLine(['--new-window', '--wait', '--pid', '102'])) + await focusWindow(window2) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window1.close() + await processKillPromise + assert.deepEqual(killedPids, [101]) + + processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window2.close() + await processKillPromise + assert.deepEqual(killedPids, [101, 102]) + }) + + it('kills the specified pid after a newly-opened file in an existing window is closed', async () => { + const window = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101'])) + await focusWindow(window) + + const filePath1 = temp.openSync('test').path + const filePath2 = temp.openSync('test').path + fs.writeFileSync(filePath1, 'File 1') + fs.writeFileSync(filePath2, 'File 2') + + const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '102', filePath1, filePath2])) + assert.equal(reusedWindow, window) + + const activeEditorPath = await evalInWebContents(window.browserWindow.webContents, send => { + const subscription = atom.workspace.onDidChangeActivePaneItem(editor => { + send(editor.getPath()) + subscription.dispose() + }) + }) + + assert([filePath1, filePath2].includes(activeEditorPath)) + assert.deepEqual(killedPids, []) + + await evalInWebContents(window.browserWindow.webContents, send => { + atom.workspace.getActivePaneItem().destroy() + send() + }) + await timeoutPromise(100) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + await evalInWebContents(window.browserWindow.webContents, send => { + atom.workspace.getActivePaneItem().destroy() + send() + }) + await processKillPromise + assert.deepEqual(killedPids, [102]) + + processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window.close() + await processKillPromise + assert.deepEqual(killedPids, [102, 101]) + }) + + it('kills the specified pid after a newly-opened directory in an existing window is closed', async () => { + const window = atomApplication.launch(parseCommandLine([])) + await focusWindow(window) + + const dirPath1 = makeTempDir() + const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101', dirPath1])) + assert.equal(reusedWindow, window) + assert.deepEqual(await getTreeViewRootDirectories(window), [dirPath1]) + assert.deepEqual(killedPids, []) + + const dirPath2 = makeTempDir() + await evalInWebContents(window.browserWindow.webContents, (send, dirPath1, dirPath2) => { + atom.project.setPaths([dirPath1, dirPath2]) + send() + }, dirPath1, dirPath2) + await timeoutPromise(100) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + await evalInWebContents(window.browserWindow.webContents, (send, dirPath2) => { + atom.project.setPaths([dirPath2]) + send() + }, dirPath2) + await processKillPromise + assert.deepEqual(killedPids, [101]) + }) + }) + + describe('when closing the last window', () => { if (process.platform === 'linux' || process.platform === 'win32') { - it('quits the application', async function () { + it('quits the application', async () => { const atomApplication = buildAtomApplication() const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')])) await focusWindow(window) window.close() await window.closedPromise - assert(electron.app.hasQuitted()) + await atomApplication.lastBeforeQuitPromise + assert(electron.app.didQuit()) }) } else if (process.platform === 'darwin') { - it('leaves the application open', async function () { + it('leaves the application open', async () => { const atomApplication = buildAtomApplication() const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')])) await focusWindow(window) window.close() await window.closedPromise - assert(!electron.app.hasQuitted()) + await timeoutPromise(1000) + assert(!electron.app.didQuit()) }) } }) - describe('when adding or removing project folders', function () { - it('stores the window state immediately', async function () { + describe('when adding or removing project folders', () => { + it('stores the window state immediately', async () => { const dirA = makeTempDir() const dirB = makeTempDir() @@ -441,8 +546,8 @@ describe('AtomApplication', function () { }) }) - describe('when opening atom:// URLs', function () { - it('loads the urlMain file in a new window', async function () { + describe('when opening atom:// URLs', () => { + it('loads the urlMain file in a new window', async () => { const packagePath = path.join(__dirname, '..', 'fixtures', 'packages', 'package-with-url-main') const packagesDirPath = path.join(process.env.ATOM_HOME, 'packages') fs.mkdirSync(packagesDirPath) @@ -454,7 +559,7 @@ describe('AtomApplication', function () { let windows = atomApplication.launch(launchOptions) await windows[0].loadedPromise - let reached = await evalInWebContents(windows[0].browserWindow.webContents, function (sendBackToMainProcess) { + let reached = await evalInWebContents(windows[0].browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(global.reachedUrlMain) }) assert.equal(reached, true); @@ -488,7 +593,7 @@ describe('AtomApplication', function () { }) }) - it('waits until all the windows have saved their state before quitting', async function () { + it('waits until all the windows have saved their state before quitting', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const atomApplication = buildAtomApplication() @@ -497,9 +602,12 @@ describe('AtomApplication', function () { const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')])) await focusWindow(window2) electron.app.quit() - assert(!electron.app.hasQuitted()) + await new Promise(process.nextTick) + assert(!electron.app.didQuit()) + await Promise.all([window1.lastPrepareToUnloadPromise, window2.lastPrepareToUnloadPromise]) - assert(electron.app.hasQuitted()) + await new Promise(process.nextTick) + assert(electron.app.didQuit()) }) it('prevents quitting if user cancels when prompted to save an item', async () => { @@ -507,7 +615,7 @@ describe('AtomApplication', function () { const window1 = atomApplication.launch(parseCommandLine([])) const window2 = atomApplication.launch(parseCommandLine([])) await Promise.all([window1.loadedPromise, window2.loadedPromise]) - await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { atom.workspace.getActiveTextEditor().insertText('unsaved text') sendBackToMainProcess() }) @@ -516,21 +624,21 @@ describe('AtomApplication', function () { mockElectronShowMessageBox({choice: 1}) electron.app.quit() await atomApplication.lastBeforeQuitPromise - assert(!electron.app.hasQuitted()) + assert(!electron.app.didQuit()) assert.equal(electron.app.quit.callCount, 1) // Ensure choosing "Cancel" doesn't try to quit the electron app more than once (regression) // Choosing "Don't save" mockElectronShowMessageBox({choice: 2}) electron.app.quit() await atomApplication.lastBeforeQuitPromise - assert(electron.app.hasQuitted()) + assert(electron.app.didQuit()) }) - function buildAtomApplication () { - const atomApplication = new AtomApplication({ + function buildAtomApplication (params = {}) { + const atomApplication = new AtomApplication(Object.assign({ resourcePath: ATOM_RESOURCE_PATH, - atomHomeDirPath: process.env.ATOM_HOME - }) + atomHomeDirPath: process.env.ATOM_HOME, + }, params)) atomApplicationsToDestroy.push(atomApplication) return atomApplication } @@ -542,40 +650,34 @@ describe('AtomApplication', function () { } function mockElectronAppQuit () { - let quitted = false - electron.app.quit = function () { - if (electron.app.quit.callCount) { - electron.app.quit.callCount++ - } else { - electron.app.quit.callCount = 1 - } + let didQuit = false - let shouldQuit = true - electron.app.emit('before-quit', {preventDefault: () => { shouldQuit = false }}) - if (shouldQuit) { - quitted = true - } - } - electron.app.hasQuitted = function () { - return quitted + electron.app.quit = function () { + this.quit.callCount++ + let defaultPrevented = false + this.emit('before-quit', {preventDefault() { defaultPrevented = true }}) + if (!defaultPrevented) didQuit = true } + + electron.app.quit.callCount = 0 + + electron.app.didQuit = () => didQuit } function mockElectronShowMessageBox ({choice}) { - electron.dialog.showMessageBox = function () { + electron.dialog.showMessageBox = () => { return choice } } function makeTempDir (name) { - const temp = require('temp').track() return fs.realpathSync(temp.mkdirSync(name)) } let channelIdCounter = 0 function evalInWebContents (webContents, source, ...args) { const channelId = 'eval-result-' + channelIdCounter++ - return new Promise(function (resolve) { + return new Promise(resolve => { electron.ipcMain.on(channelId, receiveResult) function receiveResult (event, result) { @@ -587,13 +689,13 @@ describe('AtomApplication', function () { function sendBackToMainProcess (result) { require('electron').ipcRenderer.send('${channelId}', result) } - (${source})(sendBackToMainProcess) + (${source})(sendBackToMainProcess, ${args.map(JSON.stringify).join(', ')}) `) }) } function getTreeViewRootDirectories (atomWindow) { - return evalInWebContents(atomWindow.browserWindow.webContents, function (sendBackToMainProcess) { + return evalInWebContents(atomWindow.browserWindow.webContents, sendBackToMainProcess => { atom.workspace.getLeftDock().observeActivePaneItem((treeView) => { if (treeView) { sendBackToMainProcess( @@ -607,8 +709,8 @@ describe('AtomApplication', function () { } function clearElectronSession () { - return new Promise(function (resolve) { - electron.session.defaultSession.clearStorageData(function () { + return new Promise(resolve => { + electron.session.defaultSession.clearStorageData(() => { // Resolve promise on next tick, otherwise the process stalls. This // might be a bug in Electron, but it's probably fixed on the newer // versions. diff --git a/spec/notification-manager-spec.js b/spec/notification-manager-spec.js index 3f6a20b67..3a8544d4e 100644 --- a/spec/notification-manager-spec.js +++ b/spec/notification-manager-spec.js @@ -66,4 +66,27 @@ describe('NotificationManager', () => { expect(notification.getType()).toBe('success') }) }) + + describe('clearing notifications', () => { + it('clears the notifications when ::clear has been called', () => { + manager.addSuccess('success') + expect(manager.getNotifications().length).toBe(1) + manager.clear() + expect(manager.getNotifications().length).toBe(0) + }) + + describe('adding events', () => { + let clearSpy + + beforeEach(() => { + clearSpy = jasmine.createSpy() + manager.onDidClearNotifications(clearSpy) + }) + + it('emits an event when the notifications have been cleared', () => { + manager.clear() + expect(clearSpy).toHaveBeenCalled() + }) + }) + }) }) diff --git a/spec/package-manager-spec.js b/spec/package-manager-spec.js index 0b26bf839..b1ecf834d 100644 --- a/spec/package-manager-spec.js +++ b/spec/package-manager-spec.js @@ -1030,6 +1030,13 @@ describe('PackageManager', () => { expect(atom.grammars.selectGrammar('a.alot').name).toBe('Alot') expect(atom.grammars.selectGrammar('a.alittle').name).toBe('Alittle') }) + + it('loads any tree-sitter grammars defined in the package', async () => { + await atom.packages.activatePackage('package-with-tree-sitter-grammar') + const grammar = atom.grammars.selectGrammar('test.somelang') + expect(grammar.name).toBe('Some Language') + expect(grammar.languageModule.isFakeTreeSitterParser).toBe(true) + }) }) describe('scoped-property loading', () => { diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 44319ba52..dcc3c6641 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -63,7 +63,7 @@ else beforeEach -> # Do not clobber recent project history - spyOn(atom.history, 'saveState').andReturn(Promise.resolve()) + spyOn(Object.getPrototypeOf(atom.history), 'saveState').andReturn(Promise.resolve()) atom.project.setPaths([specProjectPath]) @@ -111,7 +111,8 @@ beforeEach -> new CompositeDisposable( @emitter.on("did-tokenize", callback), @onDidChangeGrammar => - if @buffer.getLanguageMode().tokenizeInBackground.originalValue + languageMode = @buffer.getLanguageMode() + if languageMode.tokenizeInBackground?.originalValue callback() ) diff --git a/spec/syntax-scope-map-spec.js b/spec/syntax-scope-map-spec.js new file mode 100644 index 000000000..61b1bdc7d --- /dev/null +++ b/spec/syntax-scope-map-spec.js @@ -0,0 +1,77 @@ +const SyntaxScopeMap = require('../src/syntax-scope-map') + +describe('SyntaxScopeMap', () => { + it('can match immediate child selectors', () => { + const map = new SyntaxScopeMap({ + 'a > b > c': 'x', + 'b > c': 'y', + 'c': 'z' + }) + + expect(map.get(['a', 'b', 'c'], [0, 0, 0])).toBe('x') + expect(map.get(['d', 'b', 'c'], [0, 0, 0])).toBe('y') + expect(map.get(['d', 'e', 'c'], [0, 0, 0])).toBe('z') + expect(map.get(['e', 'c'], [0, 0, 0])).toBe('z') + expect(map.get(['c'], [0, 0, 0])).toBe('z') + expect(map.get(['d'], [0, 0, 0])).toBe(undefined) + }) + + it('can match :nth-child pseudo-selectors on leaves', () => { + const map = new SyntaxScopeMap({ + 'a > b': 'w', + 'a > b:nth-child(1)': 'x', + 'b': 'y', + 'b:nth-child(2)': 'z' + }) + + expect(map.get(['a', 'b'], [0, 0])).toBe('w') + expect(map.get(['a', 'b'], [0, 1])).toBe('x') + expect(map.get(['a', 'b'], [0, 2])).toBe('w') + expect(map.get(['b'], [0])).toBe('y') + expect(map.get(['b'], [1])).toBe('y') + expect(map.get(['b'], [2])).toBe('z') + }) + + it('can match :nth-child pseudo-selectors on interior nodes', () => { + const map = new SyntaxScopeMap({ + 'b:nth-child(1) > c': 'w', + 'a > b > c': 'x', + 'a > b:nth-child(2) > c': 'y' + }) + + expect(map.get(['b', 'c'], [0, 0])).toBe(undefined) + expect(map.get(['b', 'c'], [1, 0])).toBe('w') + expect(map.get(['a', 'b', 'c'], [1, 0, 0])).toBe('x') + expect(map.get(['a', 'b', 'c'], [1, 2, 0])).toBe('y') + }) + + it('allows anonymous tokens to be referred to by their string value', () => { + const map = new SyntaxScopeMap({ + '"b"': 'w', + 'a > "b"': 'x', + 'a > "b":nth-child(1)': 'y' + }) + + expect(map.get(['b'], [0], true)).toBe(undefined) + expect(map.get(['b'], [0], false)).toBe('w') + expect(map.get(['a', 'b'], [0, 0], false)).toBe('x') + expect(map.get(['a', 'b'], [0, 1], false)).toBe('y') + }) + + it('supports the wildcard selector', () => { + const map = new SyntaxScopeMap({ + '*': 'w', + 'a > *': 'x', + 'a > *:nth-child(1)': 'y', + 'a > *:nth-child(1) > b': 'z' + }) + + expect(map.get(['b'], [0])).toBe('w') + expect(map.get(['c'], [0])).toBe('w') + expect(map.get(['a', 'b'], [0, 0])).toBe('x') + expect(map.get(['a', 'b'], [0, 1])).toBe('y') + expect(map.get(['a', 'c'], [0, 1])).toBe('y') + expect(map.get(['a', 'c', 'b'], [0, 1, 1])).toBe('z') + expect(map.get(['a', 'c', 'b'], [0, 2, 1])).toBe('w') + }) +}) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 47ba4ca63..deca42eea 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -2840,83 +2840,149 @@ describe('TextEditorComponent', () => { describe('mouse input', () => { describe('on the lines', () => { - it('positions the cursor on single-click or when middle/right-clicking', async () => { - for (const button of [0, 1, 2]) { + describe('when there is only one cursor and no selection', () => { + it('positions the cursor on single-click or when middle/right-clicking', async () => { + for (const button of [0, 1, 2]) { + const {component, element, editor} = buildComponent() + const {lineHeight} = component.measurements + + editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + const maxRow = editor.getLastScreenRow() + editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: clientLeftForCharacter(component, maxRow, editor.lineLengthForScreenRow(maxRow)) + 1, + clientY: clientTopForLine(component, maxRow) + 1 + }) + expect(editor.getCursorScreenPosition()).toEqual([maxRow, editor.lineLengthForScreenRow(maxRow)]) + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, + clientY: clientTopForLine(component, 0) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, + clientY: clientTopForLine(component, 1) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([1, 0]) + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 15]) + + editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') + await component.getNextUpdatePromise() + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 14]) + + component.didMouseDownOnContent({ + detail: 1, + button, + clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, + clientY: clientTopForLine(component, 3) + lineHeight / 2 + }) + expect(editor.getCursorScreenPosition()).toEqual([3, 16]) + + expect(editor.testAutoscrollRequests).toEqual([]) + } + }) + }) + + describe('when there is more than one cursor', () => { + it('does not move the cursor when right-clicking', async () => { const {component, element, editor} = buildComponent() const {lineHeight} = component.measurements - editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + editor.setCursorScreenPosition([5, 17], {autoscroll: false}) + editor.addCursorAtScreenPosition([2, 4]) component.didMouseDownOnContent({ detail: 1, - button, + button: 2, clientX: clientLeftForCharacter(component, 0, 0) - 1, clientY: clientTopForLine(component, 0) - 1 }) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([5, 17]), Point.fromObject([2, 4])]) + }) - const maxRow = editor.getLastScreenRow() - editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) + it('does move the cursor when middle-clicking', async () => { + const {component, element, editor} = buildComponent() + const {lineHeight} = component.measurements + + editor.setCursorScreenPosition([5, 17], {autoscroll: false}) + editor.addCursorAtScreenPosition([2, 4]) component.didMouseDownOnContent({ detail: 1, - button, - clientX: clientLeftForCharacter(component, maxRow, editor.lineLengthForScreenRow(maxRow)) + 1, - clientY: clientTopForLine(component, maxRow) + 1 + button: 1, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 }) - expect(editor.getCursorScreenPosition()).toEqual([maxRow, editor.lineLengthForScreenRow(maxRow)]) + expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([0, 0])]) + }) + }) + describe('when there are non-empty selections', () => { + it('does not move the cursor when right-clicking', async () => { + const {component, element, editor} = buildComponent() + const {lineHeight} = component.measurements + + editor.setCursorScreenPosition([5, 17], {autoscroll: false}) + editor.selectRight(3) component.didMouseDownOnContent({ detail: 1, - button, - clientX: clientLeftForCharacter(component, 0, editor.lineLengthForScreenRow(0)) + 1, - clientY: clientTopForLine(component, 0) + lineHeight / 2 + button: 2, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 }) - expect(editor.getCursorScreenPosition()).toEqual([0, editor.lineLengthForScreenRow(0)]) + expect(editor.getSelectedScreenRange()).toEqual([[5, 17], [5, 20]]) + }) + it('does move the cursor when middle-clicking', async () => { + const {component, element, editor} = buildComponent() + const {lineHeight} = component.measurements + + editor.setCursorScreenPosition([5, 17], {autoscroll: false}) + editor.selectRight(3) component.didMouseDownOnContent({ detail: 1, - button, - clientX: (clientLeftForCharacter(component, 3, 0) + clientLeftForCharacter(component, 3, 1)) / 2, - clientY: clientTopForLine(component, 1) + lineHeight / 2 + button: 1, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 }) - expect(editor.getCursorScreenPosition()).toEqual([1, 0]) - - component.didMouseDownOnContent({ - detail: 1, - button, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2, - clientY: clientTopForLine(component, 3) + lineHeight / 2 - }) - expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - - component.didMouseDownOnContent({ - detail: 1, - button, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 15)) / 2 + 1, - clientY: clientTopForLine(component, 3) + lineHeight / 2 - }) - expect(editor.getCursorScreenPosition()).toEqual([3, 15]) - - editor.getBuffer().setTextInRange([[3, 14], [3, 15]], '🐣') - await component.getNextUpdatePromise() - - component.didMouseDownOnContent({ - detail: 1, - button, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2, - clientY: clientTopForLine(component, 3) + lineHeight / 2 - }) - expect(editor.getCursorScreenPosition()).toEqual([3, 14]) - - component.didMouseDownOnContent({ - detail: 1, - button, - clientX: (clientLeftForCharacter(component, 3, 14) + clientLeftForCharacter(component, 3, 16)) / 2 + 1, - clientY: clientTopForLine(component, 3) + lineHeight / 2 - }) - expect(editor.getCursorScreenPosition()).toEqual([3, 16]) - - expect(editor.testAutoscrollRequests).toEqual([]) - } + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]]) + }) }) describe('when the input is for the primary mouse button', () => { @@ -3612,421 +3678,198 @@ describe('TextEditorComponent', () => { }) describe('keyboard input', () => { - describe('on Chrome 56', () => { - it('handles inserted accented characters via the press-and-hold menu on macOS correctly', async () => { - const {editor, component, element} = buildComponent({text: '', chromeVersion: 56}) - editor.insertText('x') - editor.setCursorBufferPosition([0, 1]) + it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => { + const {editor, component, element} = buildComponent({text: '', chromeVersion: 57}) + editor.insertText('x') + editor.setCursorBufferPosition([0, 1]) - // Simulate holding the A key to open the press-and-hold menu, - // then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Escape'}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Escape'}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by typing a number. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Digit2'}) - component.didKeyup({code: 'Digit2'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by typing a number. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Digit2'}) + component.didKeyup({code: 'Digit2'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by clicking on it. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by clicking on it. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then selecting one of them with Enter. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.getHiddenInput().value = 'à' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Enter'}) - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.getHiddenInput()}) - component.didKeyup({code: 'Enter'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xá') + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then selecting one of them with Enter. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Enter'}) + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Enter'}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.getHiddenInput().value = 'à' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.getHiddenInput().value = 'a' - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyO'}) + component.didKeypress({code: 'KeyO'}) + component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyO'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xoa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyO'}) - component.didKeypress({code: 'KeyO'}) - component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyO'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.getHiddenInput().value = 'à' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xoà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xoá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.getHiddenInput().value = 'a' - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xoa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it by changing focus. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.getHiddenInput().value = 'à' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didKeyup({code: 'ArrowRight'}) - await getNextTickPromise() - expect(editor.getText()).toBe('xá') - component.didCompositionUpdate({data: 'á'}) - component.getHiddenInput().value = 'á' - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) - await getNextTickPromise() - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') - }) - }) - - describe('on other versions of Chrome', () => { - it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => { - const {editor, component, element} = buildComponent({text: '', chromeVersion: 57}) - editor.insertText('x') - editor.setCursorBufferPosition([0, 1]) - - // Simulate holding the A key to open the press-and-hold menu, - // then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Escape'}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by typing a number. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Digit2'}) - component.didKeyup({code: 'Digit2'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by clicking on it. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then selecting one of them with Enter. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Enter'}) - component.didCompositionUpdate({data: 'á'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Enter'}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyO'}) - component.didKeypress({code: 'KeyO'}) - component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyO'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xoà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xoá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xoa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - editor.undo() - expect(editor.getText()).toBe('x') - - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it by changing focus. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didCompositionUpdate({data: 'á'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') - }) + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it by changing focus. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') }) }) diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 7c05aced4..7ffdf374d 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -70,12 +70,41 @@ describe('TextEditorElement', () => { expect(element.getModel().isLineNumberGutterVisible()).toBe(false) }) + it("honors the 'readonly' attribute", async function() { + jasmineContent.innerHTML = "" + const element = jasmineContent.firstChild + + expect(element.getComponent().isInputEnabled()).toBe(false) + + element.removeAttribute('readonly') + expect(element.getComponent().isInputEnabled()).toBe(true) + + element.setAttribute('readonly', true) + expect(element.getComponent().isInputEnabled()).toBe(false) + }) + it('honors the text content', () => { jasmineContent.innerHTML = 'testing' const element = jasmineContent.firstChild expect(element.getModel().getText()).toBe('testing') }) + describe('tabIndex', () => { + it('uses a default value of -1', () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.tabIndex).toBe(-1) + expect(element.querySelector('input').tabIndex).toBe(-1) + }) + + it('uses the custom value when given', () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.tabIndex).toBe(-1) + expect(element.querySelector('input').tabIndex).toBe(42) + }) + }) + describe('when the model is assigned', () => it("adds the 'mini' attribute if .isMini() returns true on the model", async () => { const element = buildTextEditorElement() diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index 89af72137..ef2ced5e6 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -20,6 +20,17 @@ describe('TextEditor', () => { await atom.packages.activatePackage('language-javascript') }) + it('generates unique ids for each editor', async () => { + // Deserialized editors are initialized with the serialized id. We can + // initialize an editor with what we expect to be the next id: + const deserialized = new TextEditor({id: editor.id+1}) + expect(deserialized.id).toEqual(editor.id+1) + + // The id generator should skip the id used up by the deserialized one: + const fresh = new TextEditor() + expect(fresh.id).toNotEqual(deserialized.id) + }) + describe('when the editor is deserialized', () => { it('restores selections and folds based on markers in the buffer', async () => { editor.setSelectedBufferRange([[1, 2], [3, 4]]) @@ -3496,13 +3507,16 @@ describe('TextEditor', () => { }) describe("when the undo option is set to 'skip'", () => { - beforeEach(() => editor.setSelectedBufferRange([[1, 2], [1, 2]])) - - it('does not undo the skipped operation', () => { - let range = editor.insertText('x') - range = editor.insertText('y', {undo: 'skip'}) + it('groups the change with the previous change for purposes of undo and redo', () => { + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[1, 0], [1, 0]] + ]) + editor.insertText('x') + editor.insertText('y', {undo: 'skip'}) editor.undo() - expect(buffer.lineForRow(1)).toBe(' yvar sort = function(items) {') + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') }) }) }) diff --git a/spec/theme-manager-spec.js b/spec/theme-manager-spec.js index f4ed3b9f5..9d1d3a3cc 100644 --- a/spec/theme-manager-spec.js +++ b/spec/theme-manager-spec.js @@ -424,12 +424,12 @@ h2 { waitsForPromise(() => atom.themes.activateThemes()) }) - it('uses the default dark UI and syntax themes and logs a warning', function () { + it('uses the default one-dark UI and syntax themes and logs a warning', function () { const activeThemeNames = atom.themes.getActiveThemeNames() expect(console.warn.callCount).toBe(2) expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') + expect(activeThemeNames).toContain('one-dark-ui') + expect(activeThemeNames).toContain('one-dark-syntax') }) }) @@ -459,8 +459,8 @@ h2 { it('uses the default dark UI and syntax themes', function () { const activeThemeNames = atom.themes.getActiveThemeNames() expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') + expect(activeThemeNames).toContain('one-dark-ui') + expect(activeThemeNames).toContain('one-dark-syntax') }) }) @@ -471,10 +471,10 @@ h2 { waitsForPromise(() => atom.themes.activateThemes()) }) - it('uses the default dark UI theme', function () { + it('uses the default one-dark UI theme', function () { const activeThemeNames = atom.themes.getActiveThemeNames() expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') + expect(activeThemeNames).toContain('one-dark-ui') expect(activeThemeNames).toContain('atom-light-syntax') }) }) @@ -486,11 +486,11 @@ h2 { waitsForPromise(() => atom.themes.activateThemes()) }) - it('uses the default dark syntax theme', function () { + it('uses the default one-dark syntax theme', function () { const activeThemeNames = atom.themes.getActiveThemeNames() expect(activeThemeNames.length).toBe(2) expect(activeThemeNames).toContain('atom-light-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') + expect(activeThemeNames).toContain('one-dark-syntax') }) }) }) diff --git a/spec/tree-sitter-language-mode-spec.js b/spec/tree-sitter-language-mode-spec.js new file mode 100644 index 000000000..ec38c1a06 --- /dev/null +++ b/spec/tree-sitter-language-mode-spec.js @@ -0,0 +1,560 @@ +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') + +const dedent = require('dedent') +const TextBuffer = require('text-buffer') +const {Point} = TextBuffer +const TextEditor = require('../src/text-editor') +const TreeSitterGrammar = require('../src/tree-sitter-grammar') +const TreeSitterLanguageMode = require('../src/tree-sitter-language-mode') + +const cGrammarPath = require.resolve('language-c/grammars/tree-sitter-c.cson') +const pythonGrammarPath = require.resolve('language-python/grammars/tree-sitter-python.cson') +const jsGrammarPath = require.resolve('language-javascript/grammars/tree-sitter-javascript.cson') + +describe('TreeSitterLanguageMode', () => { + let editor, buffer + + beforeEach(async () => { + editor = await atom.workspace.open('') + buffer = editor.getBuffer() + }) + + describe('highlighting', () => { + it('applies the most specific scope mapping to each node in the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'program': 'source', + 'call_expression > identifier': 'function', + 'property_identifier': 'property', + 'call_expression > member_expression > property_identifier': 'method' + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('aa.bbb = cc(d.eee());') + expectTokensToEqual(editor, [[ + {text: 'aa.', scopes: ['source']}, + {text: 'bbb', scopes: ['source', 'property']}, + {text: ' = ', scopes: ['source']}, + {text: 'cc', scopes: ['source', 'function']}, + {text: '(d.', scopes: ['source']}, + {text: 'eee', scopes: ['source', 'method']}, + {text: '());', scopes: ['source']} + ]]) + }) + + it('can start or end multiple scopes at the same position', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'program': 'source', + 'call_expression': 'call', + 'member_expression': 'member', + 'identifier': 'variable', + '"("': 'open-paren', + '")"': 'close-paren', + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('a = bb.ccc();') + expectTokensToEqual(editor, [[ + {text: 'a', scopes: ['source', 'variable']}, + {text: ' = ', scopes: ['source']}, + {text: 'bb', scopes: ['source', 'call', 'member', 'variable']}, + {text: '.ccc', scopes: ['source', 'call', 'member']}, + {text: '(', scopes: ['source', 'call', 'open-paren']}, + {text: ')', scopes: ['source', 'call', 'close-paren']}, + {text: ';', scopes: ['source']} + ]]) + }) + + it('can resume highlighting on a line that starts with whitespace', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'call_expression > member_expression > property_identifier': 'function', + 'property_identifier': 'member', + 'identifier': 'variable' + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('a\n .b();') + expectTokensToEqual(editor, [ + [ + {text: 'a', scopes: ['variable']}, + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: '.', scopes: []}, + {text: 'b', scopes: ['function']}, + {text: '();', scopes: []} + ] + ]) + }) + + it('correctly skips over tokens with zero size', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-c', + scopes: { + 'primitive_type': 'type', + 'identifier': 'variable', + } + }) + + const languageMode = new TreeSitterLanguageMode({buffer, grammar}) + buffer.setLanguageMode(languageMode) + buffer.setText('int main() {\n int a\n int b;\n}'); + + editor.screenLineForScreenRow(0) + expect( + languageMode.document.rootNode.descendantForPosition(Point(1, 2), Point(1, 6)).toString() + ).toBe('(declaration (primitive_type) (identifier) (MISSING))') + + expectTokensToEqual(editor, [ + [ + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'main', scopes: ['variable']}, + {text: '() {', scopes: []} + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'a', scopes: ['variable']} + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'b', scopes: ['variable']}, + {text: ';', scopes: []} + ], + [ + {text: '}', scopes: []} + ] + ]) + }) + }) + + describe('folding', () => { + beforeEach(() => { + editor.displayLayer.reset({foldCharacter: '…'}) + }) + + it('can fold nodes that start and end with specified tokens', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + { + start: {type: '{', index: 0}, + end: {type: '}', index: -1} + }, + { + start: {type: '(', index: 0}, + end: {type: ')', index: -1} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + module.exports = + class A { + getB (c, + d, + e) { + return this.f(g) + } + } + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(false) + expect(editor.isFoldableAtBufferRow(1)).toBe(true) + expect(editor.isFoldableAtBufferRow(2)).toBe(true) + expect(editor.isFoldableAtBufferRow(3)).toBe(false) + expect(editor.isFoldableAtBufferRow(4)).toBe(true) + expect(editor.isFoldableAtBufferRow(5)).toBe(false) + + editor.foldBufferRow(2) + expect(getDisplayText(editor)).toBe(dedent ` + module.exports = + class A { + getB (…) { + return this.f(g) + } + } + `) + + editor.foldBufferRow(4) + expect(getDisplayText(editor)).toBe(dedent ` + module.exports = + class A { + getB (…) {…} + } + `) + }) + + it('can fold nodes of specified types', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + // Start the fold after the first child (the opening tag) and end it at the last child + // (the closing tag). + { + type: 'jsx_element', + start: {index: 0}, + end: {index: -1} + }, + + // End the fold at the *second* to last child of the self-closing tag: the `/`. + { + type: 'jsx_self_closing_element', + start: {index: 1}, + end: {index: -2} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + const element1 = + + const element2 = + hello + world + + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(true) + expect(editor.isFoldableAtBufferRow(1)).toBe(false) + expect(editor.isFoldableAtBufferRow(2)).toBe(false) + expect(editor.isFoldableAtBufferRow(3)).toBe(false) + expect(editor.isFoldableAtBufferRow(4)).toBe(true) + expect(editor.isFoldableAtBufferRow(5)).toBe(false) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + const element1 = + + const element2 = + hello + world + + `) + + editor.foldBufferRow(4) + expect(getDisplayText(editor)).toBe(dedent ` + const element1 = + + const element2 = … + + `) + }) + + it('can fold entire nodes when no start or end parameters are specified', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + // By default, for a node with no children, folds are started at the *end* of the first + // line of a node, and ended at the *beginning* of the last line. + {type: 'comment'} + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + /** + * Important + */ + const x = 1 /* + Also important + */ + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(true) + expect(editor.isFoldableAtBufferRow(1)).toBe(false) + expect(editor.isFoldableAtBufferRow(2)).toBe(false) + expect(editor.isFoldableAtBufferRow(3)).toBe(true) + expect(editor.isFoldableAtBufferRow(4)).toBe(false) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + /**… */ + const x = 1 /* + Also important + */ + `) + + editor.foldBufferRow(3) + expect(getDisplayText(editor)).toBe(dedent ` + /**… */ + const x = 1 /*…*/ + `) + }) + + it('tries each folding strategy for a given node in the order specified', () => { + const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, { + parser: 'tree-sitter-c', + folds: [ + // If the #ifdef has an `#else` clause, then end the fold there. + { + type: ['preproc_ifdef', 'preproc_elif'], + start: {index: 1}, + end: {type: ['preproc_else', 'preproc_elif']} + }, + + // Otherwise, end the fold at the last child - the `#endif`. + { + type: 'preproc_ifdef', + start: {index: 1}, + end: {index: -1} + }, + + // When folding an `#else` clause, the fold extends to the end of the clause. + { + type: 'preproc_else', + start: {index: 0} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32 + + #include + const char *path_separator = "\\"; + + #elif defined MACOS + + #include + const char *path_separator = "/"; + + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.screenLineForScreenRow(0) + + editor.foldBufferRow(3) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS + + #include + const char *path_separator = "/"; + + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.foldBufferRow(8) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS… + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_… + #endif + `) + + editor.foldAllAtIndentLevel(1) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS… + #else… + + #endif + + #endif + `) + }) + + describe('when folding a node that ends with a line break', () => { + it('ends the fold at the end of the previous line', () => { + const grammar = new TreeSitterGrammar(atom.grammars, pythonGrammarPath, { + parser: 'tree-sitter-python', + folds: [ + { + type: 'function_definition', + start: {type: ':'} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText(dedent ` + def ab(): + print 'a' + print 'b' + + def cd(): + print 'c' + print 'd' + `) + + editor.screenLineForScreenRow(0) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + def ab():… + + def cd(): + print 'c' + print 'd' + `) + }) + }) + }) + + describe('.scopeDescriptorForPosition', () => { + it('returns a scope descriptor representing the given position in the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + id: 'javascript', + parser: 'tree-sitter-javascript' + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText('foo({bar: baz});') + + editor.screenLineForScreenRow(0) + expect(editor.scopeDescriptorForBufferPosition({row: 0, column: 6}).getScopesArray()).toEqual([ + 'javascript', + 'program', + 'expression_statement', + 'call_expression', + 'arguments', + 'object', + 'pair', + 'property_identifier' + ]) + }) + }) + + describe('TextEditor.selectLargerSyntaxNode and .selectSmallerSyntaxNode', () => { + it('expands and contract the selection based on the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: {'program': 'source'} + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + function a (b, c, d) { + eee.f() + g() + } + `) + + editor.screenLineForScreenRow(0) + + editor.setCursorBufferPosition([1, 3]) + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f()') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('function a (b, c, d) {\n eee.f()\n g()\n}') + + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f()') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedBufferRange()).toEqual([[1, 3], [1, 3]]) + }) + }) +}) + +function getDisplayText (editor) { + return editor.displayLayer.getText() +} + +function expectTokensToEqual (editor, expectedTokenLines) { + const lastRow = editor.getLastScreenRow() + + // Assert that the correct tokens are returned regardless of which row + // the highlighting iterator starts on. + for (let startRow = 0; startRow <= lastRow; startRow++) { + editor.displayLayer.clearSpatialIndex() + editor.displayLayer.getScreenLines(startRow, Infinity) + + const tokenLines = [] + for (let row = startRow; row <= lastRow; row++) { + tokenLines[row] = editor.tokensForScreenRow(row).map(({text, scopes}) => ({ + text, + scopes: scopes.map(scope => scope + .split(' ') + .map(className => className.slice('syntax--'.length)) + .join(' ')) + })) + } + + for (let row = startRow; row <= lastRow; row++) { + const tokenLine = tokenLines[row] + const expectedTokenLine = expectedTokenLines[row] + + expect(tokenLine.length).toEqual(expectedTokenLine.length) + for (let i = 0; i < tokenLine.length; i++) { + expect(tokenLine[i]).toEqual(expectedTokenLine[i], `Token ${i}, startRow: ${startRow}`) + } + } + } +} diff --git a/spec/window-event-handler-spec.js b/spec/window-event-handler-spec.js index a03e168fa..2891aa2db 100644 --- a/spec/window-event-handler-spec.js +++ b/spec/window-event-handler-spec.js @@ -44,7 +44,15 @@ describe('WindowEventHandler', () => { }) ) }) - + + describe('resize event', () => + it('calls storeWindowDimensions', () => { + spyOn(atom, 'storeWindowDimensions') + window.dispatchEvent(new CustomEvent('resize')) + expect(atom.storeWindowDimensions).toHaveBeenCalled() + }) + ) + describe('window:close event', () => it('closes the window', () => { spyOn(atom, 'close') diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee deleted file mode 100644 index d8dee2a80..000000000 --- a/src/application-delegate.coffee +++ /dev/null @@ -1,298 +0,0 @@ -{ipcRenderer, remote, shell} = require 'electron' -ipcHelpers = require './ipc-helpers' -{Disposable} = require 'event-kit' -getWindowLoadSettings = require './get-window-load-settings' - -module.exports = -class ApplicationDelegate - getWindowLoadSettings: -> getWindowLoadSettings() - - open: (params) -> - ipcRenderer.send('open', params) - - pickFolder: (callback) -> - responseChannel = "atom-pick-folder-response" - ipcRenderer.on responseChannel, (event, path) -> - ipcRenderer.removeAllListeners(responseChannel) - callback(path) - ipcRenderer.send("pick-folder", responseChannel) - - getCurrentWindow: -> - remote.getCurrentWindow() - - closeWindow: -> - ipcHelpers.call('window-method', 'close') - - getTemporaryWindowState: -> - ipcHelpers.call('get-temporary-window-state').then (stateJSON) -> JSON.parse(stateJSON) - - setTemporaryWindowState: (state) -> - ipcHelpers.call('set-temporary-window-state', JSON.stringify(state)) - - getWindowSize: -> - [width, height] = remote.getCurrentWindow().getSize() - {width, height} - - setWindowSize: (width, height) -> - ipcHelpers.call('set-window-size', width, height) - - getWindowPosition: -> - [x, y] = remote.getCurrentWindow().getPosition() - {x, y} - - setWindowPosition: (x, y) -> - ipcHelpers.call('set-window-position', x, y) - - centerWindow: -> - ipcHelpers.call('center-window') - - focusWindow: -> - ipcHelpers.call('focus-window') - - showWindow: -> - ipcHelpers.call('show-window') - - hideWindow: -> - ipcHelpers.call('hide-window') - - reloadWindow: -> - ipcHelpers.call('window-method', 'reload') - - restartApplication: -> - ipcRenderer.send("restart-application") - - minimizeWindow: -> - ipcHelpers.call('window-method', 'minimize') - - isWindowMaximized: -> - remote.getCurrentWindow().isMaximized() - - maximizeWindow: -> - ipcHelpers.call('window-method', 'maximize') - - unmaximizeWindow: -> - ipcHelpers.call('window-method', 'unmaximize') - - isWindowFullScreen: -> - remote.getCurrentWindow().isFullScreen() - - setWindowFullScreen: (fullScreen=false) -> - ipcHelpers.call('window-method', 'setFullScreen', fullScreen) - - onDidEnterFullScreen: (callback) -> - ipcHelpers.on(ipcRenderer, 'did-enter-full-screen', callback) - - onDidLeaveFullScreen: (callback) -> - ipcHelpers.on(ipcRenderer, 'did-leave-full-screen', callback) - - openWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'openDevTools')) - - closeWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'closeDevTools')) - - toggleWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'toggleDevTools')) - - executeJavaScriptInWindowDevTools: (code) -> - ipcRenderer.send("execute-javascript-in-dev-tools", code) - - setWindowDocumentEdited: (edited) -> - ipcHelpers.call('window-method', 'setDocumentEdited', edited) - - setRepresentedFilename: (filename) -> - ipcHelpers.call('window-method', 'setRepresentedFilename', filename) - - addRecentDocument: (filename) -> - ipcRenderer.send("add-recent-document", filename) - - setRepresentedDirectoryPaths: (paths) -> - ipcHelpers.call('window-method', 'setRepresentedDirectoryPaths', paths) - - setAutoHideWindowMenuBar: (autoHide) -> - ipcHelpers.call('window-method', 'setAutoHideMenuBar', autoHide) - - setWindowMenuBarVisibility: (visible) -> - remote.getCurrentWindow().setMenuBarVisibility(visible) - - getPrimaryDisplayWorkAreaSize: -> - remote.screen.getPrimaryDisplay().workAreaSize - - getUserDefault: (key, type) -> - remote.systemPreferences.getUserDefault(key, type) - - confirm: ({message, detailedMessage, buttons}) -> - buttons ?= {} - if Array.isArray(buttons) - buttonLabels = buttons - else - buttonLabels = Object.keys(buttons) - - chosen = remote.dialog.showMessageBox(remote.getCurrentWindow(), { - type: 'info' - message: message - detail: detailedMessage - buttons: buttonLabels - normalizeAccessKeys: true - }) - - if Array.isArray(buttons) - chosen - else - callback = buttons[buttonLabels[chosen]] - callback?() - - showMessageDialog: (params) -> - - showSaveDialog: (options, callback) -> - if callback? - # Async - @getCurrentWindow().showSaveDialog(options, callback) - else - # Sync - if typeof options is 'string' - options = {defaultPath: options} - @getCurrentWindow().showSaveDialog(options) - - playBeepSound: -> - shell.beep() - - onDidOpenLocations: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'open-locations' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateAvailable: (callback) -> - outerCallback = (event, message, detail) -> - # TODO: Yes, this is strange that `onUpdateAvailable` is listening for - # `did-begin-downloading-update`. We currently have no mechanism to know - # if there is an update, so begin of downloading is a good proxy. - callback(detail) if message is 'did-begin-downloading-update' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onDidBeginDownloadingUpdate: (callback) -> - @onUpdateAvailable(callback) - - onDidBeginCheckingForUpdate: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'checking-for-update' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onDidCompleteDownloadingUpdate: (callback) -> - outerCallback = (event, message, detail) -> - # TODO: We could rename this event to `did-complete-downloading-update` - callback(detail) if message is 'update-available' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateNotAvailable: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'update-not-available' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateError: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'update-error' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onApplicationMenuCommand: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('command', outerCallback) - new Disposable -> - ipcRenderer.removeListener('command', outerCallback) - - onContextMenuCommand: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('context-command', outerCallback) - new Disposable -> - ipcRenderer.removeListener('context-command', outerCallback) - - onURIMessage: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('uri-message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('uri-message', outerCallback) - - onDidRequestUnload: (callback) -> - outerCallback = (event, message) -> - callback(event).then (shouldUnload) -> - ipcRenderer.send('did-prepare-to-unload', shouldUnload) - - ipcRenderer.on('prepare-to-unload', outerCallback) - new Disposable -> - ipcRenderer.removeListener('prepare-to-unload', outerCallback) - - onDidChangeHistoryManager: (callback) -> - outerCallback = (event, message) -> - callback(event) - - ipcRenderer.on('did-change-history-manager', outerCallback) - new Disposable -> - ipcRenderer.removeListener('did-change-history-manager', outerCallback) - - didChangeHistoryManager: -> - ipcRenderer.send('did-change-history-manager') - - openExternal: (url) -> - shell.openExternal(url) - - checkForUpdate: -> - ipcRenderer.send('command', 'application:check-for-update') - - restartAndInstallUpdate: -> - ipcRenderer.send('command', 'application:install-update') - - getAutoUpdateManagerState: -> - ipcRenderer.sendSync('get-auto-update-manager-state') - - getAutoUpdateManagerErrorMessage: -> - ipcRenderer.sendSync('get-auto-update-manager-error') - - emitWillSavePath: (path) -> - ipcRenderer.sendSync('will-save-path', path) - - emitDidSavePath: (path) -> - ipcRenderer.sendSync('did-save-path', path) - - resolveProxy: (requestId, url) -> - ipcRenderer.send('resolve-proxy', requestId, url) - - onDidResolveProxy: (callback) -> - outerCallback = (event, requestId, proxy) -> - callback(requestId, proxy) - - ipcRenderer.on('did-resolve-proxy', outerCallback) - new Disposable -> - ipcRenderer.removeListener('did-resolve-proxy', outerCallback) diff --git a/src/application-delegate.js b/src/application-delegate.js new file mode 100644 index 000000000..f4651431b --- /dev/null +++ b/src/application-delegate.js @@ -0,0 +1,364 @@ +const {ipcRenderer, remote, shell} = require('electron') +const ipcHelpers = require('./ipc-helpers') +const {Disposable} = require('event-kit') +const getWindowLoadSettings = require('./get-window-load-settings') + +module.exports = +class ApplicationDelegate { + getWindowLoadSettings () { return getWindowLoadSettings() } + + open (params) { + return ipcRenderer.send('open', params) + } + + pickFolder (callback) { + const responseChannel = 'atom-pick-folder-response' + ipcRenderer.on(responseChannel, function (event, path) { + ipcRenderer.removeAllListeners(responseChannel) + return callback(path) + }) + return ipcRenderer.send('pick-folder', responseChannel) + } + + getCurrentWindow () { + return remote.getCurrentWindow() + } + + closeWindow () { + return ipcHelpers.call('window-method', 'close') + } + + async getTemporaryWindowState () { + const stateJSON = await ipcHelpers.call('get-temporary-window-state') + return JSON.parse(stateJSON) + } + + setTemporaryWindowState (state) { + return ipcHelpers.call('set-temporary-window-state', JSON.stringify(state)) + } + + getWindowSize () { + const [width, height] = Array.from(remote.getCurrentWindow().getSize()) + return {width, height} + } + + setWindowSize (width, height) { + return ipcHelpers.call('set-window-size', width, height) + } + + getWindowPosition () { + const [x, y] = Array.from(remote.getCurrentWindow().getPosition()) + return {x, y} + } + + setWindowPosition (x, y) { + return ipcHelpers.call('set-window-position', x, y) + } + + centerWindow () { + return ipcHelpers.call('center-window') + } + + focusWindow () { + return ipcHelpers.call('focus-window') + } + + showWindow () { + return ipcHelpers.call('show-window') + } + + hideWindow () { + return ipcHelpers.call('hide-window') + } + + reloadWindow () { + return ipcHelpers.call('window-method', 'reload') + } + + restartApplication () { + return ipcRenderer.send('restart-application') + } + + minimizeWindow () { + return ipcHelpers.call('window-method', 'minimize') + } + + isWindowMaximized () { + return remote.getCurrentWindow().isMaximized() + } + + maximizeWindow () { + return ipcHelpers.call('window-method', 'maximize') + } + + unmaximizeWindow () { + return ipcHelpers.call('window-method', 'unmaximize') + } + + isWindowFullScreen () { + return remote.getCurrentWindow().isFullScreen() + } + + setWindowFullScreen (fullScreen = false) { + return ipcHelpers.call('window-method', 'setFullScreen', fullScreen) + } + + onDidEnterFullScreen (callback) { + return ipcHelpers.on(ipcRenderer, 'did-enter-full-screen', callback) + } + + onDidLeaveFullScreen (callback) { + return ipcHelpers.on(ipcRenderer, 'did-leave-full-screen', callback) + } + + async openWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'openDevTools') + } + + async closeWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'closeDevTools') + } + + async toggleWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'toggleDevTools') + } + + executeJavaScriptInWindowDevTools (code) { + return ipcRenderer.send('execute-javascript-in-dev-tools', code) + } + + didClosePathWithWaitSession (path) { + return ipcHelpers.call('window-method', 'didClosePathWithWaitSession', path) + } + + setWindowDocumentEdited (edited) { + return ipcHelpers.call('window-method', 'setDocumentEdited', edited) + } + + setRepresentedFilename (filename) { + return ipcHelpers.call('window-method', 'setRepresentedFilename', filename) + } + + addRecentDocument (filename) { + return ipcRenderer.send('add-recent-document', filename) + } + + setRepresentedDirectoryPaths (paths) { + return ipcHelpers.call('window-method', 'setRepresentedDirectoryPaths', paths) + } + + setAutoHideWindowMenuBar (autoHide) { + return ipcHelpers.call('window-method', 'setAutoHideMenuBar', autoHide) + } + + setWindowMenuBarVisibility (visible) { + return remote.getCurrentWindow().setMenuBarVisibility(visible) + } + + getPrimaryDisplayWorkAreaSize () { + return remote.screen.getPrimaryDisplay().workAreaSize + } + + getUserDefault (key, type) { + return remote.systemPreferences.getUserDefault(key, type) + } + + confirm ({message, detailedMessage, buttons}) { + let buttonLabels + if (!buttons) buttons = {} + if (Array.isArray(buttons)) { + buttonLabels = buttons + } else { + buttonLabels = Object.keys(buttons) + } + + const chosen = remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message, + detail: detailedMessage, + buttons: buttonLabels, + normalizeAccessKeys: true + }) + + if (Array.isArray(buttons)) { + return chosen + } else { + const callback = buttons[buttonLabels[chosen]] + return (typeof callback === 'function' ? callback() : undefined) + } + } + + showMessageDialog (params) {} + + showSaveDialog (options, callback) { + if (typeof callback === 'function') { + // Async + this.getCurrentWindow().showSaveDialog(options, callback) + } else { + // Sync + if (typeof params === 'string') { + options = {defaultPath: options} + } + return this.getCurrentWindow().showSaveDialog(options) + } + } + + playBeepSound () { + return shell.beep() + } + + onDidOpenLocations (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'open-locations') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateAvailable (callback) { + const outerCallback = (event, message, detail) => { + // TODO: Yes, this is strange that `onUpdateAvailable` is listening for + // `did-begin-downloading-update`. We currently have no mechanism to know + // if there is an update, so begin of downloading is a good proxy. + if (message === 'did-begin-downloading-update') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onDidBeginDownloadingUpdate (callback) { + return this.onUpdateAvailable(callback) + } + + onDidBeginCheckingForUpdate (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'checking-for-update') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onDidCompleteDownloadingUpdate (callback) { + const outerCallback = (event, message, detail) => { + // TODO: We could rename this event to `did-complete-downloading-update` + if (message === 'update-available') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateNotAvailable (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'update-not-available') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateError (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'update-error') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onApplicationMenuCommand (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('command', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('command', outerCallback)) + } + + onContextMenuCommand (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('context-command', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('context-command', outerCallback)) + } + + onURIMessage (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('uri-message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('uri-message', outerCallback)) + } + + onDidRequestUnload (callback) { + const outerCallback = async (event, message) => { + const shouldUnload = await callback(event) + ipcRenderer.send('did-prepare-to-unload', shouldUnload) + } + + ipcRenderer.on('prepare-to-unload', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('prepare-to-unload', outerCallback)) + } + + onDidChangeHistoryManager (callback) { + const outerCallback = (event, message) => callback(event) + + ipcRenderer.on('did-change-history-manager', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('did-change-history-manager', outerCallback)) + } + + didChangeHistoryManager () { + return ipcRenderer.send('did-change-history-manager') + } + + openExternal (url) { + return shell.openExternal(url) + } + + checkForUpdate () { + return ipcRenderer.send('command', 'application:check-for-update') + } + + restartAndInstallUpdate () { + return ipcRenderer.send('command', 'application:install-update') + } + + getAutoUpdateManagerState () { + return ipcRenderer.sendSync('get-auto-update-manager-state') + } + + getAutoUpdateManagerErrorMessage () { + return ipcRenderer.sendSync('get-auto-update-manager-error') + } + + emitWillSavePath (path) { + return ipcRenderer.sendSync('will-save-path', path) + } + + emitDidSavePath (path) { + return ipcRenderer.sendSync('did-save-path', path) + } + + resolveProxy (requestId, url) { + return ipcRenderer.send('resolve-proxy', requestId, url) + } + + onDidResolveProxy (callback) { + const outerCallback = (event, requestId, proxy) => callback(requestId, proxy) + + ipcRenderer.on('did-resolve-proxy', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('did-resolve-proxy', outerCallback)) + } +} diff --git a/src/atom-environment.js b/src/atom-environment.js index ec5212db5..68694f1df 100644 --- a/src/atom-environment.js +++ b/src/atom-environment.js @@ -51,13 +51,15 @@ let nextId = 0 // // An instance of this class is always available as the `atom` global. class AtomEnvironment { + /* - Section: Construction and Destruction + Section: Properties */ - // Call .loadOrCreate instead constructor (params = {}) { this.id = (params.id != null) ? params.id : nextId++ + + // Public: A {Clipboard} instance this.clipboard = params.clipboard this.updateProcessEnv = params.updateProcessEnv || updateProcessEnv this.enablePersistence = params.enablePersistence @@ -68,25 +70,44 @@ class AtomEnvironment { this.loadTime = null this.emitter = new Emitter() this.disposables = new CompositeDisposable() + this.pathsWithWaitSessions = new Set() + + // Public: A {DeserializerManager} instance this.deserializers = new DeserializerManager(this) this.deserializeTimings = {} + + // Public: A {ViewRegistry} instance this.views = new ViewRegistry(this) + + // Public: A {NotificationManager} instance this.notifications = new NotificationManager() this.stateStore = new StateStore('AtomEnvironments', 1) + // Public: A {Config} instance this.config = new Config({ notificationManager: this.notifications, enablePersistence: this.enablePersistence }) this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)}) + // Public: A {KeymapManager} instance this.keymaps = new KeymapManager({notificationManager: this.notifications}) + + // Public: A {TooltipManager} instance this.tooltips = new TooltipManager({keymapManager: this.keymaps, viewRegistry: this.views}) + + // Public: A {CommandRegistry} instance this.commands = new CommandRegistry() this.uriHandlerRegistry = new URIHandlerRegistry() + + // Public: A {GrammarRegistry} instance this.grammars = new GrammarRegistry({config: this.config}) + + // Public: A {StyleManager} instance this.styles = new StyleManager() + + // Public: A {PackageManager} instance this.packages = new PackageManager({ config: this.config, styleManager: this.styles, @@ -98,6 +119,8 @@ class AtomEnvironment { viewRegistry: this.views, uriHandlerRegistry: this.uriHandlerRegistry }) + + // Public: A {ThemeManager} instance this.themes = new ThemeManager({ packageManager: this.packages, config: this.config, @@ -105,12 +128,18 @@ class AtomEnvironment { notificationManager: this.notifications, viewRegistry: this.views }) + + // Public: A {MenuManager} instance this.menu = new MenuManager({keymapManager: this.keymaps, packageManager: this.packages}) + + // Public: A {ContextMenuManager} instance this.contextMenu = new ContextMenuManager({keymapManager: this.keymaps}) + this.packages.setMenuManager(this.menu) this.packages.setContextMenuManager(this.contextMenu) this.packages.setThemeManager(this.themes) + // Public: A {Project} instance this.project = new Project({ notificationManager: this.notifications, packageManager: this.packages, @@ -121,6 +150,7 @@ class AtomEnvironment { this.commandInstaller = new CommandInstaller(this.applicationDelegate) this.protocolHandlerInstaller = new ProtocolHandlerInstaller() + // Public: A {TextEditorRegistry} instance this.textEditors = new TextEditorRegistry({ config: this.config, grammarRegistry: this.grammars, @@ -128,6 +158,7 @@ class AtomEnvironment { packageManager: this.packages }) + // Public: A {Workspace} instance this.workspace = new Workspace({ config: this.config, project: this.project, @@ -157,7 +188,9 @@ class AtomEnvironment { this.windowEventHandler = new WindowEventHandler({atomEnvironment: this, applicationDelegate: this.applicationDelegate}) + // Public: A {HistoryManager} instance this.history = new HistoryManager({project: this.project, commands: this.commands, stateStore: this.stateStore}) + // Keep instances of HistoryManager in sync this.disposables.add(this.history.onDidChangeProjects(event => { if (!event.reloaded) this.applicationDelegate.didChangeHistoryManager() @@ -206,12 +239,13 @@ class AtomEnvironment { this.themes.initialize({configDirPath: this.configDirPath, resourcePath, safeMode, devMode}) this.commandInstaller.initialize(this.getVersion()) - this.protocolHandlerInstaller.initialize(this.config, this.notifications) this.uriHandlerRegistry.registerHostHandler('core', CoreURIHandlers.create(this)) this.autoUpdater.initialize() this.config.load() + this.protocolHandlerInstaller.initialize(this.config, this.notifications) + this.themes.loadBaseStylesheets() this.initialStyleElements = this.styles.getSnapshot() if (params.onlyLoadBaseStyleSheets) this.themes.initialLoadComplete = true @@ -326,6 +360,7 @@ class AtomEnvironment { this.grammars.clear() this.textEditors.clear() this.views.clear() + this.pathsWithWaitSessions.clear() } destroy () { @@ -789,7 +824,22 @@ class AtomEnvironment { this.document.body.appendChild(this.workspace.getElement()) if (this.backgroundStylesheet) this.backgroundStylesheet.remove() - this.watchProjectPaths() + let previousProjectPaths = this.project.getPaths() + this.disposables.add(this.project.onDidChangePaths(newPaths => { + for (let path of previousProjectPaths) { + if (this.pathsWithWaitSessions.has(path) && !newPaths.includes(path)) { + this.applicationDelegate.didClosePathWithWaitSession(path) + } + } + previousProjectPaths = newPaths + this.applicationDelegate.setRepresentedDirectoryPaths(newPaths) + })) + this.disposables.add(this.workspace.onDidDestroyPaneItem(({item}) => { + const path = item.getPath && item.getPath() + if (this.pathsWithWaitSessions.has(path)) { + this.applicationDelegate.didClosePathWithWaitSession(path) + } + })) this.packages.activate() this.keymaps.loadUserKeymap() @@ -992,13 +1042,6 @@ class AtomEnvironment { return this.themes.load() } - // Notify the browser project of the window's current project path - watchProjectPaths () { - this.disposables.add(this.project.onDidChangePaths(() => { - this.applicationDelegate.setRepresentedDirectoryPaths(this.project.getPaths()) - })) - } - setDocumentEdited (edited) { if (typeof this.applicationDelegate.setWindowDocumentEdited === 'function') { this.applicationDelegate.setWindowDocumentEdited(edited) @@ -1012,8 +1055,10 @@ class AtomEnvironment { } addProjectFolder () { - this.pickFolder((selectedPaths = []) => { - this.addToProject(selectedPaths) + return new Promise((resolve) => { + this.pickFolder((selectedPaths) => { + this.addToProject(selectedPaths || []).then(resolve) + }) }) } @@ -1264,8 +1309,9 @@ or use Pane::saveItemAs for programmatic saving.`) } } - for (var {pathToOpen, initialLine, initialColumn, forceAddToWindow} of locations) { - if (pathToOpen && (needsProjectPaths || forceAddToWindow)) { + for (const location of locations) { + const {pathToOpen} = location + if (pathToOpen && (needsProjectPaths || location.forceAddToWindow)) { if (fs.existsSync(pathToOpen)) { pushFolderToOpen(this.project.getDirectoryForProjectPath(pathToOpen).getPath()) } else if (fs.existsSync(path.dirname(pathToOpen))) { @@ -1276,8 +1322,10 @@ or use Pane::saveItemAs for programmatic saving.`) } if (!fs.isDirectorySync(pathToOpen)) { - fileLocationsToOpen.push({pathToOpen, initialLine, initialColumn}) + fileLocationsToOpen.push(location) } + + if (location.hasWaitSession) this.pathsWithWaitSessions.add(pathToOpen) } let restoredState = false @@ -1298,7 +1346,7 @@ or use Pane::saveItemAs for programmatic saving.`) if (!restoredState) { const fileOpenPromises = [] - for ({pathToOpen, initialLine, initialColumn} of fileLocationsToOpen) { + for (const {pathToOpen, initialLine, initialColumn} of fileLocationsToOpen) { fileOpenPromises.push(this.workspace && this.workspace.open(pathToOpen, {initialLine, initialColumn})) } await Promise.all(fileOpenPromises) diff --git a/src/config-schema.js b/src/config-schema.js index 2ff68be86..18dc3d774 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -342,6 +342,11 @@ const configSchema = { description: 'Emulated with Atom events' } ] + }, + useTreeSitterParsers: { + type: 'boolean', + default: false, + description: 'Use the new Tree-sitter parsing system for supported languages' } } }, diff --git a/src/config.coffee b/src/config.coffee index b8bf8a76f..84e726700 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -423,6 +423,7 @@ class Config @configFileHasErrors = false @transactDepth = 0 @pendingOperations = [] + @legacyScopeAliases = {} @requestLoad = _.debounce => @loadUserConfig() @@ -599,11 +600,22 @@ class Config # * `value` The value for the key-path getAll: (keyPath, options) -> {scope} = options if options? - result = [] if scope? scopeDescriptor = ScopeDescriptor.fromObject(scope) - result = result.concat @scopedSettingsStore.getAll(scopeDescriptor.getScopeChain(), keyPath, options) + result = @scopedSettingsStore.getAll( + scopeDescriptor.getScopeChain(), + keyPath, + options + ) + if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor) + result.push(@scopedSettingsStore.getAll( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + )...) + else + result = [] if globalValue = @getRawValue(keyPath, options) result.push(scopeSelector: '*', value: globalValue) @@ -762,6 +774,12 @@ class Config finally @endTransaction() + addLegacyScopeAlias: (languageId, legacyScopeName) -> + @legacyScopeAliases[languageId] = legacyScopeName + + removeLegacyScopeAlias: (languageId) -> + delete @legacyScopeAliases[languageId] + ### Section: Internal methods used by core ### @@ -1145,7 +1163,20 @@ class Config getRawScopedValue: (scopeDescriptor, keyPath, options) -> scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor) - @scopedSettingsStore.getPropertyValue(scopeDescriptor.getScopeChain(), keyPath, options) + result = @scopedSettingsStore.getPropertyValue( + scopeDescriptor.getScopeChain(), + keyPath, + options + ) + + if result? + result + else if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor) + @scopedSettingsStore.getPropertyValue( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + ) observeScopedKeyPath: (scope, keyPath, callback) -> callback(@get(keyPath, {scope})) @@ -1160,6 +1191,13 @@ class Config oldValue = newValue callback(event) + getLegacyScopeDescriptor: (scopeDescriptor) -> + legacyAlias = @legacyScopeAliases[scopeDescriptor.scopes[0]] + if legacyAlias + scopes = scopeDescriptor.scopes.slice() + scopes[0] = legacyAlias + new ScopeDescriptor({scopes}) + # Base schema enforcers. These will coerce raw input into the specified type, # and will throw an error when the value cannot be coerced. Throwing the error # will indicate that the value should not be set. diff --git a/src/grammar-registry.js b/src/grammar-registry.js index db86958fd..b316bdbb0 100644 --- a/src/grammar-registry.js +++ b/src/grammar-registry.js @@ -1,13 +1,16 @@ const _ = require('underscore-plus') const Grim = require('grim') +const CSON = require('season') const FirstMate = require('first-mate') const {Disposable, CompositeDisposable} = require('event-kit') const TextMateLanguageMode = require('./text-mate-language-mode') +const TreeSitterLanguageMode = require('./tree-sitter-language-mode') +const TreeSitterGrammar = require('./tree-sitter-grammar') const Token = require('./token') const fs = require('fs-plus') const {Point, Range} = require('text-buffer') -const GRAMMAR_SELECTION_RANGE = Range(Point.ZERO, Point(10, 0)).freeze() +const GRAMMAR_TYPE_BONUS = 1000 const PATH_SPLIT_REGEX = new RegExp('[/.]') // Extended: This class holds the grammars used for tokenizing. @@ -24,10 +27,13 @@ class GrammarRegistry { clear () { this.textmateRegistry.clear() + this.treeSitterGrammarsById = {} if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() this.languageOverridesByBufferId = new Map() this.grammarScoresByBuffer = new Map() + this.textMateScopeNamesByTreeSitterLanguageId = new Map() + this.treeSitterLanguageIdsByTextMateScopeName = new Map() const grammarAddedOrUpdated = this.grammarAddedOrUpdated.bind(this) this.textmateRegistry.onDidAddGrammar(grammarAddedOrUpdated) @@ -102,17 +108,18 @@ class GrammarRegistry { // Extended: Force a {TextBuffer} to use a different grammar than the // one that would otherwise be selected for it. // - // * `buffer` The {TextBuffer} whose gramamr will be set. + // * `buffer` The {TextBuffer} whose grammar will be set. // * `languageId` The {String} id of the desired language. // // Returns a {Boolean} that indicates whether the language was successfully // found. assignLanguageMode (buffer, languageId) { if (buffer.getBuffer) buffer = buffer.getBuffer() + languageId = this.normalizeLanguageId(languageId) let grammar = null if (languageId != null) { - grammar = this.textmateRegistry.grammarForScopeName(languageId) + grammar = this.grammarForId(languageId) if (!grammar) return false this.languageOverridesByBufferId.set(buffer.id, languageId) } else { @@ -136,7 +143,7 @@ class GrammarRegistry { autoAssignLanguageMode (buffer) { const result = this.selectGrammarWithScore( buffer.getPath(), - buffer.getTextInRange(GRAMMAR_SELECTION_RANGE) + getGrammarSelectionContent(buffer) ) this.languageOverridesByBufferId.delete(buffer.id) this.grammarScoresByBuffer.set(buffer, result.score) @@ -146,7 +153,11 @@ class GrammarRegistry { } languageModeForGrammarAndBuffer (grammar, buffer) { - return new TextMateLanguageMode({grammar, buffer, config: this.config}) + if (grammar instanceof TreeSitterGrammar) { + return new TreeSitterLanguageMode({grammar, buffer, config: this.config}) + } else { + return new TextMateLanguageMode({grammar, buffer, config: this.config}) + } } // Extended: Select a grammar for the given file path and file contents. @@ -165,39 +176,44 @@ class GrammarRegistry { selectGrammarWithScore (filePath, fileContents) { let bestMatch = null let highestScore = -Infinity - for (let grammar of this.textmateRegistry.grammars) { + this.forEachGrammar(grammar => { const score = this.getGrammarScore(grammar, filePath, fileContents) - if ((score > highestScore) || (bestMatch == null)) { + if (score > highestScore || bestMatch == null) { bestMatch = grammar highestScore = score } - } + }) return {grammar: bestMatch, score: highestScore} } // Extended: Returns a {Number} representing how well the grammar matches the // `filePath` and `contents`. getGrammarScore (grammar, filePath, contents) { - if ((contents == null) && fs.isFileSync(filePath)) { + if (contents == null && fs.isFileSync(filePath)) { contents = fs.readFileSync(filePath, 'utf8') } let score = this.getGrammarPathScore(grammar, filePath) - if ((score > 0) && !grammar.bundledPackage) { + if (score > 0 && !grammar.bundledPackage) { score += 0.125 } if (this.grammarMatchesContents(grammar, contents)) { score += 0.25 } + + if (score > 0 && this.isGrammarPreferredType(grammar)) { + score += GRAMMAR_TYPE_BONUS + } + return score } getGrammarPathScore (grammar, filePath) { - if (!filePath) { return -1 } + if (!filePath) return -1 if (process.platform === 'win32') { filePath = filePath.replace(/\\/g, '/') } const pathComponents = filePath.toLowerCase().split(PATH_SPLIT_REGEX) - let pathScore = -1 + let pathScore = 0 let customFileTypes if (this.config.get('core.customFileTypes')) { @@ -225,25 +241,48 @@ class GrammarRegistry { } grammarMatchesContents (grammar, contents) { - if ((contents == null) || (grammar.firstLineRegex == null)) { return false } + if (contents == null) return false - let escaped = false - let numberOfNewlinesInRegex = 0 - for (let character of grammar.firstLineRegex.source) { - switch (character) { - case '\\': - escaped = !escaped - break - case 'n': - if (escaped) { numberOfNewlinesInRegex++ } - escaped = false - break - default: - escaped = false + if (grammar.contentRegExp) { // TreeSitter grammars + return grammar.contentRegExp.test(contents) + } else if (grammar.firstLineRegex) { // FirstMate grammars + let escaped = false + let numberOfNewlinesInRegex = 0 + for (let character of grammar.firstLineRegex.source) { + switch (character) { + case '\\': + escaped = !escaped + break + case 'n': + if (escaped) { numberOfNewlinesInRegex++ } + escaped = false + break + default: + escaped = false + } } + + const lines = contents.split('\n') + return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n')) + } else { + return false } - const lines = contents.split('\n') - return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n')) + } + + forEachGrammar (callback) { + this.textmateRegistry.grammars.forEach(callback) + for (let grammarId in this.treeSitterGrammarsById) { + callback(this.treeSitterGrammarsById[grammarId]) + } + } + + grammarForId (languageId) { + languageId = this.normalizeLanguageId(languageId) + + return ( + this.textmateRegistry.grammarForScopeName(languageId) || + this.treeSitterGrammarsById[languageId] + ) } // Deprecated: Get the grammar override for the given file path. @@ -284,6 +323,8 @@ class GrammarRegistry { } grammarAddedOrUpdated (grammar) { + if (grammar.scopeName && !grammar.id) grammar.id = grammar.scopeName + this.grammarScoresByBuffer.forEach((score, buffer) => { const languageMode = buffer.getLanguageMode() if (grammar.injectionSelector) { @@ -295,16 +336,11 @@ class GrammarRegistry { const languageOverride = this.languageOverridesByBufferId.get(buffer.id) - if ((grammar.scopeName === buffer.getLanguageMode().getLanguageId() || - grammar.scopeName === languageOverride)) { + if ((grammar.id === buffer.getLanguageMode().getLanguageId() || + grammar.id === languageOverride)) { buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) } else if (!languageOverride) { - const score = this.getGrammarScore( - grammar, - buffer.getPath(), - buffer.getTextInRange(GRAMMAR_SELECTION_RANGE) - ) - + const score = this.getGrammarScore(grammar, buffer.getPath(), getGrammarSelectionContent(buffer)) const currentScore = this.grammarScoresByBuffer.get(buffer) if (currentScore == null || score > currentScore) { buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) @@ -348,15 +384,35 @@ class GrammarRegistry { } grammarForScopeName (scopeName) { - return this.textmateRegistry.grammarForScopeName(scopeName) + return this.grammarForId(scopeName) } addGrammar (grammar) { - return this.textmateRegistry.addGrammar(grammar) + if (grammar instanceof TreeSitterGrammar) { + this.treeSitterGrammarsById[grammar.id] = grammar + if (grammar.legacyScopeName) { + this.config.addLegacyScopeAlias(grammar.id, grammar.legacyScopeName) + this.textMateScopeNamesByTreeSitterLanguageId.set(grammar.id, grammar.legacyScopeName) + this.treeSitterLanguageIdsByTextMateScopeName.set(grammar.legacyScopeName, grammar.id) + } + this.grammarAddedOrUpdated(grammar) + return new Disposable(() => this.removeGrammar(grammar)) + } else { + return this.textmateRegistry.addGrammar(grammar) + } } removeGrammar (grammar) { - return this.textmateRegistry.removeGrammar(grammar) + if (grammar instanceof TreeSitterGrammar) { + delete this.treeSitterGrammarsById[grammar.id] + if (grammar.legacyScopeName) { + this.config.removeLegacyScopeAlias(grammar.id) + this.textMateScopeNamesByTreeSitterLanguageId.delete(grammar.id) + this.treeSitterLanguageIdsByTextMateScopeName.delete(grammar.legacyScopeName) + } + } else { + return this.textmateRegistry.removeGrammar(grammar) + } } removeGrammarForScopeName (scopeName) { @@ -370,7 +426,11 @@ class GrammarRegistry { // * `error` An {Error}, may be null. // * `grammar` A {Grammar} or null if an error occured. loadGrammar (grammarPath, callback) { - return this.textmateRegistry.loadGrammar(grammarPath, callback) + this.readGrammar(grammarPath, (error, grammar) => { + if (error) return callback(error) + this.addGrammar(grammar) + callback(grammar) + }) } // Extended: Read a grammar synchronously and add it to this registry. @@ -379,7 +439,9 @@ class GrammarRegistry { // // Returns a {Grammar}. loadGrammarSync (grammarPath) { - return this.textmateRegistry.loadGrammarSync(grammarPath) + const grammar = this.readGrammarSync(grammarPath) + this.addGrammar(grammar) + return grammar } // Extended: Read a grammar asynchronously but don't add it to the registry. @@ -391,7 +453,15 @@ class GrammarRegistry { // // Returns undefined. readGrammar (grammarPath, callback) { - return this.textmateRegistry.readGrammar(grammarPath, callback) + if (!callback) callback = () => {} + CSON.readFile(grammarPath, (error, params = {}) => { + if (error) return callback(error) + try { + callback(null, this.createGrammar(grammarPath, params)) + } catch (error) { + callback(error) + } + }) } // Extended: Read a grammar synchronously but don't add it to the registry. @@ -400,11 +470,18 @@ class GrammarRegistry { // // Returns a {Grammar}. readGrammarSync (grammarPath) { - return this.textmateRegistry.readGrammarSync(grammarPath) + return this.createGrammar(grammarPath, CSON.readFileSync(grammarPath) || {}) } createGrammar (grammarPath, params) { - return this.textmateRegistry.createGrammar(grammarPath, params) + if (params.type === 'tree-sitter') { + return new TreeSitterGrammar(this, grammarPath, params) + } else { + if (typeof params.scopeName !== 'string' || params.scopeName.length === 0) { + throw new Error(`Grammar missing required scopeName property: ${grammarPath}`) + } + return this.textmateRegistry.createGrammar(grammarPath, params) + } } // Extended: Get all the grammars in this registry. @@ -417,4 +494,25 @@ class GrammarRegistry { scopeForId (id) { return this.textmateRegistry.scopeForId(id) } + + isGrammarPreferredType (grammar) { + return this.config.get('core.useTreeSitterParsers') + ? grammar instanceof TreeSitterGrammar + : grammar instanceof FirstMate.Grammar + } + + normalizeLanguageId (languageId) { + if (this.config.get('core.useTreeSitterParsers')) { + return this.treeSitterLanguageIdsByTextMateScopeName.get(languageId) || languageId + } else { + return this.textMateScopeNamesByTreeSitterLanguageId.get(languageId) || languageId + } + } +} + +function getGrammarSelectionContent (buffer) { + return buffer.getTextInRange(Range( + Point(0, 0), + buffer.positionForCharacterIndex(1024) + )) } diff --git a/src/main-process/application-menu.coffee b/src/main-process/application-menu.coffee deleted file mode 100644 index 35bc7d66c..000000000 --- a/src/main-process/application-menu.coffee +++ /dev/null @@ -1,161 +0,0 @@ -{app, Menu} = require 'electron' -_ = require 'underscore-plus' -MenuHelpers = require '../menu-helpers' - -# Used to manage the global application menu. -# -# It's created by {AtomApplication} upon instantiation and used to add, remove -# and maintain the state of all menu items. -module.exports = -class ApplicationMenu - constructor: (@version, @autoUpdateManager) -> - @windowTemplates = new WeakMap() - @setActiveTemplate(@getDefaultTemplate()) - @autoUpdateManager.on 'state-changed', (state) => @showUpdateMenuItem(state) - - # Public: Updates the entire menu with the given keybindings. - # - # window - The BrowserWindow this menu template is associated with. - # template - The Object which describes the menu to display. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - update: (window, template, keystrokesByCommand) -> - @translateTemplate(template, keystrokesByCommand) - @substituteVersion(template) - @windowTemplates.set(window, template) - @setActiveTemplate(template) if window is @lastFocusedWindow - - setActiveTemplate: (template) -> - unless _.isEqual(template, @activeTemplate) - @activeTemplate = template - @menu = Menu.buildFromTemplate(_.deepClone(template)) - Menu.setApplicationMenu(@menu) - - @showUpdateMenuItem(@autoUpdateManager.getState()) - - # Register a BrowserWindow with this application menu. - addWindow: (window) -> - @lastFocusedWindow ?= window - - focusHandler = => - @lastFocusedWindow = window - if template = @windowTemplates.get(window) - @setActiveTemplate(template) - - window.on 'focus', focusHandler - window.once 'closed', => - @lastFocusedWindow = null if window is @lastFocusedWindow - @windowTemplates.delete(window) - window.removeListener 'focus', focusHandler - - @enableWindowSpecificItems(true) - - # Flattens the given menu and submenu items into an single Array. - # - # menu - A complete menu configuration object for atom-shell's menu API. - # - # Returns an Array of native menu items. - flattenMenuItems: (menu) -> - items = [] - for index, item of menu.items or {} - items.push(item) - items = items.concat(@flattenMenuItems(item.submenu)) if item.submenu - items - - # Flattens the given menu template into an single Array. - # - # template - An object describing the menu item. - # - # Returns an Array of native menu items. - flattenMenuTemplate: (template) -> - items = [] - for item in template - items.push(item) - items = items.concat(@flattenMenuTemplate(item.submenu)) if item.submenu - items - - # Public: Used to make all window related menu items are active. - # - # enable - If true enables all window specific items, if false disables all - # window specific items. - enableWindowSpecificItems: (enable) -> - for item in @flattenMenuItems(@menu) - item.enabled = enable if item.metadata?.windowSpecific - return - - # Replaces VERSION with the current version. - substituteVersion: (template) -> - if (item = _.find(@flattenMenuTemplate(template), ({label}) -> label is 'VERSION')) - item.label = "Version #{@version}" - - # Sets the proper visible state the update menu items - showUpdateMenuItem: (state) -> - checkForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Check for Update') - checkingForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Checking for Update') - downloadingUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Downloading Update') - installUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Restart and Install Update') - - return unless checkForUpdateItem? and checkingForUpdateItem? and downloadingUpdateItem? and installUpdateItem? - - checkForUpdateItem.visible = false - checkingForUpdateItem.visible = false - downloadingUpdateItem.visible = false - installUpdateItem.visible = false - - switch state - when 'idle', 'error', 'no-update-available' - checkForUpdateItem.visible = true - when 'checking' - checkingForUpdateItem.visible = true - when 'downloading' - downloadingUpdateItem.visible = true - when 'update-available' - installUpdateItem.visible = true - - # Default list of menu items. - # - # Returns an Array of menu item Objects. - getDefaultTemplate: -> - [ - label: "Atom" - submenu: [ - {label: "Check for Update", metadata: {autoUpdate: true}} - {label: 'Reload', accelerator: 'Command+R', click: => @focusedWindow()?.reload()} - {label: 'Close Window', accelerator: 'Command+Shift+W', click: => @focusedWindow()?.close()} - {label: 'Toggle Dev Tools', accelerator: 'Command+Alt+I', click: => @focusedWindow()?.toggleDevTools()} - {label: 'Quit', accelerator: 'Command+Q', click: -> app.quit()} - ] - ] - - focusedWindow: -> - _.find global.atomApplication.getAllWindows(), (atomWindow) -> atomWindow.isFocused() - - # Combines a menu template with the appropriate keystroke. - # - # template - An Object conforming to atom-shell's menu api but lacking - # accelerator and click properties. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - # - # Returns a complete menu configuration object for atom-shell's menu API. - translateTemplate: (template, keystrokesByCommand) -> - template.forEach (item) => - item.metadata ?= {} - if item.command - item.accelerator = @acceleratorForCommand(item.command, keystrokesByCommand) - item.click = -> global.atomApplication.sendCommand(item.command, item.commandDetail) - item.metadata.windowSpecific = true unless /^application:/.test(item.command, item.commandDetail) - @translateTemplate(item.submenu, keystrokesByCommand) if item.submenu - template - - # Determine the accelerator for a given command. - # - # command - The name of the command. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - # - # Returns a String containing the keystroke in a format that can be interpreted - # by Electron to provide nice icons where available. - acceleratorForCommand: (command, keystrokesByCommand) -> - firstKeystroke = keystrokesByCommand[command]?[0] - MenuHelpers.acceleratorForKeystroke(firstKeystroke) diff --git a/src/main-process/application-menu.js b/src/main-process/application-menu.js new file mode 100644 index 000000000..26dcd1941 --- /dev/null +++ b/src/main-process/application-menu.js @@ -0,0 +1,225 @@ +const {app, Menu} = require('electron') +const _ = require('underscore-plus') +const MenuHelpers = require('../menu-helpers') + +// Used to manage the global application menu. +// +// It's created by {AtomApplication} upon instantiation and used to add, remove +// and maintain the state of all menu items. +module.exports = +class ApplicationMenu { + constructor (version, autoUpdateManager) { + this.version = version + this.autoUpdateManager = autoUpdateManager + this.windowTemplates = new WeakMap() + this.setActiveTemplate(this.getDefaultTemplate()) + this.autoUpdateManager.on('state-changed', state => this.showUpdateMenuItem(state)) + } + + // Public: Updates the entire menu with the given keybindings. + // + // window - The BrowserWindow this menu template is associated with. + // template - The Object which describes the menu to display. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + update (window, template, keystrokesByCommand) { + this.translateTemplate(template, keystrokesByCommand) + this.substituteVersion(template) + this.windowTemplates.set(window, template) + if (window === this.lastFocusedWindow) return this.setActiveTemplate(template) + } + + setActiveTemplate (template) { + if (!_.isEqual(template, this.activeTemplate)) { + this.activeTemplate = template + this.menu = Menu.buildFromTemplate(_.deepClone(template)) + Menu.setApplicationMenu(this.menu) + } + + return this.showUpdateMenuItem(this.autoUpdateManager.getState()) + } + + // Register a BrowserWindow with this application menu. + addWindow (window) { + if (this.lastFocusedWindow == null) this.lastFocusedWindow = window + + const focusHandler = () => { + this.lastFocusedWindow = window + const template = this.windowTemplates.get(window) + if (template) this.setActiveTemplate(template) + } + + window.on('focus', focusHandler) + window.once('closed', () => { + if (window === this.lastFocusedWindow) this.lastFocusedWindow = null + this.windowTemplates.delete(window) + window.removeListener('focus', focusHandler) + }) + + this.enableWindowSpecificItems(true) + } + + // Flattens the given menu and submenu items into an single Array. + // + // menu - A complete menu configuration object for atom-shell's menu API. + // + // Returns an Array of native menu items. + flattenMenuItems (menu) { + const object = menu.items || {} + let items = [] + for (let index in object) { + const item = object[index] + items.push(item) + if (item.submenu) items = items.concat(this.flattenMenuItems(item.submenu)) + } + return items + } + + // Flattens the given menu template into an single Array. + // + // template - An object describing the menu item. + // + // Returns an Array of native menu items. + flattenMenuTemplate (template) { + let items = [] + for (let item of template) { + items.push(item) + if (item.submenu) items = items.concat(this.flattenMenuTemplate(item.submenu)) + } + return items + } + + // Public: Used to make all window related menu items are active. + // + // enable - If true enables all window specific items, if false disables all + // window specific items. + enableWindowSpecificItems (enable) { + for (let item of this.flattenMenuItems(this.menu)) { + if (item.metadata && item.metadata.windowSpecific) item.enabled = enable + } + } + + // Replaces VERSION with the current version. + substituteVersion (template) { + let item = this.flattenMenuTemplate(template).find(({label}) => label === 'VERSION') + if (item) item.label = `Version ${this.version}` + } + + // Sets the proper visible state the update menu items + showUpdateMenuItem (state) { + const items = this.flattenMenuItems(this.menu) + const checkForUpdateItem = items.find(({label}) => label === 'Check for Update') + const checkingForUpdateItem = items.find(({label}) => label === 'Checking for Update') + const downloadingUpdateItem = items.find(({label}) => label === 'Downloading Update') + const installUpdateItem = items.find(({label}) => label === 'Restart and Install Update') + + if (!checkForUpdateItem || !checkingForUpdateItem || + !downloadingUpdateItem || !installUpdateItem) return + + checkForUpdateItem.visible = false + checkingForUpdateItem.visible = false + downloadingUpdateItem.visible = false + installUpdateItem.visible = false + + switch (state) { + case 'idle': + case 'error': + case 'no-update-available': + checkForUpdateItem.visible = true + break + case 'checking': + checkingForUpdateItem.visible = true + break + case 'downloading': + downloadingUpdateItem.visible = true + break + case 'update-available': + installUpdateItem.visible = true + break + } + } + + // Default list of menu items. + // + // Returns an Array of menu item Objects. + getDefaultTemplate () { + return [{ + label: 'Atom', + submenu: [ + { + label: 'Check for Update', + metadata: {autoUpdate: true} + }, + { + label: 'Reload', + accelerator: 'Command+R', + click: () => { + const window = this.focusedWindow() + if (window) window.reload() + } + }, + { + label: 'Close Window', + accelerator: 'Command+Shift+W', + click: () => { + const window = this.focusedWindow() + if (window) window.close() + } + }, + { + label: 'Toggle Dev Tools', + accelerator: 'Command+Alt+I', + click: () => { + const window = this.focusedWindow() + if (window) window.toggleDevTools() + } + }, + { + label: 'Quit', + accelerator: 'Command+Q', + click: () => app.quit() + } + ] + }] + } + + focusedWindow () { + return global.atomApplication.getAllWindows().find(window => window.isFocused()) + } + + // Combines a menu template with the appropriate keystroke. + // + // template - An Object conforming to atom-shell's menu api but lacking + // accelerator and click properties. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + // + // Returns a complete menu configuration object for atom-shell's menu API. + translateTemplate (template, keystrokesByCommand) { + template.forEach(item => { + if (item.metadata == null) item.metadata = {} + if (item.command) { + item.accelerator = this.acceleratorForCommand(item.command, keystrokesByCommand) + item.click = () => global.atomApplication.sendCommand(item.command, item.commandDetail) + if (!/^application:/.test(item.command, item.commandDetail)) { + item.metadata.windowSpecific = true + } + } + if (item.submenu) this.translateTemplate(item.submenu, keystrokesByCommand) + }) + return template + } + + // Determine the accelerator for a given command. + // + // command - The name of the command. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + // + // Returns a String containing the keystroke in a format that can be interpreted + // by Electron to provide nice icons where available. + acceleratorForCommand (command, keystrokesByCommand) { + const firstKeystroke = keystrokesByCommand[command] && keystrokesByCommand[command][0] + return MenuHelpers.acceleratorForKeystroke(firstKeystroke) + } +} diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee deleted file mode 100644 index f6802705e..000000000 --- a/src/main-process/atom-application.coffee +++ /dev/null @@ -1,917 +0,0 @@ -AtomWindow = require './atom-window' -ApplicationMenu = require './application-menu' -AtomProtocolHandler = require './atom-protocol-handler' -AutoUpdateManager = require './auto-update-manager' -StorageFolder = require '../storage-folder' -Config = require '../config' -FileRecoveryService = require './file-recovery-service' -ipcHelpers = require '../ipc-helpers' -{BrowserWindow, Menu, app, dialog, ipcMain, shell, screen} = require 'electron' -{CompositeDisposable, Disposable} = require 'event-kit' -fs = require 'fs-plus' -path = require 'path' -os = require 'os' -net = require 'net' -url = require 'url' -{EventEmitter} = require 'events' -_ = require 'underscore-plus' -FindParentDir = null -Resolve = null -ConfigSchema = require '../config-schema' - -LocationSuffixRegExp = /(:\d+)(:\d+)?$/ - -# The application's singleton class. -# -# It's the entry point into the Atom application and maintains the global state -# of the application. -# -module.exports = -class AtomApplication - Object.assign @prototype, EventEmitter.prototype - - # Public: The entry point into the Atom application. - @open: (options) -> - unless options.socketPath? - if process.platform is 'win32' - userNameSafe = new Buffer(process.env.USERNAME).toString('base64') - options.socketPath = "\\\\.\\pipe\\atom-#{options.version}-#{userNameSafe}-#{process.arch}-sock" - else - options.socketPath = path.join(os.tmpdir(), "atom-#{options.version}-#{process.env.USER}.sock") - - # FIXME: Sometimes when socketPath doesn't exist, net.connect would strangely - # take a few seconds to trigger 'error' event, it could be a bug of node - # or atom-shell, before it's fixed we check the existence of socketPath to - # speedup startup. - if (process.platform isnt 'win32' and not fs.existsSync options.socketPath) or options.test or options.benchmark or options.benchmarkTest - new AtomApplication(options).initialize(options) - return - - client = net.connect {path: options.socketPath}, -> - client.write JSON.stringify(options), -> - client.end() - app.quit() - - client.on 'error', -> new AtomApplication(options).initialize(options) - - windows: null - applicationMenu: null - atomProtocolHandler: null - resourcePath: null - version: null - quitting: false - - exit: (status) -> app.exit(status) - - constructor: (options) -> - {@resourcePath, @devResourcePath, @version, @devMode, @safeMode, @socketPath, @logFile, @userDataDir} = options - @socketPath = null if options.test or options.benchmark or options.benchmarkTest - @pidsToOpenWindows = {} - @windowStack = new WindowStack() - - @config = new Config({enablePersistence: true}) - @config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)} - ConfigSchema.projectHome = { - type: 'string', - default: path.join(fs.getHomeDirectory(), 'github'), - description: 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' - } - @config.initialize({configDirPath: process.env.ATOM_HOME, @resourcePath, projectHomeSchema: ConfigSchema.projectHome}) - @config.load() - @fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, "recovery")) - @storageFolder = new StorageFolder(process.env.ATOM_HOME) - @autoUpdateManager = new AutoUpdateManager( - @version, - options.test or options.benchmark or options.benchmarkTest, - @config - ) - - @disposable = new CompositeDisposable - @handleEvents() - - # This stuff was previously done in the constructor, but we want to be able to construct this object - # for testing purposes without booting up the world. As you add tests, feel free to move instantiation - # of these various sub-objects into the constructor, but you'll need to remove the side-effects they - # perform during their construction, adding an initialize method that you call here. - initialize: (options) -> - global.atomApplication = this - - # DEPRECATED: This can be removed at some point (added in 1.13) - # It converts `useCustomTitleBar: true` to `titleBar: "custom"` - if process.platform is 'darwin' and @config.get('core.useCustomTitleBar') - @config.unset('core.useCustomTitleBar') - @config.set('core.titleBar', 'custom') - - @config.onDidChange 'core.titleBar', @promptForRestart.bind(this) - - process.nextTick => @autoUpdateManager.initialize() - @applicationMenu = new ApplicationMenu(@version, @autoUpdateManager) - @atomProtocolHandler = new AtomProtocolHandler(@resourcePath, @safeMode) - - @listenForArgumentsFromNewProcess() - @setupDockMenu() - - @launch(options) - - destroy: -> - windowsClosePromises = @getAllWindows().map (window) -> - window.close() - window.closedPromise - Promise.all(windowsClosePromises).then(=> @disposable.dispose()) - - launch: (options) -> - if options.test or options.benchmark or options.benchmarkTest - @openWithOptions(options) - else if options.pathsToOpen?.length > 0 or options.urlsToOpen?.length > 0 - if @config.get('core.restorePreviousWindowsOnStart') is 'always' - @loadState(_.deepClone(options)) - @openWithOptions(options) - else - @loadState(options) or @openPath(options) - - openWithOptions: (options) -> - { - initialPaths, pathsToOpen, executedFrom, urlsToOpen, benchmark, - benchmarkTest, test, pidToKillWhenClosed, devMode, safeMode, newWindow, - logFile, profileStartup, timeout, clearWindowState, addToLastWindow, env - } = options - - app.focus() - - if test - @runTests({ - headless: true, devMode, @resourcePath, executedFrom, pathsToOpen, - logFile, timeout, env - }) - else if benchmark or benchmarkTest - @runBenchmarks({headless: true, test: benchmarkTest, @resourcePath, executedFrom, pathsToOpen, timeout, env}) - else if pathsToOpen.length > 0 - @openPaths({ - initialPaths, pathsToOpen, executedFrom, pidToKillWhenClosed, newWindow, - devMode, safeMode, profileStartup, clearWindowState, addToLastWindow, env - }) - else if urlsToOpen.length > 0 - for urlToOpen in urlsToOpen - @openUrl({urlToOpen, devMode, safeMode, env}) - else - # Always open a editor window if this is the first instance of Atom. - @openPath({ - initialPaths, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, - clearWindowState, addToLastWindow, env - }) - - # Public: Removes the {AtomWindow} from the global window list. - removeWindow: (window) -> - @windowStack.removeWindow(window) - if @getAllWindows().length is 0 - @applicationMenu?.enableWindowSpecificItems(false) - if process.platform in ['win32', 'linux'] - app.quit() - return - @saveState(true) unless window.isSpec - - # Public: Adds the {AtomWindow} to the global window list. - addWindow: (window) -> - @windowStack.addWindow(window) - @applicationMenu?.addWindow(window.browserWindow) - window.once 'window:loaded', => - @autoUpdateManager?.emitUpdateAvailableEvent(window) - - unless window.isSpec - focusHandler = => @windowStack.touch(window) - blurHandler = => @saveState(false) - window.browserWindow.on 'focus', focusHandler - window.browserWindow.on 'blur', blurHandler - window.browserWindow.once 'closed', => - @windowStack.removeWindow(window) - window.browserWindow.removeListener 'focus', focusHandler - window.browserWindow.removeListener 'blur', blurHandler - window.browserWindow.webContents.once 'did-finish-load', => @saveState(false) - - getAllWindows: => - @windowStack.all().slice() - - getLastFocusedWindow: (predicate) => - @windowStack.getLastFocusedWindow(predicate) - - # Creates server to listen for additional atom application launches. - # - # You can run the atom command multiple times, but after the first launch - # the other launches will just pass their information to this server and then - # close immediately. - listenForArgumentsFromNewProcess: -> - return unless @socketPath? - @deleteSocketFile() - server = net.createServer (connection) => - data = '' - connection.on 'data', (chunk) -> - data = data + chunk - - connection.on 'end', => - options = JSON.parse(data) - @openWithOptions(options) - - server.listen @socketPath - server.on 'error', (error) -> console.error 'Application server failed', error - - deleteSocketFile: -> - return if process.platform is 'win32' or not @socketPath? - - if fs.existsSync(@socketPath) - try - fs.unlinkSync(@socketPath) - catch error - # Ignore ENOENT errors in case the file was deleted between the exists - # check and the call to unlink sync. This occurred occasionally on CI - # which is why this check is here. - throw error unless error.code is 'ENOENT' - - # Registers basic application commands, non-idempotent. - handleEvents: -> - getLoadSettings = => - devMode: @focusedWindow()?.devMode - safeMode: @focusedWindow()?.safeMode - - @on 'application:quit', -> app.quit() - @on 'application:new-window', -> @openPath(getLoadSettings()) - @on 'application:new-file', -> (@focusedWindow() ? this).openPath() - @on 'application:open-dev', -> @promptForPathToOpen('all', devMode: true) - @on 'application:open-safe', -> @promptForPathToOpen('all', safeMode: true) - @on 'application:inspect', ({x, y, atomWindow}) -> - atomWindow ?= @focusedWindow() - atomWindow?.browserWindow.inspectElement(x, y) - - @on 'application:open-documentation', -> shell.openExternal('http://flight-manual.atom.io/') - @on 'application:open-discussions', -> shell.openExternal('https://discuss.atom.io') - @on 'application:open-faq', -> shell.openExternal('https://atom.io/faq') - @on 'application:open-terms-of-use', -> shell.openExternal('https://atom.io/terms') - @on 'application:report-issue', -> shell.openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#reporting-bugs') - @on 'application:search-issues', -> shell.openExternal('https://github.com/search?q=+is%3Aissue+user%3Aatom') - - @on 'application:install-update', => - @quitting = true - @autoUpdateManager.install() - - @on 'application:check-for-update', => @autoUpdateManager.check() - - if process.platform is 'darwin' - @on 'application:bring-all-windows-to-front', -> Menu.sendActionToFirstResponder('arrangeInFront:') - @on 'application:hide', -> Menu.sendActionToFirstResponder('hide:') - @on 'application:hide-other-applications', -> Menu.sendActionToFirstResponder('hideOtherApplications:') - @on 'application:minimize', -> Menu.sendActionToFirstResponder('performMiniaturize:') - @on 'application:unhide-all-applications', -> Menu.sendActionToFirstResponder('unhideAllApplications:') - @on 'application:zoom', -> Menu.sendActionToFirstResponder('zoom:') - else - @on 'application:minimize', -> @focusedWindow()?.minimize() - @on 'application:zoom', -> @focusedWindow()?.maximize() - - @openPathOnEvent('application:about', 'atom://about') - @openPathOnEvent('application:show-settings', 'atom://config') - @openPathOnEvent('application:open-your-config', 'atom://.atom/config') - @openPathOnEvent('application:open-your-init-script', 'atom://.atom/init-script') - @openPathOnEvent('application:open-your-keymap', 'atom://.atom/keymap') - @openPathOnEvent('application:open-your-snippets', 'atom://.atom/snippets') - @openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet') - @openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) - - @disposable.add ipcHelpers.on app, 'before-quit', (event) => - resolveBeforeQuitPromise = null - @lastBeforeQuitPromise = new Promise((resolve) -> resolveBeforeQuitPromise = resolve) - if @quitting - resolveBeforeQuitPromise() - else - event.preventDefault() - @quitting = true - windowUnloadPromises = @getAllWindows().map((window) -> window.prepareToUnload()) - Promise.all(windowUnloadPromises).then((windowUnloadedResults) -> - didUnloadAllWindows = windowUnloadedResults.every((didUnloadWindow) -> didUnloadWindow) - app.quit() if didUnloadAllWindows - resolveBeforeQuitPromise() - ) - - @disposable.add ipcHelpers.on app, 'will-quit', => - @killAllProcesses() - @deleteSocketFile() - - @disposable.add ipcHelpers.on app, 'open-file', (event, pathToOpen) => - event.preventDefault() - @openPath({pathToOpen}) - - @disposable.add ipcHelpers.on app, 'open-url', (event, urlToOpen) => - event.preventDefault() - @openUrl({urlToOpen, @devMode, @safeMode}) - - @disposable.add ipcHelpers.on app, 'activate', (event, hasVisibleWindows) => - unless hasVisibleWindows - event?.preventDefault() - @emit('application:new-window') - - @disposable.add ipcHelpers.on ipcMain, 'restart-application', => - @restart() - - @disposable.add ipcHelpers.on ipcMain, 'resolve-proxy', (event, requestId, url) -> - event.sender.session.resolveProxy url, (proxy) -> - unless event.sender.isDestroyed() - event.sender.send('did-resolve-proxy', requestId, proxy) - - @disposable.add ipcHelpers.on ipcMain, 'did-change-history-manager', (event) => - for atomWindow in @getAllWindows() - webContents = atomWindow.browserWindow.webContents - if webContents isnt event.sender - webContents.send('did-change-history-manager') - - # A request from the associated render process to open a new render process. - @disposable.add ipcHelpers.on ipcMain, 'open', (event, options) => - window = @atomWindowForEvent(event) - if options? - if typeof options.pathsToOpen is 'string' - options.pathsToOpen = [options.pathsToOpen] - if options.pathsToOpen?.length > 0 - options.window = window - @openPaths(options) - else - new AtomWindow(this, @fileRecoveryService, options) - else - @promptForPathToOpen('all', {window}) - - @disposable.add ipcHelpers.on ipcMain, 'update-application-menu', (event, template, keystrokesByCommand) => - win = BrowserWindow.fromWebContents(event.sender) - @applicationMenu?.update(win, template, keystrokesByCommand) - - @disposable.add ipcHelpers.on ipcMain, 'run-package-specs', (event, packageSpecPath) => - @runTests({resourcePath: @devResourcePath, pathsToOpen: [packageSpecPath], headless: false}) - - @disposable.add ipcHelpers.on ipcMain, 'run-benchmarks', (event, benchmarksPath) => - @runBenchmarks({resourcePath: @devResourcePath, pathsToOpen: [benchmarksPath], headless: false, test: false}) - - @disposable.add ipcHelpers.on ipcMain, 'command', (event, command) => - @emit(command) - - @disposable.add ipcHelpers.on ipcMain, 'open-command', (event, command, args...) => - defaultPath = args[0] if args.length > 0 - switch command - when 'application:open' then @promptForPathToOpen('all', getLoadSettings(), defaultPath) - when 'application:open-file' then @promptForPathToOpen('file', getLoadSettings(), defaultPath) - when 'application:open-folder' then @promptForPathToOpen('folder', getLoadSettings(), defaultPath) - else console.log "Invalid open-command received: " + command - - @disposable.add ipcHelpers.on ipcMain, 'window-command', (event, command, args...) -> - win = BrowserWindow.fromWebContents(event.sender) - win.emit(command, args...) - - @disposable.add ipcHelpers.respondTo 'window-method', (browserWindow, method, args...) => - @atomWindowForBrowserWindow(browserWindow)?[method](args...) - - @disposable.add ipcHelpers.on ipcMain, 'pick-folder', (event, responseChannel) => - @promptForPath "folder", (selectedPaths) -> - event.sender.send(responseChannel, selectedPaths) - - @disposable.add ipcHelpers.respondTo 'set-window-size', (win, width, height) -> - win.setSize(width, height) - - @disposable.add ipcHelpers.respondTo 'set-window-position', (win, x, y) -> - win.setPosition(x, y) - - @disposable.add ipcHelpers.respondTo 'center-window', (win) -> - win.center() - - @disposable.add ipcHelpers.respondTo 'focus-window', (win) -> - win.focus() - - @disposable.add ipcHelpers.respondTo 'show-window', (win) -> - win.show() - - @disposable.add ipcHelpers.respondTo 'hide-window', (win) -> - win.hide() - - @disposable.add ipcHelpers.respondTo 'get-temporary-window-state', (win) -> - win.temporaryState - - @disposable.add ipcHelpers.respondTo 'set-temporary-window-state', (win, state) -> - win.temporaryState = state - - clipboard = require '../safe-clipboard' - @disposable.add ipcHelpers.on ipcMain, 'write-text-to-selection-clipboard', (event, selectedText) -> - clipboard.writeText(selectedText, 'selection') - - @disposable.add ipcHelpers.on ipcMain, 'write-to-stdout', (event, output) -> - process.stdout.write(output) - - @disposable.add ipcHelpers.on ipcMain, 'write-to-stderr', (event, output) -> - process.stderr.write(output) - - @disposable.add ipcHelpers.on ipcMain, 'add-recent-document', (event, filename) -> - app.addRecentDocument(filename) - - @disposable.add ipcHelpers.on ipcMain, 'execute-javascript-in-dev-tools', (event, code) -> - event.sender.devToolsWebContents?.executeJavaScript(code) - - @disposable.add ipcHelpers.on ipcMain, 'get-auto-update-manager-state', (event) => - event.returnValue = @autoUpdateManager.getState() - - @disposable.add ipcHelpers.on ipcMain, 'get-auto-update-manager-error', (event) => - event.returnValue = @autoUpdateManager.getErrorMessage() - - @disposable.add ipcHelpers.on ipcMain, 'will-save-path', (event, path) => - @fileRecoveryService.willSavePath(@atomWindowForEvent(event), path) - event.returnValue = true - - @disposable.add ipcHelpers.on ipcMain, 'did-save-path', (event, path) => - @fileRecoveryService.didSavePath(@atomWindowForEvent(event), path) - event.returnValue = true - - @disposable.add ipcHelpers.on ipcMain, 'did-change-paths', => - @saveState(false) - - @disposable.add(@disableZoomOnDisplayChange()) - - setupDockMenu: -> - if process.platform is 'darwin' - dockMenu = Menu.buildFromTemplate [ - {label: 'New Window', click: => @emit('application:new-window')} - ] - app.dock.setMenu dockMenu - - # Public: Executes the given command. - # - # If it isn't handled globally, delegate to the currently focused window. - # - # command - The string representing the command. - # args - The optional arguments to pass along. - sendCommand: (command, args...) -> - unless @emit(command, args...) - focusedWindow = @focusedWindow() - if focusedWindow? - focusedWindow.sendCommand(command, args...) - else - @sendCommandToFirstResponder(command) - - # Public: Executes the given command on the given window. - # - # command - The string representing the command. - # atomWindow - The {AtomWindow} to send the command to. - # args - The optional arguments to pass along. - sendCommandToWindow: (command, atomWindow, args...) -> - unless @emit(command, args...) - if atomWindow? - atomWindow.sendCommand(command, args...) - else - @sendCommandToFirstResponder(command) - - # Translates the command into macOS action and sends it to application's first - # responder. - sendCommandToFirstResponder: (command) -> - return false unless process.platform is 'darwin' - - switch command - when 'core:undo' then Menu.sendActionToFirstResponder('undo:') - when 'core:redo' then Menu.sendActionToFirstResponder('redo:') - when 'core:copy' then Menu.sendActionToFirstResponder('copy:') - when 'core:cut' then Menu.sendActionToFirstResponder('cut:') - when 'core:paste' then Menu.sendActionToFirstResponder('paste:') - when 'core:select-all' then Menu.sendActionToFirstResponder('selectAll:') - else return false - true - - # Public: Open the given path in the focused window when the event is - # triggered. - # - # A new window will be created if there is no currently focused window. - # - # eventName - The event to listen for. - # pathToOpen - The path to open when the event is triggered. - openPathOnEvent: (eventName, pathToOpen) -> - @on eventName, -> - if window = @focusedWindow() - window.openPath(pathToOpen) - else - @openPath({pathToOpen}) - - # Returns the {AtomWindow} for the given paths. - windowForPaths: (pathsToOpen, devMode) -> - _.find @getAllWindows(), (atomWindow) -> - atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen) - - # Returns the {AtomWindow} for the given ipcMain event. - atomWindowForEvent: ({sender}) -> - @atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender)) - - atomWindowForBrowserWindow: (browserWindow) -> - @getAllWindows().find((atomWindow) -> atomWindow.browserWindow is browserWindow) - - # Public: Returns the currently focused {AtomWindow} or undefined if none. - focusedWindow: -> - _.find @getAllWindows(), (atomWindow) -> atomWindow.isFocused() - - # Get the platform-specific window offset for new windows. - getWindowOffsetForCurrentPlatform: -> - offsetByPlatform = - darwin: 22 - win32: 26 - offsetByPlatform[process.platform] ? 0 - - # Get the dimensions for opening a new window by cascading as appropriate to - # the platform. - getDimensionsForNewWindow: -> - return if (@focusedWindow() ? @getLastFocusedWindow())?.isMaximized() - dimensions = (@focusedWindow() ? @getLastFocusedWindow())?.getDimensions() - offset = @getWindowOffsetForCurrentPlatform() - if dimensions? and offset? - dimensions.x += offset - dimensions.y += offset - dimensions - - # Public: Opens a single path, in an existing window if possible. - # - # options - - # :pathToOpen - The file path to open - # :pidToKillWhenClosed - The integer of the pid to kill - # :newWindow - Boolean of whether this should be opened in a new window. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - # :profileStartup - Boolean to control creating a profile of the startup time. - # :window - {AtomWindow} to open file paths in. - # :addToLastWindow - Boolean of whether this should be opened in last focused window. - openPath: ({initialPaths, pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window, clearWindowState, addToLastWindow, env} = {}) -> - @openPaths({initialPaths, pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window, clearWindowState, addToLastWindow, env}) - - # Public: Opens multiple paths, in existing windows if possible. - # - # options - - # :pathsToOpen - The array of file paths to open - # :pidToKillWhenClosed - The integer of the pid to kill - # :newWindow - Boolean of whether this should be opened in a new window. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - # :windowDimensions - Object with height and width keys. - # :window - {AtomWindow} to open file paths in. - # :addToLastWindow - Boolean of whether this should be opened in last focused window. - openPaths: ({initialPaths, pathsToOpen, executedFrom, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, profileStartup, window, clearWindowState, addToLastWindow, env}={}) -> - if not pathsToOpen? or pathsToOpen.length is 0 - return - env = process.env unless env? - devMode = Boolean(devMode) - safeMode = Boolean(safeMode) - clearWindowState = Boolean(clearWindowState) - locationsToOpen = (@locationForPathToOpen(pathToOpen, executedFrom, addToLastWindow) for pathToOpen in pathsToOpen) - pathsToOpen = (locationToOpen.pathToOpen for locationToOpen in locationsToOpen) - - unless pidToKillWhenClosed or newWindow - existingWindow = @windowForPaths(pathsToOpen, devMode) - stats = (fs.statSyncNoException(pathToOpen) for pathToOpen in pathsToOpen) - unless existingWindow? - if currentWindow = window ? @getLastFocusedWindow() - existingWindow = currentWindow if ( - addToLastWindow or - currentWindow.devMode is devMode and - ( - stats.every((stat) -> stat.isFile?()) or - stats.some((stat) -> stat.isDirectory?() and not currentWindow.hasProjectPath()) - ) - ) - - if existingWindow? - openedWindow = existingWindow - openedWindow.openLocations(locationsToOpen) - if openedWindow.isMinimized() - openedWindow.restore() - else - openedWindow.focus() - openedWindow.replaceEnvironment(env) - else - if devMode - try - windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) - resourcePath = @devResourcePath - - windowInitializationScript ?= require.resolve('../initialize-application-window') - resourcePath ?= @resourcePath - windowDimensions ?= @getDimensionsForNewWindow() - openedWindow = new AtomWindow(this, @fileRecoveryService, {initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env}) - openedWindow.focus() - @windowStack.addWindow(openedWindow) - - if pidToKillWhenClosed? - @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow - - openedWindow.browserWindow.once 'closed', => - @killProcessForWindow(openedWindow) - - openedWindow - - # Kill all processes associated with opened windows. - killAllProcesses: -> - @killProcess(pid) for pid of @pidsToOpenWindows - return - - # Kill process associated with the given opened window. - killProcessForWindow: (openedWindow) -> - for pid, trackedWindow of @pidsToOpenWindows - @killProcess(pid) if trackedWindow is openedWindow - return - - # Kill the process with the given pid. - killProcess: (pid) -> - try - parsedPid = parseInt(pid) - process.kill(parsedPid) if isFinite(parsedPid) - catch error - if error.code isnt 'ESRCH' - console.log("Killing process #{pid} failed: #{error.code ? error.message}") - delete @pidsToOpenWindows[pid] - - saveState: (allowEmpty=false) -> - return if @quitting - states = [] - for window in @getAllWindows() - unless window.isSpec - states.push({initialPaths: window.representedDirectoryPaths}) - states.reverse() - if states.length > 0 or allowEmpty - @storageFolder.storeSync('application.json', states) - @emit('application:did-save-state') - - loadState: (options) -> - if (@config.get('core.restorePreviousWindowsOnStart') in ['yes', 'always']) and (states = @storageFolder.load('application.json'))?.length > 0 - for state in states - @openWithOptions(Object.assign(options, { - initialPaths: state.initialPaths - pathsToOpen: state.initialPaths.filter (directoryPath) -> fs.isDirectorySync(directoryPath) - urlsToOpen: [] - devMode: @devMode - safeMode: @safeMode - })) - else - null - - # Open an atom:// url. - # - # The host of the URL being opened is assumed to be the package name - # responsible for opening the URL. A new window will be created with - # that package's `urlMain` as the bootstrap script. - # - # options - - # :urlToOpen - The atom:// url to open. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - openUrl: ({urlToOpen, devMode, safeMode, env}) -> - parsedUrl = url.parse(urlToOpen, true) - return unless parsedUrl.protocol is "atom:" - - pack = @findPackageWithName(parsedUrl.host, devMode) - if pack?.urlMain - @openPackageUrlMain(parsedUrl.host, pack.urlMain, urlToOpen, devMode, safeMode, env) - else - @openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env) - - openPackageUriHandler: (url, parsedUrl, devMode, safeMode, env) -> - bestWindow = null - if parsedUrl.host is 'core' - predicate = require('../core-uri-handlers').windowPredicate(parsedUrl) - bestWindow = @getLastFocusedWindow (win) -> - not win.isSpecWindow() and predicate(win) - - bestWindow ?= @getLastFocusedWindow (win) -> not win.isSpecWindow() - if bestWindow? - bestWindow.sendURIMessage url - bestWindow.focus() - else - resourcePath = @resourcePath - if devMode - try - windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) - resourcePath = @devResourcePath - - windowInitializationScript ?= require.resolve('../initialize-application-window') - windowDimensions = @getDimensionsForNewWindow() - win = new AtomWindow(this, @fileRecoveryService, {resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env}) - @windowStack.addWindow(win) - win.on 'window:loaded', -> - win.sendURIMessage url - - findPackageWithName: (packageName, devMode) -> - _.find @getPackageManager(devMode).getAvailablePackageMetadata(), ({name}) -> name is packageName - - openPackageUrlMain: (packageName, packageUrlMain, urlToOpen, devMode, safeMode, env) -> - packagePath = @getPackageManager(devMode).resolvePackagePath(packageName) - windowInitializationScript = path.resolve(packagePath, packageUrlMain) - windowDimensions = @getDimensionsForNewWindow() - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env}) - - getPackageManager: (devMode) -> - unless @packages? - PackageManager = require '../package-manager' - @packages = new PackageManager({}) - @packages.initialize - configDirPath: process.env.ATOM_HOME - devMode: devMode - resourcePath: @resourcePath - - @packages - - - # Opens up a new {AtomWindow} to run specs within. - # - # options - - # :headless - A Boolean that, if true, will close the window upon - # completion. - # :resourcePath - The path to include specs from. - # :specPath - The directory to load specs from. - # :safeMode - A Boolean that, if true, won't run specs from ~/.atom/packages - # and ~/.atom/dev/packages, defaults to false. - runTests: ({headless, resourcePath, executedFrom, pathsToOpen, logFile, safeMode, timeout, env}) -> - if resourcePath isnt @resourcePath and not fs.existsSync(resourcePath) - resourcePath = @resourcePath - - timeoutInSeconds = Number.parseFloat(timeout) - unless Number.isNaN(timeoutInSeconds) - timeoutHandler = -> - console.log "The test suite has timed out because it has been running for more than #{timeoutInSeconds} seconds." - process.exit(124) # Use the same exit code as the UNIX timeout util. - setTimeout(timeoutHandler, timeoutInSeconds * 1000) - - try - windowInitializationScript = require.resolve(path.resolve(@devResourcePath, 'src', 'initialize-test-window')) - catch error - windowInitializationScript = require.resolve(path.resolve(__dirname, '..', '..', 'src', 'initialize-test-window')) - - testPaths = [] - if pathsToOpen? - for pathToOpen in pathsToOpen - testPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) - - if testPaths.length is 0 - process.stderr.write 'Error: Specify at least one test path\n\n' - process.exit(1) - - legacyTestRunnerPath = @resolveLegacyTestRunnerPath() - testRunnerPath = @resolveTestRunnerPath(testPaths[0]) - devMode = true - isSpec = true - safeMode ?= false - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, resourcePath, headless, isSpec, devMode, testRunnerPath, legacyTestRunnerPath, testPaths, logFile, safeMode, env}) - - runBenchmarks: ({headless, test, resourcePath, executedFrom, pathsToOpen, env}) -> - if resourcePath isnt @resourcePath and not fs.existsSync(resourcePath) - resourcePath = @resourcePath - - try - windowInitializationScript = require.resolve(path.resolve(@devResourcePath, 'src', 'initialize-benchmark-window')) - catch error - windowInitializationScript = require.resolve(path.resolve(__dirname, '..', '..', 'src', 'initialize-benchmark-window')) - - benchmarkPaths = [] - if pathsToOpen? - for pathToOpen in pathsToOpen - benchmarkPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) - - if benchmarkPaths.length is 0 - process.stderr.write 'Error: Specify at least one benchmark path.\n\n' - process.exit(1) - - devMode = true - isSpec = true - safeMode = false - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, resourcePath, headless, test, isSpec, devMode, benchmarkPaths, safeMode, env}) - - resolveTestRunnerPath: (testPath) -> - FindParentDir ?= require 'find-parent-dir' - - if packageRoot = FindParentDir.sync(testPath, 'package.json') - packageMetadata = require(path.join(packageRoot, 'package.json')) - if packageMetadata.atomTestRunner - Resolve ?= require('resolve') - if testRunnerPath = Resolve.sync(packageMetadata.atomTestRunner, basedir: packageRoot, extensions: Object.keys(require.extensions)) - return testRunnerPath - else - process.stderr.write "Error: Could not resolve test runner path '#{packageMetadata.atomTestRunner}'" - process.exit(1) - - @resolveLegacyTestRunnerPath() - - resolveLegacyTestRunnerPath: -> - try - require.resolve(path.resolve(@devResourcePath, 'spec', 'jasmine-test-runner')) - catch error - require.resolve(path.resolve(__dirname, '..', '..', 'spec', 'jasmine-test-runner')) - - locationForPathToOpen: (pathToOpen, executedFrom='', forceAddToWindow) -> - return {pathToOpen} unless pathToOpen - - pathToOpen = pathToOpen.replace(/[:\s]+$/, '') - match = pathToOpen.match(LocationSuffixRegExp) - - if match? - pathToOpen = pathToOpen.slice(0, -match[0].length) - initialLine = Math.max(0, parseInt(match[1].slice(1)) - 1) if match[1] - initialColumn = Math.max(0, parseInt(match[2].slice(1)) - 1) if match[2] - else - initialLine = initialColumn = null - - unless url.parse(pathToOpen).protocol? - pathToOpen = path.resolve(executedFrom, fs.normalize(pathToOpen)) - - {pathToOpen, initialLine, initialColumn, forceAddToWindow} - - # Opens a native dialog to prompt the user for a path. - # - # Once paths are selected, they're opened in a new or existing {AtomWindow}s. - # - # options - - # :type - A String which specifies the type of the dialog, could be 'file', - # 'folder' or 'all'. The 'all' is only available on macOS. - # :devMode - A Boolean which controls whether any newly opened windows - # should be in dev mode or not. - # :safeMode - A Boolean which controls whether any newly opened windows - # should be in safe mode or not. - # :window - An {AtomWindow} to use for opening a selected file path. - # :path - An optional String which controls the default path to which the - # file dialog opens. - promptForPathToOpen: (type, {devMode, safeMode, window}, path=null) -> - @promptForPath type, ((pathsToOpen) => - @openPaths({pathsToOpen, devMode, safeMode, window})), path - - promptForPath: (type, callback, path) -> - properties = - switch type - when 'file' then ['openFile'] - when 'folder' then ['openDirectory'] - when 'all' then ['openFile', 'openDirectory'] - else throw new Error("#{type} is an invalid type for promptForPath") - - # Show the open dialog as child window on Windows and Linux, and as - # independent dialog on macOS. This matches most native apps. - parentWindow = - if process.platform is 'darwin' - null - else - BrowserWindow.getFocusedWindow() - - openOptions = - properties: properties.concat(['multiSelections', 'createDirectory']) - title: switch type - when 'file' then 'Open File' - when 'folder' then 'Open Folder' - else 'Open' - - # File dialog defaults to project directory of currently active editor - if path? - openOptions.defaultPath = path - - dialog.showOpenDialog(parentWindow, openOptions, callback) - - promptForRestart: -> - chosen = dialog.showMessageBox BrowserWindow.getFocusedWindow(), - type: 'warning' - title: 'Restart required' - message: "You will need to restart Atom for this change to take effect." - buttons: ['Restart Atom', 'Cancel'] - if chosen is 0 - @restart() - - restart: -> - args = [] - args.push("--safe") if @safeMode - args.push("--log-file=#{@logFile}") if @logFile? - args.push("--socket-path=#{@socketPath}") if @socketPath? - args.push("--user-data-dir=#{@userDataDir}") if @userDataDir? - if @devMode - args.push('--dev') - args.push("--resource-path=#{@resourcePath}") - app.relaunch({args}) - app.quit() - - disableZoomOnDisplayChange: -> - outerCallback = => - for window in @getAllWindows() - window.disableZoom() - - # Set the limits every time a display is added or removed, otherwise the - # configuration gets reset to the default, which allows zooming the - # webframe. - screen.on('display-added', outerCallback) - screen.on('display-removed', outerCallback) - new Disposable -> - screen.removeListener('display-added', outerCallback) - screen.removeListener('display-removed', outerCallback) - -class WindowStack - constructor: (@windows = []) -> - - addWindow: (window) => - @removeWindow(window) - @windows.unshift(window) - - touch: (window) => - @addWindow(window) - - removeWindow: (window) => - currentIndex = @windows.indexOf(window) - @windows.splice(currentIndex, 1) if currentIndex > -1 - - getLastFocusedWindow: (predicate) => - predicate ?= (win) -> true - @windows.find(predicate) - - all: => - @windows diff --git a/src/main-process/atom-application.js b/src/main-process/atom-application.js new file mode 100644 index 000000000..372bd537c --- /dev/null +++ b/src/main-process/atom-application.js @@ -0,0 +1,1376 @@ +const AtomWindow = require('./atom-window') +const ApplicationMenu = require('./application-menu') +const AtomProtocolHandler = require('./atom-protocol-handler') +const AutoUpdateManager = require('./auto-update-manager') +const StorageFolder = require('../storage-folder') +const Config = require('../config') +const FileRecoveryService = require('./file-recovery-service') +const ipcHelpers = require('../ipc-helpers') +const {BrowserWindow, Menu, app, dialog, ipcMain, shell, screen} = require('electron') +const {CompositeDisposable, Disposable} = require('event-kit') +const crypto = require('crypto') +const fs = require('fs-plus') +const path = require('path') +const os = require('os') +const net = require('net') +const url = require('url') +const {EventEmitter} = require('events') +const _ = require('underscore-plus') +let FindParentDir = null +let Resolve = null +const ConfigSchema = require('../config-schema') + +const LocationSuffixRegExp = /(:\d+)(:\d+)?$/ + +// The application's singleton class. +// +// It's the entry point into the Atom application and maintains the global state +// of the application. +// +module.exports = +class AtomApplication extends EventEmitter { + // Public: The entry point into the Atom application. + static open (options) { + if (!options.socketPath) { + const username = process.platform === 'win32' ? process.env.USERNAME : process.env.USER + + // Lowercasing the ATOM_HOME to make sure that we don't get multiple sockets + // on case-insensitive filesystems due to arbitrary case differences in paths. + const atomHomeUnique = path.resolve(process.env.ATOM_HOME).toLowerCase() + const hash = crypto + .createHash('sha1') + .update(options.version) + .update('|') + .update(process.arch) + .update('|') + .update(username) + .update('|') + .update(atomHomeUnique) + + // We only keep the first 12 characters of the hash as not to have excessively long + // socket file. Note that macOS/BSD limit the length of socket file paths (see #15081). + // The replace calls convert the digest into "URL and Filename Safe" encoding (see RFC 4648). + const atomInstanceDigest = hash + .digest('base64') + .substring(0, 12) + .replace(/\+/g, '-') + .replace(/\//g, '_') + + if (process.platform === 'win32') { + options.socketPath = `\\\\.\\pipe\\atom-${atomInstanceDigest}-sock` + } else { + options.socketPath = path.join(os.tmpdir(), `atom-${atomInstanceDigest}.sock`) + } + } + + // FIXME: Sometimes when socketPath doesn't exist, net.connect would strangely + // take a few seconds to trigger 'error' event, it could be a bug of node + // or electron, before it's fixed we check the existence of socketPath to + // speedup startup. + if ((process.platform !== 'win32' && !fs.existsSync(options.socketPath)) || + options.test || options.benchmark || options.benchmarkTest) { + new AtomApplication(options).initialize(options) + return + } + + const client = net.connect({path: options.socketPath}, () => { + client.write(JSON.stringify(options), () => { + client.end() + app.quit() + }) + }) + + client.on('error', () => new AtomApplication(options).initialize(options)) + } + + exit (status) { + app.exit(status) + } + + constructor (options) { + super() + this.quitting = false + this.getAllWindows = this.getAllWindows.bind(this) + this.getLastFocusedWindow = this.getLastFocusedWindow.bind(this) + + this.resourcePath = options.resourcePath + this.devResourcePath = options.devResourcePath + this.version = options.version + this.devMode = options.devMode + this.safeMode = options.safeMode + this.socketPath = options.socketPath + this.logFile = options.logFile + this.userDataDir = options.userDataDir + this._killProcess = options.killProcess || process.kill.bind(process) + if (options.test || options.benchmark || options.benchmarkTest) this.socketPath = null + + this.waitSessionsByWindow = new Map() + this.windowStack = new WindowStack() + + this.config = new Config({enablePersistence: true}) + this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)}) + ConfigSchema.projectHome = { + type: 'string', + default: path.join(fs.getHomeDirectory(), 'github'), + description: + 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' + } + this.config.initialize({ + configDirPath: process.env.ATOM_HOME, + resourcePath: this.resourcePath, + projectHomeSchema: ConfigSchema.projectHome + }) + this.config.load() + + this.fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, 'recovery')) + this.storageFolder = new StorageFolder(process.env.ATOM_HOME) + this.autoUpdateManager = new AutoUpdateManager( + this.version, + options.test || options.benchmark || options.benchmarkTest, + this.config + ) + + this.disposable = new CompositeDisposable() + this.handleEvents() + } + + // This stuff was previously done in the constructor, but we want to be able to construct this object + // for testing purposes without booting up the world. As you add tests, feel free to move instantiation + // of these various sub-objects into the constructor, but you'll need to remove the side-effects they + // perform during their construction, adding an initialize method that you call here. + initialize (options) { + global.atomApplication = this + + // DEPRECATED: This can be removed at some point (added in 1.13) + // It converts `useCustomTitleBar: true` to `titleBar: "custom"` + if (process.platform === 'darwin' && this.config.get('core.useCustomTitleBar')) { + this.config.unset('core.useCustomTitleBar') + this.config.set('core.titleBar', 'custom') + } + + this.config.onDidChange('core.titleBar', this.promptForRestart.bind(this)) + + process.nextTick(() => this.autoUpdateManager.initialize()) + this.applicationMenu = new ApplicationMenu(this.version, this.autoUpdateManager) + this.atomProtocolHandler = new AtomProtocolHandler(this.resourcePath, this.safeMode) + + this.listenForArgumentsFromNewProcess() + this.setupDockMenu() + + return this.launch(options) + } + + async destroy () { + const windowsClosePromises = this.getAllWindows().map(window => { + window.close() + return window.closedPromise + }) + await Promise.all(windowsClosePromises) + this.disposable.dispose() + } + + launch (options) { + if (options.test || options.benchmark || options.benchmarkTest) { + return this.openWithOptions(options) + } else if ((options.pathsToOpen && options.pathsToOpen.length > 0) || + (options.urlsToOpen && options.urlsToOpen.length > 0)) { + if (this.config.get('core.restorePreviousWindowsOnStart') === 'always') { + this.loadState(_.deepClone(options)) + } + return this.openWithOptions(options) + } else { + return this.loadState(options) || this.openPath(options) + } + } + + openWithOptions (options) { + const { + initialPaths, + pathsToOpen, + executedFrom, + urlsToOpen, + benchmark, + benchmarkTest, + test, + pidToKillWhenClosed, + devMode, + safeMode, + newWindow, + logFile, + profileStartup, + timeout, + clearWindowState, + addToLastWindow, + env + } = options + + app.focus() + + if (test) { + return this.runTests({ + headless: true, + devMode, + resourcePath: this.resourcePath, + executedFrom, + pathsToOpen, + logFile, + timeout, + env + }) + } else if (benchmark || benchmarkTest) { + return this.runBenchmarks({ + headless: true, + test: benchmarkTest, + resourcePath: this.resourcePath, + executedFrom, + pathsToOpen, + timeout, + env + }) + } else if (pathsToOpen.length > 0) { + return this.openPaths({ + initialPaths, + pathsToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }) + } else if (urlsToOpen.length > 0) { + return urlsToOpen.map(urlToOpen => this.openUrl({urlToOpen, devMode, safeMode, env})) + } else { + // Always open a editor window if this is the first instance of Atom. + return this.openPath({ + initialPaths, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }) + } + } + + // Public: Removes the {AtomWindow} from the global window list. + removeWindow (window) { + this.windowStack.removeWindow(window) + if (this.getAllWindows().length === 0) { + if (this.applicationMenu != null) { + this.applicationMenu.enableWindowSpecificItems(false) + } + if (['win32', 'linux'].includes(process.platform)) { + app.quit() + return + } + } + if (!window.isSpec) this.saveState(true) + } + + // Public: Adds the {AtomWindow} to the global window list. + addWindow (window) { + this.windowStack.addWindow(window) + if (this.applicationMenu) this.applicationMenu.addWindow(window.browserWindow) + + window.once('window:loaded', () => { + this.autoUpdateManager && this.autoUpdateManager.emitUpdateAvailableEvent(window) + }) + + if (!window.isSpec) { + const focusHandler = () => this.windowStack.touch(window) + const blurHandler = () => this.saveState(false) + window.browserWindow.on('focus', focusHandler) + window.browserWindow.on('blur', blurHandler) + window.browserWindow.once('closed', () => { + this.windowStack.removeWindow(window) + window.browserWindow.removeListener('focus', focusHandler) + window.browserWindow.removeListener('blur', blurHandler) + }) + window.browserWindow.webContents.once('did-finish-load', blurHandler) + } + } + + getAllWindows () { + return this.windowStack.all().slice() + } + + getLastFocusedWindow (predicate) { + return this.windowStack.getLastFocusedWindow(predicate) + } + + // Creates server to listen for additional atom application launches. + // + // You can run the atom command multiple times, but after the first launch + // the other launches will just pass their information to this server and then + // close immediately. + listenForArgumentsFromNewProcess () { + if (!this.socketPath) return + + this.deleteSocketFile() + const server = net.createServer(connection => { + let data = '' + connection.on('data', chunk => { data += chunk }) + connection.on('end', () => this.openWithOptions(JSON.parse(data))) + }) + + server.listen(this.socketPath) + server.on('error', error => console.error('Application server failed', error)) + } + + deleteSocketFile () { + if (process.platform === 'win32' || !this.socketPath) return + + if (fs.existsSync(this.socketPath)) { + try { + fs.unlinkSync(this.socketPath) + } catch (error) { + // Ignore ENOENT errors in case the file was deleted between the exists + // check and the call to unlink sync. This occurred occasionally on CI + // which is why this check is here. + if (error.code !== 'ENOENT') throw error + } + } + } + + // Registers basic application commands, non-idempotent. + handleEvents () { + const getLoadSettings = () => { + const window = this.focusedWindow() + return {devMode: window && window.devMode, safeMode: window && window.safeMode} + } + + this.on('application:quit', () => app.quit()) + this.on('application:new-window', () => this.openPath(getLoadSettings())) + this.on('application:new-file', () => (this.focusedWindow() || this).openPath()) + this.on('application:open-dev', () => this.promptForPathToOpen('all', {devMode: true})) + this.on('application:open-safe', () => this.promptForPathToOpen('all', {safeMode: true})) + this.on('application:inspect', ({x, y, atomWindow}) => { + if (!atomWindow) atomWindow = this.focusedWindow() + if (atomWindow) atomWindow.browserWindow.inspectElement(x, y) + }) + + this.on('application:open-documentation', () => shell.openExternal('http://flight-manual.atom.io')) + this.on('application:open-discussions', () => shell.openExternal('https://discuss.atom.io')) + this.on('application:open-faq', () => shell.openExternal('https://atom.io/faq')) + this.on('application:open-terms-of-use', () => shell.openExternal('https://atom.io/terms')) + this.on('application:report-issue', () => shell.openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#reporting-bugs')) + this.on('application:search-issues', () => shell.openExternal('https://github.com/search?q=+is%3Aissue+user%3Aatom')) + + this.on('application:install-update', () => { + this.quitting = true + this.autoUpdateManager.install() + }) + + this.on('application:check-for-update', () => this.autoUpdateManager.check()) + + if (process.platform === 'darwin') { + this.on('application:bring-all-windows-to-front', () => Menu.sendActionToFirstResponder('arrangeInFront:')) + this.on('application:hide', () => Menu.sendActionToFirstResponder('hide:')) + this.on('application:hide-other-applications', () => Menu.sendActionToFirstResponder('hideOtherApplications:')) + this.on('application:minimize', () => Menu.sendActionToFirstResponder('performMiniaturize:')) + this.on('application:unhide-all-applications', () => Menu.sendActionToFirstResponder('unhideAllApplications:')) + this.on('application:zoom', () => Menu.sendActionToFirstResponder('zoom:')) + } else { + this.on('application:minimize', () => { + const window = this.focusedWindow() + if (window) window.minimize() + }) + this.on('application:zoom', function () { + const window = this.focusedWindow() + if (window) window.maximize() + }) + } + + this.openPathOnEvent('application:about', 'atom://about') + this.openPathOnEvent('application:show-settings', 'atom://config') + this.openPathOnEvent('application:open-your-config', 'atom://.atom/config') + this.openPathOnEvent('application:open-your-init-script', 'atom://.atom/init-script') + this.openPathOnEvent('application:open-your-keymap', 'atom://.atom/keymap') + this.openPathOnEvent('application:open-your-snippets', 'atom://.atom/snippets') + this.openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet') + this.openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) + + this.disposable.add(ipcHelpers.on(app, 'before-quit', async event => { + let resolveBeforeQuitPromise + this.lastBeforeQuitPromise = new Promise(resolve => { resolveBeforeQuitPromise = resolve }) + + if (!this.quitting) { + this.quitting = true + event.preventDefault() + const windowUnloadPromises = this.getAllWindows().map(window => window.prepareToUnload()) + const windowUnloadedResults = await Promise.all(windowUnloadPromises) + if (windowUnloadedResults.every(Boolean)) app.quit() + } + + resolveBeforeQuitPromise() + })) + + this.disposable.add(ipcHelpers.on(app, 'will-quit', () => { + this.killAllProcesses() + this.deleteSocketFile() + })) + + this.disposable.add(ipcHelpers.on(app, 'open-file', (event, pathToOpen) => { + event.preventDefault() + this.openPath({pathToOpen}) + })) + + this.disposable.add(ipcHelpers.on(app, 'open-url', (event, urlToOpen) => { + event.preventDefault() + this.openUrl({urlToOpen, devMode: this.devMode, safeMode: this.safeMode}) + })) + + this.disposable.add(ipcHelpers.on(app, 'activate', (event, hasVisibleWindows) => { + if (hasVisibleWindows) return + if (event) event.preventDefault() + this.emit('application:new-window') + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'restart-application', () => { + this.restart() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'resolve-proxy', (event, requestId, url) => { + event.sender.session.resolveProxy(url, proxy => { + if (!event.sender.isDestroyed()) event.sender.send('did-resolve-proxy', requestId, proxy) + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-change-history-manager', event => { + for (let atomWindow of this.getAllWindows()) { + const {webContents} = atomWindow.browserWindow + if (webContents !== event.sender) webContents.send('did-change-history-manager') + } + })) + + // A request from the associated render process to open a new render process. + this.disposable.add(ipcHelpers.on(ipcMain, 'open', (event, options) => { + const window = this.atomWindowForEvent(event) + if (options) { + if (typeof options.pathsToOpen === 'string') { + options.pathsToOpen = [options.pathsToOpen] + } + + if (options.pathsToOpen && options.pathsToOpen.length > 0) { + options.window = window + this.openPaths(options) + } else { + this.addWindow(new AtomWindow(this, this.fileRecoveryService, options)) + } + } else { + this.promptForPathToOpen('all', {window}) + } + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'update-application-menu', (event, template, menu) => { + const window = BrowserWindow.fromWebContents(event.sender) + if (this.applicationMenu) this.applicationMenu.update(window, template, menu) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'run-package-specs', (event, packageSpecPath) => { + this.runTests({ + resourcePath: this.devResourcePath, + pathsToOpen: [packageSpecPath], + headless: false + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'run-benchmarks', (event, benchmarksPath) => { + this.runBenchmarks({ + resourcePath: this.devResourcePath, + pathsToOpen: [benchmarksPath], + headless: false, + test: false + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'command', (event, command) => { + this.emit(command) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'open-command', (event, command, defaultPath) => { + switch (command) { + case 'application:open': + return this.promptForPathToOpen('all', getLoadSettings(), defaultPath) + case 'application:open-file': + return this.promptForPathToOpen('file', getLoadSettings(), defaultPath) + case 'application:open-folder': + return this.promptForPathToOpen('folder', getLoadSettings(), defaultPath) + default: + return console.log(`Invalid open-command received: ${command}`) + } + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'window-command', (event, command, ...args) => { + const window = BrowserWindow.fromWebContents(event.sender) + return window.emit(command, ...args) + })) + + this.disposable.add(ipcHelpers.respondTo('window-method', (browserWindow, method, ...args) => { + const window = this.atomWindowForBrowserWindow(browserWindow) + if (window) window[method](...args) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'pick-folder', (event, responseChannel) => { + this.promptForPath('folder', paths => event.sender.send(responseChannel, paths)) + })) + + this.disposable.add(ipcHelpers.respondTo('set-window-size', (window, width, height) => { + window.setSize(width, height) + })) + + this.disposable.add(ipcHelpers.respondTo('set-window-position', (window, x, y) => { + window.setPosition(x, y) + })) + + this.disposable.add(ipcHelpers.respondTo('center-window', window => window.center())) + this.disposable.add(ipcHelpers.respondTo('focus-window', window => window.focus())) + this.disposable.add(ipcHelpers.respondTo('show-window', window => window.show())) + this.disposable.add(ipcHelpers.respondTo('hide-window', window => window.hide())) + this.disposable.add(ipcHelpers.respondTo('get-temporary-window-state', window => window.temporaryState)) + + this.disposable.add(ipcHelpers.respondTo('set-temporary-window-state', (win, state) => { + win.temporaryState = state + })) + + const clipboard = require('../safe-clipboard') + this.disposable.add(ipcHelpers.on(ipcMain, 'write-text-to-selection-clipboard', (event, text) => + clipboard.writeText(text, 'selection') + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'write-to-stdout', (event, output) => + process.stdout.write(output) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'write-to-stderr', (event, output) => + process.stderr.write(output) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'add-recent-document', (event, filename) => + app.addRecentDocument(filename) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'execute-javascript-in-dev-tools', (event, code) => + event.sender.devToolsWebContents && event.sender.devToolsWebContents.executeJavaScript(code) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'get-auto-update-manager-state', event => { + event.returnValue = this.autoUpdateManager.getState() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'get-auto-update-manager-error', event => { + event.returnValue = this.autoUpdateManager.getErrorMessage() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'will-save-path', (event, path) => { + this.fileRecoveryService.willSavePath(this.atomWindowForEvent(event), path) + event.returnValue = true + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-save-path', (event, path) => { + this.fileRecoveryService.didSavePath(this.atomWindowForEvent(event), path) + event.returnValue = true + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-change-paths', () => + this.saveState(false) + )) + + this.disposable.add(this.disableZoomOnDisplayChange()) + } + + setupDockMenu () { + if (process.platform === 'darwin') { + return app.dock.setMenu(Menu.buildFromTemplate([ + {label: 'New Window', click: () => this.emit('application:new-window')} + ])) + } + } + + // Public: Executes the given command. + // + // If it isn't handled globally, delegate to the currently focused window. + // + // command - The string representing the command. + // args - The optional arguments to pass along. + sendCommand (command, ...args) { + if (!this.emit(command, ...args)) { + const focusedWindow = this.focusedWindow() + if (focusedWindow) { + return focusedWindow.sendCommand(command, ...args) + } else { + return this.sendCommandToFirstResponder(command) + } + } + } + + // Public: Executes the given command on the given window. + // + // command - The string representing the command. + // atomWindow - The {AtomWindow} to send the command to. + // args - The optional arguments to pass along. + sendCommandToWindow (command, atomWindow, ...args) { + if (!this.emit(command, ...args)) { + if (atomWindow) { + return atomWindow.sendCommand(command, ...args) + } else { + return this.sendCommandToFirstResponder(command) + } + } + } + + // Translates the command into macOS action and sends it to application's first + // responder. + sendCommandToFirstResponder (command) { + if (process.platform !== 'darwin') return false + + switch (command) { + case 'core:undo': + Menu.sendActionToFirstResponder('undo:') + break + case 'core:redo': + Menu.sendActionToFirstResponder('redo:') + break + case 'core:copy': + Menu.sendActionToFirstResponder('copy:') + break + case 'core:cut': + Menu.sendActionToFirstResponder('cut:') + break + case 'core:paste': + Menu.sendActionToFirstResponder('paste:') + break + case 'core:select-all': + Menu.sendActionToFirstResponder('selectAll:') + break + default: + return false + } + return true + } + + // Public: Open the given path in the focused window when the event is + // triggered. + // + // A new window will be created if there is no currently focused window. + // + // eventName - The event to listen for. + // pathToOpen - The path to open when the event is triggered. + openPathOnEvent (eventName, pathToOpen) { + this.on(eventName, () => { + const window = this.focusedWindow() + if (window) { + return window.openPath(pathToOpen) + } else { + return this.openPath({pathToOpen}) + } + }) + } + + // Returns the {AtomWindow} for the given paths. + windowForPaths (pathsToOpen, devMode) { + return this.getAllWindows().find(window => + window.devMode === devMode && window.containsPaths(pathsToOpen) + ) + } + + // Returns the {AtomWindow} for the given ipcMain event. + atomWindowForEvent ({sender}) { + return this.atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender)) + } + + atomWindowForBrowserWindow (browserWindow) { + return this.getAllWindows().find(atomWindow => atomWindow.browserWindow === browserWindow) + } + + // Public: Returns the currently focused {AtomWindow} or undefined if none. + focusedWindow () { + return this.getAllWindows().find(window => window.isFocused()) + } + + // Get the platform-specific window offset for new windows. + getWindowOffsetForCurrentPlatform () { + const offsetByPlatform = { + darwin: 22, + win32: 26 + } + return offsetByPlatform[process.platform] || 0 + } + + // Get the dimensions for opening a new window by cascading as appropriate to + // the platform. + getDimensionsForNewWindow () { + const window = this.focusedWindow() || this.getLastFocusedWindow() + if (!window || window.isMaximized()) return + const dimensions = window.getDimensions() + if (dimensions) { + const offset = this.getWindowOffsetForCurrentPlatform() + dimensions.x += offset + dimensions.y += offset + return dimensions + } + } + + // Public: Opens a single path, in an existing window if possible. + // + // options - + // :pathToOpen - The file path to open + // :pidToKillWhenClosed - The integer of the pid to kill + // :newWindow - Boolean of whether this should be opened in a new window. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + // :profileStartup - Boolean to control creating a profile of the startup time. + // :window - {AtomWindow} to open file paths in. + // :addToLastWindow - Boolean of whether this should be opened in last focused window. + openPath ({ + initialPaths, + pathToOpen, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + return this.openPaths({ + initialPaths, + pathsToOpen: [pathToOpen], + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + }) + } + + // Public: Opens multiple paths, in existing windows if possible. + // + // options - + // :pathsToOpen - The array of file paths to open + // :pidToKillWhenClosed - The integer of the pid to kill + // :newWindow - Boolean of whether this should be opened in a new window. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + // :windowDimensions - Object with height and width keys. + // :window - {AtomWindow} to open file paths in. + // :addToLastWindow - Boolean of whether this should be opened in last focused window. + openPaths ({ + initialPaths, + pathsToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + windowDimensions, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + if (!pathsToOpen || pathsToOpen.length === 0) return + if (!env) env = process.env + devMode = Boolean(devMode) + safeMode = Boolean(safeMode) + clearWindowState = Boolean(clearWindowState) + + const locationsToOpen = [] + for (let i = 0; i < pathsToOpen.length; i++) { + const location = this.parsePathToOpen(pathsToOpen[i], executedFrom, addToLastWindow) + location.forceAddToWindow = addToLastWindow + location.hasWaitSession = pidToKillWhenClosed != null + locationsToOpen.push(location) + pathsToOpen[i] = location.pathToOpen + } + + let existingWindow + if (!newWindow) { + existingWindow = this.windowForPaths(pathsToOpen, devMode) + const stats = pathsToOpen.map(pathToOpen => fs.statSyncNoException(pathToOpen)) + if (!existingWindow) { + let lastWindow = window || this.getLastFocusedWindow() + if (lastWindow && lastWindow.devMode === devMode) { + if (addToLastWindow || ( + stats.every(s => s.isFile && s.isFile()) || + (stats.some(s => s.isDirectory && s.isDirectory()) && !lastWindow.hasProjectPath()))) { + existingWindow = lastWindow + } + } + } + } + + let openedWindow + if (existingWindow) { + openedWindow = existingWindow + openedWindow.openLocations(locationsToOpen) + if (openedWindow.isMinimized()) { + openedWindow.restore() + } else { + openedWindow.focus() + } + openedWindow.replaceEnvironment(env) + } else { + let resourcePath, windowInitializationScript + if (devMode) { + try { + windowInitializationScript = require.resolve( + path.join(this.devResourcePath, 'src', 'initialize-application-window') + ) + resourcePath = this.devResourcePath + } catch (error) {} + } + + if (!windowInitializationScript) { + windowInitializationScript = require.resolve('../initialize-application-window') + } + if (!resourcePath) resourcePath = this.resourcePath + if (!windowDimensions) windowDimensions = this.getDimensionsForNewWindow() + openedWindow = new AtomWindow(this, this.fileRecoveryService, { + initialPaths, + locationsToOpen, + windowInitializationScript, + resourcePath, + devMode, + safeMode, + windowDimensions, + profileStartup, + clearWindowState, + env + }) + this.addWindow(openedWindow) + openedWindow.focus() + } + + if (pidToKillWhenClosed != null) { + if (!this.waitSessionsByWindow.has(openedWindow)) { + this.waitSessionsByWindow.set(openedWindow, []) + } + this.waitSessionsByWindow.get(openedWindow).push({ + pid: pidToKillWhenClosed, + remainingPaths: new Set(pathsToOpen) + }) + } + + openedWindow.browserWindow.once('closed', () => this.killProcessesForWindow(openedWindow)) + return openedWindow + } + + // Kill all processes associated with opened windows. + killAllProcesses () { + for (let window of this.waitSessionsByWindow.keys()) { + this.killProcessesForWindow(window) + } + } + + killProcessesForWindow (window) { + const sessions = this.waitSessionsByWindow.get(window) + if (!sessions) return + for (const session of sessions) { + this.killProcess(session.pid) + } + this.waitSessionsByWindow.delete(window) + } + + windowDidClosePathWithWaitSession (window, initialPath) { + const waitSessions = this.waitSessionsByWindow.get(window) + if (!waitSessions) return + for (let i = waitSessions.length - 1; i >= 0; i--) { + const session = waitSessions[i] + session.remainingPaths.delete(initialPath) + if (session.remainingPaths.size === 0) { + this.killProcess(session.pid) + waitSessions.splice(i, 1) + } + } + } + + // Kill the process with the given pid. + killProcess (pid) { + try { + const parsedPid = parseInt(pid) + if (isFinite(parsedPid)) this._killProcess(parsedPid) + } catch (error) { + if (error.code !== 'ESRCH') { + console.log(`Killing process ${pid} failed: ${error.code != null ? error.code : error.message}`) + } + } + } + + saveState (allowEmpty = false) { + if (this.quitting) return + + const states = [] + for (let window of this.getAllWindows()) { + if (!window.isSpec) states.push({initialPaths: window.representedDirectoryPaths}) + } + states.reverse() + + if (states.length > 0 || allowEmpty) { + this.storageFolder.storeSync('application.json', states) + this.emit('application:did-save-state') + } + } + + loadState (options) { + const states = this.storageFolder.load('application.json') + if ( + ['yes', 'always'].includes(this.config.get('core.restorePreviousWindowsOnStart')) && + states && states.length > 0 + ) { + return states.map(state => + this.openWithOptions(Object.assign(options, { + initialPaths: state.initialPaths, + pathsToOpen: state.initialPaths.filter(p => fs.isDirectorySync(p)), + urlsToOpen: [], + devMode: this.devMode, + safeMode: this.safeMode + })) + ) + } else { + return null + } + } + + // Open an atom:// url. + // + // The host of the URL being opened is assumed to be the package name + // responsible for opening the URL. A new window will be created with + // that package's `urlMain` as the bootstrap script. + // + // options - + // :urlToOpen - The atom:// url to open. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + openUrl ({urlToOpen, devMode, safeMode, env}) { + const parsedUrl = url.parse(urlToOpen, true) + if (parsedUrl.protocol !== 'atom:') return + + const pack = this.findPackageWithName(parsedUrl.host, devMode) + if (pack && pack.urlMain) { + return this.openPackageUrlMain( + parsedUrl.host, + pack.urlMain, + urlToOpen, + devMode, + safeMode, + env + ) + } else { + return this.openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env) + } + } + + openPackageUriHandler (url, parsedUrl, devMode, safeMode, env) { + let bestWindow + + if (parsedUrl.host === 'core') { + const predicate = require('../core-uri-handlers').windowPredicate(parsedUrl) + bestWindow = this.getLastFocusedWindow(win => !win.isSpecWindow() && predicate(win)) + } + + if (!bestWindow) bestWindow = this.getLastFocusedWindow(win => !win.isSpecWindow()) + + if (bestWindow) { + bestWindow.sendURIMessage(url) + bestWindow.focus() + } else { + let windowInitializationScript + let {resourcePath} = this + if (devMode) { + try { + windowInitializationScript = require.resolve( + path.join(this.devResourcePath, 'src', 'initialize-application-window') + ) + resourcePath = this.devResourcePath + } catch (error) {} + } + + if (!windowInitializationScript) { + windowInitializationScript = require.resolve('../initialize-application-window') + } + + const windowDimensions = this.getDimensionsForNewWindow() + const window = new AtomWindow(this, this.fileRecoveryService, { + resourcePath, + windowInitializationScript, + devMode, + safeMode, + windowDimensions, + env + }) + this.addWindow(window) + window.on('window:loaded', () => window.sendURIMessage(url)) + return window + } + } + + findPackageWithName (packageName, devMode) { + return this.getPackageManager(devMode).getAvailablePackageMetadata().find(({name}) => + name === packageName + ) + } + + openPackageUrlMain (packageName, packageUrlMain, urlToOpen, devMode, safeMode, env) { + const packagePath = this.getPackageManager(devMode).resolvePackagePath(packageName) + const windowInitializationScript = path.resolve(packagePath, packageUrlMain) + const windowDimensions = this.getDimensionsForNewWindow() + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath: this.resourcePath, + devMode, + safeMode, + urlToOpen, + windowDimensions, + env + }) + this.addWindow(window) + return window + } + + getPackageManager (devMode) { + if (this.packages == null) { + const PackageManager = require('../package-manager') + this.packages = new PackageManager({}) + this.packages.initialize({ + configDirPath: process.env.ATOM_HOME, + devMode, + resourcePath: this.resourcePath + }) + } + + return this.packages + } + + // Opens up a new {AtomWindow} to run specs within. + // + // options - + // :headless - A Boolean that, if true, will close the window upon + // completion. + // :resourcePath - The path to include specs from. + // :specPath - The directory to load specs from. + // :safeMode - A Boolean that, if true, won't run specs from ~/.atom/packages + // and ~/.atom/dev/packages, defaults to false. + runTests ({headless, resourcePath, executedFrom, pathsToOpen, logFile, safeMode, timeout, env}) { + let windowInitializationScript + if (resourcePath !== this.resourcePath && !fs.existsSync(resourcePath)) { + ;({resourcePath} = this) + } + + const timeoutInSeconds = Number.parseFloat(timeout) + if (!Number.isNaN(timeoutInSeconds)) { + const timeoutHandler = function () { + console.log( + `The test suite has timed out because it has been running for more than ${timeoutInSeconds} seconds.` + ) + return process.exit(124) // Use the same exit code as the UNIX timeout util. + } + setTimeout(timeoutHandler, timeoutInSeconds * 1000) + } + + try { + windowInitializationScript = require.resolve( + path.resolve(this.devResourcePath, 'src', 'initialize-test-window') + ) + } catch (error) { + windowInitializationScript = require.resolve( + path.resolve(__dirname, '..', '..', 'src', 'initialize-test-window') + ) + } + + const testPaths = [] + if (pathsToOpen != null) { + for (let pathToOpen of pathsToOpen) { + testPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) + } + } + + if (testPaths.length === 0) { + process.stderr.write('Error: Specify at least one test path\n\n') + process.exit(1) + } + + const legacyTestRunnerPath = this.resolveLegacyTestRunnerPath() + const testRunnerPath = this.resolveTestRunnerPath(testPaths[0]) + const devMode = true + const isSpec = true + if (safeMode == null) { + safeMode = false + } + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath, + headless, + isSpec, + devMode, + testRunnerPath, + legacyTestRunnerPath, + testPaths, + logFile, + safeMode, + env + }) + this.addWindow(window) + return window + } + + runBenchmarks ({headless, test, resourcePath, executedFrom, pathsToOpen, env}) { + let windowInitializationScript + if (resourcePath !== this.resourcePath && !fs.existsSync(resourcePath)) { + ;({resourcePath} = this) + } + + try { + windowInitializationScript = require.resolve( + path.resolve(this.devResourcePath, 'src', 'initialize-benchmark-window') + ) + } catch (error) { + windowInitializationScript = require.resolve( + path.resolve(__dirname, '..', '..', 'src', 'initialize-benchmark-window') + ) + } + + const benchmarkPaths = [] + if (pathsToOpen != null) { + for (let pathToOpen of pathsToOpen) { + benchmarkPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) + } + } + + if (benchmarkPaths.length === 0) { + process.stderr.write('Error: Specify at least one benchmark path.\n\n') + process.exit(1) + } + + const devMode = true + const isSpec = true + const safeMode = false + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath, + headless, + test, + isSpec, + devMode, + benchmarkPaths, + safeMode, + env + }) + this.addWindow(window) + return window + } + + resolveTestRunnerPath (testPath) { + let packageRoot + if (FindParentDir == null) { + FindParentDir = require('find-parent-dir') + } + + if ((packageRoot = FindParentDir.sync(testPath, 'package.json'))) { + const packageMetadata = require(path.join(packageRoot, 'package.json')) + if (packageMetadata.atomTestRunner) { + let testRunnerPath + if (Resolve == null) { + Resolve = require('resolve') + } + if ( + (testRunnerPath = Resolve.sync(packageMetadata.atomTestRunner, { + basedir: packageRoot, + extensions: Object.keys(require.extensions) + })) + ) { + return testRunnerPath + } else { + process.stderr.write( + `Error: Could not resolve test runner path '${packageMetadata.atomTestRunner}'` + ) + process.exit(1) + } + } + } + + return this.resolveLegacyTestRunnerPath() + } + + resolveLegacyTestRunnerPath () { + try { + return require.resolve(path.resolve(this.devResourcePath, 'spec', 'jasmine-test-runner')) + } catch (error) { + return require.resolve(path.resolve(__dirname, '..', '..', 'spec', 'jasmine-test-runner')) + } + } + + parsePathToOpen (pathToOpen, executedFrom = '') { + let initialColumn, initialLine + if (!pathToOpen) { + return {pathToOpen} + } + + pathToOpen = pathToOpen.replace(/[:\s]+$/, '') + const match = pathToOpen.match(LocationSuffixRegExp) + + if (match != null) { + pathToOpen = pathToOpen.slice(0, -match[0].length) + if (match[1]) { + initialLine = Math.max(0, parseInt(match[1].slice(1)) - 1) + } + if (match[2]) { + initialColumn = Math.max(0, parseInt(match[2].slice(1)) - 1) + } + } else { + initialLine = initialColumn = null + } + + if (url.parse(pathToOpen).protocol == null) { + pathToOpen = path.resolve(executedFrom, fs.normalize(pathToOpen)) + } + + return {pathToOpen, initialLine, initialColumn} + } + + // Opens a native dialog to prompt the user for a path. + // + // Once paths are selected, they're opened in a new or existing {AtomWindow}s. + // + // options - + // :type - A String which specifies the type of the dialog, could be 'file', + // 'folder' or 'all'. The 'all' is only available on macOS. + // :devMode - A Boolean which controls whether any newly opened windows + // should be in dev mode or not. + // :safeMode - A Boolean which controls whether any newly opened windows + // should be in safe mode or not. + // :window - An {AtomWindow} to use for opening a selected file path. + // :path - An optional String which controls the default path to which the + // file dialog opens. + promptForPathToOpen (type, {devMode, safeMode, window}, path = null) { + return this.promptForPath( + type, + pathsToOpen => { + return this.openPaths({pathsToOpen, devMode, safeMode, window}) + }, + path + ) + } + + promptForPath (type, callback, path) { + const properties = (() => { + switch (type) { + case 'file': return ['openFile'] + case 'folder': return ['openDirectory'] + case 'all': return ['openFile', 'openDirectory'] + default: throw new Error(`${type} is an invalid type for promptForPath`) + } + })() + + // Show the open dialog as child window on Windows and Linux, and as + // independent dialog on macOS. This matches most native apps. + const parentWindow = process.platform === 'darwin' ? null : BrowserWindow.getFocusedWindow() + + const openOptions = { + properties: properties.concat(['multiSelections', 'createDirectory']), + title: (() => { + switch (type) { + case 'file': return 'Open File' + case 'folder': return 'Open Folder' + default: return 'Open' + } + })() + } + + // File dialog defaults to project directory of currently active editor + if (path) openOptions.defaultPath = path + return dialog.showOpenDialog(parentWindow, openOptions, callback) + } + + promptForRestart () { + const chosen = dialog.showMessageBox(BrowserWindow.getFocusedWindow(), { + type: 'warning', + title: 'Restart required', + message: 'You will need to restart Atom for this change to take effect.', + buttons: ['Restart Atom', 'Cancel'] + }) + if (chosen === 0) return this.restart() + } + + restart () { + const args = [] + if (this.safeMode) args.push('--safe') + if (this.logFile != null) args.push(`--log-file=${this.logFile}`) + if (this.socketPath != null) args.push(`--socket-path=${this.socketPath}`) + if (this.userDataDir != null) args.push(`--user-data-dir=${this.userDataDir}`) + if (this.devMode) { + args.push('--dev') + args.push(`--resource-path=${this.resourcePath}`) + } + app.relaunch({args}) + app.quit() + } + + disableZoomOnDisplayChange () { + const callback = () => { + this.getAllWindows().map(window => window.disableZoom()) + } + + // Set the limits every time a display is added or removed, otherwise the + // configuration gets reset to the default, which allows zooming the + // webframe. + screen.on('display-added', callback) + screen.on('display-removed', callback) + return new Disposable(() => { + screen.removeListener('display-added', callback) + screen.removeListener('display-removed', callback) + }) + } +} + +class WindowStack { + constructor (windows = []) { + this.addWindow = this.addWindow.bind(this) + this.touch = this.touch.bind(this) + this.removeWindow = this.removeWindow.bind(this) + this.getLastFocusedWindow = this.getLastFocusedWindow.bind(this) + this.all = this.all.bind(this) + this.windows = windows + } + + addWindow (window) { + this.removeWindow(window) + return this.windows.unshift(window) + } + + touch (window) { + return this.addWindow(window) + } + + removeWindow (window) { + const currentIndex = this.windows.indexOf(window) + if (currentIndex > -1) { + return this.windows.splice(currentIndex, 1) + } + } + + getLastFocusedWindow (predicate) { + if (predicate == null) { + predicate = win => true + } + return this.windows.find(predicate) + } + + all () { + return this.windows + } +} diff --git a/src/main-process/atom-protocol-handler.coffee b/src/main-process/atom-protocol-handler.coffee deleted file mode 100644 index db385b4b7..000000000 --- a/src/main-process/atom-protocol-handler.coffee +++ /dev/null @@ -1,43 +0,0 @@ -{protocol} = require 'electron' -fs = require 'fs' -path = require 'path' - -# Handles requests with 'atom' protocol. -# -# It's created by {AtomApplication} upon instantiation and is used to create a -# custom resource loader for 'atom://' URLs. -# -# The following directories are searched in order: -# * ~/.atom/assets -# * ~/.atom/dev/packages (unless in safe mode) -# * ~/.atom/packages -# * RESOURCE_PATH/node_modules -# -module.exports = -class AtomProtocolHandler - constructor: (resourcePath, safeMode) -> - @loadPaths = [] - - unless safeMode - @loadPaths.push(path.join(process.env.ATOM_HOME, 'dev', 'packages')) - - @loadPaths.push(path.join(process.env.ATOM_HOME, 'packages')) - @loadPaths.push(path.join(resourcePath, 'node_modules')) - - @registerAtomProtocol() - - # Creates the 'atom' custom protocol handler. - registerAtomProtocol: -> - protocol.registerFileProtocol 'atom', (request, callback) => - relativePath = path.normalize(request.url.substr(7)) - - if relativePath.indexOf('assets/') is 0 - assetsPath = path.join(process.env.ATOM_HOME, relativePath) - filePath = assetsPath if fs.statSyncNoException(assetsPath).isFile?() - - unless filePath - for loadPath in @loadPaths - filePath = path.join(loadPath, relativePath) - break if fs.statSyncNoException(filePath).isFile?() - - callback(filePath) diff --git a/src/main-process/atom-protocol-handler.js b/src/main-process/atom-protocol-handler.js new file mode 100644 index 000000000..1affba02a --- /dev/null +++ b/src/main-process/atom-protocol-handler.js @@ -0,0 +1,54 @@ +const {protocol} = require('electron') +const fs = require('fs') +const path = require('path') + +// Handles requests with 'atom' protocol. +// +// It's created by {AtomApplication} upon instantiation and is used to create a +// custom resource loader for 'atom://' URLs. +// +// The following directories are searched in order: +// * ~/.atom/assets +// * ~/.atom/dev/packages (unless in safe mode) +// * ~/.atom/packages +// * RESOURCE_PATH/node_modules +// +module.exports = +class AtomProtocolHandler { + constructor (resourcePath, safeMode) { + this.loadPaths = [] + + if (!safeMode) { + this.loadPaths.push(path.join(process.env.ATOM_HOME, 'dev', 'packages')) + } + + this.loadPaths.push(path.join(process.env.ATOM_HOME, 'packages')) + this.loadPaths.push(path.join(resourcePath, 'node_modules')) + + this.registerAtomProtocol() + } + + // Creates the 'atom' custom protocol handler. + registerAtomProtocol () { + protocol.registerFileProtocol('atom', (request, callback) => { + const relativePath = path.normalize(request.url.substr(7)) + + let filePath + if (relativePath.indexOf('assets/') === 0) { + const assetsPath = path.join(process.env.ATOM_HOME, relativePath) + const stat = fs.statSyncNoException(assetsPath) + if (stat && stat.isFile()) filePath = assetsPath + } + + if (!filePath) { + for (let loadPath of this.loadPaths) { + filePath = path.join(loadPath, relativePath) + const stat = fs.statSyncNoException(filePath) + if (stat && stat.isFile()) break + } + } + + callback(filePath) + }) + } +} diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee deleted file mode 100644 index 3a8c7cdc6..000000000 --- a/src/main-process/atom-window.coffee +++ /dev/null @@ -1,329 +0,0 @@ -{BrowserWindow, app, dialog, ipcMain} = require 'electron' -path = require 'path' -fs = require 'fs' -url = require 'url' -{EventEmitter} = require 'events' - -module.exports = -class AtomWindow - Object.assign @prototype, EventEmitter.prototype - - @iconPath: path.resolve(__dirname, '..', '..', 'resources', 'atom.png') - @includeShellLoadTime: true - - browserWindow: null - loaded: null - isSpec: null - - constructor: (@atomApplication, @fileRecoveryService, settings={}) -> - {@resourcePath, pathToOpen, locationsToOpen, @isSpec, @headless, @safeMode, @devMode} = settings - locationsToOpen ?= [{pathToOpen}] if pathToOpen - locationsToOpen ?= [] - - @loadedPromise = new Promise((@resolveLoadedPromise) =>) - @closedPromise = new Promise((@resolveClosedPromise) =>) - - options = - show: false - title: 'Atom' - tabbingIdentifier: 'atom' - webPreferences: - # Prevent specs from throttling when the window is in the background: - # this should result in faster CI builds, and an improvement in the - # local development experience when running specs through the UI (which - # now won't pause when e.g. minimizing the window). - backgroundThrottling: not @isSpec - # Disable the `auxclick` feature so that `click` events are triggered in - # response to a middle-click. - # (Ref: https://github.com/atom/atom/pull/12696#issuecomment-290496960) - disableBlinkFeatures: 'Auxclick' - - # Don't set icon on Windows so the exe's ico will be used as window and - # taskbar's icon. See https://github.com/atom/atom/issues/4811 for more. - if process.platform is 'linux' - options.icon = @constructor.iconPath - - if @shouldAddCustomTitleBar() - options.titleBarStyle = 'hidden' - - if @shouldAddCustomInsetTitleBar() - options.titleBarStyle = 'hidden-inset' - - if @shouldHideTitleBar() - options.frame = false - - @browserWindow = new BrowserWindow(options) - @handleEvents() - - @loadSettings = Object.assign({}, settings) - @loadSettings.appVersion = app.getVersion() - @loadSettings.resourcePath = @resourcePath - @loadSettings.devMode ?= false - @loadSettings.safeMode ?= false - @loadSettings.atomHome = process.env.ATOM_HOME - @loadSettings.clearWindowState ?= false - @loadSettings.initialPaths ?= - for {pathToOpen} in locationsToOpen when pathToOpen - stat = fs.statSyncNoException(pathToOpen) or null - if stat?.isDirectory() - pathToOpen - else - parentDirectory = path.dirname(pathToOpen) - if stat?.isFile() or fs.existsSync(parentDirectory) - parentDirectory - else - pathToOpen - @loadSettings.initialPaths.sort() - - # Only send to the first non-spec window created - if @constructor.includeShellLoadTime and not @isSpec - @constructor.includeShellLoadTime = false - @loadSettings.shellLoadTime ?= Date.now() - global.shellStartTime - - @representedDirectoryPaths = @loadSettings.initialPaths - @env = @loadSettings.env if @loadSettings.env? - - @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) - - @browserWindow.on 'window:loaded', => - @disableZoom() - @emit 'window:loaded' - @resolveLoadedPromise() - - @browserWindow.on 'window:locations-opened', => - @emit 'window:locations-opened' - - @browserWindow.on 'enter-full-screen', => - @browserWindow.webContents.send('did-enter-full-screen') - - @browserWindow.on 'leave-full-screen', => - @browserWindow.webContents.send('did-leave-full-screen') - - @browserWindow.loadURL url.format - protocol: 'file' - pathname: "#{@resourcePath}/static/index.html" - slashes: true - - @browserWindow.showSaveDialog = @showSaveDialog.bind(this) - - @browserWindow.focusOnWebView() if @isSpec - @browserWindow.temporaryState = {windowDimensions} if windowDimensions? - - hasPathToOpen = not (locationsToOpen.length is 1 and not locationsToOpen[0].pathToOpen?) - @openLocations(locationsToOpen) if hasPathToOpen and not @isSpecWindow() - - @atomApplication.addWindow(this) - - hasProjectPath: -> @representedDirectoryPaths.length > 0 - - setupContextMenu: -> - ContextMenu = require './context-menu' - - @browserWindow.on 'context-menu', (menuTemplate) => - new ContextMenu(menuTemplate, this) - - containsPaths: (paths) -> - for pathToCheck in paths - return false unless @containsPath(pathToCheck) - true - - containsPath: (pathToCheck) -> - @representedDirectoryPaths.some (projectPath) -> - if not projectPath - false - else if not pathToCheck - false - else if pathToCheck is projectPath - true - else if fs.statSyncNoException(pathToCheck).isDirectory?() - false - else if pathToCheck.indexOf(path.join(projectPath, path.sep)) is 0 - true - else - false - - handleEvents: -> - @browserWindow.on 'close', (event) => - unless @atomApplication.quitting or @unloading - event.preventDefault() - @unloading = true - @atomApplication.saveState(false) - @prepareToUnload().then (result) => - @close() if result - - @browserWindow.on 'closed', => - @fileRecoveryService.didCloseWindow(this) - @atomApplication.removeWindow(this) - @resolveClosedPromise() - - @browserWindow.on 'unresponsive', => - return if @isSpec - - chosen = dialog.showMessageBox @browserWindow, - type: 'warning' - buttons: ['Force Close', 'Keep Waiting'] - message: 'Editor is not responding' - detail: 'The editor is not responding. Would you like to force close it or just keep waiting?' - @browserWindow.destroy() if chosen is 0 - - @browserWindow.webContents.on 'crashed', => - if @headless - console.log "Renderer process crashed, exiting" - @atomApplication.exit(100) - return - - @fileRecoveryService.didCrashWindow(this) - chosen = dialog.showMessageBox @browserWindow, - type: 'warning' - buttons: ['Close Window', 'Reload', 'Keep It Open'] - message: 'The editor has crashed' - detail: 'Please report this issue to https://github.com/atom/atom' - switch chosen - when 0 then @browserWindow.destroy() - when 1 then @browserWindow.reload() - - @browserWindow.webContents.on 'will-navigate', (event, url) => - unless url is @browserWindow.webContents.getURL() - event.preventDefault() - - @setupContextMenu() - - if @isSpec - # Spec window's web view should always have focus - @browserWindow.on 'blur', => - @browserWindow.focusOnWebView() - - prepareToUnload: -> - if @isSpecWindow() - return Promise.resolve(true) - @lastPrepareToUnloadPromise = new Promise (resolve) => - callback = (event, result) => - if BrowserWindow.fromWebContents(event.sender) is @browserWindow - ipcMain.removeListener('did-prepare-to-unload', callback) - unless result - @unloading = false - @atomApplication.quitting = false - resolve(result) - ipcMain.on('did-prepare-to-unload', callback) - @browserWindow.webContents.send('prepare-to-unload') - - openPath: (pathToOpen, initialLine, initialColumn) -> - @openLocations([{pathToOpen, initialLine, initialColumn}]) - - openLocations: (locationsToOpen) -> - @loadedPromise.then => @sendMessage 'open-locations', locationsToOpen - - replaceEnvironment: (env) -> - @browserWindow.webContents.send 'environment', env - - sendMessage: (message, detail) -> - @browserWindow.webContents.send 'message', message, detail - - sendCommand: (command, args...) -> - if @isSpecWindow() - unless @atomApplication.sendCommandToFirstResponder(command) - switch command - when 'window:reload' then @reload() - when 'window:toggle-dev-tools' then @toggleDevTools() - when 'window:close' then @close() - else if @isWebViewFocused() - @sendCommandToBrowserWindow(command, args...) - else - unless @atomApplication.sendCommandToFirstResponder(command) - @sendCommandToBrowserWindow(command, args...) - - sendURIMessage: (uri) -> - @browserWindow.webContents.send 'uri-message', uri - - sendCommandToBrowserWindow: (command, args...) -> - action = if args[0]?.contextCommand then 'context-command' else 'command' - @browserWindow.webContents.send action, command, args... - - getDimensions: -> - [x, y] = @browserWindow.getPosition() - [width, height] = @browserWindow.getSize() - {x, y, width, height} - - shouldAddCustomTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'custom' - - shouldAddCustomInsetTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'custom-inset' - - shouldHideTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'hidden' - - close: -> @browserWindow.close() - - focus: -> @browserWindow.focus() - - minimize: -> @browserWindow.minimize() - - maximize: -> @browserWindow.maximize() - - unmaximize: -> @browserWindow.unmaximize() - - restore: -> @browserWindow.restore() - - setFullScreen: (fullScreen) -> @browserWindow.setFullScreen(fullScreen) - - setAutoHideMenuBar: (autoHideMenuBar) -> @browserWindow.setAutoHideMenuBar(autoHideMenuBar) - - handlesAtomCommands: -> - not @isSpecWindow() and @isWebViewFocused() - - isFocused: -> @browserWindow.isFocused() - - isMaximized: -> @browserWindow.isMaximized() - - isMinimized: -> @browserWindow.isMinimized() - - isWebViewFocused: -> @browserWindow.isWebViewFocused() - - isSpecWindow: -> @isSpec - - reload: -> - @loadedPromise = new Promise((@resolveLoadedPromise) =>) - @prepareToUnload().then (result) => - @browserWindow.reload() if result - @loadedPromise - - showSaveDialog: (options, callback) -> - options = Object.assign({ - title: 'Save File', - defaultPath: @representedDirectoryPaths[0] - }, options) - - if callback? - # Async - dialog.showSaveDialog(@browserWindow, options, callback) - else - # Sync - dialog.showSaveDialog(@browserWindow, options) - - toggleDevTools: -> @browserWindow.toggleDevTools() - - openDevTools: -> @browserWindow.openDevTools() - - closeDevTools: -> @browserWindow.closeDevTools() - - setDocumentEdited: (documentEdited) -> @browserWindow.setDocumentEdited(documentEdited) - - setRepresentedFilename: (representedFilename) -> @browserWindow.setRepresentedFilename(representedFilename) - - setRepresentedDirectoryPaths: (@representedDirectoryPaths) -> - @representedDirectoryPaths.sort() - @loadSettings.initialPaths = @representedDirectoryPaths - @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) - @atomApplication.saveState() - - copy: -> @browserWindow.copy() - - disableZoom: -> - @browserWindow.webContents.setVisualZoomLevelLimits(1, 1) diff --git a/src/main-process/atom-window.js b/src/main-process/atom-window.js new file mode 100644 index 000000000..0268cc1cf --- /dev/null +++ b/src/main-process/atom-window.js @@ -0,0 +1,432 @@ +const {BrowserWindow, app, dialog, ipcMain} = require('electron') +const path = require('path') +const fs = require('fs') +const url = require('url') +const {EventEmitter} = require('events') + +const ICON_PATH = path.resolve(__dirname, '..', '..', 'resources', 'atom.png') + +let includeShellLoadTime = true +let nextId = 0 + +module.exports = +class AtomWindow extends EventEmitter { + constructor (atomApplication, fileRecoveryService, settings = {}) { + super() + + this.id = nextId++ + this.atomApplication = atomApplication + this.fileRecoveryService = fileRecoveryService + this.isSpec = settings.isSpec + this.headless = settings.headless + this.safeMode = settings.safeMode + this.devMode = settings.devMode + this.resourcePath = settings.resourcePath + + let {pathToOpen, locationsToOpen} = settings + if (!locationsToOpen && pathToOpen) locationsToOpen = [{pathToOpen}] + if (!locationsToOpen) locationsToOpen = [] + + this.loadedPromise = new Promise(resolve => { this.resolveLoadedPromise = resolve }) + this.closedPromise = new Promise(resolve => { this.resolveClosedPromise = resolve }) + + const options = { + show: false, + title: 'Atom', + tabbingIdentifier: 'atom', + webPreferences: { + // Prevent specs from throttling when the window is in the background: + // this should result in faster CI builds, and an improvement in the + // local development experience when running specs through the UI (which + // now won't pause when e.g. minimizing the window). + backgroundThrottling: !this.isSpec, + // Disable the `auxclick` feature so that `click` events are triggered in + // response to a middle-click. + // (Ref: https://github.com/atom/atom/pull/12696#issuecomment-290496960) + disableBlinkFeatures: 'Auxclick' + } + } + + // Don't set icon on Windows so the exe's ico will be used as window and + // taskbar's icon. See https://github.com/atom/atom/issues/4811 for more. + if (process.platform === 'linux') options.icon = ICON_PATH + if (this.shouldAddCustomTitleBar()) options.titleBarStyle = 'hidden' + if (this.shouldAddCustomInsetTitleBar()) options.titleBarStyle = 'hidden-inset' + if (this.shouldHideTitleBar()) options.frame = false + this.browserWindow = new BrowserWindow(options) + + this.handleEvents() + + this.loadSettings = Object.assign({}, settings) + this.loadSettings.appVersion = app.getVersion() + this.loadSettings.resourcePath = this.resourcePath + this.loadSettings.atomHome = process.env.ATOM_HOME + if (this.loadSettings.devMode == null) this.loadSettings.devMode = false + if (this.loadSettings.safeMode == null) this.loadSettings.safeMode = false + if (this.loadSettings.clearWindowState == null) this.loadSettings.clearWindowState = false + + if (!this.loadSettings.initialPaths) { + this.loadSettings.initialPaths = [] + for (const {pathToOpen} of locationsToOpen) { + if (!pathToOpen) continue + const stat = fs.statSyncNoException(pathToOpen) || null + if (stat && stat.isDirectory()) { + this.loadSettings.initialPaths.push(pathToOpen) + } else { + const parentDirectory = path.dirname(pathToOpen) + if ((stat && stat.isFile()) || fs.existsSync(parentDirectory)) { + this.loadSettings.initialPaths.push(parentDirectory) + } else { + this.loadSettings.initialPaths.push(pathToOpen) + } + } + } + } + + this.loadSettings.initialPaths.sort() + + // Only send to the first non-spec window created + if (includeShellLoadTime && !this.isSpec) { + includeShellLoadTime = false + if (!this.loadSettings.shellLoadTime) { + this.loadSettings.shellLoadTime = Date.now() - global.shellStartTime + } + } + + this.representedDirectoryPaths = this.loadSettings.initialPaths + if (!this.loadSettings.env) this.env = this.loadSettings.env + + this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings) + + this.browserWindow.on('window:loaded', () => { + this.disableZoom() + this.emit('window:loaded') + this.resolveLoadedPromise() + }) + + this.browserWindow.on('window:locations-opened', () => { + this.emit('window:locations-opened') + }) + + this.browserWindow.on('enter-full-screen', () => { + this.browserWindow.webContents.send('did-enter-full-screen') + }) + + this.browserWindow.on('leave-full-screen', () => { + this.browserWindow.webContents.send('did-leave-full-screen') + }) + + this.browserWindow.loadURL( + url.format({ + protocol: 'file', + pathname: `${this.resourcePath}/static/index.html`, + slashes: true + }) + ) + + this.browserWindow.showSaveDialog = this.showSaveDialog.bind(this) + + if (this.isSpec) this.browserWindow.focusOnWebView() + + const hasPathToOpen = !(locationsToOpen.length === 1 && locationsToOpen[0].pathToOpen == null) + if (hasPathToOpen && !this.isSpecWindow()) this.openLocations(locationsToOpen) + } + + hasProjectPath () { + return this.representedDirectoryPaths.length > 0 + } + + setupContextMenu () { + const ContextMenu = require('./context-menu') + + this.browserWindow.on('context-menu', menuTemplate => { + return new ContextMenu(menuTemplate, this) + }) + } + + containsPaths (paths) { + return paths.every(p => this.containsPath(p)) + } + + containsPath (pathToCheck) { + if (!pathToCheck) return false + const stat = fs.statSyncNoException(pathToCheck) + if (stat && stat.isDirectory()) return false + + return this.representedDirectoryPaths.some(projectPath => + pathToCheck === projectPath || pathToCheck.startsWith(path.join(projectPath, path.sep)) + ) + } + + handleEvents () { + this.browserWindow.on('close', async event => { + if (!this.atomApplication.quitting && !this.unloading) { + event.preventDefault() + this.unloading = true + this.atomApplication.saveState(false) + if (await this.prepareToUnload()) this.close() + } + }) + + this.browserWindow.on('closed', () => { + this.fileRecoveryService.didCloseWindow(this) + this.atomApplication.removeWindow(this) + this.resolveClosedPromise() + }) + + this.browserWindow.on('unresponsive', () => { + if (this.isSpec) return + const chosen = dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Force Close', 'Keep Waiting'], + message: 'Editor is not responding', + detail: + 'The editor is not responding. Would you like to force close it or just keep waiting?' + }) + if (chosen === 0) this.browserWindow.destroy() + }) + + this.browserWindow.webContents.on('crashed', () => { + if (this.headless) { + console.log('Renderer process crashed, exiting') + this.atomApplication.exit(100) + return + } + + this.fileRecoveryService.didCrashWindow(this) + const chosen = dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Close Window', 'Reload', 'Keep It Open'], + message: 'The editor has crashed', + detail: 'Please report this issue to https://github.com/atom/atom' + }) + switch (chosen) { + case 0: return this.browserWindow.destroy() + case 1: return this.browserWindow.reload() + } + }) + + this.browserWindow.webContents.on('will-navigate', (event, url) => { + if (url !== this.browserWindow.webContents.getURL()) event.preventDefault() + }) + + this.setupContextMenu() + + // Spec window's web view should always have focus + if (this.isSpec) this.browserWindow.on('blur', () => this.browserWindow.focusOnWebView()) + } + + async prepareToUnload () { + if (this.isSpecWindow()) return true + + this.lastPrepareToUnloadPromise = new Promise(resolve => { + const callback = (event, result) => { + if (BrowserWindow.fromWebContents(event.sender) === this.browserWindow) { + ipcMain.removeListener('did-prepare-to-unload', callback) + if (!result) { + this.unloading = false + this.atomApplication.quitting = false + } + resolve(result) + } + } + ipcMain.on('did-prepare-to-unload', callback) + this.browserWindow.webContents.send('prepare-to-unload') + }) + + return this.lastPrepareToUnloadPromise + } + + openPath (pathToOpen, initialLine, initialColumn) { + return this.openLocations([{pathToOpen, initialLine, initialColumn}]) + } + + async openLocations (locationsToOpen) { + await this.loadedPromise + this.sendMessage('open-locations', locationsToOpen) + } + + replaceEnvironment (env) { + this.browserWindow.webContents.send('environment', env) + } + + sendMessage (message, detail) { + this.browserWindow.webContents.send('message', message, detail) + } + + sendCommand (command, ...args) { + if (this.isSpecWindow()) { + if (!this.atomApplication.sendCommandToFirstResponder(command)) { + switch (command) { + case 'window:reload': return this.reload() + case 'window:toggle-dev-tools': return this.toggleDevTools() + case 'window:close': return this.close() + } + } + } else if (this.isWebViewFocused()) { + this.sendCommandToBrowserWindow(command, ...args) + } else if (!this.atomApplication.sendCommandToFirstResponder(command)) { + this.sendCommandToBrowserWindow(command, ...args) + } + } + + sendURIMessage (uri) { + this.browserWindow.webContents.send('uri-message', uri) + } + + sendCommandToBrowserWindow (command, ...args) { + const action = args[0] && args[0].contextCommand + ? 'context-command' + : 'command' + this.browserWindow.webContents.send(action, command, ...args) + } + + getDimensions () { + const [x, y] = Array.from(this.browserWindow.getPosition()) + const [width, height] = Array.from(this.browserWindow.getSize()) + return {x, y, width, height} + } + + shouldAddCustomTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'custom' + ) + } + + shouldAddCustomInsetTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'custom-inset' + ) + } + + shouldHideTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'hidden' + ) + } + + close () { + return this.browserWindow.close() + } + + focus () { + return this.browserWindow.focus() + } + + minimize () { + return this.browserWindow.minimize() + } + + maximize () { + return this.browserWindow.maximize() + } + + unmaximize () { + return this.browserWindow.unmaximize() + } + + restore () { + return this.browserWindow.restore() + } + + setFullScreen (fullScreen) { + return this.browserWindow.setFullScreen(fullScreen) + } + + setAutoHideMenuBar (autoHideMenuBar) { + return this.browserWindow.setAutoHideMenuBar(autoHideMenuBar) + } + + handlesAtomCommands () { + return !this.isSpecWindow() && this.isWebViewFocused() + } + + isFocused () { + return this.browserWindow.isFocused() + } + + isMaximized () { + return this.browserWindow.isMaximized() + } + + isMinimized () { + return this.browserWindow.isMinimized() + } + + isWebViewFocused () { + return this.browserWindow.isWebViewFocused() + } + + isSpecWindow () { + return this.isSpec + } + + reload () { + this.loadedPromise = new Promise(resolve => { this.resolveLoadedPromise = resolve }) + this.prepareToUnload().then(canUnload => { + if (canUnload) this.browserWindow.reload() + }) + return this.loadedPromise + } + + showSaveDialog (options, callback) { + options = Object.assign({ + title: 'Save File', + defaultPath: this.representedDirectoryPaths[0] + }, options) + + if (typeof callback === 'function') { + // Async + dialog.showSaveDialog(this.browserWindow, options, callback) + } else { + // Sync + return dialog.showSaveDialog(this.browserWindow, options) + } + } + + toggleDevTools () { + return this.browserWindow.toggleDevTools() + } + + openDevTools () { + return this.browserWindow.openDevTools() + } + + closeDevTools () { + return this.browserWindow.closeDevTools() + } + + setDocumentEdited (documentEdited) { + return this.browserWindow.setDocumentEdited(documentEdited) + } + + setRepresentedFilename (representedFilename) { + return this.browserWindow.setRepresentedFilename(representedFilename) + } + + setRepresentedDirectoryPaths (representedDirectoryPaths) { + this.representedDirectoryPaths = representedDirectoryPaths + this.representedDirectoryPaths.sort() + this.loadSettings.initialPaths = this.representedDirectoryPaths + this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings) + return this.atomApplication.saveState() + } + + didClosePathWithWaitSession (path) { + this.atomApplication.windowDidClosePathWithWaitSession(this, path) + } + + copy () { + return this.browserWindow.copy() + } + + disableZoom () { + return this.browserWindow.webContents.setVisualZoomLevelLimits(1, 1) + } +} diff --git a/src/notification-manager.js b/src/notification-manager.js index df5e5fb42..a0ae139d3 100644 --- a/src/notification-manager.js +++ b/src/notification-manager.js @@ -27,6 +27,15 @@ class NotificationManager { return this.emitter.on('did-add-notification', callback) } + // Public: Invoke the given callback after the notifications have been cleared. + // + // * `callback` {Function} to be called after the notifications are cleared. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidClearNotifications (callback) { + return this.emitter.on('did-clear-notifications', callback) + } + /* Section: Adding Notifications */ @@ -200,7 +209,9 @@ class NotificationManager { Section: Managing Notifications */ + // Public: Clear all the notifications. clear () { this.notifications = [] + this.emitter.emit('did-clear-notifications') } } diff --git a/src/pane-resize-handle-element.coffee b/src/pane-resize-handle-element.coffee index 836dead52..69562c357 100644 --- a/src/pane-resize-handle-element.coffee +++ b/src/pane-resize-handle-element.coffee @@ -9,8 +9,12 @@ class PaneResizeHandleElement extends HTMLElement @addEventListener 'mousedown', @resizeStarted.bind(this) attachedCallback: -> - @isHorizontal = @parentElement.classList.contains("horizontal") - @classList.add if @isHorizontal then 'horizontal' else 'vertical' + # For some reason Chromium 58 is firing the attached callback after the + # element has been detached, so we ignore the callback when a parent element + # can't be found. + if @parentElement + @isHorizontal = @parentElement.classList.contains("horizontal") + @classList.add if @isHorizontal then 'horizontal' else 'vertical' detachedCallback: -> @resizeStopped() diff --git a/src/protocol-handler-installer.js b/src/protocol-handler-installer.js index 0a55bff41..37df68389 100644 --- a/src/protocol-handler-installer.js +++ b/src/protocol-handler-installer.js @@ -63,7 +63,7 @@ class ProtocolHandlerInstaller { notification = notifications.addInfo('Register as default atom:// URI handler?', { dismissable: true, icon: 'link', - description: 'Atom is not currently set as the defaut handler for atom:// URIs. Would you like Atom to handle ' + + description: 'Atom is not currently set as the default handler for atom:// URIs. Would you like Atom to handle ' + 'atom:// URIs?', buttons: [ { diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index 0bacfbb8e..a367e6188 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -160,6 +160,8 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage 'editor:select-to-previous-subword-boundary': -> @selectToPreviousSubwordBoundary() 'editor:select-to-first-character-of-line': -> @selectToFirstCharacterOfLine() 'editor:select-line': -> @selectLinesContainingCursors() + 'editor:select-larger-syntax-node': -> @selectLargerSyntaxNode() + 'editor:select-smaller-syntax-node': -> @selectSmallerSyntaxNode() }), false ) diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee index 95539cc69..2085bd6b2 100644 --- a/src/scope-descriptor.coffee +++ b/src/scope-descriptor.coffee @@ -39,11 +39,17 @@ class ScopeDescriptor getScopesArray: -> @scopes getScopeChain: -> - @scopes - .map (scope) -> - scope = ".#{scope}" unless scope[0] is '.' - scope - .join(' ') + # For backward compatibility, prefix TextMate-style scope names with + # leading dots (e.g. 'source.js' -> '.source.js'). + if @scopes[0].includes('.') + result = '' + for scope, i in @scopes + result += ' ' if i > 0 + result += '.' if scope[0] isnt '.' + result += scope + result + else + @scopes.join(' ') toString: -> @getScopeChain() diff --git a/src/syntax-scope-map.js b/src/syntax-scope-map.js new file mode 100644 index 000000000..e000fb647 --- /dev/null +++ b/src/syntax-scope-map.js @@ -0,0 +1,178 @@ +const parser = require('postcss-selector-parser') + +module.exports = +class SyntaxScopeMap { + constructor (scopeNamesBySelector) { + this.namedScopeTable = {} + this.anonymousScopeTable = {} + for (let selector in scopeNamesBySelector) { + this.addSelector(selector, scopeNamesBySelector[selector]) + } + setTableDefaults(this.namedScopeTable) + setTableDefaults(this.anonymousScopeTable) + } + + addSelector (selector, scopeName) { + parser((parseResult) => { + for (let selectorNode of parseResult.nodes) { + let currentTable = null + let currentIndexValue = null + + for (let i = selectorNode.nodes.length - 1; i >= 0; i--) { + const termNode = selectorNode.nodes[i] + + switch (termNode.type) { + case 'tag': + if (!currentTable) currentTable = this.namedScopeTable + if (!currentTable[termNode.value]) currentTable[termNode.value] = {} + currentTable = currentTable[termNode.value] + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'string': + if (!currentTable) currentTable = this.anonymousScopeTable + const value = termNode.value.slice(1, -1) + if (!currentTable[value]) currentTable[value] = {} + currentTable = currentTable[value] + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'universal': + if (currentTable) { + if (!currentTable['*']) currentTable['*'] = {} + currentTable = currentTable['*'] + } else { + if (!this.namedScopeTable['*']) { + this.namedScopeTable['*'] = this.anonymousScopeTable['*'] = {} + } + currentTable = this.namedScopeTable['*'] + } + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'combinator': + if (currentIndexValue != null) { + rejectSelector(selector) + } + + if (termNode.value === '>') { + if (!currentTable.parents) currentTable.parents = {} + currentTable = currentTable.parents + } else { + rejectSelector(selector) + } + break + + case 'pseudo': + if (termNode.value === ':nth-child') { + currentIndexValue = termNode.nodes[0].nodes[0].value + } else { + rejectSelector(selector) + } + break + + default: + rejectSelector(selector) + } + } + + currentTable.scopeName = scopeName + } + }).process(selector) + } + + get (nodeTypes, childIndices, leafIsNamed = true) { + let result + let i = nodeTypes.length - 1 + let currentTable = leafIsNamed + ? this.namedScopeTable[nodeTypes[i]] + : this.anonymousScopeTable[nodeTypes[i]] + + if (!currentTable) currentTable = this.namedScopeTable['*'] + + while (currentTable) { + if (currentTable.indices && currentTable.indices[childIndices[i]]) { + currentTable = currentTable.indices[childIndices[i]] + } + + if (currentTable.scopeName) { + result = currentTable.scopeName + } + + if (i === 0) break + i-- + currentTable = currentTable.parents && ( + currentTable.parents[nodeTypes[i]] || + currentTable.parents['*'] + ) + } + + return result + } +} + +function setTableDefaults (table) { + const defaultTypeTable = table['*'] + + for (let type in table) { + let typeTable = table[type] + if (typeTable === defaultTypeTable) continue + + if (defaultTypeTable) { + mergeTable(typeTable, defaultTypeTable) + } + + if (typeTable.parents) { + setTableDefaults(typeTable.parents) + } + + for (let key in typeTable.indices) { + const indexTable = typeTable.indices[key] + mergeTable(indexTable, typeTable, false) + if (indexTable.parents) { + setTableDefaults(indexTable.parents) + } + } + } +} + +function mergeTable (table, defaultTable, mergeIndices = true) { + if (mergeIndices && defaultTable.indices) { + if (!table.indices) table.indices = {} + for (let key in defaultTable.indices) { + if (!table.indices[key]) table.indices[key] = {} + mergeTable(table.indices[key], defaultTable.indices[key]) + } + } + + if (defaultTable.parents) { + if (!table.parents) table.parents = {} + for (let key in defaultTable.parents) { + if (!table.parents[key]) table.parents[key] = {} + mergeTable(table.parents[key], defaultTable.parents[key]) + } + } + + if (defaultTable.scopeName && !table.scopeName) { + table.scopeName = defaultTable.scopeName + } +} + +function rejectSelector (selector) { + throw new TypeError(`Unsupported selector '${selector}'`) +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index da6ec452d..867a536fc 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -56,7 +56,7 @@ class TextEditorComponent { this.props = props if (!props.model) { - props.model = new TextEditor({mini: props.mini}) + props.model = new TextEditor({mini: props.mini, readOnly: props.readOnly}) } this.props.model.component = this @@ -170,6 +170,7 @@ class TextEditorComponent { this.textDecorationBoundaries = [] this.pendingScrollTopRow = this.props.initialScrollTopRow this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn + this.tabIndex = this.props.element && this.props.element.tabIndex ? this.props.element.tabIndex : -1 this.measuredContent = false this.queryGuttersToRender() @@ -460,9 +461,13 @@ class TextEditorComponent { } } - let attributes = null + let attributes = {} if (model.isMini()) { - attributes = {mini: ''} + attributes.mini = '' + } + + if (!this.isInputEnabled()) { + attributes.readonly = '' } const dataset = {encoding: model.getEncoding()} @@ -677,7 +682,8 @@ class TextEditorComponent { scrollWidth: this.getScrollWidth(), decorationsToRender: this.decorationsToRender, cursorsBlinkedOff: this.cursorsBlinkedOff, - hiddenInputPosition: this.hiddenInputPosition + hiddenInputPosition: this.hiddenInputPosition, + tabIndex: this.tabIndex }) } @@ -1712,10 +1718,6 @@ class TextEditorComponent { return } - if (this.getChromeVersion() === 56) { - this.getHiddenInput().value = '' - } - this.compositionCheckpoint = this.props.model.createCheckpoint() if (this.accentedCharacterMenuIsOpen) { this.props.model.selectLeft() @@ -1723,16 +1725,7 @@ class TextEditorComponent { } didCompositionUpdate (event) { - if (this.getChromeVersion() === 56) { - process.nextTick(() => { - if (this.compositionCheckpoint != null) { - const previewText = this.getHiddenInput().value - this.props.model.insertText(previewText, {select: true}) - } - }) - } else { - this.props.model.insertText(event.data, {select: true}) - } + this.props.model.insertText(event.data, {select: true}) } didCompositionEnd (event) { @@ -1758,11 +1751,13 @@ class TextEditorComponent { const screenPosition = this.screenPositionForMouseEvent(event) - // All clicks should set the cursor position, but only left-clicks should - // have additional logic. - // On macOS, ctrl-click brings up the context menu so also handle that case. if (button !== 0 || (platform === 'darwin' && ctrlKey)) { - model.setCursorScreenPosition(screenPosition, {autoscroll: false}) + // Always set cursor position on middle-click + // Only set cursor position on right-click if there is one cursor with no selection + const ranges = model.getSelectedBufferRanges() + if (button === 1 || (ranges.length === 1 && ranges[0].isEmpty())) { + model.setCursorScreenPosition(screenPosition, {autoscroll: false}) + } // On Linux, pasting happens on middle click. A textInput event with the // contents of the selection clipboard will be dispatched by the browser @@ -2962,11 +2957,11 @@ class TextEditorComponent { } setInputEnabled (inputEnabled) { - this.props.inputEnabled = inputEnabled + this.props.model.update({readOnly: !inputEnabled}) } isInputEnabled (inputEnabled) { - return this.props.inputEnabled != null ? this.props.inputEnabled : true + return !this.props.model.isReadOnly() } getHiddenInput () { @@ -3017,7 +3012,7 @@ class DummyScrollbarComponent { const outerStyle = { position: 'absolute', - contain: 'strict', + contain: 'content', zIndex: 1, willChange: 'transform' } @@ -3553,7 +3548,7 @@ class CursorsAndInputComponent { const { lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput, didPaste, didTextInput, didKeydown, didKeyup, didKeypress, - didCompositionStart, didCompositionUpdate, didCompositionEnd + didCompositionStart, didCompositionUpdate, didCompositionEnd, tabIndex } = this.props let top, left @@ -3581,7 +3576,7 @@ class CursorsAndInputComponent { compositionupdate: didCompositionUpdate, compositionend: didCompositionEnd }, - tabIndex: -1, + tabIndex: tabIndex, style: { position: 'absolute', width: '1px', diff --git a/src/text-editor-element.js b/src/text-editor-element.js index d56c5596b..926f7af44 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -32,7 +32,7 @@ class TextEditorElement extends HTMLElement { createdCallback () { this.emitter = new Emitter() this.initialText = this.textContent - this.tabIndex = -1 + if (this.tabIndex == null) this.tabIndex = -1 this.addEventListener('focus', (event) => this.getComponent().didFocus(event)) this.addEventListener('blur', (event) => this.getComponent().didBlur(event)) } @@ -59,6 +59,9 @@ class TextEditorElement extends HTMLElement { case 'gutter-hidden': this.getModel().update({lineNumberGutterVisible: newValue == null}) break + case 'readonly': + this.getModel().update({readOnly: newValue != null}) + break } } } @@ -275,7 +278,8 @@ class TextEditorElement extends HTMLElement { this.component = new TextEditorComponent({ element: this, mini: this.hasAttribute('mini'), - updatedSynchronously: this.updatedSynchronously + updatedSynchronously: this.updatedSynchronously, + readOnly: this.hasAttribute('readonly') }) this.updateModelFromAttributes() } diff --git a/src/text-editor.js b/src/text-editor.js index bcd9c19d3..47fb9f485 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -119,11 +119,16 @@ class TextEditor { } this.id = params.id != null ? params.id : nextId++ + if (this.id >= nextId) { + // Ensure that new editors get unique ids: + nextId = this.id + 1 + } this.initialScrollTopRow = params.initialScrollTopRow this.initialScrollLeftColumn = params.initialScrollLeftColumn this.decorationManager = params.decorationManager this.selectionsMarkerLayer = params.selectionsMarkerLayer this.mini = (params.mini != null) ? params.mini : false + this.readOnly = (params.readOnly != null) ? params.readOnly : false this.placeholderText = params.placeholderText this.showLineNumbers = params.showLineNumbers this.assert = params.assert || (condition => condition) @@ -400,6 +405,15 @@ class TextEditor { } break + case 'readOnly': + if (value !== this.readOnly) { + this.readOnly = value + if (this.component != null) { + this.component.scheduleUpdate() + } + } + break + case 'placeholderText': if (value !== this.placeholderText) { this.placeholderText = value @@ -530,6 +544,7 @@ class TextEditor { softWrapAtPreferredLineLength: this.softWrapAtPreferredLineLength, preferredLineLength: this.preferredLineLength, mini: this.mini, + readOnly: this.readOnly, editorWidthInChars: this.editorWidthInChars, width: this.width, maxScreenLineLength: this.maxScreenLineLength, @@ -965,6 +980,12 @@ class TextEditor { isMini () { return this.mini } + setReadOnly (readOnly) { + this.update({readOnly}) + } + + isReadOnly () { return this.readOnly } + onDidChangeMini (callback) { return this.emitter.on('did-change-mini', callback) } @@ -1309,15 +1330,24 @@ class TextEditor { insertText (text, options = {}) { if (!this.emitWillInsertTextEvent(text)) return false + let groupLastChanges = false + if (options.undo === 'skip') { + options = Object.assign({}, options) + delete options.undo + groupLastChanges = true + } + const groupingInterval = options.groupUndo ? this.undoGroupingInterval : 0 if (options.autoIndentNewline == null) options.autoIndentNewline = this.shouldAutoIndent() if (options.autoDecreaseIndent == null) options.autoDecreaseIndent = this.shouldAutoIndent() - return this.mutateSelectedText(selection => { + const result = this.mutateSelectedText(selection => { const range = selection.insertText(text, options) const didInsertEvent = {text, range} this.emitter.emit('did-insert-text', didInsertEvent) return range }, groupingInterval) + if (groupLastChanges) this.buffer.groupLastChanges() + return result } // Essential: For each selection, replace the selected text with a newline. @@ -3053,6 +3083,36 @@ class TextEditor { return this.expandSelectionsBackward(selection => selection.selectToBeginningOfPreviousParagraph()) } + // Extended: For each selection, select the syntax node that contains + // that selection. + selectLargerSyntaxNode () { + const languageMode = this.buffer.getLanguageMode() + if (!languageMode.getRangeForSyntaxNodeContainingRange) return + + this.expandSelectionsForward(selection => { + const currentRange = selection.getBufferRange() + const newRange = languageMode.getRangeForSyntaxNodeContainingRange(currentRange) + if (newRange) { + if (!selection._rangeStack) selection._rangeStack = [] + selection._rangeStack.push(currentRange) + selection.setBufferRange(newRange) + } + }) + } + + // Extended: Undo the effect a preceding call to {::selectLargerSyntaxNode}. + selectSmallerSyntaxNode () { + this.expandSelectionsForward(selection => { + if (selection._rangeStack) { + const lastRange = selection._rangeStack[selection._rangeStack.length - 1] + if (lastRange && selection.getBufferRange().containsRange(lastRange)) { + selection._rangeStack.length-- + selection.setBufferRange(lastRange) + } + } + }) + } + // Extended: Select the range of the given marker if it is valid. // // * `marker` A {DisplayMarker} @@ -3583,14 +3643,15 @@ class TextEditor { return this.buffer.getLanguageMode().rootScopeDescriptor } - // Essential: Get the syntactic scopeDescriptor for the given position in buffer + // Essential: Get the syntactic {ScopeDescriptor} for the given position in buffer // coordinates. Useful with {Config::get}. // // For example, if called with a position inside the parameter list of an - // anonymous CoffeeScript function, the method returns the following array: - // `["source.coffee", "meta.inline.function.coffee", "variable.parameter.function.coffee"]` + // anonymous CoffeeScript function, this method returns a {ScopeDescriptor} with + // the following scopes array: + // `["source.coffee", "meta.function.inline.coffee", "meta.parameters.coffee", "variable.parameter.function.coffee"]` // - // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `bufferPosition` A {Point} or {Array} of `[row, column]`. // // Returns a {ScopeDescriptor}. scopeDescriptorForBufferPosition (bufferPosition) { @@ -3838,7 +3899,7 @@ class TextEditor { // Extended: Fold all foldable lines at the given indent level. // - // * `level` A {Number}. + // * `level` A {Number} starting at 0. foldAllAtIndentLevel (level) { const languageMode = this.buffer.getLanguageMode() const foldableRanges = ( @@ -4522,8 +4583,7 @@ class TextEditor { ? minBlankIndentLevel : 0 - const tabLength = this.getTabLength() - const indentString = ' '.repeat(tabLength * minIndentLevel) + const indentString = this.buildIndentString(minIndentLevel) for (let row = start; row <= end; row++) { const line = this.buffer.lineForRow(row) if (NON_WHITESPACE_REGEXP.test(line)) { diff --git a/src/text-mate-language-mode.js b/src/text-mate-language-mode.js index 123e39f58..1a7cb6d2e 100644 --- a/src/text-mate-language-mode.js +++ b/src/text-mate-language-mode.js @@ -74,10 +74,15 @@ class TextMateLanguageMode { // // Returns a {Number}. suggestedIndentForBufferRow (bufferRow, tabLength, options) { - return this._suggestedIndentForTokenizedLineAtBufferRow( + const line = this.buffer.lineForRow(bufferRow) + const tokenizedLine = this.tokenizedLineForRow(bufferRow) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( bufferRow, - this.buffer.lineForRow(bufferRow), - this.tokenizedLineForRow(bufferRow), + line, + scopeDescriptor, tabLength, options ) @@ -90,10 +95,14 @@ class TextMateLanguageMode { // // Returns a {Number}. suggestedIndentForLineAtBufferRow (bufferRow, line, tabLength) { - return this._suggestedIndentForTokenizedLineAtBufferRow( + const tokenizedLine = this.buildTokenizedLineForRowWithText(bufferRow, line) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( bufferRow, line, - this.buildTokenizedLineForRowWithText(bufferRow, line), + scopeDescriptor, tabLength ) } @@ -111,7 +120,7 @@ class TextMateLanguageMode { const currentIndentLevel = this.indentLevelForLine(line, tabLength) if (currentIndentLevel === 0) return - const scopeDescriptor = this.scopeDescriptorForPosition([bufferRow, 0]) + const scopeDescriptor = this.scopeDescriptorForPosition(new Point(bufferRow, 0)) const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) if (!decreaseIndentRegex) return @@ -138,11 +147,7 @@ class TextMateLanguageMode { return desiredIndentLevel } - _suggestedIndentForTokenizedLineAtBufferRow (bufferRow, line, tokenizedLine, tabLength, options) { - const iterator = tokenizedLine.getTokenIterator() - iterator.next() - const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) - + _suggestedIndentForLineWithScopeAtBufferRow (bufferRow, line, scopeDescriptor, tabLength, options) { const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor(scopeDescriptor) const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) diff --git a/src/theme-manager.js b/src/theme-manager.js index a23305c92..68a5eb45a 100644 --- a/src/theme-manager.js +++ b/src/theme-manager.js @@ -136,12 +136,12 @@ class ThemeManager { ] themeNames = _.intersection(themeNames, builtInThemeNames) if (themeNames.length === 0) { - themeNames = ['atom-dark-syntax', 'atom-dark-ui'] + themeNames = ['one-dark-syntax', 'one-dark-ui'] } else if (themeNames.length === 1) { if (_.endsWith(themeNames[0], '-ui')) { - themeNames.unshift('atom-dark-syntax') + themeNames.unshift('one-dark-syntax') } else { - themeNames.push('atom-dark-ui') + themeNames.push('one-dark-ui') } } } diff --git a/src/tree-sitter-grammar.js b/src/tree-sitter-grammar.js new file mode 100644 index 000000000..d00344fb1 --- /dev/null +++ b/src/tree-sitter-grammar.js @@ -0,0 +1,72 @@ +const path = require('path') +const SyntaxScopeMap = require('./syntax-scope-map') +const Module = require('module') + +module.exports = +class TreeSitterGrammar { + constructor (registry, filePath, params) { + this.registry = registry + this.id = params.id + this.name = params.name + this.legacyScopeName = params.legacyScopeName + if (params.contentRegExp) this.contentRegExp = new RegExp(params.contentRegExp) + + this.folds = params.folds || [] + + this.commentStrings = { + commentStartString: params.comments && params.comments.start, + commentEndString: params.comments && params.comments.end + } + + const scopeSelectors = {} + for (const key in params.scopes || {}) { + scopeSelectors[key] = params.scopes[key] + .split('.') + .map(s => `syntax--${s}`) + .join(' ') + } + + this.scopeMap = new SyntaxScopeMap(scopeSelectors) + this.fileTypes = params.fileTypes + + // TODO - When we upgrade to a new enough version of node, use `require.resolve` + // with the new `paths` option instead of this private API. + const languageModulePath = Module._resolveFilename(params.parser, { + id: filePath, + filename: filePath, + paths: Module._nodeModulePaths(path.dirname(filePath)) + }) + + this.languageModule = require(languageModulePath) + this.scopesById = new Map() + this.idsByScope = {} + this.nextScopeId = 256 + 1 + this.registration = null + } + + idForScope (scope) { + let id = this.idsByScope[scope] + if (!id) { + id = this.nextScopeId += 2 + this.idsByScope[scope] = id + this.scopesById.set(id, scope) + } + return id + } + + classNameForScopeId (id) { + return this.scopesById.get(id) + } + + get scopeName () { + return this.id + } + + activate () { + this.registration = this.registry.addGrammar(this) + } + + deactivate () { + if (this.registration) this.registration.dispose() + } +} diff --git a/src/tree-sitter-language-mode.js b/src/tree-sitter-language-mode.js new file mode 100644 index 000000000..41c87ba00 --- /dev/null +++ b/src/tree-sitter-language-mode.js @@ -0,0 +1,532 @@ +const {Document} = require('tree-sitter') +const {Point, Range, Emitter} = require('atom') +const ScopeDescriptor = require('./scope-descriptor') +const TokenizedLine = require('./tokenized-line') +const TextMateLanguageMode = require('./text-mate-language-mode') + +let nextId = 0 + +module.exports = +class TreeSitterLanguageMode { + constructor ({buffer, grammar, config}) { + this.id = nextId++ + this.buffer = buffer + this.grammar = grammar + this.config = config + this.document = new Document() + this.document.setInput(new TreeSitterTextBufferInput(buffer)) + this.document.setLanguage(grammar.languageModule) + this.document.parse() + this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.id]}) + this.emitter = new Emitter() + this.isFoldableCache = [] + + // TODO: Remove this once TreeSitterLanguageMode implements its own auto-indentation system. This + // is temporarily needed in order to delegate to the TextMateLanguageMode's auto-indent system. + this.regexesByPattern = {} + } + + getLanguageId () { + return this.grammar.id + } + + bufferDidChange ({oldRange, newRange, oldText, newText}) { + const startRow = oldRange.start.row + const oldEndRow = oldRange.end.row + const newEndRow = newRange.end.row + this.isFoldableCache.splice(startRow, oldEndRow - startRow, ...new Array(newEndRow - startRow)) + this.document.edit({ + startIndex: this.buffer.characterIndexForPosition(oldRange.start), + lengthRemoved: oldText.length, + lengthAdded: newText.length, + startPosition: oldRange.start, + extentRemoved: oldRange.getExtent(), + extentAdded: newRange.getExtent() + }) + } + + /* + Section - Highlighting + */ + + buildHighlightIterator () { + const invalidatedRanges = this.document.parse() + for (let i = 0, n = invalidatedRanges.length; i < n; i++) { + const range = invalidatedRanges[i] + const startRow = range.start.row + const endRow = range.end.row + for (let row = startRow; row < endRow; row++) { + this.isFoldableCache[row] = undefined + } + this.emitter.emit('did-change-highlighting', range) + } + return new TreeSitterHighlightIterator(this) + } + + onDidChangeHighlighting (callback) { + return this.emitter.on('did-change-hightlighting', callback) + } + + classNameForScopeId (scopeId) { + return this.grammar.classNameForScopeId(scopeId) + } + + /* + Section - Commenting + */ + + commentStringsForPosition () { + return this.grammar.commentStrings + } + + isRowCommented () { + return false + } + + /* + Section - Indentation + */ + + suggestedIndentForLineAtBufferRow (row, line, tabLength) { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + line, + this.rootScopeDescriptor, + tabLength + ) + } + + suggestedIndentForBufferRow (row, tabLength, options) { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + this.buffer.lineForRow(row), + this.rootScopeDescriptor, + tabLength, + options + ) + } + + indentLevelForLine (line, tabLength = tabLength) { + let indentLength = 0 + for (let i = 0, {length} = line; i < length; i++) { + const char = line[i] + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength) + } else if (char === ' ') { + indentLength++ + } else { + break + } + } + return indentLength / tabLength + } + + /* + Section - Folding + */ + + isFoldableAtRow (row) { + if (this.isFoldableCache[row] != null) return this.isFoldableCache[row] + const result = this.getFoldableRangeContainingPoint(Point(row, Infinity), 0, true) != null + this.isFoldableCache[row] = result + return result + } + + getFoldableRanges () { + return this.getFoldableRangesAtIndentLevel(null) + } + + getFoldableRangesAtIndentLevel (goalLevel) { + let result = [] + let stack = [{node: this.document.rootNode, level: 0}] + while (stack.length > 0) { + const {node, level} = stack.pop() + + const range = this.getFoldableRangeForNode(node) + if (range) { + if (goalLevel == null || level === goalLevel) { + let updatedExistingRange = false + for (let i = 0, {length} = result; i < length; i++) { + if (result[i].start.row === range.start.row && + result[i].end.row === range.end.row) { + result[i] = range + updatedExistingRange = true + break + } + } + if (!updatedExistingRange) result.push(range) + } + } + + const parentStartRow = node.startPosition.row + const parentEndRow = node.endPosition.row + for (let children = node.namedChildren, i = 0, {length} = children; i < length; i++) { + const child = children[i] + const {startPosition: childStart, endPosition: childEnd} = child + if (childEnd.row > childStart.row) { + if (childStart.row === parentStartRow && childEnd.row === parentEndRow) { + stack.push({node: child, level: level}) + } else { + const childLevel = range && range.containsPoint(childStart) && range.containsPoint(childEnd) + ? level + 1 + : level + if (childLevel <= goalLevel || goalLevel == null) { + stack.push({node: child, level: childLevel}) + } + } + } + } + } + + return result.sort((a, b) => a.start.row - b.start.row) + } + + getFoldableRangeContainingPoint (point, tabLength, existenceOnly = false) { + let node = this.document.rootNode.descendantForPosition(this.buffer.clipPosition(point)) + while (node) { + if (existenceOnly && node.startPosition.row < point.row) break + if (node.endPosition.row > point.row) { + const range = this.getFoldableRangeForNode(node, existenceOnly) + if (range) return range + } + node = node.parent + } + } + + getFoldableRangeForNode (node, existenceOnly) { + const {children, type: nodeType} = node + const childCount = children.length + let childTypes + + for (var i = 0, {length} = this.grammar.folds; i < length; i++) { + const foldEntry = this.grammar.folds[i] + + if (foldEntry.type) { + if (typeof foldEntry.type === 'string') { + if (foldEntry.type !== nodeType) continue + } else { + if (!foldEntry.type.includes(nodeType)) continue + } + } + + let foldStart + const startEntry = foldEntry.start + if (startEntry) { + if (startEntry.index != null) { + const child = children[startEntry.index] + if (!child || (startEntry.type && startEntry.type !== child.type)) continue + foldStart = child.endPosition + } else { + if (!childTypes) childTypes = children.map(child => child.type) + const index = typeof startEntry.type === 'string' + ? childTypes.indexOf(startEntry.type) + : childTypes.findIndex(type => startEntry.type.includes(type)) + if (index === -1) continue + foldStart = children[index].endPosition + } + } else { + foldStart = new Point(node.startPosition.row, Infinity) + } + + let foldEnd + const endEntry = foldEntry.end + if (endEntry) { + let foldEndNode + if (endEntry.index != null) { + const index = endEntry.index < 0 ? childCount + endEntry.index : endEntry.index + foldEndNode = children[index] + if (!foldEndNode || (endEntry.type && endEntry.type !== foldEndNode.type)) continue + } else { + if (!childTypes) childTypes = children.map(foldEndNode => foldEndNode.type) + const index = typeof endEntry.type === 'string' + ? childTypes.indexOf(endEntry.type) + : childTypes.findIndex(type => endEntry.type.includes(type)) + if (index === -1) continue + foldEndNode = children[index] + } + + if (foldEndNode.endIndex - foldEndNode.startIndex > 1 && foldEndNode.startPosition.row > foldStart.row) { + foldEnd = new Point(foldEndNode.startPosition.row - 1, Infinity) + } else { + foldEnd = foldEndNode.startPosition + } + } else { + const {endPosition} = node + if (endPosition.column === 0) { + foldEnd = Point(endPosition.row - 1, Infinity) + } else if (childCount > 0) { + foldEnd = endPosition + } else { + foldEnd = Point(endPosition.row, 0) + } + } + + return existenceOnly ? true : new Range(foldStart, foldEnd) + } + } + + /* + Syntax Tree APIs + */ + + getRangeForSyntaxNodeContainingRange (range) { + const startIndex = this.buffer.characterIndexForPosition(range.start) + const endIndex = this.buffer.characterIndexForPosition(range.end) + let node = this.document.rootNode.descendantForIndex(startIndex, endIndex - 1) + while (node && node.startIndex === startIndex && node.endIndex === endIndex) { + node = node.parent + } + if (node) return new Range(node.startPosition, node.endPosition) + } + + /* + Section - Backward compatibility shims + */ + + tokenizedLineForRow (row) { + return new TokenizedLine({ + openScopes: [], + text: this.buffer.lineForRow(row), + tags: [], + ruleStack: [], + lineEnding: this.buffer.lineEndingForRow(row), + tokenIterator: null, + grammar: this.grammar + }) + } + + scopeDescriptorForPosition (point) { + const result = [] + let node = this.document.rootNode.descendantForPosition(point) + + // Don't include anonymous token types like '(' because they prevent scope chains + // from being parsed as CSS selectors by the `slick` parser. Other css selector + // parsers like `postcss-selector-parser` do allow arbitrary quoted strings in + // selectors. + if (!node.isNamed) node = node.parent + + while (node) { + result.push(node.type) + node = node.parent + } + result.push(this.grammar.id) + return new ScopeDescriptor({scopes: result.reverse()}) + } + + hasTokenForSelector (scopeSelector) { + return false + } + + getGrammar () { + return this.grammar + } +} + +class TreeSitterHighlightIterator { + constructor (layer, document) { + this.layer = layer + + // Conceptually, the iterator represents a single position in the text. It stores this + // position both as a character index and as a `Point`. This position corresponds to a + // leaf node of the syntax tree, which either contains or follows the iterator's + // textual position. The `currentNode` property represents that leaf node, and + // `currentChildIndex` represents the child index of that leaf node within its parent. + this.currentIndex = null + this.currentPosition = null + this.currentNode = null + this.currentChildIndex = null + + // In order to determine which selectors match its current node, the iterator maintains + // a list of the current node's ancestors. Because the selectors can use the `:nth-child` + // pseudo-class, each node's child index is also stored. + this.containingNodeTypes = [] + this.containingNodeChildIndices = [] + + // At any given position, the iterator exposes the list of class names that should be + // *ended* at its current position and the list of class names that should be *started* + // at its current position. + this.closeTags = [] + this.openTags = [] + } + + seek (targetPosition) { + const containingTags = [] + + this.closeTags.length = 0 + this.openTags.length = 0 + this.containingNodeTypes.length = 0 + this.containingNodeChildIndices.length = 0 + this.currentPosition = targetPosition + this.currentIndex = this.layer.buffer.characterIndexForPosition(targetPosition) + + var node = this.layer.document.rootNode + var childIndex = -1 + var done = false + var nodeContainsTarget = true + do { + this.currentNode = node + this.currentChildIndex = childIndex + if (!nodeContainsTarget) break + this.containingNodeTypes.push(node.type) + this.containingNodeChildIndices.push(childIndex) + + const scopeName = this.currentScopeName() + if (scopeName) { + const id = this.layer.grammar.idForScope(scopeName) + if (this.currentIndex === node.startIndex) { + this.openTags.push(id) + } else { + containingTags.push(id) + } + } + + done = true + for (var i = 0, {children} = node, childCount = children.length; i < childCount; i++) { + const child = children[i] + if (child.endIndex > this.currentIndex) { + node = child + childIndex = i + done = false + if (child.startIndex > this.currentIndex) nodeContainsTarget = false + break + } + } + } while (!done) + + return containingTags + } + + moveToSuccessor () { + this.closeTags.length = 0 + this.openTags.length = 0 + + if (!this.currentNode) { + this.currentPosition = {row: Infinity, column: Infinity} + return false + } + + do { + if (this.currentIndex < this.currentNode.startIndex) { + this.currentIndex = this.currentNode.startIndex + this.currentPosition = this.currentNode.startPosition + this.pushOpenTag() + this.descendLeft() + } else if (this.currentIndex < this.currentNode.endIndex) { + while (true) { + this.currentIndex = this.currentNode.endIndex + this.currentPosition = this.currentNode.endPosition + this.pushCloseTag() + + const {nextSibling} = this.currentNode + if (nextSibling) { + this.currentNode = nextSibling + this.currentChildIndex++ + if (this.currentIndex === nextSibling.startIndex) { + this.pushOpenTag() + this.descendLeft() + } + break + } else { + this.currentNode = this.currentNode.parent + this.currentChildIndex = last(this.containingNodeChildIndices) + if (!this.currentNode) break + } + } + } else if (this.currentNode.startIndex < this.currentNode.endIndex) { + this.currentNode = this.currentNode.nextSibling + if (this.currentNode) { + this.currentChildIndex++ + this.currentPosition = this.currentNode.startPosition + this.currentIndex = this.currentNode.startIndex + this.pushOpenTag() + this.descendLeft() + } + } else { + this.pushCloseTag() + this.currentNode = this.currentNode.parent + this.currentChildIndex = last(this.containingNodeChildIndices) + } + } while (this.closeTags.length === 0 && this.openTags.length === 0 && this.currentNode) + + return true + } + + getPosition () { + return this.currentPosition + } + + getCloseScopeIds () { + return this.closeTags.slice() + } + + getOpenScopeIds () { + return this.openTags.slice() + } + + // Private methods + + descendLeft () { + let child + while ((child = this.currentNode.firstChild) && this.currentIndex === child.startIndex) { + this.currentNode = child + this.currentChildIndex = 0 + this.pushOpenTag() + } + } + + currentScopeName () { + return this.layer.grammar.scopeMap.get( + this.containingNodeTypes, + this.containingNodeChildIndices, + this.currentNode.isNamed + ) + } + + pushCloseTag () { + const scopeName = this.currentScopeName() + if (scopeName) this.closeTags.push(this.layer.grammar.idForScope(scopeName)) + this.containingNodeTypes.pop() + this.containingNodeChildIndices.pop() + } + + pushOpenTag () { + this.containingNodeTypes.push(this.currentNode.type) + this.containingNodeChildIndices.push(this.currentChildIndex) + const scopeName = this.currentScopeName() + if (scopeName) this.openTags.push(this.layer.grammar.idForScope(scopeName)) + } +} + +class TreeSitterTextBufferInput { + constructor (buffer) { + this.buffer = buffer + this.seek(0) + } + + seek (characterIndex) { + this.position = this.buffer.positionForCharacterIndex(characterIndex) + } + + read () { + const endPosition = this.buffer.clipPosition(this.position.traverse({row: 1000, column: 0})) + const text = this.buffer.getTextInRange([this.position, endPosition]) + this.position = endPosition + return text + } +} + +function last (array) { + return array[array.length - 1] +} + +// TODO: Remove this once TreeSitterLanguageMode implements its own auto-indent system. +[ + '_suggestedIndentForLineWithScopeAtBufferRow', + 'suggestedIndentForEditedBufferRow', + 'increaseIndentRegexForScopeDescriptor', + 'decreaseIndentRegexForScopeDescriptor', + 'decreaseNextIndentRegexForScopeDescriptor', + 'regexForPattern' +].forEach(methodName => { + module.exports.prototype[methodName] = TextMateLanguageMode.prototype[methodName] +}) diff --git a/src/window-event-handler.js b/src/window-event-handler.js index 6d380819b..da735294e 100644 --- a/src/window-event-handler.js +++ b/src/window-event-handler.js @@ -9,6 +9,7 @@ class WindowEventHandler { this.handleFocusNext = this.handleFocusNext.bind(this) this.handleFocusPrevious = this.handleFocusPrevious.bind(this) this.handleWindowBlur = this.handleWindowBlur.bind(this) + this.handleWindowResize = this.handleWindowResize.bind(this) this.handleEnterFullScreen = this.handleEnterFullScreen.bind(this) this.handleLeaveFullScreen = this.handleLeaveFullScreen.bind(this) this.handleWindowBeforeunload = this.handleWindowBeforeunload.bind(this) @@ -51,6 +52,7 @@ class WindowEventHandler { this.addEventListener(this.window, 'beforeunload', this.handleWindowBeforeunload) this.addEventListener(this.window, 'focus', this.handleWindowFocus) this.addEventListener(this.window, 'blur', this.handleWindowBlur) + this.addEventListener(this.window, 'resize', this.handleWindowResize) this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent) this.addEventListener(this.document, 'keydown', this.handleDocumentKeyEvent) @@ -189,6 +191,10 @@ class WindowEventHandler { this.atomEnvironment.storeWindowDimensions() } + handleWindowResize () { + this.atomEnvironment.storeWindowDimensions() + } + handleEnterFullScreen () { this.document.body.classList.add('fullscreen') } diff --git a/static/docks.less b/static/docks.less index ca40a2c45..301d7aee5 100644 --- a/static/docks.less +++ b/static/docks.less @@ -16,9 +16,10 @@ atom-dock { .atom-dock-inner { display: flex; - // Keep the area at least a pixel wide so that you have something to hover + // Keep the area at least 2 pixels wide so that you have something to hover // over to trigger the toggle button affordance even when fullscreen. - &.left, &.right { min-width: 1px; } + // Needs to be 2 pixels to work on Windows when scaled to 150%. See atom/atom #15728 + &.left, &.right { min-width: 2px; } &.bottom { min-height: 1px; } &.bottom { width: 100%; }