diff --git a/.pairs b/.pairs deleted file mode 100644 index 295531028..000000000 --- a/.pairs +++ /dev/null @@ -1,17 +0,0 @@ -pairs: - ns: Nathan Sobo; nathan - cj: Corey Johnson; cj - dg: David Graham; dgraham - ks: Kevin Sawicki; kevin - jc: Jerry Cheung; jerry - bl: Brian Lopez; brian - jp: Justin Palmer; justin - gt: Garen Torikian; garen - mc: Matt Colyer; mcolyer - bo: Ben Ogle; benogle - jr: Jason Rudolph; jasonrudolph - jl: Jessica Lord; jlord - dh: Daniel Hengeveld; danielh -email: - domain: github.com -#global: true 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 0f0d2d5a2..4d01f82df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ This project and everyone participating in it is governed by the [Atom Code of C ## I don't want to read this whole thing I just have a question!!! -> **Note:** [Please don't file an issue to ask a question.](http://blog.atom.io/2016/04/19/managing-the-deluge-of-atom-issues.html) You'll get faster results by using the resources below. +> **Note:** [Please don't file an issue to ask a question.](https://blog.atom.io/2016/04/19/managing-the-deluge-of-atom-issues.html) You'll get faster results by using the resources below. We have an official message board with a detailed FAQ and where the community chimes in with helpful advice if you have questions. @@ -45,7 +45,7 @@ We have an official message board with a detailed FAQ and where the community ch If chat is more your speed, you can join the Atom and Electron Slack team: -* [Join the Atom and Electron Slack Team](http://atom-slack.herokuapp.com/) +* [Join the Atom and Electron Slack Team](https://atom-slack.herokuapp.com/) * Even though Slack is a chat service, sometimes it takes several hours for community members to respond — please be patient! * Use the `#atom` channel for general questions or discussion about Atom * Use the `#electron` channel for questions about Electron @@ -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). @@ -119,10 +119,10 @@ Before creating bug reports, please check [this list](#before-submitting-a-bug-r #### Before Submitting A Bug Report -* **Check the [debugging guide](http://flight-manual.atom.io/hacking-atom/sections/debugging/).** You might be able to find the cause of the problem and fix things yourself. Most importantly, check if you can reproduce the problem [in the latest version of Atom](http://flight-manual.atom.io/hacking-atom/sections/debugging/#update-to-the-latest-version), if the problem happens when you run Atom in [safe mode](http://flight-manual.atom.io/hacking-atom/sections/debugging/#check-if-the-problem-shows-up-in-safe-mode), and if you can get the desired behavior by changing [Atom's or packages' config settings](http://flight-manual.atom.io/hacking-atom/sections/debugging/#check-atom-and-package-settings). +* **Check the [debugging guide](https://flight-manual.atom.io/hacking-atom/sections/debugging/).** You might be able to find the cause of the problem and fix things yourself. Most importantly, check if you can reproduce the problem [in the latest version of Atom](https://flight-manual.atom.io/hacking-atom/sections/debugging/#update-to-the-latest-version), if the problem happens when you run Atom in [safe mode](https://flight-manual.atom.io/hacking-atom/sections/debugging/#check-if-the-problem-shows-up-in-safe-mode), and if you can get the desired behavior by changing [Atom's or packages' config settings](https://flight-manual.atom.io/hacking-atom/sections/debugging/#check-atom-and-package-settings). * **Check the [FAQs on the forum](https://discuss.atom.io/c/faq)** for a list of common questions and problems. * **Determine [which repository the problem should be reported in](#atom-and-packages)**. -* **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Aatom)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. +* **Perform a [cursory search](https://github.com/search?q=+is%3Aissue+user%3Aatom)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. #### How Do I Submit A (Good) Bug Report? @@ -135,15 +135,15 @@ Explain the problem and include additional details to help maintainers reproduce * **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. * **Explain which behavior you expected to see instead and why.** -* **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. If you use the keyboard while following the steps, **record the GIF with the [Keybinding Resolver](https://github.com/atom/keybinding-resolver) shown**. You can use [this tool](http://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. +* **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. If you use the keyboard while following the steps, **record the GIF with the [Keybinding Resolver](https://github.com/atom/keybinding-resolver) shown**. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. * **If you're reporting that Atom crashed**, include a crash report with a stack trace from the operating system. On macOS, the crash report will be available in `Console.app` under "Diagnostic and usage information" > "User diagnostic reports". Include the crash report in the issue in a [code block](https://help.github.com/articles/markdown-basics/#multiple-lines), a [file attachment](https://help.github.com/articles/file-attachments-on-issues-and-pull-requests/), or put it in a [gist](https://gist.github.com/) and provide link to that gist. -* **If the problem is related to performance or memory**, include a [CPU profile capture](http://flight-manual.atom.io/hacking-atom/sections/debugging/#diagnose-runtime-performance) with your report. -* **If Chrome's developer tools pane is shown without you triggering it**, that normally means that you have a syntax error in one of your themes or in your `styles.less`. Try running in [Safe Mode](http://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode) and using a different theme or comment out the contents of your `styles.less` to see if that fixes the problem. +* **If the problem is related to performance or memory**, include a [CPU profile capture](https://flight-manual.atom.io/hacking-atom/sections/debugging/#diagnose-runtime-performance) with your report. +* **If Chrome's developer tools pane is shown without you triggering it**, that normally means that you have a syntax error in one of your themes or in your `styles.less`. Try running in [Safe Mode](https://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode) and using a different theme or comment out the contents of your `styles.less` to see if that fixes the problem. * **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below. Provide more context by answering these questions: -* **Can you reproduce the problem in [safe mode](http://flight-manual.atom.io/hacking-atom/sections/debugging/#diagnose-runtime-performance-problems-with-the-dev-tools-cpu-profiler)?** +* **Can you reproduce the problem in [safe mode](https://flight-manual.atom.io/hacking-atom/sections/debugging/#diagnose-runtime-performance-problems-with-the-dev-tools-cpu-profiler)?** * **Did the problem start happening recently** (e.g. after updating to a new version of Atom) or was this always a problem? * If the problem started happening recently, **can you reproduce the problem in an older version of Atom?** What's the most recent version in which the problem doesn't happen? You can download older versions of Atom from [the releases page](https://github.com/atom/atom/releases). * **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. @@ -155,7 +155,7 @@ Include details about your configuration and environment: * **What's the name and version of the OS you're using**? * **Are you running Atom in a virtual machine?** If so, which VM software are you using and which operating systems and versions are used for the host and the guest? * **Which [packages](#atom-and-packages) do you have installed?** You can get that list by running `apm list --installed`. -* **Are you using [local configuration files](http://flight-manual.atom.io/using-atom/sections/basic-customization/)** `config.cson`, `keymap.cson`, `snippets.cson`, `styles.less` and `init.coffee` to customize Atom? If so, provide the contents of those files, preferably in a [code block](https://help.github.com/articles/markdown-basics/#multiple-lines) or with a link to a [gist](https://gist.github.com/). +* **Are you using [local configuration files](https://flight-manual.atom.io/using-atom/sections/basic-customization/)** `config.cson`, `keymap.cson`, `snippets.cson`, `styles.less` and `init.coffee` to customize Atom? If so, provide the contents of those files, preferably in a [code block](https://help.github.com/articles/markdown-basics/#multiple-lines) or with a link to a [gist](https://gist.github.com/). * **Are you using Atom with multiple monitors?** If so, can you reproduce the problem when you use a single monitor? * **Which keyboard layout are you using?** Are you using a US layout or some other layout? @@ -167,10 +167,10 @@ Before creating enhancement suggestions, please check [this list](#before-submit #### Before Submitting An Enhancement Suggestion -* **Check the [debugging guide](http://flight-manual.atom.io/hacking-atom/sections/debugging/)** for tips — you might discover that the enhancement is already available. Most importantly, check if you're using [the latest version of Atom](http://flight-manual.atom.io/hacking-atom/sections/debugging/#update-to-the-latest-version) and if you can get the desired behavior by changing [Atom's or packages' config settings](http://flight-manual.atom.io/hacking-atom/sections/debugging/#check-atom-and-package-settings). +* **Check the [debugging guide](https://flight-manual.atom.io/hacking-atom/sections/debugging/)** for tips — you might discover that the enhancement is already available. Most importantly, check if you're using [the latest version of Atom](https://flight-manual.atom.io/hacking-atom/sections/debugging/#update-to-the-latest-version) and if you can get the desired behavior by changing [Atom's or packages' config settings](https://flight-manual.atom.io/hacking-atom/sections/debugging/#check-atom-and-package-settings). * **Check if there's already [a package](https://atom.io/packages) which provides that enhancement.** * **Determine [which repository the enhancement should be suggested in](#atom-and-packages).** -* **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Aatom)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +* **Perform a [cursory search](https://github.com/search?q=+is%3Aissue+user%3Aatom)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. #### How Do I Submit A (Good) Enhancement Suggestion? @@ -180,7 +180,7 @@ Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). * **Describe the current behavior** and **explain which behavior you expected to see instead** and why. -* **Include screenshots and animated GIFs** which help you demonstrate the steps or point out the part of Atom which the suggestion is related to. You can use [this tool](http://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. +* **Include screenshots and animated GIFs** which help you demonstrate the steps or point out the part of Atom which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. * **Explain why this enhancement would be useful** to most Atom users and isn't something that can or should be implemented as a [community package](#atom-and-packages). * **List some other text editors or applications where this enhancement exists.** * **Specify which version of Atom you're using.** You can get the exact version by running `atom -v` in your terminal, or by starting Atom and running the `Application: About` command from the [Command Palette](https://github.com/atom/command-palette). @@ -195,11 +195,11 @@ Unsure where to begin contributing to Atom? You can start by looking through the Both issue lists are sorted by total number of comments. While not perfect, number of comments is a reasonable proxy for impact a given change will have. -If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](http://flight-manual.atom.io) is free and available online. You can find the source to the manual in [atom/flight-manual.atom.io](https://github.com/atom/flight-manual.atom.io). +If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](https://flight-manual.atom.io) is free and available online. You can find the source to the manual in [atom/flight-manual.atom.io](https://github.com/atom/flight-manual.atom.io). #### Local development -Atom Core and all packages can be developed locally. For instructions on how to do this, see the following sections in the [Atom Flight Manual](http://flight-manual.atom.io): +Atom Core and all packages can be developed locally. For instructions on how to do this, see the following sections in the [Atom Flight Manual](https://flight-manual.atom.io): * [Hacking on Atom Core][hacking-on-atom-core] * [Contributing to Official Atom Packages][contributing-to-official-atom-packages] @@ -210,10 +210,10 @@ Atom Core and all packages can be developed locally. For instructions on how to * Do not include issue numbers in the PR title * Include screenshots and animated GIFs in your pull request whenever possible. * Follow the [JavaScript](#javascript-styleguide) and [CoffeeScript](#coffeescript-styleguide) styleguides. -* Include thoughtfully-worded, well-structured [Jasmine](http://jasmine.github.io/) specs in the `./spec` folder. Run them using `atom --test spec`. See the [Specs Styleguide](#specs-styleguide) below. +* Include thoughtfully-worded, well-structured [Jasmine](https://jasmine.github.io/) specs in the `./spec` folder. Run them using `atom --test spec`. See the [Specs Styleguide](#specs-styleguide) below. * Document new code based on the [Documentation Styleguide](#documentation-styleguide) * End all files with a newline -* [Avoid platform-dependent code](http://flight-manual.atom.io/hacking-atom/sections/cross-platform-compatibility/) +* [Avoid platform-dependent code](https://flight-manual.atom.io/hacking-atom/sections/cross-platform-compatibility/) * Place requires in the following order: * Built in Node Modules (such as `path`) * Built in Atom and Electron Modules (such as `atom`, `remote`) @@ -230,7 +230,7 @@ Atom Core and all packages can be developed locally. For instructions on how to * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") * Limit the first line to 72 characters or less * Reference issues and pull requests liberally after the first line -* When only changing documentation, include `[ci skip]` in the commit description +* When only changing documentation, include `[ci skip]` in the commit title * Consider starting the commit message with an applicable emoji: * :art: `:art:` when improving the format/structure of the code * :racehorse: `:racehorse:` when improving performance @@ -250,7 +250,7 @@ Atom Core and all packages can be developed locally. For instructions on how to ### JavaScript Styleguide -All JavaScript must adhere to [JavaScript Standard Style](http://standardjs.com/). +All JavaScript must adhere to [JavaScript Standard Style](https://standardjs.com/). * Prefer the object spread operator (`{...anotherObj}`) to `Object.assign()` * Inline `export`s with expressions whenever possible @@ -292,7 +292,7 @@ All JavaScript must adhere to [JavaScript Standard Style](http://standardjs.com/ ### Specs Styleguide -- Include thoughtfully-worded, well-structured [Jasmine](http://jasmine.github.io/) specs in the `./spec` folder. +- Include thoughtfully-worded, well-structured [Jasmine](https://jasmine.github.io/) specs in the `./spec` folder. - Treat `describe` as a noun or situation. - Treat `it` as a statement about state or how an operation changes state. @@ -337,7 +337,7 @@ disablePackage: (name, options, callback) -> This section lists the labels we use to help us track and manage issues and pull requests. Most labels are used across all Atom repositories, but some are specific to `atom/atom`. -[GitHub search](https://help.github.com/articles/searching-issues/) makes it easy to use labels for finding groups of issues or pull requests you're interested in. For example, you might be interested in [open issues across `atom/atom` and all Atom-owned packages which are labeled as bugs, but still need to be reliably reproduced](https://github.com/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abug+label%3Aneeds-reproduction) or perhaps [open pull requests in `atom/atom` which haven't been reviewed yet](https://github.com/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+comments%3A0). To help you find issues and pull requests, each label is listed with search links for finding open items with that label in `atom/atom` only and also across all Atom repositories. We encourage you to read about [other search filters](https://help.github.com/articles/searching-issues/) which will help you write more focused queries. +[GitHub search](https://help.github.com/articles/searching-issues/) makes it easy to use labels for finding groups of issues or pull requests you're interested in. For example, you might be interested in [open issues across `atom/atom` and all Atom-owned packages which are labeled as bugs, but still need to be reliably reproduced](https://github.com/search?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abug+label%3Aneeds-reproduction) or perhaps [open pull requests in `atom/atom` which haven't been reviewed yet](https://github.com/search?utf8=%E2%9C%93&q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+comments%3A0). To help you find issues and pull requests, each label is listed with search links for finding open items with that label in `atom/atom` only and also across all Atom repositories. We encourage you to read about [other search filters](https://help.github.com/articles/searching-issues/) which will help you write more focused queries. The labels are loosely grouped by their purpose, but it's not required that every issue have a label from every group or that an issue can't have more than one label from the same group. @@ -369,7 +369,7 @@ Please open an issue on `atom/atom` if you have suggestions for new labels, and | `windows` | [search][search-atom-repo-label-windows] | [search][search-atom-org-label-windows] | Related to Atom running on Windows. | | `linux` | [search][search-atom-repo-label-linux] | [search][search-atom-org-label-linux] | Related to Atom running on Linux. | | `mac` | [search][search-atom-repo-label-mac] | [search][search-atom-org-label-mac] | Related to Atom running on macOS. | -| `documentation` | [search][search-atom-repo-label-documentation] | [search][search-atom-org-label-documentation] | Related to any type of documentation (e.g. [API documentation](https://atom.io/docs/api/latest/) and the [flight manual](http://flight-manual.atom.io/)). | +| `documentation` | [search][search-atom-repo-label-documentation] | [search][search-atom-org-label-documentation] | Related to any type of documentation (e.g. [API documentation](https://atom.io/docs/api/latest/) and the [flight manual](https://flight-manual.atom.io/)). | | `performance` | [search][search-atom-repo-label-performance] | [search][search-atom-org-label-performance] | Related to performance. | | `security` | [search][search-atom-repo-label-security] | [search][search-atom-org-label-security] | Related to security. | | `ui` | [search][search-atom-repo-label-ui] | [search][search-atom-org-label-ui] | Related to visual design. | @@ -405,94 +405,94 @@ Please open an issue on `atom/atom` if you have suggestions for new labels, and | `requires-changes` | [search][search-atom-repo-label-requires-changes] | [search][search-atom-org-label-requires-changes] | Pull requests which need to be updated based on review comments and then reviewed again. | | `needs-testing` | [search][search-atom-repo-label-needs-testing] | [search][search-atom-org-label-needs-testing] | Pull requests which need manual testing. | -[search-atom-repo-label-enhancement]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aenhancement -[search-atom-org-label-enhancement]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aenhancement -[search-atom-repo-label-bug]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abug -[search-atom-org-label-bug]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abug -[search-atom-repo-label-question]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aquestion -[search-atom-org-label-question]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aquestion -[search-atom-repo-label-feedback]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Afeedback -[search-atom-org-label-feedback]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Afeedback -[search-atom-repo-label-help-wanted]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ahelp-wanted -[search-atom-org-label-help-wanted]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ahelp-wanted -[search-atom-repo-label-beginner]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abeginner -[search-atom-org-label-beginner]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abeginner -[search-atom-repo-label-more-information-needed]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Amore-information-needed -[search-atom-org-label-more-information-needed]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Amore-information-needed -[search-atom-repo-label-needs-reproduction]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aneeds-reproduction -[search-atom-org-label-needs-reproduction]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aneeds-reproduction -[search-atom-repo-label-triage-help-needed]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Atriage-help-needed -[search-atom-org-label-triage-help-needed]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Atriage-help-needed -[search-atom-repo-label-windows]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awindows -[search-atom-org-label-windows]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awindows -[search-atom-repo-label-linux]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Alinux -[search-atom-org-label-linux]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Alinux -[search-atom-repo-label-mac]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Amac -[search-atom-org-label-mac]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Amac -[search-atom-repo-label-documentation]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Adocumentation -[search-atom-org-label-documentation]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Adocumentation -[search-atom-repo-label-performance]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aperformance -[search-atom-org-label-performance]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aperformance -[search-atom-repo-label-security]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Asecurity -[search-atom-org-label-security]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Asecurity -[search-atom-repo-label-ui]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aui -[search-atom-org-label-ui]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aui -[search-atom-repo-label-api]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aapi -[search-atom-org-label-api]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aapi -[search-atom-repo-label-crash]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Acrash -[search-atom-org-label-crash]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Acrash -[search-atom-repo-label-auto-indent]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aauto-indent -[search-atom-org-label-auto-indent]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aauto-indent -[search-atom-repo-label-encoding]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aencoding -[search-atom-org-label-encoding]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aencoding -[search-atom-repo-label-network]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Anetwork -[search-atom-org-label-network]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Anetwork -[search-atom-repo-label-uncaught-exception]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Auncaught-exception -[search-atom-org-label-uncaught-exception]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Auncaught-exception -[search-atom-repo-label-git]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Agit -[search-atom-org-label-git]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Agit -[search-atom-repo-label-blocked]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ablocked -[search-atom-org-label-blocked]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ablocked -[search-atom-repo-label-duplicate]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aduplicate -[search-atom-org-label-duplicate]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aduplicate -[search-atom-repo-label-wontfix]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awontfix -[search-atom-org-label-wontfix]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awontfix -[search-atom-repo-label-invalid]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ainvalid -[search-atom-org-label-invalid]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ainvalid -[search-atom-repo-label-package-idea]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Apackage-idea -[search-atom-org-label-package-idea]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Apackage-idea -[search-atom-repo-label-wrong-repo]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awrong-repo -[search-atom-org-label-wrong-repo]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awrong-repo -[search-atom-repo-label-editor-rendering]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aeditor-rendering -[search-atom-org-label-editor-rendering]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aeditor-rendering -[search-atom-repo-label-build-error]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abuild-error -[search-atom-org-label-build-error]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abuild-error -[search-atom-repo-label-error-from-pathwatcher]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-pathwatcher -[search-atom-org-label-error-from-pathwatcher]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-pathwatcher -[search-atom-repo-label-error-from-save]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-save -[search-atom-org-label-error-from-save]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-save -[search-atom-repo-label-error-from-open]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-open -[search-atom-org-label-error-from-open]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-open -[search-atom-repo-label-installer]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ainstaller -[search-atom-org-label-installer]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ainstaller -[search-atom-repo-label-auto-updater]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aauto-updater -[search-atom-org-label-auto-updater]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aauto-updater -[search-atom-repo-label-deprecation-help]: https://github.com/issues?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Adeprecation-help -[search-atom-org-label-deprecation-help]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Adeprecation-help -[search-atom-repo-label-electron]: https://github.com/issues?q=is%3Aissue+repo%3Aatom%2Fatom+is%3Aopen+label%3Aelectron -[search-atom-org-label-electron]: https://github.com/issues?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aelectron -[search-atom-repo-label-work-in-progress]: https://github.com/pulls?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Awork-in-progress -[search-atom-org-label-work-in-progress]: https://github.com/pulls?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Awork-in-progress -[search-atom-repo-label-needs-review]: https://github.com/pulls?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aneeds-review -[search-atom-org-label-needs-review]: https://github.com/pulls?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aneeds-review -[search-atom-repo-label-under-review]: https://github.com/pulls?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aunder-review -[search-atom-org-label-under-review]: https://github.com/pulls?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aunder-review -[search-atom-repo-label-requires-changes]: https://github.com/pulls?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Arequires-changes -[search-atom-org-label-requires-changes]: https://github.com/pulls?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Arequires-changes -[search-atom-repo-label-needs-testing]: https://github.com/pulls?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aneeds-testing -[search-atom-org-label-needs-testing]: https://github.com/pulls?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aneeds-testing +[search-atom-repo-label-enhancement]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aenhancement +[search-atom-org-label-enhancement]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aenhancement +[search-atom-repo-label-bug]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abug +[search-atom-org-label-bug]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abug +[search-atom-repo-label-question]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aquestion +[search-atom-org-label-question]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aquestion +[search-atom-repo-label-feedback]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Afeedback +[search-atom-org-label-feedback]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Afeedback +[search-atom-repo-label-help-wanted]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ahelp-wanted +[search-atom-org-label-help-wanted]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ahelp-wanted +[search-atom-repo-label-beginner]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abeginner +[search-atom-org-label-beginner]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abeginner +[search-atom-repo-label-more-information-needed]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Amore-information-needed +[search-atom-org-label-more-information-needed]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Amore-information-needed +[search-atom-repo-label-needs-reproduction]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aneeds-reproduction +[search-atom-org-label-needs-reproduction]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aneeds-reproduction +[search-atom-repo-label-triage-help-needed]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Atriage-help-needed +[search-atom-org-label-triage-help-needed]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Atriage-help-needed +[search-atom-repo-label-windows]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awindows +[search-atom-org-label-windows]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awindows +[search-atom-repo-label-linux]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Alinux +[search-atom-org-label-linux]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Alinux +[search-atom-repo-label-mac]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Amac +[search-atom-org-label-mac]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Amac +[search-atom-repo-label-documentation]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Adocumentation +[search-atom-org-label-documentation]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Adocumentation +[search-atom-repo-label-performance]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aperformance +[search-atom-org-label-performance]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aperformance +[search-atom-repo-label-security]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Asecurity +[search-atom-org-label-security]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Asecurity +[search-atom-repo-label-ui]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aui +[search-atom-org-label-ui]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aui +[search-atom-repo-label-api]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aapi +[search-atom-org-label-api]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aapi +[search-atom-repo-label-crash]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Acrash +[search-atom-org-label-crash]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Acrash +[search-atom-repo-label-auto-indent]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aauto-indent +[search-atom-org-label-auto-indent]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aauto-indent +[search-atom-repo-label-encoding]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aencoding +[search-atom-org-label-encoding]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aencoding +[search-atom-repo-label-network]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Anetwork +[search-atom-org-label-network]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Anetwork +[search-atom-repo-label-uncaught-exception]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Auncaught-exception +[search-atom-org-label-uncaught-exception]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Auncaught-exception +[search-atom-repo-label-git]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Agit +[search-atom-org-label-git]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Agit +[search-atom-repo-label-blocked]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ablocked +[search-atom-org-label-blocked]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ablocked +[search-atom-repo-label-duplicate]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aduplicate +[search-atom-org-label-duplicate]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aduplicate +[search-atom-repo-label-wontfix]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awontfix +[search-atom-org-label-wontfix]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awontfix +[search-atom-repo-label-invalid]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ainvalid +[search-atom-org-label-invalid]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ainvalid +[search-atom-repo-label-package-idea]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Apackage-idea +[search-atom-org-label-package-idea]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Apackage-idea +[search-atom-repo-label-wrong-repo]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Awrong-repo +[search-atom-org-label-wrong-repo]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Awrong-repo +[search-atom-repo-label-editor-rendering]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aeditor-rendering +[search-atom-org-label-editor-rendering]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aeditor-rendering +[search-atom-repo-label-build-error]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Abuild-error +[search-atom-org-label-build-error]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Abuild-error +[search-atom-repo-label-error-from-pathwatcher]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-pathwatcher +[search-atom-org-label-error-from-pathwatcher]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-pathwatcher +[search-atom-repo-label-error-from-save]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-save +[search-atom-org-label-error-from-save]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-save +[search-atom-repo-label-error-from-open]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aerror-from-open +[search-atom-org-label-error-from-open]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aerror-from-open +[search-atom-repo-label-installer]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Ainstaller +[search-atom-org-label-installer]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Ainstaller +[search-atom-repo-label-auto-updater]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Aauto-updater +[search-atom-org-label-auto-updater]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aauto-updater +[search-atom-repo-label-deprecation-help]: https://github.com/search?q=is%3Aopen+is%3Aissue+repo%3Aatom%2Fatom+label%3Adeprecation-help +[search-atom-org-label-deprecation-help]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Adeprecation-help +[search-atom-repo-label-electron]: https://github.com/search?q=is%3Aissue+repo%3Aatom%2Fatom+is%3Aopen+label%3Aelectron +[search-atom-org-label-electron]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aatom+label%3Aelectron +[search-atom-repo-label-work-in-progress]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Awork-in-progress +[search-atom-org-label-work-in-progress]: https://github.com/search?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Awork-in-progress +[search-atom-repo-label-needs-review]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aneeds-review +[search-atom-org-label-needs-review]: https://github.com/search?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aneeds-review +[search-atom-repo-label-under-review]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aunder-review +[search-atom-org-label-under-review]: https://github.com/search?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aunder-review +[search-atom-repo-label-requires-changes]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Arequires-changes +[search-atom-org-label-requires-changes]: https://github.com/search?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Arequires-changes +[search-atom-repo-label-needs-testing]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aatom%2Fatom+label%3Aneeds-testing +[search-atom-org-label-needs-testing]: https://github.com/search?q=is%3Aopen+is%3Apr+user%3Aatom+label%3Aneeds-testing -[beginner]:https://github.com/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3Abeginner+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc -[help-wanted]:https://github.com/issues?q=is%3Aopen+is%3Aissue+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc+-label%3Abeginner -[contributing-to-official-atom-packages]:http://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/ -[hacking-on-atom-core]: http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/ +[beginner]:https://github.com/search?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3Abeginner+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc +[help-wanted]:https://github.com/search?q=is%3Aopen+is%3Aissue+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc+-label%3Abeginner +[contributing-to-official-atom-packages]:https://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/ +[hacking-on-atom-core]: https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/ 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 c29203ea0..0c10b1352 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Atom will automatically update when a new release is available. ### Windows -Download the latest [Atom installer](https://github.com/atom/atom/releases/latest). AtomSetup.exe is 32-bit, AtomSetup-x64.exe for 64-bit systems. +Download the latest [Atom installer](https://github.com/atom/atom/releases/latest). `AtomSetup.exe` is 32-bit. For 64-bit systems, download `AtomSetup-x64.exe`. Atom will automatically update when a new release is available. @@ -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 @@ -82,9 +66,9 @@ repeat these steps to upgrade to future releases. ## Building * [FreeBSD](./docs/build-instructions/freebsd.md) -* [Linux](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux) -* [macOS](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac) -* [Windows](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows) +* [Linux](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux) +* [macOS](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac) +* [Windows](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows) ## License diff --git a/SUPPORT.md b/SUPPORT.md index d908b3fff..a68fa1348 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,10 +2,10 @@ If you're looking for support for Atom there are a lot of options, check out: -* User Documentation — [The Atom Flight Manual](http://flight-manual.atom.io) +* User Documentation — [The Atom Flight Manual](https://flight-manual.atom.io) * Developer Documentation — [Atom API Documentation](https://atom.io/docs/api/latest) * FAQ — [The Atom FAQ on Discuss](https://discuss.atom.io/c/faq) * Message Board — [Discuss, the official Atom and Electron message board](https://discuss.atom.io) -* Chat — [Join the Atom Slack team](http://atom-slack.herokuapp.com/) +* Chat — [Join the Atom Slack team](https://atom-slack.herokuapp.com/) On Discuss and in the Atom Slack team, there are a bunch of helpful community members that should be willing to point you in the right direction. diff --git a/apm/package.json b/apm/package.json index 336544d3e..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.10" + "atom-package-manager": "1.19.0" } } diff --git a/appveyor.yml b/appveyor.yml index 0e5abaa83..c7f2d3f9e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -43,13 +43,19 @@ build_script: - SET SQUIRREL_TEMP=C:\tmp - IF [%TASK%]==[installer] ( IF [%APPVEYOR_REPO_BRANCH:~-9%]==[-releases] ( + ECHO Building on release branch - Creating production artifacts && script\build.cmd --code-sign --compress-artifacts --create-windows-installer ) ELSE ( - ECHO Skipping installer and Atom build on non-release branch + IF [%APPVEYOR_REPO_BRANCH%]==[master] IF NOT DEFINED APPVEYOR_PULL_REQUEST_NUMBER ( + ECHO Building on master branch - Creating signed zips && + script\build.cmd --code-sign --compress-artifacts + ) ELSE ( + ECHO Skipping installer build for non-release/non-master branch + ) ) ) ELSE ( - ECHO Skipping installer build on non-installer build matrix row && - script\build.cmd --code-sign --compress-artifacts + ECHO Test build only - Not creating artifacts && + script\build.cmd ) test_script: diff --git a/atom.sh b/atom.sh index b36938bc5..cd28dd334 100755 --- a/atom.sh +++ b/atom.sh @@ -9,11 +9,17 @@ else exit 1 fi -if [ "$(basename $0)" == 'atom-beta' ]; then - BETA_VERSION=true -else - BETA_VERSION= -fi +case $(basename $0) in + atom-beta) + CHANNEL=beta + ;; + atom-dev) + CHANNEL=dev + ;; + *) + CHANNEL=stable + ;; +esac export ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT=true @@ -67,7 +73,7 @@ if [ $OS == 'Mac' ]; then ATOM_APP_NAME="$(basename "$ATOM_APP")" fi - if [ -n "$BETA_VERSION" ]; then + if [ "$CHANNEL" == 'beta' ]; then ATOM_EXECUTABLE_NAME="Atom Beta" else ATOM_EXECUTABLE_NAME="Atom" @@ -101,11 +107,17 @@ elif [ $OS == 'Linux' ]; then SCRIPT=$(readlink -f "$0") USR_DIRECTORY=$(readlink -f $(dirname $SCRIPT)/..) - if [ -n "$BETA_VERSION" ]; then - ATOM_PATH="$USR_DIRECTORY/share/atom-beta/atom" - else - ATOM_PATH="$USR_DIRECTORY/share/atom/atom" - fi + case $CHANNEL in + beta) + ATOM_PATH="$USR_DIRECTORY/share/atom-beta/atom" + ;; + dev) + ATOM_PATH="$USR_DIRECTORY/share/atom-dev/atom" + ;; + *) + ATOM_PATH="$USR_DIRECTORY/share/atom/atom" + ;; + esac ATOM_HOME="${ATOM_HOME:-$HOME/.atom}" mkdir -p "$ATOM_HOME" diff --git a/benchmarks/benchmark-runner.js b/benchmarks/benchmark-runner.js index 56a37cfd4..7c45b442c 100644 --- a/benchmarks/benchmark-runner.js +++ b/benchmarks/benchmark-runner.js @@ -1,11 +1,9 @@ -/** @babel */ +const Chart = require('chart.js') +const glob = require('glob') +const fs = require('fs-plus') +const path = require('path') -import Chart from 'chart.js' -import glob from 'glob' -import fs from 'fs-plus' -import path from 'path' - -export default async function ({test, benchmarkPaths}) { +module.exports = async ({test, benchmarkPaths}) => { document.body.style.backgroundColor = '#ffffff' document.body.style.overflow = 'auto' diff --git a/benchmarks/text-editor-large-file-construction.bench.js b/benchmarks/text-editor-large-file-construction.bench.js index ec037e9e4..ff564e5ca 100644 --- a/benchmarks/text-editor-large-file-construction.bench.js +++ b/benchmarks/text-editor-large-file-construction.bench.js @@ -1,6 +1,4 @@ -/** @babel */ - -import {TextEditor, TextBuffer} from 'atom' +const {TextEditor, TextBuffer} = require('atom') const MIN_SIZE_IN_KB = 0 * 1024 const MAX_SIZE_IN_KB = 10 * 1024 @@ -8,7 +6,7 @@ const SIZE_STEP_IN_KB = 1024 const LINE_TEXT = 'Lorem ipsum dolor sit amet\n' const TEXT = LINE_TEXT.repeat(Math.ceil(MAX_SIZE_IN_KB * 1024 / LINE_TEXT.length)) -export default async function ({test}) { +module.exports = async ({test}) => { const data = [] document.body.appendChild(atom.workspace.getElement()) @@ -27,6 +25,7 @@ export default async function ({test}) { let t0 = window.performance.now() const buffer = new TextBuffer({text}) const editor = new TextEditor({buffer, autoHeight: false, largeFileMode: true}) + atom.grammars.autoAssignLanguageMode(buffer) atom.workspace.getActivePane().activateItem(editor) let t1 = window.performance.now() diff --git a/benchmarks/text-editor-long-lines.bench.js b/benchmarks/text-editor-long-lines.bench.js index ac90e4a71..92a9b9b9e 100644 --- a/benchmarks/text-editor-long-lines.bench.js +++ b/benchmarks/text-editor-long-lines.bench.js @@ -1,8 +1,6 @@ -/** @babel */ - -import path from 'path' -import fs from 'fs' -import {TextEditor, TextBuffer} from 'atom' +const path = require('path') +const fs = require('fs') +const {TextEditor, TextBuffer} = require('atom') const SIZES_IN_KB = [ 512, @@ -12,7 +10,7 @@ const SIZES_IN_KB = [ const REPEATED_TEXT = fs.readFileSync(path.join(__dirname, '..', 'spec', 'fixtures', 'sample.js'), 'utf8').replace(/\n/g, '') const TEXT = REPEATED_TEXT.repeat(Math.ceil(SIZES_IN_KB[SIZES_IN_KB.length - 1] * 1024 / REPEATED_TEXT.length)) -export default async function ({test}) { +module.exports = async ({test}) => { const data = [] const workspaceElement = atom.workspace.getElement() @@ -34,7 +32,7 @@ export default async function ({test}) { let t0 = window.performance.now() const buffer = new TextBuffer({text}) const editor = new TextEditor({buffer, autoHeight: false, largeFileMode: true}) - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + atom.grammars.assignLanguageMode(buffer, 'source.js') atom.workspace.getActivePane().activateItem(editor) let t1 = window.performance.now() 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/docs/build-instructions/build-status.md b/docs/build-instructions/build-status.md index e4fca6661..9bc806e88 100644 --- a/docs/build-instructions/build-status.md +++ b/docs/build-instructions/build-status.md @@ -114,4 +114,4 @@ | [TODO](https://github.com/atom/language-todo) | [![macOS Build Status](https://travis-ci.org/atom/language-todo.svg?branch=master)](https://travis-ci.org/atom/language-todo) | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/gcgb9m7h146lv6qp/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/language-todo/branch/master) | | [TOML](https://github.com/atom/language-toml) | [![macOS Build Status](https://travis-ci.org/atom/language-toml.svg?branch=master)](https://travis-ci.org/atom/language-toml) | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/kohao3fjyk6xv0sc/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/language-toml/branch/master) | | [XML](https://github.com/atom/language-xml) | [![macOS Build Status](https://travis-ci.org/atom/language-xml.svg?branch=master)](https://travis-ci.org/atom/language-xml) | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/m5f6rn74a6h3q5uq/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/language-xml/branch/master) | -| [YAML](https://github/atom/language-yaml) | [![macOS Build Status](https://travis-ci.org/atom/language-yaml.svg?branch=master)](https://travis-ci.org/atom/language-yaml) | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/eaa4ql7kipgphc2n/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/language-yaml/branch/master) | +| [YAML](https://github.com/atom/language-yaml) | [![macOS Build Status](https://travis-ci.org/atom/language-yaml.svg?branch=master)](https://travis-ci.org/atom/language-yaml) | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/eaa4ql7kipgphc2n/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/language-yaml/branch/master) | diff --git a/docs/build-instructions/linux.md b/docs/build-instructions/linux.md index 3499f6ac9..12e9f68ef 100644 --- a/docs/build-instructions/linux.md +++ b/docs/build-instructions/linux.md @@ -1 +1 @@ -See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux) section in the [Atom Flight Manual](http://flight-manual.atom.io). +See the [Hacking on Atom Core](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux) section in the [Atom Flight Manual](https://flight-manual.atom.io). diff --git a/docs/build-instructions/macOS.md b/docs/build-instructions/macOS.md index 3085d11f3..8a0f7b1dd 100644 --- a/docs/build-instructions/macOS.md +++ b/docs/build-instructions/macOS.md @@ -1 +1 @@ -See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac) section in the [Atom Flight Manual](http://flight-manual.atom.io). +See the [Hacking on Atom Core](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac) section in the [Atom Flight Manual](https://flight-manual.atom.io). diff --git a/docs/build-instructions/windows.md b/docs/build-instructions/windows.md index f75a07530..49b5fa74c 100644 --- a/docs/build-instructions/windows.md +++ b/docs/build-instructions/windows.md @@ -1 +1 @@ -See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows) section in the [Atom Flight Manual](http://flight-manual.atom.io). +See the [Hacking on Atom Core](https://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows) section in the [Atom Flight Manual](https://flight-manual.atom.io). diff --git a/docs/contributing-to-packages.md b/docs/contributing-to-packages.md index 67933dc26..a91e3575e 100644 --- a/docs/contributing-to-packages.md +++ b/docs/contributing-to-packages.md @@ -1 +1 @@ -See http://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/ +See https://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/ 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 3f94ad81e..974a9ab95 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.1", "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.8.3", + "text-buffer": "13.11.5", + "tree-sitter": "^0.8.6", "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", - "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.37.3", - "autocomplete-snippets": "1.11.2", - "autoflow": "0.29.0", + "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.8.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.2", + "autocomplete-snippets": "1.12.0", + "autoflow": "0.29.3", "autosave": "0.24.6", "background-tips": "0.27.1", - "bookmarks": "0.44.4", - "bracket-matcher": "0.88.0", - "command-palette": "0.42.0", + "bookmarks": "0.45.1", + "bracket-matcher": "0.89.0", + "command-palette": "0.43.0", "dalek": "0.2.1", "deprecation-cop": "0.56.9", - "dev-live-reload": "0.47.1", - "encoding-selector": "0.23.7", - "exception-reporting": "0.41.5", - "find-and-replace": "0.214.0", - "fuzzy-finder": "1.7.3", - "github": "0.8.2", - "git-diff": "1.3.6", + "dev-live-reload": "0.48.1", + "encoding-selector": "0.23.8", + "exception-reporting": "0.42.0", + "find-and-replace": "0.215.5", + "fuzzy-finder": "1.7.5", + "github": "0.9.1", + "git-diff": "1.3.9", "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", - "link": "0.31.3", - "markdown-preview": "0.159.18", + "line-ending-selector": "0.7.5", + "link": "0.31.4", + "markdown-preview": "0.159.20", "metrics": "1.2.6", - "notifications": "0.69.2", - "open-on-github": "1.3.0", - "package-generator": "1.1.1", - "settings-view": "0.253.0", - "snippets": "1.1.9", - "spell-check": "0.72.3", + "notifications": "0.70.2", + "open-on-github": "1.3.1", + "package-generator": "1.3.0", + "settings-view": "0.254.0", + "snippets": "1.3.0", + "spell-check": "0.72.7", "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", - "welcome": "0.36.5", + "update-package-dependencies": "0.13.1", + "welcome": "0.36.6", "whitespace": "0.37.5", - "wrap-guide": "0.40.2", - "language-c": "0.58.1", - "language-clojure": "0.22.4", + "wrap-guide": "0.40.3", + "language-c": "0.59.1", + "language-clojure": "0.22.6", "language-coffee-script": "0.49.3", - "language-csharp": "0.14.3", - "language-css": "0.42.7", - "language-gfm": "0.90.2", + "language-csharp": "0.14.4", + "language-css": "0.42.9", + "language-gfm": "0.90.3", "language-git": "0.19.1", - "language-go": "0.44.3", - "language-html": "0.48.2", + "language-go": "0.45.0", + "language-html": "0.48.6", "language-hyperlink": "0.16.3", "language-java": "0.27.6", - "language-javascript": "0.127.6", + "language-javascript": "0.128.1", "language-json": "0.19.1", - "language-less": "0.33.0", + "language-less": "0.34.2", "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.47.0", "language-ruby": "0.71.4", - "language-ruby-on-rails": "0.25.2", - "language-sass": "0.61.1", - "language-shellscript": "0.25.4", + "language-ruby-on-rails": "0.25.3", + "language-sass": "0.61.4", + "language-shellscript": "0.26.0", "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", "language-xml": "0.35.2", "language-yaml": "0.31.1" }, diff --git a/script/build b/script/build index acc54cdac..55cebe96d 100755 --- a/script/build +++ b/script/build @@ -28,6 +28,7 @@ const argv = yargs const checkChromedriverVersion = require('./lib/check-chromedriver-version') const cleanOutputDirectory = require('./lib/clean-output-directory') +const cleanPackageLock = require('./lib/clean-package-lock') const codeSignOnMac = require('./lib/code-sign-on-mac') const codeSignOnWindows = require('./lib/code-sign-on-windows') const compressArtifacts = require('./lib/compress-artifacts') @@ -58,6 +59,7 @@ const CONFIG = require('./config') let binariesPromise = Promise.resolve() if (!argv.existingBinaries) { + cleanPackageLock() checkChromedriverVersion() cleanOutputDirectory() copyAssets() diff --git a/script/create-installer.cmd b/script/create-installer.cmd deleted file mode 100644 index 0354f0bac..000000000 --- a/script/create-installer.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@ECHO OFF -IF NOT EXIST C:\sqtemp MKDIR C:\sqtemp -SET SQUIRREL_TEMP=C:\sqtemp -del script\package-lock.json /q -del apm\package-lock.json /q -script\build.cmd --existing-binaries --code-sign --create-windows-installer diff --git a/script/lib/clean-package-lock.js b/script/lib/clean-package-lock.js new file mode 100644 index 000000000..01376c9c5 --- /dev/null +++ b/script/lib/clean-package-lock.js @@ -0,0 +1,18 @@ +// This module exports a function that deletes all `package-lock.json` files that do +// not exist under a `node_modules` directory. + +'use strict' + +const CONFIG = require('../config') +const fs = require('fs-extra') +const glob = require('glob') +const path = require('path') + +module.exports = function () { + console.log('Deleting problematic package-lock.json files') + let paths = glob.sync(path.join(CONFIG.repositoryRootPath, '**', 'package-lock.json'), {ignore: path.join('**', 'node_modules', '**')}) + + for (let path of paths) { + fs.unlinkSync(path) + } +} diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 333acdc0a..1078ab20e 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,9 @@ 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') || + relativePath === path.join('..', 'node_modules', 'winreg', 'lib', 'registry.js') ) } }).then((snapshotScript) => { @@ -85,7 +88,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/lib/install-application.js b/script/lib/install-application.js index d21a6e53c..8a29372cd 100644 --- a/script/lib/install-application.js +++ b/script/lib/install-application.js @@ -4,30 +4,54 @@ const fs = require('fs-extra') const handleTilde = require('./handle-tilde') const path = require('path') const template = require('lodash.template') +const startCase = require('lodash.startcase') +const execSync = require('child_process').execSync const CONFIG = require('../config') +function install (installationDirPath, packagedAppFileName, packagedAppPath) { + if (fs.existsSync(installationDirPath)) { + console.log(`Removing previously installed "${packagedAppFileName}" at "${installationDirPath}"`) + fs.removeSync(installationDirPath) + } + + console.log(`Installing "${packagedAppFileName}" at "${installationDirPath}"`) + fs.copySync(packagedAppPath, installationDirPath) +} + +/** + * Finds the path to the base directory of the icon default icon theme + * This follows the freedesktop Icon Theme Specification: + * https://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#install_icons + * and the XDG Base Directory Specification: + * https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables + */ +function findBaseIconThemeDirPath () { + const defaultBaseIconThemeDir = '/usr/share/icons/hicolor' + const dataDirsString = process.env.XDG_DATA_DIRS + if (dataDirsString) { + const dataDirs = dataDirsString.split(path.delimiter) + if (dataDirs.includes('/usr/share/') || dataDirs.includes('/usr/share')) { + return defaultBaseIconThemeDir + } else { + return path.join(dataDirs[0], 'icons', 'hicolor') + } + } else { + return defaultBaseIconThemeDir + } +} + module.exports = function (packagedAppPath, installDir) { const packagedAppFileName = path.basename(packagedAppPath) if (process.platform === 'darwin') { const installPrefix = installDir !== '' ? handleTilde(installDir) : path.join(path.sep, 'Applications') const installationDirPath = path.join(installPrefix, packagedAppFileName) - if (fs.existsSync(installationDirPath)) { - console.log(`Removing previously installed "${packagedAppFileName}" at "${installationDirPath}"`) - fs.removeSync(installationDirPath) - } - console.log(`Installing "${packagedAppPath}" at "${installationDirPath}"`) - fs.copySync(packagedAppPath, installationDirPath) + install(installationDirPath, packagedAppFileName, packagedAppPath) } else if (process.platform === 'win32') { const installPrefix = installDir !== '' ? installDir : process.env.LOCALAPPDATA const installationDirPath = path.join(installPrefix, packagedAppFileName, 'app-dev') try { - if (fs.existsSync(installationDirPath)) { - console.log(`Removing previously installed "${packagedAppFileName}" at "${installationDirPath}"`) - fs.removeSync(installationDirPath) - } - console.log(`Installing "${packagedAppPath}" at "${installationDirPath}"`) - fs.copySync(packagedAppPath, installationDirPath) + install(installationDirPath, packagedAppFileName, packagedAppPath) } catch (e) { console.log(`Administrator elevation required to install into "${installationDirPath}"`) const fsAdmin = require('fs-admin') @@ -38,59 +62,92 @@ module.exports = function (packagedAppPath, installDir) { }) } } else { - const atomExecutableName = CONFIG.channel === 'beta' ? 'atom-beta' : 'atom' - const apmExecutableName = CONFIG.channel === 'beta' ? 'apm-beta' : 'apm' - const appName = CONFIG.channel === 'beta' ? 'Atom Beta' : 'Atom' + const atomExecutableName = CONFIG.channel === 'stable' ? 'atom' : 'atom-' + CONFIG.channel + const apmExecutableName = CONFIG.channel === 'stable' ? 'apm' : 'apm-' + CONFIG.channel + const appName = CONFIG.channel === 'stable' ? 'Atom' : startCase('Atom ' + CONFIG.channel) const appDescription = CONFIG.appMetadata.description const prefixDirPath = installDir !== '' ? handleTilde(installDir) : path.join('/usr', 'local') const shareDirPath = path.join(prefixDirPath, 'share') const installationDirPath = path.join(shareDirPath, atomExecutableName) const applicationsDirPath = path.join(shareDirPath, 'applications') - const desktopEntryPath = path.join(applicationsDirPath, `${atomExecutableName}.desktop`) + const binDirPath = path.join(prefixDirPath, 'bin') - const atomBinDestinationPath = path.join(binDirPath, atomExecutableName) - const apmBinDestinationPath = path.join(binDirPath, apmExecutableName) fs.mkdirpSync(applicationsDirPath) fs.mkdirpSync(binDirPath) - if (fs.existsSync(installationDirPath)) { - console.log(`Removing previously installed "${packagedAppFileName}" at "${installationDirPath}"`) - fs.removeSync(installationDirPath) - } - console.log(`Installing "${packagedAppFileName}" at "${installationDirPath}"`) - fs.copySync(packagedAppPath, installationDirPath) + install(installationDirPath, packagedAppFileName, packagedAppPath) - if (fs.existsSync(desktopEntryPath)) { - console.log(`Removing existing desktop entry file at "${desktopEntryPath}"`) - fs.removeSync(desktopEntryPath) - } - console.log(`Writing desktop entry file at "${desktopEntryPath}"`) - const iconPath = path.join(CONFIG.repositoryRootPath, 'resources', 'app-icons', CONFIG.channel, 'png', '1024.png') - const desktopEntryTemplate = fs.readFileSync(path.join(CONFIG.repositoryRootPath, 'resources', 'linux', 'atom.desktop.in')) - const desktopEntryContents = template(desktopEntryTemplate)({ - appName, - appFileName: atomExecutableName, - description: appDescription, - installDir: prefixDirPath, - iconPath - }) - fs.writeFileSync(desktopEntryPath, desktopEntryContents) + { // Install icons + const baseIconThemeDirPath = findBaseIconThemeDirPath() + const fullIconName = atomExecutableName + '.png' - if (fs.existsSync(atomBinDestinationPath)) { - console.log(`Removing existing executable at "${atomBinDestinationPath}"`) - fs.removeSync(atomBinDestinationPath) - } - console.log(`Copying atom.sh to "${atomBinDestinationPath}"`) - fs.copySync(path.join(CONFIG.repositoryRootPath, 'atom.sh'), atomBinDestinationPath) + let existingIconsFound = false + fs.readdirSync(baseIconThemeDirPath).forEach(size => { + const iconPath = path.join(baseIconThemeDirPath, size, 'apps', fullIconName) + if (fs.existsSync(iconPath)) { + if (!existingIconsFound) { + console.log(`Removing existing icons from "${baseIconThemeDirPath}"`) + } + existingIconsFound = true + fs.removeSync(iconPath) + } + }) - try { - fs.lstatSync(apmBinDestinationPath) - console.log(`Removing existing executable at "${apmBinDestinationPath}"`) - fs.removeSync(apmBinDestinationPath) - } catch (e) { } - console.log(`Symlinking apm to "${apmBinDestinationPath}"`) - fs.symlinkSync(path.join('..', 'share', atomExecutableName, 'resources', 'app', 'apm', 'node_modules', '.bin', 'apm'), apmBinDestinationPath) + console.log(`Installing icons at "${baseIconThemeDirPath}"`) + const appIconsPath = path.join(CONFIG.repositoryRootPath, 'resources', 'app-icons', CONFIG.channel, 'png') + fs.readdirSync(appIconsPath).forEach(imageName => { + if (/\.png$/.test(imageName)) { + const size = path.basename(imageName, '.png') + const iconPath = path.join(appIconsPath, imageName) + fs.copySync(iconPath, path.join(baseIconThemeDirPath, `${size}x${size}`, 'apps', fullIconName)) + } + }) + + console.log(`Updating icon cache for "${baseIconThemeDirPath}"`) + try { + execSync(`gtk-update-icon-cache ${baseIconThemeDirPath} --force`) + } catch (e) {} + } + + { // Install xdg desktop file + const desktopEntryPath = path.join(applicationsDirPath, `${atomExecutableName}.desktop`) + if (fs.existsSync(desktopEntryPath)) { + console.log(`Removing existing desktop entry file at "${desktopEntryPath}"`) + fs.removeSync(desktopEntryPath) + } + console.log(`Writing desktop entry file at "${desktopEntryPath}"`) + const desktopEntryTemplate = fs.readFileSync(path.join(CONFIG.repositoryRootPath, 'resources', 'linux', 'atom.desktop.in')) + const desktopEntryContents = template(desktopEntryTemplate)({ + appName, + appFileName: atomExecutableName, + description: appDescription, + installDir: prefixDirPath, + iconPath: atomExecutableName + }) + fs.writeFileSync(desktopEntryPath, desktopEntryContents) + } + + { // Add atom executable to the PATH + const atomBinDestinationPath = path.join(binDirPath, atomExecutableName) + if (fs.existsSync(atomBinDestinationPath)) { + console.log(`Removing existing executable at "${atomBinDestinationPath}"`) + fs.removeSync(atomBinDestinationPath) + } + console.log(`Copying atom.sh to "${atomBinDestinationPath}"`) + fs.copySync(path.join(CONFIG.repositoryRootPath, 'atom.sh'), atomBinDestinationPath) + } + + { // Link apm executable to the PATH + const apmBinDestinationPath = path.join(binDirPath, apmExecutableName) + try { + fs.lstatSync(apmBinDestinationPath) + console.log(`Removing existing executable at "${apmBinDestinationPath}"`) + fs.removeSync(apmBinDestinationPath) + } catch (e) { } + console.log(`Symlinking apm to "${apmBinDestinationPath}"`) + fs.symlinkSync(path.join('..', 'share', atomExecutableName, 'resources', 'app', 'apm', 'node_modules', '.bin', 'apm'), apmBinDestinationPath) + } console.log(`Changing permissions to 755 for "${installationDirPath}"`) fs.chmodSync(installationDirPath, '755') diff --git a/script/package.json b/script/package.json index 4cf1bfb8c..afc034df3 100644 --- a/script/package.json +++ b/script/package.json @@ -8,17 +8,18 @@ "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", + "electron-winstaller": "2.6.4", "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.startcase": "4.4.0", "lodash.template": "4.4.0", "minidump": "0.9.0", "mkdirp": "0.5.1", diff --git a/script/test b/script/test index 2f22b1e0a..c6c3a6a61 100755 --- a/script/test +++ b/script/test @@ -3,6 +3,7 @@ 'use strict' require('colors') +const argv = require('yargs').argv const assert = require('assert') const async = require('async') const childProcess = require('child_process') @@ -150,17 +151,26 @@ function runBenchmarkTests (callback) { let testSuitesToRun = testSuitesForPlatform(process.platform) function testSuitesForPlatform (platform) { + let suites = []; switch (platform) { case 'darwin': - return [runCoreMainProcessTests, runCoreRenderProcessTests, runBenchmarkTests].concat(packageTestSuites) + suites = [runCoreMainProcessTests, runCoreRenderProcessTests, runBenchmarkTests].concat(packageTestSuites) + break case 'win32': - return (process.arch === 'x64') ? [runCoreMainProcessTests, runCoreRenderProcessTests] : [runCoreMainProcessTests] + suites = (process.arch === 'x64') ? [runCoreMainProcessTests, runCoreRenderProcessTests] : [runCoreMainProcessTests] + break case 'linux': - return [runCoreMainProcessTests] + suites = [runCoreMainProcessTests] + break default: console.log(`Unrecognized platform: ${platform}`) - return [] } + + if (argv.skipMainProcessTests) { + suites = suites.filter(suite => suite !== runCoreMainProcessTests); + } + + return suites; } async.series(testSuitesToRun, function (err, exitCodes) { diff --git a/spec/atom-environment-spec.js b/spec/atom-environment-spec.js index 84b415eab..324e9eddf 100644 --- a/spec/atom-environment-spec.js +++ b/spec/atom-environment-spec.js @@ -1,5 +1,6 @@ -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {it, fit, ffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') const _ = require('underscore-plus') +const fs = require('fs') const path = require('path') const temp = require('temp').track() const AtomEnvironment = require('../src/atom-environment') @@ -301,8 +302,9 @@ describe('AtomEnvironment', () => { }) it('serializes the text editor registry', async () => { + await atom.packages.activatePackage('language-text') const editor = await atom.workspace.open('sample.js') - atom.textEditors.setGrammarOverride(editor, 'text.plain') + expect(atom.grammars.assignLanguageMode(editor, 'text.plain')).toBe(true) const atom2 = new AtomEnvironment({ applicationDelegate: atom.applicationDelegate, @@ -318,7 +320,9 @@ describe('AtomEnvironment', () => { atom2.initialize({document, window}) await atom2.deserialize(atom.serialize()) - expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain') + await atom2.packages.activatePackage('language-text') + const editor2 = atom2.workspace.getActiveTextEditor() + expect(editor2.getBuffer().getLanguageMode().getLanguageId()).toBe('text.plain') atom2.destroy() }) @@ -468,15 +472,28 @@ describe('AtomEnvironment', () => { await atom.workspace.open() }) - it('automatically restores the saved state into the current environment', () => { - const state = {} - spyOn(atom.workspace, 'open') - spyOn(atom, 'restoreStateIntoThisEnvironment') + it('automatically restores the saved state into the current environment', async () => { + const projectPath = temp.mkdirSync() + const filePath1 = path.join(projectPath, 'file-1') + const filePath2 = path.join(projectPath, 'file-2') + const filePath3 = path.join(projectPath, 'file-3') + fs.writeFileSync(filePath1, 'abc') + fs.writeFileSync(filePath2, 'def') + fs.writeFileSync(filePath3, 'ghi') - atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename]) - expect(atom.restoreStateIntoThisEnvironment).toHaveBeenCalledWith(state) - expect(atom.workspace.open.callCount).toBe(1) - expect(atom.workspace.open).toHaveBeenCalledWith(__filename) + const env1 = new AtomEnvironment({applicationDelegate: atom.applicationDelegate}) + env1.project.setPaths([projectPath]) + await env1.workspace.open(filePath1) + await env1.workspace.open(filePath2) + await env1.workspace.open(filePath3) + const env1State = env1.serialize() + env1.destroy() + + const env2 = new AtomEnvironment({applicationDelegate: atom.applicationDelegate}) + await env2.attemptRestoreProjectStateForPaths(env1State, [projectPath], [filePath2]) + const restoredURIs = env2.workspace.getPaneItems().map(p => p.getURI()) + expect(restoredURIs).toEqual([filePath1, filePath2, filePath3]) + env2.destroy() }) describe('when a dock has a non-text editor', () => { @@ -515,27 +532,31 @@ describe('AtomEnvironment', () => { }) }) - it('prompts the user to restore the state in a new window, discarding it and adding folder to current window', () => { - spyOn(atom, 'confirm').andReturn(1) + it('prompts the user to restore the state in a new window, discarding it and adding folder to current window', async () => { + jasmine.useRealClock() + spyOn(atom, 'confirm').andCallFake((options, callback) => callback(1)) spyOn(atom.project, 'addPath') spyOn(atom.workspace, 'open') const state = Symbol() atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename]) expect(atom.confirm).toHaveBeenCalled() - expect(atom.project.addPath.callCount).toBe(1) + await conditionPromise(() => atom.project.addPath.callCount === 1) + expect(atom.project.addPath).toHaveBeenCalledWith(__dirname) expect(atom.workspace.open.callCount).toBe(1) expect(atom.workspace.open).toHaveBeenCalledWith(__filename) }) - it('prompts the user to restore the state in a new window, opening a new window', () => { - spyOn(atom, 'confirm').andReturn(0) + it('prompts the user to restore the state in a new window, opening a new window', async () => { + jasmine.useRealClock() + spyOn(atom, 'confirm').andCallFake((options, callback) => callback(0)) spyOn(atom, 'open') const state = Symbol() atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename]) expect(atom.confirm).toHaveBeenCalled() + await conditionPromise(() => atom.open.callCount === 1) expect(atom.open).toHaveBeenCalledWith({ pathsToOpen: [__dirname, __filename], newWindow: true, @@ -589,7 +610,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/atom-reporter.coffee b/spec/atom-reporter.coffee index 455afcb27..a522d9298 100644 --- a/spec/atom-reporter.coffee +++ b/spec/atom-reporter.coffee @@ -9,34 +9,41 @@ ipcHelpers = require '../src/ipc-helpers' formatStackTrace = (spec, message='', stackTrace) -> return stackTrace unless stackTrace + # at ... (.../jasmine.js:1:2) jasminePattern = /^\s*at\s+.*\(?.*[/\\]jasmine(-[^/\\]*)?\.js:\d+:\d+\)?\s*$/ - firstJasmineLinePattern = /^\s*at [/\\].*[/\\]jasmine(-[^/\\]*)?\.js:\d+:\d+\)?\s*$/ + # at jasmine.Something... (.../jasmine.js:1:2) + firstJasmineLinePattern = /^\s*at\s+jasmine\.[A-Z][^\s]*\s+\(?.*[/\\]jasmine(-[^/\\]*)?\.js:\d+:\d+\)?\s*$/ lines = [] for line in stackTrace.split('\n') - lines.push(line) unless jasminePattern.test(line) break if firstJasmineLinePattern.test(line) + lines.push(line) unless jasminePattern.test(line) # Remove first line of stack when it is the same as the error message errorMatch = lines[0]?.match(/^Error: (.*)/) lines.shift() if message.trim() is errorMatch?[1]?.trim() - for line, index in lines - # Remove prefix of lines matching: at jasmine.Spec. (path:1:2) - prefixMatch = line.match(/at jasmine\.Spec\. \(([^)]+)\)/) - line = "at #{prefixMatch[1]}" if prefixMatch + lines = lines.map (line) -> + # Only format actual stacktrace lines + if /^\s*at\s/.test(line) + # Needs to occur before path relativization + if process.platform is 'win32' and /file:\/\/\//.test(line) + # file:///C:/some/file -> C:\some\file + line = line.replace('file:///', '').replace(///#{path.posix.sep}///g, path.win32.sep) - # Relativize locations to spec directory - if process.platform is 'win32' - line = line.replace('file:///', '').replace(///#{path.posix.sep}///g, path.win32.sep) - line = line.replace("at #{spec.specDirectory}#{path.sep}", 'at ') - lines[index] = line.replace("(#{spec.specDirectory}#{path.sep}", '(') # at step (path:1:2) + line = line.trim() + # at jasmine.Spec. (path:1:2) -> at path:1:2 + .replace(/^at jasmine\.Spec\. \(([^)]+)\)/, 'at $1') + # at it (path:1:2) -> at path:1:2 + .replace(/^at f*it \(([^)]+)\)/, 'at $1') + # at spec/file-test.js -> at file-test.js + .replace(spec.specDirectory + path.sep, '') + + return line - lines = lines.map (line) -> line.trim() lines.join('\n').trim() module.exports = class AtomReporter - constructor: -> @element = document.createElement('div') @element.classList.add('spec-reporter-container') diff --git a/spec/command-installer-spec.js b/spec/command-installer-spec.js index a2ecb6743..6a2a31e77 100644 --- a/spec/command-installer-spec.js +++ b/spec/command-installer-spec.js @@ -35,9 +35,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Failed to install shell commands', - detailedMessage: 'an error' + detail: 'an error' }) appDelegate.confirm.reset() @@ -46,9 +46,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Failed to install shell commands', - detailedMessage: 'another error' + detail: 'another error' }) }) @@ -61,9 +61,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Commands installed.', - detailedMessage: 'The shell commands `atom` and `apm` are installed.' + detail: 'The shell commands `atom` and `apm` are installed.' }) }) diff --git a/spec/command-registry-spec.js b/spec/command-registry-spec.js index a0ac86c08..03ef0cc34 100644 --- a/spec/command-registry-spec.js +++ b/spec/command-registry-spec.js @@ -1,5 +1,6 @@ const CommandRegistry = require('../src/command-registry'); const _ = require('underscore-plus'); +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers'); describe("CommandRegistry", () => { let registry, parent, child, grandchild; @@ -357,12 +358,41 @@ describe("CommandRegistry", () => { expect(called).toBe(true); }); - it("returns a boolean indicating whether any listeners matched the command", () => { + it("returns a promise if any listeners matched the command", () => { registry.add('.grandchild', 'command', () => {}); - expect(registry.dispatch(grandchild, 'command')).toBe(true); - expect(registry.dispatch(grandchild, 'bogus')).toBe(false); - expect(registry.dispatch(parent, 'command')).toBe(false); + expect(registry.dispatch(grandchild, 'command').constructor.name).toBe("Promise"); + expect(registry.dispatch(grandchild, 'bogus')).toBe(null); + expect(registry.dispatch(parent, 'command')).toBe(null); + }); + + it("returns a promise that resolves when the listeners resolve", async () => { + jasmine.useRealClock(); + registry.add('.grandchild', 'command', () => 1); + registry.add('.grandchild', 'command', () => Promise.resolve(2)); + registry.add('.grandchild', 'command', () => new Promise((resolve) => { + setTimeout(() => { resolve(3); }, 1); + })); + + const values = await registry.dispatch(grandchild, 'command'); + expect(values).toEqual([3, 2, 1]); + }); + + it("returns a promise that rejects when a listener is rejected", async () => { + jasmine.useRealClock(); + registry.add('.grandchild', 'command', () => 1); + registry.add('.grandchild', 'command', () => Promise.resolve(2)); + registry.add('.grandchild', 'command', () => new Promise((resolve, reject) => { + setTimeout(() => { reject(3); }, 1); + })); + + let value; + try { + value = await registry.dispatch(grandchild, 'command'); + } catch (err) { + value = err; + } + expect(value).toBe(3); }); }); 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/dock-spec.js b/spec/dock-spec.js index d4db460ae..6cdbc21f0 100644 --- a/spec/dock-spec.js +++ b/spec/dock-spec.js @@ -201,11 +201,7 @@ describe('Dock', () => { const dockElement = atom.workspace.getBottomDock().getElement() dockElement.querySelector('.atom-dock-resize-handle').dispatchEvent(new MouseEvent('mousedown', {detail: 2})) expect(dockElement.offsetHeight).toBe(0) - - // There should still be a hoverable, absolutely-positioned element so users can reveal the - // toggle affordance even when fullscreened. - expect(dockElement.querySelector('.atom-dock-inner').offsetHeight).toBe(1) - + expect(dockElement.querySelector('.atom-dock-inner').offsetHeight).toBe(0) // The content should be masked away. expect(dockElement.querySelector('.atom-dock-mask').offsetHeight).toBe(0) }) 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/git-repository-provider-spec.js b/spec/git-repository-provider-spec.js index e1d0168a9..24993fe9b 100644 --- a/spec/git-repository-provider-spec.js +++ b/spec/git-repository-provider-spec.js @@ -13,6 +13,14 @@ describe('GitRepositoryProvider', () => { provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm) }) + afterEach(() => { + if (provider) { + Object.keys(provider.pathToRepository).forEach(key => { + provider.pathToRepository[key].destroy() + }) + } + }) + describe('.repositoryForDirectory(directory)', () => { describe('when specified a Directory with a Git repository', () => { it('resolves with a GitRepository', async () => { diff --git a/spec/git-repository-spec.js b/spec/git-repository-spec.js index e03a9788a..61c80ee48 100644 --- a/spec/git-repository-spec.js +++ b/spec/git-repository-spec.js @@ -366,6 +366,7 @@ describe('GitRepository', () => { notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm, + grammarRegistry: atom.grammars, applicationDelegate: atom.applicationDelegate }) await project2.deserialize(atom.project.serialize({isUnloading: false})) diff --git a/spec/grammar-registry-spec.js b/spec/grammar-registry-spec.js new file mode 100644 index 000000000..e6d815f8d --- /dev/null +++ b/spec/grammar-registry-spec.js @@ -0,0 +1,497 @@ +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 + + beforeEach(() => { + grammarRegistry = new GrammarRegistry({config: atom.config}) + }) + + 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')) + + const buffer = new TextBuffer() + expect(grammarRegistry.assignLanguageMode(buffer, 'source.js')).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + // Returns true if we found the grammar, even if it didn't change + expect(grammarRegistry.assignLanguageMode(buffer, 'source.js')).toBe(true) + + // Language names are not case-sensitive + expect(grammarRegistry.assignLanguageMode(buffer, 'source.css')).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + + // Returns false if no language is found + expect(grammarRegistry.assignLanguageMode(buffer, 'blub')).toBe(false) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + }) + + describe('when no languageId is passed', () => { + it('makes the buffer use the null grammar', () => { + grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) + + const buffer = new TextBuffer() + expect(grammarRegistry.assignLanguageMode(buffer, 'source.css')).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + + expect(grammarRegistry.assignLanguageMode(buffer, null)).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + }) + }) + }) + + 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')) + grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) + + const buffer = new TextBuffer() + buffer.setPath('foo.js') + expect(grammarRegistry.assignLanguageMode(buffer, 'source.css')).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + + grammarRegistry.autoAssignLanguageMode(buffer) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + }) + }) + + describe('.maintainLanguageMode(buffer)', () => { + it('assigns a grammar to the buffer based on its path', async () => { + const buffer = new TextBuffer() + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/c.cson')) + + buffer.setPath('test.js') + grammarRegistry.maintainLanguageMode(buffer) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + buffer.setPath('test.c') + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.c') + }) + + 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) + + buffer.setPath('test.js') + grammarRegistry.maintainLanguageMode(buffer) + + 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', () => { + const buffer = new TextBuffer() + + buffer.setPath('test.js') + grammarRegistry.maintainLanguageMode(buffer) + + grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) + expect(grammarRegistry.assignLanguageMode(buffer, 'source.css')).toBe(true) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') + }) + + it('returns a disposable that can be used to stop the registry from updating the buffer', async () => { + const buffer = new TextBuffer() + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + + const previousSubscriptionCount = buffer.emitter.getTotalListenerCount() + const disposable = grammarRegistry.maintainLanguageMode(buffer) + expect(buffer.emitter.getTotalListenerCount()).toBeGreaterThan(previousSubscriptionCount) + expect(retainedBufferCount(grammarRegistry)).toBe(1) + + buffer.setPath('test.js') + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + buffer.setPath('test.txt') + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + + disposable.dispose() + expect(buffer.emitter.getTotalListenerCount()).toBe(previousSubscriptionCount) + expect(retainedBufferCount(grammarRegistry)).toBe(0) + + buffer.setPath('test.js') + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + expect(retainedBufferCount(grammarRegistry)).toBe(0) + }) + + it('doesn\'t do anything when called a second time with the same buffer', async () => { + const buffer = new TextBuffer() + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + const disposable1 = grammarRegistry.maintainLanguageMode(buffer) + const disposable2 = grammarRegistry.maintainLanguageMode(buffer) + + buffer.setPath('test.js') + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + disposable2.dispose() + buffer.setPath('test.txt') + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + + disposable1.dispose() + buffer.setPath('test.js') + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + }) + + it('does not retain the buffer after the buffer is destroyed', () => { + const buffer = new TextBuffer() + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + + const disposable = grammarRegistry.maintainLanguageMode(buffer) + expect(retainedBufferCount(grammarRegistry)).toBe(1) + expect(subscriptionCount(grammarRegistry)).toBe(2) + + buffer.destroy() + expect(retainedBufferCount(grammarRegistry)).toBe(0) + expect(subscriptionCount(grammarRegistry)).toBe(0) + expect(buffer.emitter.getTotalListenerCount()).toBe(0) + + disposable.dispose() + expect(retainedBufferCount(grammarRegistry)).toBe(0) + expect(subscriptionCount(grammarRegistry)).toBe(0) + }) + + it('does not retain the buffer when the grammar registry is destroyed', () => { + const buffer = new TextBuffer() + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + + const disposable = grammarRegistry.maintainLanguageMode(buffer) + expect(retainedBufferCount(grammarRegistry)).toBe(1) + expect(subscriptionCount(grammarRegistry)).toBe(2) + + grammarRegistry.clear() + + expect(retainedBufferCount(grammarRegistry)).toBe(0) + expect(subscriptionCount(grammarRegistry)).toBe(0) + expect(buffer.emitter.getTotalListenerCount()).toBe(0) + }) + }) + + describe('.selectGrammar(filePath)', () => { + it('always returns a grammar', () => { + const registry = new GrammarRegistry({config: atom.config}) + expect(registry.selectGrammar().scopeName).toBe('text.plain.null-grammar') + }) + + it('selects the text.plain grammar over the null grammar', async () => { + await atom.packages.activatePackage('language-text') + expect(atom.grammars.selectGrammar('test.txt').scopeName).toBe('text.plain') + }) + + it('selects a grammar based on the file path case insensitively', async () => { + await atom.packages.activatePackage('language-coffee-script') + expect(atom.grammars.selectGrammar('/tmp/source.coffee').scopeName).toBe('source.coffee') + expect(atom.grammars.selectGrammar('/tmp/source.COFFEE').scopeName).toBe('source.coffee') + }) + + describe('on Windows', () => { + let originalPlatform + + beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', {value: 'win32'}) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', {value: originalPlatform}) + }) + + it('normalizes back slashes to forward slashes when matching the fileTypes', async () => { + await atom.packages.activatePackage('language-git') + expect(atom.grammars.selectGrammar('something\\.git\\config').scopeName).toBe('source.git-config') + }) + }) + + it("can use the filePath to load the correct grammar based on the grammar's filetype", async () => { + await atom.packages.activatePackage('language-git') + await atom.packages.activatePackage('language-javascript') + await atom.packages.activatePackage('language-ruby') + + expect(atom.grammars.selectGrammar('file.js').name).toBe('JavaScript') // based on extension (.js) + expect(atom.grammars.selectGrammar(path.join(temp.dir, '.git', 'config')).name).toBe('Git Config') // based on end of the path (.git/config) + expect(atom.grammars.selectGrammar('Rakefile').name).toBe('Ruby') // based on the file's basename (Rakefile) + expect(atom.grammars.selectGrammar('curb').name).toBe('Null Grammar') + 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') + + const filePath = require.resolve('./fixtures/shebang') + expect(atom.grammars.selectGrammar(filePath).name).toBe('Ruby') + }) + + it('uses the number of newlines in the first line regex to determine the number of lines to test against', async () => { + await atom.packages.activatePackage('language-property-list') + await atom.packages.activatePackage('language-coffee-script') + + let fileContent = 'first-line\n' + expect(atom.grammars.selectGrammar('dummy.coffee', fileContent).name).toBe('CoffeeScript') + + fileContent = '' + expect(atom.grammars.selectGrammar('grammar.tmLanguage', fileContent).name).toBe('Null Grammar') + + fileContent += '\n' + expect(atom.grammars.selectGrammar('grammar.tmLanguage', fileContent).name).toBe('Property List (XML)') + }) + + it("doesn't read the file when the file contents are specified", async () => { + await atom.packages.activatePackage('language-ruby') + + const filePath = require.resolve('./fixtures/shebang') + const filePathContents = fs.readFileSync(filePath, 'utf8') + spyOn(fs, 'read').andCallThrough() + expect(atom.grammars.selectGrammar(filePath, filePathContents).name).toBe('Ruby') + expect(fs.read).not.toHaveBeenCalled() + }) + + describe('when multiple grammars have matching fileTypes', () => { + it('selects the grammar with the longest fileType match', () => { + const grammarPath1 = temp.path({suffix: '.json'}) + fs.writeFileSync(grammarPath1, JSON.stringify({ + name: 'test1', + scopeName: 'source1', + fileTypes: ['test'] + })) + const grammar1 = atom.grammars.loadGrammarSync(grammarPath1) + expect(atom.grammars.selectGrammar('more.test', '')).toBe(grammar1) + fs.removeSync(grammarPath1) + + const grammarPath2 = temp.path({suffix: '.json'}) + fs.writeFileSync(grammarPath2, JSON.stringify({ + name: 'test2', + scopeName: 'source2', + fileTypes: ['test', 'more.test'] + })) + const grammar2 = atom.grammars.loadGrammarSync(grammarPath2) + expect(atom.grammars.selectGrammar('more.test', '')).toBe(grammar2) + return fs.removeSync(grammarPath2) + }) + }) + + it('favors non-bundled packages when breaking scoring ties', async () => { + await atom.packages.activatePackage('language-ruby') + await atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'packages', 'package-with-rb-filetype')) + + atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true + atom.grammars.grammarForScopeName('test.rb').bundledPackage = false + + expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env ruby').scopeName).toBe('source.ruby') + expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env testruby').scopeName).toBe('test.rb') + expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe('test.rb') + }) + + describe('when there is no file path', () => { + it('does not throw an exception (regression)', () => { + expect(() => atom.grammars.selectGrammar(null, '#!/usr/bin/ruby')).not.toThrow() + expect(() => atom.grammars.selectGrammar(null, '')).not.toThrow() + expect(() => atom.grammars.selectGrammar(null, null)).not.toThrow() + }) + }) + + describe('when the user has custom grammar file types', () => { + it('considers the custom file types as well as those defined in the grammar', async () => { + await atom.packages.activatePackage('language-ruby') + atom.config.set('core.customFileTypes', {'source.ruby': ['Cheffile']}) + expect(atom.grammars.selectGrammar('build/Cheffile', 'cookbook "postgres"').scopeName).toBe('source.ruby') + }) + + it('favors user-defined file types over built-in ones of equal length', async () => { + await atom.packages.activatePackage('language-ruby') + await atom.packages.activatePackage('language-coffee-script') + + atom.config.set('core.customFileTypes', { + 'source.coffee': ['Rakefile'], + 'source.ruby': ['Cakefile'] + }) + expect(atom.grammars.selectGrammar('Rakefile', '').scopeName).toBe('source.coffee') + expect(atom.grammars.selectGrammar('Cakefile', '').scopeName).toBe('source.ruby') + }) + + it('favors user-defined file types over grammars with matching first-line-regexps', async () => { + await atom.packages.activatePackage('language-ruby') + await atom.packages.activatePackage('language-javascript') + + atom.config.set('core.customFileTypes', {'source.ruby': ['bootstrap']}) + expect(atom.grammars.selectGrammar('bootstrap', '#!/usr/bin/env node').scopeName).toBe('source.ruby') + }) + }) + + it('favors a grammar with a matching file type over one with m matching first line pattern', async () => { + await atom.packages.activatePackage('language-ruby') + 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-css') + const grammar = atom.grammars.selectGrammar('foo.css') + atom.grammars.removeGrammar(grammar) + expect(atom.grammars.selectGrammar('foo.css').name).not.toBe(grammar.name) + }) + }) + + describe('serialization', () => { + it('persists editors\' grammar overrides', async () => { + const buffer1 = new TextBuffer() + const buffer2 = new TextBuffer() + + grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/c.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-html/grammars/html.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + + grammarRegistry.maintainLanguageMode(buffer1) + grammarRegistry.maintainLanguageMode(buffer2) + grammarRegistry.assignLanguageMode(buffer1, 'source.c') + grammarRegistry.assignLanguageMode(buffer2, 'source.js') + + const buffer1Copy = await TextBuffer.deserialize(buffer1.serialize()) + const buffer2Copy = await TextBuffer.deserialize(buffer2.serialize()) + + const grammarRegistryCopy = new GrammarRegistry({config: atom.config}) + grammarRegistryCopy.deserialize(JSON.parse(JSON.stringify(grammarRegistry.serialize()))) + + grammarRegistryCopy.loadGrammarSync(require.resolve('language-c/grammars/c.cson')) + grammarRegistryCopy.loadGrammarSync(require.resolve('language-html/grammars/html.cson')) + + expect(buffer1Copy.getLanguageMode().getLanguageId()).toBe(null) + expect(buffer2Copy.getLanguageMode().getLanguageId()).toBe(null) + + grammarRegistryCopy.maintainLanguageMode(buffer1Copy) + grammarRegistryCopy.maintainLanguageMode(buffer2Copy) + expect(buffer1Copy.getLanguageMode().getLanguageId()).toBe('source.c') + expect(buffer2Copy.getLanguageMode().getLanguageId()).toBe(null) + + grammarRegistryCopy.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + expect(buffer1Copy.getLanguageMode().getLanguageId()).toBe('source.c') + expect(buffer2Copy.getLanguageMode().getLanguageId()).toBe('source.js') + }) + }) +}) + +function retainedBufferCount (grammarRegistry) { + return grammarRegistry.grammarScoresByBuffer.size +} + +function subscriptionCount (grammarRegistry) { + return grammarRegistry.subscriptions.disposables.size +} diff --git a/spec/grammars-spec.coffee b/spec/grammars-spec.coffee deleted file mode 100644 index db716528d..000000000 --- a/spec/grammars-spec.coffee +++ /dev/null @@ -1,182 +0,0 @@ -path = require 'path' -fs = require 'fs-plus' -temp = require('temp').track() -GrammarRegistry = require '../src/grammar-registry' -Grim = require 'grim' - -describe "the `grammars` global", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-text') - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - waitsForPromise -> - atom.packages.activatePackage('language-ruby') - - waitsForPromise -> - atom.packages.activatePackage('language-git') - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackages() - runs -> - atom.packages.unloadPackages() - try - temp.cleanupSync() - - describe ".selectGrammar(filePath)", -> - it "always returns a grammar", -> - registry = new GrammarRegistry(config: atom.config) - expect(registry.selectGrammar().scopeName).toBe 'text.plain.null-grammar' - - it "selects the text.plain grammar over the null grammar", -> - expect(atom.grammars.selectGrammar('test.txt').scopeName).toBe 'text.plain' - - it "selects a grammar based on the file path case insensitively", -> - expect(atom.grammars.selectGrammar('/tmp/source.coffee').scopeName).toBe 'source.coffee' - expect(atom.grammars.selectGrammar('/tmp/source.COFFEE').scopeName).toBe 'source.coffee' - - describe "on Windows", -> - originalPlatform = null - - beforeEach -> - originalPlatform = process.platform - Object.defineProperty process, 'platform', value: 'win32' - - afterEach -> - Object.defineProperty process, 'platform', value: originalPlatform - - it "normalizes back slashes to forward slashes when matching the fileTypes", -> - expect(atom.grammars.selectGrammar('something\\.git\\config').scopeName).toBe 'source.git-config' - - it "can use the filePath to load the correct grammar based on the grammar's filetype", -> - waitsForPromise -> - atom.packages.activatePackage('language-git') - - runs -> - expect(atom.grammars.selectGrammar("file.js").name).toBe "JavaScript" # based on extension (.js) - expect(atom.grammars.selectGrammar(path.join(temp.dir, '.git', 'config')).name).toBe "Git Config" # based on end of the path (.git/config) - expect(atom.grammars.selectGrammar("Rakefile").name).toBe "Ruby" # based on the file's basename (Rakefile) - expect(atom.grammars.selectGrammar("curb").name).toBe "Null Grammar" - expect(atom.grammars.selectGrammar("/hu.git/config").name).toBe "Null Grammar" - - it "uses the filePath's shebang line if the grammar cannot be determined by the extension or basename", -> - filePath = require.resolve("./fixtures/shebang") - expect(atom.grammars.selectGrammar(filePath).name).toBe "Ruby" - - it "uses the number of newlines in the first line regex to determine the number of lines to test against", -> - waitsForPromise -> - atom.packages.activatePackage('language-property-list') - - runs -> - fileContent = "first-line\n" - expect(atom.grammars.selectGrammar("dummy.coffee", fileContent).name).toBe "CoffeeScript" - - fileContent = '' - expect(atom.grammars.selectGrammar("grammar.tmLanguage", fileContent).name).toBe "Null Grammar" - - fileContent += '\n' - expect(atom.grammars.selectGrammar("grammar.tmLanguage", fileContent).name).toBe "Property List (XML)" - - it "doesn't read the file when the file contents are specified", -> - filePath = require.resolve("./fixtures/shebang") - filePathContents = fs.readFileSync(filePath, 'utf8') - spyOn(fs, 'read').andCallThrough() - expect(atom.grammars.selectGrammar(filePath, filePathContents).name).toBe "Ruby" - expect(fs.read).not.toHaveBeenCalled() - - describe "when multiple grammars have matching fileTypes", -> - it "selects the grammar with the longest fileType match", -> - grammarPath1 = temp.path(suffix: '.json') - fs.writeFileSync grammarPath1, JSON.stringify( - name: 'test1' - scopeName: 'source1' - fileTypes: ['test'] - ) - grammar1 = atom.grammars.loadGrammarSync(grammarPath1) - expect(atom.grammars.selectGrammar('more.test', '')).toBe grammar1 - fs.removeSync(grammarPath1) - - grammarPath2 = temp.path(suffix: '.json') - fs.writeFileSync grammarPath2, JSON.stringify( - name: 'test2' - scopeName: 'source2' - fileTypes: ['test', 'more.test'] - ) - grammar2 = atom.grammars.loadGrammarSync(grammarPath2) - expect(atom.grammars.selectGrammar('more.test', '')).toBe grammar2 - fs.removeSync(grammarPath2) - - it "favors non-bundled packages when breaking scoring ties", -> - waitsForPromise -> - atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'packages', 'package-with-rb-filetype')) - - runs -> - atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true - atom.grammars.grammarForScopeName('test.rb').bundledPackage = false - - expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env ruby').scopeName).toBe 'source.ruby' - expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env testruby').scopeName).toBe 'test.rb' - expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe 'test.rb' - - describe "when there is no file path", -> - it "does not throw an exception (regression)", -> - expect(-> atom.grammars.selectGrammar(null, '#!/usr/bin/ruby')).not.toThrow() - expect(-> atom.grammars.selectGrammar(null, '')).not.toThrow() - expect(-> atom.grammars.selectGrammar(null, null)).not.toThrow() - - describe "when the user has custom grammar file types", -> - it "considers the custom file types as well as those defined in the grammar", -> - atom.config.set('core.customFileTypes', 'source.ruby': ['Cheffile']) - expect(atom.grammars.selectGrammar('build/Cheffile', 'cookbook "postgres"').scopeName).toBe 'source.ruby' - - it "favors user-defined file types over built-in ones of equal length", -> - atom.config.set('core.customFileTypes', - 'source.coffee': ['Rakefile'], - 'source.ruby': ['Cakefile'] - ) - expect(atom.grammars.selectGrammar('Rakefile', '').scopeName).toBe 'source.coffee' - expect(atom.grammars.selectGrammar('Cakefile', '').scopeName).toBe 'source.ruby' - - it "favors user-defined file types over grammars with matching first-line-regexps", -> - atom.config.set('core.customFileTypes', 'source.ruby': ['bootstrap']) - expect(atom.grammars.selectGrammar('bootstrap', '#!/usr/bin/env node').scopeName).toBe 'source.ruby' - - describe "when there is a grammar with a first line pattern, the file type of the file is known, but from a different grammar", -> - it "favors file type over the matching pattern", -> - expect(atom.grammars.selectGrammar('foo.rb', '#!/usr/bin/env node').scopeName).toBe 'source.ruby' - - describe ".removeGrammar(grammar)", -> - it "removes the grammar, so it won't be returned by selectGrammar", -> - grammar = atom.grammars.selectGrammar('foo.js') - atom.grammars.removeGrammar(grammar) - expect(atom.grammars.selectGrammar('foo.js').name).not.toBe grammar.name - - describe "grammar overrides", -> - it "logs deprecations and uses the TextEditorRegistry", -> - editor = null - - waitsForPromise -> - atom.workspace.open('sample.js').then (e) -> editor = e - - runs -> - spyOn(Grim, 'deprecate') - - atom.grammars.setGrammarOverrideForPath(editor.getPath(), 'source.ruby') - expect(Grim.deprecate.callCount).toBe 1 - expect(editor.getGrammar().name).toBe 'Ruby' - - expect(atom.grammars.grammarOverrideForPath(editor.getPath())).toBe('source.ruby') - expect(Grim.deprecate.callCount).toBe 2 - - atom.grammars.clearGrammarOverrideForPath(editor.getPath(), 'source.ruby') - expect(Grim.deprecate.callCount).toBe 3 - expect(editor.getGrammar().name).toBe 'JavaScript' - - expect(atom.grammars.grammarOverrideForPath(editor.getPath())).toBe(undefined) - expect(Grim.deprecate.callCount).toBe 4 diff --git a/spec/history-manager-spec.js b/spec/history-manager-spec.js index 7a06fce9b..cc2a20058 100644 --- a/spec/history-manager-spec.js +++ b/spec/history-manager-spec.js @@ -1,10 +1,8 @@ -/** @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 @@ -182,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 01d052b96..90a512692 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() @@ -352,11 +351,9 @@ describe('AtomApplication', function () { const atomApplication1 = buildAtomApplication() const app1Window1 = atomApplication1.launch(parseCommandLine([tempDirPath1])) + await emitterEventPromise(app1Window1, 'window:locations-opened') const app1Window2 = atomApplication1.launch(parseCommandLine([tempDirPath2])) - await Promise.all([ - emitterEventPromise(app1Window1, 'window:locations-opened'), - emitterEventPromise(app1Window2, 'window:locations-opened') - ]) + await emitterEventPromise(app1Window2, 'window:locations-opened') await Promise.all([ app1Window1.prepareToUnload(), @@ -374,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) @@ -393,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() @@ -443,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) @@ -456,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); @@ -490,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() @@ -499,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 () => { @@ -509,30 +615,30 @@ 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() }) // Choosing "Cancel" - mockElectronShowMessageBox({choice: 1}) + mockElectronShowMessageBox({response: 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}) + mockElectronShowMessageBox({response: 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 } @@ -544,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 () { - return choice + function mockElectronShowMessageBox ({response}) { + electron.dialog.showMessageBox = (window, options, callback) => { + callback(response) } } 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) { @@ -589,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( @@ -609,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/pane-container-spec.js b/spec/pane-container-spec.js index 1918364f9..060808d0b 100644 --- a/spec/pane-container-spec.js +++ b/spec/pane-container-spec.js @@ -5,7 +5,7 @@ describe('PaneContainer', () => { let confirm, params beforeEach(() => { - confirm = spyOn(atom.applicationDelegate, 'confirm').andReturn(0) + confirm = spyOn(atom.applicationDelegate, 'confirm').andCallFake((options, callback) => callback(0)) params = { location: 'center', config: atom.config, @@ -280,14 +280,14 @@ describe('PaneContainer', () => { }) it('returns true if the user saves all modified files when prompted', async () => { - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) const saved = await container.confirmClose() expect(confirm).toHaveBeenCalled() expect(saved).toBeTruthy() }) it('returns false if the user cancels saving any modified file', async () => { - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) const saved = await container.confirmClose() expect(confirm).toHaveBeenCalled() expect(saved).toBeFalsy() diff --git a/spec/pane-spec.js b/spec/pane-spec.js index e448f992f..8ef274c2d 100644 --- a/spec/pane-spec.js +++ b/spec/pane-spec.js @@ -3,7 +3,7 @@ const {Emitter} = require('event-kit') const Grim = require('grim') const Pane = require('../src/pane') const PaneContainer = require('../src/pane-container') -const {it, fit, ffit, fffit, beforeEach, timeoutPromise} = require('./async-spec-helpers') +const {it, fit, ffit, fffit, beforeEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') describe('Pane', () => { let confirm, showSaveDialog, deserializerDisposable @@ -564,7 +564,7 @@ describe('Pane', () => { describe('when the item has a uri', () => { it('saves the item before destroying it', async () => { itemURI = 'test' - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) expect(item1.save).toHaveBeenCalled() @@ -576,13 +576,17 @@ describe('Pane', () => { describe('when the item has no uri', () => { it('presents a save-as dialog, then saves the item with the given uri before removing and destroying it', async () => { + jasmine.useRealClock() + itemURI = null - showSaveDialog.andReturn('/selected/path') - confirm.andReturn(0) + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) + confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) - expect(showSaveDialog).toHaveBeenCalledWith({}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) + + await conditionPromise(() => item1.saveAs.callCount === 1) expect(item1.saveAs).toHaveBeenCalledWith('/selected/path') expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) @@ -593,7 +597,7 @@ describe('Pane', () => { describe("if the [Don't Save] option is selected", () => { it('removes and destroys the item without saving it', async () => { - confirm.andReturn(2) + confirm.andCallFake((options, callback) => callback(2)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() @@ -605,7 +609,7 @@ describe('Pane', () => { describe('if the [Cancel] option is selected', () => { it('does not save, remove, or destroy the item', async () => { - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() @@ -735,7 +739,7 @@ describe('Pane', () => { beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) - showSaveDialog.andReturn('/selected/path') + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the active item has a uri', () => { @@ -764,7 +768,7 @@ describe('Pane', () => { it('opens a save dialog and saves the current item as the selected path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') await pane.saveActiveItem() - expect(showSaveDialog).toHaveBeenCalledWith({}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) @@ -779,7 +783,7 @@ describe('Pane', () => { it('does nothing if the user cancels choosing a path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') - showSaveDialog.andReturn(undefined) + showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.saveActiveItem() expect(pane.getActiveItem().saveAs).not.toHaveBeenCalled() }) @@ -835,15 +839,19 @@ describe('Pane', () => { beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) - showSaveDialog.andReturn('/selected/path') + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the current item has a saveAs method', () => { - it('opens the save dialog and calls saveAs on the item with the selected path', () => { + it('opens the save dialog and calls saveAs on the item with the selected path', async () => { + jasmine.useRealClock() + pane.getActiveItem().path = __filename pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') pane.saveActiveItemAs() - expect(showSaveDialog).toHaveBeenCalledWith({defaultPath: __filename}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({defaultPath: __filename}) + + await conditionPromise(() => pane.getActiveItem().saveAs.callCount === 1) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) @@ -1210,7 +1218,7 @@ describe('Pane', () => { item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) await pane.close() expect(confirm).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled() @@ -1225,7 +1233,7 @@ describe('Pane', () => { item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) await pane.close() expect(confirm).toHaveBeenCalled() @@ -1240,8 +1248,8 @@ describe('Pane', () => { item1.shouldPromptToSave = () => true item1.saveAs = jasmine.createSpy('saveAs') - confirm.andReturn(0) - showSaveDialog.andReturn(undefined) + confirm.andCallFake((options, callback) => callback(0)) + showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() @@ -1270,12 +1278,12 @@ describe('Pane', () => { it('does not destroy the pane if save fails and user clicks cancel', async () => { let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ if (confirmations === 1) { - return 0 // click save + callback(0) // click save } else { - return 1 + callback(1) } }) // click cancel @@ -1290,17 +1298,17 @@ describe('Pane', () => { item1.saveAs = jasmine.createSpy('saveAs').andReturn(true) let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ - return 0 + callback(0) }) // save and then save as - showSaveDialog.andReturn('new/path') + showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(2) - expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalledWith({}) + expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) @@ -1315,20 +1323,21 @@ describe('Pane', () => { }) let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ if (confirmations < 3) { - return 0 // save, save as, save as + callback(0) // save, save as, save as + } else { + callback(2) // don't save } - return 2 - }) // don't save + }) - showSaveDialog.andReturn('new/path') + showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(3) - expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalledWith({}) + expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) diff --git a/spec/project-spec.js b/spec/project-spec.js index 0f003b26b..bd6bb1fa6 100644 --- a/spec/project-spec.js +++ b/spec/project-spec.js @@ -35,7 +35,12 @@ describe('Project', () => { }) it("does not deserialize paths to directories that don't exist", () => { - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) const state = atom.project.serialize() state.paths.push('/directory/that/does/not/exist') @@ -55,7 +60,12 @@ describe('Project', () => { const childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child') fs.mkdirSync(childPath) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) atom.project.setPaths([childPath]) const state = atom.project.serialize() @@ -80,7 +90,12 @@ describe('Project', () => { runs(() => { expect(atom.project.getBuffers().length).toBe(1) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -93,7 +108,12 @@ describe('Project', () => { runs(() => { expect(atom.project.getBuffers().length).toBe(1) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -113,7 +133,12 @@ describe('Project', () => { runs(() => { expect(atom.project.getBuffers().length).toBe(1) fs.mkdirSync(pathToOpen) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -131,7 +156,12 @@ describe('Project', () => { runs(() => { expect(atom.project.getBuffers().length).toBe(1) fs.chmodSync(pathToOpen, '000') - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -148,7 +178,12 @@ describe('Project', () => { runs(() => { expect(atom.project.getBuffers().length).toBe(1) fs.unlinkSync(pathToOpen) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -165,7 +200,12 @@ describe('Project', () => { atom.workspace.getActiveTextEditor().setText('unsaved\n') expect(atom.project.getBuffers().length).toBe(1) - deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + deserializedProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -189,7 +229,12 @@ describe('Project', () => { layerA = bufferA.addMarkerLayer({persistent: true}) markerA = layerA.markPosition([0, 3]) bufferA.append('!') - notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + notQuittingProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => notQuittingProject.deserialize(atom.project.serialize({isUnloading: false}))) @@ -197,7 +242,12 @@ describe('Project', () => { runs(() => { expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).toBeUndefined() expect(notQuittingProject.getBuffers()[0].undo()).toBe(false) - quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + quittingProject = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars + }) }) waitsForPromise(() => quittingProject.deserialize(atom.project.serialize({isUnloading: true}))) @@ -209,7 +259,7 @@ describe('Project', () => { }) }) - describe('when an editor is saved and the project has no path', () => + describe('when an editor is saved and the project has no path', () => { it("sets the project's path to the saved file's parent directory", () => { const tempFile = temp.openSync().path atom.project.setPaths([]) @@ -222,7 +272,7 @@ describe('Project', () => { runs(() => expect(atom.project.getPaths()[0]).toBe(path.dirname(tempFile))) }) - ) + }) describe('before and after saving a buffer', () => { let buffer @@ -422,7 +472,7 @@ describe('Project', () => { atom.project.onDidAddBuffer(newBufferHandler) }) - describe("when given an absolute path that isn't currently open", () => + describe("when given an absolute path that isn't currently open", () => { it("returns a new edit session for the given path and emits 'buffer-created'", () => { let editor = null waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o })) @@ -432,9 +482,9 @@ describe('Project', () => { expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) }) }) - ) + }) - describe("when given a relative path that isn't currently opened", () => + describe("when given a relative path that isn't currently opened", () => { it("returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", () => { let editor = null waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o })) @@ -444,9 +494,9 @@ describe('Project', () => { expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) }) }) - ) + }) - describe('when passed the path to a buffer that is currently opened', () => + describe('when passed the path to a buffer that is currently opened', () => { it('returns a new edit session containing currently opened buffer', () => { let editor = null @@ -465,9 +515,9 @@ describe('Project', () => { }) ) }) - ) + }) - describe('when not passed a path', () => + describe('when not passed a path', () => { it("returns a new edit session and emits 'buffer-created'", () => { let editor = null waitsForPromise(() => atom.workspace.open().then(o => { editor = o })) @@ -477,7 +527,7 @@ describe('Project', () => { expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer) }) }) - ) + }) }) describe('.bufferForPath(path)', () => { @@ -537,7 +587,7 @@ describe('Project', () => { }) describe('.repositoryForDirectory(directory)', () => { - it('resolves to null when the directory does not have a repository', () => + it('resolves to null when the directory does not have a repository', () => { waitsForPromise(() => { const directory = new Directory('/tmp') return atom.project.repositoryForDirectory(directory).then((result) => { @@ -546,9 +596,9 @@ describe('Project', () => { expect(atom.project.repositoryPromisesByPath.size).toBe(0) }) }) - ) + }) - it('resolves to a GitRepository and is cached when the given directory is a Git repo', () => + it('resolves to a GitRepository and is cached when the given directory is a Git repo', () => { waitsForPromise(() => { const directory = new Directory(path.join(__dirname, '..')) const promise = atom.project.repositoryForDirectory(directory) @@ -561,7 +611,7 @@ describe('Project', () => { expect(atom.project.repositoryForDirectory(directory)).toBe(promise) }) }) - ) + }) it('creates a new repository if a previous one with the same directory had been destroyed', () => { let repository = null @@ -582,14 +632,14 @@ describe('Project', () => { }) describe('.setPaths(paths, options)', () => { - describe('when path is a file', () => + describe('when path is a file', () => { it("sets its path to the file's parent directory and updates the root directory", () => { const filePath = require.resolve('./fixtures/dir/a') atom.project.setPaths([filePath]) expect(atom.project.getPaths()[0]).toEqual(path.dirname(filePath)) expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(filePath)) }) - ) + }) describe('when path is a directory', () => { it('assigns the directories and repositories', () => { @@ -636,13 +686,13 @@ describe('Project', () => { }) }) - describe('when no paths are given', () => + describe('when no paths are given', () => { it('clears its path', () => { atom.project.setPaths([]) expect(atom.project.getPaths()).toEqual([]) expect(atom.project.getDirectories()).toEqual([]) }) - ) + }) it('normalizes the path to remove consecutive slashes, ., and .. segments', () => { atom.project.setPaths([`${require.resolve('./fixtures/dir/a')}${path.sep}b${path.sep}${path.sep}..`]) @@ -693,9 +743,9 @@ describe('Project', () => { expect(atom.project.getPaths()).toEqual(previousPaths) }) - it('optionally throws on non-existent directories', () => + it('optionally throws on non-existent directories', () => { expect(() => atom.project.addPath('/this-definitely/does-not-exist', {mustExist: true})).toThrow() - ) + }) }) describe('.removePath(path)', () => { @@ -813,7 +863,7 @@ describe('Project', () => { }) }) - describe('.onDidAddBuffer()', () => + describe('.onDidAddBuffer()', () => { it('invokes the callback with added text buffers', () => { const buffers = [] const added = [] @@ -838,9 +888,9 @@ describe('Project', () => { expect(added).toEqual([buffers[1]]) }) }) -) + }) - describe('.observeBuffers()', () => + describe('.observeBuffers()', () => { it('invokes the observer with current and future text buffers', () => { const buffers = [] const observed = [] @@ -872,7 +922,7 @@ describe('Project', () => { expect(observed).toEqual(buffers) }) }) - ) + }) describe('.relativize(path)', () => { it('returns the path, relative to whichever root directory it is inside of', () => { @@ -906,21 +956,21 @@ describe('Project', () => { expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')]) }) - describe("when the given path isn't inside of any of the project's path", () => + describe("when the given path isn't inside of any of the project's path", () => { it('returns null for the root path, and the given path unchanged', () => { const randomPath = path.join('some', 'random', 'path') expect(atom.project.relativizePath(randomPath)).toEqual([null, randomPath]) }) - ) + }) - describe('when the given path is a URL', () => + describe('when the given path is a URL', () => { it('returns null for the root path, and the given path unchanged', () => { const url = 'http://the-path' expect(atom.project.relativizePath(url)).toEqual([null, url]) }) - ) + }) - describe('when the given path is inside more than one root folder', () => + describe('when the given path is inside more than one root folder', () => { it('uses the root folder that is closest to the given path', () => { atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir')) @@ -933,10 +983,10 @@ describe('Project', () => { path.join('somewhere', 'something.txt') ]) }) - ) + }) }) - describe('.contains(path)', () => + describe('.contains(path)', () => { it('returns whether or not the given path is in one of the root directories', () => { const rootPath = atom.project.getPaths()[0] const childPath = path.join(rootPath, 'some', 'child', 'directory') @@ -945,11 +995,11 @@ describe('Project', () => { const randomPath = path.join('some', 'random', 'path') expect(atom.project.contains(randomPath)).toBe(false) }) - ) + }) - describe('.resolvePath(uri)', () => + describe('.resolvePath(uri)', () => { it('normalizes disk drive letter in passed path on #win32', () => { expect(atom.project.resolvePath('d:\\file.txt')).toEqual('D:\\file.txt') }) - ) + }) }) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 7621f9cae..dcc3c6641 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -7,10 +7,11 @@ fs = require 'fs-plus' Grim = require 'grim' pathwatcher = require 'pathwatcher' FindParentDir = require 'find-parent-dir' +{CompositeDisposable} = require 'event-kit' TextEditor = require '../src/text-editor' TextEditorElement = require '../src/text-editor-element' -TokenizedBuffer = require '../src/tokenized-buffer' +TextMateLanguageMode = require '../src/text-mate-language-mode' clipboard = require '../src/safe-clipboard' jasmineStyle = document.createElement('style') @@ -61,6 +62,9 @@ else specProjectPath = require('os').tmpdir() beforeEach -> + # Do not clobber recent project history + spyOn(Object.getPrototypeOf(atom.history), 'saveState').andReturn(Promise.resolve()) + atom.project.setPaths([specProjectPath]) window.resetTimeouts() @@ -96,8 +100,21 @@ beforeEach -> spyOn(TextEditor.prototype, "shouldPromptToSave").andReturn false # make tokenization synchronous - TokenizedBuffer.prototype.chunkSize = Infinity - spyOn(TokenizedBuffer.prototype, "tokenizeInBackground").andCallFake -> @tokenizeNextChunk() + TextMateLanguageMode.prototype.chunkSize = Infinity + spyOn(TextMateLanguageMode.prototype, "tokenizeInBackground").andCallFake -> @tokenizeNextChunk() + + # Without this spy, TextEditor.onDidTokenize callbacks would not be called + # after the buffer's language mode changed, because by the time the editor + # called its new language mode's onDidTokenize method, the language mode + # would already be fully tokenized. + spyOn(TextEditor.prototype, "onDidTokenize").andCallFake (callback) -> + new CompositeDisposable( + @emitter.on("did-tokenize", callback), + @onDidChangeGrammar => + languageMode = @buffer.getLanguageMode() + if languageMode.tokenizeInBackground?.originalValue + callback() + ) clipboardContent = 'initial clipboard content' spyOn(clipboard, 'writeText').andCallFake (text) -> clipboardContent = text 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 0738b291f..d7489348a 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -25,6 +25,8 @@ document.registerElement('text-editor-component-test-element', { }) }) +const editors = [] + describe('TextEditorComponent', () => { beforeEach(() => { jasmine.useRealClock() @@ -35,6 +37,13 @@ describe('TextEditorComponent', () => { jasmine.attachToDOM(scrollbarStyle) }) + afterEach(() => { + for (const editor of editors) { + editor.destroy() + } + editors.length = 0 + }) + describe('rendering', () => { it('renders lines and line numbers for the visible region', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) @@ -254,13 +263,13 @@ describe('TextEditorComponent', () => { it('keeps the number of tiles stable when the visible line count changes during vertical scrolling', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) await setEditorHeightInLines(component, 5.5) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers await setScrollTop(component, 0.5 * component.getLineHeight()) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers await setScrollTop(component, 1 * component.getLineHeight()) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers }) it('recycles tiles on resize', async () => { @@ -786,7 +795,7 @@ describe('TextEditorComponent', () => { const {editor, element, component} = buildComponent() expect(element.dataset.grammar).toBe('text plain null-grammar') - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + atom.grammars.assignLanguageMode(editor.getBuffer(), 'source.js') await component.getNextUpdatePromise() expect(element.dataset.grammar).toBe('source js') }) @@ -1359,7 +1368,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = 20 * (scrollSensitivity / 100) const expectedScrollLeft = component.getScrollLeft() - component.didMouseWheel({deltaX: 5, deltaY: 20}) + component.didMouseWheel({wheelDeltaX: -5, wheelDeltaY: -20}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`) @@ -1368,7 +1377,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = component.getScrollTop() - (10 * (scrollSensitivity / 100)) const expectedScrollLeft = component.getScrollLeft() - component.didMouseWheel({deltaX: 5, deltaY: -10}) + component.didMouseWheel({wheelDeltaX: -5, wheelDeltaY: 10}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`) @@ -1377,7 +1386,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = component.getScrollTop() const expectedScrollLeft = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 20, deltaY: -10}) + component.didMouseWheel({wheelDeltaX: -20, wheelDeltaY: 10}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`) @@ -1386,7 +1395,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = component.getScrollTop() const expectedScrollLeft = component.getScrollLeft() - (10 * (scrollSensitivity / 100)) - component.didMouseWheel({deltaX: -10, deltaY: 8}) + component.didMouseWheel({wheelDeltaX: 10, wheelDeltaY: -8}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(${-expectedScrollLeft}px, ${-expectedScrollTop}px)`) @@ -1398,14 +1407,14 @@ describe('TextEditorComponent', () => { const {component, editor} = buildComponent({height: 50, width: 50, scrollSensitivity}) { - component.didMouseWheel({deltaX: 0, deltaY: 3}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -3}) expect(component.getScrollTop()).toBe(1) expect(component.getScrollLeft()).toBe(0) expect(component.refs.content.style.transform).toBe(`translate(0px, -1px)`) } { - component.didMouseWheel({deltaX: 4, deltaY: 0}) + component.didMouseWheel({wheelDeltaX: -4, wheelDeltaY: 0}) expect(component.getScrollTop()).toBe(1) expect(component.getScrollLeft()).toBe(1) expect(component.refs.content.style.transform).toBe(`translate(-1px, -1px)`) @@ -1413,14 +1422,14 @@ describe('TextEditorComponent', () => { editor.update({scrollSensitivity: 100}) { - component.didMouseWheel({deltaX: 0, deltaY: -0.3}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: 0.3}) expect(component.getScrollTop()).toBe(0) expect(component.getScrollLeft()).toBe(1) expect(component.refs.content.style.transform).toBe(`translate(-1px, 0px)`) } { - component.didMouseWheel({deltaX: -0.1, deltaY: 0}) + component.didMouseWheel({wheelDeltaX: 0.1, wheelDeltaY: 0}) expect(component.getScrollTop()).toBe(0) expect(component.getScrollLeft()).toBe(0) expect(component.refs.content.style.transform).toBe(`translate(0px, 0px)`) @@ -1434,7 +1443,7 @@ describe('TextEditorComponent', () => { component.props.platform = 'linux' { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1442,7 +1451,7 @@ describe('TextEditorComponent', () => { { const expectedScrollLeft = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20, shiftKey: true}) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) await setScrollLeft(component, 0) @@ -1450,7 +1459,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: -20, wheelDeltaY: 0, shiftKey: true}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1459,7 +1468,7 @@ describe('TextEditorComponent', () => { component.props.platform = 'win32' { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1467,7 +1476,7 @@ describe('TextEditorComponent', () => { { const expectedScrollLeft = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20, shiftKey: true}) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) await setScrollLeft(component, 0) @@ -1475,7 +1484,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: -20, wheelDeltaY: 0, shiftKey: true}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1484,7 +1493,7 @@ describe('TextEditorComponent', () => { component.props.platform = 'darwin' { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1492,7 +1501,7 @@ describe('TextEditorComponent', () => { { const expectedScrollTop = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 0, deltaY: 20, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -20, shiftKey: true}) expect(component.getScrollTop()).toBe(expectedScrollTop) expect(component.refs.content.style.transform).toBe(`translate(0px, -${expectedScrollTop}px)`) await setScrollTop(component, 0) @@ -1500,7 +1509,7 @@ describe('TextEditorComponent', () => { { const expectedScrollLeft = 20 * (scrollSensitivity / 100) - component.didMouseWheel({deltaX: 20, deltaY: 0, shiftKey: true}) + component.didMouseWheel({wheelDeltaX: -20, wheelDeltaY: 0, shiftKey: true}) expect(component.getScrollLeft()).toBe(expectedScrollLeft) expect(component.refs.content.style.transform).toBe(`translate(-${expectedScrollLeft}px, 0px)`) await setScrollLeft(component, 0) @@ -2865,431 +2874,501 @@ describe('TextEditorComponent', () => { describe('mouse input', () => { describe('on the lines', () => { - it('positions the cursor on single-click', async () => { - const {component, element, editor} = buildComponent() - const {lineHeight} = component.measurements + 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: 0, - clientX: clientLeftForCharacter(component, 0, 0) - 1, - clientY: clientTopForLine(component, 0) - 1 + 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([]) + } }) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - const maxRow = editor.getLastScreenRow() - editor.setCursorScreenPosition([Infinity, Infinity], {autoscroll: false}) - component.didMouseDownOnContent({ - detail: 1, - button: 0, - 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: 0, - 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: 0, - 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: 0, - 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: 0, - 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: 0, - 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: 0, - 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([]) }) - it('selects words on double-click', () => { - const {component, editor} = buildComponent() - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) - expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) - 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([5, 17], {autoscroll: false}) + editor.addCursorAtScreenPosition([2, 4]) + component.didMouseDownOnContent({ + detail: 1, + button: 2, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([5, 17]), Point.fromObject([2, 4])]) + }) + + 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: 1, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([0, 0])]) + }) }) - it('selects lines on triple-click', () => { - const {component, editor} = buildComponent() - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, button: 0, clientX, clientY}) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) - expect(editor.testAutoscrollRequests).toEqual([]) + 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: 2, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + 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: 1, + clientX: clientLeftForCharacter(component, 0, 0) - 1, + clientY: clientTopForLine(component, 0) - 1 + }) + expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]]) + }) }) - it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { - const {component, editor} = buildComponent({platform: 'darwin'}) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) + describe('when the input is for the primary mouse button', () => { + it('selects words on double-click', () => { + const {component, editor} = buildComponent() + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 13], [1, 21]]) + expect(editor.testAutoscrollRequests).toEqual([]) + }) - // add cursor at 1, 16 - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + it('selects lines on triple-click', () => { + const {component, editor} = buildComponent() + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, clientX, clientY}) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [2, 0]]) + expect(editor.testAutoscrollRequests).toEqual([]) + }) + + it('adds or removes cursors when holding cmd or ctrl when single-clicking', () => { + const {component, editor} = buildComponent({platform: 'darwin'}) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0]]) + + // add cursor at 1, 16 + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + // remove cursor at 0, 0 + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 0, 0), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-click cursor at 1, 16 but don't remove it because it's the last one + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + + // cmd-clicking within a selection destroys it + editor.addSelectionForScreenRange([[2, 10], [2, 15]], {autoscroll: false}) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]], + [[2, 10], [2, 15]] + ]) + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 2, 13), { + detail: 1, + button: 0, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]] + ]) + + // ctrl-click does not add cursors on macOS, but it *does* move the cursor + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 4), { + detail: 1, + button: 0, + ctrlKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [1, 4]] + ]) + + // ctrl-click adds cursors on platforms *other* than macOS + component.props.platform = 'win32' + editor.setCursorScreenPosition([1, 4], {autoscroll: false}) + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + ctrlKey: true + }) + ) + expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]]) + + expect(editor.testAutoscrollRequests).toEqual([]) + }) + + it('adds word selections when holding cmd or ctrl when double-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16], {autoscroll: false}) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 1, + button: 0, + metaKey: true + }) + ) + component.didMouseDownOnContent( + Object.assign(clientPositionForCharacter(component, 1, 16), { + detail: 2, + button: 0, + metaKey: true + }) + ) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 13], [1, 21]] + ]) + expect(editor.testAutoscrollRequests).toEqual([]) + }) + + it('adds line selections when holding cmd or ctrl when triple-clicking', () => { + const {component, editor} = buildComponent() + editor.addCursorAtScreenPosition([1, 16], {autoscroll: false}) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + + const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) + component.didMouseDownOnContent({detail: 1, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 2, button: 0, metaKey: true, clientX, clientY}) + component.didMouseDownOnContent({detail: 3, button: 0, metaKey: true, clientX, clientY}) + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 0], [2, 0]] + ]) + expect(editor.testAutoscrollRequests).toEqual([]) + }) + + it('expands the last selection on shift-click', () => { + const {component, element, editor} = buildComponent() + + editor.setCursorScreenPosition([2, 18], {autoscroll: false}) + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) - // remove cursor at 0, 0 - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 0, 0), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 4, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) - // cmd-click cursor at 1, 16 but don't remove it because it's the last one - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + // reorients word-wise selections to keep the word selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18], {autoscroll: false}) + editor.getLastSelection().selectWord({autoscroll: false}) + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) - // cmd-clicking within a selection destroys it - editor.addSelectionForScreenRange([[2, 10], [2, 15]], {autoscroll: false}) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]], - [[2, 10], [2, 15]] - ]) - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 2, 13), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]] - ]) + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) - // ctrl-click does not add cursors on macOS - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 4), { + // reorients line-wise selections to keep the line selected regardless of + // where the subsequent shift-click occurs + editor.setCursorScreenPosition([2, 18], {autoscroll: false}) + editor.getLastSelection().selectLine(null, {autoscroll: false}) + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - ctrlKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 16], [1, 16]] - ]) + shiftKey: true + }, clientPositionForCharacter(component, 1, 4))) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - // ctrl-click adds cursors on platforms *other* than macOS - component.props.platform = 'win32' - editor.setCursorScreenPosition([1, 4], {autoscroll: false}) - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - ctrlKey: true - }) - ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 4], [1, 16]]) + shiftKey: true + }, clientPositionForCharacter(component, 3, 11))) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) - expect(editor.testAutoscrollRequests).toEqual([]) - }) + expect(editor.testAutoscrollRequests).toEqual([]) + }) - it('adds word selections when holding cmd or ctrl when double-clicking', () => { - const {component, editor} = buildComponent() - editor.addCursorAtScreenPosition([1, 16], {autoscroll: false}) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) + it('expands the last selection on drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + component.didMouseDownOnContent(Object.assign({ detail: 1, button: 0, - metaKey: true - }) - ) - component.didMouseDownOnContent( - Object.assign(clientPositionForCharacter(component, 1, 16), { + }, clientPositionForCharacter(component, 1, 4))) + + { + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + didDrag(clientPositionForCharacter(component, 8, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) + didDrag(clientPositionForCharacter(component, 4, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + didStopDragging() + expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) + } + + // Click-drag a second selection... selections are not merged until the + // drag stops. + component.didMouseDownOnContent(Object.assign({ + detail: 1, + button: 0, + metaKey: 1, + }, clientPositionForCharacter(component, 8, 8))) + { + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 6, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[6, 8], [8, 8]] + ]) + didDrag(clientPositionForCharacter(component, 2, 8)) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [4, 8]], + [[2, 8], [8, 8]] + ]) + didStopDragging() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 4], [8, 8]] + ]) + } + }) + + it('expands the selection word-wise on double-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') + + component.didMouseDownOnContent(Object.assign({ + detail: 1, + button: 0, + }, clientPositionForCharacter(component, 1, 4))) + component.didMouseDownOnContent(Object.assign({ detail: 2, button: 0, - metaKey: true - }) - ) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[0, 0], [0, 0]], - [[1, 13], [1, 21]] - ]) - expect(editor.testAutoscrollRequests).toEqual([]) - }) + }, clientPositionForCharacter(component, 1, 4))) - it('adds line selections when holding cmd or ctrl when triple-clicking', () => { - const {component, editor} = buildComponent() - editor.addCursorAtScreenPosition([1, 16], {autoscroll: false}) - expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [1, 16]]) - - const {clientX, clientY} = clientPositionForCharacter(component, 1, 16) - component.didMouseDownOnContent({detail: 1, button: 0, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 2, button: 0, metaKey: true, clientX, clientY}) - component.didMouseDownOnContent({detail: 3, button: 0, metaKey: true, clientX, clientY}) - - expect(editor.getSelectedScreenRanges()).toEqual([ - [[0, 0], [0, 0]], - [[1, 0], [2, 0]] - ]) - expect(editor.testAutoscrollRequests).toEqual([]) - }) - - it('expands the last selection on shift-click', () => { - const {component, element, editor} = buildComponent() - - editor.setCursorScreenPosition([2, 18], {autoscroll: false}) - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [2, 18]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 4, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 18], [4, 4]]) - - // reorients word-wise selections to keep the word selected regardless of - // where the subsequent shift-click occurs - editor.setCursorScreenPosition([2, 18], {autoscroll: false}) - editor.getLastSelection().selectWord({autoscroll: false}) - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 20]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 3, 11))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 14], [3, 13]]) - - // reorients line-wise selections to keep the word selected regardless of - // where the subsequent shift-click occurs - editor.setCursorScreenPosition([2, 18], {autoscroll: false}) - editor.getLastSelection().selectLine(null, {autoscroll: false}) - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 1, 4))) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - shiftKey: true - }, clientPositionForCharacter(component, 3, 11))) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [4, 0]]) - - expect(editor.testAutoscrollRequests).toEqual([]) - }) - - it('expands the last selection on drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') - - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) - - { - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] - didDrag(clientPositionForCharacter(component, 8, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [8, 8]]) - didDrag(clientPositionForCharacter(component, 4, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) - didStopDragging() - expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [4, 8]]) - } - - // Click-drag a second selection... selections are not merged until the - // drag stops. - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - metaKey: 1, - }, clientPositionForCharacter(component, 8, 8))) - { const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] - didDrag(clientPositionForCharacter(component, 2, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[2, 8], [8, 8]] - ]) - didDrag(clientPositionForCharacter(component, 6, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[6, 8], [8, 8]] - ]) - didDrag(clientPositionForCharacter(component, 2, 8)) - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [4, 8]], - [[2, 8], [8, 8]] - ]) - didStopDragging() - expect(editor.getSelectedScreenRanges()).toEqual([ - [[1, 4], [8, 8]] - ]) - } - }) + didDrag(clientPositionForCharacter(component, 0, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) + didDrag(clientPositionForCharacter(component, 2, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]]) + }) - it('expands the selection word-wise on double-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') + it('expands the selection line-wise on triple-click-drag', () => { + const {component, editor} = buildComponent() + spyOn(component, 'handleMouseDragUntilMouseUp') - component.didMouseDownOnContent(Object.assign({ - detail: 1, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) - component.didMouseDownOnContent(Object.assign({ - detail: 2, - button: 0, - }, clientPositionForCharacter(component, 1, 4))) + const tripleClickPosition = clientPositionForCharacter(component, 2, 8) + component.didMouseDownOnContent(Object.assign({detail: 1, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) + component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[1][0] - didDrag(clientPositionForCharacter(component, 0, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[0, 4], [1, 5]]) - didDrag(clientPositionForCharacter(component, 2, 10)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 2], [2, 13]]) - }) + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[2][0] + didDrag(clientPositionForCharacter(component, 1, 8)) + expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) + didDrag(clientPositionForCharacter(component, 4, 10)) + expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) + }) - it('expands the selection line-wise on triple-click-drag', () => { - const {component, editor} = buildComponent() - spyOn(component, 'handleMouseDragUntilMouseUp') + it('destroys folds when clicking on their fold markers', async () => { + const {component, element, editor} = buildComponent() + editor.foldBufferRow(1) + await component.getNextUpdatePromise() - const tripleClickPosition = clientPositionForCharacter(component, 2, 8) - component.didMouseDownOnContent(Object.assign({detail: 1, button: 0}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 2, button: 0}, tripleClickPosition)) - component.didMouseDownOnContent(Object.assign({detail: 3, button: 0}, tripleClickPosition)) + const target = element.querySelector('.fold-marker') + const {clientX, clientY} = clientPositionForCharacter(component, 1, editor.lineLengthForScreenRow(1)) + component.didMouseDownOnContent({detail: 1, button: 0, target, clientX, clientY}) + expect(editor.isFoldedAtBufferRow(1)).toBe(false) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[2][0] - didDrag(clientPositionForCharacter(component, 1, 8)) - expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 0]]) - didDrag(clientPositionForCharacter(component, 4, 10)) - expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [5, 0]]) - }) + it('autoscrolls the content when dragging near the edge of the scroll container', async () => { + const {component, element, editor} = buildComponent({width: 200, height: 200}) + spyOn(component, 'handleMouseDragUntilMouseUp') - it('destroys folds when clicking on their fold markers', async () => { - const {component, element, editor} = buildComponent() - editor.foldBufferRow(1) - await component.getNextUpdatePromise() + let previousScrollTop = 0 + let previousScrollLeft = 0 + function assertScrolledDownAndRight () { + expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBeGreaterThan(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() + } - const target = element.querySelector('.fold-marker') - const {clientX, clientY} = clientPositionForCharacter(component, 1, editor.lineLengthForScreenRow(1)) - component.didMouseDownOnContent({detail: 1, button: 0, target, clientX, clientY}) - expect(editor.isFoldedAtBufferRow(1)).toBe(false) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - }) + function assertScrolledUpAndLeft () { + expect(component.getScrollTop()).toBeLessThan(previousScrollTop) + previousScrollTop = component.getScrollTop() + expect(component.getScrollLeft()).toBeLessThan(previousScrollLeft) + previousScrollLeft = component.getScrollLeft() + } - it('autoscrolls the content when dragging near the edge of the scroll container', async () => { - const {component, element, editor} = buildComponent({width: 200, height: 200}) - spyOn(component, 'handleMouseDragUntilMouseUp') + component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) + const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] - let previousScrollTop = 0 - let previousScrollLeft = 0 - function assertScrolledDownAndRight () { - expect(component.getScrollTop()).toBeGreaterThan(previousScrollTop) - previousScrollTop = component.getScrollTop() - expect(component.getScrollLeft()).toBeGreaterThan(previousScrollLeft) - previousScrollLeft = component.getScrollLeft() - } + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: 199, clientY: 199}) + assertScrolledDownAndRight() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + assertScrolledUpAndLeft() - function assertScrolledUpAndLeft () { - expect(component.getScrollTop()).toBeLessThan(previousScrollTop) - previousScrollTop = component.getScrollTop() - expect(component.getScrollLeft()).toBeLessThan(previousScrollLeft) - previousScrollLeft = component.getScrollLeft() - } + // Don't artificially update scroll position beyond possible values + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) + didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) + expect(component.getScrollTop()).toBe(0) + expect(component.getScrollLeft()).toBe(0) - component.didMouseDownOnContent({detail: 1, button: 0, clientX: 100, clientY: 100}) - const {didDrag, didStopDragging} = component.handleMouseDragUntilMouseUp.argsForCall[0][0] + const maxScrollTop = component.getMaxScrollTop() + const maxScrollLeft = component.getMaxScrollLeft() + setScrollTop(component, maxScrollTop) + await setScrollLeft(component, maxScrollLeft) - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: 199, clientY: 199}) - assertScrolledDownAndRight() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - assertScrolledUpAndLeft() - - // Don't artificially update scroll position beyond possible values - expect(component.getScrollTop()).toBe(0) - expect(component.getScrollLeft()).toBe(0) - didDrag({clientX: component.getGutterContainerWidth() + 1, clientY: 1}) - expect(component.getScrollTop()).toBe(0) - expect(component.getScrollLeft()).toBe(0) - - const maxScrollTop = component.getMaxScrollTop() - const maxScrollLeft = component.getMaxScrollLeft() - setScrollTop(component, maxScrollTop) - await setScrollLeft(component, maxScrollLeft) - - didDrag({clientX: 199, clientY: 199}) - didDrag({clientX: 199, clientY: 199}) - didDrag({clientX: 199, clientY: 199}) - expect(component.getScrollTop()).toBe(maxScrollTop) - expect(component.getScrollLeft()).toBe(maxScrollLeft) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + didDrag({clientX: 199, clientY: 199}) + expect(component.getScrollTop()).toBe(maxScrollTop) + expect(component.getScrollLeft()).toBe(maxScrollLeft) + }) }) it('pastes the previously selected text when clicking the middle mouse button on Linux', async () => { @@ -3633,421 +3712,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') }) }) @@ -4512,9 +4368,11 @@ function buildEditor (params = {}) { for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'showLineNumbers', 'placeholderText', 'softWrapped', 'scrollSensitivity']) { if (params[paramName] != null) editorParams[paramName] = params[paramName] } + atom.grammars.autoAssignLanguageMode(buffer) const editor = new TextEditor(editorParams) editor.testAutoscrollRequests = [] editor.onDidRequestAutoscroll((request) => { editor.testAutoscrollRequests.push(request) }) + editors.push(editor) return editor } diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index 7cdd374a1..7ffdf374d 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -70,20 +70,47 @@ 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", function (done) { + it("adds the 'mini' attribute if .isMini() returns true on the model", async () => { const element = buildTextEditorElement() element.getModel().update({mini: true}) - atom.views.getNextUpdatePromise().then(() => { - expect(element.hasAttribute('mini')).toBe(true) - done() - }) + await atom.views.getNextUpdatePromise() + expect(element.hasAttribute('mini')).toBe(true) }) ) @@ -268,12 +295,11 @@ describe('TextEditorElement', () => { }) ) - describe('::setUpdatedSynchronously', () => + describe('::setUpdatedSynchronously', () => { it('controls whether the text editor is updated synchronously', () => { spyOn(window, 'requestAnimationFrame').andCallFake(fn => fn()) const element = buildTextEditorElement() - jasmine.attachToDOM(element) expect(element.isUpdatedSynchronously()).toBe(false) @@ -288,7 +314,7 @@ describe('TextEditorElement', () => { expect(window.requestAnimationFrame).not.toHaveBeenCalled() expect(element.textContent).toContain('goodbye') }) - ) + }) describe('::getDefaultCharacterWidth', () => { it('returns 0 before the element is attached', () => { diff --git a/spec/text-editor-registry-spec.js b/spec/text-editor-registry-spec.js index 017ef1f1b..4f4d1ee93 100644 --- a/spec/text-editor-registry-spec.js +++ b/spec/text-editor-registry-spec.js @@ -1,10 +1,8 @@ -/** @babel */ - -import TextEditorRegistry from '../src/text-editor-registry' -import TextEditor from '../src/text-editor' -import TextBuffer from 'text-buffer' -import {it, fit, ffit, fffit} from './async-spec-helpers' -import dedent from 'dedent' +const TextEditorRegistry = require('../src/text-editor-registry') +const TextEditor = require('../src/text-editor') +const TextBuffer = require('text-buffer') +const {it, fit, ffit, fffit} = require('./async-spec-helpers') +const dedent = require('dedent') describe('TextEditorRegistry', function () { let registry, editor, initialPackageActivation @@ -20,6 +18,7 @@ describe('TextEditorRegistry', function () { }) editor = new TextEditor({autoHeight: false}) + expect(atom.grammars.assignLanguageMode(editor, 'text.plain.null-grammar')).toBe(true) }) afterEach(function () { @@ -71,128 +70,17 @@ describe('TextEditorRegistry', function () { atom.config.set('editor.tabLength', 8, {scope: '.source.js'}) const editor = registry.build({buffer: new TextBuffer({filePath: 'test.js'})}) - expect(editor.getGrammar().name).toBe("JavaScript") expect(editor.getTabLength()).toBe(8) }) }) - describe('.maintainGrammar', function () { - it('assigns a grammar to the editor based on its path', async function () { - await atom.packages.activatePackage('language-javascript') - await atom.packages.activatePackage('language-c') - - editor.getBuffer().setPath('test.js') - registry.maintainGrammar(editor) - - expect(editor.getGrammar().name).toBe('JavaScript') - - editor.getBuffer().setPath('test.c') - expect(editor.getGrammar().name).toBe('C') - }) - - it('updates the editor\'s grammar when a more appropriate grammar is added for its path', async function () { - expect(editor.getGrammar().name).toBe('Null Grammar') - - editor.getBuffer().setPath('test.js') - registry.maintainGrammar(editor) - await atom.packages.activatePackage('language-javascript') - expect(editor.getGrammar().name).toBe('JavaScript') - }) - - it('returns a disposable that can be used to stop the registry from updating the editor', async function () { - await atom.packages.activatePackage('language-javascript') - - const previousSubscriptionCount = getSubscriptionCount(editor) - const disposable = registry.maintainGrammar(editor) - expect(getSubscriptionCount(editor)).toBeGreaterThan(previousSubscriptionCount) - expect(registry.editorsWithMaintainedGrammar.size).toBe(1) - - editor.getBuffer().setPath('test.js') - expect(editor.getGrammar().name).toBe('JavaScript') - - editor.getBuffer().setPath('test.txt') - expect(editor.getGrammar().name).toBe('Null Grammar') - - disposable.dispose() - expect(getSubscriptionCount(editor)).toBe(previousSubscriptionCount) - expect(registry.editorsWithMaintainedGrammar.size).toBe(0) - - editor.getBuffer().setPath('test.js') - expect(editor.getGrammar().name).toBe('Null Grammar') - expect(retainedEditorCount(registry)).toBe(0) - }) - - describe('when called twice with a given editor', function () { - it('does nothing the second time', async function () { - await atom.packages.activatePackage('language-javascript') - const disposable1 = registry.maintainGrammar(editor) - const disposable2 = registry.maintainGrammar(editor) - - editor.getBuffer().setPath('test.js') - expect(editor.getGrammar().name).toBe('JavaScript') - - disposable2.dispose() - editor.getBuffer().setPath('test.txt') - expect(editor.getGrammar().name).toBe('Null Grammar') - - disposable1.dispose() - editor.getBuffer().setPath('test.js') - expect(editor.getGrammar().name).toBe('Null Grammar') - }) - }) - }) - - describe('.setGrammarOverride', function () { - it('sets the editor\'s grammar and does not update it based on other criteria', async function () { - await atom.packages.activatePackage('language-c') - await atom.packages.activatePackage('language-javascript') - - registry.maintainGrammar(editor) - editor.getBuffer().setPath('file-1.js') - expect(editor.getGrammar().name).toBe('JavaScript') - - registry.setGrammarOverride(editor, 'source.c') - expect(editor.getGrammar().name).toBe('C') - - editor.getBuffer().setPath('file-3.rb') - await atom.packages.activatePackage('language-ruby') - expect(editor.getGrammar().name).toBe('C') - - editor.getBuffer().setPath('file-1.js') - expect(editor.getGrammar().name).toBe('C') - }) - }) - - describe('.clearGrammarOverride', function () { - it('resumes setting the grammar based on its path and content', async function () { - await atom.packages.activatePackage('language-c') - await atom.packages.activatePackage('language-javascript') - - registry.maintainGrammar(editor) - editor.getBuffer().setPath('file-1.js') - expect(editor.getGrammar().name).toBe('JavaScript') - - registry.setGrammarOverride(editor, 'source.c') - expect(registry.getGrammarOverride(editor)).toBe('source.c') - expect(editor.getGrammar().name).toBe('C') - - registry.clearGrammarOverride(editor) - expect(editor.getGrammar().name).toBe('JavaScript') - - editor.getBuffer().setPath('file-3.rb') - await atom.packages.activatePackage('language-ruby') - expect(editor.getGrammar().name).toBe('Ruby') - expect(registry.getGrammarOverride(editor)).toBe(undefined) - }) - }) - describe('.maintainConfig(editor)', function () { it('does not update the editor when config settings change for unrelated scope selectors', async function () { await atom.packages.activatePackage('language-javascript') const editor2 = new TextEditor() - editor2.setGrammar(atom.grammars.selectGrammar('test.js')) + atom.grammars.assignLanguageMode(editor2, 'source.js') registry.maintainConfig(editor) registry.maintainConfig(editor2) @@ -254,18 +142,57 @@ describe('TextEditorRegistry', function () { atom.config.set('core.fileEncoding', 'utf16le', {scopeSelector: '.source.js'}) expect(editor.getEncoding()).toBe('utf8') - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + atom.grammars.assignLanguageMode(editor, 'source.js') await initialPackageActivation expect(editor.getEncoding()).toBe('utf16le') atom.config.set('core.fileEncoding', 'utf16be', {scopeSelector: '.source.js'}) expect(editor.getEncoding()).toBe('utf16be') - editor.setGrammar(atom.grammars.selectGrammar('test.txt')) + atom.grammars.assignLanguageMode(editor, 'text.plain.null-grammar') await initialPackageActivation expect(editor.getEncoding()).toBe('utf8') }) + it('preserves editor settings that haven\'t changed between previous and current language modes', async function () { + await atom.packages.activatePackage('language-javascript') + + registry.maintainConfig(editor) + await initialPackageActivation + + expect(editor.getEncoding()).toBe('utf8') + editor.setEncoding('utf16le') + expect(editor.getEncoding()).toBe('utf16le') + + expect(editor.isSoftWrapped()).toBe(false) + editor.setSoftWrapped(true) + expect(editor.isSoftWrapped()).toBe(true) + + atom.grammars.assignLanguageMode(editor, 'source.js') + await initialPackageActivation + expect(editor.getEncoding()).toBe('utf16le') + expect(editor.isSoftWrapped()).toBe(true) + }) + + it('updates editor settings that have changed between previous and current language modes', async function () { + await atom.packages.activatePackage('language-javascript') + + registry.maintainConfig(editor) + await initialPackageActivation + + expect(editor.getEncoding()).toBe('utf8') + atom.config.set('core.fileEncoding', 'utf16be', {scopeSelector: '.text.plain.null-grammar'}) + atom.config.set('core.fileEncoding', 'utf16le', {scopeSelector: '.source.js'}) + expect(editor.getEncoding()).toBe('utf16be') + + editor.setEncoding('utf8') + expect(editor.getEncoding()).toBe('utf8') + + atom.grammars.assignLanguageMode(editor, 'source.js') + await initialPackageActivation + expect(editor.getEncoding()).toBe('utf16le') + }) + it('returns a disposable that can be used to stop the registry from updating the editor\'s config', async function () { await atom.packages.activatePackage('language-javascript') @@ -331,7 +258,7 @@ describe('TextEditorRegistry', function () { describe('when the "tabType" config setting is "auto"', function () { it('enables or disables soft tabs based on the editor\'s content', async function () { await atom.packages.activatePackage('language-javascript') - editor.setGrammar(atom.grammars.selectGrammar('test.js')) + atom.grammars.assignLanguageMode(editor, 'source.js') atom.config.set('editor.tabType', 'auto') registry.maintainConfig(editor) @@ -342,7 +269,7 @@ describe('TextEditorRegistry', function () { hello; } `) - editor.tokenizedBuffer.retokenizeLines() + editor.getBuffer().getLanguageMode().retokenizeLines() expect(editor.getSoftTabs()).toBe(true) editor.setText(dedent` @@ -350,7 +277,7 @@ describe('TextEditorRegistry', function () { hello; } `) - editor.tokenizedBuffer.retokenizeLines() + editor.getBuffer().getLanguageMode().retokenizeLines() expect(editor.getSoftTabs()).toBe(false) editor.setText(dedent` @@ -361,7 +288,7 @@ describe('TextEditorRegistry', function () { ${'\t'}hello; } ` + editor.getText()) - editor.tokenizedBuffer.retokenizeLines() + editor.getBuffer().getLanguageMode().retokenizeLines() expect(editor.getSoftTabs()).toBe(false) editor.setText(dedent` @@ -374,7 +301,7 @@ describe('TextEditorRegistry', function () { } `) - editor.tokenizedBuffer.retokenizeLines() + editor.getBuffer().getLanguageMode().retokenizeLines() expect(editor.getSoftTabs()).toBe(false) editor.setText(dedent` @@ -386,7 +313,7 @@ describe('TextEditorRegistry', function () { hello; } `) - editor.tokenizedBuffer.retokenizeLines() + editor.getBuffer().getLanguageMode().retokenizeLines() expect(editor.getSoftTabs()).toBe(true) }) }) @@ -624,19 +551,6 @@ describe('TextEditorRegistry', function () { expect(editor.getUndoGroupingInterval()).toBe(300) }) - it('sets the non-word characters based on the config', async function () { - editor.update({nonWordCharacters: '()'}) - expect(editor.getNonWordCharacters()).toBe('()') - - atom.config.set('editor.nonWordCharacters', '(){}') - registry.maintainConfig(editor) - await initialPackageActivation - expect(editor.getNonWordCharacters()).toBe('(){}') - - atom.config.set('editor.nonWordCharacters', '(){}[]') - expect(editor.getNonWordCharacters()).toBe('(){}[]') - }) - it('sets the scroll sensitivity based on the config', async function () { editor.update({scrollSensitivity: 50}) expect(editor.getScrollSensitivity()).toBe(50) @@ -650,21 +564,6 @@ describe('TextEditorRegistry', function () { expect(editor.getScrollSensitivity()).toBe(70) }) - it('gives the editor a scoped-settings delegate based on the config', async function () { - atom.config.set('editor.nonWordCharacters', '()') - atom.config.set('editor.nonWordCharacters', '(){}', {scopeSelector: '.a.b .c.d'}) - atom.config.set('editor.nonWordCharacters', '(){}[]', {scopeSelector: '.e.f *'}) - - registry.maintainConfig(editor) - await initialPackageActivation - - let delegate = editor.getScopedSettingsDelegate() - - expect(delegate.getNonWordCharacters(['a.b', 'c.d'])).toBe('(){}') - expect(delegate.getNonWordCharacters(['e.f', 'g.h'])).toBe('(){}[]') - expect(delegate.getNonWordCharacters(['i.j'])).toBe('()') - }) - describe('when called twice with a given editor', function () { it('does nothing the second time', async function () { editor.update({scrollSensitivity: 50}) @@ -686,46 +585,6 @@ describe('TextEditorRegistry', function () { }) }) }) - - describe('serialization', function () { - it('persists editors\' grammar overrides', async function () { - const editor2 = new TextEditor() - - await atom.packages.activatePackage('language-c') - await atom.packages.activatePackage('language-html') - await atom.packages.activatePackage('language-javascript') - - registry.maintainGrammar(editor) - registry.maintainGrammar(editor2) - registry.setGrammarOverride(editor, 'source.c') - registry.setGrammarOverride(editor2, 'source.js') - - await atom.packages.deactivatePackage('language-javascript') - - const editorCopy = TextEditor.deserialize(editor.serialize(), atom) - const editor2Copy = TextEditor.deserialize(editor2.serialize(), atom) - - const registryCopy = new TextEditorRegistry({ - assert: atom.assert, - config: atom.config, - grammarRegistry: atom.grammars, - packageManager: {deferredActivationHooks: null} - }) - registryCopy.deserialize(JSON.parse(JSON.stringify(registry.serialize()))) - - expect(editorCopy.getGrammar().name).toBe('Null Grammar') - expect(editor2Copy.getGrammar().name).toBe('Null Grammar') - - registryCopy.maintainGrammar(editorCopy) - registryCopy.maintainGrammar(editor2Copy) - expect(editorCopy.getGrammar().name).toBe('C') - expect(editor2Copy.getGrammar().name).toBe('Null Grammar') - - await atom.packages.activatePackage('language-javascript') - expect(editorCopy.getGrammar().name).toBe('C') - expect(editor2Copy.getGrammar().name).toBe('JavaScript') - }) - }) }) function getSubscriptionCount (editor) { diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index fa8406731..a9aa80cd1 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -7,6 +7,7 @@ const dedent = require('dedent') const clipboard = require('../src/safe-clipboard') const TextEditor = require('../src/text-editor') const TextBuffer = require('text-buffer') +const TextMateLanguageMode = require('../src/text-mate-language-mode') describe('TextEditor', () => { let buffer, editor, lineLengths @@ -19,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]]) @@ -85,22 +97,6 @@ describe('TextEditor', () => { }) }) - describe('when the editor is constructed with the largeFileMode option set to true', () => { - it("loads the editor but doesn't tokenize", async () => { - editor = await atom.workspace.openTextFile('sample.js', {largeFileMode: true}) - buffer = editor.getBuffer() - expect(editor.lineTextForScreenRow(0)).toBe(buffer.lineForRow(0)) - expect(editor.tokensForScreenRow(0).length).toBe(1) - expect(editor.tokensForScreenRow(1).length).toBe(2) // soft tab - expect(editor.lineTextForScreenRow(12)).toBe(buffer.lineForRow(12)) - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - editor.insertText('hey"') - expect(editor.tokensForScreenRow(0).length).toBe(1) - expect(editor.tokensForScreenRow(1).length).toBe(2) - }) - }) - describe('.copy()', () => { it('returns a different editor with the same initial state', () => { expect(editor.getAutoHeight()).toBeFalsy() @@ -115,12 +111,12 @@ describe('TextEditor', () => { editor.update({showCursorOnSelection: false}) editor.setSelectedBufferRange([[1, 2], [3, 4]]) editor.addSelectionForBufferRange([[5, 6], [7, 8]], {reversed: true}) - editor.foldBufferRow(4) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() editor.setScrollTopRow(3) expect(editor.getScrollTopRow()).toBe(3) editor.setScrollLeftColumn(4) expect(editor.getScrollLeftColumn()).toBe(4) + editor.foldBufferRow(4) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() const editor2 = editor.copy() const element2 = editor2.getElement() @@ -1356,7 +1352,7 @@ describe('TextEditor', () => { }) it('will limit paragraph range to comments', () => { - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + atom.grammars.assignLanguageMode(editor.getBuffer(), 'source.js') editor.setText(dedent` var quicksort = function () { /* Single line comment block */ @@ -2081,14 +2077,13 @@ describe('TextEditor', () => { expect(scopeDescriptors[0].getScopesArray()).toEqual(['source.js']) expect(scopeDescriptors[1].getScopesArray()).toEqual(['source.js', 'string.quoted.single.js']) - editor.setScopedSettingsDelegate({ - getNonWordCharacters (scopes) { - const result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' - if (scopes.some(scope => scope.startsWith('string'))) { - return result - } else { - return result + '-' - } + spyOn(editor.getBuffer().getLanguageMode(), 'getNonWordCharacters').andCallFake(function (position) { + const result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' + const scopes = this.scopeDescriptorForPosition(position).getScopesArray() + if (scopes.some(scope => scope.startsWith('string'))) { + return result + } else { + return result + '-' } }) @@ -2381,6 +2376,19 @@ describe('TextEditor', () => { ]) }) }) + + it('does not create a new selection if it would be fully contained within another selection', () => { + editor.setText('abc\ndef\nghi\njkl\nmno') + editor.setCursorBufferPosition([0, 1]) + + let addedSelectionCount = 0 + editor.onDidAddSelection(() => { addedSelectionCount++ }) + + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(addedSelectionCount).toBe(3) + }) }) describe('.addSelectionAbove()', () => { @@ -2503,6 +2511,19 @@ describe('TextEditor', () => { ]) }) }) + + it('does not create a new selection if it would be fully contained within another selection', () => { + editor.setText('abc\ndef\nghi\njkl\nmno') + editor.setCursorBufferPosition([4, 1]) + + let addedSelectionCount = 0 + editor.onDidAddSelection(() => { addedSelectionCount++ }) + + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(addedSelectionCount).toBe(3) + }) }) describe('.splitSelectionsIntoLines()', () => { @@ -3512,13 +3533,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) {') }) }) }) @@ -3694,7 +3718,7 @@ describe('TextEditor', () => { describe('when a newline is appended with a trailing closing tag behind the cursor (e.g. by pressing enter in the middel of a line)', () => { it('indents the new line to the correct level when editor.autoIndent is true and using a curly-bracket language', () => { editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar('file.js')) + atom.grammars.assignLanguageMode(editor, 'source.js') editor.setText('var test = () => {\n return true;};') editor.setCursorBufferPosition([1, 14]) editor.insertNewline() @@ -3703,7 +3727,7 @@ describe('TextEditor', () => { }) it('indents the new line to the current level when editor.autoIndent is true and no increaseIndentPattern is specified', () => { - editor.setGrammar(atom.grammars.selectGrammar('file')) + atom.grammars.assignLanguageMode(editor, null) editor.update({autoIndent: true}) editor.setText(' if true') editor.setCursorBufferPosition([0, 8]) @@ -3716,7 +3740,7 @@ describe('TextEditor', () => { it('indents the new line to the correct level when editor.autoIndent is true and using an off-side rule language', async () => { await atom.packages.activatePackage('language-coffee-script') editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar('file.coffee')) + atom.grammars.assignLanguageMode(editor, 'source.coffee') editor.setText('if true\n return trueelse\n return false') editor.setCursorBufferPosition([1, 13]) editor.insertNewline() @@ -3730,7 +3754,7 @@ describe('TextEditor', () => { it('indents the new line to the correct level when editor.autoIndent is true', async () => { await atom.packages.activatePackage('language-go') editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar('file.go')) + atom.grammars.assignLanguageMode(editor, 'source.go') editor.setText('fmt.Printf("some%s",\n "thing")') editor.setCursorBufferPosition([1, 10]) editor.insertNewline() @@ -5403,6 +5427,34 @@ describe('TextEditor', () => { expect(buffer.getLineCount()).toBe(count - 2) }) + it("restores cursor position for multiple cursors", () => { + const line = '0123456789'.repeat(8) + editor.setText((line + '\n').repeat(5)) + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([2, 8]) + editor.deleteLine() + + const cursors = editor.getCursors() + expect(cursors.length).toBe(2) + expect(cursors[0].getScreenPosition()).toEqual([0, 5]) + expect(cursors[1].getScreenPosition()).toEqual([1, 8]) + }) + + it("restores cursor position for multiple selections", () => { + const line = '0123456789'.repeat(8) + editor.setText((line + '\n').repeat(5)) + editor.setSelectedBufferRanges([ + [[0, 5], [0, 8]], + [[2, 4], [2, 15]] + ]) + editor.deleteLine() + + const cursors = editor.getCursors() + expect(cursors.length).toBe(2) + expect(cursors[0].getScreenPosition()).toEqual([0, 5]) + expect(cursors[1].getScreenPosition()).toEqual([1, 4]) + }) + it('deletes a line only once when multiple selections are on the same line', () => { const line1 = buffer.lineForRow(1) const count = buffer.getLineCount() @@ -5622,21 +5674,30 @@ describe('TextEditor', () => { }) }) - describe('when a better-matched grammar is added to syntax', () => { - it('switches to the better-matched grammar and re-tokenizes the buffer', async () => { - editor.destroy() + describe('when the buffer\'s language mode changes', () => { + it('notifies onDidTokenize observers when retokenization is finished', async () => { + // Exercise the full `tokenizeInBackground` code path, which bails out early if + // `.setVisible` has not been called with `true`. + jasmine.unspy(TextMateLanguageMode.prototype, 'tokenizeInBackground') + jasmine.attachToDOM(editor.getElement()) - const jsGrammar = atom.grammars.selectGrammar('a.js') - atom.grammars.removeGrammar(jsGrammar) + const events = [] + editor.onDidTokenize(event => events.push(event)) - editor = await atom.workspace.open('sample.js', {autoIndent: false}) + await atom.packages.activatePackage('language-c') + expect(atom.grammars.assignLanguageMode(editor.getBuffer(), 'source.c')).toBe(true) + advanceClock(1) + expect(events.length).toBe(1) + }) - expect(editor.getGrammar()).toBe(atom.grammars.nullGrammar) - expect(editor.tokensForScreenRow(0).length).toBe(1) + it('notifies onDidChangeGrammar observers', async () => { + const events = [] + editor.onDidChangeGrammar(grammar => events.push(grammar)) - atom.grammars.addGrammar(jsGrammar) - expect(editor.getGrammar()).toBe(jsGrammar) - expect(editor.tokensForScreenRow(0).length).toBeGreaterThan(1) + await atom.packages.activatePackage('language-c') + expect(atom.grammars.assignLanguageMode(editor.getBuffer(), 'source.c')).toBe(true) + expect(events.length).toBe(1) + expect(events[0].name).toBe('C') }) }) @@ -6630,17 +6691,6 @@ describe('TextEditor', () => { }) }) - describe('when the editor is constructed with the grammar option set', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-coffee-script') - }) - - it('sets the grammar', () => { - editor = new TextEditor({grammar: atom.grammars.grammarForScopeName('source.coffee')}) - expect(editor.getGrammar().name).toBe('CoffeeScript') - }) - }) - describe('softWrapAtPreferredLineLength', () => { it('soft wraps the editor at the preferred line length unless the editor is narrower or the editor is mini', () => { editor.update({ @@ -6701,6 +6751,7 @@ describe('TextEditor', () => { beforeEach(async () => { editor = await atom.workspace.open('sample.js') jasmine.unspy(editor, 'shouldPromptToSave') + spyOn(atom.stateStore, 'isConnected').andReturn(true) }) it('returns true when buffer has unsaved changes', () => { @@ -6828,7 +6879,7 @@ describe('TextEditor', () => { }) it('does nothing for empty lines and null grammar', () => { - editor.setGrammar(atom.grammars.grammarForScopeName('text.plain.null-grammar')) + atom.grammars.assignLanguageMode(editor, null) editor.setCursorBufferPosition([10, 0]) editor.toggleLineCommentsInSelection() expect(editor.lineTextForBufferRow(10)).toBe('') @@ -7028,19 +7079,15 @@ describe('TextEditor', () => { }) describe('.unfoldAll()', () => { - it('unfolds every folded line and autoscrolls', async () => { + it('unfolds every folded line', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) - const autoscrollEvents = [] - editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) const initialScreenLineCount = editor.getScreenLineCount() editor.foldBufferRow(0) editor.foldBufferRow(1) expect(editor.getScreenLineCount()).toBeLessThan(initialScreenLineCount) - expect(autoscrollEvents.length).toBe(1) editor.unfoldAll() expect(editor.getScreenLineCount()).toBe(initialScreenLineCount) - expect(autoscrollEvents.length).toBe(2) }) it('unfolds every folded line with comments', async () => { @@ -7058,11 +7105,8 @@ describe('TextEditor', () => { describe('.foldAll()', () => { it('folds every foldable line', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) - const autoscrollEvents = [] - editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) editor.foldAll() - expect(autoscrollEvents.length).toBe(1) const [fold1, fold2, fold3] = editor.unfoldAll() expect([fold1.start.row, fold1.end.row]).toEqual([0, 12]) expect([fold2.start.row, fold2.end.row]).toEqual([1, 9]) @@ -7093,11 +7137,7 @@ describe('TextEditor', () => { describe('when bufferRow can be folded', () => { it('creates a fold based on the syntactic region starting at the given row', () => { - const autoscrollEvents = [] - editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) - editor.foldBufferRow(1) - expect(autoscrollEvents.length).toBe(1) const [fold] = editor.unfoldAll() expect([fold.start.row, fold.end.row]).toEqual([1, 9]) }) @@ -7144,14 +7184,10 @@ describe('TextEditor', () => { describe('.foldCurrentRow()', () => { it('creates a fold at the location of the last cursor', async () => { editor = await atom.workspace.open() - editor.setText('\nif (x) {\n y()\n}') editor.setCursorBufferPosition([1, 0]) expect(editor.getScreenLineCount()).toBe(4) - const autoscrollEvents = [] - editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) editor.foldCurrentRow() - expect(autoscrollEvents.length).toBe(1) expect(editor.getScreenLineCount()).toBe(3) }) @@ -7168,26 +7204,21 @@ describe('TextEditor', () => { describe('.foldAllAtIndentLevel(indentLevel)', () => { it('folds blocks of text at the given indentation level', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) - const autoscrollEvents = [] - editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) editor.foldAllAtIndentLevel(0) expect(editor.lineTextForScreenRow(0)).toBe(`var quicksort = function () {${editor.displayLayer.foldCharacter}`) expect(editor.getLastScreenRow()).toBe(0) - expect(autoscrollEvents.length).toBe(1) editor.foldAllAtIndentLevel(1) expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') expect(editor.lineTextForScreenRow(1)).toBe(` var sort = function(items) {${editor.displayLayer.foldCharacter}`) expect(editor.getLastScreenRow()).toBe(4) - expect(autoscrollEvents.length).toBe(2) editor.foldAllAtIndentLevel(2) expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') expect(editor.lineTextForScreenRow(1)).toBe(' var sort = function(items) {') expect(editor.lineTextForScreenRow(2)).toBe(' if (items.length <= 1) return items;') expect(editor.getLastScreenRow()).toBe(9) - expect(autoscrollEvents.length).toBe(3) }) it('folds every foldable range at a given indentLevel', async () => { diff --git a/spec/text-mate-language-mode-spec.js b/spec/text-mate-language-mode-spec.js new file mode 100644 index 000000000..c6292a63b --- /dev/null +++ b/spec/text-mate-language-mode-spec.js @@ -0,0 +1,1040 @@ +const NullGrammar = require('../src/null-grammar') +const TextMateLanguageMode = require('../src/text-mate-language-mode') +const TextBuffer = require('text-buffer') +const {Point, Range} = TextBuffer +const _ = require('underscore-plus') +const dedent = require('dedent') +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') + +describe('TextMateLanguageMode', () => { + let languageMode, buffer, config + + beforeEach(async () => { + config = atom.config + // enable async tokenization + TextMateLanguageMode.prototype.chunkSize = 5 + jasmine.unspy(TextMateLanguageMode.prototype, 'tokenizeInBackground') + await atom.packages.activatePackage('language-javascript') + }) + + afterEach(() => { + buffer && buffer.destroy() + languageMode && languageMode.destroy() + }) + + describe('when the editor is constructed with the largeFileMode option set to true', () => { + it("loads the editor but doesn't tokenize", async () => { + const line = 'a b c d\n' + buffer = new TextBuffer(line.repeat(256 * 1024)) + expect(buffer.getText().length).toBe(2 * 1024 * 1024) + languageMode = new TextMateLanguageMode({ + buffer, + grammar: atom.grammars.grammarForScopeName('source.js'), + tabLength: 2 + }) + buffer.setLanguageMode(languageMode) + + expect(languageMode.isRowCommented(0)).toBeFalsy() + + // It treats the entire line as one big token + let iterator = languageMode.buildHighlightIterator() + iterator.seek({row: 0, column: 0}) + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual({row: 0, column: 7}) + + buffer.insert([0, 0], 'hey"') + iterator = languageMode.buildHighlightIterator() + iterator.seek({row: 0, column: 0}) + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual({row: 0, column: 11}) + }) + }) + + describe('tokenizing', () => { + describe('when the buffer is destroyed', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + languageMode = new TextMateLanguageMode({buffer, config, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + languageMode.startTokenizing() + }) + + it('stops tokenization', () => { + languageMode.destroy() + spyOn(languageMode, 'tokenizeNextChunk') + advanceClock() + expect(languageMode.tokenizeNextChunk).not.toHaveBeenCalled() + }) + }) + + describe('when the buffer contains soft-tabs', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + buffer.setLanguageMode(languageMode) + languageMode.startTokenizing() + }) + + afterEach(() => { + languageMode.destroy() + buffer.release() + }) + + describe('on construction', () => + it('tokenizes lines chunk at a time in the background', () => { + const line0 = languageMode.tokenizedLines[0] + expect(line0).toBeUndefined() + + const line11 = languageMode.tokenizedLines[11] + expect(line11).toBeUndefined() + + // tokenize chunk 1 + advanceClock() + expect(languageMode.tokenizedLines[0].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[4].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[5]).toBeUndefined() + + // tokenize chunk 2 + advanceClock() + expect(languageMode.tokenizedLines[5].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[9].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[10]).toBeUndefined() + + // tokenize last chunk + advanceClock() + expect(languageMode.tokenizedLines[10].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[12].ruleStack != null).toBeTruthy() + }) + ) + + describe('when the buffer is partially tokenized', () => { + beforeEach(() => { + // tokenize chunk 1 only + advanceClock() + }) + + describe('when there is a buffer change inside the tokenized region', () => { + describe('when lines are added', () => { + it('pushes the invalid rows down', () => { + expect(languageMode.firstInvalidRow()).toBe(5) + buffer.insert([1, 0], '\n\n') + expect(languageMode.firstInvalidRow()).toBe(7) + }) + }) + + describe('when lines are removed', () => { + it('pulls the invalid rows up', () => { + expect(languageMode.firstInvalidRow()).toBe(5) + buffer.delete([[1, 0], [3, 0]]) + expect(languageMode.firstInvalidRow()).toBe(2) + }) + }) + + describe('when the change invalidates all the lines before the current invalid region', () => { + it('retokenizes the invalidated lines and continues into the valid region', () => { + expect(languageMode.firstInvalidRow()).toBe(5) + buffer.insert([2, 0], '/*') + expect(languageMode.firstInvalidRow()).toBe(3) + advanceClock() + expect(languageMode.firstInvalidRow()).toBe(8) + }) + }) + }) + + describe('when there is a buffer change surrounding an invalid row', () => { + it('pushes the invalid row to the end of the change', () => { + buffer.setTextInRange([[4, 0], [6, 0]], '\n\n\n') + expect(languageMode.firstInvalidRow()).toBe(8) + }) + }) + + describe('when there is a buffer change inside an invalid region', () => { + it('does not attempt to tokenize the lines in the change, and preserves the existing invalid row', () => { + expect(languageMode.firstInvalidRow()).toBe(5) + buffer.setTextInRange([[6, 0], [7, 0]], '\n\n\n') + expect(languageMode.tokenizedLines[6]).toBeUndefined() + expect(languageMode.tokenizedLines[7]).toBeUndefined() + expect(languageMode.firstInvalidRow()).toBe(5) + }) + }) + }) + + describe('when the buffer is fully tokenized', () => { + beforeEach(() => fullyTokenize(languageMode)) + + describe('when there is a buffer change that is smaller than the chunk size', () => { + describe('when lines are updated, but none are added or removed', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[0, 0], [2, 0]], 'foo()\n7\n') + + expect(languageMode.tokenizedLines[0].tokens[1]).toEqual({value: '(', scopes: ['source.js', 'meta.function-call.js', 'meta.arguments.js', 'punctuation.definition.arguments.begin.bracket.round.js']}) + expect(languageMode.tokenizedLines[1].tokens[0]).toEqual({value: '7', scopes: ['source.js', 'constant.numeric.decimal.js']}) + // line 2 is unchanged + expect(languageMode.tokenizedLines[2].tokens[1]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.insert([2, 0], '/*') + expect(languageMode.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() + expect(languageMode.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + it('resumes highlighting with the state of the previous line', () => { + buffer.insert([0, 0], '/*') + buffer.insert([5, 0], '*/') + + buffer.insert([1, 0], 'var ') + expect(languageMode.tokenizedLines[1].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + describe('when lines are both updated and removed', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[1, 0], [3, 0]], 'foo()') + + // previous line 0 remains + expect(languageMode.tokenizedLines[0].tokens[0]).toEqual({value: 'var', scopes: ['source.js', 'storage.type.var.js']}) + + // previous line 3 should be combined with input to form line 1 + expect(languageMode.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(languageMode.tokenizedLines[1].tokens[6]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + + // lines below deleted regions should be shifted upward + expect(languageMode.tokenizedLines[2].tokens[1]).toEqual({value: 'while', scopes: ['source.js', 'keyword.control.js']}) + expect(languageMode.tokenizedLines[3].tokens[1]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + expect(languageMode.tokenizedLines[4].tokens[1]).toEqual({value: '<', scopes: ['source.js', 'keyword.operator.comparison.js']}) + }) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.setTextInRange([[2, 0], [3, 0]], '/*') + expect(languageMode.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) + expect(languageMode.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() + expect(languageMode.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + }) + }) + + describe('when lines are both updated and inserted', () => { + it('updates tokens to reflect the change', () => { + buffer.setTextInRange([[1, 0], [2, 0]], 'foo()\nbar()\nbaz()\nquux()') + + // previous line 0 remains + expect(languageMode.tokenizedLines[0].tokens[0]).toEqual({ value: 'var', scopes: ['source.js', 'storage.type.var.js']}) + + // 3 new lines inserted + expect(languageMode.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(languageMode.tokenizedLines[2].tokens[0]).toEqual({value: 'bar', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(languageMode.tokenizedLines[3].tokens[0]).toEqual({value: 'baz', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + + // previous line 2 is joined with quux() on line 4 + expect(languageMode.tokenizedLines[4].tokens[0]).toEqual({value: 'quux', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) + expect(languageMode.tokenizedLines[4].tokens[4]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) + + // previous line 3 is pushed down to become line 5 + expect(languageMode.tokenizedLines[5].tokens[3]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) + }) + }) + + describe('when the change invalidates the tokenization of subsequent lines', () => { + it('schedules the invalidated lines to be tokenized in the background', () => { + buffer.insert([5, 30], '/* */') + buffer.insert([2, 0], '/*\nabcde\nabcder') + expect(languageMode.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) + expect(languageMode.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js']) + + advanceClock() // tokenize invalidated lines in background + expect(languageMode.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[6].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[7].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) + expect(languageMode.tokenizedLines[8].tokens[0].scopes).not.toBe(['source.js', 'comment.block.js']) + }) + }) + }) + + describe('when there is an insertion that is larger than the chunk size', () => { + it('tokenizes the initial chunk synchronously, then tokenizes the remaining lines in the background', () => { + const commentBlock = _.multiplyString('// a comment\n', languageMode.chunkSize + 2) + buffer.insert([0, 0], commentBlock) + expect(languageMode.tokenizedLines[0].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[4].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[5]).toBeUndefined() + + advanceClock() + expect(languageMode.tokenizedLines[5].ruleStack != null).toBeTruthy() + expect(languageMode.tokenizedLines[6].ruleStack != null).toBeTruthy() + }) + }) + }) + }) + + describe('when the buffer contains hard-tabs', () => { + beforeEach(async () => { + atom.packages.activatePackage('language-coffee-script') + + buffer = atom.project.bufferForPathSync('sample-with-tabs.coffee') + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.coffee')}) + languageMode.startTokenizing() + }) + + afterEach(() => { + languageMode.destroy() + buffer.release() + }) + + describe('when the buffer is fully tokenized', () => { + beforeEach(() => fullyTokenize(languageMode)) + }) + }) + + describe('when tokenization completes', () => { + it('emits the `tokenized` event', async () => { + const editor = await atom.workspace.open('sample.js') + + const tokenizedHandler = jasmine.createSpy('tokenized handler') + editor.languageMode.onDidTokenize(tokenizedHandler) + fullyTokenize(editor.getBuffer().getLanguageMode()) + expect(tokenizedHandler.callCount).toBe(1) + }) + + it("doesn't re-emit the `tokenized` event when it is re-tokenized", async () => { + const editor = await atom.workspace.open('sample.js') + fullyTokenize(editor.languageMode) + + const tokenizedHandler = jasmine.createSpy('tokenized handler') + editor.languageMode.onDidTokenize(tokenizedHandler) + editor.getBuffer().insert([0, 0], "'") + fullyTokenize(editor.languageMode) + expect(tokenizedHandler).not.toHaveBeenCalled() + }) + }) + + describe('when the grammar is updated because a grammar it includes is activated', async () => { + it('re-emits the `tokenized` event', async () => { + let tokenizationCount = 0 + + const editor = await atom.workspace.open('coffee.coffee') + editor.onDidTokenize(() => { tokenizationCount++ }) + fullyTokenize(editor.getBuffer().getLanguageMode()) + tokenizationCount = 0 + + await atom.packages.activatePackage('language-coffee-script') + fullyTokenize(editor.getBuffer().getLanguageMode()) + expect(tokenizationCount).toBe(1) + }) + + it('retokenizes the buffer', async () => { + await atom.packages.activatePackage('language-ruby-on-rails') + await atom.packages.activatePackage('language-ruby') + + buffer = atom.project.bufferForPathSync() + buffer.setText("
<%= User.find(2).full_name %>
") + + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.selectGrammar('test.erb')}) + fullyTokenize(languageMode) + expect(languageMode.tokenizedLines[0].tokens[0]).toEqual({ + value: "
", + scopes: ['text.html.ruby'] + }) + + await atom.packages.activatePackage('language-html') + fullyTokenize(languageMode) + expect(languageMode.tokenizedLines[0].tokens[0]).toEqual({ + value: '<', + scopes: ['text.html.ruby', 'meta.tag.block.div.html', 'punctuation.definition.tag.begin.html'] + }) + }) + }) + + describe('when the buffer is configured with the null grammar', () => { + it('does not actually tokenize using the grammar', () => { + spyOn(NullGrammar, 'tokenizeLine').andCallThrough() + buffer = atom.project.bufferForPathSync('sample.will-use-the-null-grammar') + buffer.setText('a\nb\nc') + languageMode = new TextMateLanguageMode({buffer, config}) + const tokenizeCallback = jasmine.createSpy('onDidTokenize') + languageMode.onDidTokenize(tokenizeCallback) + + expect(languageMode.tokenizedLines[0]).toBeUndefined() + expect(languageMode.tokenizedLines[1]).toBeUndefined() + expect(languageMode.tokenizedLines[2]).toBeUndefined() + expect(tokenizeCallback.callCount).toBe(0) + expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() + + fullyTokenize(languageMode) + expect(languageMode.tokenizedLines[0]).toBeUndefined() + expect(languageMode.tokenizedLines[1]).toBeUndefined() + expect(languageMode.tokenizedLines[2]).toBeUndefined() + expect(tokenizeCallback.callCount).toBe(0) + expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() + }) + }) + }) + + describe('.tokenForPosition(position)', () => { + afterEach(() => { + languageMode.destroy() + buffer.release() + }) + + it('returns the correct token (regression)', () => { + buffer = atom.project.bufferForPathSync('sample.js') + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + fullyTokenize(languageMode) + expect(languageMode.tokenForPosition([1, 0]).scopes).toEqual(['source.js']) + expect(languageMode.tokenForPosition([1, 1]).scopes).toEqual(['source.js']) + expect(languageMode.tokenForPosition([1, 2]).scopes).toEqual(['source.js', 'storage.type.var.js']) + }) + }) + + describe('.bufferRangeForScopeAtPosition(selector, position)', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + fullyTokenize(languageMode) + }) + + describe('when the selector does not match the token at the position', () => + it('returns a falsy value', () => expect(languageMode.bufferRangeForScopeAtPosition('.bogus', [0, 1])).toBeUndefined()) + ) + + describe('when the selector matches a single token at the position', () => { + it('returns the range covered by the token', () => { + expect(languageMode.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 1])).toEqual([[0, 0], [0, 3]]) + expect(languageMode.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 3])).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when the selector matches a run of multiple tokens at the position', () => { + it('returns the range covered by all contiguous tokens (within a single line)', () => { + expect(languageMode.bufferRangeForScopeAtPosition('.function', [1, 18])).toEqual([[1, 6], [1, 28]]) + }) + }) + }) + + describe('.tokenizedLineForRow(row)', () => { + it("returns the tokenized line for a row, or a placeholder line if it hasn't been tokenized yet", () => { + buffer = atom.project.bufferForPathSync('sample.js') + const grammar = atom.grammars.grammarForScopeName('source.js') + languageMode = new TextMateLanguageMode({buffer, config, grammar}) + const line0 = buffer.lineForRow(0) + + const jsScopeStartId = grammar.startIdForScope(grammar.scopeName) + const jsScopeEndId = grammar.endIdForScope(grammar.scopeName) + languageMode.startTokenizing() + expect(languageMode.tokenizedLines[0]).toBeUndefined() + expect(languageMode.tokenizedLineForRow(0).text).toBe(line0) + expect(languageMode.tokenizedLineForRow(0).tags).toEqual([jsScopeStartId, line0.length, jsScopeEndId]) + advanceClock(1) + expect(languageMode.tokenizedLines[0]).not.toBeUndefined() + expect(languageMode.tokenizedLineForRow(0).text).toBe(line0) + expect(languageMode.tokenizedLineForRow(0).tags).not.toEqual([jsScopeStartId, line0.length, jsScopeEndId]) + }) + + it('returns undefined if the requested row is outside the buffer range', () => { + buffer = atom.project.bufferForPathSync('sample.js') + const grammar = atom.grammars.grammarForScopeName('source.js') + languageMode = new TextMateLanguageMode({buffer, config, grammar}) + fullyTokenize(languageMode) + expect(languageMode.tokenizedLineForRow(999)).toBeUndefined() + }) + }) + + describe('.buildHighlightIterator', () => { + const {TextMateHighlightIterator} = TextMateLanguageMode + + it('iterates over the syntactic scope boundaries', () => { + buffer = new TextBuffer({text: 'var foo = 1 /*\nhello*/var bar = 2\n'}) + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + fullyTokenize(languageMode) + + const iterator = languageMode.buildHighlightIterator() + iterator.seek(Point(0, 0)) + + const expectedBoundaries = [ + {position: Point(0, 0), closeTags: [], openTags: ['syntax--source syntax--js', 'syntax--storage syntax--type syntax--var syntax--js']}, + {position: Point(0, 3), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, + {position: Point(0, 8), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, + {position: Point(0, 9), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, + {position: Point(0, 10), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, + {position: Point(0, 11), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []}, + {position: Point(0, 12), closeTags: [], openTags: ['syntax--comment syntax--block syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js']}, + {position: Point(0, 14), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js'], openTags: []}, + {position: Point(1, 5), closeTags: [], openTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js']}, + {position: Point(1, 7), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js', 'syntax--comment syntax--block syntax--js'], openTags: ['syntax--storage syntax--type syntax--var syntax--js']}, + {position: Point(1, 10), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, + {position: Point(1, 15), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, + {position: Point(1, 16), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, + {position: Point(1, 17), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, + {position: Point(1, 18), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []} + ] + + while (true) { + const boundary = { + position: iterator.getPosition(), + closeTags: iterator.getCloseScopeIds().map(scopeId => languageMode.classNameForScopeId(scopeId)), + openTags: iterator.getOpenScopeIds().map(scopeId => languageMode.classNameForScopeId(scopeId)) + } + + expect(boundary).toEqual(expectedBoundaries.shift()) + if (!iterator.moveToSuccessor()) { break } + } + + expect(iterator.seek(Point(0, 1)).map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--storage syntax--type syntax--var syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.seek(Point(0, 8)).map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(0, 8)) + expect(iterator.seek(Point(1, 0)).map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--comment syntax--block syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(1, 0)) + expect(iterator.seek(Point(1, 18)).map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js', + 'syntax--constant syntax--numeric syntax--decimal syntax--js' + ]) + expect(iterator.getPosition()).toEqual(Point(1, 18)) + + expect(iterator.seek(Point(2, 0)).map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual([ + 'syntax--source syntax--js' + ]) + iterator.moveToSuccessor() + }) // ensure we don't infinitely loop (regression test) + + it('does not report columns beyond the length of the line', async () => { + await atom.packages.activatePackage('language-coffee-script') + + buffer = new TextBuffer({text: '# hello\n# world'}) + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.coffee')}) + fullyTokenize(languageMode) + + const iterator = languageMode.buildHighlightIterator() + iterator.seek(Point(0, 0)) + iterator.moveToSuccessor() + iterator.moveToSuccessor() + expect(iterator.getPosition().column).toBe(7) + + iterator.moveToSuccessor() + expect(iterator.getPosition().column).toBe(0) + + iterator.seek(Point(0, 7)) + expect(iterator.getPosition().column).toBe(7) + + iterator.seek(Point(0, 8)) + expect(iterator.getPosition().column).toBe(7) + }) + + it('correctly terminates scopes at the beginning of the line (regression)', () => { + const grammar = atom.grammars.createGrammar('test', { + 'scopeName': 'text.broken', + 'name': 'Broken grammar', + 'patterns': [ + {'begin': 'start', 'end': '(?=end)', 'name': 'blue.broken'}, + {'match': '.', 'name': 'yellow.broken'} + ] + }) + + buffer = new TextBuffer({text: 'start x\nend x\nx'}) + languageMode = new TextMateLanguageMode({buffer, config, grammar}) + fullyTokenize(languageMode) + + const iterator = languageMode.buildHighlightIterator() + iterator.seek(Point(1, 0)) + + expect(iterator.getPosition()).toEqual([1, 0]) + expect(iterator.getCloseScopeIds().map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual(['syntax--blue syntax--broken']) + expect(iterator.getOpenScopeIds().map(scopeId => languageMode.classNameForScopeId(scopeId))).toEqual(['syntax--yellow syntax--broken']) + }) + + describe('TextMateHighlightIterator.seek(position)', function () { + it('seeks to the leftmost tag boundary greater than or equal to the given position and returns the containing tags', function () { + const languageMode = { + tokenizedLineForRow (row) { + if (row === 0) { + return { + tags: [-1, -2, -3, -4, -5, 3, -3, -4, -6, -5, 4, -6, -3, -4], + text: 'foo bar', + openScopes: [] + } + } else { + return null + } + } + } + + const iterator = new TextMateHighlightIterator(languageMode) + + expect(iterator.seek(Point(0, 0))).toEqual([]) + expect(iterator.getPosition()).toEqual(Point(0, 0)) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([257]) + + iterator.moveToSuccessor() + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([259]) + + expect(iterator.seek(Point(0, 1))).toEqual([261]) + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([259]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.getCloseScopeIds()).toEqual([259, 261]) + expect(iterator.getOpenScopeIds()).toEqual([261]) + + expect(iterator.seek(Point(0, 3))).toEqual([261]) + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([259]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 3)) + expect(iterator.getCloseScopeIds()).toEqual([259, 261]) + expect(iterator.getOpenScopeIds()).toEqual([261]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 7)) + expect(iterator.getCloseScopeIds()).toEqual([261]) + expect(iterator.getOpenScopeIds()).toEqual([259]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 7)) + expect(iterator.getCloseScopeIds()).toEqual([259]) + expect(iterator.getOpenScopeIds()).toEqual([]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(1, 0)) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([]) + + expect(iterator.seek(Point(0, 5))).toEqual([261]) + expect(iterator.getPosition()).toEqual(Point(0, 7)) + expect(iterator.getCloseScopeIds()).toEqual([261]) + expect(iterator.getOpenScopeIds()).toEqual([259]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 7)) + expect(iterator.getCloseScopeIds()).toEqual([259]) + expect(iterator.getOpenScopeIds()).toEqual([]) + }) + }) + + describe('TextMateHighlightIterator.moveToSuccessor()', function () { + it('reports two boundaries at the same position when tags close, open, then close again without a non-negative integer separating them (regression)', () => { + const languageMode = { + tokenizedLineForRow () { + return { + tags: [-1, -2, -1, -2], + text: '', + openScopes: [] + } + } + } + + const iterator = new TextMateHighlightIterator(languageMode) + + iterator.seek(Point(0, 0)) + expect(iterator.getPosition()).toEqual(Point(0, 0)) + expect(iterator.getCloseScopeIds()).toEqual([]) + expect(iterator.getOpenScopeIds()).toEqual([257]) + + iterator.moveToSuccessor() + expect(iterator.getPosition()).toEqual(Point(0, 0)) + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([257]) + + iterator.moveToSuccessor() + expect(iterator.getCloseScopeIds()).toEqual([257]) + expect(iterator.getOpenScopeIds()).toEqual([]) + }) + }) + }) + + describe('.suggestedIndentForBufferRow', () => { + let editor + + describe('javascript', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + await atom.packages.activatePackage('language-javascript') + }) + + it('bases indentation off of the previous non-blank line', () => { + expect(editor.suggestedIndentForBufferRow(0)).toBe(0) + expect(editor.suggestedIndentForBufferRow(1)).toBe(1) + expect(editor.suggestedIndentForBufferRow(2)).toBe(2) + expect(editor.suggestedIndentForBufferRow(5)).toBe(3) + expect(editor.suggestedIndentForBufferRow(7)).toBe(2) + expect(editor.suggestedIndentForBufferRow(9)).toBe(1) + expect(editor.suggestedIndentForBufferRow(11)).toBe(1) + }) + + it('does not take invisibles into account', () => { + editor.update({showInvisibles: true}) + expect(editor.suggestedIndentForBufferRow(0)).toBe(0) + expect(editor.suggestedIndentForBufferRow(1)).toBe(1) + expect(editor.suggestedIndentForBufferRow(2)).toBe(2) + expect(editor.suggestedIndentForBufferRow(5)).toBe(3) + expect(editor.suggestedIndentForBufferRow(7)).toBe(2) + expect(editor.suggestedIndentForBufferRow(9)).toBe(1) + expect(editor.suggestedIndentForBufferRow(11)).toBe(1) + }) + }) + + describe('css', () => { + beforeEach(async () => { + editor = await atom.workspace.open('css.css', {autoIndent: true}) + await atom.packages.activatePackage('language-source') + await atom.packages.activatePackage('language-css') + }) + + it('does not return negative values (regression)', () => { + editor.setText('.test {\npadding: 0;\n}') + expect(editor.suggestedIndentForBufferRow(2)).toBe(0) + }) + }) + }) + + describe('.isFoldableAtRow(row)', () => { + beforeEach(() => { + buffer = atom.project.bufferForPathSync('sample.js') + buffer.insert([10, 0], ' // multi-line\n // comment\n // block\n') + buffer.insert([0, 0], '// multi-line\n// comment\n// block\n') + languageMode = new TextMateLanguageMode({buffer, config, grammar: atom.grammars.grammarForScopeName('source.js')}) + buffer.setLanguageMode(languageMode) + fullyTokenize(languageMode) + }) + + it('includes the first line of multi-line comments', () => { + expect(languageMode.isFoldableAtRow(0)).toBe(true) + expect(languageMode.isFoldableAtRow(1)).toBe(false) + expect(languageMode.isFoldableAtRow(2)).toBe(false) + expect(languageMode.isFoldableAtRow(3)).toBe(true) // because of indent + expect(languageMode.isFoldableAtRow(13)).toBe(true) + expect(languageMode.isFoldableAtRow(14)).toBe(false) + expect(languageMode.isFoldableAtRow(15)).toBe(false) + expect(languageMode.isFoldableAtRow(16)).toBe(false) + + buffer.insert([0, Infinity], '\n') + + expect(languageMode.isFoldableAtRow(0)).toBe(false) + expect(languageMode.isFoldableAtRow(1)).toBe(false) + expect(languageMode.isFoldableAtRow(2)).toBe(true) + expect(languageMode.isFoldableAtRow(3)).toBe(false) + + buffer.undo() + + expect(languageMode.isFoldableAtRow(0)).toBe(true) + expect(languageMode.isFoldableAtRow(1)).toBe(false) + expect(languageMode.isFoldableAtRow(2)).toBe(false) + expect(languageMode.isFoldableAtRow(3)).toBe(true) + }) // because of indent + + it('includes non-comment lines that precede an increase in indentation', () => { + buffer.insert([2, 0], ' ') // commented lines preceding an indent aren't foldable + + expect(languageMode.isFoldableAtRow(1)).toBe(false) + expect(languageMode.isFoldableAtRow(2)).toBe(false) + expect(languageMode.isFoldableAtRow(3)).toBe(true) + expect(languageMode.isFoldableAtRow(4)).toBe(true) + expect(languageMode.isFoldableAtRow(5)).toBe(false) + expect(languageMode.isFoldableAtRow(6)).toBe(false) + expect(languageMode.isFoldableAtRow(7)).toBe(true) + expect(languageMode.isFoldableAtRow(8)).toBe(false) + + buffer.insert([7, 0], ' ') + + expect(languageMode.isFoldableAtRow(6)).toBe(true) + expect(languageMode.isFoldableAtRow(7)).toBe(false) + expect(languageMode.isFoldableAtRow(8)).toBe(false) + + buffer.undo() + + expect(languageMode.isFoldableAtRow(6)).toBe(false) + expect(languageMode.isFoldableAtRow(7)).toBe(true) + expect(languageMode.isFoldableAtRow(8)).toBe(false) + + buffer.insert([7, 0], ' \n x\n') + + expect(languageMode.isFoldableAtRow(6)).toBe(true) + expect(languageMode.isFoldableAtRow(7)).toBe(false) + expect(languageMode.isFoldableAtRow(8)).toBe(false) + + buffer.insert([9, 0], ' ') + + expect(languageMode.isFoldableAtRow(6)).toBe(true) + expect(languageMode.isFoldableAtRow(7)).toBe(false) + expect(languageMode.isFoldableAtRow(8)).toBe(false) + }) + }) + + describe('.getFoldableRangesAtIndentLevel', () => { + it('returns the ranges that can be folded at the given indent level', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + languageMode = new TextMateLanguageMode({buffer, config}) + + expect(simulateFold(languageMode.getFoldableRangesAtIndentLevel(0, 2))).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) {⋯ + } + `) + + expect(simulateFold(languageMode.getFoldableRangesAtIndentLevel(1, 2))).toBe(dedent ` + if (a) { + b(); + if (c) {⋯ + } + h() + } + i() + if (j) { + k() + } + `) + + expect(simulateFold(languageMode.getFoldableRangesAtIndentLevel(2, 2))).toBe(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) {⋯ + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + }) + }) + + describe('.getFoldableRanges', () => { + it('returns the ranges that can be folded', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + languageMode = new TextMateLanguageMode({buffer, config}) + + expect(languageMode.getFoldableRanges(2).map(r => r.toString())).toEqual([ + ...languageMode.getFoldableRangesAtIndentLevel(0, 2), + ...languageMode.getFoldableRangesAtIndentLevel(1, 2), + ...languageMode.getFoldableRangesAtIndentLevel(2, 2), + ].sort((a, b) => (a.start.row - b.start.row) || (a.end.row - b.end.row)).map(r => r.toString())) + }) + }) + + describe('.getFoldableRangeContainingPoint', () => { + it('returns the range for the smallest fold that contains the given range', () => { + buffer = new TextBuffer(dedent ` + if (a) { + b(); + if (c) { + d() + if (e) { + f() + } + g() + } + h() + } + i() + if (j) { + k() + } + `) + + languageMode = new TextMateLanguageMode({buffer, config}) + + expect(languageMode.getFoldableRangeContainingPoint(Point(0, 5), 2)).toBeNull() + + let range = languageMode.getFoldableRangeContainingPoint(Point(0, 10), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) { + k() + } + `) + + range = languageMode.getFoldableRangeContainingPoint(Point(7, 0), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) { + b(); + if (c) {⋯ + } + h() + } + i() + if (j) { + k() + } + `) + + range = languageMode.getFoldableRangeContainingPoint(Point(1, Infinity), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) {⋯ + } + i() + if (j) { + k() + } + `) + + range = languageMode.getFoldableRangeContainingPoint(Point(2, 20), 2) + expect(simulateFold([range])).toBe(dedent ` + if (a) { + b(); + if (c) {⋯ + } + h() + } + i() + if (j) { + k() + } + `) + }) + + it('works for coffee-script', async () => { + const editor = await atom.workspace.open('coffee.coffee') + await atom.packages.activatePackage('language-coffee-script') + buffer = editor.buffer + languageMode = editor.languageMode + + expect(languageMode.getFoldableRangeContainingPoint(Point(0, Infinity), 2)).toEqual([[0, Infinity], [20, Infinity]]) + expect(languageMode.getFoldableRangeContainingPoint(Point(1, Infinity), 2)).toEqual([[1, Infinity], [17, Infinity]]) + expect(languageMode.getFoldableRangeContainingPoint(Point(2, Infinity), 2)).toEqual([[1, Infinity], [17, Infinity]]) + expect(languageMode.getFoldableRangeContainingPoint(Point(19, Infinity), 2)).toEqual([[19, Infinity], [20, Infinity]]) + }) + + it('works for javascript', async () => { + const editor = await atom.workspace.open('sample.js') + await atom.packages.activatePackage('language-javascript') + buffer = editor.buffer + languageMode = editor.languageMode + + expect(editor.languageMode.getFoldableRangeContainingPoint(Point(0, Infinity), 2)).toEqual([[0, Infinity], [12, Infinity]]) + expect(editor.languageMode.getFoldableRangeContainingPoint(Point(1, Infinity), 2)).toEqual([[1, Infinity], [9, Infinity]]) + expect(editor.languageMode.getFoldableRangeContainingPoint(Point(2, Infinity), 2)).toEqual([[1, Infinity], [9, Infinity]]) + expect(editor.languageMode.getFoldableRangeContainingPoint(Point(4, Infinity), 2)).toEqual([[4, Infinity], [7, Infinity]]) + }) + }) + + describe('TokenIterator', () => + it('correctly terminates scopes at the beginning of the line (regression)', () => { + const grammar = atom.grammars.createGrammar('test', { + 'scopeName': 'text.broken', + 'name': 'Broken grammar', + 'patterns': [ + { + 'begin': 'start', + 'end': '(?=end)', + 'name': 'blue.broken' + }, + { + 'match': '.', + 'name': 'yellow.broken' + } + ] + }) + + const buffer = new TextBuffer({text: dedent` + start x + end x + x + `}) + + const languageMode = new TextMateLanguageMode({ + buffer, + grammar, + config: atom.config, + grammarRegistry: atom.grammars, + packageManager: atom.packages, + assert: atom.assert + }) + + fullyTokenize(languageMode) + + const tokenIterator = languageMode.tokenizedLineForRow(1).getTokenIterator() + tokenIterator.next() + + expect(tokenIterator.getBufferStart()).toBe(0) + expect(tokenIterator.getScopeEnds()).toEqual([]) + expect(tokenIterator.getScopeStarts()).toEqual(['text.broken', 'yellow.broken']) + }) + ) + + function simulateFold (ranges) { + buffer.transact(() => { + for (const range of ranges.reverse()) { + buffer.setTextInRange(range, '⋯') + } + }) + let text = buffer.getText() + buffer.undo() + return text + } + + function fullyTokenize (languageMode) { + languageMode.startTokenizing() + while (languageMode.firstInvalidRow() != null) { + advanceClock() + } + } +}) 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/token-iterator-spec.js b/spec/token-iterator-spec.js deleted file mode 100644 index f6d43395c..000000000 --- a/spec/token-iterator-spec.js +++ /dev/null @@ -1,43 +0,0 @@ -const TextBuffer = require('text-buffer') -const TokenizedBuffer = require('../src/tokenized-buffer') - -describe('TokenIterator', () => - it('correctly terminates scopes at the beginning of the line (regression)', () => { - const grammar = atom.grammars.createGrammar('test', { - 'scopeName': 'text.broken', - 'name': 'Broken grammar', - 'patterns': [ - { - 'begin': 'start', - 'end': '(?=end)', - 'name': 'blue.broken' - }, - { - 'match': '.', - 'name': 'yellow.broken' - } - ] - }) - - const buffer = new TextBuffer({text: `\ -start x -end x -x\ -`}) - const tokenizedBuffer = new TokenizedBuffer({ - buffer, - config: atom.config, - grammarRegistry: atom.grammars, - packageManager: atom.packages, - assert: atom.assert - }) - tokenizedBuffer.setGrammar(grammar) - - const tokenIterator = tokenizedBuffer.tokenizedLines[1].getTokenIterator() - tokenIterator.next() - - expect(tokenIterator.getBufferStart()).toBe(0) - expect(tokenIterator.getScopeEnds()).toEqual([]) - expect(tokenIterator.getScopeStarts()).toEqual(['text.broken', 'yellow.broken']) - }) -) diff --git a/spec/tokenized-buffer-iterator-spec.js b/spec/tokenized-buffer-iterator-spec.js deleted file mode 100644 index 1b26f7b38..000000000 --- a/spec/tokenized-buffer-iterator-spec.js +++ /dev/null @@ -1,110 +0,0 @@ -/** @babel */ - -import TokenizedBufferIterator from '../src/tokenized-buffer-iterator' -import {Point} from 'text-buffer' - -describe('TokenizedBufferIterator', () => { - describe('seek(position)', function () { - it('seeks to the leftmost tag boundary greater than or equal to the given position and returns the containing tags', function () { - const tokenizedBuffer = { - tokenizedLineForRow (row) { - if (row === 0) { - return { - tags: [-1, -2, -3, -4, -5, 3, -3, -4, -6, -5, 4, -6, -3, -4], - text: 'foo bar', - openScopes: [] - } - } else { - return null - } - } - } - - const iterator = new TokenizedBufferIterator(tokenizedBuffer) - - expect(iterator.seek(Point(0, 0))).toEqual([]) - expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([257]) - - iterator.moveToSuccessor() - expect(iterator.getCloseScopeIds()).toEqual([257]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - expect(iterator.seek(Point(0, 1))).toEqual([261]) - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([259, 261]) - expect(iterator.getOpenScopeIds()).toEqual([261]) - - expect(iterator.seek(Point(0, 3))).toEqual([261]) - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.getCloseScopeIds()).toEqual([259, 261]) - expect(iterator.getOpenScopeIds()).toEqual([261]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([261]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([259]) - expect(iterator.getOpenScopeIds()).toEqual([]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([]) - - expect(iterator.seek(Point(0, 5))).toEqual([261]) - expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([261]) - expect(iterator.getOpenScopeIds()).toEqual([259]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 7)) - expect(iterator.getCloseScopeIds()).toEqual([259]) - expect(iterator.getOpenScopeIds()).toEqual([]) - }) - }) - - describe('moveToSuccessor()', function () { - it('reports two boundaries at the same position when tags close, open, then close again without a non-negative integer separating them (regression)', () => { - const tokenizedBuffer = { - tokenizedLineForRow () { - return { - tags: [-1, -2, -1, -2], - text: '', - openScopes: [] - } - } - } - - const iterator = new TokenizedBufferIterator(tokenizedBuffer) - - iterator.seek(Point(0, 0)) - expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseScopeIds()).toEqual([]) - expect(iterator.getOpenScopeIds()).toEqual([257]) - - iterator.moveToSuccessor() - expect(iterator.getPosition()).toEqual(Point(0, 0)) - expect(iterator.getCloseScopeIds()).toEqual([257]) - expect(iterator.getOpenScopeIds()).toEqual([257]) - - iterator.moveToSuccessor() - expect(iterator.getCloseScopeIds()).toEqual([257]) - expect(iterator.getOpenScopeIds()).toEqual([]) - }) - }) -}) diff --git a/spec/tokenized-buffer-spec.js b/spec/tokenized-buffer-spec.js deleted file mode 100644 index b1574673a..000000000 --- a/spec/tokenized-buffer-spec.js +++ /dev/null @@ -1,904 +0,0 @@ -const NullGrammar = require('../src/null-grammar') -const TokenizedBuffer = require('../src/tokenized-buffer') -const TextBuffer = require('text-buffer') -const {Point, Range} = TextBuffer -const _ = require('underscore-plus') -const dedent = require('dedent') -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') -const {ScopedSettingsDelegate} = require('../src/text-editor-registry') - -describe('TokenizedBuffer', () => { - let tokenizedBuffer, buffer - - beforeEach(async () => { - // enable async tokenization - TokenizedBuffer.prototype.chunkSize = 5 - jasmine.unspy(TokenizedBuffer.prototype, 'tokenizeInBackground') - await atom.packages.activatePackage('language-javascript') - }) - - afterEach(() => { - buffer && buffer.destroy() - tokenizedBuffer && tokenizedBuffer.destroy() - }) - - function startTokenizing (tokenizedBuffer) { - tokenizedBuffer.setVisible(true) - } - - function fullyTokenize (tokenizedBuffer) { - tokenizedBuffer.setVisible(true) - while (tokenizedBuffer.firstInvalidRow() != null) { - advanceClock() - } - } - - describe('serialization', () => { - describe('when the underlying buffer has a path', () => { - beforeEach(async () => { - buffer = atom.project.bufferForPathSync('sample.js') - await atom.packages.activatePackage('language-coffee-script') - }) - - it('deserializes it searching among the buffers in the current project', () => { - const tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) - const tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) - expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) - }) - }) - - describe('when the underlying buffer has no path', () => { - beforeEach(() => buffer = atom.project.bufferForPathSync(null)) - - it('deserializes it searching among the buffers in the current project', () => { - const tokenizedBufferA = new TokenizedBuffer({buffer, tabLength: 2}) - const tokenizedBufferB = TokenizedBuffer.deserialize(JSON.parse(JSON.stringify(tokenizedBufferA.serialize())), atom) - expect(tokenizedBufferB.buffer).toBe(tokenizedBufferA.buffer) - }) - }) - }) - - describe('tokenizing', () => { - describe('when the buffer is destroyed', () => { - beforeEach(() => { - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - }) - - it('stops tokenization', () => { - tokenizedBuffer.destroy() - spyOn(tokenizedBuffer, 'tokenizeNextChunk') - advanceClock() - expect(tokenizedBuffer.tokenizeNextChunk).not.toHaveBeenCalled() - }) - }) - - describe('when the buffer contains soft-tabs', () => { - beforeEach(() => { - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - }) - - afterEach(() => { - tokenizedBuffer.destroy() - buffer.release() - }) - - describe('on construction', () => - it('tokenizes lines chunk at a time in the background', () => { - const line0 = tokenizedBuffer.tokenizedLines[0] - expect(line0).toBeUndefined() - - const line11 = tokenizedBuffer.tokenizedLines[11] - expect(line11).toBeUndefined() - - // tokenize chunk 1 - advanceClock() - expect(tokenizedBuffer.tokenizedLines[0].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[4].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() - - // tokenize chunk 2 - advanceClock() - expect(tokenizedBuffer.tokenizedLines[5].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[9].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[10]).toBeUndefined() - - // tokenize last chunk - advanceClock() - expect(tokenizedBuffer.tokenizedLines[10].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[12].ruleStack != null).toBeTruthy() - }) - ) - - describe('when the buffer is partially tokenized', () => { - beforeEach(() => { - // tokenize chunk 1 only - advanceClock() - }) - - describe('when there is a buffer change inside the tokenized region', () => { - describe('when lines are added', () => { - it('pushes the invalid rows down', () => { - expect(tokenizedBuffer.firstInvalidRow()).toBe(5) - buffer.insert([1, 0], '\n\n') - expect(tokenizedBuffer.firstInvalidRow()).toBe(7) - }) - }) - - describe('when lines are removed', () => { - it('pulls the invalid rows up', () => { - expect(tokenizedBuffer.firstInvalidRow()).toBe(5) - buffer.delete([[1, 0], [3, 0]]) - expect(tokenizedBuffer.firstInvalidRow()).toBe(2) - }) - }) - - describe('when the change invalidates all the lines before the current invalid region', () => { - it('retokenizes the invalidated lines and continues into the valid region', () => { - expect(tokenizedBuffer.firstInvalidRow()).toBe(5) - buffer.insert([2, 0], '/*') - expect(tokenizedBuffer.firstInvalidRow()).toBe(3) - advanceClock() - expect(tokenizedBuffer.firstInvalidRow()).toBe(8) - }) - }) - }) - - describe('when there is a buffer change surrounding an invalid row', () => { - it('pushes the invalid row to the end of the change', () => { - buffer.setTextInRange([[4, 0], [6, 0]], '\n\n\n') - expect(tokenizedBuffer.firstInvalidRow()).toBe(8) - }) - }) - - describe('when there is a buffer change inside an invalid region', () => { - it('does not attempt to tokenize the lines in the change, and preserves the existing invalid row', () => { - expect(tokenizedBuffer.firstInvalidRow()).toBe(5) - buffer.setTextInRange([[6, 0], [7, 0]], '\n\n\n') - expect(tokenizedBuffer.tokenizedLines[6]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[7]).toBeUndefined() - expect(tokenizedBuffer.firstInvalidRow()).toBe(5) - }) - }) - }) - - describe('when the buffer is fully tokenized', () => { - beforeEach(() => fullyTokenize(tokenizedBuffer)) - - describe('when there is a buffer change that is smaller than the chunk size', () => { - describe('when lines are updated, but none are added or removed', () => { - it('updates tokens to reflect the change', () => { - buffer.setTextInRange([[0, 0], [2, 0]], 'foo()\n7\n') - - expect(tokenizedBuffer.tokenizedLines[0].tokens[1]).toEqual({value: '(', scopes: ['source.js', 'meta.function-call.js', 'meta.arguments.js', 'punctuation.definition.arguments.begin.bracket.round.js']}) - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: '7', scopes: ['source.js', 'constant.numeric.decimal.js']}) - // line 2 is unchanged - expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) - }) - - describe('when the change invalidates the tokenization of subsequent lines', () => { - it('schedules the invalidated lines to be tokenized in the background', () => { - buffer.insert([5, 30], '/* */') - buffer.insert([2, 0], '/*') - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - }) - }) - - it('resumes highlighting with the state of the previous line', () => { - buffer.insert([0, 0], '/*') - buffer.insert([5, 0], '*/') - - buffer.insert([1, 0], 'var ') - expect(tokenizedBuffer.tokenizedLines[1].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - }) - }) - - describe('when lines are both updated and removed', () => { - it('updates tokens to reflect the change', () => { - buffer.setTextInRange([[1, 0], [3, 0]], 'foo()') - - // previous line 0 remains - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({value: 'var', scopes: ['source.js', 'storage.type.var.js']}) - - // previous line 3 should be combined with input to form line 1 - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) - expect(tokenizedBuffer.tokenizedLines[1].tokens[6]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) - - // lines below deleted regions should be shifted upward - expect(tokenizedBuffer.tokenizedLines[2].tokens[1]).toEqual({value: 'while', scopes: ['source.js', 'keyword.control.js']}) - expect(tokenizedBuffer.tokenizedLines[3].tokens[1]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) - expect(tokenizedBuffer.tokenizedLines[4].tokens[1]).toEqual({value: '<', scopes: ['source.js', 'keyword.operator.comparison.js']}) - }) - }) - - describe('when the change invalidates the tokenization of subsequent lines', () => { - it('schedules the invalidated lines to be tokenized in the background', () => { - buffer.insert([5, 30], '/* */') - buffer.setTextInRange([[2, 0], [3, 0]], '/*') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js']) - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - }) - }) - - describe('when lines are both updated and inserted', () => { - it('updates tokens to reflect the change', () => { - buffer.setTextInRange([[1, 0], [2, 0]], 'foo()\nbar()\nbaz()\nquux()') - - // previous line 0 remains - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ value: 'var', scopes: ['source.js', 'storage.type.var.js']}) - - // 3 new lines inserted - expect(tokenizedBuffer.tokenizedLines[1].tokens[0]).toEqual({value: 'foo', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) - expect(tokenizedBuffer.tokenizedLines[2].tokens[0]).toEqual({value: 'bar', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) - expect(tokenizedBuffer.tokenizedLines[3].tokens[0]).toEqual({value: 'baz', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) - - // previous line 2 is joined with quux() on line 4 - expect(tokenizedBuffer.tokenizedLines[4].tokens[0]).toEqual({value: 'quux', scopes: ['source.js', 'meta.function-call.js', 'entity.name.function.js']}) - expect(tokenizedBuffer.tokenizedLines[4].tokens[4]).toEqual({value: 'if', scopes: ['source.js', 'keyword.control.js']}) - - // previous line 3 is pushed down to become line 5 - expect(tokenizedBuffer.tokenizedLines[5].tokens[3]).toEqual({value: '=', scopes: ['source.js', 'keyword.operator.assignment.js']}) - }) - }) - - describe('when the change invalidates the tokenization of subsequent lines', () => { - it('schedules the invalidated lines to be tokenized in the background', () => { - buffer.insert([5, 30], '/* */') - buffer.insert([2, 0], '/*\nabcde\nabcder') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual(['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js']) - expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js']) - - advanceClock() // tokenize invalidated lines in background - expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[6].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[7].tokens[0].scopes).toEqual(['source.js', 'comment.block.js']) - expect(tokenizedBuffer.tokenizedLines[8].tokens[0].scopes).not.toBe(['source.js', 'comment.block.js']) - }) - }) - }) - - describe('when there is an insertion that is larger than the chunk size', () => - it('tokenizes the initial chunk synchronously, then tokenizes the remaining lines in the background', () => { - const commentBlock = _.multiplyString('// a comment\n', tokenizedBuffer.chunkSize + 2) - buffer.insert([0, 0], commentBlock) - expect(tokenizedBuffer.tokenizedLines[0].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[4].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[5]).toBeUndefined() - - advanceClock() - expect(tokenizedBuffer.tokenizedLines[5].ruleStack != null).toBeTruthy() - expect(tokenizedBuffer.tokenizedLines[6].ruleStack != null).toBeTruthy() - }) - ) - - it('does not break out soft tabs across a scope boundary', async () => { - await atom.packages.activatePackage('language-gfm') - - tokenizedBuffer.setTabLength(4) - tokenizedBuffer.setGrammar(atom.grammars.selectGrammar('.md')) - buffer.setText(' 0) length += tag - } - - expect(length).toBe(4) - }) - }) - }) - - describe('when the buffer contains hard-tabs', () => { - beforeEach(async () => { - atom.packages.activatePackage('language-coffee-script') - - buffer = atom.project.bufferForPathSync('sample-with-tabs.coffee') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.coffee'), tabLength: 2}) - startTokenizing(tokenizedBuffer) - }) - - afterEach(() => { - tokenizedBuffer.destroy() - buffer.release() - }) - - describe('when the buffer is fully tokenized', () => { - beforeEach(() => fullyTokenize(tokenizedBuffer)) - }) - }) - - describe('when tokenization completes', () => { - it('emits the `tokenized` event', async () => { - const editor = await atom.workspace.open('sample.js') - - const tokenizedHandler = jasmine.createSpy('tokenized handler') - editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) - fullyTokenize(editor.tokenizedBuffer) - expect(tokenizedHandler.callCount).toBe(1) - }) - - it("doesn't re-emit the `tokenized` event when it is re-tokenized", async () => { - const editor = await atom.workspace.open('sample.js') - fullyTokenize(editor.tokenizedBuffer) - - const tokenizedHandler = jasmine.createSpy('tokenized handler') - editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) - editor.getBuffer().insert([0, 0], "'") - fullyTokenize(editor.tokenizedBuffer) - expect(tokenizedHandler).not.toHaveBeenCalled() - }) - }) - - describe('when the grammar is updated because a grammar it includes is activated', async () => { - it('re-emits the `tokenized` event', async () => { - const editor = await atom.workspace.open('coffee.coffee') - - const tokenizedHandler = jasmine.createSpy('tokenized handler') - editor.tokenizedBuffer.onDidTokenize(tokenizedHandler) - fullyTokenize(editor.tokenizedBuffer) - tokenizedHandler.reset() - - await atom.packages.activatePackage('language-coffee-script') - fullyTokenize(editor.tokenizedBuffer) - expect(tokenizedHandler.callCount).toBe(1) - }) - - it('retokenizes the buffer', async () => { - await atom.packages.activatePackage('language-ruby-on-rails') - await atom.packages.activatePackage('language-ruby') - - buffer = atom.project.bufferForPathSync() - buffer.setText("
<%= User.find(2).full_name %>
") - - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.selectGrammar('test.erb'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ - value: "
", - scopes: ['text.html.ruby'] - }) - - await atom.packages.activatePackage('language-html') - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0].tokens[0]).toEqual({ - value: '<', - scopes: ['text.html.ruby', 'meta.tag.block.div.html', 'punctuation.definition.tag.begin.html'] - }) - }) - }) - - describe('when the buffer is configured with the null grammar', () => { - it('does not actually tokenize using the grammar', () => { - spyOn(NullGrammar, 'tokenizeLine').andCallThrough() - buffer = atom.project.bufferForPathSync('sample.will-use-the-null-grammar') - buffer.setText('a\nb\nc') - tokenizedBuffer = new TokenizedBuffer({buffer, tabLength: 2}) - const tokenizeCallback = jasmine.createSpy('onDidTokenize') - tokenizedBuffer.onDidTokenize(tokenizeCallback) - - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() - expect(tokenizeCallback.callCount).toBe(0) - expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() - - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[1]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLines[2]).toBeUndefined() - expect(tokenizeCallback.callCount).toBe(0) - expect(NullGrammar.tokenizeLine).not.toHaveBeenCalled() - }) - }) - }) - - describe('.tokenForPosition(position)', () => { - afterEach(() => { - tokenizedBuffer.destroy() - buffer.release() - }) - - it('returns the correct token (regression)', () => { - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenForPosition([1, 0]).scopes).toEqual(['source.js']) - expect(tokenizedBuffer.tokenForPosition([1, 1]).scopes).toEqual(['source.js']) - expect(tokenizedBuffer.tokenForPosition([1, 2]).scopes).toEqual(['source.js', 'storage.type.var.js']) - }) - }) - - describe('.bufferRangeForScopeAtPosition(selector, position)', () => { - beforeEach(() => { - buffer = atom.project.bufferForPathSync('sample.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - }) - - describe('when the selector does not match the token at the position', () => - it('returns a falsy value', () => expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.bogus', [0, 1])).toBeUndefined()) - ) - - describe('when the selector matches a single token at the position', () => { - it('returns the range covered by the token', () => { - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 1])).toEqual([[0, 0], [0, 3]]) - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.storage.type.var.js', [0, 3])).toEqual([[0, 0], [0, 3]]) - }) - }) - - describe('when the selector matches a run of multiple tokens at the position', () => { - it('returns the range covered by all contiguous tokens (within a single line)', () => { - expect(tokenizedBuffer.bufferRangeForScopeAtPosition('.function', [1, 18])).toEqual([[1, 6], [1, 28]]) - }) - }) - }) - - describe('.tokenizedLineForRow(row)', () => { - it("returns the tokenized line for a row, or a placeholder line if it hasn't been tokenized yet", () => { - buffer = atom.project.bufferForPathSync('sample.js') - const grammar = atom.grammars.grammarForScopeName('source.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - const line0 = buffer.lineForRow(0) - - const jsScopeStartId = grammar.startIdForScope(grammar.scopeName) - const jsScopeEndId = grammar.endIdForScope(grammar.scopeName) - startTokenizing(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([jsScopeStartId, line0.length, jsScopeEndId]) - advanceClock(1) - expect(tokenizedBuffer.tokenizedLines[0]).not.toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).not.toEqual([jsScopeStartId, line0.length, jsScopeEndId]) - - const nullScopeStartId = NullGrammar.startIdForScope(NullGrammar.scopeName) - const nullScopeEndId = NullGrammar.endIdForScope(NullGrammar.scopeName) - tokenizedBuffer.setGrammar(NullGrammar) - startTokenizing(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLines[0]).toBeUndefined() - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) - advanceClock(1) - expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe(line0) - expect(tokenizedBuffer.tokenizedLineForRow(0).tags).toEqual([nullScopeStartId, line0.length, nullScopeEndId]) - }) - - it('returns undefined if the requested row is outside the buffer range', () => { - buffer = atom.project.bufferForPathSync('sample.js') - const grammar = atom.grammars.grammarForScopeName('source.js') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenizedLineForRow(999)).toBeUndefined() - }) - }) - - describe('text decoration layer API', () => { - describe('iterator', () => { - it('iterates over the syntactic scope boundaries', () => { - buffer = new TextBuffer({text: 'var foo = 1 /*\nhello*/var bar = 2\n'}) - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - const iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(0, 0)) - - const expectedBoundaries = [ - {position: Point(0, 0), closeTags: [], openTags: ['syntax--source syntax--js', 'syntax--storage syntax--type syntax--var syntax--js']}, - {position: Point(0, 3), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, - {position: Point(0, 8), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, - {position: Point(0, 9), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, - {position: Point(0, 10), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, - {position: Point(0, 11), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []}, - {position: Point(0, 12), closeTags: [], openTags: ['syntax--comment syntax--block syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js']}, - {position: Point(0, 14), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js'], openTags: []}, - {position: Point(1, 5), closeTags: [], openTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js']}, - {position: Point(1, 7), closeTags: ['syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js', 'syntax--comment syntax--block syntax--js'], openTags: ['syntax--storage syntax--type syntax--var syntax--js']}, - {position: Point(1, 10), closeTags: ['syntax--storage syntax--type syntax--var syntax--js'], openTags: []}, - {position: Point(1, 15), closeTags: [], openTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js']}, - {position: Point(1, 16), closeTags: ['syntax--keyword syntax--operator syntax--assignment syntax--js'], openTags: []}, - {position: Point(1, 17), closeTags: [], openTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js']}, - {position: Point(1, 18), closeTags: ['syntax--constant syntax--numeric syntax--decimal syntax--js'], openTags: []} - ] - - while (true) { - const boundary = { - position: iterator.getPosition(), - closeTags: iterator.getCloseScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId)), - openTags: iterator.getOpenScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId)) - } - - expect(boundary).toEqual(expectedBoundaries.shift()) - if (!iterator.moveToSuccessor()) { break } - } - - expect(iterator.seek(Point(0, 1)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - 'syntax--source syntax--js', - 'syntax--storage syntax--type syntax--var syntax--js' - ]) - expect(iterator.getPosition()).toEqual(Point(0, 3)) - expect(iterator.seek(Point(0, 8)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - 'syntax--source syntax--js' - ]) - expect(iterator.getPosition()).toEqual(Point(0, 8)) - expect(iterator.seek(Point(1, 0)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - 'syntax--source syntax--js', - 'syntax--comment syntax--block syntax--js' - ]) - expect(iterator.getPosition()).toEqual(Point(1, 0)) - expect(iterator.seek(Point(1, 18)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - 'syntax--source syntax--js', - 'syntax--constant syntax--numeric syntax--decimal syntax--js' - ]) - expect(iterator.getPosition()).toEqual(Point(1, 18)) - - expect(iterator.seek(Point(2, 0)).map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual([ - 'syntax--source syntax--js' - ]) - iterator.moveToSuccessor() - }) // ensure we don't infinitely loop (regression test) - - it('does not report columns beyond the length of the line', async () => { - await atom.packages.activatePackage('language-coffee-script') - - buffer = new TextBuffer({text: '# hello\n# world'}) - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.coffee'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - const iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(0, 0)) - iterator.moveToSuccessor() - iterator.moveToSuccessor() - expect(iterator.getPosition().column).toBe(7) - - iterator.moveToSuccessor() - expect(iterator.getPosition().column).toBe(0) - - iterator.seek(Point(0, 7)) - expect(iterator.getPosition().column).toBe(7) - - iterator.seek(Point(0, 8)) - expect(iterator.getPosition().column).toBe(7) - }) - - it('correctly terminates scopes at the beginning of the line (regression)', () => { - const grammar = atom.grammars.createGrammar('test', { - 'scopeName': 'text.broken', - 'name': 'Broken grammar', - 'patterns': [ - {'begin': 'start', 'end': '(?=end)', 'name': 'blue.broken'}, - {'match': '.', 'name': 'yellow.broken'} - ] - }) - - buffer = new TextBuffer({text: 'start x\nend x\nx'}) - tokenizedBuffer = new TokenizedBuffer({buffer, grammar, tabLength: 2}) - fullyTokenize(tokenizedBuffer) - - const iterator = tokenizedBuffer.buildIterator() - iterator.seek(Point(1, 0)) - - expect(iterator.getPosition()).toEqual([1, 0]) - expect(iterator.getCloseScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual(['syntax--blue syntax--broken']) - expect(iterator.getOpenScopeIds().map(scopeId => tokenizedBuffer.classNameForScopeId(scopeId))).toEqual(['syntax--yellow syntax--broken']) - }) - }) - }) - - describe('.suggestedIndentForBufferRow', () => { - let editor - - describe('javascript', () => { - beforeEach(async () => { - editor = await atom.workspace.open('sample.js', {autoIndent: false}) - await atom.packages.activatePackage('language-javascript') - }) - - it('bases indentation off of the previous non-blank line', () => { - expect(editor.suggestedIndentForBufferRow(0)).toBe(0) - expect(editor.suggestedIndentForBufferRow(1)).toBe(1) - expect(editor.suggestedIndentForBufferRow(2)).toBe(2) - expect(editor.suggestedIndentForBufferRow(5)).toBe(3) - expect(editor.suggestedIndentForBufferRow(7)).toBe(2) - expect(editor.suggestedIndentForBufferRow(9)).toBe(1) - expect(editor.suggestedIndentForBufferRow(11)).toBe(1) - }) - - it('does not take invisibles into account', () => { - editor.update({showInvisibles: true}) - expect(editor.suggestedIndentForBufferRow(0)).toBe(0) - expect(editor.suggestedIndentForBufferRow(1)).toBe(1) - expect(editor.suggestedIndentForBufferRow(2)).toBe(2) - expect(editor.suggestedIndentForBufferRow(5)).toBe(3) - expect(editor.suggestedIndentForBufferRow(7)).toBe(2) - expect(editor.suggestedIndentForBufferRow(9)).toBe(1) - expect(editor.suggestedIndentForBufferRow(11)).toBe(1) - }) - }) - - describe('css', () => { - beforeEach(async () => { - editor = await atom.workspace.open('css.css', {autoIndent: true}) - await atom.packages.activatePackage('language-source') - await atom.packages.activatePackage('language-css') - }) - - it('does not return negative values (regression)', () => { - editor.setText('.test {\npadding: 0;\n}') - expect(editor.suggestedIndentForBufferRow(2)).toBe(0) - }) - }) - }) - - describe('.isFoldableAtRow(row)', () => { - beforeEach(() => { - buffer = atom.project.bufferForPathSync('sample.js') - buffer.insert([10, 0], ' // multi-line\n // comment\n // block\n') - buffer.insert([0, 0], '// multi-line\n// comment\n// block\n') - tokenizedBuffer = new TokenizedBuffer({buffer, grammar: atom.grammars.grammarForScopeName('source.js'), tabLength: 2}) - fullyTokenize(tokenizedBuffer) - }) - - it('includes the first line of multi-line comments', () => { - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) // because of indent - expect(tokenizedBuffer.isFoldableAtRow(13)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(14)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(15)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(16)).toBe(false) - - buffer.insert([0, Infinity], '\n') - - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(false) - - buffer.undo() - - expect(tokenizedBuffer.isFoldableAtRow(0)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) - }) // because of indent - - it('includes non-comment lines that precede an increase in indentation', () => { - buffer.insert([2, 0], ' ') // commented lines preceding an indent aren't foldable - - expect(tokenizedBuffer.isFoldableAtRow(1)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(2)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(3)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(4)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(5)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) - - buffer.insert([7, 0], ' ') - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) - - buffer.undo() - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) - - buffer.insert([7, 0], ' \n x\n') - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) - - buffer.insert([9, 0], ' ') - - expect(tokenizedBuffer.isFoldableAtRow(6)).toBe(true) - expect(tokenizedBuffer.isFoldableAtRow(7)).toBe(false) - expect(tokenizedBuffer.isFoldableAtRow(8)).toBe(false) - }) - }) - - describe('.getFoldableRangesAtIndentLevel', () => { - it('returns the ranges that can be folded at the given indent level', () => { - buffer = new TextBuffer(dedent ` - if (a) { - b(); - if (c) { - d() - if (e) { - f() - } - g() - } - h() - } - i() - if (j) { - k() - } - `) - - tokenizedBuffer = new TokenizedBuffer({buffer}) - - expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(0, 2))).toBe(dedent ` - if (a) {⋯ - } - i() - if (j) {⋯ - } - `) - - expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(1, 2))).toBe(dedent ` - if (a) { - b(); - if (c) {⋯ - } - h() - } - i() - if (j) { - k() - } - `) - - expect(simulateFold(tokenizedBuffer.getFoldableRangesAtIndentLevel(2, 2))).toBe(dedent ` - if (a) { - b(); - if (c) { - d() - if (e) {⋯ - } - g() - } - h() - } - i() - if (j) { - k() - } - `) - }) - }) - - describe('.getFoldableRanges', () => { - it('returns the ranges that can be folded', () => { - buffer = new TextBuffer(dedent ` - if (a) { - b(); - if (c) { - d() - if (e) { - f() - } - g() - } - h() - } - i() - if (j) { - k() - } - `) - - tokenizedBuffer = new TokenizedBuffer({buffer}) - - expect(tokenizedBuffer.getFoldableRanges(2).map(r => r.toString())).toEqual([ - ...tokenizedBuffer.getFoldableRangesAtIndentLevel(0, 2), - ...tokenizedBuffer.getFoldableRangesAtIndentLevel(1, 2), - ...tokenizedBuffer.getFoldableRangesAtIndentLevel(2, 2), - ].sort((a, b) => (a.start.row - b.start.row) || (a.end.row - b.end.row)).map(r => r.toString())) - }) - }) - - describe('.getFoldableRangeContainingPoint', () => { - it('returns the range for the smallest fold that contains the given range', () => { - buffer = new TextBuffer(dedent ` - if (a) { - b(); - if (c) { - d() - if (e) { - f() - } - g() - } - h() - } - i() - if (j) { - k() - } - `) - - tokenizedBuffer = new TokenizedBuffer({buffer}) - - expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, 5), 2)).toBeNull() - - let range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, 10), 2) - expect(simulateFold([range])).toBe(dedent ` - if (a) {⋯ - } - i() - if (j) { - k() - } - `) - - range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity), 2) - expect(simulateFold([range])).toBe(dedent ` - if (a) {⋯ - } - i() - if (j) { - k() - } - `) - - range = tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, 20), 2) - expect(simulateFold([range])).toBe(dedent ` - if (a) { - b(); - if (c) {⋯ - } - h() - } - i() - if (j) { - k() - } - `) - }) - - it('works for coffee-script', async () => { - const editor = await atom.workspace.open('coffee.coffee') - await atom.packages.activatePackage('language-coffee-script') - buffer = editor.buffer - tokenizedBuffer = editor.tokenizedBuffer - - expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, Infinity))).toEqual([[0, Infinity], [20, Infinity]]) - expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity))).toEqual([[1, Infinity], [17, Infinity]]) - expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, Infinity))).toEqual([[1, Infinity], [17, Infinity]]) - expect(tokenizedBuffer.getFoldableRangeContainingPoint(Point(19, Infinity))).toEqual([[19, Infinity], [20, Infinity]]) - }) - - it('works for javascript', async () => { - const editor = await atom.workspace.open('sample.js') - await atom.packages.activatePackage('language-javascript') - buffer = editor.buffer - tokenizedBuffer = editor.tokenizedBuffer - - expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(0, Infinity))).toEqual([[0, Infinity], [12, Infinity]]) - expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(1, Infinity))).toEqual([[1, Infinity], [9, Infinity]]) - expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(2, Infinity))).toEqual([[1, Infinity], [9, Infinity]]) - expect(editor.tokenizedBuffer.getFoldableRangeContainingPoint(Point(4, Infinity))).toEqual([[4, Infinity], [7, Infinity]]) - }) - }) - - function simulateFold (ranges) { - buffer.transact(() => { - for (const range of ranges.reverse()) { - buffer.setTextInRange(range, '⋯') - } - }) - let text = buffer.getText() - buffer.undo() - return text - } -}) 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/spec/workspace-element-spec.js b/spec/workspace-element-spec.js index 552e95b6d..90d973773 100644 --- a/spec/workspace-element-spec.js +++ b/spec/workspace-element-spec.js @@ -561,8 +561,6 @@ describe('WorkspaceElement', () => { expectToggleButtonHidden(rightDock) expectToggleButtonHidden(bottomDock) - workspaceElement.paneContainer.dispatchEvent(new MouseEvent('mouseleave')) - // --- Right Dock --- // Mouse over where the toggle button would be if the dock were hovered @@ -591,7 +589,7 @@ describe('WorkspaceElement', () => { // Mouse to edge of the window moveMouse({clientX: 575, clientY: 150}) expectToggleButtonHidden(rightDock) - moveMouse({clientX: 600, clientY: 150}) + moveMouse({clientX: 598, clientY: 150}) expectToggleButtonVisible(rightDock, 'icon-chevron-left') // Click the toggle button again @@ -627,7 +625,7 @@ describe('WorkspaceElement', () => { // Mouse to edge of the window moveMouse({clientX: 25, clientY: 150}) expectToggleButtonHidden(leftDock) - moveMouse({clientX: 0, clientY: 150}) + moveMouse({clientX: 2, clientY: 150}) expectToggleButtonVisible(leftDock, 'icon-chevron-right') // Click the toggle button again @@ -663,7 +661,7 @@ describe('WorkspaceElement', () => { // Mouse to edge of the window moveMouse({clientX: 300, clientY: 290}) expectToggleButtonHidden(leftDock) - moveMouse({clientX: 300, clientY: 300}) + moveMouse({clientX: 300, clientY: 299}) expectToggleButtonVisible(bottomDock, 'icon-chevron-up') // Click the toggle button again diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index 1bde0e6fe..4b115e594 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -1,5 +1,7 @@ const path = require('path') const temp = require('temp').track() +const dedent = require('dedent') +const TextBuffer = require('text-buffer') const TextEditor = require('../src/text-editor') const Workspace = require('../src/workspace') const Project = require('../src/project') @@ -8,7 +10,7 @@ const _ = require('underscore-plus') const fstream = require('fstream') const fs = require('fs-plus') const AtomEnvironment = require('../src/atom-environment') -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') describe('Workspace', () => { let workspace @@ -43,7 +45,8 @@ describe('Workspace', () => { notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm.bind(atom), - applicationDelegate: atom.applicationDelegate + applicationDelegate: atom.applicationDelegate, + grammarRegistry: atom.grammars }) return atom.project.deserialize(projectState).then(() => { workspace = atom.workspace = new Workspace({ @@ -656,59 +659,42 @@ describe('Workspace', () => { }) }) - describe('when the file is over 2MB', () => { - it('opens the editor with largeFileMode: true', () => { - spyOn(fs, 'getSizeSync').andReturn(2 * 1048577) // 2MB - - let editor = null - waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) - - runs(() => expect(editor.largeFileMode).toBe(true)) - }) - }) - - describe('when the file is over user-defined limit', () => { - const shouldPromptForFileOfSize = (size, shouldPrompt) => { + describe('when the file size is over the limit defined in `core.warnOnLargeFileLimit`', () => { + const shouldPromptForFileOfSize = async (size, shouldPrompt) => { spyOn(fs, 'getSizeSync').andReturn(size * 1048577) - atom.applicationDelegate.confirm.andCallFake(() => selectedButtonIndex) - atom.applicationDelegate.confirm() - var selectedButtonIndex = 1 // cancel - let editor = null - waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + let selectedButtonIndex = 1 // cancel + atom.applicationDelegate.confirm.andCallFake((options, callback) => callback(selectedButtonIndex)) + + let editor = await workspace.open('sample.js') if (shouldPrompt) { - runs(() => { - expect(editor).toBeUndefined() - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() + expect(editor).toBeUndefined() + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - atom.applicationDelegate.confirm.reset() - selectedButtonIndex = 0 - }) // open the file + atom.applicationDelegate.confirm.reset() + selectedButtonIndex = 0 // open the file - waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + editor = await workspace.open('sample.js') - runs(() => { - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - expect(editor.largeFileMode).toBe(true) - }) + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() } else { - runs(() => expect(editor).not.toBeUndefined()) + expect(editor).not.toBeUndefined() } } - it('prompts the user to make sure they want to open a file this big', () => { + it('prompts before opening the file', async () => { atom.config.set('core.warnOnLargeFileLimit', 20) - shouldPromptForFileOfSize(20, true) + await shouldPromptForFileOfSize(20, true) }) - it("doesn't prompt on files below the limit", () => { + it("doesn't prompt on files below the limit", async () => { atom.config.set('core.warnOnLargeFileLimit', 30) - shouldPromptForFileOfSize(20, false) + await shouldPromptForFileOfSize(20, false) }) - it('prompts for smaller files with a lower limit', () => { + it('prompts for smaller files with a lower limit', async () => { atom.config.set('core.warnOnLargeFileLimit', 5) - shouldPromptForFileOfSize(10, true) + await shouldPromptForFileOfSize(10, true) }) }) @@ -943,6 +929,18 @@ describe('Workspace', () => { }) }) }) + + describe('when opening an editor with a buffer that isn\'t part of the project', () => { + it('adds the buffer to the project', async () => { + const buffer = new TextBuffer() + const editor = new TextEditor({buffer}) + + await atom.workspace.open(editor) + + expect(atom.project.getBuffers().map(buffer => buffer.id)).toContain(buffer.id) + expect(buffer.getLanguageMode().getLanguageId()).toBe('text.plain.null-grammar') + }) + }) }) describe('finding items in the workspace', () => { @@ -1217,8 +1215,8 @@ describe('Workspace', () => { }) }) - describe('::onDidStopChangingActivePaneItem()', function () { - it('invokes observers when the active item of the active pane stops changing', function () { + describe('::onDidStopChangingActivePaneItem()', () => { + it('invokes observers when the active item of the active pane stops changing', () => { const pane1 = atom.workspace.getCenter().getActivePane() const pane2 = pane1.splitRight({items: [document.createElement('div'), document.createElement('div')]}); atom.workspace.getLeftDock().getActivePane().addItem(document.createElement('div')) @@ -1237,29 +1235,22 @@ describe('Workspace', () => { }) describe('the grammar-used hook', () => { - it('fires when opening a file or changing the grammar of an open file', () => { - let editor = null - let javascriptGrammarUsed = false - let coffeescriptGrammarUsed = false + it('fires when opening a file or changing the grammar of an open file', async () => { + let resolveJavascriptGrammarUsed, resolveCoffeeScriptGrammarUsed + const javascriptGrammarUsed = new Promise(resolve => { resolveJavascriptGrammarUsed = resolve }) + const coffeescriptGrammarUsed = new Promise(resolve => { resolveCoffeeScriptGrammarUsed = resolve }) atom.packages.triggerDeferredActivationHooks() + atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', resolveJavascriptGrammarUsed) + atom.packages.onDidTriggerActivationHook('language-coffee-script:grammar-used', resolveCoffeeScriptGrammarUsed) - runs(() => { - atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', () => { javascriptGrammarUsed = true }) - atom.packages.onDidTriggerActivationHook('language-coffee-script:grammar-used', () => { coffeescriptGrammarUsed = true }) - }) + const editor = await atom.workspace.open('sample.js', {autoIndent: false}) + await atom.packages.activatePackage('language-javascript') + await javascriptGrammarUsed - waitsForPromise(() => atom.workspace.open('sample.js', {autoIndent: false}).then(o => { editor = o })) - - waitsForPromise(() => atom.packages.activatePackage('language-javascript')) - - waitsFor(() => javascriptGrammarUsed) - - waitsForPromise(() => atom.packages.activatePackage('language-coffee-script')) - - runs(() => editor.setGrammar(atom.grammars.selectGrammar('.coffee'))) - - waitsFor(() => coffeescriptGrammarUsed) + await atom.packages.activatePackage('language-coffee-script') + atom.grammars.assignLanguageMode(editor, 'source.coffee') + await coffeescriptGrammarUsed }) }) @@ -1382,7 +1373,7 @@ describe('Workspace', () => { describe('::getActiveTextEditor()', () => { describe("when the workspace center's active pane item is a text editor", () => { - describe('when the workspace center has focus', function () { + describe('when the workspace center has focus', () => { it('returns the text editor', () => { const workspaceCenter = workspace.getCenter() const editor = new TextEditor() @@ -1393,7 +1384,7 @@ describe('Workspace', () => { }) }) - describe('when a dock has focus', function () { + describe('when a dock has focus', () => { it('returns the text editor', () => { const workspaceCenter = workspace.getCenter() const editor = new TextEditor() @@ -1521,34 +1512,27 @@ describe('Workspace', () => { }) describe('when an editor is destroyed', () => { - it('removes the editor', () => { - let editor = null - - waitsForPromise(() => workspace.open('a').then(e => { editor = e })) - - runs(() => { - expect(workspace.getTextEditors()).toHaveLength(1) - editor.destroy() - expect(workspace.getTextEditors()).toHaveLength(0) - }) + it('removes the editor', async () => { + const editor = await workspace.open('a') + expect(workspace.getTextEditors()).toHaveLength(1) + editor.destroy() + expect(workspace.getTextEditors()).toHaveLength(0) }) }) describe('when an editor is copied because its pane is split', () => { - it('sets up the new editor to be configured by the text editor registry', () => { - waitsForPromise(() => atom.packages.activatePackage('language-javascript')) + it('sets up the new editor to be configured by the text editor registry', async () => { + await atom.packages.activatePackage('language-javascript') - waitsForPromise(() => - workspace.open('a').then(editor => { - atom.textEditors.setGrammarOverride(editor, 'source.js') - expect(editor.getGrammar().name).toBe('JavaScript') + const editor = await workspace.open('a') - workspace.getActivePane().splitRight({copyActiveItem: true}) - const newEditor = workspace.getActiveTextEditor() - expect(newEditor).not.toBe(editor) - expect(newEditor.getGrammar().name).toBe('JavaScript') - }) - ) + atom.grammars.assignLanguageMode(editor, 'source.js') + expect(editor.getGrammar().name).toBe('JavaScript') + + workspace.getActivePane().splitRight({copyActiveItem: true}) + const newEditor = workspace.getActiveTextEditor() + expect(newEditor).not.toBe(editor) + expect(newEditor.getGrammar().name).toBe('JavaScript') }) }) @@ -1561,11 +1545,10 @@ describe('Workspace', () => { waitsForPromise(() => atom.workspace.open('sample.coffee')) - runs(function () { - atom.workspace.getActiveTextEditor().setText(`\ -i = /test/; #FIXME\ -` - ) + runs(() => { + atom.workspace.getActiveTextEditor().setText(dedent ` + i = /test/; #FIXME\ + `) const atom2 = new AtomEnvironment({applicationDelegate: atom.applicationDelegate}) atom2.initialize({ @@ -2789,7 +2772,7 @@ i = /test/; #FIXME\ }) describe('grammar activation', () => { - it('notifies the workspace of which grammar is used', () => { + it('notifies the workspace of which grammar is used', async () => { atom.packages.triggerDeferredActivationHooks() const javascriptGrammarUsed = jasmine.createSpy('js grammar used') @@ -2800,52 +2783,51 @@ i = /test/; #FIXME\ atom.packages.onDidTriggerActivationHook('language-ruby:grammar-used', rubyGrammarUsed) atom.packages.onDidTriggerActivationHook('language-c:grammar-used', cGrammarUsed) - waitsForPromise(() => atom.packages.activatePackage('language-ruby')) - waitsForPromise(() => atom.packages.activatePackage('language-javascript')) - waitsForPromise(() => atom.packages.activatePackage('language-c')) - waitsForPromise(() => atom.workspace.open('sample-with-comments.js')) + await atom.packages.activatePackage('language-ruby') + await atom.packages.activatePackage('language-javascript') + await atom.packages.activatePackage('language-c') + await atom.workspace.open('sample-with-comments.js') - runs(() => { - // Hooks are triggered when opening new editors - expect(javascriptGrammarUsed).toHaveBeenCalled() + // Hooks are triggered when opening new editors + expect(javascriptGrammarUsed).toHaveBeenCalled() - // Hooks are triggered when changing existing editors grammars - atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.c')) - expect(cGrammarUsed).toHaveBeenCalled() + // Hooks are triggered when changing existing editors grammars + atom.grammars.assignLanguageMode(atom.workspace.getActiveTextEditor(), 'source.c') + expect(cGrammarUsed).toHaveBeenCalled() - // Hooks are triggered when editors are added in other ways. - atom.workspace.getActivePane().splitRight({copyActiveItem: true}) - atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.ruby')) - expect(rubyGrammarUsed).toHaveBeenCalled() - }) + // Hooks are triggered when editors are added in other ways. + atom.workspace.getActivePane().splitRight({copyActiveItem: true}) + atom.grammars.assignLanguageMode(atom.workspace.getActiveTextEditor(), 'source.ruby') + expect(rubyGrammarUsed).toHaveBeenCalled() }) }) describe('.checkoutHeadRevision()', () => { let editor = null - beforeEach(() => { + beforeEach(async () => { + jasmine.useRealClock() atom.config.set('editor.confirmCheckoutHeadRevision', false) - waitsForPromise(() => atom.workspace.open('sample-with-comments.js').then(o => { editor = o })) + editor = await atom.workspace.open('sample-with-comments.js') }) - it('reverts to the version of its file checked into the project repository', () => { + it('reverts to the version of its file checked into the project repository', async () => { editor.setCursorBufferPosition([0, 0]) editor.insertText('---\n') expect(editor.lineTextForBufferRow(0)).toBe('---') - waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + atom.workspace.checkoutHeadRevision(editor) - runs(() => expect(editor.lineTextForBufferRow(0)).toBe('')) + await conditionPromise(() => editor.lineTextForBufferRow(0) === '') }) describe("when there's no repository for the editor's file", () => { - it("doesn't do anything", () => { + it("doesn't do anything", async () => { editor = new TextEditor() editor.setText('stuff') atom.workspace.checkoutHeadRevision(editor) - waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + atom.workspace.checkoutHeadRevision(editor) }) }) }) @@ -2894,4 +2876,6 @@ i = /test/; #FIXME\ }) }) -const escapeStringRegex = str => str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') +function escapeStringRegex (string) { + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') +} diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee deleted file mode 100644 index 55c27eb61..000000000 --- a/src/application-delegate.coffee +++ /dev/null @@ -1,294 +0,0 @@ -_ = require 'underscore-plus' -{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 _.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 _.isArray(buttons) - chosen - else - callback = buttons[buttonLabels[chosen]] - callback?() - - showMessageDialog: (params) -> - - showSaveDialog: (params) -> - if typeof params is 'string' - params = {defaultPath: params} - @getCurrentWindow().showSaveDialog(params) - - 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..a6d701078 --- /dev/null +++ b/src/application-delegate.js @@ -0,0 +1,374 @@ +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 (options, callback) { + if (typeof callback === 'function') { + // Async version: pass options directly to Electron but set sane defaults + options = Object.assign({type: 'info', normalizeAccessKeys: true}, options) + remote.dialog.showMessageBox(remote.getCurrentWindow(), options, callback) + } else { + // Legacy sync version: options can only have `message`, + // `detailedMessage` (optional), and buttons array or object (optional) + let {message, detailedMessage, buttons} = options + + 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]] + if (typeof callback === 'function') callback() + } + } + } + + 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 663bb6c00..a80cde66c 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,26 +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) - TextEditor.setScheduler(this.views) + + // 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, @@ -99,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, @@ -106,16 +128,29 @@ 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) - this.project = new Project({notificationManager: this.notifications, packageManager: this.packages, config: this.config, applicationDelegate: this.applicationDelegate}) + // Public: A {Project} instance + this.project = new Project({ + notificationManager: this.notifications, + packageManager: this.packages, + grammarRegistry: this.grammars, + config: this.config, + applicationDelegate: this.applicationDelegate + }) this.commandInstaller = new CommandInstaller(this.applicationDelegate) this.protocolHandlerInstaller = new ProtocolHandlerInstaller() + // Public: A {TextEditorRegistry} instance this.textEditors = new TextEditorRegistry({ config: this.config, grammarRegistry: this.grammars, @@ -123,6 +158,7 @@ class AtomEnvironment { packageManager: this.packages }) + // Public: A {Workspace} instance this.workspace = new Workspace({ config: this.config, project: this.project, @@ -152,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() @@ -201,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 @@ -321,6 +360,7 @@ class AtomEnvironment { this.grammars.clear() this.textEditors.clear() this.views.clear() + this.pathsWithWaitSessions.clear() } destroy () { @@ -333,7 +373,7 @@ class AtomEnvironment { if (this.project) this.project.destroy() this.project = null this.commands.clear() - this.stylesElement.remove() + if (this.stylesElement) this.stylesElement.remove() this.config.unobserveUserConfig() this.autoUpdater.destroy() this.uriHandlerRegistry.destroy() @@ -784,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() @@ -815,10 +870,9 @@ class AtomEnvironment { project: this.project.serialize(options), workspace: this.workspace.serialize(), packageStates: this.packages.serialize(), - grammars: {grammarOverridesByPath: this.grammars.grammarOverridesByPath}, + grammars: this.grammars.serialize(), fullScreen: this.isFullScreen(), - windowDimensions: this.windowDimensions, - textEditors: this.textEditors.serialize() + windowDimensions: this.windowDimensions } } @@ -911,29 +965,63 @@ class AtomEnvironment { // Essential: A flexible way to open a dialog akin to an alert dialog. // + // While both async and sync versions are provided, it is recommended to use the async version + // such that the renderer process is not blocked while the dialog box is open. + // + // The async version accepts the same options as Electron's `dialog.showMessageBox`. + // For convenience, it sets `type` to `'info'` and `normalizeAccessKeys` to `true` by default. + // // If the dialog is closed (via `Esc` key or `X` in the top corner) without selecting a button // the first button will be clicked unless a "Cancel" or "No" button is provided. // // ## Examples // - // ```coffee - // atom.confirm - // message: 'How you feeling?' - // detailedMessage: 'Be honest.' - // buttons: - // Good: -> window.alert('good to hear') - // Bad: -> window.alert('bummer') + // ```js + // // Async version (recommended) + // atom.confirm({ + // message: 'How you feeling?', + // detail: 'Be honest.', + // buttons: ['Good', 'Bad'] + // }, response => { + // if (response === 0) { + // window.alert('good to hear') + // } else { + // window.alert('bummer') + // } + // }) + // + // ```js + // // Legacy sync version + // const chosen = atom.confirm({ + // message: 'How you feeling?', + // detailedMessage: 'Be honest.', + // buttons: { + // Good: () => window.alert('good to hear'), + // Bad: () => window.alert('bummer') + // } + // }) // ``` // - // * `options` An {Object} with the following keys: + // * `options` An options {Object}. If the callback argument is also supplied, see the documentation at + // https://electronjs.org/docs/api/dialog#dialogshowmessageboxbrowserwindow-options-callback for the list of + // available options. Otherwise, only the following keys are accepted: // * `message` The {String} message to display. // * `detailedMessage` (optional) The {String} detailed message to display. - // * `buttons` (optional) Either an array of strings or an object where keys are - // button names and the values are callbacks to invoke when clicked. + // * `buttons` (optional) Either an {Array} of {String}s or an {Object} where keys are + // button names and the values are callback {Function}s to invoke when clicked. + // * `callback` (optional) A {Function} that will be called with the index of the chosen option. + // If a callback is supplied, the dialog will be non-blocking. This argument is recommended. // - // Returns the chosen button index {Number} if the buttons option is an array or the return value of the callback if the buttons option is an object. - confirm (params = {}) { - return this.applicationDelegate.confirm(params) + // Returns the chosen button index {Number} if the buttons option is an array + // or the return value of the callback if the buttons option is an object. + // If a callback function is supplied, returns `undefined`. + confirm (options = {}, callback) { + if (callback) { + // Async: no return value + this.applicationDelegate.confirm(options, callback) + } else { + return this.applicationDelegate.confirm(options) + } } /* @@ -988,13 +1076,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) @@ -1008,8 +1089,10 @@ class AtomEnvironment { } addProjectFolder () { - this.pickFolder((selectedPaths = []) => { - this.addToProject(selectedPaths) + return new Promise((resolve) => { + this.pickFolder((selectedPaths) => { + this.addToProject(selectedPaths || []).then(resolve) + }) }) } @@ -1022,7 +1105,7 @@ class AtomEnvironment { } } - attemptRestoreProjectStateForPaths (state, projectPaths, filesToOpen = []) { + async attemptRestoreProjectStateForPaths (state, projectPaths, filesToOpen = []) { const center = this.workspace.getCenter() const windowIsUnused = () => { for (let container of this.workspace.getPaneContainers()) { @@ -1038,33 +1121,41 @@ class AtomEnvironment { } if (windowIsUnused()) { - this.restoreStateIntoThisEnvironment(state) + await this.restoreStateIntoThisEnvironment(state) return Promise.all(filesToOpen.map(file => this.workspace.open(file))) } else { + let resolveDiscardStatePromise = null + const discardStatePromise = new Promise((resolve) => { + resolveDiscardStatePromise = resolve + }) const nouns = projectPaths.length === 1 ? 'folder' : 'folders' - const choice = this.confirm({ + this.confirm({ message: 'Previous automatically-saved project state detected', - detailedMessage: `There is previously saved state for the selected ${nouns}. ` + + detail: `There is previously saved state for the selected ${nouns}. ` + `Would you like to add the ${nouns} to this window, permanently discarding the saved state, ` + `or open the ${nouns} in a new window, restoring the saved state?`, buttons: [ '&Open in new window and recover state', '&Add to this window and discard state' - ]}) - if (choice === 0) { - this.open({ - pathsToOpen: projectPaths.concat(filesToOpen), - newWindow: true, - devMode: this.inDevMode(), - safeMode: this.inSafeMode() - }) - return Promise.resolve(null) - } else if (choice === 1) { - for (let selectedPath of projectPaths) { - this.project.addPath(selectedPath) + ] + }, response => { + if (response === 0) { + this.open({ + pathsToOpen: projectPaths.concat(filesToOpen), + newWindow: true, + devMode: this.inDevMode(), + safeMode: this.inSafeMode() + }) + resolveDiscardStatePromise(Promise.resolve(null)) + } else if (response === 1) { + for (let selectedPath of projectPaths) { + this.project.addPath(selectedPath) + } + resolveDiscardStatePromise(Promise.all(filesToOpen.map(file => this.workspace.open(file)))) } - return Promise.all(filesToOpen.map(file => this.workspace.open(file))) - } + }) + + return discardStatePromise } } @@ -1076,12 +1167,11 @@ class AtomEnvironment { return this.deserialize(state) } - showSaveDialog (callback) { - callback(this.showSaveDialogSync()) - } - showSaveDialogSync (options = {}) { - this.applicationDelegate.showSaveDialog(options) + deprecate(`atom.showSaveDialogSync is deprecated and will be removed soon. +Please, implement ::saveAs and ::getSaveDialogOptions instead for pane items +or use Pane::saveItemAs for programmatic saving.`) + return this.applicationDelegate.showSaveDialog(options) } async saveState (options, storageKey) { @@ -1112,11 +1202,6 @@ class AtomEnvironment { async deserialize (state) { if (!state) return Promise.resolve() - const grammarOverridesByPath = state.grammars && state.grammars.grammarOverridesByPath - if (grammarOverridesByPath) { - this.grammars.grammarOverridesByPath = grammarOverridesByPath - } - this.setFullScreen(state.fullScreen) const missingProjectPaths = [] @@ -1141,7 +1226,7 @@ class AtomEnvironment { this.deserializeTimings.project = Date.now() - startTime - if (state.textEditors) this.textEditors.deserialize(state.textEditors) + if (state.grammars) this.grammars.deserialize(state.grammars) startTime = Date.now() if (state.workspace) this.workspace.deserialize(state.workspace, this.deserializers) @@ -1266,8 +1351,9 @@ class AtomEnvironment { } } - 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))) { @@ -1278,8 +1364,10 @@ class AtomEnvironment { } if (!fs.isDirectorySync(pathToOpen)) { - fileLocationsToOpen.push({pathToOpen, initialLine, initialColumn}) + fileLocationsToOpen.push(location) } + + if (location.hasWaitSession) this.pathsWithWaitSessions.add(pathToOpen) } let restoredState = false @@ -1300,7 +1388,7 @@ class AtomEnvironment { 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/color.js b/src/color.js index 2f2947e16..52f555076 100644 --- a/src/color.js +++ b/src/color.js @@ -89,6 +89,10 @@ export default class Color { return this.alpha === 1 ? this.toHexString() : this.toRGBAString() } + toString () { + return this.toRGBAString() + } + isEqual (color) { if (this === color) { return true diff --git a/src/command-installer.js b/src/command-installer.js index 2c032d6c5..85360da17 100644 --- a/src/command-installer.js +++ b/src/command-installer.js @@ -23,8 +23,8 @@ class CommandInstaller { const showErrorDialog = (error) => { this.applicationDelegate.confirm({ message: 'Failed to install shell commands', - detailedMessage: error.message - }) + detail: error.message + }, () => {}) } this.installAtomCommand(true, error => { @@ -33,8 +33,8 @@ class CommandInstaller { if (error) return showErrorDialog(error) this.applicationDelegate.confirm({ message: 'Commands installed.', - detailedMessage: 'The shell commands `atom` and `apm` are installed.' - }) + detail: 'The shell commands `atom` and `apm` are installed.' + }, () => {}) }) }) } diff --git a/src/command-registry.js b/src/command-registry.js index 9e6d8c2e1..e503691db 100644 --- a/src/command-registry.js +++ b/src/command-registry.js @@ -309,7 +309,7 @@ module.exports = class CommandRegistry { handleCommandEvent (event) { let propagationStopped = false let immediatePropagationStopped = false - let matched = false + let matched = [] let currentTarget = event.target const dispatchedEvent = new CustomEvent(event.type, { @@ -373,10 +373,6 @@ module.exports = class CommandRegistry { listeners = selectorBasedListeners.concat(listeners) } - if (listeners.length > 0) { - matched = true - } - // Call inline listeners first in reverse registration order, // and selector-based listeners by specificity and reverse // registration order. @@ -385,7 +381,7 @@ module.exports = class CommandRegistry { if (immediatePropagationStopped) { break } - listener.didDispatch.call(currentTarget, dispatchedEvent) + matched.push(listener.didDispatch.call(currentTarget, dispatchedEvent)) } if (currentTarget === window) { @@ -399,7 +395,7 @@ module.exports = class CommandRegistry { this.emitter.emit('did-dispatch', dispatchedEvent) - return matched + return (matched.length > 0 ? Promise.all(matched) : null) } commandRegistered (commandName) { 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/core-uri-handlers.js b/src/core-uri-handlers.js index 2af00f610..b2ddbbc25 100644 --- a/src/core-uri-handlers.js +++ b/src/core-uri-handlers.js @@ -1,9 +1,16 @@ +// Converts a query string parameter for a line or column number +// to a zero-based line or column number for the Atom API. +function getLineColNumber (numStr) { + const num = parseInt(numStr || 0, 10) + return Math.max(num - 1, 0) +} + function openFile (atom, {query}) { const {filename, line, column} = query atom.workspace.open(filename, { - initialLine: parseInt(line || 0, 10), - initialColumn: parseInt(column || 0, 10), + initialLine: getLineColNumber(line), + initialColumn: getLineColNumber(column), searchAllPanes: true }) } diff --git a/src/cursor.js b/src/cursor.js index 181eeb971..41e47bb75 100644 --- a/src/cursor.js +++ b/src/cursor.js @@ -705,7 +705,7 @@ class Cursor extends Model { */ getNonWordCharacters () { - return this.editor.getNonWordCharacters(this.getScopeDescriptor().getScopesArray()) + return this.editor.getNonWordCharacters(this.getBufferPosition()) } changePosition (options, fn) { diff --git a/src/deserializer-manager.js b/src/deserializer-manager.js index f5f2e6429..a11acc319 100644 --- a/src/deserializer-manager.js +++ b/src/deserializer-manager.js @@ -34,7 +34,7 @@ export default class DeserializerManager { // common approach is to register a *constructor* as the deserializer for its // instances by adding a `.deserialize()` class method. When your method is // called, it will be passed serialized state as the first argument and the - // {Atom} environment object as the second argument, which is useful if you + // {AtomEnvironment} object as the second argument, which is useful if you // wish to avoid referencing the `atom` global. add (...deserializers) { for (let i = 0; i < deserializers.length; i++) { diff --git a/src/dock.js b/src/dock.js index 7f2856800..1ee27d5c7 100644 --- a/src/dock.js +++ b/src/dock.js @@ -327,12 +327,15 @@ module.exports = class Dock { // Include all panels that are closer to the edge than the dock in our calculations. switch (this.location) { case 'right': + if (!this.isVisible()) bounds.left = bounds.right - 2 bounds.right = Number.POSITIVE_INFINITY break case 'bottom': + if (!this.isVisible()) bounds.top = bounds.bottom - 1 bounds.bottom = Number.POSITIVE_INFINITY break case 'left': + if (!this.isVisible()) bounds.right = bounds.left + 2 bounds.left = Number.NEGATIVE_INFINITY break } diff --git a/src/grammar-registry.js b/src/grammar-registry.js index f2994acf1..b316bdbb0 100644 --- a/src/grammar-registry.js +++ b/src/grammar-registry.js @@ -1,28 +1,165 @@ 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 Grim = require('grim') +const {Point, Range} = require('text-buffer') -const PathSplitRegex = new RegExp('[/.]') +const GRAMMAR_TYPE_BONUS = 1000 +const PATH_SPLIT_REGEX = new RegExp('[/.]') -// Extended: Syntax class holding the grammars used for tokenizing. +// Extended: This class holds the grammars used for tokenizing. // // An instance of this class is always available as the `atom.grammars` global. -// -// The Syntax class also contains properties for things such as the -// language-specific comment regexes. See {::getProperty} for more details. module.exports = -class GrammarRegistry extends FirstMate.GrammarRegistry { +class GrammarRegistry { constructor ({config} = {}) { - super({maxTokensPerLine: 100, maxLineLength: 1000}) this.config = config + this.subscriptions = new CompositeDisposable() + this.textmateRegistry = new FirstMate.GrammarRegistry({maxTokensPerLine: 100, maxLineLength: 1000}) + this.clear() + } + + 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) + this.textmateRegistry.onDidUpdateGrammar(grammarAddedOrUpdated) + } + + serialize () { + const languageOverridesByBufferId = {} + this.languageOverridesByBufferId.forEach((languageId, bufferId) => { + languageOverridesByBufferId[bufferId] = languageId + }) + return {languageOverridesByBufferId} + } + + deserialize (params) { + for (const bufferId in params.languageOverridesByBufferId || {}) { + this.languageOverridesByBufferId.set( + bufferId, + params.languageOverridesByBufferId[bufferId] + ) + } } createToken (value, scopes) { return new Token({value, scopes}) } + // Extended: set a {TextBuffer}'s language mode based on its path and content, + // and continue to update its language mode as grammars are added or updated, or + // the buffer's file path changes. + // + // * `buffer` The {TextBuffer} whose language mode will be maintained. + // + // Returns a {Disposable} that can be used to stop updating the buffer's + // language mode. + maintainLanguageMode (buffer) { + this.grammarScoresByBuffer.set(buffer, null) + + const languageOverride = this.languageOverridesByBufferId.get(buffer.id) + if (languageOverride) { + this.assignLanguageMode(buffer, languageOverride) + } else { + this.autoAssignLanguageMode(buffer) + } + + const pathChangeSubscription = buffer.onDidChangePath(() => { + this.grammarScoresByBuffer.delete(buffer) + if (!this.languageOverridesByBufferId.has(buffer.id)) { + this.autoAssignLanguageMode(buffer) + } + }) + + const destroySubscription = buffer.onDidDestroy(() => { + this.grammarScoresByBuffer.delete(buffer) + this.languageOverridesByBufferId.delete(buffer.id) + this.subscriptions.remove(destroySubscription) + this.subscriptions.remove(pathChangeSubscription) + }) + + this.subscriptions.add(pathChangeSubscription, destroySubscription) + + return new Disposable(() => { + destroySubscription.dispose() + pathChangeSubscription.dispose() + this.subscriptions.remove(pathChangeSubscription) + this.subscriptions.remove(destroySubscription) + this.grammarScoresByBuffer.delete(buffer) + this.languageOverridesByBufferId.delete(buffer.id) + }) + } + + // Extended: Force a {TextBuffer} to use a different grammar than the + // one that would otherwise be selected for it. + // + // * `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.grammarForId(languageId) + if (!grammar) return false + this.languageOverridesByBufferId.set(buffer.id, languageId) + } else { + this.languageOverridesByBufferId.set(buffer.id, null) + grammar = this.textmateRegistry.nullGrammar + } + + this.grammarScoresByBuffer.set(buffer, null) + if (grammar.scopeName !== buffer.getLanguageMode().getLanguageId()) { + buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) + } + + return true + } + + // Extended: Remove any language mode override that has been set for the + // given {TextBuffer}. This will assign to the buffer the best language + // mode available. + // + // * `buffer` The {TextBuffer}. + autoAssignLanguageMode (buffer) { + const result = this.selectGrammarWithScore( + buffer.getPath(), + getGrammarSelectionContent(buffer) + ) + this.languageOverridesByBufferId.delete(buffer.id) + this.grammarScoresByBuffer.set(buffer, result.score) + if (result.grammar.scopeName !== buffer.getLanguageMode().getLanguageId()) { + buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(result.grammar, buffer)) + } + } + + languageModeForGrammarAndBuffer (grammar, buffer) { + 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. // // This picks the best match by checking the file path and contents against @@ -39,39 +176,44 @@ class GrammarRegistry extends FirstMate.GrammarRegistry { selectGrammarWithScore (filePath, fileContents) { let bestMatch = null let highestScore = -Infinity - for (let grammar of this.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(PathSplitRegex) - let pathScore = -1 + const pathComponents = filePath.toLowerCase().split(PATH_SPLIT_REGEX) + let pathScore = 0 let customFileTypes if (this.config.get('core.customFileTypes')) { @@ -85,7 +227,7 @@ class GrammarRegistry extends FirstMate.GrammarRegistry { for (let i = 0; i < fileTypes.length; i++) { const fileType = fileTypes[i] - const fileTypeComponents = fileType.toLowerCase().split(PathSplitRegex) + const fileTypeComponents = fileType.toLowerCase().split(PATH_SPLIT_REGEX) const pathSuffix = pathComponents.slice(-fileTypeComponents.length) if (_.isEqual(pathSuffix, fileTypeComponents)) { pathScore = Math.max(pathScore, fileType.length) @@ -99,25 +241,48 @@ class GrammarRegistry extends FirstMate.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. @@ -126,46 +291,228 @@ class GrammarRegistry extends FirstMate.GrammarRegistry { // // Returns a {String} such as `"source.js"`. grammarOverrideForPath (filePath) { - Grim.deprecate('Use atom.textEditors.getGrammarOverride(editor) instead') - - const editor = getEditorForPath(filePath) - if (editor) { - return atom.textEditors.getGrammarOverride(editor) - } + Grim.deprecate('Use buffer.getLanguageMode().getLanguageId() instead') + const buffer = atom.project.findBufferForPath(filePath) + if (buffer) return this.languageOverridesByBufferId.get(buffer.id) } // Deprecated: Set the grammar override for the given file path. // // * `filePath` A non-empty {String} file path. - // * `scopeName` A {String} such as `"source.js"`. + // * `languageId` A {String} such as `"source.js"`. // // Returns undefined. - setGrammarOverrideForPath (filePath, scopeName) { - Grim.deprecate('Use atom.textEditors.setGrammarOverride(editor, scopeName) instead') - - const editor = getEditorForPath(filePath) - if (editor) { - atom.textEditors.setGrammarOverride(editor, scopeName) + setGrammarOverrideForPath (filePath, languageId) { + Grim.deprecate('Use atom.grammars.assignLanguageMode(buffer, languageId) instead') + const buffer = atom.project.findBufferForPath(filePath) + if (buffer) { + const grammar = this.grammarForScopeName(languageId) + if (grammar) this.languageOverridesByBufferId.set(buffer.id, grammar.name) } } - // Deprecated: Remove the grammar override for the given file path. + // Remove the grammar override for the given file path. // // * `filePath` A {String} file path. // // Returns undefined. clearGrammarOverrideForPath (filePath) { - Grim.deprecate('Use atom.textEditors.clearGrammarOverride(editor) instead') + Grim.deprecate('Use atom.grammars.autoAssignLanguageMode(buffer) instead') + const buffer = atom.project.findBufferForPath(filePath) + if (buffer) this.languageOverridesByBufferId.delete(buffer.id) + } - const editor = getEditorForPath(filePath) - if (editor) { - atom.textEditors.clearGrammarOverride(editor) + grammarAddedOrUpdated (grammar) { + if (grammar.scopeName && !grammar.id) grammar.id = grammar.scopeName + + this.grammarScoresByBuffer.forEach((score, buffer) => { + const languageMode = buffer.getLanguageMode() + if (grammar.injectionSelector) { + if (languageMode.hasTokenForSelector(grammar.injectionSelector)) { + languageMode.retokenizeLines() + } + return + } + + const languageOverride = this.languageOverridesByBufferId.get(buffer.id) + + 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(), getGrammarSelectionContent(buffer)) + const currentScore = this.grammarScoresByBuffer.get(buffer) + if (currentScore == null || score > currentScore) { + buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) + this.grammarScoresByBuffer.set(buffer, score) + } + } + }) + } + + // Extended: Invoke the given callback when a grammar is added to the registry. + // + // * `callback` {Function} to call when a grammar is added. + // * `grammar` {Grammar} that was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddGrammar (callback) { + return this.textmateRegistry.onDidAddGrammar(callback) + } + + // Extended: Invoke the given callback when a grammar is updated due to a grammar + // it depends on being added or removed from the registry. + // + // * `callback` {Function} to call when a grammar is updated. + // * `grammar` {Grammar} that was updated. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidUpdateGrammar (callback) { + return this.textmateRegistry.onDidUpdateGrammar(callback) + } + + get nullGrammar () { + return this.textmateRegistry.nullGrammar + } + + get grammars () { + return this.textmateRegistry.grammars + } + + decodeTokens () { + return this.textmateRegistry.decodeTokens.apply(this.textmateRegistry, arguments) + } + + grammarForScopeName (scopeName) { + return this.grammarForId(scopeName) + } + + 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) { + 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) { + return this.textmateRegistry.removeGrammarForScopeName(scopeName) + } + + // Extended: Read a grammar asynchronously and add it to the registry. + // + // * `grammarPath` A {String} absolute file path to a grammar file. + // * `callback` A {Function} to call when loaded with the following arguments: + // * `error` An {Error}, may be null. + // * `grammar` A {Grammar} or null if an error occured. + 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. + // + // * `grammarPath` A {String} absolute file path to a grammar file. + // + // Returns a {Grammar}. + 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. + // + // * `grammarPath` A {String} absolute file path to a grammar file. + // * `callback` A {Function} to call when read with the following arguments: + // * `error` An {Error}, may be null. + // * `grammar` A {Grammar} or null if an error occured. + // + // Returns undefined. + 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. + // + // * `grammarPath` A {String} absolute file path to a grammar file. + // + // Returns a {Grammar}. + readGrammarSync (grammarPath) { + return this.createGrammar(grammarPath, CSON.readFileSync(grammarPath) || {}) + } + + 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. + // + // Returns a non-empty {Array} of {Grammar} instances. + getGrammars () { + return this.textmateRegistry.getGrammars() + } + + 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 getEditorForPath (filePath) { - if (filePath != null) { - return atom.workspace.getTextEditors().find(editor => editor.getPath() === filePath) - } +function getGrammarSelectionContent (buffer) { + return buffer.getTextInRange(Range( + Point(0, 0), + buffer.positionForCharacterIndex(1024) + )) } diff --git a/src/history-manager.js b/src/history-manager.js index a8ddbaae9..306c11812 100644 --- a/src/history-manager.js +++ b/src/history-manager.js @@ -50,8 +50,8 @@ export class HistoryManager { return this.emitter.on('did-change-projects', callback) } - didChangeProjects (args) { - this.emitter.emit('did-change-projects', args || { reloaded: false }) + didChangeProjects (args = {reloaded: false}) { + this.emitter.emit('did-change-projects', args) } async addProject (paths, lastOpened) { @@ -93,7 +93,7 @@ export class HistoryManager { } async loadState () { - let history = await this.stateStore.load('history-manager') + const history = await this.stateStore.load('history-manager') if (history && history.projects) { this.projects = history.projects.filter(p => Array.isArray(p.paths) && p.paths.length > 0).map(p => new HistoryProject(p.paths, new Date(p.lastOpened))) this.didChangeProjects({reloaded: true}) diff --git a/src/initialize-application-window.coffee b/src/initialize-application-window.coffee index 0c4c0a391..f8f670cf5 100644 --- a/src/initialize-application-window.coffee +++ b/src/initialize-application-window.coffee @@ -67,6 +67,7 @@ global.atom = new AtomEnvironment({ enablePersistence: true }) +TextEditor.setScheduler(global.atom.views) global.atom.preloadPackages() # Like sands through the hourglass, so are the days of our lives. diff --git a/src/initialize-test-window.coffee b/src/initialize-test-window.coffee index 5ad10670a..c6aaada0e 100644 --- a/src/initialize-test-window.coffee +++ b/src/initialize-test-window.coffee @@ -82,6 +82,7 @@ module.exports = ({blobStore}) -> params.onlyLoadBaseStyleSheets = true unless params.hasOwnProperty("onlyLoadBaseStyleSheets") atomEnvironment = new AtomEnvironment(params) atomEnvironment.initialize(params) + TextEditor.setScheduler(atomEnvironment.views) atomEnvironment promise = testRunner({ 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 ca3995c05..000000000 --- a/src/main-process/atom-window.coffee +++ /dev/null @@ -1,323 +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: (params) -> - params = Object.assign({ - title: 'Save File', - defaultPath: @representedDirectoryPaths[0] - }, params) - dialog.showSaveDialog(@browserWindow, params) - - 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/main-process/context-menu.coffee b/src/main-process/context-menu.coffee index 1bc9c29ba..ce1faf82d 100644 --- a/src/main-process/context-menu.coffee +++ b/src/main-process/context-menu.coffee @@ -5,7 +5,7 @@ class ContextMenu constructor: (template, @atomWindow) -> template = @createClickHandlers(template) menu = Menu.buildFromTemplate(template) - menu.popup(@atomWindow.browserWindow) + menu.popup(@atomWindow.browserWindow, {async: true}) # It's necessary to build the event handlers in this process, otherwise # closures are dragged across processes and failed to be garbage collected 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/package.coffee b/src/package.coffee deleted file mode 100644 index 1635c75dc..000000000 --- a/src/package.coffee +++ /dev/null @@ -1,848 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -async = require 'async' -CSON = require 'season' -fs = require 'fs-plus' -{Emitter, CompositeDisposable} = require 'event-kit' - -CompileCache = require './compile-cache' -ModuleCache = require './module-cache' -ScopedProperties = require './scoped-properties' -BufferedProcess = require './buffered-process' - -# Extended: Loads and activates a package's main module and resources such as -# stylesheets, keymaps, grammar, editor properties, and menus. -module.exports = -class Package - keymaps: null - menus: null - stylesheets: null - stylesheetDisposables: null - grammars: null - settings: null - mainModulePath: null - resolvedMainModulePath: false - mainModule: null - mainInitialized: false - mainActivated: false - - ### - Section: Construction - ### - - constructor: (params) -> - { - @path, @metadata, @bundledPackage, @preloadedPackage, @packageManager, @config, @styleManager, @commandRegistry, - @keymapManager, @notificationManager, @grammarRegistry, @themeManager, - @menuManager, @contextMenuManager, @deserializerManager, @viewRegistry - } = params - - @emitter = new Emitter - @metadata ?= @packageManager.loadPackageMetadata(@path) - @bundledPackage ?= @packageManager.isBundledPackagePath(@path) - @name = @metadata?.name ? params.name ? path.basename(@path) - @reset() - - ### - Section: Event Subscription - ### - - # Essential: Invoke the given callback when all packages have been activated. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDeactivate: (callback) -> - @emitter.on 'did-deactivate', callback - - ### - Section: Instance Methods - ### - - enable: -> - @config.removeAtKeyPath('core.disabledPackages', @name) - - disable: -> - @config.pushAtKeyPath('core.disabledPackages', @name) - - isTheme: -> - @metadata?.theme? - - measure: (key, fn) -> - startTime = Date.now() - value = fn() - @[key] = Date.now() - startTime - value - - getType: -> 'atom' - - getStyleSheetPriority: -> 0 - - preload: -> - @loadKeymaps() - @loadMenus() - @registerDeserializerMethods() - @activateCoreStartupServices() - @registerURIHandler() - @configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata() - @requireMainModule() - @settingsPromise = @loadSettings() - - @activationDisposables = new CompositeDisposable - @activateKeymaps() - @activateMenus() - settings.activate() for settings in @settings - @settingsActivated = true - - finishLoading: -> - @measure 'loadTime', => - @path = path.join(@packageManager.resourcePath, @path) - ModuleCache.add(@path, @metadata) - - @loadStylesheets() - # Unfortunately some packages are accessing `@mainModulePath`, so we need - # to compute that variable eagerly also for preloaded packages. - @getMainModulePath() - - load: -> - @measure 'loadTime', => - try - ModuleCache.add(@path, @metadata) - - @loadKeymaps() - @loadMenus() - @loadStylesheets() - @registerDeserializerMethods() - @activateCoreStartupServices() - @registerURIHandler() - @registerTranspilerConfig() - @configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata() - @settingsPromise = @loadSettings() - if @shouldRequireMainModuleOnLoad() and not @mainModule? - @requireMainModule() - catch error - @handleError("Failed to load the #{@name} package", error) - this - - unload: -> - @unregisterTranspilerConfig() - - shouldRequireMainModuleOnLoad: -> - not ( - @metadata.deserializers? or - @metadata.viewProviders? or - @metadata.configSchema? or - @activationShouldBeDeferred() or - localStorage.getItem(@getCanDeferMainModuleRequireStorageKey()) is 'true' - ) - - reset: -> - @stylesheets = [] - @keymaps = [] - @menus = [] - @grammars = [] - @settings = [] - @mainInitialized = false - @mainActivated = false - - initializeIfNeeded: -> - return if @mainInitialized - @measure 'initializeTime', => - try - # The main module's `initialize()` method is guaranteed to be called - # before its `activate()`. This gives you a chance to handle the - # serialized package state before the package's derserializers and view - # providers are used. - @requireMainModule() unless @mainModule? - @mainModule.initialize?(@packageManager.getPackageState(@name) ? {}) - @mainInitialized = true - catch error - @handleError("Failed to initialize the #{@name} package", error) - return - - activate: -> - @grammarsPromise ?= @loadGrammars() - @activationPromise ?= - new Promise (resolve, reject) => - @resolveActivationPromise = resolve - @measure 'activateTime', => - try - @activateResources() - if @activationShouldBeDeferred() - @subscribeToDeferredActivation() - else - @activateNow() - catch error - @handleError("Failed to activate the #{@name} package", error) - - Promise.all([@grammarsPromise, @settingsPromise, @activationPromise]) - - activateNow: -> - try - @requireMainModule() unless @mainModule? - @configSchemaRegisteredOnActivate = @registerConfigSchemaFromMainModule() - @registerViewProviders() - @activateStylesheets() - if @mainModule? and not @mainActivated - @initializeIfNeeded() - @mainModule.activateConfig?() - @mainModule.activate?(@packageManager.getPackageState(@name) ? {}) - @mainActivated = true - @activateServices() - @activationCommandSubscriptions?.dispose() - @activationHookSubscriptions?.dispose() - catch error - @handleError("Failed to activate the #{@name} package", error) - - @resolveActivationPromise?() - - registerConfigSchemaFromMetadata: -> - if configSchema = @metadata.configSchema - @config.setSchema @name, {type: 'object', properties: configSchema} - true - else - false - - registerConfigSchemaFromMainModule: -> - if @mainModule? and not @configSchemaRegisteredOnLoad - if @mainModule.config? and typeof @mainModule.config is 'object' - @config.setSchema @name, {type: 'object', properties: @mainModule.config} - return true - false - - # TODO: Remove. Settings view calls this method currently. - activateConfig: -> - return if @configSchemaRegisteredOnLoad - @requireMainModule() - @registerConfigSchemaFromMainModule() - - activateStylesheets: -> - return if @stylesheetsActivated - - @stylesheetDisposables = new CompositeDisposable - - priority = @getStyleSheetPriority() - for [sourcePath, source] in @stylesheets - if match = path.basename(sourcePath).match(/[^.]*\.([^.]*)\./) - context = match[1] - else if @metadata.theme is 'syntax' - context = 'atom-text-editor' - else - context = undefined - - @stylesheetDisposables.add( - @styleManager.addStyleSheet( - source, - { - sourcePath, - priority, - context, - skipDeprecatedSelectorsTransformation: @bundledPackage - } - ) - ) - @stylesheetsActivated = true - - activateResources: -> - @activationDisposables ?= new CompositeDisposable - - keymapIsDisabled = _.include(@config.get("core.packagesWithKeymapsDisabled") ? [], @name) - if keymapIsDisabled - @deactivateKeymaps() - else unless @keymapActivated - @activateKeymaps() - - unless @menusActivated - @activateMenus() - - unless @grammarsActivated - grammar.activate() for grammar in @grammars - @grammarsActivated = true - - unless @settingsActivated - settings.activate() for settings in @settings - @settingsActivated = true - - activateKeymaps: -> - return if @keymapActivated - - @keymapDisposables = new CompositeDisposable() - - validateSelectors = not @preloadedPackage - @keymapDisposables.add(@keymapManager.add(keymapPath, map, 0, validateSelectors)) for [keymapPath, map] in @keymaps - @menuManager.update() - - @keymapActivated = true - - deactivateKeymaps: -> - return if not @keymapActivated - - @keymapDisposables?.dispose() - @menuManager.update() - - @keymapActivated = false - - hasKeymaps: -> - for [path, map] in @keymaps - if map.length > 0 - return true - false - - activateMenus: -> - validateSelectors = not @preloadedPackage - for [menuPath, map] in @menus when map['context-menu']? - try - itemsBySelector = map['context-menu'] - @activationDisposables.add(@contextMenuManager.add(itemsBySelector, validateSelectors)) - catch error - if error.code is 'EBADSELECTOR' - error.message += " in #{menuPath}" - error.stack += "\n at #{menuPath}:1:1" - throw error - - for [menuPath, map] in @menus when map['menu']? - @activationDisposables.add(@menuManager.add(map['menu'])) - - @menusActivated = true - - activateServices: -> - for name, {versions} of @metadata.providedServices - servicesByVersion = {} - for version, methodName of versions - if typeof @mainModule[methodName] is 'function' - servicesByVersion[version] = @mainModule[methodName]() - @activationDisposables.add @packageManager.serviceHub.provide(name, servicesByVersion) - - for name, {versions} of @metadata.consumedServices - for version, methodName of versions - if typeof @mainModule[methodName] is 'function' - @activationDisposables.add @packageManager.serviceHub.consume(name, version, @mainModule[methodName].bind(@mainModule)) - return - - registerURIHandler: -> - handlerConfig = @getURIHandler() - if methodName = handlerConfig?.method - @uriHandlerSubscription = @packageManager.registerURIHandlerForPackage @name, (args...) => - @handleURI(methodName, args) - - unregisterURIHandler: -> - @uriHandlerSubscription?.dispose() - - handleURI: (methodName, args) -> - @activate().then => @mainModule[methodName]?.apply(@mainModule, args) - @activateNow() unless @mainActivated - - registerTranspilerConfig: -> - if @metadata.atomTranspilers - CompileCache.addTranspilerConfigForPath(@path, @name, @metadata, @metadata.atomTranspilers) - - unregisterTranspilerConfig: -> - if @metadata.atomTranspilers - CompileCache.removeTranspilerConfigForPath(@path) - - loadKeymaps: -> - if @bundledPackage and @packageManager.packagesCache[@name]? - @keymaps = (["core:#{keymapPath}", keymapObject] for keymapPath, keymapObject of @packageManager.packagesCache[@name].keymaps) - else - @keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, CSON.readFileSync(keymapPath, allowDuplicateKeys: false) ? {}] - return - - loadMenus: -> - if @bundledPackage and @packageManager.packagesCache[@name]? - @menus = (["core:#{menuPath}", menuObject] for menuPath, menuObject of @packageManager.packagesCache[@name].menus) - else - @menus = @getMenuPaths().map (menuPath) -> [menuPath, CSON.readFileSync(menuPath) ? {}] - return - - getKeymapPaths: -> - keymapsDirPath = path.join(@path, 'keymaps') - if @metadata.keymaps - @metadata.keymaps.map (name) -> fs.resolve(keymapsDirPath, name, ['json', 'cson', '']) - else - fs.listSync(keymapsDirPath, ['cson', 'json']) - - getMenuPaths: -> - menusDirPath = path.join(@path, 'menus') - if @metadata.menus - @metadata.menus.map (name) -> fs.resolve(menusDirPath, name, ['json', 'cson', '']) - else - fs.listSync(menusDirPath, ['cson', 'json']) - - loadStylesheets: -> - @stylesheets = @getStylesheetPaths().map (stylesheetPath) => - [stylesheetPath, @themeManager.loadStylesheet(stylesheetPath, true)] - - registerDeserializerMethods: -> - if @metadata.deserializers? - Object.keys(@metadata.deserializers).forEach (deserializerName) => - methodName = @metadata.deserializers[deserializerName] - @deserializerManager.add - name: deserializerName, - deserialize: (state, atomEnvironment) => - @registerViewProviders() - @requireMainModule() - @initializeIfNeeded() - @mainModule[methodName](state, atomEnvironment) - return - - activateCoreStartupServices: -> - if directoryProviderService = @metadata.providedServices?['atom.directory-provider'] - @requireMainModule() - servicesByVersion = {} - for version, methodName of directoryProviderService.versions - if typeof @mainModule[methodName] is 'function' - servicesByVersion[version] = @mainModule[methodName]() - @packageManager.serviceHub.provide('atom.directory-provider', servicesByVersion) - - registerViewProviders: -> - if @metadata.viewProviders? and not @registeredViewProviders - @requireMainModule() - @metadata.viewProviders.forEach (methodName) => - @viewRegistry.addViewProvider (model) => - @initializeIfNeeded() - @mainModule[methodName](model) - @registeredViewProviders = true - - getStylesheetsPath: -> - path.join(@path, 'styles') - - getStylesheetPaths: -> - if @bundledPackage and @packageManager.packagesCache[@name]?.styleSheetPaths? - styleSheetPaths = @packageManager.packagesCache[@name].styleSheetPaths - styleSheetPaths.map (styleSheetPath) => path.join(@path, styleSheetPath) - else - stylesheetDirPath = @getStylesheetsPath() - if @metadata.mainStyleSheet - [fs.resolve(@path, @metadata.mainStyleSheet)] - else if @metadata.styleSheets - @metadata.styleSheets.map (name) -> fs.resolve(stylesheetDirPath, name, ['css', 'less', '']) - else if indexStylesheet = fs.resolve(@path, 'index', ['css', 'less']) - [indexStylesheet] - else - fs.listSync(stylesheetDirPath, ['css', 'less']) - - loadGrammarsSync: -> - return if @grammarsLoaded - - if @preloadedPackage and @packageManager.packagesCache[@name]? - grammarPaths = @packageManager.packagesCache[@name].grammarPaths - else - grammarPaths = fs.listSync(path.join(@path, 'grammars'), ['json', 'cson']) - - for grammarPath in grammarPaths - if @preloadedPackage and @packageManager.packagesCache[@name]? - grammarPath = path.resolve(@packageManager.resourcePath, grammarPath) - - try - grammar = @grammarRegistry.readGrammarSync(grammarPath) - grammar.packageName = @name - grammar.bundledPackage = @bundledPackage - @grammars.push(grammar) - grammar.activate() - catch error - console.warn("Failed to load grammar: #{grammarPath}", error.stack ? error) - - @grammarsLoaded = true - @grammarsActivated = true - - loadGrammars: -> - return Promise.resolve() if @grammarsLoaded - - loadGrammar = (grammarPath, callback) => - if @preloadedPackage - grammarPath = path.resolve(@packageManager.resourcePath, grammarPath) - - @grammarRegistry.readGrammar grammarPath, (error, grammar) => - if error? - detail = "#{error.message} in #{grammarPath}" - stack = "#{error.stack}\n at #{grammarPath}:1:1" - @notificationManager.addFatalError("Failed to load a #{@name} package grammar", {stack, detail, packageName: @name, dismissable: true}) - else - grammar.packageName = @name - grammar.bundledPackage = @bundledPackage - @grammars.push(grammar) - grammar.activate() if @grammarsActivated - callback() - - new Promise (resolve) => - if @preloadedPackage and @packageManager.packagesCache[@name]? - grammarPaths = @packageManager.packagesCache[@name].grammarPaths - async.each grammarPaths, loadGrammar, -> resolve() - else - grammarsDirPath = path.join(@path, 'grammars') - fs.exists grammarsDirPath, (grammarsDirExists) -> - return resolve() unless grammarsDirExists - - fs.list grammarsDirPath, ['json', 'cson'], (error, grammarPaths=[]) -> - async.each grammarPaths, loadGrammar, -> resolve() - - loadSettings: -> - @settings = [] - - loadSettingsFile = (settingsPath, callback) => - ScopedProperties.load settingsPath, @config, (error, settings) => - if error? - detail = "#{error.message} in #{settingsPath}" - stack = "#{error.stack}\n at #{settingsPath}:1:1" - @notificationManager.addFatalError("Failed to load the #{@name} package settings", {stack, detail, packageName: @name, dismissable: true}) - else - @settings.push(settings) - settings.activate() if @settingsActivated - callback() - - new Promise (resolve) => - if @preloadedPackage and @packageManager.packagesCache[@name]? - for settingsPath, scopedProperties of @packageManager.packagesCache[@name].settings - settings = new ScopedProperties("core:#{settingsPath}", scopedProperties ? {}, @config) - @settings.push(settings) - settings.activate() if @settingsActivated - resolve() - else - settingsDirPath = path.join(@path, 'settings') - fs.exists settingsDirPath, (settingsDirExists) -> - return resolve() unless settingsDirExists - - fs.list settingsDirPath, ['json', 'cson'], (error, settingsPaths=[]) -> - async.each settingsPaths, loadSettingsFile, -> resolve() - - serialize: -> - if @mainActivated - try - @mainModule?.serialize?() - catch e - console.error "Error serializing package '#{@name}'", e.stack - - deactivate: -> - @activationPromise = null - @resolveActivationPromise = null - @activationCommandSubscriptions?.dispose() - @activationHookSubscriptions?.dispose() - @configSchemaRegisteredOnActivate = false - @unregisterURIHandler() - @deactivateResources() - @deactivateKeymaps() - - unless @mainActivated - @emitter.emit 'did-deactivate' - return - - try - deactivationResult = @mainModule?.deactivate?() - catch e - console.error "Error deactivating package '#{@name}'", e.stack - - # We support then-able async promises as well as sync ones from deactivate - if typeof deactivationResult?.then is 'function' - deactivationResult.then => @afterDeactivation() - else - @afterDeactivation() - - afterDeactivation: -> - try - @mainModule?.deactivateConfig?() - catch e - console.error "Error deactivating package '#{@name}'", e.stack - @mainActivated = false - @mainInitialized = false - @emitter.emit 'did-deactivate' - - deactivateResources: -> - grammar.deactivate() for grammar in @grammars - settings.deactivate() for settings in @settings - @stylesheetDisposables?.dispose() - @activationDisposables?.dispose() - @keymapDisposables?.dispose() - @stylesheetsActivated = false - @grammarsActivated = false - @settingsActivated = false - @menusActivated = false - - reloadStylesheets: -> - try - @loadStylesheets() - catch error - @handleError("Failed to reload the #{@name} package stylesheets", error) - - @stylesheetDisposables?.dispose() - @stylesheetDisposables = new CompositeDisposable - @stylesheetsActivated = false - @activateStylesheets() - - requireMainModule: -> - if @bundledPackage and @packageManager.packagesCache[@name]? - if @packageManager.packagesCache[@name].main? - @mainModule = require(@packageManager.packagesCache[@name].main) - else if @mainModuleRequired - @mainModule - else if not @isCompatible() - console.warn """ - Failed to require the main module of '#{@name}' because it requires one or more incompatible native modules (#{_.pluck(@incompatibleModules, 'name').join(', ')}). - Run `apm rebuild` in the package directory and restart Atom to resolve. - """ - return - else - mainModulePath = @getMainModulePath() - if fs.isFileSync(mainModulePath) - @mainModuleRequired = true - - previousViewProviderCount = @viewRegistry.getViewProviderCount() - previousDeserializerCount = @deserializerManager.getDeserializerCount() - @mainModule = require(mainModulePath) - if (@viewRegistry.getViewProviderCount() is previousViewProviderCount and - @deserializerManager.getDeserializerCount() is previousDeserializerCount) - localStorage.setItem(@getCanDeferMainModuleRequireStorageKey(), 'true') - - getMainModulePath: -> - return @mainModulePath if @resolvedMainModulePath - @resolvedMainModulePath = true - - if @bundledPackage and @packageManager.packagesCache[@name]? - if @packageManager.packagesCache[@name].main - @mainModulePath = path.resolve(@packageManager.resourcePath, 'static', @packageManager.packagesCache[@name].main) - else - @mainModulePath = null - else - mainModulePath = - if @metadata.main - path.join(@path, @metadata.main) - else - path.join(@path, 'index') - @mainModulePath = fs.resolveExtension(mainModulePath, ["", CompileCache.supportedExtensions...]) - - activationShouldBeDeferred: -> - @hasActivationCommands() or @hasActivationHooks() or @hasDeferredURIHandler() - - hasActivationHooks: -> - @getActivationHooks()?.length > 0 - - hasActivationCommands: -> - for selector, commands of @getActivationCommands() - return true if commands.length > 0 - false - - hasDeferredURIHandler: -> - @getURIHandler() and @getURIHandler().deferActivation isnt false - - subscribeToDeferredActivation: -> - @subscribeToActivationCommands() - @subscribeToActivationHooks() - - subscribeToActivationCommands: -> - @activationCommandSubscriptions = new CompositeDisposable - for selector, commands of @getActivationCommands() - for command in commands - do (selector, command) => - # Add dummy command so it appears in menu. - # The real command will be registered on package activation - try - @activationCommandSubscriptions.add @commandRegistry.add selector, command, -> - catch error - if error.code is 'EBADSELECTOR' - metadataPath = path.join(@path, 'package.json') - error.message += " in #{metadataPath}" - error.stack += "\n at #{metadataPath}:1:1" - throw error - - @activationCommandSubscriptions.add @commandRegistry.onWillDispatch (event) => - return unless event.type is command - currentTarget = event.target - while currentTarget - if currentTarget.webkitMatchesSelector(selector) - @activationCommandSubscriptions.dispose() - @activateNow() - break - currentTarget = currentTarget.parentElement - return - return - - getActivationCommands: -> - return @activationCommands if @activationCommands? - - @activationCommands = {} - - if @metadata.activationCommands? - for selector, commands of @metadata.activationCommands - @activationCommands[selector] ?= [] - if _.isString(commands) - @activationCommands[selector].push(commands) - else if _.isArray(commands) - @activationCommands[selector].push(commands...) - - @activationCommands - - subscribeToActivationHooks: -> - @activationHookSubscriptions = new CompositeDisposable - for hook in @getActivationHooks() - do (hook) => - @activationHookSubscriptions.add(@packageManager.onDidTriggerActivationHook(hook, => @activateNow())) if hook? and _.isString(hook) and hook.trim().length > 0 - - return - - getActivationHooks: -> - return @activationHooks if @metadata? and @activationHooks? - - @activationHooks = [] - - if @metadata.activationHooks? - if _.isArray(@metadata.activationHooks) - @activationHooks.push(@metadata.activationHooks...) - else if _.isString(@metadata.activationHooks) - @activationHooks.push(@metadata.activationHooks) - - @activationHooks = _.uniq(@activationHooks) - - getURIHandler: -> - @metadata?.uriHandler - - # Does the given module path contain native code? - isNativeModule: (modulePath) -> - try - fs.listSync(path.join(modulePath, 'build', 'Release'), ['.node']).length > 0 - catch error - false - - # Get an array of all the native modules that this package depends on. - # - # First try to get this information from - # @metadata._atomModuleCache.extensions. If @metadata._atomModuleCache doesn't - # exist, recurse through all dependencies. - getNativeModuleDependencyPaths: -> - nativeModulePaths = [] - - if @metadata._atomModuleCache? - relativeNativeModuleBindingPaths = @metadata._atomModuleCache.extensions?['.node'] ? [] - for relativeNativeModuleBindingPath in relativeNativeModuleBindingPaths - nativeModulePath = path.join(@path, relativeNativeModuleBindingPath, '..', '..', '..') - nativeModulePaths.push(nativeModulePath) - return nativeModulePaths - - traversePath = (nodeModulesPath) => - try - for modulePath in fs.listSync(nodeModulesPath) - nativeModulePaths.push(modulePath) if @isNativeModule(modulePath) - traversePath(path.join(modulePath, 'node_modules')) - return - - traversePath(path.join(@path, 'node_modules')) - nativeModulePaths - - ### - Section: Native Module Compatibility - ### - - # Extended: Are all native modules depended on by this package correctly - # compiled against the current version of Atom? - # - # Incompatible packages cannot be activated. - # - # Returns a {Boolean}, true if compatible, false if incompatible. - isCompatible: -> - return @compatible if @compatible? - - if @preloadedPackage - # Preloaded packages are always considered compatible - @compatible = true - else if @getMainModulePath() - @incompatibleModules = @getIncompatibleNativeModules() - @compatible = @incompatibleModules.length is 0 and not @getBuildFailureOutput()? - else - @compatible = true - - # Extended: Rebuild native modules in this package's dependencies for the - # current version of Atom. - # - # Returns a {Promise} that resolves with an object containing `code`, - # `stdout`, and `stderr` properties based on the results of running - # `apm rebuild` on the package. - rebuild: -> - new Promise (resolve) => - @runRebuildProcess (result) => - if result.code is 0 - global.localStorage.removeItem(@getBuildFailureOutputStorageKey()) - else - @compatible = false - global.localStorage.setItem(@getBuildFailureOutputStorageKey(), result.stderr) - global.localStorage.setItem(@getIncompatibleNativeModulesStorageKey(), '[]') - resolve(result) - - # Extended: If a previous rebuild failed, get the contents of stderr. - # - # Returns a {String} or null if no previous build failure occurred. - getBuildFailureOutput: -> - global.localStorage.getItem(@getBuildFailureOutputStorageKey()) - - runRebuildProcess: (callback) -> - stderr = '' - stdout = '' - new BufferedProcess({ - command: @packageManager.getApmPath() - args: ['rebuild', '--no-color'] - options: {cwd: @path} - stderr: (output) -> stderr += output - stdout: (output) -> stdout += output - exit: (code) -> callback({code, stdout, stderr}) - }) - - getBuildFailureOutputStorageKey: -> - "installed-packages:#{@name}:#{@metadata.version}:build-error" - - getIncompatibleNativeModulesStorageKey: -> - electronVersion = process.versions.electron - "installed-packages:#{@name}:#{@metadata.version}:electron-#{electronVersion}:incompatible-native-modules" - - getCanDeferMainModuleRequireStorageKey: -> - "installed-packages:#{@name}:#{@metadata.version}:can-defer-main-module-require" - - # Get the incompatible native modules that this package depends on. - # This recurses through all dependencies and requires all modules that - # contain a `.node` file. - # - # This information is cached in local storage on a per package/version basis - # to minimize the impact on startup time. - getIncompatibleNativeModules: -> - unless @packageManager.devMode - try - if arrayAsString = global.localStorage.getItem(@getIncompatibleNativeModulesStorageKey()) - return JSON.parse(arrayAsString) - - incompatibleNativeModules = [] - for nativeModulePath in @getNativeModuleDependencyPaths() - try - require(nativeModulePath) - catch error - try - version = require("#{nativeModulePath}/package.json").version - incompatibleNativeModules.push - path: nativeModulePath - name: path.basename(nativeModulePath) - version: version - error: error.message - - global.localStorage.setItem(@getIncompatibleNativeModulesStorageKey(), JSON.stringify(incompatibleNativeModules)) - incompatibleNativeModules - - handleError: (message, error) -> - if atom.inSpecMode() - throw error - - if error.filename and error.location and (error instanceof SyntaxError) - location = "#{error.filename}:#{error.location.first_line + 1}:#{error.location.first_column + 1}" - detail = "#{error.message} in #{location}" - stack = """ - SyntaxError: #{error.message} - at #{location} - """ - else if error.less and error.filename and error.column? and error.line? - # Less errors - location = "#{error.filename}:#{error.line}:#{error.column}" - detail = "#{error.message} in #{location}" - stack = """ - LessError: #{error.message} - at #{location} - """ - else - detail = error.message - stack = error.stack ? error - - @notificationManager.addFatalError(message, {stack, detail, packageName: @name, dismissable: true}) diff --git a/src/package.js b/src/package.js new file mode 100644 index 000000000..8d5cbc3ca --- /dev/null +++ b/src/package.js @@ -0,0 +1,1107 @@ +const path = require('path') +const async = require('async') +const CSON = require('season') +const fs = require('fs-plus') +const {Emitter, CompositeDisposable} = require('event-kit') +const dedent = require('dedent') + +const CompileCache = require('./compile-cache') +const ModuleCache = require('./module-cache') +const ScopedProperties = require('./scoped-properties') +const BufferedProcess = require('./buffered-process') + +// Extended: Loads and activates a package's main module and resources such as +// stylesheets, keymaps, grammar, editor properties, and menus. +module.exports = +class Package { + /* + Section: Construction + */ + + constructor (params) { + this.config = params.config + this.packageManager = params.packageManager + this.styleManager = params.styleManager + this.commandRegistry = params.commandRegistry + this.keymapManager = params.keymapManager + this.notificationManager = params.notificationManager + this.grammarRegistry = params.grammarRegistry + this.themeManager = params.themeManager + this.menuManager = params.menuManager + this.contextMenuManager = params.contextMenuManager + this.deserializerManager = params.deserializerManager + this.viewRegistry = params.viewRegistry + this.emitter = new Emitter() + + this.mainModule = null + this.path = params.path + this.preloadedPackage = params.preloadedPackage + this.metadata = + params.metadata || + this.packageManager.loadPackageMetadata(this.path) + this.bundledPackage = params.bundledPackage != null + ? params.bundledPackage + : this.packageManager.isBundledPackagePath(this.path) + this.name = + params.name || + (this.metadata && this.metadata.name) || + path.basename(this.path) + this.reset() + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback when all packages have been activated. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDeactivate (callback) { + return this.emitter.on('did-deactivate', callback) + } + + /* + Section: Instance Methods + */ + + enable () { + return this.config.removeAtKeyPath('core.disabledPackages', this.name) + } + + disable () { + return this.config.pushAtKeyPath('core.disabledPackages', this.name) + } + + isTheme () { + return this.metadata && this.metadata.theme + } + + measure (key, fn) { + const startTime = Date.now() + const value = fn() + this[key] = Date.now() - startTime + return value + } + + getType () { return 'atom' } + + getStyleSheetPriority () { return 0 } + + preload () { + this.loadKeymaps() + this.loadMenus() + this.registerDeserializerMethods() + this.activateCoreStartupServices() + this.registerURIHandler() + this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata() + this.requireMainModule() + this.settingsPromise = this.loadSettings() + + this.activationDisposables = new CompositeDisposable() + this.activateKeymaps() + this.activateMenus() + for (let settings of this.settings) { + settings.activate() + } + this.settingsActivated = true + } + + finishLoading () { + this.measure('loadTime', () => { + this.path = path.join(this.packageManager.resourcePath, this.path) + ModuleCache.add(this.path, this.metadata) + + this.loadStylesheets() + // Unfortunately some packages are accessing `@mainModulePath`, so we need + // to compute that variable eagerly also for preloaded packages. + this.getMainModulePath() + }) + } + + load () { + this.measure('loadTime', () => { + try { + ModuleCache.add(this.path, this.metadata) + + this.loadKeymaps() + this.loadMenus() + this.loadStylesheets() + this.registerDeserializerMethods() + this.activateCoreStartupServices() + this.registerURIHandler() + this.registerTranspilerConfig() + this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata() + this.settingsPromise = this.loadSettings() + if (this.shouldRequireMainModuleOnLoad() && (this.mainModule == null)) { + this.requireMainModule() + } + } catch (error) { + this.handleError(`Failed to load the ${this.name} package`, error) + } + }) + return this + } + + unload () { + this.unregisterTranspilerConfig() + } + + shouldRequireMainModuleOnLoad () { + return !( + this.metadata.deserializers || + this.metadata.viewProviders || + this.metadata.configSchema || + this.activationShouldBeDeferred() || + localStorage.getItem(this.getCanDeferMainModuleRequireStorageKey()) === 'true' + ) + } + + reset () { + this.stylesheets = [] + this.keymaps = [] + this.menus = [] + this.grammars = [] + this.settings = [] + this.mainInitialized = false + this.mainActivated = false + } + + initializeIfNeeded () { + if (this.mainInitialized) return + this.measure('initializeTime', () => { + try { + // The main module's `initialize()` method is guaranteed to be called + // before its `activate()`. This gives you a chance to handle the + // serialized package state before the package's derserializers and view + // providers are used. + if (!this.mainModule) this.requireMainModule() + if (typeof this.mainModule.initialize === 'function') { + this.mainModule.initialize(this.packageManager.getPackageState(this.name) || {}) + } + this.mainInitialized = true + } catch (error) { + this.handleError(`Failed to initialize the ${this.name} package`, error) + } + }) + } + + activate () { + if (!this.grammarsPromise) this.grammarsPromise = this.loadGrammars() + if (!this.activationPromise) { + this.activationPromise = new Promise((resolve, reject) => { + this.resolveActivationPromise = resolve + this.measure('activateTime', () => { + try { + this.activateResources() + if (this.activationShouldBeDeferred()) { + return this.subscribeToDeferredActivation() + } else { + return this.activateNow() + } + } catch (error) { + return this.handleError(`Failed to activate the ${this.name} package`, error) + } + }) + }) + } + + return Promise.all([this.grammarsPromise, this.settingsPromise, this.activationPromise]) + } + + activateNow () { + try { + if (!this.mainModule) this.requireMainModule() + this.configSchemaRegisteredOnActivate = this.registerConfigSchemaFromMainModule() + this.registerViewProviders() + this.activateStylesheets() + if (this.mainModule && !this.mainActivated) { + this.initializeIfNeeded() + if (typeof this.mainModule.activateConfig === 'function') { + this.mainModule.activateConfig() + } + if (typeof this.mainModule.activate === 'function') { + this.mainModule.activate(this.packageManager.getPackageState(this.name) || {}) + } + this.mainActivated = true + this.activateServices() + } + if (this.activationCommandSubscriptions) this.activationCommandSubscriptions.dispose() + if (this.activationHookSubscriptions) this.activationHookSubscriptions.dispose() + } catch (error) { + this.handleError(`Failed to activate the ${this.name} package`, error) + } + + if (typeof this.resolveActivationPromise === 'function') this.resolveActivationPromise() + } + + registerConfigSchemaFromMetadata () { + const configSchema = this.metadata.configSchema + if (configSchema) { + this.config.setSchema(this.name, {type: 'object', properties: configSchema}) + return true + } else { + return false + } + } + + registerConfigSchemaFromMainModule () { + if (this.mainModule && !this.configSchemaRegisteredOnLoad) { + if (typeof this.mainModule.config === 'object') { + this.config.setSchema(this.name, {type: 'object', properties: this.mainModule.config}) + return true + } + } + return false + } + + // TODO: Remove. Settings view calls this method currently. + activateConfig () { + if (this.configSchemaRegisteredOnLoad) return + this.requireMainModule() + this.registerConfigSchemaFromMainModule() + } + + activateStylesheets () { + if (this.stylesheetsActivated) return + + this.stylesheetDisposables = new CompositeDisposable() + + const priority = this.getStyleSheetPriority() + for (let [sourcePath, source] of this.stylesheets) { + const match = path.basename(sourcePath).match(/[^.]*\.([^.]*)\./) + + let context + if (match) { + context = match[1] + } else if (this.metadata.theme === 'syntax') { + context = 'atom-text-editor' + } + + this.stylesheetDisposables.add( + this.styleManager.addStyleSheet( + source, + { + sourcePath, + priority, + context, + skipDeprecatedSelectorsTransformation: this.bundledPackage + } + ) + ) + } + + this.stylesheetsActivated = true + } + + activateResources () { + if (!this.activationDisposables) this.activationDisposables = new CompositeDisposable() + + const packagesWithKeymapsDisabled = this.config.get('core.packagesWithKeymapsDisabled') + if (packagesWithKeymapsDisabled && packagesWithKeymapsDisabled.includes(this.name)) { + this.deactivateKeymaps() + } else if (!this.keymapActivated) { + this.activateKeymaps() + } + + if (!this.menusActivated) { + this.activateMenus() + } + + if (!this.grammarsActivated) { + for (let grammar of this.grammars) { + grammar.activate() + } + this.grammarsActivated = true + } + + if (!this.settingsActivated) { + for (let settings of this.settings) { + settings.activate() + } + this.settingsActivated = true + } + } + + activateKeymaps () { + if (this.keymapActivated) return + + this.keymapDisposables = new CompositeDisposable() + + const validateSelectors = !this.preloadedPackage + for (let [keymapPath, map] of this.keymaps) { + this.keymapDisposables.add(this.keymapManager.add(keymapPath, map, 0, validateSelectors)) + } + this.menuManager.update() + + this.keymapActivated = true + } + + deactivateKeymaps () { + if (!this.keymapActivated) return + if (this.keymapDisposables) { + this.keymapDisposables.dispose() + } + this.menuManager.update() + this.keymapActivated = false + } + + hasKeymaps () { + for (let [, map] of this.keymaps) { + if (map.length > 0) return true + } + return false + } + + activateMenus () { + const validateSelectors = !this.preloadedPackage + for (const [menuPath, map] of this.menus) { + if (map['context-menu']) { + try { + const itemsBySelector = map['context-menu'] + this.activationDisposables.add(this.contextMenuManager.add(itemsBySelector, validateSelectors)) + } catch (error) { + if (error.code === 'EBADSELECTOR') { + error.message += ` in ${menuPath}` + error.stack += `\n at ${menuPath}:1:1` + } + throw error + } + } + } + + for (const [, map] of this.menus) { + if (map.menu) this.activationDisposables.add(this.menuManager.add(map.menu)) + } + + this.menusActivated = true + } + + activateServices () { + let methodName, version, versions + for (var name in this.metadata.providedServices) { + ({versions} = this.metadata.providedServices[name]) + const servicesByVersion = {} + for (version in versions) { + methodName = versions[version] + if (typeof this.mainModule[methodName] === 'function') { + servicesByVersion[version] = this.mainModule[methodName]() + } + } + this.activationDisposables.add(this.packageManager.serviceHub.provide(name, servicesByVersion)) + } + + for (name in this.metadata.consumedServices) { + ({versions} = this.metadata.consumedServices[name]) + for (version in versions) { + methodName = versions[version] + if (typeof this.mainModule[methodName] === 'function') { + this.activationDisposables.add(this.packageManager.serviceHub.consume(name, version, this.mainModule[methodName].bind(this.mainModule))) + } + } + } + } + + registerURIHandler () { + const handlerConfig = this.getURIHandler() + const methodName = handlerConfig && handlerConfig.method + if (methodName) { + this.uriHandlerSubscription = this.packageManager.registerURIHandlerForPackage(this.name, (...args) => + this.handleURI(methodName, args) + ) + } + } + + unregisterURIHandler () { + if (this.uriHandlerSubscription) this.uriHandlerSubscription.dispose() + } + + handleURI (methodName, args) { + this.activate().then(() => { + if (this.mainModule[methodName]) this.mainModule[methodName].apply(this.mainModule, args) + }) + if (!this.mainActivated) this.activateNow() + } + + registerTranspilerConfig () { + if (this.metadata.atomTranspilers) { + CompileCache.addTranspilerConfigForPath(this.path, this.name, this.metadata, this.metadata.atomTranspilers) + } + } + + unregisterTranspilerConfig () { + if (this.metadata.atomTranspilers) { + CompileCache.removeTranspilerConfigForPath(this.path) + } + } + + loadKeymaps () { + if (this.bundledPackage && this.packageManager.packagesCache[this.name]) { + this.keymaps = [] + for (const keymapPath in this.packageManager.packagesCache[this.name].keymaps) { + const keymapObject = this.packageManager.packagesCache[this.name].keymaps[keymapPath] + this.keymaps.push([`core:${keymapPath}`, keymapObject]) + } + } else { + this.keymaps = this.getKeymapPaths().map((keymapPath) => [ + keymapPath, + CSON.readFileSync(keymapPath, {allowDuplicateKeys: false}) || {} + ]) + } + } + + loadMenus () { + if (this.bundledPackage && this.packageManager.packagesCache[this.name]) { + this.menus = [] + for (const menuPath in this.packageManager.packagesCache[this.name].menus) { + const menuObject = this.packageManager.packagesCache[this.name].menus[menuPath] + this.menus.push([`core:${menuPath}`, menuObject]) + } + } else { + this.menus = this.getMenuPaths().map((menuPath) => [ + menuPath, + CSON.readFileSync(menuPath) || {} + ]) + } + } + + getKeymapPaths () { + const keymapsDirPath = path.join(this.path, 'keymaps') + if (this.metadata.keymaps) { + return this.metadata.keymaps.map(name => fs.resolve(keymapsDirPath, name, ['json', 'cson', ''])) + } else { + return fs.listSync(keymapsDirPath, ['cson', 'json']) + } + } + + getMenuPaths () { + const menusDirPath = path.join(this.path, 'menus') + if (this.metadata.menus) { + return this.metadata.menus.map(name => fs.resolve(menusDirPath, name, ['json', 'cson', ''])) + } else { + return fs.listSync(menusDirPath, ['cson', 'json']) + } + } + + loadStylesheets () { + this.stylesheets = this.getStylesheetPaths().map(stylesheetPath => + [stylesheetPath, this.themeManager.loadStylesheet(stylesheetPath, true)] + ) + } + + registerDeserializerMethods () { + if (this.metadata.deserializers) { + Object.keys(this.metadata.deserializers).forEach(deserializerName => { + const methodName = this.metadata.deserializers[deserializerName] + this.deserializerManager.add({ + name: deserializerName, + deserialize: (state, atomEnvironment) => { + this.registerViewProviders() + this.requireMainModule() + this.initializeIfNeeded() + return this.mainModule[methodName](state, atomEnvironment) + } + }) + }) + } + } + + activateCoreStartupServices () { + const directoryProviderService = + this.metadata.providedServices && + this.metadata.providedServices['atom.directory-provider'] + if (directoryProviderService) { + this.requireMainModule() + const servicesByVersion = {} + for (let version in directoryProviderService.versions) { + const methodName = directoryProviderService.versions[version] + if (typeof this.mainModule[methodName] === 'function') { + servicesByVersion[version] = this.mainModule[methodName]() + } + } + this.packageManager.serviceHub.provide('atom.directory-provider', servicesByVersion) + } + } + + registerViewProviders () { + if (this.metadata.viewProviders && !this.registeredViewProviders) { + this.requireMainModule() + this.metadata.viewProviders.forEach(methodName => { + this.viewRegistry.addViewProvider(model => { + this.initializeIfNeeded() + return this.mainModule[methodName](model) + }) + }) + this.registeredViewProviders = true + } + } + + getStylesheetsPath () { + return path.join(this.path, 'styles') + } + + getStylesheetPaths () { + if (this.bundledPackage && + this.packageManager.packagesCache[this.name] && + this.packageManager.packagesCache[this.name].styleSheetPaths) { + const {styleSheetPaths} = this.packageManager.packagesCache[this.name] + return styleSheetPaths.map(styleSheetPath => path.join(this.path, styleSheetPath)) + } else { + let indexStylesheet + const stylesheetDirPath = this.getStylesheetsPath() + if (this.metadata.mainStyleSheet) { + return [fs.resolve(this.path, this.metadata.mainStyleSheet)] + } else if (this.metadata.styleSheets) { + return this.metadata.styleSheets.map(name => fs.resolve(stylesheetDirPath, name, ['css', 'less', ''])) + } else if ((indexStylesheet = fs.resolve(this.path, 'index', ['css', 'less']))) { + return [indexStylesheet] + } else { + return fs.listSync(stylesheetDirPath, ['css', 'less']) + } + } + } + + loadGrammarsSync () { + if (this.grammarsLoaded) return + + let grammarPaths + if (this.preloadedPackage && this.packageManager.packagesCache[this.name]) { + ({grammarPaths} = this.packageManager.packagesCache[this.name]) + } else { + grammarPaths = fs.listSync(path.join(this.path, 'grammars'), ['json', 'cson']) + } + + for (let grammarPath of grammarPaths) { + if (this.preloadedPackage && this.packageManager.packagesCache[this.name]) { + grammarPath = path.resolve(this.packageManager.resourcePath, grammarPath) + } + + try { + const grammar = this.grammarRegistry.readGrammarSync(grammarPath) + grammar.packageName = this.name + grammar.bundledPackage = this.bundledPackage + this.grammars.push(grammar) + grammar.activate() + } catch (error) { + console.warn(`Failed to load grammar: ${grammarPath}`, error.stack || error) + } + } + + this.grammarsLoaded = true + this.grammarsActivated = true + } + + loadGrammars () { + if (this.grammarsLoaded) return Promise.resolve() + + const loadGrammar = (grammarPath, callback) => { + if (this.preloadedPackage) { + grammarPath = path.resolve(this.packageManager.resourcePath, grammarPath) + } + + return this.grammarRegistry.readGrammar(grammarPath, (error, grammar) => { + if (error) { + const detail = `${error.message} in ${grammarPath}` + const stack = `${error.stack}\n at ${grammarPath}:1:1` + this.notificationManager.addFatalError(`Failed to load a ${this.name} package grammar`, {stack, detail, packageName: this.name, dismissable: true}) + } else { + grammar.packageName = this.name + grammar.bundledPackage = this.bundledPackage + this.grammars.push(grammar) + if (this.grammarsActivated) grammar.activate() + } + return callback() + }) + } + + return new Promise(resolve => { + if (this.preloadedPackage && this.packageManager.packagesCache[this.name]) { + const { grammarPaths } = this.packageManager.packagesCache[this.name] + return async.each(grammarPaths, loadGrammar, () => resolve()) + } else { + const grammarsDirPath = path.join(this.path, 'grammars') + fs.exists(grammarsDirPath, (grammarsDirExists) => { + if (!grammarsDirExists) return resolve() + fs.list(grammarsDirPath, ['json', 'cson'], (error, grammarPaths) => { + if (error || !grammarPaths) return resolve() + async.each(grammarPaths, loadGrammar, () => resolve()) + }) + }) + } + }) + } + + loadSettings () { + this.settings = [] + + const loadSettingsFile = (settingsPath, callback) => { + return ScopedProperties.load(settingsPath, this.config, (error, settings) => { + if (error) { + const detail = `${error.message} in ${settingsPath}` + const stack = `${error.stack}\n at ${settingsPath}:1:1` + this.notificationManager.addFatalError(`Failed to load the ${this.name} package settings`, {stack, detail, packageName: this.name, dismissable: true}) + } else { + this.settings.push(settings) + if (this.settingsActivated) { settings.activate() } + } + return callback() + }) + } + + return new Promise(resolve => { + if (this.preloadedPackage && this.packageManager.packagesCache[this.name]) { + for (let settingsPath in this.packageManager.packagesCache[this.name].settings) { + const scopedProperties = this.packageManager.packagesCache[this.name].settings[settingsPath] + const settings = new ScopedProperties(`core:${settingsPath}`, scopedProperties || {}, this.config) + this.settings.push(settings) + if (this.settingsActivated) { settings.activate() } + } + return resolve() + } else { + const settingsDirPath = path.join(this.path, 'settings') + fs.exists(settingsDirPath, (settingsDirExists) => { + if (!settingsDirExists) return resolve() + fs.list(settingsDirPath, ['json', 'cson'], (error, settingsPaths) => { + if (error || !settingsPaths) return resolve() + async.each(settingsPaths, loadSettingsFile, () => resolve()) + }) + }) + } + }) + } + + serialize () { + if (this.mainActivated) { + if (typeof this.mainModule.serialize === 'function') { + try { + return this.mainModule.serialize() + } catch (error) { + console.error(`Error serializing package '${this.name}'`, error.stack) + } + } + } + } + + async deactivate () { + this.activationPromise = null + this.resolveActivationPromise = null + if (this.activationCommandSubscriptions) this.activationCommandSubscriptions.dispose() + if (this.activationHookSubscriptions) this.activationHookSubscriptions.dispose() + this.configSchemaRegisteredOnActivate = false + this.unregisterURIHandler() + this.deactivateResources() + this.deactivateKeymaps() + + if (!this.mainActivated) { + this.emitter.emit('did-deactivate') + return + } + + if (typeof this.mainModule.deactivate === 'function') { + try { + const deactivationResult = this.mainModule.deactivate() + if (deactivationResult && typeof deactivationResult.then === 'function') { + await deactivationResult + } + } catch (error) { + console.error(`Error deactivating package '${this.name}'`, error.stack) + } + } + + if (typeof this.mainModule.deactivateConfig === 'function') { + try { + await this.mainModule.deactivateConfig() + } catch (error) { + console.error(`Error deactivating package '${this.name}'`, error.stack) + } + } + + this.mainActivated = false + this.mainInitialized = false + this.emitter.emit('did-deactivate') + } + + deactivateResources () { + for (let grammar of this.grammars) { + grammar.deactivate() + } + for (let settings of this.settings) { + settings.deactivate() + } + + if (this.stylesheetDisposables) this.stylesheetDisposables.dispose() + if (this.activationDisposables) this.activationDisposables.dispose() + if (this.keymapDisposables) this.keymapDisposables.dispose() + + this.stylesheetsActivated = false + this.grammarsActivated = false + this.settingsActivated = false + this.menusActivated = false + } + + reloadStylesheets () { + try { + this.loadStylesheets() + } catch (error) { + this.handleError(`Failed to reload the ${this.name} package stylesheets`, error) + } + + if (this.stylesheetDisposables) this.stylesheetDisposables.dispose() + this.stylesheetDisposables = new CompositeDisposable() + this.stylesheetsActivated = false + this.activateStylesheets() + } + + requireMainModule () { + if (this.bundledPackage && this.packageManager.packagesCache[this.name]) { + if (this.packageManager.packagesCache[this.name].main) { + this.mainModule = require(this.packageManager.packagesCache[this.name].main) + return this.mainModule + } + } else if (this.mainModuleRequired) { + return this.mainModule + } else if (!this.isCompatible()) { + const nativeModuleNames = this.incompatibleModules.map(m => m.name).join(', ') + console.warn(dedent ` + Failed to require the main module of '${this.name}' because it requires one or more incompatible native modules (${nativeModuleNames}). + Run \`apm rebuild\` in the package directory and restart Atom to resolve.\ + `) + } else { + const mainModulePath = this.getMainModulePath() + if (fs.isFileSync(mainModulePath)) { + this.mainModuleRequired = true + + const previousViewProviderCount = this.viewRegistry.getViewProviderCount() + const previousDeserializerCount = this.deserializerManager.getDeserializerCount() + this.mainModule = require(mainModulePath) + if ((this.viewRegistry.getViewProviderCount() === previousViewProviderCount) && + (this.deserializerManager.getDeserializerCount() === previousDeserializerCount)) { + localStorage.setItem(this.getCanDeferMainModuleRequireStorageKey(), 'true') + } + return this.mainModule + } + } + } + + getMainModulePath () { + if (this.resolvedMainModulePath) return this.mainModulePath + this.resolvedMainModulePath = true + + if (this.bundledPackage && this.packageManager.packagesCache[this.name]) { + if (this.packageManager.packagesCache[this.name].main) { + this.mainModulePath = path.resolve(this.packageManager.resourcePath, 'static', this.packageManager.packagesCache[this.name].main) + } else { + this.mainModulePath = null + } + } else { + const mainModulePath = this.metadata.main + ? path.join(this.path, this.metadata.main) + : path.join(this.path, 'index') + this.mainModulePath = fs.resolveExtension(mainModulePath, ['', ...CompileCache.supportedExtensions]) + } + return this.mainModulePath + } + + activationShouldBeDeferred () { + return this.hasActivationCommands() || this.hasActivationHooks() || this.hasDeferredURIHandler() + } + + hasActivationHooks () { + const hooks = this.getActivationHooks() + return hooks && hooks.length > 0 + } + + hasActivationCommands () { + const object = this.getActivationCommands() + for (let selector in object) { + const commands = object[selector] + if (commands.length > 0) return true + } + return false + } + + hasDeferredURIHandler () { + const handler = this.getURIHandler() + return handler && handler.deferActivation !== false + } + + subscribeToDeferredActivation () { + this.subscribeToActivationCommands() + this.subscribeToActivationHooks() + } + + subscribeToActivationCommands () { + this.activationCommandSubscriptions = new CompositeDisposable() + const object = this.getActivationCommands() + for (let selector in object) { + const commands = object[selector] + for (let command of commands) { + ((selector, command) => { + // Add dummy command so it appears in menu. + // The real command will be registered on package activation + try { + this.activationCommandSubscriptions.add(this.commandRegistry.add(selector, command, function () {})) + } catch (error) { + if (error.code === 'EBADSELECTOR') { + const metadataPath = path.join(this.path, 'package.json') + error.message += ` in ${metadataPath}` + error.stack += `\n at ${metadataPath}:1:1` + } + throw error + } + + this.activationCommandSubscriptions.add(this.commandRegistry.onWillDispatch(event => { + if (event.type !== command) return + let currentTarget = event.target + while (currentTarget) { + if (currentTarget.webkitMatchesSelector(selector)) { + this.activationCommandSubscriptions.dispose() + this.activateNow() + break + } + currentTarget = currentTarget.parentElement + } + })) + })(selector, command) + } + } + } + + getActivationCommands () { + if (this.activationCommands) return this.activationCommands + + this.activationCommands = {} + + if (this.metadata.activationCommands) { + for (let selector in this.metadata.activationCommands) { + const commands = this.metadata.activationCommands[selector] + if (!this.activationCommands[selector]) this.activationCommands[selector] = [] + if (typeof commands === 'string') { + this.activationCommands[selector].push(commands) + } else if (Array.isArray(commands)) { + this.activationCommands[selector].push(...commands) + } + } + } + + return this.activationCommands + } + + subscribeToActivationHooks () { + this.activationHookSubscriptions = new CompositeDisposable() + for (let hook of this.getActivationHooks()) { + if (typeof hook === 'string' && hook.trim().length > 0) { + this.activationHookSubscriptions.add( + this.packageManager.onDidTriggerActivationHook(hook, () => this.activateNow()) + ) + } + } + } + + getActivationHooks () { + if (this.metadata && this.activationHooks) return this.activationHooks + + if (this.metadata.activationHooks) { + if (Array.isArray(this.metadata.activationHooks)) { + this.activationHooks = Array.from(new Set(this.metadata.activationHooks)) + } else if (typeof this.metadata.activationHooks === 'string') { + this.activationHooks = [this.metadata.activationHooks] + } else { + this.activationHooks = [] + } + } else { + this.activationHooks = [] + } + + return this.activationHooks + } + + getURIHandler () { + return this.metadata && this.metadata.uriHandler + } + + // Does the given module path contain native code? + isNativeModule (modulePath) { + try { + return fs.listSync(path.join(modulePath, 'build', 'Release'), ['.node']).length > 0 + } catch (error) { + return false + } + } + + // Get an array of all the native modules that this package depends on. + // + // First try to get this information from + // @metadata._atomModuleCache.extensions. If @metadata._atomModuleCache doesn't + // exist, recurse through all dependencies. + getNativeModuleDependencyPaths () { + const nativeModulePaths = [] + + if (this.metadata._atomModuleCache) { + const relativeNativeModuleBindingPaths = + (this.metadata._atomModuleCache.extensions && this.metadata._atomModuleCache.extensions['.node']) || + [] + for (let relativeNativeModuleBindingPath of relativeNativeModuleBindingPaths) { + const nativeModulePath = path.join(this.path, relativeNativeModuleBindingPath, '..', '..', '..') + nativeModulePaths.push(nativeModulePath) + } + return nativeModulePaths + } + + var traversePath = nodeModulesPath => { + try { + for (let modulePath of fs.listSync(nodeModulesPath)) { + if (this.isNativeModule(modulePath)) nativeModulePaths.push(modulePath) + traversePath(path.join(modulePath, 'node_modules')) + } + } catch (error) {} + } + + traversePath(path.join(this.path, 'node_modules')) + + return nativeModulePaths + } + + /* + Section: Native Module Compatibility + */ + + // Extended: Are all native modules depended on by this package correctly + // compiled against the current version of Atom? + // + // Incompatible packages cannot be activated. + // + // Returns a {Boolean}, true if compatible, false if incompatible. + isCompatible () { + if (this.compatible == null) { + if (this.preloadedPackage) { + this.compatible = true + } else if (this.getMainModulePath()) { + this.incompatibleModules = this.getIncompatibleNativeModules() + this.compatible = + this.incompatibleModules.length === 0 && + this.getBuildFailureOutput() == null + } else { + this.compatible = true + } + } + return this.compatible + } + + // Extended: Rebuild native modules in this package's dependencies for the + // current version of Atom. + // + // Returns a {Promise} that resolves with an object containing `code`, + // `stdout`, and `stderr` properties based on the results of running + // `apm rebuild` on the package. + rebuild () { + return new Promise(resolve => + this.runRebuildProcess(result => { + if (result.code === 0) { + global.localStorage.removeItem(this.getBuildFailureOutputStorageKey()) + } else { + this.compatible = false + global.localStorage.setItem(this.getBuildFailureOutputStorageKey(), result.stderr) + } + global.localStorage.setItem(this.getIncompatibleNativeModulesStorageKey(), '[]') + resolve(result) + }) + ) + } + + // Extended: If a previous rebuild failed, get the contents of stderr. + // + // Returns a {String} or null if no previous build failure occurred. + getBuildFailureOutput () { + return global.localStorage.getItem(this.getBuildFailureOutputStorageKey()) + } + + runRebuildProcess (done) { + let stderr = '' + let stdout = '' + return new BufferedProcess({ + command: this.packageManager.getApmPath(), + args: ['rebuild', '--no-color'], + options: {cwd: this.path}, + stderr (output) { stderr += output }, + stdout (output) { stdout += output }, + exit (code) { done({code, stdout, stderr}) } + }) + } + + getBuildFailureOutputStorageKey () { + return `installed-packages:${this.name}:${this.metadata.version}:build-error` + } + + getIncompatibleNativeModulesStorageKey () { + const electronVersion = process.versions.electron + return `installed-packages:${this.name}:${this.metadata.version}:electron-${electronVersion}:incompatible-native-modules` + } + + getCanDeferMainModuleRequireStorageKey () { + return `installed-packages:${this.name}:${this.metadata.version}:can-defer-main-module-require` + } + + // Get the incompatible native modules that this package depends on. + // This recurses through all dependencies and requires all modules that + // contain a `.node` file. + // + // This information is cached in local storage on a per package/version basis + // to minimize the impact on startup time. + getIncompatibleNativeModules () { + if (!this.packageManager.devMode) { + try { + const arrayAsString = global.localStorage.getItem(this.getIncompatibleNativeModulesStorageKey()) + if (arrayAsString) return JSON.parse(arrayAsString) + } catch (error1) {} + } + + const incompatibleNativeModules = [] + for (let nativeModulePath of this.getNativeModuleDependencyPaths()) { + try { + require(nativeModulePath) + } catch (error) { + let version + try { + ({version} = require(`${nativeModulePath}/package.json`)) + } catch (error2) {} + incompatibleNativeModules.push({ + path: nativeModulePath, + name: path.basename(nativeModulePath), + version, + error: error.message + }) + } + } + + global.localStorage.setItem( + this.getIncompatibleNativeModulesStorageKey(), + JSON.stringify(incompatibleNativeModules) + ) + + return incompatibleNativeModules + } + + handleError (message, error) { + if (atom.inSpecMode()) throw error + + let detail, location, stack + if (error.filename && error.location && error instanceof SyntaxError) { + location = `${error.filename}:${error.location.first_line + 1}:${error.location.first_column + 1}` + detail = `${error.message} in ${location}` + stack = 'SyntaxError: ' + error.message + '\n' + 'at ' + location + } else if (error.less && error.filename && error.column != null && error.line != null) { + location = `${error.filename}:${error.line}:${error.column}` + detail = `${error.message} in ${location}` + stack = 'LessError: ' + error.message + '\n' + 'at ' + location + } else { + detail = error.message + stack = error.stack || error + } + + this.notificationManager.addFatalError(message, { + stack, detail, packageName: this.name, dismissable: true + }) + } +} 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/pane.js b/src/pane.js index 0305b39dd..af93f8e1e 100644 --- a/src/pane.js +++ b/src/pane.js @@ -790,57 +790,53 @@ class Pane { } promptToSaveItem (item, options = {}) { - if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) { - return Promise.resolve(true) - } - - let uri - if (typeof item.getURI === 'function') { - uri = item.getURI() - } else if (typeof item.getUri === 'function') { - uri = item.getUri() - } else { - return Promise.resolve(true) - } - - const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri - - const saveDialog = (saveButtonText, saveFn, message) => { - const chosen = this.applicationDelegate.confirm({ - message, - detailedMessage: 'Your changes will be lost if you close this item without saving.', - buttons: [saveButtonText, 'Cancel', "&Don't Save"]} - ) - - switch (chosen) { - case 0: - return new Promise(resolve => { - return saveFn(item, error => { - if (error instanceof SaveCancelledError) { - resolve(false) - } else if (error) { - saveDialog( - 'Save as', - this.saveItemAs, - `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}` - ).then(resolve) - } else { - resolve(true) - } - }) - }) - case 1: - return Promise.resolve(false) - case 2: - return Promise.resolve(true) + return new Promise((resolve, reject) => { + if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) { + return resolve(true) } - } - return saveDialog( - 'Save', - this.saveItem, - `'${title}' has changes, do you want to save them?` - ) + let uri + if (typeof item.getURI === 'function') { + uri = item.getURI() + } else if (typeof item.getUri === 'function') { + uri = item.getUri() + } else { + return resolve(true) + } + + const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri + + const saveDialog = (saveButtonText, saveFn, message) => { + this.applicationDelegate.confirm({ + message, + detail: 'Your changes will be lost if you close this item without saving.', + buttons: [saveButtonText, 'Cancel', "&Don't Save"] + }, response => { + switch (response) { + case 0: + return saveFn(item, error => { + if (error instanceof SaveCancelledError) { + resolve(false) + } else if (error) { + saveDialog( + 'Save as', + this.saveItemAs, + `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}` + ) + } else { + resolve(true) + } + }) + case 1: + return resolve(false) + case 2: + return resolve(true) + } + }) + } + + saveDialog('Save', this.saveItem, `'${title}' has changes, do you want to save them?`) + }) } // Public: Save the active item. @@ -908,7 +904,7 @@ class Pane { // after the item is successfully saved, or with the error if it failed. // The return value will be that of `nextAction` or `undefined` if it was not // provided - saveItemAs (item, nextAction) { + async saveItemAs (item, nextAction) { if (!item) return if (typeof item.saveAs !== 'function') return @@ -919,22 +915,34 @@ class Pane { const itemPath = item.getPath() if (itemPath && !saveOptions.defaultPath) saveOptions.defaultPath = itemPath - const newItemPath = this.applicationDelegate.showSaveDialog(saveOptions) - if (newItemPath) { - return promisify(() => item.saveAs(newItemPath)) - .then(() => { - if (nextAction) nextAction() - }) - .catch(error => { - if (nextAction) { - nextAction(error) - } else { - this.handleSaveError(error, item) - } - }) - } else if (nextAction) { - return nextAction(new SaveCancelledError('Save Cancelled')) - } + let resolveSaveDialogPromise = null + const saveDialogPromise = new Promise(resolve => { resolveSaveDialogPromise = resolve }) + this.applicationDelegate.showSaveDialog(saveOptions, newItemPath => { + if (newItemPath) { + promisify(() => item.saveAs(newItemPath)) + .then(() => { + if (nextAction) { + resolveSaveDialogPromise(nextAction()) + } else { + resolveSaveDialogPromise() + } + }) + .catch(error => { + if (nextAction) { + resolveSaveDialogPromise(nextAction(error)) + } else { + this.handleSaveError(error, item) + resolveSaveDialogPromise() + } + }) + } else if (nextAction) { + resolveSaveDialogPromise(nextAction(new SaveCancelledError('Save Cancelled'))) + } else { + resolveSaveDialogPromise() + } + }) + + return await saveDialogPromise } // Public: Save all items. diff --git a/src/path-watcher.js b/src/path-watcher.js index 5a2d10bde..d0ff90dd1 100644 --- a/src/path-watcher.js +++ b/src/path-watcher.js @@ -422,7 +422,7 @@ class PathWatcher { // Extended: Return a {Promise} that will resolve when the underlying native watcher is ready to begin sending events. // When testing filesystem watchers, it's important to await this promise before making filesystem changes that you // intend to assert about because there will be a delay between the instantiation of the watcher and the activation - // of the underlying OS resources that feed it events. + // of the underlying OS resources that feed its events. // // PathWatchers acquired through `watchPath` are already started. // @@ -533,7 +533,7 @@ class PathWatcher { } } - // Extended: Unsubscribe all subscribers from filesystem events. Native resources will be release asynchronously, + // Extended: Unsubscribe all subscribers from filesystem events. Native resources will be released asynchronously, // but this watcher will stop broadcasting events immediately. dispose () { for (const sub of this.changeCallbacks.values()) { diff --git a/src/project.js b/src/project.js index 92a11ec7a..18e71c915 100644 --- a/src/project.js +++ b/src/project.js @@ -2,7 +2,7 @@ const path = require('path') const _ = require('underscore-plus') const fs = require('fs-plus') -const {Emitter, Disposable} = require('event-kit') +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') const TextBuffer = require('text-buffer') const {watchPath} = require('./path-watcher') @@ -19,10 +19,12 @@ class Project extends Model { Section: Construction and Destruction */ - constructor ({notificationManager, packageManager, config, applicationDelegate}) { + constructor ({notificationManager, packageManager, config, applicationDelegate, grammarRegistry}) { super() this.notificationManager = notificationManager this.applicationDelegate = applicationDelegate + this.grammarRegistry = grammarRegistry + this.emitter = new Emitter() this.buffers = [] this.rootDirectories = [] @@ -35,6 +37,7 @@ class Project extends Model { this.watcherPromisesByPath = {} this.retiredBufferIDs = new Set() this.retiredBufferPaths = new Set() + this.subscriptions = new CompositeDisposable() this.consumeServices(packageManager) } @@ -54,6 +57,9 @@ class Project extends Model { this.emitter.dispose() this.emitter = new Emitter() + this.subscriptions.dispose() + this.subscriptions = new CompositeDisposable() + for (let buffer of this.buffers) { if (buffer != null) buffer.destroy() } @@ -104,6 +110,7 @@ class Project extends Model { return Promise.all(bufferPromises).then(buffers => { this.buffers = buffers.filter(Boolean) for (let buffer of this.buffers) { + this.grammarRegistry.maintainLanguageMode(buffer) this.subscribeToBuffer(buffer) } this.setPaths(state.paths || [], {mustExist: true, exact: true}) @@ -211,7 +218,7 @@ class Project extends Model { // // This method will be removed in 2.0 because it does synchronous I/O. // Prefer the following, which evaluates to a {Promise} that resolves to an - // {Array} of {Repository} objects: + // {Array} of {GitRepository} objects: // ``` // Promise.all(atom.project.getDirectories().map( // atom.project.repositoryForDirectory.bind(atom.project))) @@ -222,10 +229,10 @@ class Project extends Model { // Public: Get the repository for a given directory asynchronously. // - // * `directory` {Directory} for which to get a {Repository}. + // * `directory` {Directory} for which to get a {GitRepository}. // // Returns a {Promise} that resolves with either: - // * {Repository} if a repository can be created for the given directory + // * {GitRepository} if a repository can be created for the given directory // * `null` if no repository can be created for the given directory. repositoryForDirectory (directory) { const pathForDirectory = directory.getRealPathSync() @@ -654,11 +661,8 @@ class Project extends Model { } addBuffer (buffer, options = {}) { - return this.addBufferAtIndex(buffer, this.buffers.length, options) - } - - addBufferAtIndex (buffer, index, options = {}) { - this.buffers.splice(index, 0, buffer) + this.buffers.push(buffer) + this.subscriptions.add(this.grammarRegistry.maintainLanguageMode(buffer)) this.subscribeToBuffer(buffer) this.emitter.emit('did-add-buffer', buffer) return buffer diff --git a/src/protocol-handler-installer.js b/src/protocol-handler-installer.js index 0a55bff41..27a272ea0 100644 --- a/src/protocol-handler-installer.js +++ b/src/protocol-handler-installer.js @@ -12,13 +12,13 @@ class ProtocolHandlerInstaller { } isDefaultProtocolClient () { - return remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler']) + return remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--']) } setAsDefaultProtocolClient () { // This Electron API is only available on Windows and macOS. There might be some // hacks to make it work on Linux; see https://github.com/electron/electron/issues/6440 - return this.isSupported() && remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler']) + return this.isSupported() && remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--']) } initialize (config, notifications) { @@ -26,19 +26,28 @@ class ProtocolHandlerInstaller { return } - if (!this.isDefaultProtocolClient()) { - const behaviorWhenNotProtocolClient = config.get(SETTING) - switch (behaviorWhenNotProtocolClient) { - case PROMPT: + const behaviorWhenNotProtocolClient = config.get(SETTING) + switch (behaviorWhenNotProtocolClient) { + case PROMPT: + if (!this.isDefaultProtocolClient()) { this.promptToBecomeProtocolClient(config, notifications) - break - case ALWAYS: + } + break + case ALWAYS: + if (!this.isDefaultProtocolClient()) { this.setAsDefaultProtocolClient() - break - case NEVER: - default: - // Do nothing - } + } + break + case NEVER: + if (process.platform === 'win32') { + // Only win32 supports deregistration + const Registry = require('winreg') + const commandKey = new Registry({hive: 'HKCR', key: `\\atom`}) + commandKey.destroy((_err, _val) => { /* no op */ }) + } + break + default: + // Do nothing } } @@ -63,7 +72,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 7dc0d3298..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 ) @@ -219,18 +221,40 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage 'editor:toggle-soft-wrap': -> @toggleSoftWrapped() 'editor:fold-all': -> @foldAll() 'editor:unfold-all': -> @unfoldAll() - 'editor:fold-current-row': -> @foldCurrentRow() - 'editor:unfold-current-row': -> @unfoldCurrentRow() + 'editor:fold-current-row': -> + @foldCurrentRow() + @scrollToCursorPosition() + 'editor:unfold-current-row': -> + @unfoldCurrentRow() + @scrollToCursorPosition() 'editor:fold-selection': -> @foldSelectedLines() - 'editor:fold-at-indent-level-1': -> @foldAllAtIndentLevel(0) - 'editor:fold-at-indent-level-2': -> @foldAllAtIndentLevel(1) - 'editor:fold-at-indent-level-3': -> @foldAllAtIndentLevel(2) - 'editor:fold-at-indent-level-4': -> @foldAllAtIndentLevel(3) - 'editor:fold-at-indent-level-5': -> @foldAllAtIndentLevel(4) - 'editor:fold-at-indent-level-6': -> @foldAllAtIndentLevel(5) - 'editor:fold-at-indent-level-7': -> @foldAllAtIndentLevel(6) - 'editor:fold-at-indent-level-8': -> @foldAllAtIndentLevel(7) - 'editor:fold-at-indent-level-9': -> @foldAllAtIndentLevel(8) + 'editor:fold-at-indent-level-1': -> + @foldAllAtIndentLevel(0) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-2': -> + @foldAllAtIndentLevel(1) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-3': -> + @foldAllAtIndentLevel(2) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-4': -> + @foldAllAtIndentLevel(3) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-5': -> + @foldAllAtIndentLevel(4) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-6': -> + @foldAllAtIndentLevel(5) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-7': -> + @foldAllAtIndentLevel(6) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-8': -> + @foldAllAtIndentLevel(7) + @scrollToCursorPosition() + 'editor:fold-at-indent-level-9': -> + @foldAllAtIndentLevel(8) + @scrollToCursorPosition() 'editor:log-cursor-scope': -> showCursorScope(@getCursorScope(), notificationManager) 'editor:copy-path': -> copyPathToClipboard(this, project, clipboard, false) 'editor:copy-project-path': -> copyPathToClipboard(this, project, clipboard, true) diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee index 95539cc69..f1070f277 100644 --- a/src/scope-descriptor.coffee +++ b/src/scope-descriptor.coffee @@ -2,7 +2,7 @@ # root of the syntax tree to a token including _all_ scope names for the entire # path. # -# Methods that take a `ScopeDescriptor` will also accept an {Array} of {Strings} +# Methods that take a `ScopeDescriptor` will also accept an {Array} of {String} # scope names e.g. `['.source.js']`. # # You can use `ScopeDescriptor`s to get language-specific config settings via @@ -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/selection.js b/src/selection.js index a54ba68b8..2c64fa126 100644 --- a/src/selection.js +++ b/src/selection.js @@ -448,9 +448,19 @@ class Selection { if (options.autoIndent && textIsAutoIndentable && !NonWhitespaceRegExp.test(precedingText) && (remainingLines.length > 0)) { autoIndentFirstLine = true const firstLine = precedingText + firstInsertedLine - desiredIndentLevel = this.editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) - indentAdjustment = desiredIndentLevel - this.editor.indentLevelForLine(firstLine) - this.adjustIndent(remainingLines, indentAdjustment) + const languageMode = this.editor.buffer.getLanguageMode() + desiredIndentLevel = ( + languageMode.suggestedIndentForLineAtBufferRow && + languageMode.suggestedIndentForLineAtBufferRow( + oldBufferRange.start.row, + firstLine, + this.editor.getTabLength() + ) + ) + if (desiredIndentLevel != null) { + indentAdjustment = desiredIndentLevel - this.editor.indentLevelForLine(firstLine) + this.adjustIndent(remainingLines, indentAdjustment) + } } text = firstInsertedLine @@ -575,7 +585,8 @@ class Selection { // is empty unless the selection spans multiple lines in which case all lines // are removed. deleteLine () { - if (this.isEmpty()) { + const range = this.getBufferRange() + if (range.isEmpty()) { const start = this.cursor.getScreenRow() const range = this.editor.bufferRowsForScreenRows(start, start + 1) if (range[1] > range[0]) { @@ -584,12 +595,12 @@ class Selection { this.editor.buffer.deleteRow(range[0]) } } else { - const range = this.getBufferRange() const start = range.start.row let end = range.end.row if (end !== this.editor.buffer.getLastRow() && range.end.column === 0) end-- this.editor.buffer.deleteRows(start, end) } + this.cursor.setBufferPosition({row: this.cursor.getBufferRow(), column: range.start.column}) } // Public: Joins the current line with the one below it. Lines will @@ -821,8 +832,12 @@ class Selection { if (clippedRange.isEmpty()) continue } - const selection = this.editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers({containsScreenRange: clippedRange}) + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + } + break } } @@ -843,8 +858,12 @@ class Selection { if (clippedRange.isEmpty()) continue } - const selection = this.editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers({containsScreenRange: clippedRange}) + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + } + break } } 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 70a324cd5..67323e66f 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() @@ -467,9 +468,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()} @@ -579,7 +584,6 @@ class TextEditorComponent { on: {mousedown: this.didMouseDownOnContent}, style }, - this.renderHighlightDecorations(), this.renderLineTiles(), this.renderBlockDecorationMeasurementArea(), this.renderCharacterMeasurementLine() @@ -597,13 +601,15 @@ class TextEditorComponent { } renderLineTiles () { - const children = [] const style = { position: 'absolute', contain: 'strict', overflow: 'hidden' } + const children = [] + children.push(this.renderHighlightDecorations()) + if (this.hasInitialMeasurements) { const {lineComponentsByScreenLineId} = this @@ -684,7 +690,8 @@ class TextEditorComponent { scrollWidth: this.getScrollWidth(), decorationsToRender: this.decorationsToRender, cursorsBlinkedOff: this.cursorsBlinkedOff, - hiddenInputPosition: this.hiddenInputPosition + hiddenInputPosition: this.hiddenInputPosition, + tabIndex: this.tabIndex }) } @@ -1517,28 +1524,28 @@ class TextEditorComponent { didMouseWheel (event) { const scrollSensitivity = this.props.model.getScrollSensitivity() / 100 - let {deltaX, deltaY} = event + let {wheelDeltaX, wheelDeltaY} = event - if (Math.abs(deltaX) > Math.abs(deltaY)) { - deltaX = (Math.sign(deltaX) === 1) - ? Math.max(1, deltaX * scrollSensitivity) - : Math.min(-1, deltaX * scrollSensitivity) - deltaY = 0 + if (Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY)) { + wheelDeltaX = (Math.sign(wheelDeltaX) === 1) + ? Math.max(1, wheelDeltaX * scrollSensitivity) + : Math.min(-1, wheelDeltaX * scrollSensitivity) + wheelDeltaY = 0 } else { - deltaX = 0 - deltaY = (Math.sign(deltaY) === 1) - ? Math.max(1, deltaY * scrollSensitivity) - : Math.min(-1, deltaY * scrollSensitivity) + wheelDeltaX = 0 + wheelDeltaY = (Math.sign(wheelDeltaY) === 1) + ? Math.max(1, wheelDeltaY * scrollSensitivity) + : Math.min(-1, wheelDeltaY * scrollSensitivity) } if (this.getPlatform() !== 'darwin' && event.shiftKey) { - let temp = deltaX - deltaX = deltaY - deltaY = temp + let temp = wheelDeltaX + wheelDeltaX = wheelDeltaY + wheelDeltaY = temp } - const scrollLeftChanged = deltaX !== 0 && this.setScrollLeft(this.getScrollLeft() + deltaX) - const scrollTopChanged = deltaY !== 0 && this.setScrollTop(this.getScrollTop() + deltaY) + const scrollLeftChanged = wheelDeltaX !== 0 && this.setScrollLeft(this.getScrollLeft() - wheelDeltaX) + const scrollTopChanged = wheelDeltaY !== 0 && this.setScrollTop(this.getScrollTop() - wheelDeltaY) if (scrollLeftChanged || scrollTopChanged) this.updateSync() } @@ -1719,10 +1726,6 @@ class TextEditorComponent { return } - if (this.getChromeVersion() === 56) { - this.getHiddenInput().value = '' - } - this.compositionCheckpoint = this.props.model.createCheckpoint() if (this.accentedCharacterMenuIsOpen) { this.props.model.selectLeft() @@ -1730,16 +1733,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) { @@ -1763,33 +1757,30 @@ class TextEditorComponent { } } - // On Linux, position the cursor on middle mouse button click. A - // textInput event with the contents of the selection clipboard will be - // dispatched by the browser automatically on mouseup. - if (platform === 'linux' && button === 1) { - const selection = clipboard.readText('selection') - const screenPosition = this.screenPositionForMouseEvent(event) - model.setCursorScreenPosition(screenPosition, {autoscroll: false}) - model.insertText(selection) + const screenPosition = this.screenPositionForMouseEvent(event) + + if (button !== 0 || (platform === 'darwin' && ctrlKey)) { + // 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 + // automatically on mouseup. + if (platform === 'linux' && button === 1) model.insertText(clipboard.readText('selection')) return } - // Only handle mousedown events for left mouse button (or the middle mouse - // button on Linux where it pastes the selection clipboard). - if (button !== 0) return - - // Ctrl-click brings up the context menu on macOS - if (platform === 'darwin' && ctrlKey) return - - const screenPosition = this.screenPositionForMouseEvent(event) - if (target && target.matches('.fold-marker')) { const bufferPosition = model.bufferPositionForScreenPosition(screenPosition) model.destroyFoldsContainingBufferPositions([bufferPosition], false) return } - const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') + const addOrRemoveSelection = metaKey || ctrlKey switch (detail) { case 1: @@ -2974,11 +2965,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 () { @@ -3029,7 +3020,7 @@ class DummyScrollbarComponent { const outerStyle = { position: 'absolute', - contain: 'strict', + contain: 'content', zIndex: 1, willChange: 'transform' } @@ -3552,7 +3543,8 @@ class CursorsAndInputComponent { zIndex: 1, width: scrollWidth + 'px', height: scrollHeight + 'px', - pointerEvents: 'none' + pointerEvents: 'none', + userSelect: 'none' } }, children) } @@ -3565,7 +3557,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 @@ -3593,7 +3585,7 @@ class CursorsAndInputComponent { compositionupdate: didCompositionUpdate, compositionend: didCompositionEnd }, - tabIndex: -1, + tabIndex: tabIndex, style: { position: 'absolute', width: '1px', @@ -4028,6 +4020,7 @@ class HighlightsComponent { this.element.style.contain = 'strict' this.element.style.position = 'absolute' this.element.style.overflow = 'hidden' + this.element.style.userSelect = 'none' this.highlightComponentsByKey = new Map() this.update(props) } 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-registry.js b/src/text-editor-registry.js index d891a5868..9b802f5f8 100644 --- a/src/text-editor-registry.js +++ b/src/text-editor-registry.js @@ -1,9 +1,7 @@ -/** @babel */ - -import {Emitter, Disposable, CompositeDisposable} from 'event-kit' -import {Point, Range} from 'text-buffer' -import TextEditor from './text-editor' -import ScopeDescriptor from './scope-descriptor' +const _ = require('underscore-plus') +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') +const TextEditor = require('./text-editor') +const ScopeDescriptor = require('./scope-descriptor') const EDITOR_PARAMS_BY_SETTING_KEY = [ ['core.fileEncoding', 'encoding'], @@ -23,12 +21,9 @@ const EDITOR_PARAMS_BY_SETTING_KEY = [ ['editor.autoIndentOnPaste', 'autoIndentOnPaste'], ['editor.scrollPastEnd', 'scrollPastEnd'], ['editor.undoGroupingInterval', 'undoGroupingInterval'], - ['editor.nonWordCharacters', 'nonWordCharacters'], ['editor.scrollSensitivity', 'scrollSensitivity'] ] -const GRAMMAR_SELECTION_RANGE = Range(Point.ZERO, Point(10, 0)).freeze() - // Experimental: This global registry tracks registered `TextEditors`. // // If you want to add functionality to a wider set of text editors than just @@ -40,13 +35,11 @@ const GRAMMAR_SELECTION_RANGE = Range(Point.ZERO, Point(10, 0)).freeze() // them for observation via `atom.textEditors.add`. **Important:** When you're // done using your editor, be sure to call `dispose` on the returned disposable // to avoid leaking editors. -export default class TextEditorRegistry { - constructor ({config, grammarRegistry, assert, packageManager}) { +module.exports = +class TextEditorRegistry { + constructor ({config, assert, packageManager}) { this.assert = assert this.config = config - this.grammarRegistry = grammarRegistry - this.scopedSettingsDelegate = new ScopedSettingsDelegate(config) - this.grammarAddedOrUpdated = this.grammarAddedOrUpdated.bind(this) this.clear() this.initialPackageActivationPromise = new Promise((resolve) => { @@ -83,10 +76,6 @@ export default class TextEditorRegistry { this.editorsWithMaintainedGrammar = new Set() this.editorGrammarOverrides = {} this.editorGrammarScores = new WeakMap() - this.subscriptions.add( - this.grammarRegistry.onDidAddGrammar(this.grammarAddedOrUpdated), - this.grammarRegistry.onDidUpdateGrammar(this.grammarAddedOrUpdated) - ) } destroy () { @@ -114,10 +103,10 @@ export default class TextEditorRegistry { let scope = null if (params.buffer) { - const filePath = params.buffer.getPath() - const headContent = params.buffer.getTextInRange(GRAMMAR_SELECTION_RANGE) - params.grammar = this.grammarRegistry.selectGrammar(filePath, headContent) - scope = new ScopeDescriptor({scopes: [params.grammar.scopeName]}) + const {grammar} = params.buffer.getLanguageMode() + if (grammar) { + scope = new ScopeDescriptor({scopes: [grammar.scopeName]}) + } } Object.assign(params, this.textEditorParamsForScope(scope)) @@ -159,13 +148,11 @@ export default class TextEditorRegistry { } this.editorsWithMaintainedConfig.add(editor) - editor.setScopedSettingsDelegate(this.scopedSettingsDelegate) - - this.subscribeToSettingsForEditorScope(editor) - const grammarChangeSubscription = editor.onDidChangeGrammar(() => { - this.subscribeToSettingsForEditorScope(editor) + this.updateAndMonitorEditorSettings(editor) + const languageChangeSubscription = editor.buffer.onDidChangeLanguageMode((newLanguageMode, oldLanguageMode) => { + this.updateAndMonitorEditorSettings(editor, oldLanguageMode) }) - this.subscriptions.add(grammarChangeSubscription) + this.subscriptions.add(languageChangeSubscription) const updateTabTypes = () => { const configOptions = {scope: editor.getRootScopeDescriptor()} @@ -182,152 +169,87 @@ export default class TextEditorRegistry { return new Disposable(() => { this.editorsWithMaintainedConfig.delete(editor) - editor.setScopedSettingsDelegate(null) tokenizeSubscription.dispose() - grammarChangeSubscription.dispose() - this.subscriptions.remove(grammarChangeSubscription) + languageChangeSubscription.dispose() + this.subscriptions.remove(languageChangeSubscription) this.subscriptions.remove(tokenizeSubscription) }) } - // Set a {TextEditor}'s grammar based on its path and content, and continue - // to update its grammar as grammars are added or updated, or the editor's - // file path changes. + // Deprecated: set a {TextEditor}'s grammar based on its path and content, + // and continue to update its grammar as grammars are added or updated, or + // the editor's file path changes. // // * `editor` The editor whose grammar will be maintained. // // Returns a {Disposable} that can be used to stop updating the editor's // grammar. maintainGrammar (editor) { - if (this.editorsWithMaintainedGrammar.has(editor)) { - return new Disposable(noop) - } - - this.editorsWithMaintainedGrammar.add(editor) - - const buffer = editor.getBuffer() - for (let existingEditor of this.editorsWithMaintainedGrammar) { - if (existingEditor.getBuffer() === buffer) { - const existingOverride = this.editorGrammarOverrides[existingEditor.id] - if (existingOverride) { - this.editorGrammarOverrides[editor.id] = existingOverride - } - break - } - } - - this.selectGrammarForEditor(editor) - - const pathChangeSubscription = editor.onDidChangePath(() => { - this.editorGrammarScores.delete(editor) - this.selectGrammarForEditor(editor) - }) - - this.subscriptions.add(pathChangeSubscription) - - return new Disposable(() => { - delete this.editorGrammarOverrides[editor.id] - this.editorsWithMaintainedGrammar.delete(editor) - this.subscriptions.remove(pathChangeSubscription) - pathChangeSubscription.dispose() - }) + atom.grammars.maintainGrammar(editor.getBuffer()) } - // Force a {TextEditor} to use a different grammar than the one that would - // otherwise be selected for it. + // Deprecated: Force a {TextEditor} to use a different grammar than the + // one that would otherwise be selected for it. // // * `editor` The editor whose gramamr will be set. - // * `scopeName` The {String} root scope name for the desired {Grammar}. - setGrammarOverride (editor, scopeName) { - this.editorGrammarOverrides[editor.id] = scopeName - this.editorGrammarScores.delete(editor) - editor.setGrammar(this.grammarRegistry.grammarForScopeName(scopeName)) + // * `languageId` The {String} language ID for the desired {Grammar}. + setGrammarOverride (editor, languageId) { + atom.grammars.assignLanguageMode(editor.getBuffer(), languageId) } - // Retrieve the grammar scope name that has been set as a grammar override - // for the given {TextEditor}. + // Deprecated: Retrieve the grammar scope name that has been set as a + // grammar override for the given {TextEditor}. // // * `editor` The editor. // // Returns a {String} scope name, or `null` if no override has been set // for the given editor. getGrammarOverride (editor) { - return this.editorGrammarOverrides[editor.id] + return editor.getBuffer().getLanguageMode().grammar.scopeName } - // Remove any grammar override that has been set for the given {TextEditor}. + // Deprecated: Remove any grammar override that has been set for the given {TextEditor}. // // * `editor` The editor. clearGrammarOverride (editor) { - delete this.editorGrammarOverrides[editor.id] - this.selectGrammarForEditor(editor) + atom.grammars.autoAssignLanguageMode(editor.getBuffer()) } - // Private - - grammarAddedOrUpdated (grammar) { - this.editorsWithMaintainedGrammar.forEach((editor) => { - if (grammar.injectionSelector) { - if (editor.tokenizedBuffer.hasTokenForSelector(grammar.injectionSelector)) { - editor.tokenizedBuffer.retokenizeLines() - } - return - } - - const grammarOverride = this.editorGrammarOverrides[editor.id] - if (grammarOverride) { - if (grammar.scopeName === grammarOverride) { - editor.setGrammar(grammar) - } - } else { - const score = this.grammarRegistry.getGrammarScore( - grammar, - editor.getPath(), - editor.getTextInBufferRange(GRAMMAR_SELECTION_RANGE) - ) - - let currentScore = this.editorGrammarScores.get(editor) - if (currentScore == null || score > currentScore) { - editor.setGrammar(grammar) - this.editorGrammarScores.set(editor, score) - } - } - }) + async updateAndMonitorEditorSettings (editor, oldLanguageMode) { + await this.initialPackageActivationPromise + this.updateEditorSettingsForLanguageMode(editor, oldLanguageMode) + await this.subscribeToSettingsForEditorScope(editor) } - selectGrammarForEditor (editor) { - const grammarOverride = this.editorGrammarOverrides[editor.id] + updateEditorSettingsForLanguageMode (editor, oldLanguageMode) { + const newLanguageMode = editor.buffer.getLanguageMode() - if (grammarOverride) { - const grammar = this.grammarRegistry.grammarForScopeName(grammarOverride) - editor.setGrammar(grammar) - return - } + if (oldLanguageMode) { + const newSettings = this.textEditorParamsForScope(newLanguageMode.rootScopeDescriptor) + const oldSettings = this.textEditorParamsForScope(oldLanguageMode.rootScopeDescriptor) - const {grammar, score} = this.grammarRegistry.selectGrammarWithScore( - editor.getPath(), - editor.getTextInBufferRange(GRAMMAR_SELECTION_RANGE) - ) + const updatedSettings = {} + for (const [, paramName] of EDITOR_PARAMS_BY_SETTING_KEY) { + // Update the setting only if it has changed between the two language + // modes. This prevents user-modified settings in an editor (like + // 'softWrapped') from being reset when the language mode changes. + if (!_.isEqual(newSettings[paramName], oldSettings[paramName])) { + updatedSettings[paramName] = newSettings[paramName] + } + } - if (!grammar) { - throw new Error(`No grammar found for path: ${editor.getPath()}`) - } - - const currentScore = this.editorGrammarScores.get(editor) - if (currentScore == null || score > currentScore) { - editor.setGrammar(grammar) - this.editorGrammarScores.set(editor, score) + if (_.size(updatedSettings) > 0) { + editor.update(updatedSettings) + } + } else { + editor.update(this.textEditorParamsForScope(newLanguageMode.rootScopeDescriptor)) } } async subscribeToSettingsForEditorScope (editor) { - await this.initialPackageActivationPromise - const scopeDescriptor = editor.getRootScopeDescriptor() const scopeChain = scopeDescriptor.getScopeChain() - editor.update(this.textEditorParamsForScope(scopeDescriptor)) - if (!this.scopesWithConfigSubscriptions.has(scopeChain)) { this.scopesWithConfigSubscriptions.add(scopeChain) const configOptions = {scope: scopeDescriptor} @@ -390,44 +312,3 @@ function shouldEditorUseSoftTabs (editor, tabType, softTabs) { } function noop () {} - -class ScopedSettingsDelegate { - constructor (config) { - this.config = config - } - - getNonWordCharacters (scope) { - return this.config.get('editor.nonWordCharacters', {scope: scope}) - } - - getIncreaseIndentPattern (scope) { - return this.config.get('editor.increaseIndentPattern', {scope: scope}) - } - - getDecreaseIndentPattern (scope) { - return this.config.get('editor.decreaseIndentPattern', {scope: scope}) - } - - getDecreaseNextIndentPattern (scope) { - return this.config.get('editor.decreaseNextIndentPattern', {scope: scope}) - } - - getFoldEndPattern (scope) { - return this.config.get('editor.foldEndPattern', {scope: scope}) - } - - getCommentStrings (scope) { - const commentStartEntries = this.config.getAll('editor.commentStart', {scope}) - const commentEndEntries = this.config.getAll('editor.commentEnd', {scope}) - const commentStartEntry = commentStartEntries[0] - const commentEndEntry = commentEndEntries.find((entry) => { - return entry.scopeSelector === commentStartEntry.scopeSelector - }) - return { - commentStartString: commentStartEntry && commentStartEntry.value, - commentEndString: commentEndEntry && commentEndEntry.value - } - } -} - -TextEditorRegistry.ScopedSettingsDelegate = ScopedSettingsDelegate diff --git a/src/text-editor.js b/src/text-editor.js index 8eee5c140..32d3102c2 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -7,9 +7,10 @@ const {CompositeDisposable, Disposable, Emitter} = require('event-kit') const TextBuffer = require('text-buffer') const {Point, Range} = TextBuffer const DecorationManager = require('./decoration-manager') -const TokenizedBuffer = require('./tokenized-buffer') const Cursor = require('./cursor') const Selection = require('./selection') +const NullGrammar = require('./null-grammar') +const TextMateLanguageMode = require('./text-mate-language-mode') const TextMateScopeSelector = require('first-mate').ScopeSelector const GutterContainer = require('./gutter-container') @@ -22,6 +23,8 @@ const NON_WHITESPACE_REGEXP = /\S/ const ZERO_WIDTH_NBSP = '\ufeff' let nextId = 0 +const DEFAULT_NON_WORD_CHARACTERS = "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" + // Essential: This class represents all essential editing state for a single // {TextBuffer}, including cursor and selection positions, folds, and soft wraps. // If you're manipulating the state of an editor, use this class. @@ -86,12 +89,13 @@ class TextEditor { static deserialize (state, atomEnvironment) { if (state.version !== SERIALIZATION_VERSION) return null - try { - const tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) - if (!tokenizedBuffer) return null + let bufferId = state.tokenizedBuffer + ? state.tokenizedBuffer.bufferId + : state.bufferId - state.tokenizedBuffer = tokenizedBuffer - state.tabLength = state.tokenizedBuffer.getTabLength() + try { + state.buffer = atomEnvironment.project.bufferForIdSync(bufferId) + if (!state.buffer) return null } catch (error) { if (error.syscall === 'read') { return // Error reading the file, don't deserialize an editor for it @@ -100,7 +104,6 @@ class TextEditor { } } - state.buffer = state.tokenizedBuffer.buffer state.assert = atomEnvironment.assert.bind(atomEnvironment) const editor = new TextEditor(state) if (state.registered) { @@ -116,14 +119,18 @@ 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.largeFileMode = params.largeFileMode this.assert = params.assert || (condition => condition) this.showInvisibles = (params.showInvisibles != null) ? params.showInvisibles : true this.autoHeight = params.autoHeight @@ -142,7 +149,6 @@ class TextEditor { this.autoIndent = (params.autoIndent != null) ? params.autoIndent : true this.autoIndentOnPaste = (params.autoIndentOnPaste != null) ? params.autoIndentOnPaste : true this.undoGroupingInterval = (params.undoGroupingInterval != null) ? params.undoGroupingInterval : 300 - this.nonWordCharacters = (params.nonWordCharacters != null) ? params.nonWordCharacters : "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" this.softWrapped = (params.softWrapped != null) ? params.softWrapped : false this.softWrapAtPreferredLineLength = (params.softWrapAtPreferredLineLength != null) ? params.softWrapAtPreferredLineLength : false this.preferredLineLength = (params.preferredLineLength != null) ? params.preferredLineLength : 80 @@ -171,17 +177,20 @@ class TextEditor { this.selections = [] this.hasTerminatedPendingState = false - this.buffer = params.buffer || new TextBuffer({ - shouldDestroyOnFileDelete () { return atom.config.get('core.closeDeletedFileTabs') } - }) + if (params.buffer) { + this.buffer = params.buffer + } else { + this.buffer = new TextBuffer({ + shouldDestroyOnFileDelete () { return atom.config.get('core.closeDeletedFileTabs') } + }) + this.buffer.setLanguageMode(new TextMateLanguageMode({buffer: this.buffer, config: atom.config})) + } - this.tokenizedBuffer = params.tokenizedBuffer || new TokenizedBuffer({ - grammar: params.grammar, - tabLength, - buffer: this.buffer, - largeFileMode: this.largeFileMode, - assert: this.assert + const languageMode = this.buffer.getLanguageMode() + this.languageModeSubscription = languageMode.onDidTokenize && languageMode.onDidTokenize(() => { + this.emitter.emit('did-tokenize') }) + if (this.languageModeSubscription) this.disposables.add(this.languageModeSubscription) if (params.displayLayer) { this.displayLayer = params.displayLayer @@ -217,8 +226,6 @@ class TextEditor { this.selectionsMarkerLayer = this.addMarkerLayer({maintainHistory: true, persistent: true}) } - this.displayLayer.setTextDecorationLayer(this.tokenizedBuffer) - this.decorationManager = new DecorationManager(this) this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'cursor'}) if (!this.isMini()) this.decorateCursorLine() @@ -271,9 +278,8 @@ class TextEditor { return this } - get languageMode () { - return this.tokenizedBuffer - } + get languageMode () { return this.buffer.getLanguageMode() } + get tokenizedBuffer () { return this.buffer.getLanguageMode() } get rowsPerPage () { return this.getRowsPerPage() @@ -319,10 +325,6 @@ class TextEditor { this.undoGroupingInterval = value break - case 'nonWordCharacters': - this.nonWordCharacters = value - break - case 'scrollSensitivity': this.scrollSensitivity = value break @@ -344,8 +346,7 @@ class TextEditor { break case 'tabLength': - if (value > 0 && value !== this.tokenizedBuffer.getTabLength()) { - this.tokenizedBuffer.setTabLength(value) + if (value > 0 && value !== this.displayLayer.tabLength) { displayLayerParams.tabLength = value } break @@ -404,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 @@ -513,34 +523,30 @@ class TextEditor { } serialize () { - const tokenizedBufferState = this.tokenizedBuffer.serialize() - return { deserializer: 'TextEditor', version: SERIALIZATION_VERSION, - // TODO: Remove this forward-compatible fallback once 1.8 reaches stable. - displayBuffer: {tokenizedBuffer: tokenizedBufferState}, - - tokenizedBuffer: tokenizedBufferState, displayLayerId: this.displayLayer.id, selectionsMarkerLayerId: this.selectionsMarkerLayer.id, initialScrollTopRow: this.getScrollTopRow(), initialScrollLeftColumn: this.getScrollLeftColumn(), + tabLength: this.displayLayer.tabLength, atomicSoftTabs: this.displayLayer.atomicSoftTabs, softWrapHangingIndentLength: this.displayLayer.softWrapHangingIndent, id: this.id, + bufferId: this.buffer.id, softTabs: this.softTabs, softWrapped: this.softWrapped, softWrapAtPreferredLineLength: this.softWrapAtPreferredLineLength, preferredLineLength: this.preferredLineLength, mini: this.mini, + readOnly: this.readOnly, editorWidthInChars: this.editorWidthInChars, width: this.width, - largeFileMode: this.largeFileMode, maxScreenLineLength: this.maxScreenLineLength, registered: this.registered, invisibles: this.invisibles, @@ -553,6 +559,7 @@ class TextEditor { subscribeToBuffer () { this.buffer.retain() + this.disposables.add(this.buffer.onDidChangeLanguageMode(this.handleLanguageModeChange.bind(this))) this.disposables.add(this.buffer.onDidChangePath(() => { this.emitter.emit('did-change-title', this.getTitle()) this.emitter.emit('did-change-path', this.getPath()) @@ -576,7 +583,6 @@ class TextEditor { } subscribeToDisplayLayer () { - this.disposables.add(this.tokenizedBuffer.onDidChangeGrammar(this.handleGrammarChange.bind(this))) this.disposables.add(this.displayLayer.onDidChange(changes => { this.mergeIntersectingSelections() if (this.component) this.component.didChangeDisplayLayer(changes) @@ -596,7 +602,6 @@ class TextEditor { this.alive = false this.disposables.dispose() this.displayLayer.destroy() - this.tokenizedBuffer.destroy() for (let selection of this.selections.slice()) { selection.destroy() } @@ -731,7 +736,9 @@ class TextEditor { // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangeGrammar (callback) { - return this.emitter.on('did-change-grammar', callback) + return this.buffer.onDidChangeLanguageMode(() => { + callback(this.buffer.getLanguageMode().grammar) + }) } // Extended: Calls your `callback` when the result of {::isModified} changes. @@ -947,7 +954,7 @@ class TextEditor { selectionsMarkerLayer, softTabs, suppressCursorCreation: true, - tabLength: this.tokenizedBuffer.getTabLength(), + tabLength: this.getTabLength(), initialScrollTopRow: this.getScrollTopRow(), initialScrollLeftColumn: this.getScrollLeftColumn(), assert: this.assert, @@ -960,7 +967,12 @@ class TextEditor { } // Controls visibility based on the given {Boolean}. - setVisible (visible) { this.tokenizedBuffer.setVisible(visible) } + setVisible (visible) { + if (visible) { + const languageMode = this.buffer.getLanguageMode() + if (languageMode.startTokenizing) languageMode.startTokenizing() + } + } setMini (mini) { this.update({mini}) @@ -968,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) } @@ -1312,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. @@ -2646,7 +2673,7 @@ class TextEditor { return this.cursors.slice() } - // Extended: Get all {Cursors}s, ordered by their position in the buffer + // Extended: Get all {Cursor}s, ordered by their position in the buffer // instead of the order in which they were added. // // Returns an {Array} of {Selection}s. @@ -3056,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} @@ -3353,7 +3410,7 @@ class TextEditor { // Essential: Get the on-screen length of tab characters. // // Returns a {Number}. - getTabLength () { return this.tokenizedBuffer.getTabLength() } + getTabLength () { return this.displayLayer.tabLength } // Essential: Set the on-screen length of tab characters. Setting this to a // {Number} This will override the `editor.tabLength` setting. @@ -3384,9 +3441,10 @@ class TextEditor { // Returns a {Boolean} or undefined if no non-comment lines had leading // whitespace. usesSoftTabs () { + const languageMode = this.buffer.getLanguageMode() + const hasIsRowCommented = languageMode.isRowCommented for (let bufferRow = 0, end = Math.min(1000, this.buffer.getLastRow()); bufferRow <= end; bufferRow++) { - const tokenizedLine = this.tokenizedBuffer.tokenizedLines[bufferRow] - if (tokenizedLine && tokenizedLine.isComment()) continue + if (hasIsRowCommented && languageMode.isRowCommented(bufferRow)) continue const line = this.buffer.lineForRow(bufferRow) if (line[0] === ' ') return true if (line[0] === '\t') return false @@ -3509,7 +3567,19 @@ class TextEditor { // // Returns a {Number}. indentLevelForLine (line) { - return this.tokenizedBuffer.indentLevelForLine(line) + const tabLength = this.getTabLength() + 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 } // Extended: Indent rows intersecting selections based on the grammar's suggested @@ -3542,27 +3612,24 @@ class TextEditor { // Essential: Get the current {Grammar} of this editor. getGrammar () { - return this.tokenizedBuffer.grammar + const languageMode = this.buffer.getLanguageMode() + return languageMode.getGrammar && languageMode.getGrammar() || NullGrammar } - // Essential: Set the current {Grammar} of this editor. + // Deprecated: Set the current {Grammar} of this editor. // // Assigning a grammar will cause the editor to re-tokenize based on the new // grammar. // // * `grammar` {Grammar} setGrammar (grammar) { - return this.tokenizedBuffer.setGrammar(grammar) - } - - // Reload the grammar based on the file name. - reloadGrammar () { - return this.tokenizedBuffer.reloadGrammar() + const buffer = this.getBuffer() + buffer.setLanguageMode(atom.grammars.languageModeForGrammarAndBuffer(grammar, buffer)) } // Experimental: Get a notification when async tokenization is completed. onDidTokenize (callback) { - return this.tokenizedBuffer.onDidTokenize(callback) + return this.emitter.on('did-tokenize', callback) } /* @@ -3573,21 +3640,22 @@ class TextEditor { // e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with // {Config::get} to get language specific config values. getRootScopeDescriptor () { - return this.tokenizedBuffer.rootScopeDescriptor + 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) { - return this.tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) + return this.buffer.getLanguageMode().scopeDescriptorForPosition(bufferPosition) } // Extended: Get the range in buffer coordinates of all tokens surrounding the @@ -3604,7 +3672,7 @@ class TextEditor { } bufferRangeForScopeAtPosition (scopeSelector, position) { - return this.tokenizedBuffer.bufferRangeForScopeAtPosition(scopeSelector, position) + return this.buffer.getLanguageMode().bufferRangeForScopeAtPosition(scopeSelector, position) } // Extended: Determine if the given row is entirely a comment @@ -3622,7 +3690,7 @@ class TextEditor { } tokenForBufferPosition (bufferPosition) { - return this.tokenizedBuffer.tokenForPosition(bufferPosition) + return this.buffer.getLanguageMode().tokenForPosition(bufferPosition) } /* @@ -3749,20 +3817,18 @@ class TextEditor { // level. foldCurrentRow () { const {row} = this.getCursorBufferPosition() - const range = this.tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) - if (range) { - const result = this.displayLayer.foldBufferRange(range) - this.scrollToCursorPosition() - return result - } + const languageMode = this.buffer.getLanguageMode() + const range = ( + languageMode.getFoldableRangeContainingPoint && + languageMode.getFoldableRangeContainingPoint(Point(row, Infinity), this.getTabLength()) + ) + if (range) return this.displayLayer.foldBufferRange(range) } // Essential: Unfold the most recent cursor's row by one level. unfoldCurrentRow () { const {row} = this.getCursorBufferPosition() - const result = this.displayLayer.destroyFoldsContainingBufferPositions([Point(row, Infinity)], false) - this.scrollToCursorPosition() - return result + return this.displayLayer.destroyFoldsContainingBufferPositions([Point(row, Infinity)], false) } // Essential: Fold the given row in buffer coordinates based on its indentation @@ -3774,13 +3840,16 @@ class TextEditor { // * `bufferRow` A {Number}. foldBufferRow (bufferRow) { let position = Point(bufferRow, Infinity) + const languageMode = this.buffer.getLanguageMode() while (true) { - const foldableRange = this.tokenizedBuffer.getFoldableRangeContainingPoint(position, this.getTabLength()) + const foldableRange = ( + languageMode.getFoldableRangeContainingPoint && + languageMode.getFoldableRangeContainingPoint(position, this.getTabLength()) + ) if (foldableRange) { const existingFolds = this.displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) if (existingFolds.length === 0) { this.displayLayer.foldBufferRange(foldableRange) - this.scrollToCursorPosition() } else { const firstExistingFoldRange = this.displayLayer.bufferRangeForFold(existingFolds[0]) if (firstExistingFoldRange.start.isLessThan(position)) { @@ -3798,9 +3867,7 @@ class TextEditor { // * `bufferRow` A {Number} unfoldBufferRow (bufferRow) { const position = Point(bufferRow, Infinity) - const result = this.displayLayer.destroyFoldsContainingBufferPositions([position]) - this.scrollToCursorPosition() - return result + return this.displayLayer.destroyFoldsContainingBufferPositions([position]) } // Extended: For each selection, fold the rows it intersects. @@ -3812,11 +3879,15 @@ class TextEditor { // Extended: Fold all foldable lines. foldAll () { + const languageMode = this.buffer.getLanguageMode() + const foldableRanges = ( + languageMode.getFoldableRanges && + languageMode.getFoldableRanges(this.getTabLength()) + ) this.displayLayer.destroyAllFolds() - for (let range of this.tokenizedBuffer.getFoldableRanges(this.getTabLength())) { + for (let range of foldableRanges || []) { this.displayLayer.foldBufferRange(range) } - this.scrollToCursorPosition() } // Extended: Unfold all existing folds. @@ -3828,13 +3899,17 @@ 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 = ( + languageMode.getFoldableRangesAtIndentLevel && + languageMode.getFoldableRangesAtIndentLevel(level, this.getTabLength()) + ) this.displayLayer.destroyAllFolds() - for (let range of this.tokenizedBuffer.getFoldableRangesAtIndentLevel(level, this.getTabLength())) { + for (let range of foldableRanges || []) { this.displayLayer.foldBufferRange(range) } - this.scrollToCursorPosition() } // Extended: Determine whether the given row in buffer coordinates is foldable. @@ -3845,7 +3920,8 @@ class TextEditor { // // Returns a {Boolean}. isFoldableAtBufferRow (bufferRow) { - return this.tokenizedBuffer.isFoldableAtRow(bufferRow) + const languageMode = this.buffer.getLanguageMode() + return languageMode.isFoldableAtRow && languageMode.isFoldableAtRow(bufferRow) } // Extended: Determine whether the given row in screen coordinates is foldable. @@ -3862,14 +3938,11 @@ class TextEditor { // Extended: Fold the given buffer row if it isn't currently folded, and unfold // it otherwise. toggleFoldAtBufferRow (bufferRow) { - let result if (this.isFoldedAtBufferRow(bufferRow)) { - result = this.unfoldBufferRow(bufferRow) + return this.unfoldBufferRow(bufferRow) } else { - result = this.foldBufferRow(bufferRow) + return this.foldBufferRow(bufferRow) } - this.scrollToCursorPosition() - return result } // Extended: Determine whether the most recently added cursor's row is folded. @@ -3908,9 +3981,7 @@ class TextEditor { // // Returns the new {Fold}. foldBufferRowRange (startRow, endRow) { - const result = this.foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) - this.scrollToCursorPosition() - return result + return this.foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) } foldBufferRange (range) { @@ -4055,18 +4126,6 @@ class TextEditor { Section: Config */ - // Experimental: Supply an object that will provide the editor with settings - // for specific syntactic scopes. See the `ScopedSettingsDelegate` in - // `text-editor-registry.js` for an example implementation. - setScopedSettingsDelegate (scopedSettingsDelegate) { - this.scopedSettingsDelegate = scopedSettingsDelegate - this.tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate - } - - // Experimental: Retrieve the {Object} that provides the editor with settings - // for specific syntactic scopes. - getScopedSettingsDelegate () { return this.scopedSettingsDelegate } - // Experimental: Is auto-indentation enabled for this editor? // // Returns a {Boolean}. @@ -4114,21 +4173,34 @@ class TextEditor { // for the purpose of word-based cursor movements. // // Returns a {String} containing the non-word characters. - getNonWordCharacters (scopes) { - if (this.scopedSettingsDelegate && this.scopedSettingsDelegate.getNonWordCharacters) { - return this.scopedSettingsDelegate.getNonWordCharacters(scopes) || this.nonWordCharacters - } else { - return this.nonWordCharacters - } + getNonWordCharacters (position) { + const languageMode = this.buffer.getLanguageMode() + return ( + languageMode.getNonWordCharacters && + languageMode.getNonWordCharacters(position || Point(0, 0)) + ) || DEFAULT_NON_WORD_CHARACTERS } /* Section: Event Handlers */ - handleGrammarChange () { + handleLanguageModeChange () { this.unfoldAll() - return this.emitter.emit('did-change-grammar', this.getGrammar()) + if (this.languageModeSubscription) { + this.languageModeSubscription.dispose() + this.disposables.remove(this.languageModeSubscription) + } + const languageMode = this.buffer.getLanguageMode() + + if (this.component && this.component.visible && languageMode.startTokenizing) { + languageMode.startTokenizing() + } + this.languageModeSubscription = languageMode.onDidTokenize && languageMode.onDidTokenize(() => { + this.emitter.emit('did-tokenize') + }) + if (this.languageModeSubscription) this.disposables.add(this.languageModeSubscription) + this.emitter.emit('did-change-grammar', languageMode.grammar) } /* @@ -4398,7 +4470,11 @@ class TextEditor { */ suggestedIndentForBufferRow (bufferRow, options) { - return this.tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) + const languageMode = this.buffer.getLanguageMode() + return ( + languageMode.suggestedIndentForBufferRow && + languageMode.suggestedIndentForBufferRow(bufferRow, this.getTabLength(), options) + ) } // Given a buffer row, indent it. @@ -4423,17 +4499,21 @@ class TextEditor { } autoDecreaseIndentForBufferRow (bufferRow) { - const indentLevel = this.tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) + const languageMode = this.buffer.getLanguageMode() + const indentLevel = ( + languageMode.suggestedIndentForEditedBufferRow && + languageMode.suggestedIndentForEditedBufferRow(bufferRow, this.getTabLength()) + ) if (indentLevel != null) this.setIndentationForBufferRow(bufferRow, indentLevel) } toggleLineCommentForBufferRow (row) { this.toggleLineCommentsForBufferRows(row, row) } toggleLineCommentsForBufferRows (start, end) { - let { - commentStartString, - commentEndString - } = this.tokenizedBuffer.commentStringsForPosition(Point(start, 0)) + const languageMode = this.buffer.getLanguageMode() + let {commentStartString, commentEndString} = + languageMode.commentStringsForPosition && + languageMode.commentStringsForPosition(Point(start, 0)) || {} if (!commentStartString) return commentStartString = commentStartString.trim() @@ -4503,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)) { @@ -4524,12 +4603,13 @@ class TextEditor { rowRangeForParagraphAtBufferRow (bufferRow) { if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(bufferRow))) return - const isCommented = this.tokenizedBuffer.isRowCommented(bufferRow) + const languageMode = this.buffer.getLanguageMode() + const isCommented = languageMode.isRowCommented(bufferRow) let startRow = bufferRow while (startRow > 0) { if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(startRow - 1))) break - if (this.tokenizedBuffer.isRowCommented(startRow - 1) !== isCommented) break + if (languageMode.isRowCommented(startRow - 1) !== isCommented) break startRow-- } @@ -4537,7 +4617,7 @@ class TextEditor { const rowCount = this.getLineCount() while (endRow < rowCount) { if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(endRow + 1))) break - if (this.tokenizedBuffer.isRowCommented(endRow + 1) !== isCommented) break + if (languageMode.isRowCommented(endRow + 1) !== isCommented) break endRow++ } diff --git a/src/tokenized-buffer.js b/src/text-mate-language-mode.js similarity index 72% rename from src/tokenized-buffer.js rename to src/text-mate-language-mode.js index 2a9446256..152636ab7 100644 --- a/src/tokenized-buffer.js +++ b/src/text-mate-language-mode.js @@ -4,27 +4,16 @@ const {Point, Range} = require('text-buffer') const TokenizedLine = require('./tokenized-line') const TokenIterator = require('./token-iterator') const ScopeDescriptor = require('./scope-descriptor') -const TokenizedBufferIterator = require('./tokenized-buffer-iterator') const NullGrammar = require('./null-grammar') const {OnigRegExp} = require('oniguruma') -const {toFirstMateScopeId} = require('./first-mate-helpers') +const {toFirstMateScopeId, fromFirstMateScopeId} = require('./first-mate-helpers') const NON_WHITESPACE_REGEX = /\S/ let nextId = 0 const prefixedScopes = new Map() -module.exports = -class TokenizedBuffer { - static deserialize (state, atomEnvironment) { - const buffer = atomEnvironment.project.bufferForIdSync(state.bufferId) - if (!buffer) return null - - state.buffer = buffer - state.assert = atomEnvironment.assert - return new TokenizedBuffer(state) - } - +class TextMateLanguageMode { constructor (params) { this.emitter = new Emitter() this.disposables = new CompositeDisposable() @@ -32,16 +21,19 @@ class TokenizedBuffer { this.regexesByPattern = {} this.alive = true - this.visible = false + this.tokenizationStarted = false this.id = params.id != null ? params.id : nextId++ this.buffer = params.buffer - this.tabLength = params.tabLength this.largeFileMode = params.largeFileMode - this.assert = params.assert - this.scopedSettingsDelegate = params.scopedSettingsDelegate + this.config = params.config + this.largeFileMode = params.largeFileMode != null + ? params.largeFileMode + : this.buffer.buffer.getLength() >= 2 * 1024 * 1024 - this.setGrammar(params.grammar || NullGrammar) - this.disposables.add(this.buffer.registerTextDecorationLayer(this)) + this.grammar = params.grammar || NullGrammar + this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.scopeName]}) + this.disposables.add(this.grammar.onDidUpdate(() => this.retokenizeLines())) + this.retokenizeLines() } destroy () { @@ -59,6 +51,19 @@ class TokenizedBuffer { return !this.alive } + getGrammar () { + return this.grammar + } + + getLanguageId () { + return this.grammar.scopeName + } + + getNonWordCharacters (position) { + const scope = this.scopeDescriptorForPosition(position) + return this.config.get('editor.nonWordCharacters', {scope}) + } + /* Section - auto-indent */ @@ -68,10 +73,19 @@ class TokenizedBuffer { // * bufferRow - A {Number} indicating the buffer row // // Returns a {Number}. - suggestedIndentForBufferRow (bufferRow, options) { + suggestedIndentForBufferRow (bufferRow, tabLength, options) { const line = this.buffer.lineForRow(bufferRow) const tokenizedLine = this.tokenizedLineForRow(bufferRow) - return this._suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( + bufferRow, + line, + scopeDescriptor, + tabLength, + options + ) } // Get the suggested indentation level for a given line of text, if it were inserted at the given @@ -80,9 +94,17 @@ class TokenizedBuffer { // * bufferRow - A {Number} indicating the buffer row // // Returns a {Number}. - suggestedIndentForLineAtBufferRow (bufferRow, line, options) { + suggestedIndentForLineAtBufferRow (bufferRow, line, tabLength) { const tokenizedLine = this.buildTokenizedLineForRowWithText(bufferRow, line) - return this._suggestedIndentForTokenizedLineAtBufferRow(bufferRow, line, tokenizedLine, options) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( + bufferRow, + line, + scopeDescriptor, + tabLength + ) } // Get the suggested indentation level for a line in the buffer on which the user is currently @@ -93,12 +115,12 @@ class TokenizedBuffer { // * bufferRow - The row {Number} // // Returns a {Number}. - suggestedIndentForEditedBufferRow (bufferRow) { + suggestedIndentForEditedBufferRow (bufferRow, tabLength) { const line = this.buffer.lineForRow(bufferRow) - const currentIndentLevel = this.indentLevelForLine(line) + 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 @@ -108,7 +130,7 @@ class TokenizedBuffer { if (precedingRow == null) return const precedingLine = this.buffer.lineForRow(precedingRow) - let desiredIndentLevel = this.indentLevelForLine(precedingLine) + let desiredIndentLevel = this.indentLevelForLine(precedingLine, tabLength) const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor(scopeDescriptor) if (increaseIndentRegex) { @@ -125,11 +147,7 @@ class TokenizedBuffer { return desiredIndentLevel } - _suggestedIndentForTokenizedLineAtBufferRow (bufferRow, line, tokenizedLine, 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) @@ -144,7 +162,7 @@ class TokenizedBuffer { } const precedingLine = this.buffer.lineForRow(precedingRow) - let desiredIndentLevel = this.indentLevelForLine(precedingLine) + let desiredIndentLevel = this.indentLevelForLine(precedingLine, tabLength) if (!increaseIndentRegex) return desiredIndentLevel if (!this.isRowCommented(precedingRow)) { @@ -164,16 +182,25 @@ class TokenizedBuffer { */ commentStringsForPosition (position) { - if (this.scopedSettingsDelegate) { - const scope = this.scopeDescriptorForPosition(position) - return this.scopedSettingsDelegate.getCommentStrings(scope) - } else { - return {} + const scope = this.scopeDescriptorForPosition(position) + const commentStartEntries = this.config.getAll('editor.commentStart', {scope}) + const commentEndEntries = this.config.getAll('editor.commentEnd', {scope}) + const commentStartEntry = commentStartEntries[0] + const commentEndEntry = commentEndEntries.find((entry) => { + return entry.scopeSelector === commentStartEntry.scopeSelector + }) + return { + commentStartString: commentStartEntry && commentStartEntry.value, + commentEndString: commentEndEntry && commentEndEntry.value } } - buildIterator () { - return new TokenizedBufferIterator(this) + /* + Section - Syntax Highlighting + */ + + buildHighlightIterator () { + return new TextMateHighlightIterator(this) } classNameForScopeId (id) { @@ -196,47 +223,14 @@ class TokenizedBuffer { return [] } - onDidInvalidateRange (fn) { - return this.emitter.on('did-invalidate-range', fn) - } - - serialize () { - return { - deserializer: 'TokenizedBuffer', - bufferPath: this.buffer.getPath(), - bufferId: this.buffer.getId(), - tabLength: this.tabLength, - largeFileMode: this.largeFileMode - } - } - - observeGrammar (callback) { - callback(this.grammar) - return this.onDidChangeGrammar(callback) - } - - onDidChangeGrammar (callback) { - return this.emitter.on('did-change-grammar', callback) + onDidChangeHighlighting (fn) { + return this.emitter.on('did-change-highlighting', fn) } onDidTokenize (callback) { return this.emitter.on('did-tokenize', callback) } - setGrammar (grammar) { - if (!grammar || grammar === this.grammar) return - - this.grammar = grammar - this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.scopeName]}) - - if (this.grammarUpdateDisposable) this.grammarUpdateDisposable.dispose() - this.grammarUpdateDisposable = this.grammar.onDidUpdate(() => this.retokenizeLines()) - this.disposables.add(this.grammarUpdateDisposable) - - this.retokenizeLines() - this.emitter.emit('did-change-grammar', grammar) - } - getGrammarSelectionContent () { return this.buffer.getTextInRange([[0, 0], [10, 0]]) } @@ -264,21 +258,15 @@ class TokenizedBuffer { } } - setVisible (visible) { - this.visible = visible - if (this.visible && this.grammar.name !== 'Null Grammar' && !this.largeFileMode) { + startTokenizing () { + this.tokenizationStarted = true + if (this.grammar.name !== 'Null Grammar' && !this.largeFileMode) { this.tokenizeInBackground() } } - getTabLength () { return this.tabLength } - - setTabLength (tabLength) { - this.tabLength = tabLength - } - tokenizeInBackground () { - if (!this.visible || this.pendingChunk || !this.alive) return + if (!this.tokenizationStarted || this.pendingChunk || !this.alive) return this.pendingChunk = true _.defer(() => { @@ -316,7 +304,7 @@ class TokenizedBuffer { this.validateRow(endRow) if (!filledRegion) this.invalidateRow(endRow + 1) - this.emitter.emit('did-invalidate-range', Range(Point(startRow, 0), Point(endRow + 1, 0))) + this.emitter.emit('did-change-highlighting', Range(Point(startRow, 0), Point(endRow + 1, 0))) } if (this.firstInvalidRow() != null) { @@ -486,18 +474,6 @@ class TokenizedBuffer { while (true) { if (scopes.pop() === matchingStartTag) break if (scopes.length === 0) { - this.assert(false, 'Encountered an unmatched scope end tag.', error => { - error.metadata = { - grammarScopeName: this.grammar.scopeName, - unmatchedEndTag: this.grammar.scopeForId(tag) - } - const path = require('path') - error.privateMetadataDescription = `The contents of \`${path.basename(this.buffer.getPath())}\`` - error.privateMetadata = { - filePath: this.buffer.getPath(), - fileContents: this.buffer.getText() - } - }) break } } @@ -507,7 +483,7 @@ class TokenizedBuffer { return scopes } - indentLevelForLine (line, tabLength = this.tabLength) { + indentLevelForLine (line, tabLength) { let indentLength = 0 for (let i = 0, {length} = line; i < length; i++) { const char = line[i] @@ -629,7 +605,7 @@ class TokenizedBuffer { for (let row = point.row - 1; row >= 0; row--) { const endRow = this.endRowForFoldAtRow(row, tabLength) - if (endRow != null && endRow > point.row) { + if (endRow != null && endRow >= point.row) { return Range(Point(row, Infinity), Point(endRow, Infinity)) } } @@ -712,28 +688,20 @@ class TokenizedBuffer { return foldEndRow } - increaseIndentRegexForScopeDescriptor (scopeDescriptor) { - if (this.scopedSettingsDelegate) { - return this.regexForPattern(this.scopedSettingsDelegate.getIncreaseIndentPattern(scopeDescriptor)) - } + increaseIndentRegexForScopeDescriptor (scope) { + return this.regexForPattern(this.config.get('editor.increaseIndentPattern', {scope})) } - decreaseIndentRegexForScopeDescriptor (scopeDescriptor) { - if (this.scopedSettingsDelegate) { - return this.regexForPattern(this.scopedSettingsDelegate.getDecreaseIndentPattern(scopeDescriptor)) - } + decreaseIndentRegexForScopeDescriptor (scope) { + return this.regexForPattern(this.config.get('editor.decreaseIndentPattern', {scope})) } - decreaseNextIndentRegexForScopeDescriptor (scopeDescriptor) { - if (this.scopedSettingsDelegate) { - return this.regexForPattern(this.scopedSettingsDelegate.getDecreaseNextIndentPattern(scopeDescriptor)) - } + decreaseNextIndentRegexForScopeDescriptor (scope) { + return this.regexForPattern(this.config.get('editor.decreaseNextIndentPattern', {scope})) } - foldEndRegexForScopeDescriptor (scopes) { - if (this.scopedSettingsDelegate) { - return this.regexForPattern(this.scopedSettingsDelegate.getFoldEndPattern(scopes)) - } + foldEndRegexForScopeDescriptor (scope) { + return this.regexForPattern(this.config.get('editor.foldEndPattern', {scope})) } regexForPattern (pattern) { @@ -753,7 +721,7 @@ class TokenizedBuffer { } } -module.exports.prototype.chunkSize = 50 +TextMateLanguageMode.prototype.chunkSize = 50 function selectorMatchesAnyScope (selector, scopes) { const targetClasses = selector.replace(/^\./, '').split('.') @@ -762,3 +730,142 @@ function selectorMatchesAnyScope (selector, scopes) { return _.isSubset(targetClasses, scopeClasses) }) } + +class TextMateHighlightIterator { + constructor (languageMode) { + this.languageMode = languageMode + this.openScopeIds = null + this.closeScopeIds = null + } + + seek (position) { + this.openScopeIds = [] + this.closeScopeIds = [] + this.tagIndex = null + + const currentLine = this.languageMode.tokenizedLineForRow(position.row) + this.currentLineTags = currentLine.tags + this.currentLineLength = currentLine.text.length + const containingScopeIds = currentLine.openScopes.map((id) => fromFirstMateScopeId(id)) + + let currentColumn = 0 + for (let index = 0; index < this.currentLineTags.length; index++) { + const tag = this.currentLineTags[index] + if (tag >= 0) { + if (currentColumn >= position.column) { + this.tagIndex = index + break + } else { + currentColumn += tag + while (this.closeScopeIds.length > 0) { + this.closeScopeIds.shift() + containingScopeIds.pop() + } + while (this.openScopeIds.length > 0) { + const openTag = this.openScopeIds.shift() + containingScopeIds.push(openTag) + } + } + } else { + const scopeId = fromFirstMateScopeId(tag) + if ((tag & 1) === 0) { + if (this.openScopeIds.length > 0) { + if (currentColumn >= position.column) { + this.tagIndex = index + break + } else { + while (this.closeScopeIds.length > 0) { + this.closeScopeIds.shift() + containingScopeIds.pop() + } + while (this.openScopeIds.length > 0) { + const openTag = this.openScopeIds.shift() + containingScopeIds.push(openTag) + } + } + } + this.closeScopeIds.push(scopeId) + } else { + this.openScopeIds.push(scopeId) + } + } + } + + if (this.tagIndex == null) { + this.tagIndex = this.currentLineTags.length + } + this.position = Point(position.row, Math.min(this.currentLineLength, currentColumn)) + return containingScopeIds + } + + moveToSuccessor () { + this.openScopeIds = [] + this.closeScopeIds = [] + while (true) { + if (this.tagIndex === this.currentLineTags.length) { + if (this.isAtTagBoundary()) { + break + } else if (!this.moveToNextLine()) { + return false + } + } else { + const tag = this.currentLineTags[this.tagIndex] + if (tag >= 0) { + if (this.isAtTagBoundary()) { + break + } else { + this.position = Point(this.position.row, Math.min( + this.currentLineLength, + this.position.column + this.currentLineTags[this.tagIndex] + )) + } + } else { + const scopeId = fromFirstMateScopeId(tag) + if ((tag & 1) === 0) { + if (this.openScopeIds.length > 0) { + break + } else { + this.closeScopeIds.push(scopeId) + } + } else { + this.openScopeIds.push(scopeId) + } + } + this.tagIndex++ + } + } + return true + } + + getPosition () { + return this.position + } + + getCloseScopeIds () { + return this.closeScopeIds.slice() + } + + getOpenScopeIds () { + return this.openScopeIds.slice() + } + + moveToNextLine () { + this.position = Point(this.position.row + 1, 0) + const tokenizedLine = this.languageMode.tokenizedLineForRow(this.position.row) + if (tokenizedLine == null) { + return false + } else { + this.currentLineTags = tokenizedLine.tags + this.currentLineLength = tokenizedLine.text.length + this.tagIndex = 0 + return true + } + } + + isAtTagBoundary () { + return this.closeScopeIds.length > 0 || this.openScopeIds.length > 0 + } +} + +TextMateLanguageMode.TextMateHighlightIterator = TextMateHighlightIterator +module.exports = TextMateLanguageMode diff --git a/src/theme-manager.js b/src/theme-manager.js index 6abf0fc74..68a5eb45a 100644 --- a/src/theme-manager.js +++ b/src/theme-manager.js @@ -50,6 +50,8 @@ class ThemeManager { // updating the list of active themes have completed. // // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangeActiveThemes (callback) { return this.emitter.on('did-change-active-themes', callback) } @@ -134,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/token-iterator.js b/src/token-iterator.js index a698fc748..87d41be37 100644 --- a/src/token-iterator.js +++ b/src/token-iterator.js @@ -1,7 +1,7 @@ module.exports = class TokenIterator { - constructor (tokenizedBuffer) { - this.tokenizedBuffer = tokenizedBuffer + constructor (languageMode) { + this.languageMode = languageMode } reset (line) { @@ -9,7 +9,7 @@ class TokenIterator { this.index = null this.startColumn = 0 this.endColumn = 0 - this.scopes = this.line.openScopes.map(id => this.tokenizedBuffer.grammar.scopeForId(id)) + this.scopes = this.line.openScopes.map(id => this.languageMode.grammar.scopeForId(id)) this.scopeStarts = this.scopes.slice() this.scopeEnds = [] return this @@ -30,7 +30,7 @@ class TokenIterator { while (this.index < tags.length) { const tag = tags[this.index] if (tag < 0) { - const scope = this.tokenizedBuffer.grammar.scopeForId(tag) + const scope = this.languageMode.grammar.scopeForId(tag) if ((tag % 2) === 0) { if (this.scopeStarts[this.scopeStarts.length - 1] === scope) { this.scopeStarts.pop() diff --git a/src/tokenized-buffer-iterator.js b/src/tokenized-buffer-iterator.js deleted file mode 100644 index d22f97874..000000000 --- a/src/tokenized-buffer-iterator.js +++ /dev/null @@ -1,138 +0,0 @@ -const {Point} = require('text-buffer') -const {fromFirstMateScopeId} = require('./first-mate-helpers') - -module.exports = class TokenizedBufferIterator { - constructor (tokenizedBuffer) { - this.tokenizedBuffer = tokenizedBuffer - this.openScopeIds = null - this.closeScopeIds = null - } - - seek (position) { - this.openScopeIds = [] - this.closeScopeIds = [] - this.tagIndex = null - - const currentLine = this.tokenizedBuffer.tokenizedLineForRow(position.row) - this.currentLineTags = currentLine.tags - this.currentLineLength = currentLine.text.length - const containingScopeIds = currentLine.openScopes.map((id) => fromFirstMateScopeId(id)) - - let currentColumn = 0 - for (let index = 0; index < this.currentLineTags.length; index++) { - const tag = this.currentLineTags[index] - if (tag >= 0) { - if (currentColumn >= position.column) { - this.tagIndex = index - break - } else { - currentColumn += tag - while (this.closeScopeIds.length > 0) { - this.closeScopeIds.shift() - containingScopeIds.pop() - } - while (this.openScopeIds.length > 0) { - const openTag = this.openScopeIds.shift() - containingScopeIds.push(openTag) - } - } - } else { - const scopeId = fromFirstMateScopeId(tag) - if ((tag & 1) === 0) { - if (this.openScopeIds.length > 0) { - if (currentColumn >= position.column) { - this.tagIndex = index - break - } else { - while (this.closeScopeIds.length > 0) { - this.closeScopeIds.shift() - containingScopeIds.pop() - } - while (this.openScopeIds.length > 0) { - const openTag = this.openScopeIds.shift() - containingScopeIds.push(openTag) - } - } - } - this.closeScopeIds.push(scopeId) - } else { - this.openScopeIds.push(scopeId) - } - } - } - - if (this.tagIndex == null) { - this.tagIndex = this.currentLineTags.length - } - this.position = Point(position.row, Math.min(this.currentLineLength, currentColumn)) - return containingScopeIds - } - - moveToSuccessor () { - this.openScopeIds = [] - this.closeScopeIds = [] - while (true) { - if (this.tagIndex === this.currentLineTags.length) { - if (this.isAtTagBoundary()) { - break - } else if (!this.moveToNextLine()) { - return false - } - } else { - const tag = this.currentLineTags[this.tagIndex] - if (tag >= 0) { - if (this.isAtTagBoundary()) { - break - } else { - this.position = Point(this.position.row, Math.min( - this.currentLineLength, - this.position.column + this.currentLineTags[this.tagIndex] - )) - } - } else { - const scopeId = fromFirstMateScopeId(tag) - if ((tag & 1) === 0) { - if (this.openScopeIds.length > 0) { - break - } else { - this.closeScopeIds.push(scopeId) - } - } else { - this.openScopeIds.push(scopeId) - } - } - this.tagIndex++ - } - } - return true - } - - getPosition () { - return this.position - } - - getCloseScopeIds () { - return this.closeScopeIds.slice() - } - - getOpenScopeIds () { - return this.openScopeIds.slice() - } - - moveToNextLine () { - this.position = Point(this.position.row + 1, 0) - const tokenizedLine = this.tokenizedBuffer.tokenizedLineForRow(this.position.row) - if (tokenizedLine == null) { - return false - } else { - this.currentLineTags = tokenizedLine.tags - this.currentLineLength = tokenizedLine.text.length - this.tagIndex = 0 - return true - } - } - - isAtTagBoundary () { - return this.closeScopeIds.length > 0 || this.openScopeIds.length > 0 - } -} 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..af4bbdc1a --- /dev/null +++ b/src/tree-sitter-language-mode.js @@ -0,0 +1,527 @@ +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 nodeContainsTarget = true + for (;;) { + 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) + } + } + + node = node.firstChildForIndex(this.currentIndex) + if (node) { + if (node.startIndex > this.currentIndex) nodeContainsTarget = false + childIndex = node.childIndex + } else { + break + } + } + + 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/src/workspace-element.js b/src/workspace-element.js index bd0e1b971..c9a30af85 100644 --- a/src/workspace-element.js +++ b/src/workspace-element.js @@ -104,6 +104,7 @@ class WorkspaceElement extends HTMLElement { this.addEventListener('mousewheel', this.handleMousewheel.bind(this), true) window.addEventListener('dragstart', this.handleDragStart) + window.addEventListener('mousemove', this.handleEdgesMouseMove) this.panelContainers = { top: this.model.panelContainers.top.getElement(), @@ -132,6 +133,10 @@ class WorkspaceElement extends HTMLElement { return this } + destroy () { + this.subscriptions.dispose() + } + getModel () { return this.model } handleDragStart (event) { @@ -169,7 +174,6 @@ class WorkspaceElement extends HTMLElement { // being hovered. this.cursorInCenter = false this.updateHoveredDock({x: event.pageX, y: event.pageY}) - window.addEventListener('mousemove', this.handleEdgesMouseMove) window.addEventListener('dragend', this.handleDockDragEnd) } @@ -199,7 +203,6 @@ class WorkspaceElement extends HTMLElement { checkCleanupDockHoverEvents () { if (this.cursorInCenter && !this.hoveredDock) { - window.removeEventListener('mousemove', this.handleEdgesMouseMove) window.removeEventListener('dragend', this.handleDockDragEnd) } } diff --git a/src/workspace.js b/src/workspace.js index defb43df0..66e7f8ba5 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -310,7 +310,10 @@ module.exports = class Workspace extends Model { this.originalFontSize = null this.openers = [] this.destroyedItemURIs = [] - this.element = null + if (this.element) { + this.element.destroy() + this.element = null + } this.consumeServices(this.packageManager) } @@ -494,10 +497,12 @@ module.exports = class Workspace extends Model { if (item instanceof TextEditor) { const subscriptions = new CompositeDisposable( this.textEditorRegistry.add(item), - this.textEditorRegistry.maintainGrammar(item), this.textEditorRegistry.maintainConfig(item), item.observeGrammar(this.handleGrammarUsed.bind(this)) ) + if (!this.project.findBufferForId(item.buffer.id)) { + this.project.addBuffer(item.buffer) + } item.onDidDestroy(() => { subscriptions.dispose() }) this.emitter.emit('did-add-text-editor', {textEditor: item, pane, index}) } @@ -1158,16 +1163,17 @@ module.exports = class Workspace extends Model { // * `uri` A {String} containing a URI. // // Returns a {Promise} that resolves to the {TextEditor} (or other item) for the given URI. - createItemForURI (uri, options) { + async createItemForURI (uri, options) { if (uri != null) { - for (let opener of this.getOpeners()) { + for (const opener of this.getOpeners()) { const item = opener(uri, options) - if (item != null) return Promise.resolve(item) + if (item != null) return item } } try { - return this.openTextFile(uri, options) + const item = await this.openTextFile(uri, options) + return item } catch (error) { switch (error.code) { case 'CANCELLED': @@ -1197,7 +1203,7 @@ module.exports = class Workspace extends Model { } } - openTextFile (uri, options) { + async openTextFile (uri, options) { const filePath = this.project.resolvePath(uri) if (filePath != null) { @@ -1213,24 +1219,37 @@ module.exports = class Workspace extends Model { const fileSize = fs.getSizeSync(filePath) - const largeFileMode = fileSize >= (2 * 1048576) // 2MB - if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { // 20MB by default - const choice = this.applicationDelegate.confirm({ + let [resolveConfirmFileOpenPromise, rejectConfirmFileOpenPromise] = [] + const confirmFileOpenPromise = new Promise((resolve, reject) => { + resolveConfirmFileOpenPromise = resolve + rejectConfirmFileOpenPromise = reject + }) + + if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { // 40MB by default + this.applicationDelegate.confirm({ message: 'Atom will be unresponsive during the loading of very large files.', - detailedMessage: 'Do you still want to load this file?', + detail: 'Do you still want to load this file?', buttons: ['Proceed', 'Cancel'] + }, response => { + if (response === 1) { + rejectConfirmFileOpenPromise() + } else { + resolveConfirmFileOpenPromise() + } }) - if (choice === 1) { - const error = new Error() - error.code = 'CANCELLED' - throw error - } + } else { + resolveConfirmFileOpenPromise() } - return this.project.bufferForPath(filePath, options) - .then(buffer => { - return this.textEditorRegistry.build(Object.assign({buffer, largeFileMode, autoHeight: false}, options)) - }) + try { + await confirmFileOpenPromise + const buffer = await this.project.bufferForPath(filePath, options) + return this.textEditorRegistry.build(Object.assign({buffer, autoHeight: false}, options)) + } catch (e) { + const error = new Error() + error.code = 'CANCELLED' + throw error + } } handleGrammarUsed (grammar) { @@ -1250,11 +1269,8 @@ module.exports = class Workspace extends Model { // Returns a {TextEditor}. buildTextEditor (params) { const editor = this.textEditorRegistry.build(params) - const subscriptions = new CompositeDisposable( - this.textEditorRegistry.maintainGrammar(editor), - this.textEditorRegistry.maintainConfig(editor) - ) - editor.onDidDestroy(() => { subscriptions.dispose() }) + const subscription = this.textEditorRegistry.maintainConfig(editor) + editor.onDidDestroy(() => subscription.dispose()) return editor } @@ -1557,6 +1573,7 @@ module.exports = class Workspace extends Model { if (this.activeItemSubscriptions != null) { this.activeItemSubscriptions.dispose() } + if (this.element) this.element.destroy() } /* @@ -1990,25 +2007,22 @@ module.exports = class Workspace extends Model { checkoutHeadRevision (editor) { if (editor.getPath()) { - const checkoutHead = () => { - return this.project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) - .then(repository => repository && repository.checkoutHeadForEditor(editor)) + const checkoutHead = async () => { + const repository = await this.project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) + if (repository) repository.checkoutHeadForEditor(editor) } if (this.config.get('editor.confirmCheckoutHeadRevision')) { this.applicationDelegate.confirm({ message: 'Confirm Checkout HEAD Revision', - detailedMessage: `Are you sure you want to discard all changes to "${editor.getFileName()}" since the last Git commit?`, - buttons: { - OK: checkoutHead, - Cancel: null - } + detail: `Are you sure you want to discard all changes to "${editor.getFileName()}" since the last Git commit?`, + buttons: ['OK', 'Cancel'] + }, response => { + if (response === 0) checkoutHead() }) } else { - return checkoutHead() + checkoutHead() } - } else { - return Promise.resolve(false) } } } diff --git a/static/docks.less b/static/docks.less index ca40a2c45..283402e09 100644 --- a/static/docks.less +++ b/static/docks.less @@ -16,11 +16,6 @@ atom-dock { .atom-dock-inner { display: flex; - // Keep the area at least a pixel wide so that you have something to hover - // over to trigger the toggle button affordance even when fullscreen. - &.left, &.right { min-width: 1px; } - &.bottom { min-height: 1px; } - &.bottom { width: 100%; } &.left, &.right { height: 100%; }