diff --git a/.coffeelintignore b/.coffeelintignore new file mode 100644 index 00000000000..1db51fed757 --- /dev/null +++ b/.coffeelintignore @@ -0,0 +1 @@ +spec/fixtures diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..b54c3b8df0a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +**/spec/fixtures/**/*.js +node_modules +/vendor/ +/out/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000000..62995e39c06 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "extends": [ + "./script/node_modules/eslint-config-standard/eslintrc.json", + "./script/node_modules/eslint-config-prettier/index.js", + "./script/node_modules/eslint-config-prettier/standard.js" + ], + "plugins": [ + "prettier" + ], + "env": { + "browser": true, + "node": true + }, + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 8, + "ecmaFeatures": { + "jsx": true + } + }, + "globals": { + "atom": true, + "snapshotResult": true + }, + "rules": { + "standard/no-callback-literal": ["off"], + "node/no-deprecated-api": ["off"], + "prettier/prettier": ["error"] + }, + "overrides": [ + { + "files": ["spec/**", "**-spec.js", "**.test.js"], + "env": { + "jasmine": true + }, + "globals": { + "advanceClock": true, + "fakeClearInterval": true, + "fakeSetInterval": true, + "waitsForPromise": true + } + } + ] +} diff --git a/.gitattributes b/.gitattributes index 6eb1a23ae9a..d2728b92676 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,16 @@ spec/fixtures/css.css text eol=lf spec/fixtures/sample.js text eol=lf spec/fixtures/sample.less text eol=lf spec/fixtures/sample.txt text eol=lf + +# Windows bash scripts are also Unix LF endings +*.sh eol=lf + +# The script executables should be LF so they can be edited on Windows +script/bootstrap text eol=lf +script/build text eol=lf +script/cibuild text eol=lf +script/clean text eol=lf +script/lint text eol=lf +script/postprocess-junit-results text eol=lf +script/test text eol=lf +script/verify-snapshot-script text eol=lf diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 00000000000..ba43e69a4b9 --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,17 @@ +# Configuration for lock-threads - https://github.com/dessant/lock-threads + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 180 +# Comment to post before locking. Set to `false` to disable +lockComment: > + This issue has been automatically locked since there has not been + any recent activity after it was closed. If you can still reproduce this issue in + [Safe Mode](https://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode) + then please open a new issue and fill out + [the entire issue template](https://github.com/atom/.github/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) + to ensure that we have enough information to address your issue. Thanks! +# Issues or pull requests with these labels will not be locked +exemptLabels: + - help-wanted +# Limit to only `issues` or `pulls` +only: issues diff --git a/.github/move.yml b/.github/move.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 00000000000..1c8799d1351 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,15 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an issue is closed for lack of response +daysUntilClose: 28 + +# Label requiring a response +responseRequiredLabel: more-information-needed + +# Comment to post when closing an issue for lack of response. Set to `false` to disable. +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. Please reach out if you have or find the answers we need so + that we can investigate further. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000000..4888a3bb691 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,35 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 365 +# Number of days of inactivity before a stale Issue or Pull Request is closed +daysUntilClose: 14 +# Issues or Pull Requests with these labels will never be considered stale +exemptLabels: + - regression + - security + - triaged +# Label to use when marking as stale +staleLabel: stale +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + Thanks for your contribution! + + This issue has been automatically marked as stale because it has not had + recent activity. Because the Atom team treats their issues + [as their backlog](https://en.wikipedia.org/wiki/Scrum_(software_development)#Product_backlog), stale issues + are closed. If you would like this issue to remain open: + + 1. Verify that you can still reproduce the issue in the latest version of Atom + 1. Comment that the issue is still reproducible and include: + * What version of Atom you reproduced the issue on + * What OS and version you reproduced the issue on + * What steps you followed to reproduce the issue + + Issues that are labeled as triaged will not be automatically marked as stale. +# Comment to post when removing the stale label. Set to `false` to disable +unmarkComment: false +# Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable +closeComment: false +# Limit to only `issues` or `pulls` +only: issues diff --git a/.gitignore b/.gitignore index 1257ab37108..c743195e322 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,21 @@ *.swp *~ .DS_Store +.eslintcache Thumbs.db .project .svn .nvm-version +.vscode +.python-version node_modules -npm-debug.log -debug.log +*.log /tags /atom-shell/ +/out/ docs/output docs/includes spec/fixtures/evil-files/ +!spec/fixtures/packages/package-with-incompatible-native-module-loaded-conditionally/node_modules/ +out/ +/electron/ diff --git a/.node-version b/.node-version deleted file mode 100644 index 87a1cf595a6..00000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -v0.12.0 diff --git a/.pairs b/.pairs deleted file mode 100644 index 2955310284b..00000000000 --- 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/.prettierrc b/.prettierrc new file mode 100644 index 00000000000..544138be456 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/.python-version b/.python-version deleted file mode 100644 index 49cdd668e1c..00000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -2.7.6 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ab65307ae82..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -git: - depth: 10 - -env: - global: - - ATOM_ACCESS_TOKEN=da809a6077bb1b0aa7c5623f7b2d5f1fec2faae4 - - matrix: - - NODE_VERSION=0.12 - -os: - - linux - - osx - -sudo: false - -install: - - git clone https://github.com/creationix/nvm.git /tmp/.nvm - - source /tmp/.nvm/nvm.sh - - nvm install $NODE_VERSION - - nvm use $NODE_VERSION - -script: script/cibuild - -notifications: - email: - on_success: never - on_failure: change - -addons: - apt: - packages: - - build-essential - - git - - libgnome-keyring-dev - - fakeroot diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..d503b174046 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [atom@github.com](mailto:atom@github.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +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]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe9a742b470..6e7e80bfb0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,90 +2,231 @@ :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: -The following is a set of guidelines for contributing to Atom and its packages, -which are hosted in the [Atom Organization](https://github.com/atom) on GitHub. -These are just guidelines, not rules, use your best judgment and feel free to -propose changes to this document in a pull request. - -## Submitting Issues - -* You can create an issue [here](https://github.com/atom/atom/issues/new), but - before doing that please read the notes below on debugging and submitting issues, - and include as many details as possible with your report. -* Check the [debugging guide](https://atom.io/docs/latest/hacking-atom-debugging) for tips - on debugging. You might be able to find the cause of the problem and fix - things yourself. -* Include the version of Atom you are using and the OS. -* Include screenshots and animated GIFs whenever possible; they are immensely - helpful. -* Include the behavior you expected and other places you've seen that behavior - such as Emacs, vi, Xcode, etc. -* Check the dev tools (`alt-cmd-i`) for errors to include. If the dev tools - are open _before_ the error is triggered, a full stack trace for the error - will be logged. If you can reproduce the error, use this approach to get the - full stack trace and include it in the issue. -* On Mac, check Console.app for stack traces to include if reporting a crash. -* Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Aatom) - to see if a similar issue has already been submitted. -* Please setup a [profile picture](https://help.github.com/articles/how-do-i-set-up-my-profile-picture) - to make yourself recognizable and so we can all get to know each other better. - -### Package Repositories - -This is the repository for the core Atom editor only. Atom comes bundled with -many packages and themes that are stored in other repos under the -[Atom organization](https://github.com/atom) such as -[tabs](https://github.com/atom/tabs), -[find-and-replace](https://github.com/atom/find-and-replace), -[language-javascript](https://github.com/atom/language-javascript), and -[atom-light-ui](https://github.com/atom/atom-light-ui). - -If your issue is related to a specific package, open an issue on that package's -issue tracker. If you're unsure which package is causing your problem or if -you're having an issue with Atom core, open an issue on this repository. - -For more information on how to work with Atom's official packages, see -[Contributing to Atom Packages](https://github.com/atom/atom/blob/master/docs/contributing-to-packages.md) - -## Pull Requests - -* Include screenshots and animated GIFs in your pull request whenever possible. -* Follow the [CoffeeScript](#coffeescript-styleguide), - [JavaScript](https://github.com/styleguide/javascript), - and [CSS](https://github.com/styleguide/css) styleguides. -* Include thoughtfully-worded, well-structured - [Jasmine](http://jasmine.github.io/) specs in the `./spec` folder. Run them using `apm test`. See the [Specs Styleguide](#specs-styleguide) below. -* Document new code based on the - [Documentation Styleguide](#documentation-styleguide) -* End files with a newline. -* Place requires in the following order: - * Built in Node Modules (such as `path`) - * Built in Atom and Atom Shell Modules (such as `atom`, `shell`) - * Local Modules (using relative paths) -* Place class properties in the following order: - * Class methods and properties (methods starting with a `@`) - * Instance methods and properties -* Avoid platform-dependent code: - * Use `require('fs-plus').getHomeDirectory()` to get the home directory. - * Use `path.join()` to concatenate filenames. - * Use `os.tmpdir()` rather than `/tmp` when you need to reference the - temporary directory. -* Using a plain `return` when returning explicitly at the end of a function. - * Not `return null`, `return undefined`, `null`, or `undefined` +The following is a set of guidelines for contributing to Atom and its packages, which are hosted in the [Atom Organization](https://github.com/atom) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +#### Table Of Contents + +[Code of Conduct](#code-of-conduct) + +[I don't want to read this whole thing, I just have a question!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question) + +[What should I know before I get started?](#what-should-i-know-before-i-get-started) + * [Atom and Packages](#atom-and-packages) + * [Atom Design Decisions](#design-decisions) + +[How Can I Contribute?](#how-can-i-contribute) + * [Reporting Bugs](#reporting-bugs) + * [Suggesting Enhancements](#suggesting-enhancements) + * [Your First Code Contribution](#your-first-code-contribution) + * [Pull Requests](#pull-requests) + +[Styleguides](#styleguides) + * [Git Commit Messages](#git-commit-messages) + * [JavaScript Styleguide](#javascript-styleguide) + * [CoffeeScript Styleguide](#coffeescript-styleguide) + * [Specs Styleguide](#specs-styleguide) + * [Documentation Styleguide](#documentation-styleguide) + +[Additional Notes](#additional-notes) + * [Issue and Pull Request Labels](#issue-and-pull-request-labels) + +## Code of Conduct + +This project and everyone participating in it is governed by the [Atom Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [atom@github.com](mailto:atom@github.com). + +## 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.](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. + +* [Github Discussions, the official Atom message board](https://github.com/atom/atom/discussions) +* [Atom FAQ](https://flight-manual.atom.io/faq/) + +## What should I know before I get started? + +### Atom and Packages + +Atom is a large open source project — it's made up of over [200 repositories](https://github.com/atom). When you initially consider contributing to Atom, you might be unsure about which of those 200 repositories implements the functionality you want to change or report a bug for. This section should help you with that. + +Atom is intentionally very modular. Nearly every non-editor UI element you interact with comes from a package, even fundamental things like tabs and the status-bar. These packages are packages in the same way that packages in the [Atom package repository](https://atom.io/packages) are packages, with one difference: they are bundled into the [default distribution](https://github.com/atom/atom/blob/10b8de6fc499a7def9b072739486e68530d67ab4/package.json#L58). + + + +![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. + +Here's a list of the big ones: + +* [atom/atom](https://github.com/atom/atom) - Atom Core! The core editor component is responsible for basic text editing (e.g. cursors, selections, scrolling), text indentation, wrapping, and folding, text rendering, editor rendering, file system operations (e.g. saving), and installation and auto-updating. You should also use this repository for feedback related to the [Atom API](https://atom.io/docs/api/latest) and for large, overarching design proposals. +* [tree-view](https://github.com/atom/tree-view) - file and directory listing on the left of the UI. +* [fuzzy-finder](https://github.com/atom/fuzzy-finder) - the quick file opener. +* [find-and-replace](https://github.com/atom/find-and-replace) - all search and replace functionality. +* [tabs](https://github.com/atom/tabs) - the tabs for open editors at the top of the UI. +* [status-bar](https://github.com/atom/status-bar) - the status bar at the bottom of the UI. +* [markdown-preview](https://github.com/atom/markdown-preview) - the rendered markdown pane item. +* [settings-view](https://github.com/atom/settings-view) - the settings UI pane item. +* [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 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 for 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). + +There are many more, but this list should be a good starting point. For more information on how to work with Atom's official packages, see [Contributing to Atom Packages][contributing-to-official-atom-packages]. + +Also, because Atom is so extensible, it's possible that a feature you've become accustomed to in Atom or an issue you're encountering isn't coming from a bundled package at all, but rather a [community package](https://atom.io/packages) you've installed. Each community package has its own repository too. + +#### Package Conventions + +There are a few conventions that have developed over time around packages: + +* Packages that add one or more syntax highlighting grammars are named `language-[language-name]` + * Language packages can add other things besides just a grammar. Many offer commonly-used snippets. Try not to add too much though. +* Theme packages are split into two categories: UI and Syntax themes + * UI themes are named `[theme-name]-ui` + * Syntax themes are named `[theme-name]-syntax` + * Often themes that are designed to work together are given the same root name, for example: `one-dark-ui` and `one-dark-syntax` + * UI themes style everything outside of the editor pane — all of the green areas in the [packages image above](#atom-packages-image) + * Syntax themes style just the items inside the editor pane, mostly syntax highlighting +* Packages that add [autocomplete providers](https://github.com/atom/autocomplete-plus/wiki/Autocomplete-Providers) are named `autocomplete-[what-they-autocomplete]` — ex: [autocomplete-css](https://github.com/atom/autocomplete-css) + +### Design Decisions + +When we make a significant decision in how we maintain the project and what we can or cannot support, we will document it in the [atom/design-decisions repository](https://github.com/atom/design-decisions). If you have a question around how we do things, check to see if it is documented there. If it is *not* documented there, please open a new topic on [Github Discussions, the official Atom message board](https://github.com/atom/atom/discussions) and ask your question. + +## How Can I Contribute? + +### Reporting Bugs + +This section guides you through submitting a bug report for Atom. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer: :computer:, and find related reports :mag_right:. + +Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). Fill out [the required template](https://github.com/atom/.github/blob/master/.github/ISSUE_TEMPLATE/bug_report.md), the information it asks for helps us resolve issues faster. + +> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. + +#### Before Submitting A Bug Report + +* **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 [faq](https://flight-manual.atom.io/faq/) and the [discussions](https://github.com/atom/atom/discussions)** 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/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? + +Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined [which repository](#atom-and-packages) your bug is related to, create an issue on that repository and provide the following information by filling in [the template](https://github.com/atom/.github/blob/master/.github/ISSUE_TEMPLATE/bug_report.md). + +Explain the problem and include additional details to help maintainers reproduce the problem: + +* **Use a clear and descriptive title** for the issue to identify the problem. +* **Describe the exact steps which reproduce the problem** in as many details as possible. For example, start by explaining how you started Atom, e.g. which command exactly you used in the terminal, or how you started Atom otherwise. When listing steps, **don't just say what you did, but explain how you did it**. For example, if you moved the cursor to the end of a line, explain if you used the mouse, or a keyboard shortcut or an Atom command, and if so which one? +* **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](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](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](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. +* If the problem is related to working with files (e.g. opening and editing files), **does the problem happen for all files and projects or only some?** Does the problem happen only when working with local or remote files (e.g. on network drives), with files of a specific type (e.g. only JavaScript or Python files), with large files or files with very long lines, or with files in a specific encoding? Is there anything else special about the files you are using? + +Include details about your configuration and environment: + +* **Which version of Atom are you 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). +* **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](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? + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Atom, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:. + +Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in [the template](https://github.com/atom/.github/blob/master/.github/ISSUE_TEMPLATE/feature_request.md), including the steps that you imagine you would take if the feature you're requesting existed. -## Git Commit Messages +#### Before Submitting An Enhancement Suggestion + +* **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/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? + +Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined [which repository](#atom-and-packages) your enhancement suggestion is related to, create an issue on that repository and provide the following information: + +* **Use a clear and descriptive title** for the issue to identify the suggestion. +* **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](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). +* **Specify the name and version of the OS you're using.** + +### Your First Code Contribution + +Unsure where to begin contributing to Atom? You can start by looking through these `beginner` and `help-wanted` issues: + +* [Beginner issues][beginner] - issues which should only require a few lines of code, and a test or two. +* [Help wanted issues][help-wanted] - issues which should be a bit more involved than `beginner` issues. + +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](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](https://flight-manual.atom.io): + +* [Hacking on Atom Core][hacking-on-atom-core] +* [Contributing to Official Atom Packages][contributing-to-official-atom-packages] + +### Pull Requests + +The process described here has several goals: + +- Maintain Atom's quality +- Fix problems that are important to users +- Engage the community in working toward the best possible Atom +- Enable a sustainable system for Atom's maintainers to review contributions + +Please follow these steps to have your contribution considered by the maintainers: + +1. Follow all instructions in [the template](PULL_REQUEST_TEMPLATE.md) +2. Follow the [styleguides](#styleguides) +3. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing
What if the status checks are failing?If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.
+ +While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted. + +## Styleguides + +### Git Commit Messages * Use the present tense ("Add feature" not "Added feature") * 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 +* Reference issues and pull requests liberally after the first line +* 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 * :non-potable_water: `:non-potable_water:` when plugging memory leaks * :memo: `:memo:` when writing docs * :penguin: `:penguin:` when fixing something on Linux - * :apple: `:apple:` when fixing something on Mac OS + * :apple: `:apple:` when fixing something on macOS * :checkered_flag: `:checkered_flag:` when fixing something on Windows * :bug: `:bug:` when fixing a bug * :fire: `:fire:` when removing code or files @@ -96,10 +237,40 @@ For more information on how to work with Atom's official packages, see * :arrow_down: `:arrow_down:` when downgrading dependencies * :shirt: `:shirt:` when removing linter warnings -## CoffeeScript Styleguide +### JavaScript Styleguide + +All JavaScript code is linted with [Prettier](https://prettier.io/). + +* Prefer the object spread operator (`{...anotherObj}`) to `Object.assign()` +* Inline `export`s with expressions whenever possible + ```js + // Use this: + export default class ClassName { + + } + + // Instead of: + class ClassName { + + } + export default ClassName + ``` +* Place requires in the following order: + * Built in Node Modules (such as `path`) + * Built in Atom and Electron Modules (such as `atom`, `remote`) + * Local Modules (using relative paths) +* Place class properties in the following order: + * Class methods and properties (methods starting with `static`) + * Instance methods and properties +* [Avoid platform-dependent code](https://flight-manual.atom.io/hacking-atom/sections/cross-platform-compatibility/) + +### CoffeeScript Styleguide * Set parameter defaults without spaces around the equal sign * `clear = (count=1) ->` instead of `clear = (count = 1) ->` +* Use spaces around operators + * `count + 1` instead of `count+1` +* Use spaces after commas (unless separated by newlines) * Use parentheses if it improves code clarity. * Prefer alphabetic keywords to symbolic keywords: * `a is b` instead of `a == b` @@ -113,15 +284,24 @@ For more information on how to work with Atom's official packages, see * Use `slice()` to copy an array * Add an explicit `return` when your function ends with a `for`/`while` loop and you don't want it to return a collected array. +* Use `this` instead of a standalone `@` + * `return this` instead of `return @` +* Place requires in the following order: + * Built in Node Modules (such as `path`) + * Built in Atom and Electron Modules (such as `atom`, `remote`) + * Local Modules (using relative paths) +* Place class properties in the following order: + * Class methods and properties (methods starting with a `@`) + * Instance methods and properties +* [Avoid platform-dependent code](https://flight-manual.atom.io/hacking-atom/sections/cross-platform-compatibility/) -## Specs Styleguide +### Specs Styleguide -- Include thoughtfully-worded, well-structured - [Jasmine](http://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. +- 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. -### Example +#### Example ```coffee describe 'a dog', -> @@ -132,7 +312,7 @@ describe 'a dog', -> # spec here ``` -## Documentation Styleguide +### Documentation Styleguide * Use [AtomDoc](https://github.com/atom/atomdoc). * Use [Markdown](https://daringfireball.net/projects/markdown). @@ -141,7 +321,7 @@ describe 'a dog', -> * Reference instance methods with `{ClassName::methodName}` * Reference class methods with `{ClassName.methodName}` -### Example +#### Example ```coffee # Public: Disable the package with the given name. @@ -155,3 +335,169 @@ describe 'a dog', -> # Returns `undefined`. disablePackage: (name, options, callback) -> ``` + +## Additional Notes + +### Issue and Pull Request Labels + +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/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 has a label from every group or that an issue can't have more than one label from the same group. + +Please open an issue on `atom/atom` if you have suggestions for new labels, and if you notice some labels are missing on some repositories, then please open an issue on that repository. + +#### Type of Issue and Issue State + +| Label name | `atom/atom` :mag_right: | `atom`‑org :mag_right: | Description | +| --- | --- | --- | --- | +| `enhancement` | [search][search-atom-repo-label-enhancement] | [search][search-atom-org-label-enhancement] | Feature requests. | +| `bug` | [search][search-atom-repo-label-bug] | [search][search-atom-org-label-bug] | Confirmed bugs or reports that are very likely to be bugs. | +| `question` | [search][search-atom-repo-label-question] | [search][search-atom-org-label-question] | Questions more than bug reports or feature requests (e.g. how do I do X). | +| `feedback` | [search][search-atom-repo-label-feedback] | [search][search-atom-org-label-feedback] | General feedback more than bug reports or feature requests. | +| `help-wanted` | [search][search-atom-repo-label-help-wanted] | [search][search-atom-org-label-help-wanted] | The Atom core team would appreciate help from the community in resolving these issues. | +| `beginner` | [search][search-atom-repo-label-beginner] | [search][search-atom-org-label-beginner] | Less complex issues which would be good first issues to work on for users who want to contribute to Atom. | +| `more-information-needed` | [search][search-atom-repo-label-more-information-needed] | [search][search-atom-org-label-more-information-needed] | More information needs to be collected about these problems or feature requests (e.g. steps to reproduce). | +| `needs-reproduction` | [search][search-atom-repo-label-needs-reproduction] | [search][search-atom-org-label-needs-reproduction] | Likely bugs, but haven't been reliably reproduced. | +| `blocked` | [search][search-atom-repo-label-blocked] | [search][search-atom-org-label-blocked] | Issues blocked on other issues. | +| `duplicate` | [search][search-atom-repo-label-duplicate] | [search][search-atom-org-label-duplicate] | Issues which are duplicates of other issues, i.e. they have been reported before. | +| `wontfix` | [search][search-atom-repo-label-wontfix] | [search][search-atom-org-label-wontfix] | The Atom core team has decided not to fix these issues for now, either because they're working as intended or for some other reason. | +| `invalid` | [search][search-atom-repo-label-invalid] | [search][search-atom-org-label-invalid] | Issues which aren't valid (e.g. user errors). | +| `package-idea` | [search][search-atom-repo-label-package-idea] | [search][search-atom-org-label-package-idea] | Feature request which might be good candidates for new packages, instead of extending Atom or core Atom packages. | +| `wrong-repo` | [search][search-atom-repo-label-wrong-repo] | [search][search-atom-org-label-wrong-repo] | Issues reported on the wrong repository (e.g. a bug related to the [Settings View package](https://github.com/atom/settings-view) was reported on [Atom core](https://github.com/atom/atom)). | + +#### Topic Categories + +| Label name | `atom/atom` :mag_right: | `atom`‑org :mag_right: | Description | +| --- | --- | --- | --- | +| `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](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. | +| `api` | [search][search-atom-repo-label-api] | [search][search-atom-org-label-api] | Related to Atom's public APIs. | +| `uncaught-exception` | [search][search-atom-repo-label-uncaught-exception] | [search][search-atom-org-label-uncaught-exception] | Issues about uncaught exceptions, normally created from the [Notifications package](https://github.com/atom/notifications). | +| `crash` | [search][search-atom-repo-label-crash] | [search][search-atom-org-label-crash] | Reports of Atom completely crashing. | +| `auto-indent` | [search][search-atom-repo-label-auto-indent] | [search][search-atom-org-label-auto-indent] | Related to auto-indenting text. | +| `encoding` | [search][search-atom-repo-label-encoding] | [search][search-atom-org-label-encoding] | Related to character encoding. | +| `network` | [search][search-atom-repo-label-network] | [search][search-atom-org-label-network] | Related to network problems or working with remote files (e.g. on network drives). | +| `git` | [search][search-atom-repo-label-git] | [search][search-atom-org-label-git] | Related to Git functionality (e.g. problems with gitignore files or with showing the correct file status). | + +#### `atom/atom` Topic Categories + +| Label name | `atom/atom` :mag_right: | `atom`‑org :mag_right: | Description | +| --- | --- | --- | --- | +| `editor-rendering` | [search][search-atom-repo-label-editor-rendering] | [search][search-atom-org-label-editor-rendering] | Related to language-independent aspects of rendering text (e.g. scrolling, soft wrap, and font rendering). | +| `build-error` | [search][search-atom-repo-label-build-error] | [search][search-atom-org-label-build-error] | Related to problems with building Atom from source. | +| `error-from-pathwatcher` | [search][search-atom-repo-label-error-from-pathwatcher] | [search][search-atom-org-label-error-from-pathwatcher] | Related to errors thrown by the [pathwatcher library](https://github.com/atom/node-pathwatcher). | +| `error-from-save` | [search][search-atom-repo-label-error-from-save] | [search][search-atom-org-label-error-from-save] | Related to errors thrown when saving files. | +| `error-from-open` | [search][search-atom-repo-label-error-from-open] | [search][search-atom-org-label-error-from-open] | Related to errors thrown when opening files. | +| `installer` | [search][search-atom-repo-label-installer] | [search][search-atom-org-label-installer] | Related to the Atom installers for different OSes. | +| `auto-updater` | [search][search-atom-repo-label-auto-updater] | [search][search-atom-org-label-auto-updater] | Related to the auto-updater for different OSes. | +| `deprecation-help` | [search][search-atom-repo-label-deprecation-help] | [search][search-atom-org-label-deprecation-help] | Issues for helping package authors remove usage of deprecated APIs in packages. | +| `electron` | [search][search-atom-repo-label-electron] | [search][search-atom-org-label-electron] | Issues that require changes to [Electron](https://electron.atom.io) to fix or implement. | + +#### Pull Request Labels + +| Label name | `atom/atom` :mag_right: | `atom`‑org :mag_right: | Description +| --- | --- | --- | --- | +| `work-in-progress` | [search][search-atom-repo-label-work-in-progress] | [search][search-atom-org-label-work-in-progress] | Pull requests which are still being worked on, more changes will follow. | +| `needs-review` | [search][search-atom-repo-label-needs-review] | [search][search-atom-org-label-needs-review] | Pull requests which need code review, and approval from maintainers or Atom core team. | +| `under-review` | [search][search-atom-repo-label-under-review] | [search][search-atom-org-label-under-review] | Pull requests being reviewed by maintainers or Atom core team. | +| `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/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/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/Dockerfile b/Dockerfile index d792c30c5c7..0bcc7eca762 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,31 @@ -# VERSION: 0.1 -# DESCRIPTION: Image to build Atom and create a .rpm file +# VERSION: 0.2 +# DESCRIPTION: Image to build Atom -# Base docker image -FROM fedora:21 +FROM ubuntu:20.04 # Install dependencies -RUN yum install -y \ - make \ - gcc \ - gcc-c++ \ - glibc-devel \ - git-core \ - libgnome-keyring-devel \ - rpmdevtools \ - nodejs \ - npm +RUN apt-get update && \ + DEBIAN_FRONTEND="noninteractive" \ + apt-get install -y \ + build-essential \ + git \ + libsecret-1-dev \ + fakeroot \ + rpm \ + libx11-dev \ + libxkbfile-dev \ + libgdk-pixbuf2.0-dev \ + libgtk-3-dev \ + libxss-dev \ + libasound2-dev \ + npm && \ + rm -rf /var/lib/apt/lists/* -RUN npm install -g npm@1.4.28 --loglevel error +# Update npm and dependencies +RUN npm install -g npm --loglevel error -ADD . /atom -WORKDIR /atom +# Use python2 by default +RUN npm config set python /usr/bin/python2 -g + +ENTRYPOINT ["/usr/bin/env", "sh", "-c"] +CMD ["bash"] diff --git a/LICENSE.md b/LICENSE.md index bbb875dc281..6c66ba9599f 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2015 GitHub Inc. +Copyright (c) 2011-2022 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 new file mode 100644 index 00000000000..cdf247b79a4 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +⚛👋 Hello there! Welcome. Please follow the steps below to tell us about your contribution. + +1. Copy the correct template for your contribution + - 🐛 Are you fixing a bug? Copy the template from + - 📈 Are you improving performance? Copy the template from + - 📝 Are you updating documentation? Copy the template from + - 💻 Are you changing functionality? Copy the template from +2. Replace this text with the contents of the template +3. Fill in all sections of the template +4. Click "Create pull request" diff --git a/README.md b/README.md index 8958f303dbb..2d8dcc3422f 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,35 @@ -![Atom](https://cloud.githubusercontent.com/assets/72919/2874231/3af1db48-d3dd-11e3-98dc-6066f8bc766f.png) +# Atom -[![Build Status](https://travis-ci.org/atom/atom.svg?branch=master)](https://travis-ci.org/atom/atom) -[![Dependency Status](https://david-dm.org/atom/atom.svg)](https://david-dm.org/atom/atom) +[![Build status](https://dev.azure.com/github/Atom/_apis/build/status/Atom%20Production%20Branches?branchName=master)](https://dev.azure.com/github/Atom/_build/latest?definitionId=32&branchName=master) -Atom is a hackable text editor for the 21st century, built on [Electron](https://github.com/atom/electron), and based on everything we love about our favorite editors. We designed it to be deeply customizable, but still approachable using the default configuration. +> Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) -Visit [atom.io](https://atom.io) to learn more or visit the [Atom forum](https://discuss.atom.io). +Atom is a hackable text editor for the 21st century, built on [Electron](https://github.com/electron/electron), and based on everything we love about our favorite editors. We designed it to be deeply customizable, but still approachable using the default configuration. + +![Atom](https://user-images.githubusercontent.com/378023/49132477-f4b77680-f31f-11e8-8357-ac6491761c6c.png) + +![Atom Screenshot](https://user-images.githubusercontent.com/378023/49132478-f4b77680-f31f-11e8-9e10-e8454d8d9b7e.png) + +Visit [atom.io](https://atom.io) to learn more or visit the [Atom forum](https://github.com/atom/atom/discussions). Follow [@AtomEditor](https://twitter.com/atomeditor) on Twitter for important announcements. -Visit [issue #3684](https://github.com/atom/atom/issues/3684) to learn more -about the Atom 1.0 roadmap. +This project adheres to the Contributor Covenant [code of conduct](CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. Please report unacceptable behavior to atom@github.com. ## Documentation -If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](https://atom.io/docs/latest/) is free and available online, along with ePub, PDF and mobi versions. You can find the source to the manual in [atom/docs](https://github.com/atom/docs). +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). The [API reference](https://atom.io/docs/api) for developing packages is also documented on Atom.io. ## Installing -### OS X +### Prerequisites +- [Git](https://git-scm.com) + +### macOS Download the latest [Atom release](https://github.com/atom/atom/releases/latest). @@ -29,41 +37,51 @@ Atom will automatically update when a new release is available. ### Windows -Download the latest [AtomSetup.exe installer](https://github.com/atom/atom/releases/latest). +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. -You can also download an `atom-windows.zip` file from the [releases page](https://github.com/atom/atom/releases/latest). +You can also download `atom-windows.zip` (32-bit) or `atom-x64-windows.zip` (64-bit) from the [releases page](https://github.com/atom/atom/releases/latest). The `.zip` version will not automatically update. -Using [chocolatey](https://chocolatey.org/)? Run `cinst Atom` to install -the latest version of Atom. +Using [Chocolatey](https://chocolatey.org)? Run `cinst Atom` to install the latest version of Atom. -### Debian Linux (Ubuntu) +### Linux -Currently only a 64-bit version is available. +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. +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. -The Linux version does not currently automatically update so you will need to -repeat these steps to upgrade to future releases. +#### Archive extraction -### Red Hat Linux (Fedora, CentOS, Red Hat) +An archive is available for people who don't want to install `atom` as root. -Currently only a 64-bit version is available. +This version enables you to install multiple Atom versions in parallel. It has been built on Ubuntu 64-bit, +but should be compatible with other Linux distributions. -1. Download `atom.x86_64.rpm` from the [Atom releases page](https://github.com/atom/atom/releases/latest). -2. Run `sudo yum localinstall atom.x86_64.rpm` on the downloaded package. -3. Launch Atom using the installed `atom` command. +1. Install dependencies (on Ubuntu): +```sh +sudo apt install git libasound2 libcurl4 libgbm1 libgcrypt20 libgtk-3-0 libnotify4 libnss3 libglib2.0-bin xdg-utils libx11-xcb1 libxcb-dri3-0 libxss1 libxtst6 libxkbfile1 +``` +2. Download `atom-amd64.tar.gz` from the [Atom releases page](https://github.com/atom/atom/releases/latest). +3. Run `tar xf atom-amd64.tar.gz` in the directory where you want to extract the Atom folder. +4. Launch Atom using the installed `atom` command from the newly extracted directory. The Linux version does not currently automatically update so you will need to repeat these steps to upgrade to future releases. ## Building -* [Linux](docs/build-instructions/linux.md) -* [OS X](docs/build-instructions/os-x.md) -* [FreeBSD](docs/build-instructions/freebsd.md) -* [Windows](docs/build-instructions/windows.md) +* [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) + +## Discussion + +* Discuss Atom on [GitHub Discussions](https://github.com/atom/atom/discussions) + +## License + +[MIT](https://github.com/atom/atom/blob/master/LICENSE.md) + +When using the Atom or other GitHub logos, be sure to follow the [GitHub logo guidelines](https://github.com/logos). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000000..55483c8050b --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,9 @@ +# Atom Support + +If you're looking for support for Atom there are a lot of options, check out: + +* User Documentation — [The Atom Flight Manual](https://flight-manual.atom.io) +* Developer Documentation — [Atom API Documentation](https://atom.io/docs/api/latest) +* Message Board — [Github Discussions, the official Atom message board](https://github.com/atom/atom/discussions) + +On Atoms Github Discussions board, there are a bunch of helpful community members that should be willing to point you in the right direction. diff --git a/apm/package-lock.json b/apm/package-lock.json new file mode 100644 index 00000000000..069dfba288d --- /dev/null +++ b/apm/package-lock.json @@ -0,0 +1,4730 @@ +{ + "name": "atom-bundled-apm", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "atom-package-manager": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/atom-package-manager/-/atom-package-manager-2.6.2.tgz", + "integrity": "sha512-nDzUDyN/TYx4YEZ/lrMFXrfAQFVth0/GZ2Ih05HPpBo7aVlZLnDo1c1B2jxrp0BboIoEoEMJ/JZlQ5mmwGCMaA==", + "requires": { + "@atom/plist": "0.4.4", + "asar-require": "0.3.0", + "async": "^3.2.0", + "colors": "~1.4.0", + "first-mate": "^7.4.1", + "fs-plus": "3.x", + "git-utils": "^5.7.1", + "glob": "^7.1.6", + "hosted-git-info": "^3.0.7", + "keytar": "^6.0.1", + "mv": "2.1.1", + "ncp": "~2.0.0", + "npm": "^6.14.9", + "open": "7.3.0", + "q": "~1.5.1", + "read": "~1.0.7", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "season": "^6.0.2", + "semver": "^7.3.4", + "tar": "^6.0.5", + "temp": "^0.9.4", + "underscore-plus": "1.x", + "wordwrap": "1.0.0", + "wrench": "~1.5.1", + "yargs": "^3.32.0" + }, + "dependencies": { + "@atom/plist": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@atom/plist/-/plist-0.4.4.tgz", + "integrity": "sha512-EYwsXOY+Jp9vQ2yNWHuWaJufI58vbQWg235OkvGBWnITFVUOdow49OBj7ET9HCksZz560yXbSa1ywzqDIrFPZw==", + "requires": { + "xmlbuilder": "0.4.x", + "xmldom": "0.1.x" + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "asar": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/asar/-/asar-0.12.1.tgz", + "integrity": "sha1-35Q+jrXNdPvKBmPi10uPK3J7UI8=", + "requires": { + "chromium-pickle-js": "^0.1.0", + "commander": "^2.9.0", + "cuint": "^0.2.1", + "glob": "^6.0.4", + "minimatch": "^3.0.0", + "mkdirp": "^0.5.0", + "mksnapshot": "^0.3.0", + "tmp": "0.0.28" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "asar-require": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/asar-require/-/asar-require-0.3.0.tgz", + "integrity": "sha1-R+TLRBSJSthplTbNDFjAySFRtFs=", + "requires": { + "asar": "0.12.1" + } + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=", + "requires": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + } + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=" + }, + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=", + "requires": { + "traverse": ">=0.3.0 <0.4" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "chromium-pickle-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.1.0.tgz", + "integrity": "sha1-HUixB9ghJqLz4hHC6iX4A7pVGyE=" + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==" + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "cson-parser": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/cson-parser/-/cson-parser-1.3.5.tgz", + "integrity": "sha1-fsZ14DkUVTO/KmqFYHPxWZ2cLSQ=", + "requires": { + "coffee-script": "^1.10.0" + } + }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=" + }, + "d": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz", + "integrity": "sha1-2hhMU10Y2O57oqoim5FACfrhEwk=", + "requires": { + "es5-ext": "~0.10.2" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "requires": { + "mimic-response": "^2.0.0" + } + }, + "decompress-zip": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/decompress-zip/-/decompress-zip-0.3.3.tgz", + "integrity": "sha512-/fy1L4s+4jujqj3kNptWjilFw3E6De8U6XUFvqmh4npN3Vsypm3oT2V0bXcmbBWS+5j5tr4okYaFrOmyZkszEg==", + "requires": { + "binary": "^0.3.0", + "graceful-fs": "^4.1.3", + "mkpath": "^0.1.0", + "nopt": "^3.0.1", + "q": "^1.1.2", + "readable-stream": "^1.1.8", + "touch": "0.0.3" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "emissary": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/emissary/-/emissary-1.3.3.tgz", + "integrity": "sha1-phjZLWgrIy0xER3DYlpd9mF5lgY=", + "requires": { + "es6-weak-map": "^0.1.2", + "mixto": "1.x", + "property-accessors": "^1.1", + "underscore-plus": "1.x" + } + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "es5-ext": { + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + }, + "dependencies": { + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "requires": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + } + } + }, + "es6-iterator": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", + "integrity": "sha1-1vWLjE/EE8JJtLqhl2j45NfIlE4=", + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.5", + "es6-symbol": "~2.0.1" + } + }, + "es6-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-2.0.1.tgz", + "integrity": "sha1-dhtcZ8/U8dGK+yNPaR1nhoLLO/M=", + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.5" + } + }, + "es6-weak-map": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-0.1.4.tgz", + "integrity": "sha1-cGzvnpmqI2undmwjnIueKG6n0ig=", + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.6", + "es6-iterator": "~0.1.3", + "es6-symbol": "~2.0.1" + } + }, + "event-kit": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/event-kit/-/event-kit-2.5.3.tgz", + "integrity": "sha512-b7Qi1JNzY4BfAYfnIRanLk0DOD1gdkWHT4GISIn8Q2tAf3LpU8SP2CMwWaq40imYoKWbtN4ZhbSRxvsnikooZQ==" + }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" + }, + "ext": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", + "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "requires": { + "type": "^2.0.0" + }, + "dependencies": { + "type": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz", + "integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "first-mate": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/first-mate/-/first-mate-7.4.1.tgz", + "integrity": "sha512-SEG5W0aajCvK/Ngoo3he3Ib4DsT+CRPhBAgSju5hksBLvvUfRWP7Jf3+HQ+CNTD4GZZqbDNOEJNOxbf35EblrQ==", + "requires": { + "emissary": "^1", + "event-kit": "^2.2.0", + "fs-plus": "^3.0.0", + "grim": "^2.0.1", + "oniguruma": "7.2.1", + "season": "^6.0.2", + "underscore-plus": "^1" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "fs-extra": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.26.7.tgz", + "integrity": "sha1-muH92UiXeY7at20JGM9C0MMYT6k=", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0", + "path-is-absolute": "^1.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, + "fs-plus": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fs-plus/-/fs-plus-3.1.1.tgz", + "integrity": "sha512-Se2PJdOWXqos1qVTkvqqjb0CSnfBnwwD+pq+z4ksT+e97mEShod/hrNg0TRCCsXPbJzcIq+NuzQhigunMWMJUA==", + "requires": { + "async": "^1.5.2", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.2", + "underscore-plus": "1.x" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "git-utils": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/git-utils/-/git-utils-5.7.1.tgz", + "integrity": "sha512-+mWdJDq9emWoq6GzzrGEB7SIBmAk0lNNv2wgNkgwTVZUkAFkWvgRsJ+Kvs3d1QQD6WG6vczti2WLpjmh2Twtlw==", + "requires": { + "fs-plus": "^3.0.0", + "nan": "^2.14.0" + } + }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" + }, + "grim": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/grim/-/grim-2.0.3.tgz", + "integrity": "sha512-FM20Ump11qYLK9k9DbL8yzVpy+YBieya1JG15OeH8s+KbHq8kL4SdwRtURwIUHniSxb24EoBUpwKfFjGNVi4/Q==", + "requires": { + "event-kit": "^2.0.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, + "hosted-git-info": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", + "integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "keytar": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-6.0.1.tgz", + "integrity": "sha512-1Ihpf2tdM3sLwGMkYHXYhVC/hx5BDR7CWFL4IrBA3IDZo0xHhS2nM+tU9Y+u/U7okNfbVkwmKsieLkcWRMh93g==", + "requires": { + "node-addon-api": "^3.0.0", + "prebuild-install": "5.3.4" + } + }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "requires": { + "invert-kv": "^1.0.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "mime-db": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz", + "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==" + }, + "mime-types": { + "version": "2.1.30", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz", + "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==", + "requires": { + "mime-db": "1.47.0" + } + }, + "mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "minipass": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", + "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mixto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mixto/-/mixto-1.0.0.tgz", + "integrity": "sha1-wyDvYbUvKJj1IuF9i7xtUG2EJbY=" + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "mkpath": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/mkpath/-/mkpath-0.1.0.tgz", + "integrity": "sha1-dVSm+Nhxg0zJe1RisSLEwSTW3pE=" + }, + "mksnapshot": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/mksnapshot/-/mksnapshot-0.3.5.tgz", + "integrity": "sha512-PSBoZaj9h9myC3uRRW62RxmX8mrN3XbOkMEyURUD7v5CeJgtYTar50XU738t7Q0LtG1pBPtp5n5QwDGggRnEvw==", + "requires": { + "decompress-zip": "0.3.x", + "fs-extra": "0.26.7", + "request": "2.x" + } + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "requires": { + "glob": "^6.0.1" + } + } + } + }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" + }, + "napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=" + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" + }, + "node-abi": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.26.0.tgz", + "integrity": "sha512-ag/Vos/mXXpWLLAYWsAoQdgS+gW7IwvgMLOgqopm/DbzAjazLltzgzpVMsFlgmo9TzG5hGXeaBZx2AI731RIsQ==", + "requires": { + "semver": "^5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "node-addon-api": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz", + "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==" + }, + "noop-logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", + "integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI=" + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "requires": { + "abbrev": "1" + } + }, + "npm": { + "version": "6.14.13", + "resolved": "https://registry.npmjs.org/npm/-/npm-6.14.13.tgz", + "integrity": "sha512-SRl4jJi0EBHY2xKuu98FLRMo3VhYQSA6otyLnjSEiHoSG/9shXCFNJy9tivpUJvtkN9s6VDdItHa5Rn+fNBzag==", + "requires": { + "JSONStream": "^1.3.5", + "abbrev": "~1.1.1", + "ansicolors": "~0.3.2", + "ansistyles": "~0.1.3", + "aproba": "^2.0.0", + "archy": "~1.0.0", + "bin-links": "^1.1.8", + "bluebird": "^3.5.5", + "byte-size": "^5.0.1", + "cacache": "^12.0.3", + "call-limit": "^1.1.1", + "chownr": "^1.1.4", + "ci-info": "^2.0.0", + "cli-columns": "^3.1.2", + "cli-table3": "^0.5.1", + "cmd-shim": "^3.0.3", + "columnify": "~1.5.4", + "config-chain": "^1.1.12", + "debuglog": "*", + "detect-indent": "~5.0.0", + "detect-newline": "^2.1.0", + "dezalgo": "~1.0.3", + "editor": "~1.0.0", + "figgy-pudding": "^3.5.1", + "find-npm-prefix": "^1.0.2", + "fs-vacuum": "~1.2.10", + "fs-write-stream-atomic": "~1.0.10", + "gentle-fs": "^2.3.1", + "glob": "^7.1.6", + "graceful-fs": "^4.2.4", + "has-unicode": "~2.0.1", + "hosted-git-info": "^2.8.9", + "iferr": "^1.0.2", + "imurmurhash": "*", + "infer-owner": "^1.0.4", + "inflight": "~1.0.6", + "inherits": "^2.0.4", + "ini": "^1.3.8", + "init-package-json": "^1.10.3", + "is-cidr": "^3.0.0", + "json-parse-better-errors": "^1.0.2", + "lazy-property": "~1.0.0", + "libcipm": "^4.0.8", + "libnpm": "^3.0.1", + "libnpmaccess": "^3.0.2", + "libnpmhook": "^5.0.3", + "libnpmorg": "^1.0.1", + "libnpmsearch": "^2.0.2", + "libnpmteam": "^1.0.2", + "libnpx": "^10.2.4", + "lock-verify": "^2.1.0", + "lockfile": "^1.0.4", + "lodash._baseindexof": "*", + "lodash._baseuniq": "~4.6.0", + "lodash._bindcallback": "*", + "lodash._cacheindexof": "*", + "lodash._createcache": "*", + "lodash._getnative": "*", + "lodash.clonedeep": "~4.5.0", + "lodash.restparam": "*", + "lodash.union": "~4.6.0", + "lodash.uniq": "~4.5.0", + "lodash.without": "~4.4.0", + "lru-cache": "^5.1.1", + "meant": "^1.0.2", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.5", + "move-concurrently": "^1.0.1", + "node-gyp": "^5.1.0", + "nopt": "^4.0.3", + "normalize-package-data": "^2.5.0", + "npm-audit-report": "^1.3.3", + "npm-cache-filename": "~1.0.2", + "npm-install-checks": "^3.0.2", + "npm-lifecycle": "^3.1.5", + "npm-package-arg": "^6.1.1", + "npm-packlist": "^1.4.8", + "npm-pick-manifest": "^3.0.2", + "npm-profile": "^4.0.4", + "npm-registry-fetch": "^4.0.7", + "npm-user-validate": "^1.0.1", + "npmlog": "~4.1.2", + "once": "~1.4.0", + "opener": "^1.5.2", + "osenv": "^0.1.5", + "pacote": "^9.5.12", + "path-is-inside": "~1.0.2", + "promise-inflight": "~1.0.1", + "qrcode-terminal": "^0.12.0", + "query-string": "^6.8.2", + "qw": "~1.0.1", + "read": "~1.0.7", + "read-cmd-shim": "^1.0.5", + "read-installed": "~4.0.3", + "read-package-json": "^2.1.1", + "read-package-tree": "^5.3.1", + "readable-stream": "^3.6.0", + "readdir-scoped-modules": "^1.1.0", + "request": "^2.88.0", + "retry": "^0.12.0", + "rimraf": "^2.7.1", + "safe-buffer": "^5.1.2", + "semver": "^5.7.1", + "sha": "^3.0.0", + "slide": "~1.1.6", + "sorted-object": "~2.0.1", + "sorted-union-stream": "~2.1.3", + "ssri": "^6.0.2", + "stringify-package": "^1.0.1", + "tar": "^4.4.13", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "uid-number": "0.0.6", + "umask": "~1.1.0", + "unique-filename": "^1.1.1", + "unpipe": "~1.0.0", + "update-notifier": "^2.5.0", + "uuid": "^3.3.3", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "~3.0.0", + "which": "^1.3.1", + "worker-farm": "^1.7.0", + "write-file-atomic": "^2.4.3" + }, + "dependencies": { + "JSONStream": { + "version": "1.3.5", + "bundled": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "abbrev": { + "version": "1.1.1", + "bundled": true + }, + "agent-base": { + "version": "4.3.0", + "bundled": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "agentkeepalive": { + "version": "3.5.2", + "bundled": true, + "requires": { + "humanize-ms": "^1.2.1" + } + }, + "ansi-align": { + "version": "2.0.0", + "bundled": true, + "requires": { + "string-width": "^2.0.0" + } + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true + }, + "ansi-styles": { + "version": "3.2.1", + "bundled": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansicolors": { + "version": "0.3.2", + "bundled": true + }, + "ansistyles": { + "version": "0.1.3", + "bundled": true + }, + "aproba": { + "version": "2.0.0", + "bundled": true + }, + "archy": { + "version": "1.0.0", + "bundled": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "asap": { + "version": "2.0.6", + "bundled": true + }, + "asn1": { + "version": "0.2.4", + "bundled": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "bundled": true + }, + "asynckit": { + "version": "0.4.0", + "bundled": true + }, + "aws-sign2": { + "version": "0.7.0", + "bundled": true + }, + "aws4": { + "version": "1.8.0", + "bundled": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "bundled": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bin-links": { + "version": "1.1.8", + "bundled": true, + "requires": { + "bluebird": "^3.5.3", + "cmd-shim": "^3.0.0", + "gentle-fs": "^2.3.0", + "graceful-fs": "^4.1.15", + "npm-normalize-package-bin": "^1.0.0", + "write-file-atomic": "^2.3.0" + } + }, + "bluebird": { + "version": "3.5.5", + "bundled": true + }, + "boxen": { + "version": "1.3.0", + "bundled": true, + "requires": { + "ansi-align": "^2.0.0", + "camelcase": "^4.0.0", + "chalk": "^2.0.1", + "cli-boxes": "^1.0.0", + "string-width": "^2.0.0", + "term-size": "^1.2.0", + "widest-line": "^2.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-from": { + "version": "1.0.0", + "bundled": true + }, + "builtins": { + "version": "1.0.3", + "bundled": true + }, + "byline": { + "version": "5.0.0", + "bundled": true + }, + "byte-size": { + "version": "5.0.1", + "bundled": true + }, + "cacache": { + "version": "12.0.3", + "bundled": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "call-limit": { + "version": "1.1.1", + "bundled": true + }, + "camelcase": { + "version": "4.1.0", + "bundled": true + }, + "capture-stack-trace": { + "version": "1.0.0", + "bundled": true + }, + "caseless": { + "version": "0.12.0", + "bundled": true + }, + "chalk": { + "version": "2.4.1", + "bundled": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chownr": { + "version": "1.1.4", + "bundled": true + }, + "ci-info": { + "version": "2.0.0", + "bundled": true + }, + "cidr-regex": { + "version": "2.0.10", + "bundled": true, + "requires": { + "ip-regex": "^2.1.0" + } + }, + "cli-boxes": { + "version": "1.0.0", + "bundled": true + }, + "cli-columns": { + "version": "3.1.2", + "bundled": true, + "requires": { + "string-width": "^2.0.0", + "strip-ansi": "^3.0.1" + } + }, + "cli-table3": { + "version": "0.5.1", + "bundled": true, + "requires": { + "colors": "^1.1.2", + "object-assign": "^4.1.0", + "string-width": "^2.1.1" + } + }, + "cliui": { + "version": "5.0.0", + "bundled": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true + }, + "string-width": { + "version": "3.1.0", + "bundled": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "bundled": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "clone": { + "version": "1.0.4", + "bundled": true + }, + "cmd-shim": { + "version": "3.0.3", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.2", + "mkdirp": "~0.5.0" + } + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true + }, + "color-convert": { + "version": "1.9.1", + "bundled": true, + "requires": { + "color-name": "^1.1.1" + } + }, + "color-name": { + "version": "1.1.3", + "bundled": true + }, + "colors": { + "version": "1.3.3", + "bundled": true, + "optional": true + }, + "columnify": { + "version": "1.5.4", + "bundled": true, + "requires": { + "strip-ansi": "^3.0.0", + "wcwidth": "^1.0.0" + } + }, + "combined-stream": { + "version": "1.0.6", + "bundled": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "concat-stream": { + "version": "1.6.2", + "bundled": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "config-chain": { + "version": "1.1.12", + "bundled": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "configstore": { + "version": "3.1.5", + "bundled": true, + "requires": { + "dot-prop": "^4.2.1", + "graceful-fs": "^4.1.2", + "make-dir": "^1.0.0", + "unique-string": "^1.0.0", + "write-file-atomic": "^2.0.0", + "xdg-basedir": "^3.0.0" + } + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true + }, + "copy-concurrently": { + "version": "1.0.5", + "bundled": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "bundled": true + }, + "iferr": { + "version": "0.1.5", + "bundled": true + } + } + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true + }, + "create-error-class": { + "version": "3.0.2", + "bundled": true, + "requires": { + "capture-stack-trace": "^1.0.0" + } + }, + "cross-spawn": { + "version": "5.1.0", + "bundled": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "bundled": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "bundled": true + } + } + }, + "crypto-random-string": { + "version": "1.0.0", + "bundled": true + }, + "cyclist": { + "version": "0.2.2", + "bundled": true + }, + "dashdash": { + "version": "1.14.1", + "bundled": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "3.1.0", + "bundled": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "bundled": true + } + } + }, + "debuglog": { + "version": "1.0.1", + "bundled": true + }, + "decamelize": { + "version": "1.2.0", + "bundled": true + }, + "decode-uri-component": { + "version": "0.2.0", + "bundled": true + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true + }, + "defaults": { + "version": "1.0.3", + "bundled": true, + "requires": { + "clone": "^1.0.2" + } + }, + "define-properties": { + "version": "1.1.3", + "bundled": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delayed-stream": { + "version": "1.0.0", + "bundled": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true + }, + "detect-indent": { + "version": "5.0.0", + "bundled": true + }, + "detect-newline": { + "version": "2.1.0", + "bundled": true + }, + "dezalgo": { + "version": "1.0.3", + "bundled": true, + "requires": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "dot-prop": { + "version": "4.2.1", + "bundled": true, + "requires": { + "is-obj": "^1.0.0" + } + }, + "dotenv": { + "version": "5.0.1", + "bundled": true + }, + "duplexer3": { + "version": "0.1.4", + "bundled": true + }, + "duplexify": { + "version": "3.6.0", + "bundled": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "bundled": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "editor": { + "version": "1.0.0", + "bundled": true + }, + "emoji-regex": { + "version": "7.0.3", + "bundled": true + }, + "encoding": { + "version": "0.1.12", + "bundled": true, + "requires": { + "iconv-lite": "~0.4.13" + } + }, + "end-of-stream": { + "version": "1.4.1", + "bundled": true, + "requires": { + "once": "^1.4.0" + } + }, + "env-paths": { + "version": "2.2.0", + "bundled": true + }, + "err-code": { + "version": "1.1.2", + "bundled": true + }, + "errno": { + "version": "0.1.7", + "bundled": true, + "requires": { + "prr": "~1.0.1" + } + }, + "es-abstract": { + "version": "1.12.0", + "bundled": true, + "requires": { + "es-to-primitive": "^1.1.1", + "function-bind": "^1.1.1", + "has": "^1.0.1", + "is-callable": "^1.1.3", + "is-regex": "^1.0.4" + } + }, + "es-to-primitive": { + "version": "1.2.0", + "bundled": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es6-promise": { + "version": "4.2.8", + "bundled": true + }, + "es6-promisify": { + "version": "5.0.0", + "bundled": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "bundled": true + }, + "execa": { + "version": "0.7.0", + "bundled": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "bundled": true + } + } + }, + "extend": { + "version": "3.0.2", + "bundled": true + }, + "extsprintf": { + "version": "1.3.0", + "bundled": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "bundled": true + }, + "figgy-pudding": { + "version": "3.5.1", + "bundled": true + }, + "find-npm-prefix": { + "version": "1.0.2", + "bundled": true + }, + "flush-write-stream": { + "version": "1.0.3", + "bundled": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.4" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "forever-agent": { + "version": "0.6.1", + "bundled": true + }, + "form-data": { + "version": "2.3.2", + "bundled": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "from2": { + "version": "2.3.0", + "bundled": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "fs-minipass": { + "version": "1.2.7", + "bundled": true, + "requires": { + "minipass": "^2.6.0" + }, + "dependencies": { + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + } + } + }, + "fs-vacuum": { + "version": "1.2.10", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.2", + "path-is-inside": "^1.0.1", + "rimraf": "^2.5.2" + } + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" + }, + "dependencies": { + "iferr": { + "version": "0.1.5", + "bundled": true + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true + }, + "function-bind": { + "version": "1.1.1", + "bundled": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "bundled": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "genfun": { + "version": "5.0.0", + "bundled": true + }, + "gentle-fs": { + "version": "2.3.1", + "bundled": true, + "requires": { + "aproba": "^1.1.2", + "chownr": "^1.1.2", + "cmd-shim": "^3.0.3", + "fs-vacuum": "^1.2.10", + "graceful-fs": "^4.1.11", + "iferr": "^0.1.5", + "infer-owner": "^1.0.4", + "mkdirp": "^0.5.1", + "path-is-inside": "^1.0.2", + "read-cmd-shim": "^1.0.1", + "slide": "^1.1.6" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "bundled": true + }, + "iferr": { + "version": "0.1.5", + "bundled": true + } + } + }, + "get-caller-file": { + "version": "2.0.5", + "bundled": true + }, + "get-stream": { + "version": "4.1.0", + "bundled": true, + "requires": { + "pump": "^3.0.0" + } + }, + "getpass": { + "version": "0.1.7", + "bundled": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.6", + "bundled": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "global-dirs": { + "version": "0.1.1", + "bundled": true, + "requires": { + "ini": "^1.3.4" + } + }, + "got": { + "version": "6.7.1", + "bundled": true, + "requires": { + "create-error-class": "^3.0.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "is-redirect": "^1.0.0", + "is-retry-allowed": "^1.0.0", + "is-stream": "^1.0.0", + "lowercase-keys": "^1.0.0", + "safe-buffer": "^5.0.1", + "timed-out": "^4.0.0", + "unzip-response": "^2.0.1", + "url-parse-lax": "^1.0.0" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "bundled": true + } + } + }, + "graceful-fs": { + "version": "4.2.4", + "bundled": true + }, + "har-schema": { + "version": "2.0.0", + "bundled": true + }, + "har-validator": { + "version": "5.1.5", + "bundled": true, + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "bundled": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "bundled": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "bundled": true + } + } + }, + "has": { + "version": "1.0.3", + "bundled": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "bundled": true + }, + "has-symbols": { + "version": "1.0.0", + "bundled": true + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true + }, + "hosted-git-info": { + "version": "2.8.9", + "bundled": true + }, + "http-cache-semantics": { + "version": "3.8.1", + "bundled": true + }, + "http-proxy-agent": { + "version": "2.1.0", + "bundled": true, + "requires": { + "agent-base": "4", + "debug": "3.1.0" + } + }, + "http-signature": { + "version": "1.2.0", + "bundled": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-proxy-agent": { + "version": "2.2.4", + "bundled": true, + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + } + }, + "humanize-ms": { + "version": "1.2.1", + "bundled": true, + "requires": { + "ms": "^2.0.0" + } + }, + "iconv-lite": { + "version": "0.4.23", + "bundled": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "iferr": { + "version": "1.0.2", + "bundled": true + }, + "ignore-walk": { + "version": "3.0.3", + "bundled": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "import-lazy": { + "version": "2.1.0", + "bundled": true + }, + "imurmurhash": { + "version": "0.1.4", + "bundled": true + }, + "infer-owner": { + "version": "1.0.4", + "bundled": true + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "bundled": true + }, + "ini": { + "version": "1.3.8", + "bundled": true + }, + "init-package-json": { + "version": "1.10.3", + "bundled": true, + "requires": { + "glob": "^7.1.1", + "npm-package-arg": "^4.0.0 || ^5.0.0 || ^6.0.0", + "promzard": "^0.3.0", + "read": "~1.0.1", + "read-package-json": "1 || 2", + "semver": "2.x || 3.x || 4 || 5", + "validate-npm-package-license": "^3.0.1", + "validate-npm-package-name": "^3.0.0" + } + }, + "ip": { + "version": "1.1.5", + "bundled": true + }, + "ip-regex": { + "version": "2.1.0", + "bundled": true + }, + "is-callable": { + "version": "1.1.4", + "bundled": true + }, + "is-ci": { + "version": "1.2.1", + "bundled": true, + "requires": { + "ci-info": "^1.5.0" + }, + "dependencies": { + "ci-info": { + "version": "1.6.0", + "bundled": true + } + } + }, + "is-cidr": { + "version": "3.0.0", + "bundled": true, + "requires": { + "cidr-regex": "^2.0.10" + } + }, + "is-date-object": { + "version": "1.0.1", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-installed-globally": { + "version": "0.1.0", + "bundled": true, + "requires": { + "global-dirs": "^0.1.0", + "is-path-inside": "^1.0.0" + } + }, + "is-npm": { + "version": "1.0.0", + "bundled": true + }, + "is-obj": { + "version": "1.0.1", + "bundled": true + }, + "is-path-inside": { + "version": "1.0.1", + "bundled": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-redirect": { + "version": "1.0.0", + "bundled": true + }, + "is-regex": { + "version": "1.0.4", + "bundled": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-retry-allowed": { + "version": "1.2.0", + "bundled": true + }, + "is-stream": { + "version": "1.1.0", + "bundled": true + }, + "is-symbol": { + "version": "1.0.2", + "bundled": true, + "requires": { + "has-symbols": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "bundled": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true + }, + "isexe": { + "version": "2.0.0", + "bundled": true + }, + "isstream": { + "version": "0.1.2", + "bundled": true + }, + "jsbn": { + "version": "0.1.1", + "bundled": true, + "optional": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "bundled": true + }, + "json-schema": { + "version": "0.2.3", + "bundled": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "bundled": true + }, + "jsonparse": { + "version": "1.3.1", + "bundled": true + }, + "jsprim": { + "version": "1.4.1", + "bundled": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "latest-version": { + "version": "3.1.0", + "bundled": true, + "requires": { + "package-json": "^4.0.0" + } + }, + "lazy-property": { + "version": "1.0.0", + "bundled": true + }, + "libcipm": { + "version": "4.0.8", + "bundled": true, + "requires": { + "bin-links": "^1.1.2", + "bluebird": "^3.5.1", + "figgy-pudding": "^3.5.1", + "find-npm-prefix": "^1.0.2", + "graceful-fs": "^4.1.11", + "ini": "^1.3.5", + "lock-verify": "^2.1.0", + "mkdirp": "^0.5.1", + "npm-lifecycle": "^3.0.0", + "npm-logical-tree": "^1.2.1", + "npm-package-arg": "^6.1.0", + "pacote": "^9.1.0", + "read-package-json": "^2.0.13", + "rimraf": "^2.6.2", + "worker-farm": "^1.6.0" + } + }, + "libnpm": { + "version": "3.0.1", + "bundled": true, + "requires": { + "bin-links": "^1.1.2", + "bluebird": "^3.5.3", + "find-npm-prefix": "^1.0.2", + "libnpmaccess": "^3.0.2", + "libnpmconfig": "^1.2.1", + "libnpmhook": "^5.0.3", + "libnpmorg": "^1.0.1", + "libnpmpublish": "^1.1.2", + "libnpmsearch": "^2.0.2", + "libnpmteam": "^1.0.2", + "lock-verify": "^2.0.2", + "npm-lifecycle": "^3.0.0", + "npm-logical-tree": "^1.2.1", + "npm-package-arg": "^6.1.0", + "npm-profile": "^4.0.2", + "npm-registry-fetch": "^4.0.0", + "npmlog": "^4.1.2", + "pacote": "^9.5.3", + "read-package-json": "^2.0.13", + "stringify-package": "^1.0.0" + } + }, + "libnpmaccess": { + "version": "3.0.2", + "bundled": true, + "requires": { + "aproba": "^2.0.0", + "get-stream": "^4.0.0", + "npm-package-arg": "^6.1.0", + "npm-registry-fetch": "^4.0.0" + } + }, + "libnpmconfig": { + "version": "1.2.1", + "bundled": true, + "requires": { + "figgy-pudding": "^3.5.1", + "find-up": "^3.0.0", + "ini": "^1.3.5" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "bundled": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "bundled": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.2.0", + "bundled": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "bundled": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "bundled": true + } + } + }, + "libnpmhook": { + "version": "5.0.3", + "bundled": true, + "requires": { + "aproba": "^2.0.0", + "figgy-pudding": "^3.4.1", + "get-stream": "^4.0.0", + "npm-registry-fetch": "^4.0.0" + } + }, + "libnpmorg": { + "version": "1.0.1", + "bundled": true, + "requires": { + "aproba": "^2.0.0", + "figgy-pudding": "^3.4.1", + "get-stream": "^4.0.0", + "npm-registry-fetch": "^4.0.0" + } + }, + "libnpmpublish": { + "version": "1.1.2", + "bundled": true, + "requires": { + "aproba": "^2.0.0", + "figgy-pudding": "^3.5.1", + "get-stream": "^4.0.0", + "lodash.clonedeep": "^4.5.0", + "normalize-package-data": "^2.4.0", + "npm-package-arg": "^6.1.0", + "npm-registry-fetch": "^4.0.0", + "semver": "^5.5.1", + "ssri": "^6.0.1" + } + }, + "libnpmsearch": { + "version": "2.0.2", + "bundled": true, + "requires": { + "figgy-pudding": "^3.5.1", + "get-stream": "^4.0.0", + "npm-registry-fetch": "^4.0.0" + } + }, + "libnpmteam": { + "version": "1.0.2", + "bundled": true, + "requires": { + "aproba": "^2.0.0", + "figgy-pudding": "^3.4.1", + "get-stream": "^4.0.0", + "npm-registry-fetch": "^4.0.0" + } + }, + "libnpx": { + "version": "10.2.4", + "bundled": true, + "requires": { + "dotenv": "^5.0.1", + "npm-package-arg": "^6.0.0", + "rimraf": "^2.6.2", + "safe-buffer": "^5.1.0", + "update-notifier": "^2.3.0", + "which": "^1.3.0", + "y18n": "^4.0.0", + "yargs": "^14.2.3" + } + }, + "lock-verify": { + "version": "2.1.0", + "bundled": true, + "requires": { + "npm-package-arg": "^6.1.0", + "semver": "^5.4.1" + } + }, + "lockfile": { + "version": "1.0.4", + "bundled": true, + "requires": { + "signal-exit": "^3.0.2" + } + }, + "lodash._baseindexof": { + "version": "3.1.0", + "bundled": true + }, + "lodash._baseuniq": { + "version": "4.6.0", + "bundled": true, + "requires": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "lodash._bindcallback": { + "version": "3.0.1", + "bundled": true + }, + "lodash._cacheindexof": { + "version": "3.0.2", + "bundled": true + }, + "lodash._createcache": { + "version": "3.1.2", + "bundled": true, + "requires": { + "lodash._getnative": "^3.0.0" + } + }, + "lodash._createset": { + "version": "4.0.3", + "bundled": true + }, + "lodash._getnative": { + "version": "3.9.1", + "bundled": true + }, + "lodash._root": { + "version": "3.0.1", + "bundled": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "bundled": true + }, + "lodash.restparam": { + "version": "3.6.1", + "bundled": true + }, + "lodash.union": { + "version": "4.6.0", + "bundled": true + }, + "lodash.uniq": { + "version": "4.5.0", + "bundled": true + }, + "lodash.without": { + "version": "4.4.0", + "bundled": true + }, + "lowercase-keys": { + "version": "1.0.1", + "bundled": true + }, + "lru-cache": { + "version": "5.1.1", + "bundled": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "1.3.0", + "bundled": true, + "requires": { + "pify": "^3.0.0" + } + }, + "make-fetch-happen": { + "version": "5.0.2", + "bundled": true, + "requires": { + "agentkeepalive": "^3.4.1", + "cacache": "^12.0.0", + "http-cache-semantics": "^3.8.1", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^2.2.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "node-fetch-npm": "^2.0.2", + "promise-retry": "^1.1.1", + "socks-proxy-agent": "^4.0.0", + "ssri": "^6.0.0" + } + }, + "meant": { + "version": "1.0.2", + "bundled": true + }, + "mime-db": { + "version": "1.35.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.19", + "bundled": true, + "requires": { + "mime-db": "~1.35.0" + } + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "bundled": true + }, + "minizlib": { + "version": "1.3.3", + "bundled": true, + "requires": { + "minipass": "^2.9.0" + }, + "dependencies": { + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + } + } + }, + "mississippi": { + "version": "3.0.0", + "bundled": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "mkdirp": { + "version": "0.5.5", + "bundled": true, + "requires": { + "minimist": "^1.2.5" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "bundled": true + } + } + }, + "move-concurrently": { + "version": "1.0.1", + "bundled": true, + "requires": { + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "bundled": true + } + } + }, + "ms": { + "version": "2.1.1", + "bundled": true + }, + "mute-stream": { + "version": "0.0.7", + "bundled": true + }, + "node-fetch-npm": { + "version": "2.0.2", + "bundled": true, + "requires": { + "encoding": "^0.1.11", + "json-parse-better-errors": "^1.0.0", + "safe-buffer": "^5.1.1" + } + }, + "node-gyp": { + "version": "5.1.0", + "bundled": true, + "requires": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.2", + "mkdirp": "^0.5.1", + "nopt": "^4.0.1", + "npmlog": "^4.1.2", + "request": "^2.88.0", + "rimraf": "^2.6.3", + "semver": "^5.7.1", + "tar": "^4.4.12", + "which": "^1.3.1" + } + }, + "nopt": { + "version": "4.0.3", + "bundled": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "bundled": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "resolve": { + "version": "1.10.0", + "bundled": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "npm-audit-report": { + "version": "1.3.3", + "bundled": true, + "requires": { + "cli-table3": "^0.5.0", + "console-control-strings": "^1.1.0" + } + }, + "npm-bundled": { + "version": "1.1.1", + "bundled": true, + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-cache-filename": { + "version": "1.0.2", + "bundled": true + }, + "npm-install-checks": { + "version": "3.0.2", + "bundled": true, + "requires": { + "semver": "^2.3.0 || 3.x || 4 || 5" + } + }, + "npm-lifecycle": { + "version": "3.1.5", + "bundled": true, + "requires": { + "byline": "^5.0.0", + "graceful-fs": "^4.1.15", + "node-gyp": "^5.0.2", + "resolve-from": "^4.0.0", + "slide": "^1.1.6", + "uid-number": "0.0.6", + "umask": "^1.1.0", + "which": "^1.3.1" + } + }, + "npm-logical-tree": { + "version": "1.2.1", + "bundled": true + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "bundled": true + }, + "npm-package-arg": { + "version": "6.1.1", + "bundled": true, + "requires": { + "hosted-git-info": "^2.7.1", + "osenv": "^0.1.5", + "semver": "^5.6.0", + "validate-npm-package-name": "^3.0.0" + } + }, + "npm-packlist": { + "version": "1.4.8", + "bundled": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-pick-manifest": { + "version": "3.0.2", + "bundled": true, + "requires": { + "figgy-pudding": "^3.5.1", + "npm-package-arg": "^6.0.0", + "semver": "^5.4.1" + } + }, + "npm-profile": { + "version": "4.0.4", + "bundled": true, + "requires": { + "aproba": "^1.1.2 || 2", + "figgy-pudding": "^3.4.1", + "npm-registry-fetch": "^4.0.0" + } + }, + "npm-registry-fetch": { + "version": "4.0.7", + "bundled": true, + "requires": { + "JSONStream": "^1.3.4", + "bluebird": "^3.5.1", + "figgy-pudding": "^3.4.1", + "lru-cache": "^5.1.1", + "make-fetch-happen": "^5.0.0", + "npm-package-arg": "^6.1.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "bundled": true + } + } + }, + "npm-run-path": { + "version": "2.0.2", + "bundled": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "npm-user-validate": { + "version": "1.0.1", + "bundled": true + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true + }, + "oauth-sign": { + "version": "0.9.0", + "bundled": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true + }, + "object-keys": { + "version": "1.0.12", + "bundled": true + }, + "object.getownpropertydescriptors": { + "version": "2.0.3", + "bundled": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.1" + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1" + } + }, + "opener": { + "version": "1.5.2", + "bundled": true + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-finally": { + "version": "1.0.0", + "bundled": true + }, + "package-json": { + "version": "4.0.1", + "bundled": true, + "requires": { + "got": "^6.7.1", + "registry-auth-token": "^3.0.1", + "registry-url": "^3.0.3", + "semver": "^5.1.0" + } + }, + "pacote": { + "version": "9.5.12", + "bundled": true, + "requires": { + "bluebird": "^3.5.3", + "cacache": "^12.0.2", + "chownr": "^1.1.2", + "figgy-pudding": "^3.5.1", + "get-stream": "^4.1.0", + "glob": "^7.1.3", + "infer-owner": "^1.0.4", + "lru-cache": "^5.1.1", + "make-fetch-happen": "^5.0.0", + "minimatch": "^3.0.4", + "minipass": "^2.3.5", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "normalize-package-data": "^2.4.0", + "npm-normalize-package-bin": "^1.0.0", + "npm-package-arg": "^6.1.0", + "npm-packlist": "^1.1.12", + "npm-pick-manifest": "^3.0.0", + "npm-registry-fetch": "^4.0.0", + "osenv": "^0.1.5", + "promise-inflight": "^1.0.1", + "promise-retry": "^1.1.1", + "protoduck": "^5.0.1", + "rimraf": "^2.6.2", + "safe-buffer": "^5.1.2", + "semver": "^5.6.0", + "ssri": "^6.0.1", + "tar": "^4.4.10", + "unique-filename": "^1.1.1", + "which": "^1.3.1" + }, + "dependencies": { + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + } + } + }, + "parallel-transform": { + "version": "1.1.0", + "bundled": true, + "requires": { + "cyclist": "~0.2.2", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "path-exists": { + "version": "3.0.0", + "bundled": true + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "path-is-inside": { + "version": "1.0.2", + "bundled": true + }, + "path-key": { + "version": "2.0.1", + "bundled": true + }, + "path-parse": { + "version": "1.0.6", + "bundled": true + }, + "performance-now": { + "version": "2.1.0", + "bundled": true + }, + "pify": { + "version": "3.0.0", + "bundled": true + }, + "prepend-http": { + "version": "1.0.4", + "bundled": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true + }, + "promise-inflight": { + "version": "1.0.1", + "bundled": true + }, + "promise-retry": { + "version": "1.1.1", + "bundled": true, + "requires": { + "err-code": "^1.0.0", + "retry": "^0.10.0" + }, + "dependencies": { + "retry": { + "version": "0.10.1", + "bundled": true + } + } + }, + "promzard": { + "version": "0.3.0", + "bundled": true, + "requires": { + "read": "1" + } + }, + "proto-list": { + "version": "1.2.4", + "bundled": true + }, + "protoduck": { + "version": "5.0.1", + "bundled": true, + "requires": { + "genfun": "^5.0.0" + } + }, + "prr": { + "version": "1.0.1", + "bundled": true + }, + "pseudomap": { + "version": "1.0.2", + "bundled": true + }, + "psl": { + "version": "1.1.29", + "bundled": true + }, + "pump": { + "version": "3.0.0", + "bundled": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "bundled": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + }, + "dependencies": { + "pump": { + "version": "2.0.1", + "bundled": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, + "punycode": { + "version": "1.4.1", + "bundled": true + }, + "qrcode-terminal": { + "version": "0.12.0", + "bundled": true + }, + "qs": { + "version": "6.5.2", + "bundled": true + }, + "query-string": { + "version": "6.8.2", + "bundled": true, + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, + "qw": { + "version": "1.0.1", + "bundled": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "read": { + "version": "1.0.7", + "bundled": true, + "requires": { + "mute-stream": "~0.0.4" + } + }, + "read-cmd-shim": { + "version": "1.0.5", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "read-installed": { + "version": "4.0.3", + "bundled": true, + "requires": { + "debuglog": "^1.0.1", + "graceful-fs": "^4.1.2", + "read-package-json": "^2.0.0", + "readdir-scoped-modules": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "slide": "~1.1.3", + "util-extend": "^1.0.1" + } + }, + "read-package-json": { + "version": "2.1.1", + "bundled": true, + "requires": { + "glob": "^7.1.1", + "graceful-fs": "^4.1.2", + "json-parse-better-errors": "^1.0.1", + "normalize-package-data": "^2.0.0", + "npm-normalize-package-bin": "^1.0.0" + } + }, + "read-package-tree": { + "version": "5.3.1", + "bundled": true, + "requires": { + "read-package-json": "^2.0.0", + "readdir-scoped-modules": "^1.0.0", + "util-promisify": "^2.1.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "bundled": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdir-scoped-modules": { + "version": "1.1.0", + "bundled": true, + "requires": { + "debuglog": "^1.0.1", + "dezalgo": "^1.0.0", + "graceful-fs": "^4.1.2", + "once": "^1.3.0" + } + }, + "registry-auth-token": { + "version": "3.4.0", + "bundled": true, + "requires": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "registry-url": { + "version": "3.1.0", + "bundled": true, + "requires": { + "rc": "^1.0.1" + } + }, + "request": { + "version": "2.88.0", + "bundled": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "require-directory": { + "version": "2.1.1", + "bundled": true + }, + "require-main-filename": { + "version": "2.0.0", + "bundled": true + }, + "resolve-from": { + "version": "4.0.0", + "bundled": true + }, + "retry": { + "version": "0.12.0", + "bundled": true + }, + "rimraf": { + "version": "2.7.1", + "bundled": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-queue": { + "version": "1.0.3", + "bundled": true, + "requires": { + "aproba": "^1.1.1" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "bundled": true + } + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true + }, + "semver": { + "version": "5.7.1", + "bundled": true + }, + "semver-diff": { + "version": "2.1.0", + "bundled": true, + "requires": { + "semver": "^5.0.3" + } + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true + }, + "sha": { + "version": "3.0.0", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "shebang-command": { + "version": "1.2.0", + "bundled": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "bundled": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true + }, + "slide": { + "version": "1.1.6", + "bundled": true + }, + "smart-buffer": { + "version": "4.1.0", + "bundled": true + }, + "socks": { + "version": "2.3.3", + "bundled": true, + "requires": { + "ip": "1.1.5", + "smart-buffer": "^4.1.0" + } + }, + "socks-proxy-agent": { + "version": "4.0.2", + "bundled": true, + "requires": { + "agent-base": "~4.2.1", + "socks": "~2.3.2" + }, + "dependencies": { + "agent-base": { + "version": "4.2.1", + "bundled": true, + "requires": { + "es6-promisify": "^5.0.0" + } + } + } + }, + "sorted-object": { + "version": "2.0.1", + "bundled": true + }, + "sorted-union-stream": { + "version": "2.1.3", + "bundled": true, + "requires": { + "from2": "^1.3.0", + "stream-iterate": "^1.1.0" + }, + "dependencies": { + "from2": { + "version": "1.3.0", + "bundled": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "~1.1.10" + } + }, + "isarray": { + "version": "0.0.1", + "bundled": true + }, + "readable-stream": { + "version": "1.1.14", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "bundled": true + } + } + }, + "spdx-correct": { + "version": "3.0.0", + "bundled": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "bundled": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "bundled": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "bundled": true + }, + "split-on-first": { + "version": "1.1.0", + "bundled": true + }, + "sshpk": { + "version": "1.14.2", + "bundled": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "ssri": { + "version": "6.0.2", + "bundled": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "stream-each": { + "version": "1.2.2", + "bundled": true, + "requires": { + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" + } + }, + "stream-iterate": { + "version": "1.2.0", + "bundled": true, + "requires": { + "readable-stream": "^2.1.5", + "stream-shift": "^1.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "stream-shift": { + "version": "1.0.0", + "bundled": true + }, + "strict-uri-encode": { + "version": "2.0.0", + "bundled": true + }, + "string-width": { + "version": "2.1.1", + "bundled": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true + }, + "strip-ansi": { + "version": "4.0.0", + "bundled": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "string_decoder": { + "version": "1.3.0", + "bundled": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.0", + "bundled": true + } + } + }, + "stringify-package": { + "version": "1.0.1", + "bundled": true + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "bundled": true + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true + }, + "supports-color": { + "version": "5.4.0", + "bundled": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tar": { + "version": "4.4.13", + "bundled": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + }, + "dependencies": { + "minipass": { + "version": "2.9.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + } + } + }, + "term-size": { + "version": "1.2.0", + "bundled": true, + "requires": { + "execa": "^0.7.0" + } + }, + "text-table": { + "version": "0.2.0", + "bundled": true + }, + "through": { + "version": "2.3.8", + "bundled": true + }, + "through2": { + "version": "2.0.3", + "bundled": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "timed-out": { + "version": "4.0.1", + "bundled": true + }, + "tiny-relative-date": { + "version": "1.3.0", + "bundled": true + }, + "tough-cookie": { + "version": "2.4.3", + "bundled": true, + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "bundled": true, + "optional": true + }, + "typedarray": { + "version": "0.0.6", + "bundled": true + }, + "uid-number": { + "version": "0.0.6", + "bundled": true + }, + "umask": { + "version": "1.1.0", + "bundled": true + }, + "unique-filename": { + "version": "1.1.1", + "bundled": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.0", + "bundled": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "unique-string": { + "version": "1.0.0", + "bundled": true, + "requires": { + "crypto-random-string": "^1.0.0" + } + }, + "unpipe": { + "version": "1.0.0", + "bundled": true + }, + "unzip-response": { + "version": "2.0.1", + "bundled": true + }, + "update-notifier": { + "version": "2.5.0", + "bundled": true, + "requires": { + "boxen": "^1.2.1", + "chalk": "^2.0.1", + "configstore": "^3.0.0", + "import-lazy": "^2.1.0", + "is-ci": "^1.0.10", + "is-installed-globally": "^0.1.0", + "is-npm": "^1.0.0", + "latest-version": "^3.0.0", + "semver-diff": "^2.0.0", + "xdg-basedir": "^3.0.0" + } + }, + "uri-js": { + "version": "4.4.0", + "bundled": true, + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "bundled": true + } + } + }, + "url-parse-lax": { + "version": "1.0.0", + "bundled": true, + "requires": { + "prepend-http": "^1.0.1" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true + }, + "util-extend": { + "version": "1.0.3", + "bundled": true + }, + "util-promisify": { + "version": "2.1.0", + "bundled": true, + "requires": { + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "uuid": { + "version": "3.3.3", + "bundled": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "bundled": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validate-npm-package-name": { + "version": "3.0.0", + "bundled": true, + "requires": { + "builtins": "^1.0.3" + } + }, + "verror": { + "version": "1.10.0", + "bundled": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "wcwidth": { + "version": "1.0.1", + "bundled": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "which": { + "version": "1.3.1", + "bundled": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "bundled": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "requires": { + "string-width": "^1.0.2" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "bundled": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "widest-line": { + "version": "2.0.1", + "bundled": true, + "requires": { + "string-width": "^2.1.1" + } + }, + "worker-farm": { + "version": "1.7.0", + "bundled": true, + "requires": { + "errno": "~0.1.7" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "bundled": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "bundled": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true + }, + "string-width": { + "version": "3.1.0", + "bundled": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "bundled": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true + }, + "write-file-atomic": { + "version": "2.4.3", + "bundled": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "xdg-basedir": { + "version": "3.0.0", + "bundled": true + }, + "xtend": { + "version": "4.0.1", + "bundled": true + }, + "y18n": { + "version": "4.0.1", + "bundled": true + }, + "yallist": { + "version": "3.0.3", + "bundled": true + }, + "yargs": { + "version": "14.2.3", + "bundled": true, + "requires": { + "cliui": "^5.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^15.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "bundled": true + }, + "find-up": { + "version": "3.0.0", + "bundled": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true + }, + "locate-path": { + "version": "3.0.0", + "bundled": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "bundled": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "bundled": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "bundled": true + }, + "string-width": { + "version": "3.1.0", + "bundled": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "bundled": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "15.0.1", + "bundled": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "bundled": true + } + } + } + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "oniguruma": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/oniguruma/-/oniguruma-7.2.1.tgz", + "integrity": "sha512-WPS/e1uzhswPtJSe+Zls/kAj27+lEqZjCmRSjnYk/Z4L2Mu+lJC2JWtkZhPJe4kZeTQfz7ClcLyXlI4J68MG2w==", + "requires": { + "nan": "^2.14.0" + } + }, + "open": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/open/-/open-7.3.0.tgz", + "integrity": "sha512-mgLwQIx2F/ye9SmbrUkurZCnkoXyXyu9EbHtJZrICjVAJfyMArdHp3KkixGdZx1ZHFPNIwl0DDM1dFFqXbTLZw==", + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "requires": { + "lcid": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "prebuild-install": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.4.tgz", + "integrity": "sha512-AkKN+pf4fSEihjapLEEj8n85YIw/tN6BQqkhzbDc0RvEZGdkpJBGMUYx66AAMcPG2KzmPQS7Cm16an4HVBRRMA==", + "requires": { + "detect-libc": "^1.0.3", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp": "^0.5.1", + "napi-build-utils": "^1.0.1", + "node-abi": "^2.7.0", + "noop-logger": "^0.1.1", + "npmlog": "^4.0.1", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^3.0.3", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0", + "which-pm-runs": "^1.0.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "property-accessors": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/property-accessors/-/property-accessors-1.1.3.tgz", + "integrity": "sha1-Hd6EAkYxhlkJ7zBwM2VoDF+SixU=", + "requires": { + "es6-weak-map": "^0.1.2", + "mixto": "1.x" + } + }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "requires": { + "mute-stream": "~0.0.4" + } + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "season": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/season/-/season-6.0.2.tgz", + "integrity": "sha1-naWPsd3SSCTXYhstxjpxI7UCF7Y=", + "requires": { + "cson-parser": "^1.3.0", + "fs-plus": "^3.0.0", + "yargs": "^3.23.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" + }, + "simple-get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz", + "integrity": "sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==", + "requires": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, + "tar": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", + "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + } + } + }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + } + } + }, + "temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "requires": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "dependencies": { + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "tmp": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.28.tgz", + "integrity": "sha1-Fyc1t/YU6nrzlmT6hM8N5OUV0SA=", + "requires": { + "os-tmpdir": "~1.0.1" + } + }, + "touch": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/touch/-/touch-0.0.3.tgz", + "integrity": "sha1-Ua7z1ElXHU8oel2Hyci0kYGg2x0=", + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "requires": { + "abbrev": "1" + } + } + } + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, + "underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" + }, + "underscore-plus": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/underscore-plus/-/underscore-plus-1.7.0.tgz", + "integrity": "sha512-A3BEzkeicFLnr+U/Q3EyWwJAQPbA19mtZZ4h+lLq3ttm9kn8WC4R3YpuJZEXmWdLjYP47Zc8aLZm9kwdv+zzvA==", + "requires": { + "underscore": "^1.9.1" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "which-pm-runs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", + "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=" + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "window-size": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", + "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=" + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "wrench": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/wrench/-/wrench-1.5.9.tgz", + "integrity": "sha1-QRaRxjqbJTGxcAJnJ5veyiOyFCo=" + }, + "xmlbuilder": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-0.4.3.tgz", + "integrity": "sha1-xGFLp04K0ZbmCcknLNnh3bKKilg=" + }, + "xmldom": { + "version": "0.1.31", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", + "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==" + }, + "y18n": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", + "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yargs": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", + "integrity": "sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU=", + "requires": { + "camelcase": "^2.0.1", + "cliui": "^3.0.3", + "decamelize": "^1.1.1", + "os-locale": "^1.4.0", + "string-width": "^1.0.1", + "window-size": "^0.1.4", + "y18n": "^3.2.0" + } + } + } + } + } +} diff --git a/apm/package.json b/apm/package.json index 64f93716943..55505df2f02 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "0.168.0" + "atom-package-manager": "2.6.2" } } diff --git a/atom.sh b/atom.sh index 236f49ff654..ef709ebb343 100755 --- a/atom.sh +++ b/atom.sh @@ -1,20 +1,49 @@ -#!/usr/bin/env bash +#!/bin/bash if [ "$(uname)" == 'Darwin' ]; then OS='Mac' elif [ "$(expr substr $(uname -s) 1 5)" == 'Linux' ]; then OS='Linux' -elif [ "$(expr substr $(uname -s) 1 10)" == 'MINGW32_NT' ]; then - OS='Cygwin' else echo "Your platform ($(uname -a)) is not supported." exit 1 fi -while getopts ":wtfvh-:" opt; do +case $(basename $0) in + atom-beta) + CHANNEL=beta + ;; + atom-nightly) + CHANNEL=nightly + ;; + atom-dev) + CHANNEL=dev + ;; + *) + CHANNEL=stable + ;; +esac + +# Only set the ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT env var if it hasn't been set. +if [ -z "$ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT" ] +then + export ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT=true +fi + +ATOM_ADD=false +ATOM_NEW_WINDOW=false +EXIT_CODE_OVERRIDE= + +while getopts ":anwtfvh-:" opt; do case "$opt" in -) case "${OPTARG}" in + add) + ATOM_ADD=true + ;; + new-window) + ATOM_NEW_WINDOW=true + ;; wait) WAIT=1 ;; @@ -22,11 +51,20 @@ while getopts ":wtfvh-:" opt; do REDIRECT_STDERR=1 EXPECT_OUTPUT=1 ;; - foreground|test) + foreground|benchmark|benchmark-test|test) EXPECT_OUTPUT=1 ;; + enable-electron-logging) + export ELECTRON_ENABLE_LOGGING=1 + ;; esac ;; + a) + ATOM_ADD=true + ;; + n) + ATOM_NEW_WINDOW=true + ;; w) WAIT=1 ;; @@ -40,44 +78,100 @@ while getopts ":wtfvh-:" opt; do esac done +if [ "${ATOM_ADD}" = "true" ] && [ "${ATOM_NEW_WINDOW}" = "true" ]; then + EXPECT_OUTPUT=1 + EXIT_CODE_OVERRIDE=1 +fi + if [ $REDIRECT_STDERR ]; then exec 2> /dev/null fi +ATOM_HOME="${ATOM_HOME:-$HOME/.atom}" +mkdir -p "$ATOM_HOME" + if [ $OS == 'Mac' ]; then - ATOM_APP_NAME=Atom.app + if [ -L "$0" ]; then + SCRIPT="$(readlink "$0")" + else + SCRIPT="$0" + fi + ATOM_APP="$(dirname "$(dirname "$(dirname "$(dirname "$SCRIPT")")")")" + if [ "$ATOM_APP" == . ]; then + unset ATOM_APP + else + ATOM_PATH="$(dirname "$ATOM_APP")" + ATOM_APP_NAME="$(basename "$ATOM_APP")" + fi + + if [ ! -z "${ATOM_APP_NAME}" ]; then + # If ATOM_APP_NAME is known, use it as the executable name + ATOM_EXECUTABLE_NAME="${ATOM_APP_NAME%.*}" + else + # Else choose it from the inferred channel name + if [ "$CHANNEL" == 'beta' ]; then + ATOM_EXECUTABLE_NAME="Atom Beta" + elif [ "$CHANNEL" == 'nightly' ]; then + ATOM_EXECUTABLE_NAME="Atom Nightly" + elif [ "$CHANNEL" == 'dev' ]; then + ATOM_EXECUTABLE_NAME="Atom Dev" + else + ATOM_EXECUTABLE_NAME="Atom" + fi + fi if [ -z "${ATOM_PATH}" ]; then - # If ATOM_PATH isnt set, check /Applications and then ~/Applications for Atom.app + # If ATOM_PATH isn't set, check /Applications and then ~/Applications for Atom.app if [ -x "/Applications/$ATOM_APP_NAME" ]; then ATOM_PATH="/Applications" elif [ -x "$HOME/Applications/$ATOM_APP_NAME" ]; then ATOM_PATH="$HOME/Applications" else - # We havent found an Atom.app, use spotlight to search for Atom + # We haven't found an Atom.app, use spotlight to search for Atom ATOM_PATH="$(mdfind "kMDItemCFBundleIdentifier == 'com.github.atom'" | grep -v ShipIt | head -1 | xargs -0 dirname)" # Exit if Atom can't be found if [ ! -x "$ATOM_PATH/$ATOM_APP_NAME" ]; then - echo "Cannot locate Atom.app, it is usually located in /Applications. Set the ATOM_PATH environment variable to the directory containing Atom.app." + echo "Cannot locate ${ATOM_APP_NAME}, it is usually located in /Applications. Set the ATOM_PATH environment variable to the directory containing ${ATOM_APP_NAME}." exit 1 fi fi fi if [ $EXPECT_OUTPUT ]; then - "$ATOM_PATH/$ATOM_APP_NAME/Contents/MacOS/Atom" --executed-from="$(pwd)" --pid=$$ "$@" - exit $? + "$ATOM_PATH/$ATOM_APP_NAME/Contents/MacOS/$ATOM_EXECUTABLE_NAME" --executed-from="$(pwd)" --pid=$$ "$@" + ATOM_EXIT=$? + if [ ${ATOM_EXIT} -eq 0 ] && [ -n "${EXIT_CODE_OVERRIDE}" ]; then + exit "${EXIT_CODE_OVERRIDE}" + else + exit ${ATOM_EXIT} + fi else open -a "$ATOM_PATH/$ATOM_APP_NAME" -n --args --executed-from="$(pwd)" --pid=$$ --path-environment="$PATH" "$@" fi elif [ $OS == 'Linux' ]; then SCRIPT=$(readlink -f "$0") USR_DIRECTORY=$(readlink -f $(dirname $SCRIPT)/..) - ATOM_PATH="$USR_DIRECTORY/share/atom/atom" - ATOM_HOME="${ATOM_HOME:-$HOME/.atom}" - mkdir -p "$ATOM_HOME" + case $CHANNEL in + beta) + ATOM_PATH="$USR_DIRECTORY/share/atom-beta/atom" + ;; + nightly) + ATOM_PATH="$USR_DIRECTORY/share/atom-nightly/atom" + ;; + dev) + ATOM_PATH="$USR_DIRECTORY/share/atom-dev/atom" + ;; + *) + ATOM_PATH="$USR_DIRECTORY/share/atom/atom" + ;; + esac + + #Will allow user to get context menu on cinnamon desktop enviroment + if [[ "$(expr substr $(printenv | grep "DESKTOP_SESSION=") 17 8)" == "cinnamon" ]]; then + cp "resources/linux/desktopenviroment/cinnamon/atom.nemo_action" "/usr/share/nemo/actions/atom.nemo_action" + fi : ${TMPDIR:=/tmp} @@ -85,7 +179,12 @@ elif [ $OS == 'Linux' ]; then if [ $EXPECT_OUTPUT ]; then "$ATOM_PATH" --executed-from="$(pwd)" --pid=$$ "$@" - exit $? + ATOM_EXIT=$? + if [ ${ATOM_EXIT} -eq 0 ] && [ -n "${EXIT_CODE_OVERRIDE}" ]; then + exit "${EXIT_CODE_OVERRIDE}" + else + exit ${ATOM_EXIT} + fi else ( nohup "$ATOM_PATH" --executed-from="$(pwd)" --pid=$$ "$@" > "$ATOM_HOME/nohup.out" 2>&1 @@ -103,8 +202,20 @@ on_die() { } trap 'on_die' SIGQUIT SIGTERM -# If the wait flag is set, don't exit this process until Atom tells it to. +# If the wait flag is set, don't exit this process until Atom kills it. if [ $WAIT ]; then + WAIT_FIFO="$ATOM_HOME/.wait_fifo" + + if [ ! -p "$WAIT_FIFO" ]; then + rm -f "$WAIT_FIFO" + mkfifo "$WAIT_FIFO" + fi + + # Block endlessly by reading from a named pipe. + exec 2>/dev/null + read < "$WAIT_FIFO" + + # If the read completes for some reason, fall back to sleeping in a loop. while true; do sleep 1 done diff --git a/benchmark/benchmark-bootstrap.coffee b/benchmark/benchmark-bootstrap.coffee deleted file mode 100644 index 6e9d6dd1f76..00000000000 --- a/benchmark/benchmark-bootstrap.coffee +++ /dev/null @@ -1,12 +0,0 @@ -require '../src/window' -Atom = require '../src/atom' -window.atom = Atom.loadOrCreate('spec') -atom.show() unless atom.getLoadSettings().exitWhenDone -window.atom = atom - -{runSpecSuite} = require '../spec/jasmine-helper' - -atom.openDevTools() - -document.title = "Benchmark Suite" -runSpecSuite('../benchmark/benchmark-suite', atom.getLoadSettings().logFile) diff --git a/benchmark/benchmark-helper.coffee b/benchmark/benchmark-helper.coffee deleted file mode 100644 index d831572c5cd..00000000000 --- a/benchmark/benchmark-helper.coffee +++ /dev/null @@ -1,115 +0,0 @@ -require '../spec/spec-helper' - -path = require 'path' -{$} = require '../src/space-pen-extensions' -{Point} = require 'atom' -_ = require 'underscore-plus' -fs = require 'fs-plus' -Project = require '../src/project' -TokenizedBuffer = require '../src/tokenized-buffer' - -defaultCount = 100 -window.pbenchmark = (args...) -> window.benchmark(args..., profile: true) -window.fbenchmark = (args...) -> window.benchmark(args..., focused: true) -window.fpbenchmark = (args...) -> window.benchmark(args..., profile: true, focused: true) -window.pfbenchmark = window.fpbenchmark - -window.benchmarkFixturesProject = new Project(path.join(__dirname, 'fixtures')) - -beforeEach -> - window.project = window.benchmarkFixturesProject - jasmine.unspy(window, 'setTimeout') - jasmine.unspy(window, 'clearTimeout') - jasmine.unspy(TokenizedBuffer::, 'tokenizeInBackground') - -window.benchmark = (args...) -> - description = args.shift() - if typeof args[0] is 'number' - count = args.shift() - else - count = defaultCount - [fn, options] = args - {profile, focused} = (options ? {}) - - method = if focused then fit else it - method description, -> - total = measure -> - console.profile(description) if profile - _.times count, fn - console.profileEnd(description) if profile - avg = total / count - - fullname = @getFullName().replace(/\s|\.$/g, "") - report = "#{fullname}: #{total} / #{count} = #{avg}ms" - console.log(report) - - if atom.getLoadSettings().exitWhenDone - url = "https://github.com/_stats" - data = [type: 'timing', metric: "atom.#{fullname}", ms: avg] - $.ajax url, - async: false - data: JSON.stringify(data) - error: (args...) -> - console.log "Failed to send atom.#{fullname}\n#{JSON.stringify(args)}" - -window.measure = (fn) -> - start = new Date().getTime() - fn() - new Date().getTime() - start - -window.waitsForPromise = (fn) -> - window.waitsFor (moveOn) -> - fn().done(moveOn) - -window.keyIdentifierForKey = (key) -> - if key.length > 1 # named key - key - else - charCode = key.toUpperCase().charCodeAt(0) - "U+00" + charCode.toString(16) - -window.keydownEvent = (key, properties={}) -> - $.Event "keydown", _.extend({originalEvent: {keyIdentifier: keyIdentifierForKey(key)}}, properties) - -window.clickEvent = (properties={}) -> - $.Event "click", properties - -window.mouseEvent = (type, properties) -> - if properties.point - {point, editorView} = properties - {top, left} = @pagePixelPositionForPoint(editorView, point) - properties.pageX = left + 1 - properties.pageY = top + 1 - properties.originalEvent ?= {detail: 1} - $.Event type, properties - -window.mousedownEvent = (properties={}) -> - window.mouseEvent('mousedown', properties) - -window.mousemoveEvent = (properties={}) -> - window.mouseEvent('mousemove', properties) - -window.pagePixelPositionForPoint = (editorView, point) -> - point = Point.fromObject point - top = editorView.lines.offset().top + point.row * editorView.lineHeight - left = editorView.lines.offset().left + point.column * editorView.charWidth - editorView.lines.scrollLeft() - {top, left} - -window.seteditorViewWidthInChars = (editorView, widthInChars, charWidth=editorView.charWidth) -> - editorView.width(charWidth * widthInChars + editorView.lines.position().left) - -$.fn.resultOfTrigger = (type) -> - event = $.Event(type) - this.trigger(event) - event.result - -$.fn.enableKeymap = -> - @on 'keydown', (e) -> window.keymap.handleKeyEvent(e) - -$.fn.attachToDom = -> - $('#jasmine-content').append(this) - -$.fn.textInput = (data) -> - event = document.createEvent 'TextEvent' - event.initTextEvent('textInput', true, true, window, data) - this.each -> this.dispatchEvent(event) diff --git a/benchmark/benchmark-suite.coffee b/benchmark/benchmark-suite.coffee deleted file mode 100644 index 7b06d9758f5..00000000000 --- a/benchmark/benchmark-suite.coffee +++ /dev/null @@ -1,219 +0,0 @@ -require './benchmark-helper' -{$} = require '../src/space-pen-extensions' -_ = require 'underscore-plus' -{WorkspaceView} = require 'atom' -TokenizedBuffer = require '../src/tokenized-buffer' - -describe "editorView.", -> - editorView = null - - beforeEach -> - atom.workspaceViewParentSelector = '#jasmine-content' - atom.workspaceView = atom.views.getView(atom.workspace).__spacePenView - atom.workspaceView.attachToDom() - - atom.workspaceView.width(1024) - atom.workspaceView.height(768) - atom.workspaceView.openSync() - editorView = atom.workspaceView.getActiveView() - - afterEach -> - if editorView.pendingDisplayUpdate - waitsFor "editor to finish rendering", (done) -> - editorView.on 'editor:display-updated', done - - describe "keymap.", -> - event = null - - beforeEach -> - event = keydownEvent('x', target: editorView.hiddenInput[0]) - - benchmark "keydown-event-with-no-binding", 10, -> - keymap.handleKeyEvent(event) - - describe "opening-buffers.", -> - benchmark "300-line-file.", -> - buffer = project.bufferForPathSync('medium.coffee') - - describe "empty-file.", -> - benchmark "insert-delete", -> - editorView.insertText('x') - editorView.backspace() - - describe "300-line-file.", -> - beforeEach -> - atom.workspaceView.openSync('medium.coffee') - - describe "at-begining.", -> - benchmark "insert-delete", -> - editorView.insertText('x') - editorView.backspace() - - benchmark "insert-delete-rehighlight", -> - editorView.insertText('"') - editorView.backspace() - - describe "at-end.", -> - beforeEach -> - editorView.moveToBottom() - - benchmark "insert-delete", -> - editorView.insertText('"') - editorView.backspace() - - describe "empty-vs-set-innerHTML.", -> - [firstRow, lastRow] = [] - beforeEach -> - firstRow = editorView.getModel().getFirstVisibleScreenRow() - lastRow = editorView.getModel().getLastVisibleScreenRow() - - benchmark "build-gutter-html.", 1000, -> - editorView.gutter.renderLineNumbers(null, firstRow, lastRow) - - benchmark "set-innerHTML.", 1000, -> - editorView.gutter.renderLineNumbers(null, firstRow, lastRow) - editorView.gutter.lineNumbers[0].innerHtml = '' - - benchmark "empty.", 1000, -> - editorView.gutter.renderLineNumbers(null, firstRow, lastRow) - editorView.gutter.lineNumbers.empty() - - describe "positionLeftForLineAndColumn.", -> - line = null - beforeEach -> - editorView.scrollTop(2000) - editorView.resetDisplay() - line = editorView.lineElementForScreenRow(106)[0] - - describe "one-line.", -> - beforeEach -> - editorView.clearCharacterWidthCache() - - benchmark "uncached", 5000, -> - editorView.positionLeftForLineAndColumn(line, 106, 82) - editorView.clearCharacterWidthCache() - - benchmark "cached", 5000, -> - editorView.positionLeftForLineAndColumn(line, 106, 82) - - describe "multiple-lines.", -> - [firstRow, lastRow] = [] - beforeEach -> - firstRow = editorView.getModel().getFirstVisibleScreenRow() - lastRow = editorView.getModel().getLastVisibleScreenRow() - - benchmark "cache-entire-visible-area", 100, -> - for i in [firstRow..lastRow] - line = editorView.lineElementForScreenRow(i)[0] - editorView.positionLeftForLineAndColumn(line, i, Math.max(0, editorView.getModel().lineTextForBufferRow(i).length)) - - describe "text-rendering.", -> - beforeEach -> - editorView.scrollTop(2000) - - benchmark "resetDisplay", 50, -> - editorView.resetDisplay() - - benchmark "htmlForScreenRows", 1000, -> - lastRow = editorView.getLastScreenRow() - editorView.htmlForScreenRows(0, lastRow) - - benchmark "htmlForScreenRows.htmlParsing", 50, -> - lastRow = editorView.getLastScreenRow() - html = editorView.htmlForScreenRows(0, lastRow) - - div = document.createElement('div') - div.innerHTML = html - - describe "gutter-api.", -> - describe "getLineNumberElementsForClass.", -> - beforeEach -> - editorView.gutter.addClassToLine(20, 'omgwow') - editorView.gutter.addClassToLine(40, 'omgwow') - - benchmark "DOM", 20000, -> - editorView.gutter.getLineNumberElementsForClass('omgwow') - - benchmark "getLineNumberElement.DOM", 20000, -> - editorView.gutter.getLineNumberElement(12) - - benchmark "toggle-class", 2000, -> - editorView.gutter.addClassToLine(40, 'omgwow') - editorView.gutter.removeClassFromLine(40, 'omgwow') - - describe "find-then-unset.", -> - classes = ['one', 'two', 'three', 'four'] - - benchmark "single-class", 200, -> - editorView.gutter.addClassToLine(30, 'omgwow') - editorView.gutter.addClassToLine(40, 'omgwow') - editorView.gutter.removeClassFromAllLines('omgwow') - - benchmark "multiple-class", 200, -> - editorView.gutter.addClassToLine(30, 'one') - editorView.gutter.addClassToLine(30, 'two') - - editorView.gutter.addClassToLine(40, 'two') - editorView.gutter.addClassToLine(40, 'three') - editorView.gutter.addClassToLine(40, 'four') - - for klass in classes - editorView.gutter.removeClassFromAllLines(klass) - - describe "line-htmlification.", -> - div = null - html = null - beforeEach -> - lastRow = editorView.getLastScreenRow() - html = editorView.htmlForScreenRows(0, lastRow) - div = document.createElement('div') - - benchmark "setInnerHTML", 1, -> - div.innerHTML = html - - describe "9000-line-file.", -> - benchmark "opening.", 5, -> - atom.workspaceView.openSync('huge.js') - - describe "after-opening.", -> - beforeEach -> - atom.workspaceView.openSync('huge.js') - - benchmark "moving-to-eof.", 1, -> - editorView.moveToBottom() - - describe "on-first-line.", -> - benchmark "inserting-newline", 5, -> - editorView.insertNewline() - - describe "on-last-visible-line.", -> - beforeEach -> - editorView.setCursorScreenPosition([editorView.getLastVisibleScreenRow(), 0]) - - benchmark "move-down-and-scroll", 300, -> - editorView.trigger 'move-down' - - describe "at-eof.", -> - endPosition = null - - beforeEach -> - editorView.moveToBottom() - endPosition = editorView.getCursorScreenPosition() - - benchmark "move-to-beginning-of-word", -> - editorView.moveToBeginningOfWord() - editorView.setCursorScreenPosition(endPosition) - - benchmark "insert", -> - editorView.insertText('x') - -describe "TokenizedBuffer.", -> - describe "coffee-script-grammar.", -> - [languageMode, buffer] = [] - - beforeEach -> - editor = benchmarkFixturesProject.openSync('medium.coffee') - {languageMode, buffer} = editor - - benchmark "construction", 20, -> - new TokenizedBuffer(buffer, {languageMode, tabLength: 2}) diff --git a/benchmark/browser-process-startup.coffee b/benchmark/browser-process-startup.coffee deleted file mode 100755 index 2b06eaaa4c2..00000000000 --- a/benchmark/browser-process-startup.coffee +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env coffee - -{spawn, exec} = require 'child_process' -fs = require 'fs' -os = require 'os' -path = require 'path' -_ = require 'underscore-plus' -temp = require 'temp' - -directoryToOpen = temp.mkdirSync('browser-process-startup-') -socketPath = path.join(os.tmpdir(), "atom-#{process.env.USER}.sock") -numberOfRuns = 10 - -deleteSocketFile = -> - try - fs.unlinkSync(socketPath) if fs.existsSync(socketPath) - catch error - console.error(error) - -launchAtom = (callback) -> - deleteSocketFile() - - cmd = 'atom' - args = ['--safe', '--new-window', '--foreground', directoryToOpen] - atomProcess = spawn(cmd, args) - - output = '' - startupTimes = [] - dataListener = (data) -> - output += data - if match = /App load time: (\d+)/.exec(output) - startupTime = parseInt(match[1]) - atomProcess.stderr.removeListener 'data', dataListener - atomProcess.kill() - exec 'pkill -9 Atom', (error) -> - console.error(error) if error? - callback(startupTime) - - atomProcess.stderr.on 'data', dataListener - -startupTimes = [] -collector = (startupTime) -> - startupTimes.push(startupTime) - if startupTimes.length < numberOfRuns - launchAtom(collector) - else - maxTime = _.max(startupTimes) - minTime = _.min(startupTimes) - totalTime = startupTimes.reduce (previousValue=0, currentValue) -> previousValue + currentValue - console.log "Startup Runs: #{startupTimes.length}" - console.log "First run time: #{startupTimes[0]}ms" - console.log "Max time: #{maxTime}ms" - console.log "Min time: #{minTime}ms" - console.log "Average time: #{Math.round(totalTime/startupTimes.length)}ms" - -launchAtom(collector) diff --git a/benchmark/fixtures/huge.js b/benchmark/fixtures/huge.js deleted file mode 100644 index 0b1562f1455..00000000000 --- a/benchmark/fixtures/huge.js +++ /dev/null @@ -1,9245 +0,0 @@ -/*! - * jQuery JavaScript Library v1.7.1 - * http://jquery.com/ - * - * Copyright 2011, John Resig - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * Includes Sizzle.js - * http://sizzlejs.com/ - * Copyright 2011, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * - * Date: Mon Nov 21 21:11:03 2011 -0500 - */ -(function( window, undefined ) { - -// Use the correct document accordingly with window argument (sandbox) -var document = window.document, - navigator = window.navigator, - location = window.location; -var jQuery = (function() { - -// Define a local copy of jQuery -var jQuery = function( selector, context ) { - // The jQuery object is actually just the init constructor 'enhanced' - return new jQuery.fn.init( selector, context, rootjQuery ); - }, - - // Map over jQuery in case of overwrite - _jQuery = window.jQuery, - - // Map over the $ in case of overwrite - _$ = window.$, - - // A central reference to the root jQuery(document) - rootjQuery, - - // A simple way to check for HTML strings or ID strings - // Prioritize #id over to avoid XSS via location.hash (#9521) - quickExpr = /^(?:[^#<]*(<[\w\W]+>)[^>]*$|#([\w\-]*)$)/, - - // Check if a string has a non-whitespace character in it - rnotwhite = /\S/, - - // Used for trimming whitespace - trimLeft = /^\s+/, - trimRight = /\s+$/, - - // Match a standalone tag - rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>)?$/, - - // JSON RegExp - rvalidchars = /^[\],:{}\s]*$/, - rvalidescape = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, - rvalidtokens = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, - rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g, - - // Useragent RegExp - rwebkit = /(webkit)[ \/]([\w.]+)/, - ropera = /(opera)(?:.*version)?[ \/]([\w.]+)/, - rmsie = /(msie) ([\w.]+)/, - rmozilla = /(mozilla)(?:.*? rv:([\w.]+))?/, - - // Matches dashed string for camelizing - rdashAlpha = /-([a-z]|[0-9])/ig, - rmsPrefix = /^-ms-/, - - // Used by jQuery.camelCase as callback to replace() - fcamelCase = function( all, letter ) { - return ( letter + "" ).toUpperCase(); - }, - - // Keep a UserAgent string for use with jQuery.browser - userAgent = navigator.userAgent, - - // For matching the engine and version of the browser - browserMatch, - - // The deferred used on DOM ready - readyList, - - // The ready event handler - DOMContentLoaded, - - // Save a reference to some core methods - toString = Object.prototype.toString, - hasOwn = Object.prototype.hasOwnProperty, - push = Array.prototype.push, - slice = Array.prototype.slice, - trim = String.prototype.trim, - indexOf = Array.prototype.indexOf, - - // [[Class]] -> type pairs - class2type = {}; - -jQuery.fn = jQuery.prototype = { - constructor: jQuery, - init: function( selector, context, rootjQuery ) { - var match, elem, ret, doc; - - // Handle $(""), $(null), or $(undefined) - if ( !selector ) { - return this; - } - - // Handle $(DOMElement) - if ( selector.nodeType ) { - this.context = this[0] = selector; - this.length = 1; - return this; - } - - // The body element only exists once, optimize finding it - if ( selector === "body" && !context && document.body ) { - this.context = document; - this[0] = document.body; - this.selector = selector; - this.length = 1; - return this; - } - - // Handle HTML strings - if ( typeof selector === "string" ) { - // Are we dealing with HTML string or an ID? - if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { - // Assume that strings that start and end with <> are HTML and skip the regex check - match = [ null, selector, null ]; - - } else { - match = quickExpr.exec( selector ); - } - - // Verify a match, and that no context was specified for #id - if ( match && (match[1] || !context) ) { - - // HANDLE: $(html) -> $(array) - if ( match[1] ) { - context = context instanceof jQuery ? context[0] : context; - doc = ( context ? context.ownerDocument || context : document ); - - // If a single string is passed in and it's a single tag - // just do a createElement and skip the rest - ret = rsingleTag.exec( selector ); - - if ( ret ) { - if ( jQuery.isPlainObject( context ) ) { - selector = [ document.createElement( ret[1] ) ]; - jQuery.fn.attr.call( selector, context, true ); - - } else { - selector = [ doc.createElement( ret[1] ) ]; - } - - } else { - ret = jQuery.buildFragment( [ match[1] ], [ doc ] ); - selector = ( ret.cacheable ? jQuery.clone(ret.fragment) : ret.fragment ).childNodes; - } - - return jQuery.merge( this, selector ); - - // HANDLE: $("#id") - } else { - elem = document.getElementById( match[2] ); - - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - if ( elem && elem.parentNode ) { - // Handle the case where IE and Opera return items - // by name instead of ID - if ( elem.id !== match[2] ) { - return rootjQuery.find( selector ); - } - - // Otherwise, we inject the element directly into the jQuery object - this.length = 1; - this[0] = elem; - } - - this.context = document; - this.selector = selector; - return this; - } - - // HANDLE: $(expr, $(...)) - } else if ( !context || context.jquery ) { - return ( context || rootjQuery ).find( selector ); - - // HANDLE: $(expr, context) - // (which is just equivalent to: $(context).find(expr) - } else { - return this.constructor( context ).find( selector ); - } - - // HANDLE: $(function) - // Shortcut for document ready - } else if ( jQuery.isFunction( selector ) ) { - return rootjQuery.ready( selector ); - } - - if ( selector.selector !== undefined ) { - this.selector = selector.selector; - this.context = selector.context; - } - - return jQuery.makeArray( selector, this ); - }, - - // Start with an empty selector - selector: "", - - // The current version of jQuery being used - jquery: "1.7.1", - - // The default length of a jQuery object is 0 - length: 0, - - // The number of elements contained in the matched element set - size: function() { - return this.length; - }, - - toArray: function() { - return slice.call( this, 0 ); - }, - - // Get the Nth element in the matched element set OR - // Get the whole matched element set as a clean array - get: function( num ) { - return num == null ? - - // Return a 'clean' array - this.toArray() : - - // Return just the object - ( num < 0 ? this[ this.length + num ] : this[ num ] ); - }, - - // Take an array of elements and push it onto the stack - // (returning the new matched element set) - pushStack: function( elems, name, selector ) { - // Build a new jQuery matched element set - var ret = this.constructor(); - - if ( jQuery.isArray( elems ) ) { - push.apply( ret, elems ); - - } else { - jQuery.merge( ret, elems ); - } - - // Add the old object onto the stack (as a reference) - ret.prevObject = this; - - ret.context = this.context; - - if ( name === "find" ) { - ret.selector = this.selector + ( this.selector ? " " : "" ) + selector; - } else if ( name ) { - ret.selector = this.selector + "." + name + "(" + selector + ")"; - } - - // Return the newly-formed element set - return ret; - }, - - // Execute a callback for every element in the matched set. - // (You can seed the arguments with an array of args, but this is - // only used internally.) - each: function( callback, args ) { - return jQuery.each( this, callback, args ); - }, - - ready: function( fn ) { - // Attach the listeners - jQuery.bindReady(); - - // Add the callback - readyList.add( fn ); - - return this; - }, - - eq: function( i ) { - i = +i; - return i === -1 ? - this.slice( i ) : - this.slice( i, i + 1 ); - }, - - first: function() { - return this.eq( 0 ); - }, - - last: function() { - return this.eq( -1 ); - }, - - slice: function() { - return this.pushStack( slice.apply( this, arguments ), - "slice", slice.call(arguments).join(",") ); - }, - - map: function( callback ) { - return this.pushStack( jQuery.map(this, function( elem, i ) { - return callback.call( elem, i, elem ); - })); - }, - - end: function() { - return this.prevObject || this.constructor(null); - }, - - // For internal use only. - // Behaves like an Array's method, not like a jQuery method. - push: push, - sort: [].sort, - splice: [].splice -}; - -// Give the init function the jQuery prototype for later instantiation -jQuery.fn.init.prototype = jQuery.fn; - -jQuery.extend = jQuery.fn.extend = function() { - var options, name, src, copy, copyIsArray, clone, - target = arguments[0] || {}, - i = 1, - length = arguments.length, - deep = false; - - // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - target = arguments[1] || {}; - // skip the boolean and the target - i = 2; - } - - // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !jQuery.isFunction(target) ) { - target = {}; - } - - // extend jQuery itself if only one argument is passed - if ( length === i ) { - target = this; - --i; - } - - for ( ; i < length; i++ ) { - // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) != null ) { - // Extend the base object - for ( name in options ) { - src = target[ name ]; - copy = options[ name ]; - - // Prevent never-ending loop - if ( target === copy ) { - continue; - } - - // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { - if ( copyIsArray ) { - copyIsArray = false; - clone = src && jQuery.isArray(src) ? src : []; - - } else { - clone = src && jQuery.isPlainObject(src) ? src : {}; - } - - // Never move original objects, clone them - target[ name ] = jQuery.extend( deep, clone, copy ); - - // Don't bring in undefined values - } else if ( copy !== undefined ) { - target[ name ] = copy; - } - } - } - } - - // Return the modified object - return target; -}; - -jQuery.extend({ - noConflict: function( deep ) { - if ( window.$ === jQuery ) { - window.$ = _$; - } - - if ( deep && window.jQuery === jQuery ) { - window.jQuery = _jQuery; - } - - return jQuery; - }, - - // Is the DOM ready to be used? Set to true once it occurs. - isReady: false, - - // A counter to track how many items to wait for before - // the ready event fires. See #6781 - readyWait: 1, - - // Hold (or release) the ready event - holdReady: function( hold ) { - if ( hold ) { - jQuery.readyWait++; - } else { - jQuery.ready( true ); - } - }, - - // Handle when the DOM is ready - ready: function( wait ) { - // Either a released hold or an DOMready/load event and not yet ready - if ( (wait === true && !--jQuery.readyWait) || (wait !== true && !jQuery.isReady) ) { - // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). - if ( !document.body ) { - return setTimeout( jQuery.ready, 1 ); - } - - // Remember that the DOM is ready - jQuery.isReady = true; - - // If a normal DOM Ready event fired, decrement, and wait if need be - if ( wait !== true && --jQuery.readyWait > 0 ) { - return; - } - - // If there are functions bound, to execute - readyList.fireWith( document, [ jQuery ] ); - - // Trigger any bound ready events - if ( jQuery.fn.trigger ) { - jQuery( document ).trigger( "ready" ).off( "ready" ); - } - } - }, - - bindReady: function() { - if ( readyList ) { - return; - } - - readyList = jQuery.Callbacks( "once memory" ); - - // Catch cases where $(document).ready() is called after the - // browser event has already occurred. - if ( document.readyState === "complete" ) { - // Handle it asynchronously to allow scripts the opportunity to delay ready - return setTimeout( jQuery.ready, 1 ); - } - - // Mozilla, Opera and webkit nightlies currently support this event - if ( document.addEventListener ) { - // Use the handy event callback - document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false ); - - // A fallback to window.onload, that will always work - window.addEventListener( "load", jQuery.ready, false ); - - // If IE event model is used - } else if ( document.attachEvent ) { - // ensure firing before onload, - // maybe late but safe also for iframes - document.attachEvent( "onreadystatechange", DOMContentLoaded ); - - // A fallback to window.onload, that will always work - window.attachEvent( "onload", jQuery.ready ); - - // If IE and not a frame - // continually check to see if the document is ready - var toplevel = false; - - try { - toplevel = window.frameElement == null; - } catch(e) {} - - if ( document.documentElement.doScroll && toplevel ) { - doScrollCheck(); - } - } - }, - - // See test/unit/core.js for details concerning isFunction. - // Since version 1.3, DOM methods and functions like alert - // aren't supported. They return false on IE (#2968). - isFunction: function( obj ) { - return jQuery.type(obj) === "function"; - }, - - isArray: Array.isArray || function( obj ) { - return jQuery.type(obj) === "array"; - }, - - // A crude way of determining if an object is a window - isWindow: function( obj ) { - return obj && typeof obj === "object" && "setInterval" in obj; - }, - - isNumeric: function( obj ) { - return !isNaN( parseFloat(obj) ) && isFinite( obj ); - }, - - type: function( obj ) { - return obj == null ? - String( obj ) : - class2type[ toString.call(obj) ] || "object"; - }, - - isPlainObject: function( obj ) { - // Must be an Object. - // Because of IE, we also have to check the presence of the constructor property. - // Make sure that DOM nodes and window objects don't pass through, as well - if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { - return false; - } - - try { - // Not own constructor property must be Object - if ( obj.constructor && - !hasOwn.call(obj, "constructor") && - !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { - return false; - } - } catch ( e ) { - // IE8,9 Will throw exceptions on certain host objects #9897 - return false; - } - - // Own properties are enumerated firstly, so to speed up, - // if last one is own, then all properties are own. - - var key; - for ( key in obj ) {} - - return key === undefined || hasOwn.call( obj, key ); - }, - - isEmptyObject: function( obj ) { - for ( var name in obj ) { - return false; - } - return true; - }, - - error: function( msg ) { - throw new Error( msg ); - }, - - parseJSON: function( data ) { - if ( typeof data !== "string" || !data ) { - return null; - } - - // Make sure leading/trailing whitespace is removed (IE can't handle it) - data = jQuery.trim( data ); - - // Attempt to parse using the native JSON parser first - if ( window.JSON && window.JSON.parse ) { - return window.JSON.parse( data ); - } - - // Make sure the incoming data is actual JSON - // Logic borrowed from http://json.org/json2.js - if ( rvalidchars.test( data.replace( rvalidescape, "@" ) - .replace( rvalidtokens, "]" ) - .replace( rvalidbraces, "")) ) { - - return ( new Function( "return " + data ) )(); - - } - jQuery.error( "Invalid JSON: " + data ); - }, - - // Cross-browser xml parsing - parseXML: function( data ) { - var xml, tmp; - try { - if ( window.DOMParser ) { // Standard - tmp = new DOMParser(); - xml = tmp.parseFromString( data , "text/xml" ); - } else { // IE - xml = new ActiveXObject( "Microsoft.XMLDOM" ); - xml.async = "false"; - xml.loadXML( data ); - } - } catch( e ) { - xml = undefined; - } - if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { - jQuery.error( "Invalid XML: " + data ); - } - return xml; - }, - - noop: function() {}, - - // Evaluates a script in a global context - // Workarounds based on findings by Jim Driscoll - // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context - globalEval: function( data ) { - if ( data && rnotwhite.test( data ) ) { - // We use execScript on Internet Explorer - // We use an anonymous function so that context is window - // rather than jQuery in Firefox - ( window.execScript || function( data ) { - window[ "eval" ].call( window, data ); - } )( data ); - } - }, - - // Convert dashed to camelCase; used by the css and data modules - // Microsoft forgot to hump their vendor prefix (#9572) - camelCase: function( string ) { - return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); - }, - - nodeName: function( elem, name ) { - return elem.nodeName && elem.nodeName.toUpperCase() === name.toUpperCase(); - }, - - // args is for internal usage only - each: function( object, callback, args ) { - var name, i = 0, - length = object.length, - isObj = length === undefined || jQuery.isFunction( object ); - - if ( args ) { - if ( isObj ) { - for ( name in object ) { - if ( callback.apply( object[ name ], args ) === false ) { - break; - } - } - } else { - for ( ; i < length; ) { - if ( callback.apply( object[ i++ ], args ) === false ) { - break; - } - } - } - - // A special, fast, case for the most common use of each - } else { - if ( isObj ) { - for ( name in object ) { - if ( callback.call( object[ name ], name, object[ name ] ) === false ) { - break; - } - } - } else { - for ( ; i < length; ) { - if ( callback.call( object[ i ], i, object[ i++ ] ) === false ) { - break; - } - } - } - } - - return object; - }, - - // Use native String.trim function wherever possible - trim: trim ? - function( text ) { - return text == null ? - "" : - trim.call( text ); - } : - - // Otherwise use our own trimming functionality - function( text ) { - return text == null ? - "" : - text.toString().replace( trimLeft, "" ).replace( trimRight, "" ); - }, - - // results is for internal usage only - makeArray: function( array, results ) { - var ret = results || []; - - if ( array != null ) { - // The window, strings (and functions) also have 'length' - // Tweaked logic slightly to handle Blackberry 4.7 RegExp issues #6930 - var type = jQuery.type( array ); - - if ( array.length == null || type === "string" || type === "function" || type === "regexp" || jQuery.isWindow( array ) ) { - push.call( ret, array ); - } else { - jQuery.merge( ret, array ); - } - } - - return ret; - }, - - inArray: function( elem, array, i ) { - var len; - - if ( array ) { - if ( indexOf ) { - return indexOf.call( array, elem, i ); - } - - len = array.length; - i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; - - for ( ; i < len; i++ ) { - // Skip accessing in sparse arrays - if ( i in array && array[ i ] === elem ) { - return i; - } - } - } - - return -1; - }, - - merge: function( first, second ) { - var i = first.length, - j = 0; - - if ( typeof second.length === "number" ) { - for ( var l = second.length; j < l; j++ ) { - first[ i++ ] = second[ j ]; - } - - } else { - while ( second[j] !== undefined ) { - first[ i++ ] = second[ j++ ]; - } - } - - first.length = i; - - return first; - }, - - grep: function( elems, callback, inv ) { - var ret = [], retVal; - inv = !!inv; - - // Go through the array, only saving the items - // that pass the validator function - for ( var i = 0, length = elems.length; i < length; i++ ) { - retVal = !!callback( elems[ i ], i ); - if ( inv !== retVal ) { - ret.push( elems[ i ] ); - } - } - - return ret; - }, - - // arg is for internal usage only - map: function( elems, callback, arg ) { - var value, key, ret = [], - i = 0, - length = elems.length, - // jquery objects are treated as arrays - isArray = elems instanceof jQuery || length !== undefined && typeof length === "number" && ( ( length > 0 && elems[ 0 ] && elems[ length -1 ] ) || length === 0 || jQuery.isArray( elems ) ) ; - - // Go through the array, translating each of the items to their - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret[ ret.length ] = value; - } - } - - // Go through every key on the object, - } else { - for ( key in elems ) { - value = callback( elems[ key ], key, arg ); - - if ( value != null ) { - ret[ ret.length ] = value; - } - } - } - - // Flatten any nested arrays - return ret.concat.apply( [], ret ); - }, - - // A global GUID counter for objects - guid: 1, - - // Bind a function to a context, optionally partially applying any - // arguments. - proxy: function( fn, context ) { - if ( typeof context === "string" ) { - var tmp = fn[ context ]; - context = fn; - fn = tmp; - } - - // Quick check to determine if target is callable, in the spec - // this throws a TypeError, but we will just return undefined. - if ( !jQuery.isFunction( fn ) ) { - return undefined; - } - - // Simulated bind - var args = slice.call( arguments, 2 ), - proxy = function() { - return fn.apply( context, args.concat( slice.call( arguments ) ) ); - }; - - // Set the guid of unique handler to the same of original handler, so it can be removed - proxy.guid = fn.guid = fn.guid || proxy.guid || jQuery.guid++; - - return proxy; - }, - - // Mutifunctional method to get and set values to a collection - // The value/s can optionally be executed if it's a function - access: function( elems, key, value, exec, fn, pass ) { - var length = elems.length; - - // Setting many attributes - if ( typeof key === "object" ) { - for ( var k in key ) { - jQuery.access( elems, k, key[k], exec, fn, value ); - } - return elems; - } - - // Setting one attribute - if ( value !== undefined ) { - // Optionally, function values get executed if exec is true - exec = !pass && exec && jQuery.isFunction(value); - - for ( var i = 0; i < length; i++ ) { - fn( elems[i], key, exec ? value.call( elems[i], i, fn( elems[i], key ) ) : value, pass ); - } - - return elems; - } - - // Getting an attribute - return length ? fn( elems[0], key ) : undefined; - }, - - now: function() { - return ( new Date() ).getTime(); - }, - - // Use of jQuery.browser is frowned upon. - // More details: http://docs.jquery.com/Utilities/jQuery.browser - uaMatch: function( ua ) { - ua = ua.toLowerCase(); - - var match = rwebkit.exec( ua ) || - ropera.exec( ua ) || - rmsie.exec( ua ) || - ua.indexOf("compatible") < 0 && rmozilla.exec( ua ) || - []; - - return { browser: match[1] || "", version: match[2] || "0" }; - }, - - sub: function() { - function jQuerySub( selector, context ) { - return new jQuerySub.fn.init( selector, context ); - } - jQuery.extend( true, jQuerySub, this ); - jQuerySub.superclass = this; - jQuerySub.fn = jQuerySub.prototype = this(); - jQuerySub.fn.constructor = jQuerySub; - jQuerySub.sub = this.sub; - jQuerySub.fn.init = function init( selector, context ) { - if ( context && context instanceof jQuery && !(context instanceof jQuerySub) ) { - context = jQuerySub( context ); - } - - return jQuery.fn.init.call( this, selector, context, rootjQuerySub ); - }; - jQuerySub.fn.init.prototype = jQuerySub.fn; - var rootjQuerySub = jQuerySub(document); - return jQuerySub; - }, - - browser: {} -}); - -// Populate the class2type map -jQuery.each("Boolean Number String Function Array Date RegExp Object".split(" "), function(i, name) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); -}); - -browserMatch = jQuery.uaMatch( userAgent ); -if ( browserMatch.browser ) { - jQuery.browser[ browserMatch.browser ] = true; - jQuery.browser.version = browserMatch.version; -} - -// Deprecated, use jQuery.browser.webkit instead -if ( jQuery.browser.webkit ) { - jQuery.browser.safari = true; -} - -// IE doesn't match non-breaking spaces with \s -if ( rnotwhite.test( "\xA0" ) ) { - trimLeft = /^[\s\xA0]+/; - trimRight = /[\s\xA0]+$/; -} - -// All jQuery objects should point back to these -rootjQuery = jQuery(document); - -// Cleanup functions for the document ready method -if ( document.addEventListener ) { - DOMContentLoaded = function() { - document.removeEventListener( "DOMContentLoaded", DOMContentLoaded, false ); - jQuery.ready(); - }; - -} else if ( document.attachEvent ) { - DOMContentLoaded = function() { - // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). - if ( document.readyState === "complete" ) { - document.detachEvent( "onreadystatechange", DOMContentLoaded ); - jQuery.ready(); - } - }; -} - -// The DOM ready check for Internet Explorer -function doScrollCheck() { - if ( jQuery.isReady ) { - return; - } - - try { - // If IE is used, use the trick by Diego Perini - // http://javascript.nwbox.com/IEContentLoaded/ - document.documentElement.doScroll("left"); - } catch(e) { - setTimeout( doScrollCheck, 1 ); - return; - } - - // and execute any waiting functions - jQuery.ready(); -} - -return jQuery; - -})(); - - -// String to Object flags format cache -var flagsCache = {}; - -// Convert String-formatted flags into Object-formatted ones and store in cache -function createFlags( flags ) { - var object = flagsCache[ flags ] = {}, - i, length; - flags = flags.split( /\s+/ ); - for ( i = 0, length = flags.length; i < length; i++ ) { - object[ flags[i] ] = true; - } - return object; -} - -/* - * Create a callback list using the following parameters: - * - * flags: an optional list of space-separated flags that will change how - * the callback list behaves - * - * By default a callback list will act like an event callback list and can be - * "fired" multiple times. - * - * Possible flags: - * - * once: will ensure the callback list can only be fired once (like a Deferred) - * - * memory: will keep track of previous values and will call any callback added - * after the list has been fired right away with the latest "memorized" - * values (like a Deferred) - * - * unique: will ensure a callback can only be added once (no duplicate in the list) - * - * stopOnFalse: interrupt callings when a callback returns false - * - */ -jQuery.Callbacks = function( flags ) { - - // Convert flags from String-formatted to Object-formatted - // (we check in cache first) - flags = flags ? ( flagsCache[ flags ] || createFlags( flags ) ) : {}; - - var // Actual callback list - list = [], - // Stack of fire calls for repeatable lists - stack = [], - // Last fire value (for non-forgettable lists) - memory, - // Flag to know if list is currently firing - firing, - // First callback to fire (used internally by add and fireWith) - firingStart, - // End of the loop when firing - firingLength, - // Index of currently firing callback (modified by remove if needed) - firingIndex, - // Add one or several callbacks to the list - add = function( args ) { - var i, - length, - elem, - type, - actual; - for ( i = 0, length = args.length; i < length; i++ ) { - elem = args[ i ]; - type = jQuery.type( elem ); - if ( type === "array" ) { - // Inspect recursively - add( elem ); - } else if ( type === "function" ) { - // Add if not in unique mode and callback is not in - if ( !flags.unique || !self.has( elem ) ) { - list.push( elem ); - } - } - } - }, - // Fire callbacks - fire = function( context, args ) { - args = args || []; - memory = !flags.memory || [ context, args ]; - firing = true; - firingIndex = firingStart || 0; - firingStart = 0; - firingLength = list.length; - for ( ; list && firingIndex < firingLength; firingIndex++ ) { - if ( list[ firingIndex ].apply( context, args ) === false && flags.stopOnFalse ) { - memory = true; // Mark as halted - break; - } - } - firing = false; - if ( list ) { - if ( !flags.once ) { - if ( stack && stack.length ) { - memory = stack.shift(); - self.fireWith( memory[ 0 ], memory[ 1 ] ); - } - } else if ( memory === true ) { - self.disable(); - } else { - list = []; - } - } - }, - // Actual Callbacks object - self = { - // Add a callback or a collection of callbacks to the list - add: function() { - if ( list ) { - var length = list.length; - add( arguments ); - // Do we need to add the callbacks to the - // current firing batch? - if ( firing ) { - firingLength = list.length; - // With memory, if we're not firing then - // we should call right away, unless previous - // firing was halted (stopOnFalse) - } else if ( memory && memory !== true ) { - firingStart = length; - fire( memory[ 0 ], memory[ 1 ] ); - } - } - return this; - }, - // Remove a callback from the list - remove: function() { - if ( list ) { - var args = arguments, - argIndex = 0, - argLength = args.length; - for ( ; argIndex < argLength ; argIndex++ ) { - for ( var i = 0; i < list.length; i++ ) { - if ( args[ argIndex ] === list[ i ] ) { - // Handle firingIndex and firingLength - if ( firing ) { - if ( i <= firingLength ) { - firingLength--; - if ( i <= firingIndex ) { - firingIndex--; - } - } - } - // Remove the element - list.splice( i--, 1 ); - // If we have some unicity property then - // we only need to do this once - if ( flags.unique ) { - break; - } - } - } - } - } - return this; - }, - // Control if a given callback is in the list - has: function( fn ) { - if ( list ) { - var i = 0, - length = list.length; - for ( ; i < length; i++ ) { - if ( fn === list[ i ] ) { - return true; - } - } - } - return false; - }, - // Remove all callbacks from the list - empty: function() { - list = []; - return this; - }, - // Have the list do nothing anymore - disable: function() { - list = stack = memory = undefined; - return this; - }, - // Is it disabled? - disabled: function() { - return !list; - }, - // Lock the list in its current state - lock: function() { - stack = undefined; - if ( !memory || memory === true ) { - self.disable(); - } - return this; - }, - // Is it locked? - locked: function() { - return !stack; - }, - // Call all callbacks with the given context and arguments - fireWith: function( context, args ) { - if ( stack ) { - if ( firing ) { - if ( !flags.once ) { - stack.push( [ context, args ] ); - } - } else if ( !( flags.once && memory ) ) { - fire( context, args ); - } - } - return this; - }, - // Call all the callbacks with the given arguments - fire: function() { - self.fireWith( this, arguments ); - return this; - }, - // To know if the callbacks have already been called at least once - fired: function() { - return !!memory; - } - }; - - return self; -}; - - - - -var // Static reference to slice - sliceDeferred = [].slice; - -jQuery.extend({ - - Deferred: function( func ) { - var doneList = jQuery.Callbacks( "once memory" ), - failList = jQuery.Callbacks( "once memory" ), - progressList = jQuery.Callbacks( "memory" ), - state = "pending", - lists = { - resolve: doneList, - reject: failList, - notify: progressList - }, - promise = { - done: doneList.add, - fail: failList.add, - progress: progressList.add, - - state: function() { - return state; - }, - - // Deprecated - isResolved: doneList.fired, - isRejected: failList.fired, - - then: function( doneCallbacks, failCallbacks, progressCallbacks ) { - deferred.done( doneCallbacks ).fail( failCallbacks ).progress( progressCallbacks ); - return this; - }, - always: function() { - deferred.done.apply( deferred, arguments ).fail.apply( deferred, arguments ); - return this; - }, - pipe: function( fnDone, fnFail, fnProgress ) { - return jQuery.Deferred(function( newDefer ) { - jQuery.each( { - done: [ fnDone, "resolve" ], - fail: [ fnFail, "reject" ], - progress: [ fnProgress, "notify" ] - }, function( handler, data ) { - var fn = data[ 0 ], - action = data[ 1 ], - returned; - if ( jQuery.isFunction( fn ) ) { - deferred[ handler ](function() { - returned = fn.apply( this, arguments ); - if ( returned && jQuery.isFunction( returned.promise ) ) { - returned.promise().then( newDefer.resolve, newDefer.reject, newDefer.notify ); - } else { - newDefer[ action + "With" ]( this === deferred ? newDefer : this, [ returned ] ); - } - }); - } else { - deferred[ handler ]( newDefer[ action ] ); - } - }); - }).promise(); - }, - // Get a promise for this deferred - // If obj is provided, the promise aspect is added to the object - promise: function( obj ) { - if ( obj == null ) { - obj = promise; - } else { - for ( var key in promise ) { - obj[ key ] = promise[ key ]; - } - } - return obj; - } - }, - deferred = promise.promise({}), - key; - - for ( key in lists ) { - deferred[ key ] = lists[ key ].fire; - deferred[ key + "With" ] = lists[ key ].fireWith; - } - - // Handle state - deferred.done( function() { - state = "resolved"; - }, failList.disable, progressList.lock ).fail( function() { - state = "rejected"; - }, doneList.disable, progressList.lock ); - - // Call given func if any - if ( func ) { - func.call( deferred, deferred ); - } - - // All done! - return deferred; - }, - - // Deferred helper - when: function( firstParam ) { - var args = sliceDeferred.call( arguments, 0 ), - i = 0, - length = args.length, - pValues = new Array( length ), - count = length, - pCount = length, - deferred = length <= 1 && firstParam && jQuery.isFunction( firstParam.promise ) ? - firstParam : - jQuery.Deferred(), - promise = deferred.promise(); - function resolveFunc( i ) { - return function( value ) { - args[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value; - if ( !( --count ) ) { - deferred.resolveWith( deferred, args ); - } - }; - } - function progressFunc( i ) { - return function( value ) { - pValues[ i ] = arguments.length > 1 ? sliceDeferred.call( arguments, 0 ) : value; - deferred.notifyWith( promise, pValues ); - }; - } - if ( length > 1 ) { - for ( ; i < length; i++ ) { - if ( args[ i ] && args[ i ].promise && jQuery.isFunction( args[ i ].promise ) ) { - args[ i ].promise().then( resolveFunc(i), deferred.reject, progressFunc(i) ); - } else { - --count; - } - } - if ( !count ) { - deferred.resolveWith( deferred, args ); - } - } else if ( deferred !== firstParam ) { - deferred.resolveWith( deferred, length ? [ firstParam ] : [] ); - } - return promise; - } -}); - - - - -jQuery.support = (function() { - - var support, - all, - a, - select, - opt, - input, - marginDiv, - fragment, - tds, - events, - eventName, - i, - isSupported, - div = document.createElement( "div" ), - documentElement = document.documentElement; - - // Preliminary tests - div.setAttribute("className", "t"); - div.innerHTML = "
a"; - - all = div.getElementsByTagName( "*" ); - a = div.getElementsByTagName( "a" )[ 0 ]; - - // Can't get basic test support - if ( !all || !all.length || !a ) { - return {}; - } - - // First batch of supports tests - select = document.createElement( "select" ); - opt = select.appendChild( document.createElement("option") ); - input = div.getElementsByTagName( "input" )[ 0 ]; - - support = { - // IE strips leading whitespace when .innerHTML is used - leadingWhitespace: ( div.firstChild.nodeType === 3 ), - - // Make sure that tbody elements aren't automatically inserted - // IE will insert them into empty tables - tbody: !div.getElementsByTagName("tbody").length, - - // Make sure that link elements get serialized correctly by innerHTML - // This requires a wrapper element in IE - htmlSerialize: !!div.getElementsByTagName("link").length, - - // Get the style information from getAttribute - // (IE uses .cssText instead) - style: /top/.test( a.getAttribute("style") ), - - // Make sure that URLs aren't manipulated - // (IE normalizes it by default) - hrefNormalized: ( a.getAttribute("href") === "/a" ), - - // Make sure that element opacity exists - // (IE uses filter instead) - // Use a regex to work around a WebKit issue. See #5145 - opacity: /^0.55/.test( a.style.opacity ), - - // Verify style float existence - // (IE uses styleFloat instead of cssFloat) - cssFloat: !!a.style.cssFloat, - - // Make sure that if no value is specified for a checkbox - // that it defaults to "on". - // (WebKit defaults to "" instead) - checkOn: ( input.value === "on" ), - - // Make sure that a selected-by-default option has a working selected property. - // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) - optSelected: opt.selected, - - // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) - getSetAttribute: div.className !== "t", - - // Tests for enctype support on a form(#6743) - enctype: !!document.createElement("form").enctype, - - // Makes sure cloning an html5 element does not cause problems - // Where outerHTML is undefined, this still works - html5Clone: document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav>", - - // Will be defined later - submitBubbles: true, - changeBubbles: true, - focusinBubbles: false, - deleteExpando: true, - noCloneEvent: true, - inlineBlockNeedsLayout: false, - shrinkWrapBlocks: false, - reliableMarginRight: true - }; - - // Make sure checked status is properly cloned - input.checked = true; - support.noCloneChecked = input.cloneNode( true ).checked; - - // Make sure that the options inside disabled selects aren't marked as disabled - // (WebKit marks them as disabled) - select.disabled = true; - support.optDisabled = !opt.disabled; - - // Test to see if it's possible to delete an expando from an element - // Fails in Internet Explorer - try { - delete div.test; - } catch( e ) { - support.deleteExpando = false; - } - - if ( !div.addEventListener && div.attachEvent && div.fireEvent ) { - div.attachEvent( "onclick", function() { - // Cloning a node shouldn't copy over any - // bound event handlers (IE does this) - support.noCloneEvent = false; - }); - div.cloneNode( true ).fireEvent( "onclick" ); - } - - // Check if a radio maintains its value - // after being appended to the DOM - input = document.createElement("input"); - input.value = "t"; - input.setAttribute("type", "radio"); - support.radioValue = input.value === "t"; - - input.setAttribute("checked", "checked"); - div.appendChild( input ); - fragment = document.createDocumentFragment(); - fragment.appendChild( div.lastChild ); - - // WebKit doesn't clone checked state correctly in fragments - support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked; - - // Check if a disconnected checkbox will retain its checked - // value of true after appended to the DOM (IE6/7) - support.appendChecked = input.checked; - - fragment.removeChild( input ); - fragment.appendChild( div ); - - div.innerHTML = ""; - - // Check if div with explicit width and no margin-right incorrectly - // gets computed margin-right based on width of container. For more - // info see bug #3333 - // Fails in WebKit before Feb 2011 nightlies - // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right - if ( window.getComputedStyle ) { - marginDiv = document.createElement( "div" ); - marginDiv.style.width = "0"; - marginDiv.style.marginRight = "0"; - div.style.width = "2px"; - div.appendChild( marginDiv ); - support.reliableMarginRight = - ( parseInt( ( window.getComputedStyle( marginDiv, null ) || { marginRight: 0 } ).marginRight, 10 ) || 0 ) === 0; - } - - // Technique from Juriy Zaytsev - // http://perfectionkills.com/detecting-event-support-without-browser-sniffing/ - // We only care about the case where non-standard event systems - // are used, namely in IE. Short-circuiting here helps us to - // avoid an eval call (in setAttribute) which can cause CSP - // to go haywire. See: https://developer.mozilla.org/en/Security/CSP - if ( div.attachEvent ) { - for( i in { - submit: 1, - change: 1, - focusin: 1 - }) { - eventName = "on" + i; - isSupported = ( eventName in div ); - if ( !isSupported ) { - div.setAttribute( eventName, "return;" ); - isSupported = ( typeof div[ eventName ] === "function" ); - } - support[ i + "Bubbles" ] = isSupported; - } - } - - fragment.removeChild( div ); - - // Null elements to avoid leaks in IE - fragment = select = opt = marginDiv = div = input = null; - - // Run tests that need a body at doc ready - jQuery(function() { - var container, outer, inner, table, td, offsetSupport, - conMarginTop, ptlm, vb, style, html, - body = document.getElementsByTagName("body")[0]; - - if ( !body ) { - // Return for frameset docs that don't have a body - return; - } - - conMarginTop = 1; - ptlm = "position:absolute;top:0;left:0;width:1px;height:1px;margin:0;"; - vb = "visibility:hidden;border:0;"; - style = "style='" + ptlm + "border:5px solid #000;padding:0;'"; - html = "
" + - "" + - "
"; - - container = document.createElement("div"); - container.style.cssText = vb + "width:0;height:0;position:static;top:0;margin-top:" + conMarginTop + "px"; - body.insertBefore( container, body.firstChild ); - - // Construct the test element - div = document.createElement("div"); - container.appendChild( div ); - - // Check if table cells still have offsetWidth/Height when they are set - // to display:none and there are still other visible table cells in a - // table row; if so, offsetWidth/Height are not reliable for use when - // determining if an element has been hidden directly using - // display:none (it is still safe to use offsets if a parent element is - // hidden; don safety goggles and see bug #4512 for more information). - // (only IE 8 fails this test) - div.innerHTML = "
t
"; - tds = div.getElementsByTagName( "td" ); - isSupported = ( tds[ 0 ].offsetHeight === 0 ); - - tds[ 0 ].style.display = ""; - tds[ 1 ].style.display = "none"; - - // Check if empty table cells still have offsetWidth/Height - // (IE <= 8 fail this test) - support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 ); - - // Figure out if the W3C box model works as expected - div.innerHTML = ""; - div.style.width = div.style.paddingLeft = "1px"; - jQuery.boxModel = support.boxModel = div.offsetWidth === 2; - - if ( typeof div.style.zoom !== "undefined" ) { - // Check if natively block-level elements act like inline-block - // elements when setting their display to 'inline' and giving - // them layout - // (IE < 8 does this) - div.style.display = "inline"; - div.style.zoom = 1; - support.inlineBlockNeedsLayout = ( div.offsetWidth === 2 ); - - // Check if elements with layout shrink-wrap their children - // (IE 6 does this) - div.style.display = ""; - div.innerHTML = "
"; - support.shrinkWrapBlocks = ( div.offsetWidth !== 2 ); - } - - div.style.cssText = ptlm + vb; - div.innerHTML = html; - - outer = div.firstChild; - inner = outer.firstChild; - td = outer.nextSibling.firstChild.firstChild; - - offsetSupport = { - doesNotAddBorder: ( inner.offsetTop !== 5 ), - doesAddBorderForTableAndCells: ( td.offsetTop === 5 ) - }; - - inner.style.position = "fixed"; - inner.style.top = "20px"; - - // safari subtracts parent border width here which is 5px - offsetSupport.fixedPosition = ( inner.offsetTop === 20 || inner.offsetTop === 15 ); - inner.style.position = inner.style.top = ""; - - outer.style.overflow = "hidden"; - outer.style.position = "relative"; - - offsetSupport.subtractsBorderForOverflowNotVisible = ( inner.offsetTop === -5 ); - offsetSupport.doesNotIncludeMarginInBodyOffset = ( body.offsetTop !== conMarginTop ); - - body.removeChild( container ); - div = container = null; - - jQuery.extend( support, offsetSupport ); - }); - - return support; -})(); - - - - -var rbrace = /^(?:\{.*\}|\[.*\])$/, - rmultiDash = /([A-Z])/g; - -jQuery.extend({ - cache: {}, - - // Please use with caution - uuid: 0, - - // Unique for each copy of jQuery on the page - // Non-digits removed to match rinlinejQuery - expando: "jQuery" + ( jQuery.fn.jquery + Math.random() ).replace( /\D/g, "" ), - - // The following elements throw uncatchable exceptions if you - // attempt to add expando properties to them. - noData: { - "embed": true, - // Ban all objects except for Flash (which handle expandos) - "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000", - "applet": true - }, - - hasData: function( elem ) { - elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; - return !!elem && !isEmptyDataObject( elem ); - }, - - data: function( elem, name, data, pvt /* Internal Use Only */ ) { - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var privateCache, thisCache, ret, - internalKey = jQuery.expando, - getByName = typeof name === "string", - - // We have to handle DOM nodes and JS objects differently because IE6-7 - // can't GC object references properly across the DOM-JS boundary - isNode = elem.nodeType, - - // Only DOM nodes need the global jQuery cache; JS object data is - // attached directly to the object so GC can occur automatically - cache = isNode ? jQuery.cache : elem, - - // Only defining an ID for JS objects if its cache already exists allows - // the code to shortcut on the same path as a DOM node with no cache - id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey, - isEvents = name === "events"; - - // Avoid doing any more work than we need to when trying to get data on an - // object that has no data at all - if ( (!id || !cache[id] || (!isEvents && !pvt && !cache[id].data)) && getByName && data === undefined ) { - return; - } - - if ( !id ) { - // Only DOM nodes need a new unique ID for each element since their data - // ends up in the global cache - if ( isNode ) { - elem[ internalKey ] = id = ++jQuery.uuid; - } else { - id = internalKey; - } - } - - if ( !cache[ id ] ) { - cache[ id ] = {}; - - // Avoids exposing jQuery metadata on plain JS objects when the object - // is serialized using JSON.stringify - if ( !isNode ) { - cache[ id ].toJSON = jQuery.noop; - } - } - - // An object can be passed to jQuery.data instead of a key/value pair; this gets - // shallow copied over onto the existing cache - if ( typeof name === "object" || typeof name === "function" ) { - if ( pvt ) { - cache[ id ] = jQuery.extend( cache[ id ], name ); - } else { - cache[ id ].data = jQuery.extend( cache[ id ].data, name ); - } - } - - privateCache = thisCache = cache[ id ]; - - // jQuery data() is stored in a separate object inside the object's internal data - // cache in order to avoid key collisions between internal data and user-defined - // data. - if ( !pvt ) { - if ( !thisCache.data ) { - thisCache.data = {}; - } - - thisCache = thisCache.data; - } - - if ( data !== undefined ) { - thisCache[ jQuery.camelCase( name ) ] = data; - } - - // Users should not attempt to inspect the internal events object using jQuery.data, - // it is undocumented and subject to change. But does anyone listen? No. - if ( isEvents && !thisCache[ name ] ) { - return privateCache.events; - } - - // Check for both converted-to-camel and non-converted data property names - // If a data property was specified - if ( getByName ) { - - // First Try to find as-is property data - ret = thisCache[ name ]; - - // Test for null|undefined property data - if ( ret == null ) { - - // Try to find the camelCased property - ret = thisCache[ jQuery.camelCase( name ) ]; - } - } else { - ret = thisCache; - } - - return ret; - }, - - removeData: function( elem, name, pvt /* Internal Use Only */ ) { - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var thisCache, i, l, - - // Reference to internal data cache key - internalKey = jQuery.expando, - - isNode = elem.nodeType, - - // See jQuery.data for more information - cache = isNode ? jQuery.cache : elem, - - // See jQuery.data for more information - id = isNode ? elem[ internalKey ] : internalKey; - - // If there is already no cache entry for this object, there is no - // purpose in continuing - if ( !cache[ id ] ) { - return; - } - - if ( name ) { - - thisCache = pvt ? cache[ id ] : cache[ id ].data; - - if ( thisCache ) { - - // Support array or space separated string names for data keys - if ( !jQuery.isArray( name ) ) { - - // try the string as a key before any manipulation - if ( name in thisCache ) { - name = [ name ]; - } else { - - // split the camel cased version by spaces unless a key with the spaces exists - name = jQuery.camelCase( name ); - if ( name in thisCache ) { - name = [ name ]; - } else { - name = name.split( " " ); - } - } - } - - for ( i = 0, l = name.length; i < l; i++ ) { - delete thisCache[ name[i] ]; - } - - // If there is no data left in the cache, we want to continue - // and let the cache object itself get destroyed - if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) { - return; - } - } - } - - // See jQuery.data for more information - if ( !pvt ) { - delete cache[ id ].data; - - // Don't destroy the parent cache unless the internal data object - // had been the only thing left in it - if ( !isEmptyDataObject(cache[ id ]) ) { - return; - } - } - - // Browsers that fail expando deletion also refuse to delete expandos on - // the window, but it will allow it on all other JS objects; other browsers - // don't care - // Ensure that `cache` is not a window object #10080 - if ( jQuery.support.deleteExpando || !cache.setInterval ) { - delete cache[ id ]; - } else { - cache[ id ] = null; - } - - // We destroyed the cache and need to eliminate the expando on the node to avoid - // false lookups in the cache for entries that no longer exist - if ( isNode ) { - // IE does not allow us to delete expando properties from nodes, - // nor does it have a removeAttribute function on Document nodes; - // we must handle all of these cases - if ( jQuery.support.deleteExpando ) { - delete elem[ internalKey ]; - } else if ( elem.removeAttribute ) { - elem.removeAttribute( internalKey ); - } else { - elem[ internalKey ] = null; - } - } - }, - - // For internal use only. - _data: function( elem, name, data ) { - return jQuery.data( elem, name, data, true ); - }, - - // A method for determining if a DOM node can handle the data expando - acceptData: function( elem ) { - if ( elem.nodeName ) { - var match = jQuery.noData[ elem.nodeName.toLowerCase() ]; - - if ( match ) { - return !(match === true || elem.getAttribute("classid") !== match); - } - } - - return true; - } -}); - -jQuery.fn.extend({ - data: function( key, value ) { - var parts, attr, name, - data = null; - - if ( typeof key === "undefined" ) { - if ( this.length ) { - data = jQuery.data( this[0] ); - - if ( this[0].nodeType === 1 && !jQuery._data( this[0], "parsedAttrs" ) ) { - attr = this[0].attributes; - for ( var i = 0, l = attr.length; i < l; i++ ) { - name = attr[i].name; - - if ( name.indexOf( "data-" ) === 0 ) { - name = jQuery.camelCase( name.substring(5) ); - - dataAttr( this[0], name, data[ name ] ); - } - } - jQuery._data( this[0], "parsedAttrs", true ); - } - } - - return data; - - } else if ( typeof key === "object" ) { - return this.each(function() { - jQuery.data( this, key ); - }); - } - - parts = key.split("."); - parts[1] = parts[1] ? "." + parts[1] : ""; - - if ( value === undefined ) { - data = this.triggerHandler("getData" + parts[1] + "!", [parts[0]]); - - // Try to fetch any internally stored data first - if ( data === undefined && this.length ) { - data = jQuery.data( this[0], key ); - data = dataAttr( this[0], key, data ); - } - - return data === undefined && parts[1] ? - this.data( parts[0] ) : - data; - - } else { - return this.each(function() { - var self = jQuery( this ), - args = [ parts[0], value ]; - - self.triggerHandler( "setData" + parts[1] + "!", args ); - jQuery.data( this, key, value ); - self.triggerHandler( "changeData" + parts[1] + "!", args ); - }); - } - }, - - removeData: function( key ) { - return this.each(function() { - jQuery.removeData( this, key ); - }); - } -}); - -function dataAttr( elem, key, data ) { - // If nothing was found internally, try to fetch any - // data from the HTML5 data-* attribute - if ( data === undefined && elem.nodeType === 1 ) { - - var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); - - data = elem.getAttribute( name ); - - if ( typeof data === "string" ) { - try { - data = data === "true" ? true : - data === "false" ? false : - data === "null" ? null : - jQuery.isNumeric( data ) ? parseFloat( data ) : - rbrace.test( data ) ? jQuery.parseJSON( data ) : - data; - } catch( e ) {} - - // Make sure we set the data so it isn't changed later - jQuery.data( elem, key, data ); - - } else { - data = undefined; - } - } - - return data; -} - -// checks a cache object for emptiness -function isEmptyDataObject( obj ) { - for ( var name in obj ) { - - // if the public data object is empty, the private is still empty - if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { - continue; - } - if ( name !== "toJSON" ) { - return false; - } - } - - return true; -} - - - - -function handleQueueMarkDefer( elem, type, src ) { - var deferDataKey = type + "defer", - queueDataKey = type + "queue", - markDataKey = type + "mark", - defer = jQuery._data( elem, deferDataKey ); - if ( defer && - ( src === "queue" || !jQuery._data(elem, queueDataKey) ) && - ( src === "mark" || !jQuery._data(elem, markDataKey) ) ) { - // Give room for hard-coded callbacks to fire first - // and eventually mark/queue something else on the element - setTimeout( function() { - if ( !jQuery._data( elem, queueDataKey ) && - !jQuery._data( elem, markDataKey ) ) { - jQuery.removeData( elem, deferDataKey, true ); - defer.fire(); - } - }, 0 ); - } -} - -jQuery.extend({ - - _mark: function( elem, type ) { - if ( elem ) { - type = ( type || "fx" ) + "mark"; - jQuery._data( elem, type, (jQuery._data( elem, type ) || 0) + 1 ); - } - }, - - _unmark: function( force, elem, type ) { - if ( force !== true ) { - type = elem; - elem = force; - force = false; - } - if ( elem ) { - type = type || "fx"; - var key = type + "mark", - count = force ? 0 : ( (jQuery._data( elem, key ) || 1) - 1 ); - if ( count ) { - jQuery._data( elem, key, count ); - } else { - jQuery.removeData( elem, key, true ); - handleQueueMarkDefer( elem, type, "mark" ); - } - } - }, - - queue: function( elem, type, data ) { - var q; - if ( elem ) { - type = ( type || "fx" ) + "queue"; - q = jQuery._data( elem, type ); - - // Speed up dequeue by getting out quickly if this is just a lookup - if ( data ) { - if ( !q || jQuery.isArray(data) ) { - q = jQuery._data( elem, type, jQuery.makeArray(data) ); - } else { - q.push( data ); - } - } - return q || []; - } - }, - - dequeue: function( elem, type ) { - type = type || "fx"; - - var queue = jQuery.queue( elem, type ), - fn = queue.shift(), - hooks = {}; - - // If the fx queue is dequeued, always remove the progress sentinel - if ( fn === "inprogress" ) { - fn = queue.shift(); - } - - if ( fn ) { - // Add a progress sentinel to prevent the fx queue from being - // automatically dequeued - if ( type === "fx" ) { - queue.unshift( "inprogress" ); - } - - jQuery._data( elem, type + ".run", hooks ); - fn.call( elem, function() { - jQuery.dequeue( elem, type ); - }, hooks ); - } - - if ( !queue.length ) { - jQuery.removeData( elem, type + "queue " + type + ".run", true ); - handleQueueMarkDefer( elem, type, "queue" ); - } - } -}); - -jQuery.fn.extend({ - queue: function( type, data ) { - if ( typeof type !== "string" ) { - data = type; - type = "fx"; - } - - if ( data === undefined ) { - return jQuery.queue( this[0], type ); - } - return this.each(function() { - var queue = jQuery.queue( this, type, data ); - - if ( type === "fx" && queue[0] !== "inprogress" ) { - jQuery.dequeue( this, type ); - } - }); - }, - dequeue: function( type ) { - return this.each(function() { - jQuery.dequeue( this, type ); - }); - }, - // Based off of the plugin by Clint Helfers, with permission. - // http://blindsignals.com/index.php/2009/07/jquery-delay/ - delay: function( time, type ) { - time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; - type = type || "fx"; - - return this.queue( type, function( next, hooks ) { - var timeout = setTimeout( next, time ); - hooks.stop = function() { - clearTimeout( timeout ); - }; - }); - }, - clearQueue: function( type ) { - return this.queue( type || "fx", [] ); - }, - // Get a promise resolved when queues of a certain type - // are emptied (fx is the type by default) - promise: function( type, object ) { - if ( typeof type !== "string" ) { - object = type; - type = undefined; - } - type = type || "fx"; - var defer = jQuery.Deferred(), - elements = this, - i = elements.length, - count = 1, - deferDataKey = type + "defer", - queueDataKey = type + "queue", - markDataKey = type + "mark", - tmp; - function resolve() { - if ( !( --count ) ) { - defer.resolveWith( elements, [ elements ] ); - } - } - while( i-- ) { - if (( tmp = jQuery.data( elements[ i ], deferDataKey, undefined, true ) || - ( jQuery.data( elements[ i ], queueDataKey, undefined, true ) || - jQuery.data( elements[ i ], markDataKey, undefined, true ) ) && - jQuery.data( elements[ i ], deferDataKey, jQuery.Callbacks( "once memory" ), true ) )) { - count++; - tmp.add( resolve ); - } - } - resolve(); - return defer.promise(); - } -}); - - - - -var rclass = /[\n\t\r]/g, - rspace = /\s+/, - rreturn = /\r/g, - rtype = /^(?:button|input)$/i, - rfocusable = /^(?:button|input|object|select|textarea)$/i, - rclickable = /^a(?:rea)?$/i, - rboolean = /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i, - getSetAttribute = jQuery.support.getSetAttribute, - nodeHook, boolHook, fixSpecified; - -jQuery.fn.extend({ - attr: function( name, value ) { - return jQuery.access( this, name, value, true, jQuery.attr ); - }, - - removeAttr: function( name ) { - return this.each(function() { - jQuery.removeAttr( this, name ); - }); - }, - - prop: function( name, value ) { - return jQuery.access( this, name, value, true, jQuery.prop ); - }, - - removeProp: function( name ) { - name = jQuery.propFix[ name ] || name; - return this.each(function() { - // try/catch handles cases where IE balks (such as removing a property on window) - try { - this[ name ] = undefined; - delete this[ name ]; - } catch( e ) {} - }); - }, - - addClass: function( value ) { - var classNames, i, l, elem, - setClass, c, cl; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).addClass( value.call(this, j, this.className) ); - }); - } - - if ( value && typeof value === "string" ) { - classNames = value.split( rspace ); - - for ( i = 0, l = this.length; i < l; i++ ) { - elem = this[ i ]; - - if ( elem.nodeType === 1 ) { - if ( !elem.className && classNames.length === 1 ) { - elem.className = value; - - } else { - setClass = " " + elem.className + " "; - - for ( c = 0, cl = classNames.length; c < cl; c++ ) { - if ( !~setClass.indexOf( " " + classNames[ c ] + " " ) ) { - setClass += classNames[ c ] + " "; - } - } - elem.className = jQuery.trim( setClass ); - } - } - } - } - - return this; - }, - - removeClass: function( value ) { - var classNames, i, l, elem, className, c, cl; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).removeClass( value.call(this, j, this.className) ); - }); - } - - if ( (value && typeof value === "string") || value === undefined ) { - classNames = ( value || "" ).split( rspace ); - - for ( i = 0, l = this.length; i < l; i++ ) { - elem = this[ i ]; - - if ( elem.nodeType === 1 && elem.className ) { - if ( value ) { - className = (" " + elem.className + " ").replace( rclass, " " ); - for ( c = 0, cl = classNames.length; c < cl; c++ ) { - className = className.replace(" " + classNames[ c ] + " ", " "); - } - elem.className = jQuery.trim( className ); - - } else { - elem.className = ""; - } - } - } - } - - return this; - }, - - toggleClass: function( value, stateVal ) { - var type = typeof value, - isBool = typeof stateVal === "boolean"; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( i ) { - jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); - }); - } - - return this.each(function() { - if ( type === "string" ) { - // toggle individual class names - var className, - i = 0, - self = jQuery( this ), - state = stateVal, - classNames = value.split( rspace ); - - while ( (className = classNames[ i++ ]) ) { - // check each className given, space seperated list - state = isBool ? state : !self.hasClass( className ); - self[ state ? "addClass" : "removeClass" ]( className ); - } - - } else if ( type === "undefined" || type === "boolean" ) { - if ( this.className ) { - // store className if set - jQuery._data( this, "__className__", this.className ); - } - - // toggle whole className - this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; - } - }); - }, - - hasClass: function( selector ) { - var className = " " + selector + " ", - i = 0, - l = this.length; - for ( ; i < l; i++ ) { - if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) > -1 ) { - return true; - } - } - - return false; - }, - - val: function( value ) { - var hooks, ret, isFunction, - elem = this[0]; - - if ( !arguments.length ) { - if ( elem ) { - hooks = jQuery.valHooks[ elem.nodeName.toLowerCase() ] || jQuery.valHooks[ elem.type ]; - - if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { - return ret; - } - - ret = elem.value; - - return typeof ret === "string" ? - // handle most common string cases - ret.replace(rreturn, "") : - // handle cases where value is null/undef or number - ret == null ? "" : ret; - } - - return; - } - - isFunction = jQuery.isFunction( value ); - - return this.each(function( i ) { - var self = jQuery(this), val; - - if ( this.nodeType !== 1 ) { - return; - } - - if ( isFunction ) { - val = value.call( this, i, self.val() ); - } else { - val = value; - } - - // Treat null/undefined as ""; convert numbers to string - if ( val == null ) { - val = ""; - } else if ( typeof val === "number" ) { - val += ""; - } else if ( jQuery.isArray( val ) ) { - val = jQuery.map(val, function ( value ) { - return value == null ? "" : value + ""; - }); - } - - hooks = jQuery.valHooks[ this.nodeName.toLowerCase() ] || jQuery.valHooks[ this.type ]; - - // If set returns undefined, fall back to normal setting - if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { - this.value = val; - } - }); - } -}); - -jQuery.extend({ - valHooks: { - option: { - get: function( elem ) { - // attributes.value is undefined in Blackberry 4.7 but - // uses .value. See #6932 - var val = elem.attributes.value; - return !val || val.specified ? elem.value : elem.text; - } - }, - select: { - get: function( elem ) { - var value, i, max, option, - index = elem.selectedIndex, - values = [], - options = elem.options, - one = elem.type === "select-one"; - - // Nothing was selected - if ( index < 0 ) { - return null; - } - - // Loop through all the selected options - i = one ? index : 0; - max = one ? index + 1 : options.length; - for ( ; i < max; i++ ) { - option = options[ i ]; - - // Don't return options that are disabled or in a disabled optgroup - if ( option.selected && (jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null) && - (!option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" )) ) { - - // Get the specific value for the option - value = jQuery( option ).val(); - - // We don't need an array for one selects - if ( one ) { - return value; - } - - // Multi-Selects return an array - values.push( value ); - } - } - - // Fixes Bug #2551 -- select.val() broken in IE after form.reset() - if ( one && !values.length && options.length ) { - return jQuery( options[ index ] ).val(); - } - - return values; - }, - - set: function( elem, value ) { - var values = jQuery.makeArray( value ); - - jQuery(elem).find("option").each(function() { - this.selected = jQuery.inArray( jQuery(this).val(), values ) >= 0; - }); - - if ( !values.length ) { - elem.selectedIndex = -1; - } - return values; - } - } - }, - - attrFn: { - val: true, - css: true, - html: true, - text: true, - data: true, - width: true, - height: true, - offset: true - }, - - attr: function( elem, name, value, pass ) { - var ret, hooks, notxml, - nType = elem.nodeType; - - // don't get/set attributes on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - if ( pass && name in jQuery.attrFn ) { - return jQuery( elem )[ name ]( value ); - } - - // Fallback to prop when attributes are not supported - if ( typeof elem.getAttribute === "undefined" ) { - return jQuery.prop( elem, name, value ); - } - - notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); - - // All attributes are lowercase - // Grab necessary hook if one is defined - if ( notxml ) { - name = name.toLowerCase(); - hooks = jQuery.attrHooks[ name ] || ( rboolean.test( name ) ? boolHook : nodeHook ); - } - - if ( value !== undefined ) { - - if ( value === null ) { - jQuery.removeAttr( elem, name ); - return; - - } else if ( hooks && "set" in hooks && notxml && (ret = hooks.set( elem, value, name )) !== undefined ) { - return ret; - - } else { - elem.setAttribute( name, "" + value ); - return value; - } - - } else if ( hooks && "get" in hooks && notxml && (ret = hooks.get( elem, name )) !== null ) { - return ret; - - } else { - - ret = elem.getAttribute( name ); - - // Non-existent attributes return null, we normalize to undefined - return ret === null ? - undefined : - ret; - } - }, - - removeAttr: function( elem, value ) { - var propName, attrNames, name, l, - i = 0; - - if ( value && elem.nodeType === 1 ) { - attrNames = value.toLowerCase().split( rspace ); - l = attrNames.length; - - for ( ; i < l; i++ ) { - name = attrNames[ i ]; - - if ( name ) { - propName = jQuery.propFix[ name ] || name; - - // See #9699 for explanation of this approach (setting first, then removal) - jQuery.attr( elem, name, "" ); - elem.removeAttribute( getSetAttribute ? name : propName ); - - // Set corresponding property to false for boolean attributes - if ( rboolean.test( name ) && propName in elem ) { - elem[ propName ] = false; - } - } - } - } - }, - - attrHooks: { - type: { - set: function( elem, value ) { - // We can't allow the type property to be changed (since it causes problems in IE) - if ( rtype.test( elem.nodeName ) && elem.parentNode ) { - jQuery.error( "type property can't be changed" ); - } else if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { - // Setting the type on a radio button after the value resets the value in IE6-9 - // Reset value to it's default in case type is set after value - // This is for element creation - var val = elem.value; - elem.setAttribute( "type", value ); - if ( val ) { - elem.value = val; - } - return value; - } - } - }, - // Use the value property for back compat - // Use the nodeHook for button elements in IE6/7 (#1954) - value: { - get: function( elem, name ) { - if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { - return nodeHook.get( elem, name ); - } - return name in elem ? - elem.value : - null; - }, - set: function( elem, value, name ) { - if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { - return nodeHook.set( elem, value, name ); - } - // Does not return so that setAttribute is also used - elem.value = value; - } - } - }, - - propFix: { - tabindex: "tabIndex", - readonly: "readOnly", - "for": "htmlFor", - "class": "className", - maxlength: "maxLength", - cellspacing: "cellSpacing", - cellpadding: "cellPadding", - rowspan: "rowSpan", - colspan: "colSpan", - usemap: "useMap", - frameborder: "frameBorder", - contenteditable: "contentEditable" - }, - - prop: function( elem, name, value ) { - var ret, hooks, notxml, - nType = elem.nodeType; - - // don't get/set properties on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); - - if ( notxml ) { - // Fix name and attach hooks - name = jQuery.propFix[ name ] || name; - hooks = jQuery.propHooks[ name ]; - } - - if ( value !== undefined ) { - if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { - return ret; - - } else { - return ( elem[ name ] = value ); - } - - } else { - if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { - return ret; - - } else { - return elem[ name ]; - } - } - }, - - propHooks: { - tabIndex: { - get: function( elem ) { - // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set - // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ - var attributeNode = elem.getAttributeNode("tabindex"); - - return attributeNode && attributeNode.specified ? - parseInt( attributeNode.value, 10 ) : - rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? - 0 : - undefined; - } - } - } -}); - -// Add the tabIndex propHook to attrHooks for back-compat (different case is intentional) -jQuery.attrHooks.tabindex = jQuery.propHooks.tabIndex; - -// Hook for boolean attributes -boolHook = { - get: function( elem, name ) { - // Align boolean attributes with corresponding properties - // Fall back to attribute presence where some booleans are not supported - var attrNode, - property = jQuery.prop( elem, name ); - return property === true || typeof property !== "boolean" && ( attrNode = elem.getAttributeNode(name) ) && attrNode.nodeValue !== false ? - name.toLowerCase() : - undefined; - }, - set: function( elem, value, name ) { - var propName; - if ( value === false ) { - // Remove boolean attributes when set to false - jQuery.removeAttr( elem, name ); - } else { - // value is true since we know at this point it's type boolean and not false - // Set boolean attributes to the same name and set the DOM property - propName = jQuery.propFix[ name ] || name; - if ( propName in elem ) { - // Only set the IDL specifically if it already exists on the element - elem[ propName ] = true; - } - - elem.setAttribute( name, name.toLowerCase() ); - } - return name; - } -}; - -// IE6/7 do not support getting/setting some attributes with get/setAttribute -if ( !getSetAttribute ) { - - fixSpecified = { - name: true, - id: true - }; - - // Use this for any attribute in IE6/7 - // This fixes almost every IE6/7 issue - nodeHook = jQuery.valHooks.button = { - get: function( elem, name ) { - var ret; - ret = elem.getAttributeNode( name ); - return ret && ( fixSpecified[ name ] ? ret.nodeValue !== "" : ret.specified ) ? - ret.nodeValue : - undefined; - }, - set: function( elem, value, name ) { - // Set the existing or create a new attribute node - var ret = elem.getAttributeNode( name ); - if ( !ret ) { - ret = document.createAttribute( name ); - elem.setAttributeNode( ret ); - } - return ( ret.nodeValue = value + "" ); - } - }; - - // Apply the nodeHook to tabindex - jQuery.attrHooks.tabindex.set = nodeHook.set; - - // Set width and height to auto instead of 0 on empty string( Bug #8150 ) - // This is for removals - jQuery.each([ "width", "height" ], function( i, name ) { - jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { - set: function( elem, value ) { - if ( value === "" ) { - elem.setAttribute( name, "auto" ); - return value; - } - } - }); - }); - - // Set contenteditable to false on removals(#10429) - // Setting to empty string throws an error as an invalid value - jQuery.attrHooks.contenteditable = { - get: nodeHook.get, - set: function( elem, value, name ) { - if ( value === "" ) { - value = "false"; - } - nodeHook.set( elem, value, name ); - } - }; -} - - -// Some attributes require a special call on IE -if ( !jQuery.support.hrefNormalized ) { - jQuery.each([ "href", "src", "width", "height" ], function( i, name ) { - jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { - get: function( elem ) { - var ret = elem.getAttribute( name, 2 ); - return ret === null ? undefined : ret; - } - }); - }); -} - -if ( !jQuery.support.style ) { - jQuery.attrHooks.style = { - get: function( elem ) { - // Return undefined in the case of empty string - // Normalize to lowercase since IE uppercases css property names - return elem.style.cssText.toLowerCase() || undefined; - }, - set: function( elem, value ) { - return ( elem.style.cssText = "" + value ); - } - }; -} - -// Safari mis-reports the default selected property of an option -// Accessing the parent's selectedIndex property fixes it -if ( !jQuery.support.optSelected ) { - jQuery.propHooks.selected = jQuery.extend( jQuery.propHooks.selected, { - get: function( elem ) { - var parent = elem.parentNode; - - if ( parent ) { - parent.selectedIndex; - - // Make sure that it also works with optgroups, see #5701 - if ( parent.parentNode ) { - parent.parentNode.selectedIndex; - } - } - return null; - } - }); -} - -// IE6/7 call enctype encoding -if ( !jQuery.support.enctype ) { - jQuery.propFix.enctype = "encoding"; -} - -// Radios and checkboxes getter/setter -if ( !jQuery.support.checkOn ) { - jQuery.each([ "radio", "checkbox" ], function() { - jQuery.valHooks[ this ] = { - get: function( elem ) { - // Handle the case where in Webkit "" is returned instead of "on" if a value isn't specified - return elem.getAttribute("value") === null ? "on" : elem.value; - } - }; - }); -} -jQuery.each([ "radio", "checkbox" ], function() { - jQuery.valHooks[ this ] = jQuery.extend( jQuery.valHooks[ this ], { - set: function( elem, value ) { - if ( jQuery.isArray( value ) ) { - return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); - } - } - }); -}); - - - - -var rformElems = /^(?:textarea|input|select)$/i, - rtypenamespace = /^([^\.]*)?(?:\.(.+))?$/, - rhoverHack = /\bhover(\.\S+)?\b/, - rkeyEvent = /^key/, - rmouseEvent = /^(?:mouse|contextmenu)|click/, - rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, - rquickIs = /^(\w*)(?:#([\w\-]+))?(?:\.([\w\-]+))?$/, - quickParse = function( selector ) { - var quick = rquickIs.exec( selector ); - if ( quick ) { - // 0 1 2 3 - // [ _, tag, id, class ] - quick[1] = ( quick[1] || "" ).toLowerCase(); - quick[3] = quick[3] && new RegExp( "(?:^|\\s)" + quick[3] + "(?:\\s|$)" ); - } - return quick; - }, - quickIs = function( elem, m ) { - var attrs = elem.attributes || {}; - return ( - (!m[1] || elem.nodeName.toLowerCase() === m[1]) && - (!m[2] || (attrs.id || {}).value === m[2]) && - (!m[3] || m[3].test( (attrs[ "class" ] || {}).value )) - ); - }, - hoverHack = function( events ) { - return jQuery.event.special.hover ? events : events.replace( rhoverHack, "mouseenter$1 mouseleave$1" ); - }; - -/* - * Helper functions for managing events -- not part of the public interface. - * Props to Dean Edwards' addEvent library for many of the ideas. - */ -jQuery.event = { - - add: function( elem, types, handler, data, selector ) { - - var elemData, eventHandle, events, - t, tns, type, namespaces, handleObj, - handleObjIn, quick, handlers, special; - - // Don't attach events to noData or text/comment nodes (allow plain objects tho) - if ( elem.nodeType === 3 || elem.nodeType === 8 || !types || !handler || !(elemData = jQuery._data( elem )) ) { - return; - } - - // Caller can pass in an object of custom data in lieu of the handler - if ( handler.handler ) { - handleObjIn = handler; - handler = handleObjIn.handler; - } - - // Make sure that the handler has a unique ID, used to find/remove it later - if ( !handler.guid ) { - handler.guid = jQuery.guid++; - } - - // Init the element's event structure and main handler, if this is the first - events = elemData.events; - if ( !events ) { - elemData.events = events = {}; - } - eventHandle = elemData.handle; - if ( !eventHandle ) { - elemData.handle = eventHandle = function( e ) { - // Discard the second event of a jQuery.event.trigger() and - // when an event is called after a page has unloaded - return typeof jQuery !== "undefined" && (!e || jQuery.event.triggered !== e.type) ? - jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : - undefined; - }; - // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events - eventHandle.elem = elem; - } - - // Handle multiple events separated by a space - // jQuery(...).bind("mouseover mouseout", fn); - types = jQuery.trim( hoverHack(types) ).split( " " ); - for ( t = 0; t < types.length; t++ ) { - - tns = rtypenamespace.exec( types[t] ) || []; - type = tns[1]; - namespaces = ( tns[2] || "" ).split( "." ).sort(); - - // If event changes its type, use the special event handlers for the changed type - special = jQuery.event.special[ type ] || {}; - - // If selector defined, determine special event api type, otherwise given type - type = ( selector ? special.delegateType : special.bindType ) || type; - - // Update special based on newly reset type - special = jQuery.event.special[ type ] || {}; - - // handleObj is passed to all event handlers - handleObj = jQuery.extend({ - type: type, - origType: tns[1], - data: data, - handler: handler, - guid: handler.guid, - selector: selector, - quick: quickParse( selector ), - namespace: namespaces.join(".") - }, handleObjIn ); - - // Init the event handler queue if we're the first - handlers = events[ type ]; - if ( !handlers ) { - handlers = events[ type ] = []; - handlers.delegateCount = 0; - - // Only use addEventListener/attachEvent if the special events handler returns false - if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { - // Bind the global event handler to the element - if ( elem.addEventListener ) { - elem.addEventListener( type, eventHandle, false ); - - } else if ( elem.attachEvent ) { - elem.attachEvent( "on" + type, eventHandle ); - } - } - } - - if ( special.add ) { - special.add.call( elem, handleObj ); - - if ( !handleObj.handler.guid ) { - handleObj.handler.guid = handler.guid; - } - } - - // Add to the element's handler list, delegates in front - if ( selector ) { - handlers.splice( handlers.delegateCount++, 0, handleObj ); - } else { - handlers.push( handleObj ); - } - - // Keep track of which events have ever been used, for event optimization - jQuery.event.global[ type ] = true; - } - - // Nullify elem to prevent memory leaks in IE - elem = null; - }, - - global: {}, - - // Detach an event or set of events from an element - remove: function( elem, types, handler, selector, mappedTypes ) { - - var elemData = jQuery.hasData( elem ) && jQuery._data( elem ), - t, tns, type, origType, namespaces, origCount, - j, events, special, handle, eventType, handleObj; - - if ( !elemData || !(events = elemData.events) ) { - return; - } - - // Once for each type.namespace in types; type may be omitted - types = jQuery.trim( hoverHack( types || "" ) ).split(" "); - for ( t = 0; t < types.length; t++ ) { - tns = rtypenamespace.exec( types[t] ) || []; - type = origType = tns[1]; - namespaces = tns[2]; - - // Unbind all events (on this namespace, if provided) for the element - if ( !type ) { - for ( type in events ) { - jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); - } - continue; - } - - special = jQuery.event.special[ type ] || {}; - type = ( selector? special.delegateType : special.bindType ) || type; - eventType = events[ type ] || []; - origCount = eventType.length; - namespaces = namespaces ? new RegExp("(^|\\.)" + namespaces.split(".").sort().join("\\.(?:.*\\.)?") + "(\\.|$)") : null; - - // Remove matching events - for ( j = 0; j < eventType.length; j++ ) { - handleObj = eventType[ j ]; - - if ( ( mappedTypes || origType === handleObj.origType ) && - ( !handler || handler.guid === handleObj.guid ) && - ( !namespaces || namespaces.test( handleObj.namespace ) ) && - ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { - eventType.splice( j--, 1 ); - - if ( handleObj.selector ) { - eventType.delegateCount--; - } - if ( special.remove ) { - special.remove.call( elem, handleObj ); - } - } - } - - // Remove generic event handler if we removed something and no more handlers exist - // (avoids potential for endless recursion during removal of special event handlers) - if ( eventType.length === 0 && origCount !== eventType.length ) { - if ( !special.teardown || special.teardown.call( elem, namespaces ) === false ) { - jQuery.removeEvent( elem, type, elemData.handle ); - } - - delete events[ type ]; - } - } - - // Remove the expando if it's no longer used - if ( jQuery.isEmptyObject( events ) ) { - handle = elemData.handle; - if ( handle ) { - handle.elem = null; - } - - // removeData also checks for emptiness and clears the expando if empty - // so use it instead of delete - jQuery.removeData( elem, [ "events", "handle" ], true ); - } - }, - - // Events that are safe to short-circuit if no handlers are attached. - // Native DOM events should not be added, they may have inline handlers. - customEvent: { - "getData": true, - "setData": true, - "changeData": true - }, - - trigger: function( event, data, elem, onlyHandlers ) { - // Don't do events on text and comment nodes - if ( elem && (elem.nodeType === 3 || elem.nodeType === 8) ) { - return; - } - - // Event object or event type - var type = event.type || event, - namespaces = [], - cache, exclusive, i, cur, old, ontype, special, handle, eventPath, bubbleType; - - // focus/blur morphs to focusin/out; ensure we're not firing them right now - if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { - return; - } - - if ( type.indexOf( "!" ) >= 0 ) { - // Exclusive events trigger only for the exact event (no namespaces) - type = type.slice(0, -1); - exclusive = true; - } - - if ( type.indexOf( "." ) >= 0 ) { - // Namespaced trigger; create a regexp to match event type in handle() - namespaces = type.split("."); - type = namespaces.shift(); - namespaces.sort(); - } - - if ( (!elem || jQuery.event.customEvent[ type ]) && !jQuery.event.global[ type ] ) { - // No jQuery handlers for this event type, and it can't have inline handlers - return; - } - - // Caller can pass in an Event, Object, or just an event type string - event = typeof event === "object" ? - // jQuery.Event object - event[ jQuery.expando ] ? event : - // Object literal - new jQuery.Event( type, event ) : - // Just the event type (string) - new jQuery.Event( type ); - - event.type = type; - event.isTrigger = true; - event.exclusive = exclusive; - event.namespace = namespaces.join( "." ); - event.namespace_re = event.namespace? new RegExp("(^|\\.)" + namespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") : null; - ontype = type.indexOf( ":" ) < 0 ? "on" + type : ""; - - // Handle a global trigger - if ( !elem ) { - - // TODO: Stop taunting the data cache; remove global events and always attach to document - cache = jQuery.cache; - for ( i in cache ) { - if ( cache[ i ].events && cache[ i ].events[ type ] ) { - jQuery.event.trigger( event, data, cache[ i ].handle.elem, true ); - } - } - return; - } - - // Clean up the event in case it is being reused - event.result = undefined; - if ( !event.target ) { - event.target = elem; - } - - // Clone any incoming data and prepend the event, creating the handler arg list - data = data != null ? jQuery.makeArray( data ) : []; - data.unshift( event ); - - // Allow special events to draw outside the lines - special = jQuery.event.special[ type ] || {}; - if ( special.trigger && special.trigger.apply( elem, data ) === false ) { - return; - } - - // Determine event propagation path in advance, per W3C events spec (#9951) - // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) - eventPath = [[ elem, special.bindType || type ]]; - if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { - - bubbleType = special.delegateType || type; - cur = rfocusMorph.test( bubbleType + type ) ? elem : elem.parentNode; - old = null; - for ( ; cur; cur = cur.parentNode ) { - eventPath.push([ cur, bubbleType ]); - old = cur; - } - - // Only add window if we got to document (e.g., not plain obj or detached DOM) - if ( old && old === elem.ownerDocument ) { - eventPath.push([ old.defaultView || old.parentWindow || window, bubbleType ]); - } - } - - // Fire handlers on the event path - for ( i = 0; i < eventPath.length && !event.isPropagationStopped(); i++ ) { - - cur = eventPath[i][0]; - event.type = eventPath[i][1]; - - handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); - if ( handle ) { - handle.apply( cur, data ); - } - // Note that this is a bare JS function and not a jQuery handler - handle = ontype && cur[ ontype ]; - if ( handle && jQuery.acceptData( cur ) && handle.apply( cur, data ) === false ) { - event.preventDefault(); - } - } - event.type = type; - - // If nobody prevented the default action, do it now - if ( !onlyHandlers && !event.isDefaultPrevented() ) { - - if ( (!special._default || special._default.apply( elem.ownerDocument, data ) === false) && - !(type === "click" && jQuery.nodeName( elem, "a" )) && jQuery.acceptData( elem ) ) { - - // Call a native DOM method on the target with the same name name as the event. - // Can't use an .isFunction() check here because IE6/7 fails that test. - // Don't do default actions on window, that's where global variables be (#6170) - // IE<9 dies on focus/blur to hidden element (#1486) - if ( ontype && elem[ type ] && ((type !== "focus" && type !== "blur") || event.target.offsetWidth !== 0) && !jQuery.isWindow( elem ) ) { - - // Don't re-trigger an onFOO event when we call its FOO() method - old = elem[ ontype ]; - - if ( old ) { - elem[ ontype ] = null; - } - - // Prevent re-triggering of the same event, since we already bubbled it above - jQuery.event.triggered = type; - elem[ type ](); - jQuery.event.triggered = undefined; - - if ( old ) { - elem[ ontype ] = old; - } - } - } - } - - return event.result; - }, - - dispatch: function( event ) { - - // Make a writable jQuery.Event from the native event object - event = jQuery.event.fix( event || window.event ); - - var handlers = ( (jQuery._data( this, "events" ) || {} )[ event.type ] || []), - delegateCount = handlers.delegateCount, - args = [].slice.call( arguments, 0 ), - run_all = !event.exclusive && !event.namespace, - handlerQueue = [], - i, j, cur, jqcur, ret, selMatch, matched, matches, handleObj, sel, related; - - // Use the fix-ed jQuery.Event rather than the (read-only) native event - args[0] = event; - event.delegateTarget = this; - - // Determine handlers that should run if there are delegated events - // Avoid disabled elements in IE (#6911) and non-left-click bubbling in Firefox (#3861) - if ( delegateCount && !event.target.disabled && !(event.button && event.type === "click") ) { - - // Pregenerate a single jQuery object for reuse with .is() - jqcur = jQuery(this); - jqcur.context = this.ownerDocument || this; - - for ( cur = event.target; cur != this; cur = cur.parentNode || this ) { - selMatch = {}; - matches = []; - jqcur[0] = cur; - for ( i = 0; i < delegateCount; i++ ) { - handleObj = handlers[ i ]; - sel = handleObj.selector; - - if ( selMatch[ sel ] === undefined ) { - selMatch[ sel ] = ( - handleObj.quick ? quickIs( cur, handleObj.quick ) : jqcur.is( sel ) - ); - } - if ( selMatch[ sel ] ) { - matches.push( handleObj ); - } - } - if ( matches.length ) { - handlerQueue.push({ elem: cur, matches: matches }); - } - } - } - - // Add the remaining (directly-bound) handlers - if ( handlers.length > delegateCount ) { - handlerQueue.push({ elem: this, matches: handlers.slice( delegateCount ) }); - } - - // Run delegates first; they may want to stop propagation beneath us - for ( i = 0; i < handlerQueue.length && !event.isPropagationStopped(); i++ ) { - matched = handlerQueue[ i ]; - event.currentTarget = matched.elem; - - for ( j = 0; j < matched.matches.length && !event.isImmediatePropagationStopped(); j++ ) { - handleObj = matched.matches[ j ]; - - // Triggered event must either 1) be non-exclusive and have no namespace, or - // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). - if ( run_all || (!event.namespace && !handleObj.namespace) || event.namespace_re && event.namespace_re.test( handleObj.namespace ) ) { - - event.data = handleObj.data; - event.handleObj = handleObj; - - ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) - .apply( matched.elem, args ); - - if ( ret !== undefined ) { - event.result = ret; - if ( ret === false ) { - event.preventDefault(); - event.stopPropagation(); - } - } - } - } - } - - return event.result; - }, - - // Includes some event props shared by KeyEvent and MouseEvent - // *** attrChange attrName relatedNode srcElement are not normalized, non-W3C, deprecated, will be removed in 1.8 *** - props: "attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), - - fixHooks: {}, - - keyHooks: { - props: "char charCode key keyCode".split(" "), - filter: function( event, original ) { - - // Add which for key events - if ( event.which == null ) { - event.which = original.charCode != null ? original.charCode : original.keyCode; - } - - return event; - } - }, - - mouseHooks: { - props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), - filter: function( event, original ) { - var eventDoc, doc, body, - button = original.button, - fromElement = original.fromElement; - - // Calculate pageX/Y if missing and clientX/Y available - if ( event.pageX == null && original.clientX != null ) { - eventDoc = event.target.ownerDocument || document; - doc = eventDoc.documentElement; - body = eventDoc.body; - - event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); - event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); - } - - // Add relatedTarget, if necessary - if ( !event.relatedTarget && fromElement ) { - event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; - } - - // Add which for click: 1 === left; 2 === middle; 3 === right - // Note: button is not normalized, so don't use it - if ( !event.which && button !== undefined ) { - event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); - } - - return event; - } - }, - - fix: function( event ) { - if ( event[ jQuery.expando ] ) { - return event; - } - - // Create a writable copy of the event object and normalize some properties - var i, prop, - originalEvent = event, - fixHook = jQuery.event.fixHooks[ event.type ] || {}, - copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; - - event = jQuery.Event( originalEvent ); - - for ( i = copy.length; i; ) { - prop = copy[ --i ]; - event[ prop ] = originalEvent[ prop ]; - } - - // Fix target property, if necessary (#1925, IE 6/7/8 & Safari2) - if ( !event.target ) { - event.target = originalEvent.srcElement || document; - } - - // Target should not be a text node (#504, Safari) - if ( event.target.nodeType === 3 ) { - event.target = event.target.parentNode; - } - - // For mouse/key events; add metaKey if it's not there (#3368, IE6/7/8) - if ( event.metaKey === undefined ) { - event.metaKey = event.ctrlKey; - } - - return fixHook.filter? fixHook.filter( event, originalEvent ) : event; - }, - - special: { - ready: { - // Make sure the ready event is setup - setup: jQuery.bindReady - }, - - load: { - // Prevent triggered image.load events from bubbling to window.load - noBubble: true - }, - - focus: { - delegateType: "focusin" - }, - blur: { - delegateType: "focusout" - }, - - beforeunload: { - setup: function( data, namespaces, eventHandle ) { - // We only want to do this special case on windows - if ( jQuery.isWindow( this ) ) { - this.onbeforeunload = eventHandle; - } - }, - - teardown: function( namespaces, eventHandle ) { - if ( this.onbeforeunload === eventHandle ) { - this.onbeforeunload = null; - } - } - } - }, - - simulate: function( type, elem, event, bubble ) { - // Piggyback on a donor event to simulate a different one. - // Fake originalEvent to avoid donor's stopPropagation, but if the - // simulated event prevents default then we do the same on the donor. - var e = jQuery.extend( - new jQuery.Event(), - event, - { type: type, - isSimulated: true, - originalEvent: {} - } - ); - if ( bubble ) { - jQuery.event.trigger( e, null, elem ); - } else { - jQuery.event.dispatch.call( elem, e ); - } - if ( e.isDefaultPrevented() ) { - event.preventDefault(); - } - } -}; - -// Some plugins are using, but it's undocumented/deprecated and will be removed. -// The 1.7 special event interface should provide all the hooks needed now. -jQuery.event.handle = jQuery.event.dispatch; - -jQuery.removeEvent = document.removeEventListener ? - function( elem, type, handle ) { - if ( elem.removeEventListener ) { - elem.removeEventListener( type, handle, false ); - } - } : - function( elem, type, handle ) { - if ( elem.detachEvent ) { - elem.detachEvent( "on" + type, handle ); - } - }; - -jQuery.Event = function( src, props ) { - // Allow instantiation without the 'new' keyword - if ( !(this instanceof jQuery.Event) ) { - return new jQuery.Event( src, props ); - } - - // Event object - if ( src && src.type ) { - this.originalEvent = src; - this.type = src.type; - - // Events bubbling up the document may have been marked as prevented - // by a handler lower down the tree; reflect the correct value. - this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false || - src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse; - - // Event type - } else { - this.type = src; - } - - // Put explicitly provided properties onto the event object - if ( props ) { - jQuery.extend( this, props ); - } - - // Create a timestamp if incoming event doesn't have one - this.timeStamp = src && src.timeStamp || jQuery.now(); - - // Mark it as fixed - this[ jQuery.expando ] = true; -}; - -function returnFalse() { - return false; -} -function returnTrue() { - return true; -} - -// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding -// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html -jQuery.Event.prototype = { - preventDefault: function() { - this.isDefaultPrevented = returnTrue; - - var e = this.originalEvent; - if ( !e ) { - return; - } - - // if preventDefault exists run it on the original event - if ( e.preventDefault ) { - e.preventDefault(); - - // otherwise set the returnValue property of the original event to false (IE) - } else { - e.returnValue = false; - } - }, - stopPropagation: function() { - this.isPropagationStopped = returnTrue; - - var e = this.originalEvent; - if ( !e ) { - return; - } - // if stopPropagation exists run it on the original event - if ( e.stopPropagation ) { - e.stopPropagation(); - } - // otherwise set the cancelBubble property of the original event to true (IE) - e.cancelBubble = true; - }, - stopImmediatePropagation: function() { - this.isImmediatePropagationStopped = returnTrue; - this.stopPropagation(); - }, - isDefaultPrevented: returnFalse, - isPropagationStopped: returnFalse, - isImmediatePropagationStopped: returnFalse -}; - -// Create mouseenter/leave events using mouseover/out and event-time checks -jQuery.each({ - mouseenter: "mouseover", - mouseleave: "mouseout" -}, function( orig, fix ) { - jQuery.event.special[ orig ] = { - delegateType: fix, - bindType: fix, - - handle: function( event ) { - var target = this, - related = event.relatedTarget, - handleObj = event.handleObj, - selector = handleObj.selector, - ret; - - // For mousenter/leave call the handler if related is outside the target. - // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !jQuery.contains( target, related )) ) { - event.type = handleObj.origType; - ret = handleObj.handler.apply( this, arguments ); - event.type = fix; - } - return ret; - } - }; -}); - -// IE submit delegation -if ( !jQuery.support.submitBubbles ) { - - jQuery.event.special.submit = { - setup: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Lazy-add a submit handler when a descendant form may potentially be submitted - jQuery.event.add( this, "click._submit keypress._submit", function( e ) { - // Node name check avoids a VML-related crash in IE (#9807) - var elem = e.target, - form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; - if ( form && !form._submit_attached ) { - jQuery.event.add( form, "submit._submit", function( event ) { - // If form was submitted by the user, bubble the event up the tree - if ( this.parentNode && !event.isTrigger ) { - jQuery.event.simulate( "submit", this.parentNode, event, true ); - } - }); - form._submit_attached = true; - } - }); - // return undefined since we don't need an event listener - }, - - teardown: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Remove delegated handlers; cleanData eventually reaps submit handlers attached above - jQuery.event.remove( this, "._submit" ); - } - }; -} - -// IE change delegation and checkbox/radio fix -if ( !jQuery.support.changeBubbles ) { - - jQuery.event.special.change = { - - setup: function() { - - if ( rformElems.test( this.nodeName ) ) { - // IE doesn't fire change on a check/radio until blur; trigger it on click - // after a propertychange. Eat the blur-change in special.change.handle. - // This still fires onchange a second time for check/radio after blur. - if ( this.type === "checkbox" || this.type === "radio" ) { - jQuery.event.add( this, "propertychange._change", function( event ) { - if ( event.originalEvent.propertyName === "checked" ) { - this._just_changed = true; - } - }); - jQuery.event.add( this, "click._change", function( event ) { - if ( this._just_changed && !event.isTrigger ) { - this._just_changed = false; - jQuery.event.simulate( "change", this, event, true ); - } - }); - } - return false; - } - // Delegated event; lazy-add a change handler on descendant inputs - jQuery.event.add( this, "beforeactivate._change", function( e ) { - var elem = e.target; - - if ( rformElems.test( elem.nodeName ) && !elem._change_attached ) { - jQuery.event.add( elem, "change._change", function( event ) { - if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { - jQuery.event.simulate( "change", this.parentNode, event, true ); - } - }); - elem._change_attached = true; - } - }); - }, - - handle: function( event ) { - var elem = event.target; - - // Swallow native change events from checkbox/radio, we already triggered them above - if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { - return event.handleObj.handler.apply( this, arguments ); - } - }, - - teardown: function() { - jQuery.event.remove( this, "._change" ); - - return rformElems.test( this.nodeName ); - } - }; -} - -// Create "bubbling" focus and blur events -if ( !jQuery.support.focusinBubbles ) { - jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { - - // Attach a single capturing handler while someone wants focusin/focusout - var attaches = 0, - handler = function( event ) { - jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); - }; - - jQuery.event.special[ fix ] = { - setup: function() { - if ( attaches++ === 0 ) { - document.addEventListener( orig, handler, true ); - } - }, - teardown: function() { - if ( --attaches === 0 ) { - document.removeEventListener( orig, handler, true ); - } - } - }; - }); -} - -jQuery.fn.extend({ - - on: function( types, selector, data, fn, /*INTERNAL*/ one ) { - var origFn, type; - - // Types can be a map of types/handlers - if ( typeof types === "object" ) { - // ( types-Object, selector, data ) - if ( typeof selector !== "string" ) { - // ( types-Object, data ) - data = selector; - selector = undefined; - } - for ( type in types ) { - this.on( type, selector, data, types[ type ], one ); - } - return this; - } - - if ( data == null && fn == null ) { - // ( types, fn ) - fn = selector; - data = selector = undefined; - } else if ( fn == null ) { - if ( typeof selector === "string" ) { - // ( types, selector, fn ) - fn = data; - data = undefined; - } else { - // ( types, data, fn ) - fn = data; - data = selector; - selector = undefined; - } - } - if ( fn === false ) { - fn = returnFalse; - } else if ( !fn ) { - return this; - } - - if ( one === 1 ) { - origFn = fn; - fn = function( event ) { - // Can use an empty set, since event contains the info - jQuery().off( event ); - return origFn.apply( this, arguments ); - }; - // Use same guid so caller can remove using origFn - fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); - } - return this.each( function() { - jQuery.event.add( this, types, fn, data, selector ); - }); - }, - one: function( types, selector, data, fn ) { - return this.on.call( this, types, selector, data, fn, 1 ); - }, - off: function( types, selector, fn ) { - if ( types && types.preventDefault && types.handleObj ) { - // ( event ) dispatched jQuery.Event - var handleObj = types.handleObj; - jQuery( types.delegateTarget ).off( - handleObj.namespace? handleObj.type + "." + handleObj.namespace : handleObj.type, - handleObj.selector, - handleObj.handler - ); - return this; - } - if ( typeof types === "object" ) { - // ( types-object [, selector] ) - for ( var type in types ) { - this.off( type, selector, types[ type ] ); - } - return this; - } - if ( selector === false || typeof selector === "function" ) { - // ( types [, fn] ) - fn = selector; - selector = undefined; - } - if ( fn === false ) { - fn = returnFalse; - } - return this.each(function() { - jQuery.event.remove( this, types, fn, selector ); - }); - }, - - bind: function( types, data, fn ) { - return this.on( types, null, data, fn ); - }, - unbind: function( types, fn ) { - return this.off( types, null, fn ); - }, - - live: function( types, data, fn ) { - jQuery( this.context ).on( types, this.selector, data, fn ); - return this; - }, - die: function( types, fn ) { - jQuery( this.context ).off( types, this.selector || "**", fn ); - return this; - }, - - delegate: function( selector, types, data, fn ) { - return this.on( types, selector, data, fn ); - }, - undelegate: function( selector, types, fn ) { - // ( namespace ) or ( selector, types [, fn] ) - return arguments.length == 1? this.off( selector, "**" ) : this.off( types, selector, fn ); - }, - - trigger: function( type, data ) { - return this.each(function() { - jQuery.event.trigger( type, data, this ); - }); - }, - triggerHandler: function( type, data ) { - if ( this[0] ) { - return jQuery.event.trigger( type, data, this[0], true ); - } - }, - - toggle: function( fn ) { - // Save reference to arguments for access in closure - var args = arguments, - guid = fn.guid || jQuery.guid++, - i = 0, - toggler = function( event ) { - // Figure out which function to execute - var lastToggle = ( jQuery._data( this, "lastToggle" + fn.guid ) || 0 ) % i; - jQuery._data( this, "lastToggle" + fn.guid, lastToggle + 1 ); - - // Make sure that clicks stop - event.preventDefault(); - - // and execute the function - return args[ lastToggle ].apply( this, arguments ) || false; - }; - - // link all the functions, so any of them can unbind this click handler - toggler.guid = guid; - while ( i < args.length ) { - args[ i++ ].guid = guid; - } - - return this.click( toggler ); - }, - - hover: function( fnOver, fnOut ) { - return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); - } -}); - -jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " + - "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + - "change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) { - - // Handle event binding - jQuery.fn[ name ] = function( data, fn ) { - if ( fn == null ) { - fn = data; - data = null; - } - - return arguments.length > 0 ? - this.on( name, null, data, fn ) : - this.trigger( name ); - }; - - if ( jQuery.attrFn ) { - jQuery.attrFn[ name ] = true; - } - - if ( rkeyEvent.test( name ) ) { - jQuery.event.fixHooks[ name ] = jQuery.event.keyHooks; - } - - if ( rmouseEvent.test( name ) ) { - jQuery.event.fixHooks[ name ] = jQuery.event.mouseHooks; - } -}); - - - -/*! - * Sizzle CSS Selector Engine - * Copyright 2011, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * More information: http://sizzlejs.com/ - */ -(function(){ - -var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, - expando = "sizcache" + (Math.random() + '').replace('.', ''), - done = 0, - toString = Object.prototype.toString, - hasDuplicate = false, - baseHasDuplicate = true, - rBackslash = /\\/g, - rReturn = /\r\n/g, - rNonWord = /\W/; - -// Here we check if the JavaScript engine is using some sort of -// optimization where it does not always call our comparision -// function. If that is the case, discard the hasDuplicate value. -// Thus far that includes Google Chrome. -[0, 0].sort(function() { - baseHasDuplicate = false; - return 0; -}); - -var Sizzle = function( selector, context, results, seed ) { - results = results || []; - context = context || document; - - var origContext = context; - - if ( context.nodeType !== 1 && context.nodeType !== 9 ) { - return []; - } - - if ( !selector || typeof selector !== "string" ) { - return results; - } - - var m, set, checkSet, extra, ret, cur, pop, i, - prune = true, - contextXML = Sizzle.isXML( context ), - parts = [], - soFar = selector; - - // Reset the position of the chunker regexp (start from head) - do { - chunker.exec( "" ); - m = chunker.exec( soFar ); - - if ( m ) { - soFar = m[3]; - - parts.push( m[1] ); - - if ( m[2] ) { - extra = m[3]; - break; - } - } - } while ( m ); - - if ( parts.length > 1 && origPOS.exec( selector ) ) { - - if ( parts.length === 2 && Expr.relative[ parts[0] ] ) { - set = posProcess( parts[0] + parts[1], context, seed ); - - } else { - set = Expr.relative[ parts[0] ] ? - [ context ] : - Sizzle( parts.shift(), context ); - - while ( parts.length ) { - selector = parts.shift(); - - if ( Expr.relative[ selector ] ) { - selector += parts.shift(); - } - - set = posProcess( selector, set, seed ); - } - } - - } else { - // Take a shortcut and set the context if the root selector is an ID - // (but not if it'll be faster if the inner selector is an ID) - if ( !seed && parts.length > 1 && context.nodeType === 9 && !contextXML && - Expr.match.ID.test(parts[0]) && !Expr.match.ID.test(parts[parts.length - 1]) ) { - - ret = Sizzle.find( parts.shift(), context, contextXML ); - context = ret.expr ? - Sizzle.filter( ret.expr, ret.set )[0] : - ret.set[0]; - } - - if ( context ) { - ret = seed ? - { expr: parts.pop(), set: makeArray(seed) } : - Sizzle.find( parts.pop(), parts.length === 1 && (parts[0] === "~" || parts[0] === "+") && context.parentNode ? context.parentNode : context, contextXML ); - - set = ret.expr ? - Sizzle.filter( ret.expr, ret.set ) : - ret.set; - - if ( parts.length > 0 ) { - checkSet = makeArray( set ); - - } else { - prune = false; - } - - while ( parts.length ) { - cur = parts.pop(); - pop = cur; - - if ( !Expr.relative[ cur ] ) { - cur = ""; - } else { - pop = parts.pop(); - } - - if ( pop == null ) { - pop = context; - } - - Expr.relative[ cur ]( checkSet, pop, contextXML ); - } - - } else { - checkSet = parts = []; - } - } - - if ( !checkSet ) { - checkSet = set; - } - - if ( !checkSet ) { - Sizzle.error( cur || selector ); - } - - if ( toString.call(checkSet) === "[object Array]" ) { - if ( !prune ) { - results.push.apply( results, checkSet ); - - } else if ( context && context.nodeType === 1 ) { - for ( i = 0; checkSet[i] != null; i++ ) { - if ( checkSet[i] && (checkSet[i] === true || checkSet[i].nodeType === 1 && Sizzle.contains(context, checkSet[i])) ) { - results.push( set[i] ); - } - } - - } else { - for ( i = 0; checkSet[i] != null; i++ ) { - if ( checkSet[i] && checkSet[i].nodeType === 1 ) { - results.push( set[i] ); - } - } - } - - } else { - makeArray( checkSet, results ); - } - - if ( extra ) { - Sizzle( extra, origContext, results, seed ); - Sizzle.uniqueSort( results ); - } - - return results; -}; - -Sizzle.uniqueSort = function( results ) { - if ( sortOrder ) { - hasDuplicate = baseHasDuplicate; - results.sort( sortOrder ); - - if ( hasDuplicate ) { - for ( var i = 1; i < results.length; i++ ) { - if ( results[i] === results[ i - 1 ] ) { - results.splice( i--, 1 ); - } - } - } - } - - return results; -}; - -Sizzle.matches = function( expr, set ) { - return Sizzle( expr, null, null, set ); -}; - -Sizzle.matchesSelector = function( node, expr ) { - return Sizzle( expr, null, null, [node] ).length > 0; -}; - -Sizzle.find = function( expr, context, isXML ) { - var set, i, len, match, type, left; - - if ( !expr ) { - return []; - } - - for ( i = 0, len = Expr.order.length; i < len; i++ ) { - type = Expr.order[i]; - - if ( (match = Expr.leftMatch[ type ].exec( expr )) ) { - left = match[1]; - match.splice( 1, 1 ); - - if ( left.substr( left.length - 1 ) !== "\\" ) { - match[1] = (match[1] || "").replace( rBackslash, "" ); - set = Expr.find[ type ]( match, context, isXML ); - - if ( set != null ) { - expr = expr.replace( Expr.match[ type ], "" ); - break; - } - } - } - } - - if ( !set ) { - set = typeof context.getElementsByTagName !== "undefined" ? - context.getElementsByTagName( "*" ) : - []; - } - - return { set: set, expr: expr }; -}; - -Sizzle.filter = function( expr, set, inplace, not ) { - var match, anyFound, - type, found, item, filter, left, - i, pass, - old = expr, - result = [], - curLoop = set, - isXMLFilter = set && set[0] && Sizzle.isXML( set[0] ); - - while ( expr && set.length ) { - for ( type in Expr.filter ) { - if ( (match = Expr.leftMatch[ type ].exec( expr )) != null && match[2] ) { - filter = Expr.filter[ type ]; - left = match[1]; - - anyFound = false; - - match.splice(1,1); - - if ( left.substr( left.length - 1 ) === "\\" ) { - continue; - } - - if ( curLoop === result ) { - result = []; - } - - if ( Expr.preFilter[ type ] ) { - match = Expr.preFilter[ type ]( match, curLoop, inplace, result, not, isXMLFilter ); - - if ( !match ) { - anyFound = found = true; - - } else if ( match === true ) { - continue; - } - } - - if ( match ) { - for ( i = 0; (item = curLoop[i]) != null; i++ ) { - if ( item ) { - found = filter( item, match, i, curLoop ); - pass = not ^ found; - - if ( inplace && found != null ) { - if ( pass ) { - anyFound = true; - - } else { - curLoop[i] = false; - } - - } else if ( pass ) { - result.push( item ); - anyFound = true; - } - } - } - } - - if ( found !== undefined ) { - if ( !inplace ) { - curLoop = result; - } - - expr = expr.replace( Expr.match[ type ], "" ); - - if ( !anyFound ) { - return []; - } - - break; - } - } - } - - // Improper expression - if ( expr === old ) { - if ( anyFound == null ) { - Sizzle.error( expr ); - - } else { - break; - } - } - - old = expr; - } - - return curLoop; -}; - -Sizzle.error = function( msg ) { - throw new Error( "Syntax error, unrecognized expression: " + msg ); -}; - -/** - * Utility function for retreiving the text value of an array of DOM nodes - * @param {Array|Element} elem - */ -var getText = Sizzle.getText = function( elem ) { - var i, node, - nodeType = elem.nodeType, - ret = ""; - - if ( nodeType ) { - if ( nodeType === 1 || nodeType === 9 ) { - // Use textContent || innerText for elements - if ( typeof elem.textContent === 'string' ) { - return elem.textContent; - } else if ( typeof elem.innerText === 'string' ) { - // Replace IE's carriage returns - return elem.innerText.replace( rReturn, '' ); - } else { - // Traverse it's children - for ( elem = elem.firstChild; elem; elem = elem.nextSibling) { - ret += getText( elem ); - } - } - } else if ( nodeType === 3 || nodeType === 4 ) { - return elem.nodeValue; - } - } else { - - // If no nodeType, this is expected to be an array - for ( i = 0; (node = elem[i]); i++ ) { - // Do not traverse comment nodes - if ( node.nodeType !== 8 ) { - ret += getText( node ); - } - } - } - return ret; -}; - -var Expr = Sizzle.selectors = { - order: [ "ID", "NAME", "TAG" ], - - match: { - ID: /#((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, - CLASS: /\.((?:[\w\u00c0-\uFFFF\-]|\\.)+)/, - NAME: /\[name=['"]*((?:[\w\u00c0-\uFFFF\-]|\\.)+)['"]*\]/, - ATTR: /\[\s*((?:[\w\u00c0-\uFFFF\-]|\\.)+)\s*(?:(\S?=)\s*(?:(['"])(.*?)\3|(#?(?:[\w\u00c0-\uFFFF\-]|\\.)*)|)|)\s*\]/, - TAG: /^((?:[\w\u00c0-\uFFFF\*\-]|\\.)+)/, - CHILD: /:(only|nth|last|first)-child(?:\(\s*(even|odd|(?:[+\-]?\d+|(?:[+\-]?\d*)?n\s*(?:[+\-]\s*\d+)?))\s*\))?/, - POS: /:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^\-]|$)/, - PSEUDO: /:((?:[\w\u00c0-\uFFFF\-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/ - }, - - leftMatch: {}, - - attrMap: { - "class": "className", - "for": "htmlFor" - }, - - attrHandle: { - href: function( elem ) { - return elem.getAttribute( "href" ); - }, - type: function( elem ) { - return elem.getAttribute( "type" ); - } - }, - - relative: { - "+": function(checkSet, part){ - var isPartStr = typeof part === "string", - isTag = isPartStr && !rNonWord.test( part ), - isPartStrNotTag = isPartStr && !isTag; - - if ( isTag ) { - part = part.toLowerCase(); - } - - for ( var i = 0, l = checkSet.length, elem; i < l; i++ ) { - if ( (elem = checkSet[i]) ) { - while ( (elem = elem.previousSibling) && elem.nodeType !== 1 ) {} - - checkSet[i] = isPartStrNotTag || elem && elem.nodeName.toLowerCase() === part ? - elem || false : - elem === part; - } - } - - if ( isPartStrNotTag ) { - Sizzle.filter( part, checkSet, true ); - } - }, - - ">": function( checkSet, part ) { - var elem, - isPartStr = typeof part === "string", - i = 0, - l = checkSet.length; - - if ( isPartStr && !rNonWord.test( part ) ) { - part = part.toLowerCase(); - - for ( ; i < l; i++ ) { - elem = checkSet[i]; - - if ( elem ) { - var parent = elem.parentNode; - checkSet[i] = parent.nodeName.toLowerCase() === part ? parent : false; - } - } - - } else { - for ( ; i < l; i++ ) { - elem = checkSet[i]; - - if ( elem ) { - checkSet[i] = isPartStr ? - elem.parentNode : - elem.parentNode === part; - } - } - - if ( isPartStr ) { - Sizzle.filter( part, checkSet, true ); - } - } - }, - - "": function(checkSet, part, isXML){ - var nodeCheck, - doneName = done++, - checkFn = dirCheck; - - if ( typeof part === "string" && !rNonWord.test( part ) ) { - part = part.toLowerCase(); - nodeCheck = part; - checkFn = dirNodeCheck; - } - - checkFn( "parentNode", part, doneName, checkSet, nodeCheck, isXML ); - }, - - "~": function( checkSet, part, isXML ) { - var nodeCheck, - doneName = done++, - checkFn = dirCheck; - - if ( typeof part === "string" && !rNonWord.test( part ) ) { - part = part.toLowerCase(); - nodeCheck = part; - checkFn = dirNodeCheck; - } - - checkFn( "previousSibling", part, doneName, checkSet, nodeCheck, isXML ); - } - }, - - find: { - ID: function( match, context, isXML ) { - if ( typeof context.getElementById !== "undefined" && !isXML ) { - var m = context.getElementById(match[1]); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - return m && m.parentNode ? [m] : []; - } - }, - - NAME: function( match, context ) { - if ( typeof context.getElementsByName !== "undefined" ) { - var ret = [], - results = context.getElementsByName( match[1] ); - - for ( var i = 0, l = results.length; i < l; i++ ) { - if ( results[i].getAttribute("name") === match[1] ) { - ret.push( results[i] ); - } - } - - return ret.length === 0 ? null : ret; - } - }, - - TAG: function( match, context ) { - if ( typeof context.getElementsByTagName !== "undefined" ) { - return context.getElementsByTagName( match[1] ); - } - } - }, - preFilter: { - CLASS: function( match, curLoop, inplace, result, not, isXML ) { - match = " " + match[1].replace( rBackslash, "" ) + " "; - - if ( isXML ) { - return match; - } - - for ( var i = 0, elem; (elem = curLoop[i]) != null; i++ ) { - if ( elem ) { - if ( not ^ (elem.className && (" " + elem.className + " ").replace(/[\t\n\r]/g, " ").indexOf(match) >= 0) ) { - if ( !inplace ) { - result.push( elem ); - } - - } else if ( inplace ) { - curLoop[i] = false; - } - } - } - - return false; - }, - - ID: function( match ) { - return match[1].replace( rBackslash, "" ); - }, - - TAG: function( match, curLoop ) { - return match[1].replace( rBackslash, "" ).toLowerCase(); - }, - - CHILD: function( match ) { - if ( match[1] === "nth" ) { - if ( !match[2] ) { - Sizzle.error( match[0] ); - } - - match[2] = match[2].replace(/^\+|\s*/g, ''); - - // parse equations like 'even', 'odd', '5', '2n', '3n+2', '4n-1', '-n+6' - var test = /(-?)(\d*)(?:n([+\-]?\d*))?/.exec( - match[2] === "even" && "2n" || match[2] === "odd" && "2n+1" || - !/\D/.test( match[2] ) && "0n+" + match[2] || match[2]); - - // calculate the numbers (first)n+(last) including if they are negative - match[2] = (test[1] + (test[2] || 1)) - 0; - match[3] = test[3] - 0; - } - else if ( match[2] ) { - Sizzle.error( match[0] ); - } - - // TODO: Move to normal caching system - match[0] = done++; - - return match; - }, - - ATTR: function( match, curLoop, inplace, result, not, isXML ) { - var name = match[1] = match[1].replace( rBackslash, "" ); - - if ( !isXML && Expr.attrMap[name] ) { - match[1] = Expr.attrMap[name]; - } - - // Handle if an un-quoted value was used - match[4] = ( match[4] || match[5] || "" ).replace( rBackslash, "" ); - - if ( match[2] === "~=" ) { - match[4] = " " + match[4] + " "; - } - - return match; - }, - - PSEUDO: function( match, curLoop, inplace, result, not ) { - if ( match[1] === "not" ) { - // If we're dealing with a complex expression, or a simple one - if ( ( chunker.exec(match[3]) || "" ).length > 1 || /^\w/.test(match[3]) ) { - match[3] = Sizzle(match[3], null, null, curLoop); - - } else { - var ret = Sizzle.filter(match[3], curLoop, inplace, true ^ not); - - if ( !inplace ) { - result.push.apply( result, ret ); - } - - return false; - } - - } else if ( Expr.match.POS.test( match[0] ) || Expr.match.CHILD.test( match[0] ) ) { - return true; - } - - return match; - }, - - POS: function( match ) { - match.unshift( true ); - - return match; - } - }, - - filters: { - enabled: function( elem ) { - return elem.disabled === false && elem.type !== "hidden"; - }, - - disabled: function( elem ) { - return elem.disabled === true; - }, - - checked: function( elem ) { - return elem.checked === true; - }, - - selected: function( elem ) { - // Accessing this property makes selected-by-default - // options in Safari work properly - if ( elem.parentNode ) { - elem.parentNode.selectedIndex; - } - - return elem.selected === true; - }, - - parent: function( elem ) { - return !!elem.firstChild; - }, - - empty: function( elem ) { - return !elem.firstChild; - }, - - has: function( elem, i, match ) { - return !!Sizzle( match[3], elem ).length; - }, - - header: function( elem ) { - return (/h\d/i).test( elem.nodeName ); - }, - - text: function( elem ) { - var attr = elem.getAttribute( "type" ), type = elem.type; - // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) - // use getAttribute instead to test this case - return elem.nodeName.toLowerCase() === "input" && "text" === type && ( attr === type || attr === null ); - }, - - radio: function( elem ) { - return elem.nodeName.toLowerCase() === "input" && "radio" === elem.type; - }, - - checkbox: function( elem ) { - return elem.nodeName.toLowerCase() === "input" && "checkbox" === elem.type; - }, - - file: function( elem ) { - return elem.nodeName.toLowerCase() === "input" && "file" === elem.type; - }, - - password: function( elem ) { - return elem.nodeName.toLowerCase() === "input" && "password" === elem.type; - }, - - submit: function( elem ) { - var name = elem.nodeName.toLowerCase(); - return (name === "input" || name === "button") && "submit" === elem.type; - }, - - image: function( elem ) { - return elem.nodeName.toLowerCase() === "input" && "image" === elem.type; - }, - - reset: function( elem ) { - var name = elem.nodeName.toLowerCase(); - return (name === "input" || name === "button") && "reset" === elem.type; - }, - - button: function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && "button" === elem.type || name === "button"; - }, - - input: function( elem ) { - return (/input|select|textarea|button/i).test( elem.nodeName ); - }, - - focus: function( elem ) { - return elem === elem.ownerDocument.activeElement; - } - }, - setFilters: { - first: function( elem, i ) { - return i === 0; - }, - - last: function( elem, i, match, array ) { - return i === array.length - 1; - }, - - even: function( elem, i ) { - return i % 2 === 0; - }, - - odd: function( elem, i ) { - return i % 2 === 1; - }, - - lt: function( elem, i, match ) { - return i < match[3] - 0; - }, - - gt: function( elem, i, match ) { - return i > match[3] - 0; - }, - - nth: function( elem, i, match ) { - return match[3] - 0 === i; - }, - - eq: function( elem, i, match ) { - return match[3] - 0 === i; - } - }, - filter: { - PSEUDO: function( elem, match, i, array ) { - var name = match[1], - filter = Expr.filters[ name ]; - - if ( filter ) { - return filter( elem, i, match, array ); - - } else if ( name === "contains" ) { - return (elem.textContent || elem.innerText || getText([ elem ]) || "").indexOf(match[3]) >= 0; - - } else if ( name === "not" ) { - var not = match[3]; - - for ( var j = 0, l = not.length; j < l; j++ ) { - if ( not[j] === elem ) { - return false; - } - } - - return true; - - } else { - Sizzle.error( name ); - } - }, - - CHILD: function( elem, match ) { - var first, last, - doneName, parent, cache, - count, diff, - type = match[1], - node = elem; - - switch ( type ) { - case "only": - case "first": - while ( (node = node.previousSibling) ) { - if ( node.nodeType === 1 ) { - return false; - } - } - - if ( type === "first" ) { - return true; - } - - node = elem; - - case "last": - while ( (node = node.nextSibling) ) { - if ( node.nodeType === 1 ) { - return false; - } - } - - return true; - - case "nth": - first = match[2]; - last = match[3]; - - if ( first === 1 && last === 0 ) { - return true; - } - - doneName = match[0]; - parent = elem.parentNode; - - if ( parent && (parent[ expando ] !== doneName || !elem.nodeIndex) ) { - count = 0; - - for ( node = parent.firstChild; node; node = node.nextSibling ) { - if ( node.nodeType === 1 ) { - node.nodeIndex = ++count; - } - } - - parent[ expando ] = doneName; - } - - diff = elem.nodeIndex - last; - - if ( first === 0 ) { - return diff === 0; - - } else { - return ( diff % first === 0 && diff / first >= 0 ); - } - } - }, - - ID: function( elem, match ) { - return elem.nodeType === 1 && elem.getAttribute("id") === match; - }, - - TAG: function( elem, match ) { - return (match === "*" && elem.nodeType === 1) || !!elem.nodeName && elem.nodeName.toLowerCase() === match; - }, - - CLASS: function( elem, match ) { - return (" " + (elem.className || elem.getAttribute("class")) + " ") - .indexOf( match ) > -1; - }, - - ATTR: function( elem, match ) { - var name = match[1], - result = Sizzle.attr ? - Sizzle.attr( elem, name ) : - Expr.attrHandle[ name ] ? - Expr.attrHandle[ name ]( elem ) : - elem[ name ] != null ? - elem[ name ] : - elem.getAttribute( name ), - value = result + "", - type = match[2], - check = match[4]; - - return result == null ? - type === "!=" : - !type && Sizzle.attr ? - result != null : - type === "=" ? - value === check : - type === "*=" ? - value.indexOf(check) >= 0 : - type === "~=" ? - (" " + value + " ").indexOf(check) >= 0 : - !check ? - value && result !== false : - type === "!=" ? - value !== check : - type === "^=" ? - value.indexOf(check) === 0 : - type === "$=" ? - value.substr(value.length - check.length) === check : - type === "|=" ? - value === check || value.substr(0, check.length + 1) === check + "-" : - false; - }, - - POS: function( elem, match, i, array ) { - var name = match[2], - filter = Expr.setFilters[ name ]; - - if ( filter ) { - return filter( elem, i, match, array ); - } - } - } -}; - -var origPOS = Expr.match.POS, - fescape = function(all, num){ - return "\\" + (num - 0 + 1); - }; - -for ( var type in Expr.match ) { - Expr.match[ type ] = new RegExp( Expr.match[ type ].source + (/(?![^\[]*\])(?![^\(]*\))/.source) ); - Expr.leftMatch[ type ] = new RegExp( /(^(?:.|\r|\n)*?)/.source + Expr.match[ type ].source.replace(/\\(\d+)/g, fescape) ); -} - -var makeArray = function( array, results ) { - array = Array.prototype.slice.call( array, 0 ); - - if ( results ) { - results.push.apply( results, array ); - return results; - } - - return array; -}; - -// Perform a simple check to determine if the browser is capable of -// converting a NodeList to an array using builtin methods. -// Also verifies that the returned array holds DOM nodes -// (which is not the case in the Blackberry browser) -try { - Array.prototype.slice.call( document.documentElement.childNodes, 0 )[0].nodeType; - -// Provide a fallback method if it does not work -} catch( e ) { - makeArray = function( array, results ) { - var i = 0, - ret = results || []; - - if ( toString.call(array) === "[object Array]" ) { - Array.prototype.push.apply( ret, array ); - - } else { - if ( typeof array.length === "number" ) { - for ( var l = array.length; i < l; i++ ) { - ret.push( array[i] ); - } - - } else { - for ( ; array[i]; i++ ) { - ret.push( array[i] ); - } - } - } - - return ret; - }; -} - -var sortOrder, siblingCheck; - -if ( document.documentElement.compareDocumentPosition ) { - sortOrder = function( a, b ) { - if ( a === b ) { - hasDuplicate = true; - return 0; - } - - if ( !a.compareDocumentPosition || !b.compareDocumentPosition ) { - return a.compareDocumentPosition ? -1 : 1; - } - - return a.compareDocumentPosition(b) & 4 ? -1 : 1; - }; - -} else { - sortOrder = function( a, b ) { - // The nodes are identical, we can exit early - if ( a === b ) { - hasDuplicate = true; - return 0; - - // Fallback to using sourceIndex (in IE) if it's available on both nodes - } else if ( a.sourceIndex && b.sourceIndex ) { - return a.sourceIndex - b.sourceIndex; - } - - var al, bl, - ap = [], - bp = [], - aup = a.parentNode, - bup = b.parentNode, - cur = aup; - - // If the nodes are siblings (or identical) we can do a quick check - if ( aup === bup ) { - return siblingCheck( a, b ); - - // If no parents were found then the nodes are disconnected - } else if ( !aup ) { - return -1; - - } else if ( !bup ) { - return 1; - } - - // Otherwise they're somewhere else in the tree so we need - // to build up a full list of the parentNodes for comparison - while ( cur ) { - ap.unshift( cur ); - cur = cur.parentNode; - } - - cur = bup; - - while ( cur ) { - bp.unshift( cur ); - cur = cur.parentNode; - } - - al = ap.length; - bl = bp.length; - - // Start walking down the tree looking for a discrepancy - for ( var i = 0; i < al && i < bl; i++ ) { - if ( ap[i] !== bp[i] ) { - return siblingCheck( ap[i], bp[i] ); - } - } - - // We ended someplace up the tree so do a sibling check - return i === al ? - siblingCheck( a, bp[i], -1 ) : - siblingCheck( ap[i], b, 1 ); - }; - - siblingCheck = function( a, b, ret ) { - if ( a === b ) { - return ret; - } - - var cur = a.nextSibling; - - while ( cur ) { - if ( cur === b ) { - return -1; - } - - cur = cur.nextSibling; - } - - return 1; - }; -} - -// Check to see if the browser returns elements by name when -// querying by getElementById (and provide a workaround) -(function(){ - // We're going to inject a fake input element with a specified name - var form = document.createElement("div"), - id = "script" + (new Date()).getTime(), - root = document.documentElement; - - form.innerHTML = ""; - - // Inject it into the root element, check its status, and remove it quickly - root.insertBefore( form, root.firstChild ); - - // The workaround has to do additional checks after a getElementById - // Which slows things down for other browsers (hence the branching) - if ( document.getElementById( id ) ) { - Expr.find.ID = function( match, context, isXML ) { - if ( typeof context.getElementById !== "undefined" && !isXML ) { - var m = context.getElementById(match[1]); - - return m ? - m.id === match[1] || typeof m.getAttributeNode !== "undefined" && m.getAttributeNode("id").nodeValue === match[1] ? - [m] : - undefined : - []; - } - }; - - Expr.filter.ID = function( elem, match ) { - var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); - - return elem.nodeType === 1 && node && node.nodeValue === match; - }; - } - - root.removeChild( form ); - - // release memory in IE - root = form = null; -})(); - -(function(){ - // Check to see if the browser returns only elements - // when doing getElementsByTagName("*") - - // Create a fake element - var div = document.createElement("div"); - div.appendChild( document.createComment("") ); - - // Make sure no comments are found - if ( div.getElementsByTagName("*").length > 0 ) { - Expr.find.TAG = function( match, context ) { - var results = context.getElementsByTagName( match[1] ); - - // Filter out possible comments - if ( match[1] === "*" ) { - var tmp = []; - - for ( var i = 0; results[i]; i++ ) { - if ( results[i].nodeType === 1 ) { - tmp.push( results[i] ); - } - } - - results = tmp; - } - - return results; - }; - } - - // Check to see if an attribute returns normalized href attributes - div.innerHTML = ""; - - if ( div.firstChild && typeof div.firstChild.getAttribute !== "undefined" && - div.firstChild.getAttribute("href") !== "#" ) { - - Expr.attrHandle.href = function( elem ) { - return elem.getAttribute( "href", 2 ); - }; - } - - // release memory in IE - div = null; -})(); - -if ( document.querySelectorAll ) { - (function(){ - var oldSizzle = Sizzle, - div = document.createElement("div"), - id = "__sizzle__"; - - div.innerHTML = "

"; - - // Safari can't handle uppercase or unicode characters when - // in quirks mode. - if ( div.querySelectorAll && div.querySelectorAll(".TEST").length === 0 ) { - return; - } - - Sizzle = function( query, context, extra, seed ) { - context = context || document; - - // Only use querySelectorAll on non-XML documents - // (ID selectors don't work in non-HTML documents) - if ( !seed && !Sizzle.isXML(context) ) { - // See if we find a selector to speed up - var match = /^(\w+$)|^\.([\w\-]+$)|^#([\w\-]+$)/.exec( query ); - - if ( match && (context.nodeType === 1 || context.nodeType === 9) ) { - // Speed-up: Sizzle("TAG") - if ( match[1] ) { - return makeArray( context.getElementsByTagName( query ), extra ); - - // Speed-up: Sizzle(".CLASS") - } else if ( match[2] && Expr.find.CLASS && context.getElementsByClassName ) { - return makeArray( context.getElementsByClassName( match[2] ), extra ); - } - } - - if ( context.nodeType === 9 ) { - // Speed-up: Sizzle("body") - // The body element only exists once, optimize finding it - if ( query === "body" && context.body ) { - return makeArray( [ context.body ], extra ); - - // Speed-up: Sizzle("#ID") - } else if ( match && match[3] ) { - var elem = context.getElementById( match[3] ); - - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - if ( elem && elem.parentNode ) { - // Handle the case where IE and Opera return items - // by name instead of ID - if ( elem.id === match[3] ) { - return makeArray( [ elem ], extra ); - } - - } else { - return makeArray( [], extra ); - } - } - - try { - return makeArray( context.querySelectorAll(query), extra ); - } catch(qsaError) {} - - // qSA works strangely on Element-rooted queries - // We can work around this by specifying an extra ID on the root - // and working up from there (Thanks to Andrew Dupont for the technique) - // IE 8 doesn't work on object elements - } else if ( context.nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { - var oldContext = context, - old = context.getAttribute( "id" ), - nid = old || id, - hasParent = context.parentNode, - relativeHierarchySelector = /^\s*[+~]/.test( query ); - - if ( !old ) { - context.setAttribute( "id", nid ); - } else { - nid = nid.replace( /'/g, "\\$&" ); - } - if ( relativeHierarchySelector && hasParent ) { - context = context.parentNode; - } - - try { - if ( !relativeHierarchySelector || hasParent ) { - return makeArray( context.querySelectorAll( "[id='" + nid + "'] " + query ), extra ); - } - - } catch(pseudoError) { - } finally { - if ( !old ) { - oldContext.removeAttribute( "id" ); - } - } - } - } - - return oldSizzle(query, context, extra, seed); - }; - - for ( var prop in oldSizzle ) { - Sizzle[ prop ] = oldSizzle[ prop ]; - } - - // release memory in IE - div = null; - })(); -} - -(function(){ - var html = document.documentElement, - matches = html.matchesSelector || html.mozMatchesSelector || html.webkitMatchesSelector || html.msMatchesSelector; - - if ( matches ) { - // Check to see if it's possible to do matchesSelector - // on a disconnected node (IE 9 fails this) - var disconnectedMatch = !matches.call( document.createElement( "div" ), "div" ), - pseudoWorks = false; - - try { - // This should fail with an exception - // Gecko does not error, returns false instead - matches.call( document.documentElement, "[test!='']:sizzle" ); - - } catch( pseudoError ) { - pseudoWorks = true; - } - - Sizzle.matchesSelector = function( node, expr ) { - // Make sure that attribute selectors are quoted - expr = expr.replace(/\=\s*([^'"\]]*)\s*\]/g, "='$1']"); - - if ( !Sizzle.isXML( node ) ) { - try { - if ( pseudoWorks || !Expr.match.PSEUDO.test( expr ) && !/!=/.test( expr ) ) { - var ret = matches.call( node, expr ); - - // IE 9's matchesSelector returns false on disconnected nodes - if ( ret || !disconnectedMatch || - // As well, disconnected nodes are said to be in a document - // fragment in IE 9, so check for that - node.document && node.document.nodeType !== 11 ) { - return ret; - } - } - } catch(e) {} - } - - return Sizzle(expr, null, null, [node]).length > 0; - }; - } -})(); - -(function(){ - var div = document.createElement("div"); - - div.innerHTML = "
"; - - // Opera can't find a second classname (in 9.6) - // Also, make sure that getElementsByClassName actually exists - if ( !div.getElementsByClassName || div.getElementsByClassName("e").length === 0 ) { - return; - } - - // Safari caches class attributes, doesn't catch changes (in 3.2) - div.lastChild.className = "e"; - - if ( div.getElementsByClassName("e").length === 1 ) { - return; - } - - Expr.order.splice(1, 0, "CLASS"); - Expr.find.CLASS = function( match, context, isXML ) { - if ( typeof context.getElementsByClassName !== "undefined" && !isXML ) { - return context.getElementsByClassName(match[1]); - } - }; - - // release memory in IE - div = null; -})(); - -function dirNodeCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - - if ( elem ) { - var match = false; - - elem = elem[dir]; - - while ( elem ) { - if ( elem[ expando ] === doneName ) { - match = checkSet[elem.sizset]; - break; - } - - if ( elem.nodeType === 1 && !isXML ){ - elem[ expando ] = doneName; - elem.sizset = i; - } - - if ( elem.nodeName.toLowerCase() === cur ) { - match = elem; - break; - } - - elem = elem[dir]; - } - - checkSet[i] = match; - } - } -} - -function dirCheck( dir, cur, doneName, checkSet, nodeCheck, isXML ) { - for ( var i = 0, l = checkSet.length; i < l; i++ ) { - var elem = checkSet[i]; - - if ( elem ) { - var match = false; - - elem = elem[dir]; - - while ( elem ) { - if ( elem[ expando ] === doneName ) { - match = checkSet[elem.sizset]; - break; - } - - if ( elem.nodeType === 1 ) { - if ( !isXML ) { - elem[ expando ] = doneName; - elem.sizset = i; - } - - if ( typeof cur !== "string" ) { - if ( elem === cur ) { - match = true; - break; - } - - } else if ( Sizzle.filter( cur, [elem] ).length > 0 ) { - match = elem; - break; - } - } - - elem = elem[dir]; - } - - checkSet[i] = match; - } - } -} - -if ( document.documentElement.contains ) { - Sizzle.contains = function( a, b ) { - return a !== b && (a.contains ? a.contains(b) : true); - }; - -} else if ( document.documentElement.compareDocumentPosition ) { - Sizzle.contains = function( a, b ) { - return !!(a.compareDocumentPosition(b) & 16); - }; - -} else { - Sizzle.contains = function() { - return false; - }; -} - -Sizzle.isXML = function( elem ) { - // documentElement is verified for cases where it doesn't yet exist - // (such as loading iframes in IE - #4833) - var documentElement = (elem ? elem.ownerDocument || elem : 0).documentElement; - - return documentElement ? documentElement.nodeName !== "HTML" : false; -}; - -var posProcess = function( selector, context, seed ) { - var match, - tmpSet = [], - later = "", - root = context.nodeType ? [context] : context; - - // Position selectors must be done after the filter - // And so must :not(positional) so we move all PSEUDOs to the end - while ( (match = Expr.match.PSEUDO.exec( selector )) ) { - later += match[0]; - selector = selector.replace( Expr.match.PSEUDO, "" ); - } - - selector = Expr.relative[selector] ? selector + "*" : selector; - - for ( var i = 0, l = root.length; i < l; i++ ) { - Sizzle( selector, root[i], tmpSet, seed ); - } - - return Sizzle.filter( later, tmpSet ); -}; - -// EXPOSE -// Override sizzle attribute retrieval -Sizzle.attr = jQuery.attr; -Sizzle.selectors.attrMap = {}; -jQuery.find = Sizzle; -jQuery.expr = Sizzle.selectors; -jQuery.expr[":"] = jQuery.expr.filters; -jQuery.unique = Sizzle.uniqueSort; -jQuery.text = Sizzle.getText; -jQuery.isXMLDoc = Sizzle.isXML; -jQuery.contains = Sizzle.contains; - - -})(); - - -var runtil = /Until$/, - rparentsprev = /^(?:parents|prevUntil|prevAll)/, - // Note: This RegExp should be improved, or likely pulled from Sizzle - rmultiselector = /,/, - isSimple = /^.[^:#\[\.,]*$/, - slice = Array.prototype.slice, - POS = jQuery.expr.match.POS, - // methods guaranteed to produce a unique set when starting from a unique set - guaranteedUnique = { - children: true, - contents: true, - next: true, - prev: true - }; - -jQuery.fn.extend({ - find: function( selector ) { - var self = this, - i, l; - - if ( typeof selector !== "string" ) { - return jQuery( selector ).filter(function() { - for ( i = 0, l = self.length; i < l; i++ ) { - if ( jQuery.contains( self[ i ], this ) ) { - return true; - } - } - }); - } - - var ret = this.pushStack( "", "find", selector ), - length, n, r; - - for ( i = 0, l = this.length; i < l; i++ ) { - length = ret.length; - jQuery.find( selector, this[i], ret ); - - if ( i > 0 ) { - // Make sure that the results are unique - for ( n = length; n < ret.length; n++ ) { - for ( r = 0; r < length; r++ ) { - if ( ret[r] === ret[n] ) { - ret.splice(n--, 1); - break; - } - } - } - } - } - - return ret; - }, - - has: function( target ) { - var targets = jQuery( target ); - return this.filter(function() { - for ( var i = 0, l = targets.length; i < l; i++ ) { - if ( jQuery.contains( this, targets[i] ) ) { - return true; - } - } - }); - }, - - not: function( selector ) { - return this.pushStack( winnow(this, selector, false), "not", selector); - }, - - filter: function( selector ) { - return this.pushStack( winnow(this, selector, true), "filter", selector ); - }, - - is: function( selector ) { - return !!selector && ( - typeof selector === "string" ? - // If this is a positional selector, check membership in the returned set - // so $("p:first").is("p:last") won't return true for a doc with two "p". - POS.test( selector ) ? - jQuery( selector, this.context ).index( this[0] ) >= 0 : - jQuery.filter( selector, this ).length > 0 : - this.filter( selector ).length > 0 ); - }, - - closest: function( selectors, context ) { - var ret = [], i, l, cur = this[0]; - - // Array (deprecated as of jQuery 1.7) - if ( jQuery.isArray( selectors ) ) { - var level = 1; - - while ( cur && cur.ownerDocument && cur !== context ) { - for ( i = 0; i < selectors.length; i++ ) { - - if ( jQuery( cur ).is( selectors[ i ] ) ) { - ret.push({ selector: selectors[ i ], elem: cur, level: level }); - } - } - - cur = cur.parentNode; - level++; - } - - return ret; - } - - // String - var pos = POS.test( selectors ) || typeof selectors !== "string" ? - jQuery( selectors, context || this.context ) : - 0; - - for ( i = 0, l = this.length; i < l; i++ ) { - cur = this[i]; - - while ( cur ) { - if ( pos ? pos.index(cur) > -1 : jQuery.find.matchesSelector(cur, selectors) ) { - ret.push( cur ); - break; - - } else { - cur = cur.parentNode; - if ( !cur || !cur.ownerDocument || cur === context || cur.nodeType === 11 ) { - break; - } - } - } - } - - ret = ret.length > 1 ? jQuery.unique( ret ) : ret; - - return this.pushStack( ret, "closest", selectors ); - }, - - // Determine the position of an element within - // the matched set of elements - index: function( elem ) { - - // No argument, return index in parent - if ( !elem ) { - return ( this[0] && this[0].parentNode ) ? this.prevAll().length : -1; - } - - // index in selector - if ( typeof elem === "string" ) { - return jQuery.inArray( this[0], jQuery( elem ) ); - } - - // Locate the position of the desired element - return jQuery.inArray( - // If it receives a jQuery object, the first element is used - elem.jquery ? elem[0] : elem, this ); - }, - - add: function( selector, context ) { - var set = typeof selector === "string" ? - jQuery( selector, context ) : - jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ), - all = jQuery.merge( this.get(), set ); - - return this.pushStack( isDisconnected( set[0] ) || isDisconnected( all[0] ) ? - all : - jQuery.unique( all ) ); - }, - - andSelf: function() { - return this.add( this.prevObject ); - } -}); - -// A painfully simple check to see if an element is disconnected -// from a document (should be improved, where feasible). -function isDisconnected( node ) { - return !node || !node.parentNode || node.parentNode.nodeType === 11; -} - -jQuery.each({ - parent: function( elem ) { - var parent = elem.parentNode; - return parent && parent.nodeType !== 11 ? parent : null; - }, - parents: function( elem ) { - return jQuery.dir( elem, "parentNode" ); - }, - parentsUntil: function( elem, i, until ) { - return jQuery.dir( elem, "parentNode", until ); - }, - next: function( elem ) { - return jQuery.nth( elem, 2, "nextSibling" ); - }, - prev: function( elem ) { - return jQuery.nth( elem, 2, "previousSibling" ); - }, - nextAll: function( elem ) { - return jQuery.dir( elem, "nextSibling" ); - }, - prevAll: function( elem ) { - return jQuery.dir( elem, "previousSibling" ); - }, - nextUntil: function( elem, i, until ) { - return jQuery.dir( elem, "nextSibling", until ); - }, - prevUntil: function( elem, i, until ) { - return jQuery.dir( elem, "previousSibling", until ); - }, - siblings: function( elem ) { - return jQuery.sibling( elem.parentNode.firstChild, elem ); - }, - children: function( elem ) { - return jQuery.sibling( elem.firstChild ); - }, - contents: function( elem ) { - return jQuery.nodeName( elem, "iframe" ) ? - elem.contentDocument || elem.contentWindow.document : - jQuery.makeArray( elem.childNodes ); - } -}, function( name, fn ) { - jQuery.fn[ name ] = function( until, selector ) { - var ret = jQuery.map( this, fn, until ); - - if ( !runtil.test( name ) ) { - selector = until; - } - - if ( selector && typeof selector === "string" ) { - ret = jQuery.filter( selector, ret ); - } - - ret = this.length > 1 && !guaranteedUnique[ name ] ? jQuery.unique( ret ) : ret; - - if ( (this.length > 1 || rmultiselector.test( selector )) && rparentsprev.test( name ) ) { - ret = ret.reverse(); - } - - return this.pushStack( ret, name, slice.call( arguments ).join(",") ); - }; -}); - -jQuery.extend({ - filter: function( expr, elems, not ) { - if ( not ) { - expr = ":not(" + expr + ")"; - } - - return elems.length === 1 ? - jQuery.find.matchesSelector(elems[0], expr) ? [ elems[0] ] : [] : - jQuery.find.matches(expr, elems); - }, - - dir: function( elem, dir, until ) { - var matched = [], - cur = elem[ dir ]; - - while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { - if ( cur.nodeType === 1 ) { - matched.push( cur ); - } - cur = cur[dir]; - } - return matched; - }, - - nth: function( cur, result, dir, elem ) { - result = result || 1; - var num = 0; - - for ( ; cur; cur = cur[dir] ) { - if ( cur.nodeType === 1 && ++num === result ) { - break; - } - } - - return cur; - }, - - sibling: function( n, elem ) { - var r = []; - - for ( ; n; n = n.nextSibling ) { - if ( n.nodeType === 1 && n !== elem ) { - r.push( n ); - } - } - - return r; - } -}); - -// Implement the identical functionality for filter and not -function winnow( elements, qualifier, keep ) { - - // Can't pass null or undefined to indexOf in Firefox 4 - // Set to 0 to skip string check - qualifier = qualifier || 0; - - if ( jQuery.isFunction( qualifier ) ) { - return jQuery.grep(elements, function( elem, i ) { - var retVal = !!qualifier.call( elem, i, elem ); - return retVal === keep; - }); - - } else if ( qualifier.nodeType ) { - return jQuery.grep(elements, function( elem, i ) { - return ( elem === qualifier ) === keep; - }); - - } else if ( typeof qualifier === "string" ) { - var filtered = jQuery.grep(elements, function( elem ) { - return elem.nodeType === 1; - }); - - if ( isSimple.test( qualifier ) ) { - return jQuery.filter(qualifier, filtered, !keep); - } else { - qualifier = jQuery.filter( qualifier, filtered ); - } - } - - return jQuery.grep(elements, function( elem, i ) { - return ( jQuery.inArray( elem, qualifier ) >= 0 ) === keep; - }); -} - - - - -function createSafeFragment( document ) { - var list = nodeNames.split( "|" ), - safeFrag = document.createDocumentFragment(); - - if ( safeFrag.createElement ) { - while ( list.length ) { - safeFrag.createElement( - list.pop() - ); - } - } - return safeFrag; -} - -var nodeNames = "abbr|article|aside|audio|canvas|datalist|details|figcaption|figure|footer|" + - "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", - rinlinejQuery = / jQuery\d+="(?:\d+|null)"/g, - rleadingWhitespace = /^\s+/, - rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig, - rtagName = /<([\w:]+)/, - rtbody = /", "" ], - legend: [ 1, "
", "
" ], - thead: [ 1, "", "
" ], - tr: [ 2, "", "
" ], - td: [ 3, "", "
" ], - col: [ 2, "", "
" ], - area: [ 1, "", "" ], - _default: [ 0, "", "" ] - }, - safeFragment = createSafeFragment( document ); - -wrapMap.optgroup = wrapMap.option; -wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; -wrapMap.th = wrapMap.td; - -// IE can't serialize and + `.trim() + ); + + const htmlCommentStrings = { + commentStartString: '' + }; + const jsCommentStrings = { + commentStartString: '//', + commentEndString: undefined + }; + + expect(languageMode.commentStringsForPosition(new Point(0, 0))).toEqual( + htmlCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(1, 0))).toEqual( + htmlCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(2, 0))).toEqual( + jsCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(3, 0))).toEqual( + jsCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(4, 0))).toEqual( + htmlCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(5, 0))).toEqual( + jsCommentStrings + ); + expect(languageMode.commentStringsForPosition(new Point(6, 0))).toEqual( + htmlCommentStrings + ); + }); + }); + + describe('TextEditor.selectLargerSyntaxNode and .selectSmallerSyntaxNode', () => { + it('expands and contracts the selection based on the syntax tree', async () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { program: 'source' } + }); + + buffer.setText(dedent` + function a (b, c, d) { + eee.f() + g() + } + `); + + buffer.setLanguageMode(new TreeSitterLanguageMode({ buffer, grammar })); + + 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]]); + }); + + it('handles injected languages', async () => { + const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + scopeName: 'javascript', + parser: 'tree-sitter-javascript', + scopes: { + property_identifier: 'property', + 'call_expression > identifier': 'function', + template_string: 'string', + 'template_substitution > "${"': 'interpolation', + 'template_substitution > "}"': 'interpolation' + }, + injectionRegExp: 'javascript', + injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] + }); + + const htmlGrammar = new TreeSitterGrammar( + atom.grammars, + htmlGrammarPath, + { + scopeName: 'html', + parser: 'tree-sitter-html', + scopes: { + fragment: 'html', + tag_name: 'tag', + attribute_name: 'attr' + }, + injectionRegExp: 'html' + } + ); + + atom.grammars.addGrammar(htmlGrammar); + + buffer.setText('a = html ` c${def()}e${f}g `'); + const languageMode = new TreeSitterLanguageMode({ + buffer, + grammar: jsGrammar, + grammars: atom.grammars + }); + buffer.setLanguageMode(languageMode); + + editor.setCursorBufferPosition({ + row: 0, + column: buffer.getText().indexOf('ef()') + }); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('def'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('def()'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('${def()}'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('c${def()}e${f}g'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('c${def()}e${f}g'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe(' c${def()}e${f}g '); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('` c${def()}e${f}g `'); + editor.selectLargerSyntaxNode(); + expect(editor.getSelectedText()).toBe('html ` c${def()}e${f}g `'); + }); + }); + + describe('.tokenizedLineForRow(row)', () => { + it('returns a shimmed TokenizedLine with tokens', () => { + 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', + identifier: 'variable' + } + }); + + buffer.setText('aa.bbb = cc(d.eee());\n\n \n b'); + + const languageMode = new TreeSitterLanguageMode({ buffer, grammar }); + buffer.setLanguageMode(languageMode); + + expect(languageMode.tokenizedLineForRow(0).tokens).toEqual([ + { value: 'aa', scopes: ['source', 'variable'] }, + { value: '.', scopes: ['source'] }, + { value: 'bbb', scopes: ['source', 'property'] }, + { value: ' = ', scopes: ['source'] }, + { value: 'cc', scopes: ['source', 'function'] }, + { value: '(', scopes: ['source'] }, + { value: 'd', scopes: ['source', 'variable'] }, + { value: '.', scopes: ['source'] }, + { value: 'eee', scopes: ['source', 'method'] }, + { value: '());', scopes: ['source'] } + ]); + expect(languageMode.tokenizedLineForRow(1).tokens).toEqual([]); + expect(languageMode.tokenizedLineForRow(2).tokens).toEqual([ + { value: ' ', scopes: ['source'] } + ]); + expect(languageMode.tokenizedLineForRow(3).tokens).toEqual([ + { value: ' ', scopes: ['source'] }, + { value: 'b', scopes: ['source', 'variable'] } + ]); + }); + }); +}); + +function nextHighlightingUpdate(languageMode) { + return new Promise(resolve => { + const subscription = languageMode.onDidChangeHighlighting(() => { + subscription.dispose(); + resolve(); + }); + }); +} + +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++) { + // Clear the screen line cache between iterations, but not on the first + // iteration, so that the first iteration tests that the cache has been + // correctly invalidated by any changes. + if (startRow > 0) { + 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.replace('syntax--', '')) + .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}` + ); + } + } + } + + // Fully populate the screen line cache again so that cache invalidation + // due to subsequent edits can be tested. + editor.displayLayer.getScreenLines(0, Infinity); +} + +const HTML_TEMPLATE_LITERAL_INJECTION_POINT = { + type: 'call_expression', + language(node) { + if ( + node.lastChild.type === 'template_string' && + node.firstChild.type === 'identifier' + ) { + return node.firstChild.text; + } + }, + content(node) { + return node.lastChild; + } +}; + +const SCRIPT_TAG_INJECTION_POINT = { + type: 'script_element', + language() { + return 'javascript'; + }, + content(node) { + return node.child(1); + } +}; + +const JSDOC_INJECTION_POINT = { + type: 'comment', + language(comment) { + if (comment.text.startsWith('/**')) return 'jsdoc'; + }, + content(comment) { + return comment; + } +}; diff --git a/spec/typescript-spec.coffee b/spec/typescript-spec.coffee deleted file mode 100644 index 493715d36fe..00000000000 --- a/spec/typescript-spec.coffee +++ /dev/null @@ -1,30 +0,0 @@ -typescript = require '../src/typescript' -crypto = require 'crypto' - -describe "TypeScript transpiler support", -> - describe "::createTypeScriptVersionAndOptionsDigest", -> - it "returns a digest for the library version and specified options", -> - defaultOptions = - target: 1 # ES5 - module: 'commonjs' - sourceMap: true - version = '1.4.1' - shasum = crypto.createHash('sha1') - shasum.update('typescript', 'utf8') - shasum.update('\0', 'utf8') - shasum.update(version, 'utf8') - shasum.update('\0', 'utf8') - shasum.update(JSON.stringify(defaultOptions)) - expectedDigest = shasum.digest('hex') - - observedDigest = typescript.createTypeScriptVersionAndOptionsDigest(version, defaultOptions) - expect(observedDigest).toEqual expectedDigest - - describe "when there is a .ts file", -> - it "transpiles it using typescript", -> - transpiled = require('./fixtures/typescript/valid.ts') - expect(transpiled(3)).toBe 4 - - describe "when the .ts file is invalid", -> - it "does not transpile", -> - expect(-> require('./fixtures/typescript/invalid.ts')).toThrow() diff --git a/spec/typescript-spec.js b/spec/typescript-spec.js new file mode 100644 index 00000000000..f68b32e9e62 --- /dev/null +++ b/spec/typescript-spec.js @@ -0,0 +1,11 @@ +describe('TypeScript transpiler support', function() { + describe('when there is a .ts file', () => + it('transpiles it using typescript', function() { + const transpiled = require('./fixtures/typescript/valid.ts'); + expect(transpiled(3)).toBe(4); + })); + + describe('when the .ts file is invalid', () => + it('does not transpile', () => + expect(() => require('./fixtures/typescript/invalid.ts')).toThrow())); +}); diff --git a/spec/update-process-env-spec.js b/spec/update-process-env-spec.js new file mode 100644 index 00000000000..62722a6d2a9 --- /dev/null +++ b/spec/update-process-env-spec.js @@ -0,0 +1,393 @@ +/** @babel */ + +import path from 'path'; +import childProcess from 'child_process'; +import { + updateProcessEnv, + shouldGetEnvFromShell +} from '../src/update-process-env'; +import mockSpawn from 'mock-spawn'; +const temp = require('temp').track(); + +describe('updateProcessEnv(launchEnv)', function() { + let originalProcessEnv, originalProcessPlatform, originalSpawn, spawn; + + beforeEach(function() { + originalSpawn = childProcess.spawn; + spawn = mockSpawn(); + childProcess.spawn = spawn; + originalProcessEnv = process.env; + originalProcessPlatform = process.platform; + process.env = {}; + }); + + afterEach(function() { + if (originalSpawn) { + childProcess.spawn = originalSpawn; + } + process.env = originalProcessEnv; + process.platform = originalProcessPlatform; + try { + temp.cleanupSync(); + } catch (e) { + // Do nothing + } + }); + + describe('when the launch environment appears to come from a shell', function() { + it('updates process.env to match the launch environment because PWD is set', async function() { + process.env = { + WILL_BE_DELETED: 'hi', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + const initialProcessEnv = process.env; + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + TERM: 'xterm-something', + KEY1: 'value1', + KEY2: 'value2' + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + TERM: 'xterm-something', + KEY1: 'value1', + KEY2: 'value2', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + // See #11302. On Windows, `process.env` is a magic object that offers + // case-insensitive environment variable matching, so we cannot replace it + // with another object. + expect(process.env).toBe(initialProcessEnv); + }); + + it('updates process.env to match the launch environment because PROMPT is set', async function() { + process.env = { + WILL_BE_DELETED: 'hi', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + const initialProcessEnv = process.env; + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PROMPT: '$P$G', + KEY1: 'value1', + KEY2: 'value2' + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PROMPT: '$P$G', + KEY1: 'value1', + KEY2: 'value2', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + // See #11302. On Windows, `process.env` is a magic object that offers + // case-insensitive environment variable matching, so we cannot replace it + // with another object. + expect(process.env).toBe(initialProcessEnv); + }); + + it('updates process.env to match the launch environment because PSModulePath is set', async function() { + process.env = { + WILL_BE_DELETED: 'hi', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + const initialProcessEnv = process.env; + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PSModulePath: + 'C:\\Program Files\\WindowsPowerShell\\Modules;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules\\', + KEY1: 'value1', + KEY2: 'value2' + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PSModulePath: + 'C:\\Program Files\\WindowsPowerShell\\Modules;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules\\', + KEY1: 'value1', + KEY2: 'value2', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + // See #11302. On Windows, `process.env` is a magic object that offers + // case-insensitive environment variable matching, so we cannot replace it + // with another object. + expect(process.env).toBe(initialProcessEnv); + }); + + it('allows ATOM_HOME to be overwritten only if the new value is a valid path', async function() { + let newAtomHomePath = temp.mkdirSync('atom-home'); + + process.env = { + WILL_BE_DELETED: 'hi', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir' + }); + expect(process.env).toEqual({ + PWD: '/the/dir', + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + ATOM_HOME: path.join(newAtomHomePath, 'non-existent') + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + ATOM_HOME: newAtomHomePath + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: newAtomHomePath + }); + }); + + it('allows ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT to be preserved if set', async function() { + process.env = { + WILL_BE_DELETED: 'hi', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + await updateProcessEnv({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + + await updateProcessEnv({ + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + expect(process.env).toEqual({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + PWD: '/the/dir', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }); + }); + + it('allows an existing env variable to be updated', async function() { + process.env = { + WILL_BE_UPDATED: 'old-value', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home' + }; + + await updateProcessEnv(process.env); + expect(process.env).toEqual(process.env); + + let updatedEnv = { + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + WILL_BE_UPDATED: 'new-value', + NODE_ENV: 'the-node-env', + NODE_PATH: '/the/node/path', + ATOM_HOME: '/the/atom/home', + PWD: '/the/dir' + }; + + await updateProcessEnv(updatedEnv); + expect(process.env).toEqual(updatedEnv); + }); + }); + + describe('when the launch environment does not come from a shell', function() { + describe('on macOS', function() { + it("updates process.env to match the environment in the user's login shell", async function() { + if (process.platform === 'win32') return; // TestsThatFailOnWin32 + + process.platform = 'darwin'; + process.env.SHELL = '/my/custom/bash'; + spawn.setDefault( + spawn.simple( + 0, + 'FOO=BAR=BAZ=QUUX\0MULTILINE\nNAME=multiline\nvalue\0TERM=xterm-something\0PATH=/usr/bin:/bin:/usr/sbin:/sbin:/crazy/path' + ) + ); + await updateProcessEnv(process.env); + expect(spawn.calls.length).toBe(1); + expect(spawn.calls[0].command).toBe('/my/custom/bash'); + expect(spawn.calls[0].args).toEqual([ + '-ilc', + 'command awk \'BEGIN{for(v in ENVIRON) printf("%s=%s%c", v, ENVIRON[v], 0)}\'' + ]); + expect(process.env).toEqual({ + FOO: 'BAR=BAZ=QUUX', + 'MULTILINE\nNAME': 'multiline\nvalue', + TERM: 'xterm-something', + PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/crazy/path' + }); + + // Doesn't error + await updateProcessEnv(null); + }); + }); + + describe('on linux', function() { + it("updates process.env to match the environment in the user's login shell", async function() { + if (process.platform === 'win32') return; // TestsThatFailOnWin32 + + process.platform = 'linux'; + process.env.SHELL = '/my/custom/bash'; + spawn.setDefault( + spawn.simple( + 0, + 'FOO=BAR=BAZ=QUUX\0MULTILINE\nNAME=multiline\nvalue\0TERM=xterm-something\0PATH=/usr/bin:/bin:/usr/sbin:/sbin:/crazy/path' + ) + ); + await updateProcessEnv(process.env); + expect(spawn.calls.length).toBe(1); + expect(spawn.calls[0].command).toBe('/my/custom/bash'); + expect(spawn.calls[0].args).toEqual([ + '-ilc', + 'command awk \'BEGIN{for(v in ENVIRON) printf("%s=%s%c", v, ENVIRON[v], 0)}\'' + ]); + expect(process.env).toEqual({ + FOO: 'BAR=BAZ=QUUX', + 'MULTILINE\nNAME': 'multiline\nvalue', + TERM: 'xterm-something', + PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/crazy/path' + }); + + // Doesn't error + await updateProcessEnv(null); + }); + }); + + describe('on windows', function() { + it('does not update process.env', async function() { + process.platform = 'win32'; + spyOn(childProcess, 'spawn'); + process.env = { FOO: 'bar' }; + + await updateProcessEnv(process.env); + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(process.env).toEqual({ FOO: 'bar' }); + }); + }); + + describe('shouldGetEnvFromShell()', function() { + it('indicates when the environment should be fetched from the shell', function() { + if (process.platform === 'win32') return; // TestsThatFailOnWin32 + + process.platform = 'darwin'; + expect(shouldGetEnvFromShell({ SHELL: '/bin/sh' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/sh' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/bash' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/bash' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/zsh' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/zsh' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/fish' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/fish' })).toBe( + true + ); + process.platform = 'linux'; + expect(shouldGetEnvFromShell({ SHELL: '/bin/sh' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/sh' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/bash' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/bash' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/zsh' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/zsh' })).toBe( + true + ); + expect(shouldGetEnvFromShell({ SHELL: '/bin/fish' })).toBe(true); + expect(shouldGetEnvFromShell({ SHELL: '/usr/local/bin/fish' })).toBe( + true + ); + }); + + it('returns false when the environment indicates that Atom was launched from a shell', function() { + process.platform = 'darwin'; + expect( + shouldGetEnvFromShell({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + SHELL: '/bin/sh' + }) + ).toBe(false); + process.platform = 'linux'; + expect( + shouldGetEnvFromShell({ + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', + SHELL: '/bin/sh' + }) + ).toBe(false); + }); + + it('returns false when the shell is undefined or empty', function() { + process.platform = 'darwin'; + expect(shouldGetEnvFromShell(undefined)).toBe(false); + expect(shouldGetEnvFromShell({})).toBe(false); + + process.platform = 'linux'; + expect(shouldGetEnvFromShell(undefined)).toBe(false); + expect(shouldGetEnvFromShell({})).toBe(false); + }); + }); + }); +}); diff --git a/spec/uri-handler-registry-spec.js b/spec/uri-handler-registry-spec.js new file mode 100644 index 00000000000..390cfd903c5 --- /dev/null +++ b/spec/uri-handler-registry-spec.js @@ -0,0 +1,100 @@ +/** @babel */ + +import url from 'url'; + +import URIHandlerRegistry from '../src/uri-handler-registry'; + +describe('URIHandlerRegistry', () => { + let registry; + + beforeEach(() => { + registry = new URIHandlerRegistry(5); + }); + + it('handles URIs on a per-host basis', async () => { + const testPackageSpy = jasmine.createSpy(); + const otherPackageSpy = jasmine.createSpy(); + registry.registerHostHandler('test-package', testPackageSpy); + registry.registerHostHandler('other-package', otherPackageSpy); + + await registry.handleURI('atom://yet-another-package/path'); + expect(testPackageSpy).not.toHaveBeenCalled(); + expect(otherPackageSpy).not.toHaveBeenCalled(); + + await registry.handleURI('atom://test-package/path'); + expect(testPackageSpy).toHaveBeenCalledWith( + url.parse('atom://test-package/path', true), + 'atom://test-package/path' + ); + expect(otherPackageSpy).not.toHaveBeenCalled(); + + await registry.handleURI('atom://other-package/path'); + expect(otherPackageSpy).toHaveBeenCalledWith( + url.parse('atom://other-package/path', true), + 'atom://other-package/path' + ); + }); + + it('keeps track of the most recent URIs', async () => { + const spy1 = jasmine.createSpy(); + const spy2 = jasmine.createSpy(); + const changeSpy = jasmine.createSpy(); + registry.registerHostHandler('one', spy1); + registry.registerHostHandler('two', spy2); + registry.onHistoryChange(changeSpy); + + const uris = [ + 'atom://one/something?asdf=1', + 'atom://fake/nothing', + 'atom://two/other/stuff', + 'atom://one/more/thing', + 'atom://two/more/stuff' + ]; + + for (const u of uris) { + await registry.handleURI(u); + } + + expect(changeSpy.callCount).toBe(5); + expect(registry.getRecentlyHandledURIs()).toEqual( + uris + .map((u, idx) => { + return { + id: idx + 1, + uri: u, + handled: !u.match(/fake/), + host: url.parse(u).host + }; + }) + .reverse() + ); + + await registry.handleURI('atom://another/url'); + expect(changeSpy.callCount).toBe(6); + const history = registry.getRecentlyHandledURIs(); + expect(history.length).toBe(5); + expect(history[0].uri).toBe('atom://another/url'); + expect(history[4].uri).toBe(uris[1]); + }); + + it('refuses to handle bad URLs', async () => { + const invalidUris = [ + 'atom:package/path', + 'atom:8080://package/path', + 'user:pass@atom://package/path', + 'smth://package/path' + ]; + + let numErrors = 0; + for (const uri of invalidUris) { + try { + await registry.handleURI(uri); + expect(uri).toBe('throwing an error'); + } catch (ex) { + numErrors++; + } + } + + expect(numErrors).toBe(invalidUris.length); + }); +}); diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee deleted file mode 100644 index df822309e35..00000000000 --- a/spec/view-registry-spec.coffee +++ /dev/null @@ -1,207 +0,0 @@ -ViewRegistry = require '../src/view-registry' -{View} = require '../src/space-pen-extensions' - -describe "ViewRegistry", -> - registry = null - - beforeEach -> - registry = new ViewRegistry - - describe "::getView(object)", -> - describe "when passed a DOM node", -> - it "returns the given DOM node", -> - node = document.createElement('div') - expect(registry.getView(node)).toBe node - - describe "when passed a SpacePen view", -> - it "returns the root node of the view with a .spacePenView property pointing at the SpacePen view", -> - class TestView extends View - @content: -> @div "Hello" - - view = new TestView - node = registry.getView(view) - expect(node.textContent).toBe "Hello" - expect(node.spacePenView).toBe view - - describe "when passed a model object", -> - describe "when a view provider is registered matching the object's constructor", -> - it "constructs a view element and assigns the model on it", -> - class TestModel - - class TestModelSubclass extends TestModel - - class TestView - initialize: (@model) -> this - - model = new TestModel - - registry.addViewProvider TestModel, (model) -> - new TestView().initialize(model) - - view = registry.getView(model) - expect(view instanceof TestView).toBe true - expect(view.model).toBe model - - subclassModel = new TestModelSubclass - view2 = registry.getView(subclassModel) - expect(view2 instanceof TestView).toBe true - expect(view2.model).toBe subclassModel - - describe "when no view provider is registered for the object's constructor", -> - describe "when the object has a .getViewClass() method", -> - it "builds an instance of the view class with the model, then returns its root node with a __spacePenView property pointing at the view", -> - class TestView extends View - @content: (model) -> @div model.name - initialize: (@model) -> - - class TestModel - constructor: (@name) -> - getViewClass: -> TestView - - model = new TestModel("hello") - node = registry.getView(model) - - expect(node.textContent).toBe "hello" - view = node.spacePenView - expect(view instanceof TestView).toBe true - expect(view.model).toBe model - - # returns the same DOM node for repeated calls - expect(registry.getView(model)).toBe node - - describe "when the object has no .getViewClass() method", -> - it "throws an exception", -> - expect(-> registry.getView(new Object)).toThrow() - - describe "::addViewProvider(providerSpec)", -> - it "returns a disposable that can be used to remove the provider", -> - class TestModel - class TestView - initialize: (@model) -> this - - disposable = registry.addViewProvider TestModel, (model) -> - new TestView().initialize(model) - - expect(registry.getView(new TestModel) instanceof TestView).toBe true - disposable.dispose() - expect(-> registry.getView(new TestModel)).toThrow() - - describe "::updateDocument(fn) and ::readDocument(fn)", -> - frameRequests = null - - beforeEach -> - frameRequests = [] - spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> frameRequests.push(fn) - - it "performs all pending writes before all pending reads on the next animation frame", -> - events = [] - - registry.updateDocument -> events.push('write 1') - registry.readDocument -> events.push('read 1') - registry.readDocument -> events.push('read 2') - registry.updateDocument -> events.push('write 2') - - expect(events).toEqual [] - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(events).toEqual ['write 1', 'write 2', 'read 1', 'read 2'] - - frameRequests = [] - events = [] - disposable = registry.updateDocument -> events.push('write 3') - registry.updateDocument -> events.push('write 4') - registry.readDocument -> events.push('read 3') - - disposable.dispose() - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(events).toEqual ['write 4', 'read 3'] - - it "performs writes requested from read callbacks in the same animation frame", -> - spyOn(window, 'setInterval').andCallFake(fakeSetInterval) - spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) - events = [] - - registry.pollDocument -> events.push('poll') - registry.pollAfterNextUpdate() - registry.updateDocument -> events.push('write 1') - registry.readDocument -> - registry.updateDocument -> events.push('write from read 1') - events.push('read 1') - registry.readDocument -> - registry.updateDocument -> events.push('write from read 2') - events.push('read 2') - registry.updateDocument -> events.push('write 2') - - expect(frameRequests.length).toBe 1 - frameRequests[0]() - expect(frameRequests.length).toBe 1 - - expect(events).toEqual [ - 'write 1' - 'write 2' - 'read 1' - 'read 2' - 'poll' - 'write from read 1' - 'write from read 2' - ] - - it "pauses DOM polling when reads or writes are pending", -> - spyOn(window, 'setInterval').andCallFake(fakeSetInterval) - spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) - events = [] - - registry.pollDocument -> events.push('poll') - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - - advanceClock(registry.documentPollingInterval) - expect(events).toEqual [] - - frameRequests[0]() - expect(events).toEqual ['write', 'read', 'poll'] - - advanceClock(registry.documentPollingInterval) - expect(events).toEqual ['write', 'read', 'poll', 'poll'] - - it "polls the document after updating when ::pollAfterNextUpdate() has been called", -> - events = [] - registry.pollDocument -> events.push('poll') - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - frameRequests.shift()() - expect(events).toEqual ['write', 'read'] - - events = [] - registry.pollAfterNextUpdate() - registry.updateDocument -> events.push('write') - registry.readDocument -> events.push('read') - frameRequests.shift()() - expect(events).toEqual ['write', 'read', 'poll'] - - describe "::pollDocument(fn)", -> - it "calls all registered reader functions on an interval until they are disabled via a returned disposable", -> - spyOn(window, 'setInterval').andCallFake(fakeSetInterval) - - events = [] - disposable1 = registry.pollDocument -> events.push('poll 1') - disposable2 = registry.pollDocument -> events.push('poll 2') - - expect(events).toEqual [] - - advanceClock(registry.documentPollingInterval) - expect(events).toEqual ['poll 1', 'poll 2'] - - advanceClock(registry.documentPollingInterval) - expect(events).toEqual ['poll 1', 'poll 2', 'poll 1', 'poll 2'] - - disposable1.dispose() - advanceClock(registry.documentPollingInterval) - expect(events).toEqual ['poll 1', 'poll 2', 'poll 1', 'poll 2', 'poll 2'] - - disposable2.dispose() - advanceClock(registry.documentPollingInterval) - expect(events).toEqual ['poll 1', 'poll 2', 'poll 1', 'poll 2', 'poll 2'] diff --git a/spec/view-registry-spec.js b/spec/view-registry-spec.js new file mode 100644 index 00000000000..192d1b347ea --- /dev/null +++ b/spec/view-registry-spec.js @@ -0,0 +1,214 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const ViewRegistry = require('../src/view-registry'); + +describe('ViewRegistry', () => { + let registry = null; + + beforeEach(() => { + registry = new ViewRegistry(); + }); + + afterEach(() => { + registry.clearDocumentRequests(); + }); + + describe('::getView(object)', () => { + describe('when passed a DOM node', () => + it('returns the given DOM node', () => { + const node = document.createElement('div'); + expect(registry.getView(node)).toBe(node); + })); + + describe('when passed an object with an element property', () => + it("returns the element property if it's an instance of HTMLElement", () => { + class TestComponent { + constructor() { + this.element = document.createElement('div'); + } + } + + const component = new TestComponent(); + expect(registry.getView(component)).toBe(component.element); + })); + + describe('when passed an object with a getElement function', () => + it("returns the return value of getElement if it's an instance of HTMLElement", () => { + class TestComponent { + getElement() { + if (this.myElement == null) { + this.myElement = document.createElement('div'); + } + return this.myElement; + } + } + + const component = new TestComponent(); + expect(registry.getView(component)).toBe(component.myElement); + })); + + describe('when passed a model object', () => { + describe("when a view provider is registered matching the object's constructor", () => + it('constructs a view element and assigns the model on it', () => { + class TestModel {} + + class TestModelSubclass extends TestModel {} + + class TestView { + initialize(model) { + this.model = model; + return this; + } + } + + const model = new TestModel(); + + registry.addViewProvider(TestModel, model => + new TestView().initialize(model) + ); + + const view = registry.getView(model); + expect(view instanceof TestView).toBe(true); + expect(view.model).toBe(model); + + const subclassModel = new TestModelSubclass(); + const view2 = registry.getView(subclassModel); + expect(view2 instanceof TestView).toBe(true); + expect(view2.model).toBe(subclassModel); + })); + + describe('when a view provider is registered generically, and works with the object', () => + it('constructs a view element and assigns the model on it', () => { + registry.addViewProvider(model => { + if (model.a === 'b') { + const element = document.createElement('div'); + element.className = 'test-element'; + return element; + } + }); + + const view = registry.getView({ a: 'b' }); + expect(view.className).toBe('test-element'); + + expect(() => registry.getView({ a: 'c' })).toThrow(); + })); + + describe("when no view provider is registered for the object's constructor", () => + it('throws an exception', () => { + expect(() => registry.getView({})).toThrow(); + })); + }); + }); + + describe('::addViewProvider(providerSpec)', () => + it('returns a disposable that can be used to remove the provider', () => { + class TestModel {} + class TestView { + initialize(model) { + this.model = model; + return this; + } + } + + const disposable = registry.addViewProvider(TestModel, model => + new TestView().initialize(model) + ); + + expect(registry.getView(new TestModel()) instanceof TestView).toBe(true); + disposable.dispose(); + expect(() => registry.getView(new TestModel())).toThrow(); + })); + + describe('::updateDocument(fn) and ::readDocument(fn)', () => { + let frameRequests = null; + + beforeEach(() => { + frameRequests = []; + spyOn(window, 'requestAnimationFrame').andCallFake(fn => + frameRequests.push(fn) + ); + }); + + it('performs all pending writes before all pending reads on the next animation frame', () => { + let events = []; + + registry.updateDocument(() => events.push('write 1')); + registry.readDocument(() => events.push('read 1')); + registry.readDocument(() => events.push('read 2')); + registry.updateDocument(() => events.push('write 2')); + + expect(events).toEqual([]); + + expect(frameRequests.length).toBe(1); + frameRequests[0](); + expect(events).toEqual(['write 1', 'write 2', 'read 1', 'read 2']); + + frameRequests = []; + events = []; + const disposable = registry.updateDocument(() => events.push('write 3')); + registry.updateDocument(() => events.push('write 4')); + registry.readDocument(() => events.push('read 3')); + + disposable.dispose(); + + expect(frameRequests.length).toBe(1); + frameRequests[0](); + expect(events).toEqual(['write 4', 'read 3']); + }); + + it('performs writes requested from read callbacks in the same animation frame', () => { + spyOn(window, 'setInterval').andCallFake(fakeSetInterval); + spyOn(window, 'clearInterval').andCallFake(fakeClearInterval); + const events = []; + + registry.updateDocument(() => events.push('write 1')); + registry.readDocument(() => { + registry.updateDocument(() => events.push('write from read 1')); + events.push('read 1'); + }); + registry.readDocument(() => { + registry.updateDocument(() => events.push('write from read 2')); + events.push('read 2'); + }); + registry.updateDocument(() => events.push('write 2')); + + expect(frameRequests.length).toBe(1); + frameRequests[0](); + expect(frameRequests.length).toBe(1); + + expect(events).toEqual([ + 'write 1', + 'write 2', + 'read 1', + 'read 2', + 'write from read 1', + 'write from read 2' + ]); + }); + }); + + describe('::getNextUpdatePromise()', () => + it('returns a promise that resolves at the end of the next update cycle', () => { + let updateCalled = false; + let readCalled = false; + + waitsFor('getNextUpdatePromise to resolve', done => { + registry.getNextUpdatePromise().then(() => { + expect(updateCalled).toBe(true); + expect(readCalled).toBe(true); + done(); + }); + + registry.updateDocument(() => { + updateCalled = true; + }); + registry.readDocument(() => { + readCalled = true; + }); + }); + })); +}); diff --git a/spec/window-event-handler-spec.js b/spec/window-event-handler-spec.js new file mode 100644 index 00000000000..51a3ca47bb0 --- /dev/null +++ b/spec/window-event-handler-spec.js @@ -0,0 +1,295 @@ +const KeymapManager = require('atom-keymap'); +const WindowEventHandler = require('../src/window-event-handler'); +const { conditionPromise } = require('./async-spec-helpers'); + +describe('WindowEventHandler', () => { + let windowEventHandler; + + beforeEach(() => { + atom.uninstallWindowEventHandler(); + spyOn(atom, 'hide'); + const initialPath = atom.project.getPaths()[0]; + spyOn(atom, 'getLoadSettings').andCallFake(() => { + const loadSettings = atom.getLoadSettings.originalValue.call(atom); + loadSettings.initialPath = initialPath; + return loadSettings; + }); + atom.project.destroy(); + windowEventHandler = new WindowEventHandler({ + atomEnvironment: atom, + applicationDelegate: atom.applicationDelegate + }); + windowEventHandler.initialize(window, document); + }); + + afterEach(() => { + windowEventHandler.unsubscribe(); + atom.installWindowEventHandler(); + }); + + describe('when the window is loaded', () => + it("doesn't have .is-blurred on the body tag", () => { + if (process.platform === 'win32') { + return; + } // Win32TestFailures - can not steal focus + expect(document.body.className).not.toMatch('is-blurred'); + })); + + describe('when the window is blurred', () => { + beforeEach(() => window.dispatchEvent(new CustomEvent('blur'))); + + afterEach(() => document.body.classList.remove('is-blurred')); + + it('adds the .is-blurred class on the body', () => + expect(document.body.className).toMatch('is-blurred')); + + describe('when the window is focused again', () => + it('removes the .is-blurred class from the body', () => { + window.dispatchEvent(new CustomEvent('focus')); + expect(document.body.className).not.toMatch('is-blurred'); + })); + }); + + describe('resize event', () => + it('calls storeWindowDimensions', async () => { + jasmine.useRealClock(); + + spyOn(atom, 'storeWindowDimensions'); + window.dispatchEvent(new CustomEvent('resize')); + + await conditionPromise(() => atom.storeWindowDimensions.callCount > 0); + })); + + describe('window:close event', () => + it('closes the window', () => { + spyOn(atom, 'close'); + window.dispatchEvent(new CustomEvent('window:close')); + expect(atom.close).toHaveBeenCalled(); + })); + + describe('when a link is clicked', () => { + it('opens the http/https links in an external application', () => { + const { shell } = require('electron'); + spyOn(shell, 'openExternal'); + + const link = document.createElement('a'); + const linkChild = document.createElement('span'); + link.appendChild(linkChild); + link.href = 'http://github.com'; + jasmine.attachToDOM(link); + const fakeEvent = { + target: linkChild, + currentTarget: link, + preventDefault: () => {} + }; + + windowEventHandler.handleLinkClick(fakeEvent); + expect(shell.openExternal).toHaveBeenCalled(); + expect(shell.openExternal.argsForCall[0][0]).toBe('http://github.com'); + shell.openExternal.reset(); + + link.href = 'https://github.com'; + windowEventHandler.handleLinkClick(fakeEvent); + expect(shell.openExternal).toHaveBeenCalled(); + expect(shell.openExternal.argsForCall[0][0]).toBe('https://github.com'); + shell.openExternal.reset(); + + link.href = ''; + windowEventHandler.handleLinkClick(fakeEvent); + expect(shell.openExternal).not.toHaveBeenCalled(); + shell.openExternal.reset(); + + link.href = '#scroll-me'; + windowEventHandler.handleLinkClick(fakeEvent); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('opens the "atom://" links with URL handler', () => { + const uriHandler = windowEventHandler.atomEnvironment.uriHandlerRegistry; + expect(uriHandler).toBeDefined(); + spyOn(uriHandler, 'handleURI'); + + const link = document.createElement('a'); + const linkChild = document.createElement('span'); + link.appendChild(linkChild); + link.href = 'atom://github.com'; + jasmine.attachToDOM(link); + const fakeEvent = { + target: linkChild, + currentTarget: link, + preventDefault: () => {} + }; + + windowEventHandler.handleLinkClick(fakeEvent); + expect(uriHandler.handleURI).toHaveBeenCalled(); + expect(uriHandler.handleURI.argsForCall[0][0]).toBe('atom://github.com'); + }); + }); + + describe('when a form is submitted', () => + it("prevents the default so that the window's URL isn't changed", () => { + const form = document.createElement('form'); + jasmine.attachToDOM(form); + + let defaultPrevented = false; + const event = new CustomEvent('submit', { bubbles: true }); + event.preventDefault = () => { + defaultPrevented = true; + }; + form.dispatchEvent(event); + expect(defaultPrevented).toBe(true); + })); + + describe('core:focus-next and core:focus-previous', () => { + describe('when there is no currently focused element', () => + it('focuses the element with the lowest/highest tabindex', () => { + const wrapperDiv = document.createElement('div'); + wrapperDiv.innerHTML = ` +
+ + +
+ `.trim(); + const elements = wrapperDiv.firstChild; + jasmine.attachToDOM(elements); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(1); + + document.body.focus(); + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(2); + })); + + describe('when a tabindex is set on the currently focused element', () => + it('focuses the element with the next highest/lowest tabindex, skipping disabled elements', () => { + const wrapperDiv = document.createElement('div'); + wrapperDiv.innerHTML = ` +
+ + + + + + + +
+ `.trim(); + const elements = wrapperDiv.firstChild; + jasmine.attachToDOM(elements); + + elements.querySelector('[tabindex="1"]').focus(); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(2); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(3); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(5); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(7); + + elements.dispatchEvent( + new CustomEvent('core:focus-next', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(1); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(7); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(5); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(3); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(2); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(1); + + elements.dispatchEvent( + new CustomEvent('core:focus-previous', { bubbles: true }) + ); + expect(document.activeElement.tabIndex).toBe(7); + })); + }); + + describe('when keydown events occur on the document', () => + it('dispatches the event via the KeymapManager and CommandRegistry', () => { + const dispatchedCommands = []; + atom.commands.onWillDispatch(command => dispatchedCommands.push(command)); + atom.commands.add('*', { 'foo-command': () => {} }); + atom.keymaps.add('source-name', { '*': { x: 'foo-command' } }); + + const event = KeymapManager.buildKeydownEvent('x', { + target: document.createElement('div') + }); + document.dispatchEvent(event); + + expect(dispatchedCommands.length).toBe(1); + expect(dispatchedCommands[0].type).toBe('foo-command'); + })); + + describe('native key bindings', () => + it("correctly dispatches them to active elements with the '.native-key-bindings' class", () => { + const webContentsSpy = jasmine.createSpyObj('webContents', [ + 'copy', + 'paste' + ]); + spyOn(atom.applicationDelegate, 'getCurrentWindow').andReturn({ + webContents: webContentsSpy, + on: () => {} + }); + + const nativeKeyBindingsInput = document.createElement('input'); + nativeKeyBindingsInput.classList.add('native-key-bindings'); + jasmine.attachToDOM(nativeKeyBindingsInput); + nativeKeyBindingsInput.focus(); + + atom.dispatchApplicationMenuCommand('core:copy'); + atom.dispatchApplicationMenuCommand('core:paste'); + + expect(webContentsSpy.copy).toHaveBeenCalled(); + expect(webContentsSpy.paste).toHaveBeenCalled(); + + webContentsSpy.copy.reset(); + webContentsSpy.paste.reset(); + + const normalInput = document.createElement('input'); + jasmine.attachToDOM(normalInput); + normalInput.focus(); + + atom.dispatchApplicationMenuCommand('core:copy'); + atom.dispatchApplicationMenuCommand('core:paste'); + + expect(webContentsSpy.copy).not.toHaveBeenCalled(); + expect(webContentsSpy.paste).not.toHaveBeenCalled(); + })); +}); diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee deleted file mode 100644 index 376a71ab3ff..00000000000 --- a/spec/window-spec.coffee +++ /dev/null @@ -1,295 +0,0 @@ -{$, $$} = require '../src/space-pen-extensions' -path = require 'path' -fs = require 'fs-plus' -temp = require 'temp' -TextEditor = require '../src/text-editor' -WindowEventHandler = require '../src/window-event-handler' - -describe "Window", -> - [projectPath, windowEventHandler] = [] - - beforeEach -> - spyOn(atom, 'hide') - initialPath = atom.project.getPaths()[0] - spyOn(atom, 'getLoadSettings').andCallFake -> - loadSettings = atom.getLoadSettings.originalValue.call(atom) - loadSettings.initialPath = initialPath - loadSettings - atom.project.destroy() - windowEventHandler = new WindowEventHandler() - atom.deserializeEditorWindow() - projectPath = atom.project.getPaths()[0] - - afterEach -> - windowEventHandler.unsubscribe() - $(window).off 'beforeunload' - - describe "when the window is loaded", -> - it "doesn't have .is-blurred on the body tag", -> - expect($("body")).not.toHaveClass("is-blurred") - - describe "when the window is blurred", -> - beforeEach -> - $(window).triggerHandler 'blur' - - afterEach -> - $('body').removeClass('is-blurred') - - it "adds the .is-blurred class on the body", -> - expect($("body")).toHaveClass("is-blurred") - - describe "when the window is focused again", -> - it "removes the .is-blurred class from the body", -> - $(window).triggerHandler 'focus' - expect($("body")).not.toHaveClass("is-blurred") - - describe "window:close event", -> - it "closes the window", -> - spyOn(atom, 'close') - $(window).trigger 'window:close' - expect(atom.close).toHaveBeenCalled() - - describe "beforeunload event", -> - [beforeUnloadEvent] = [] - - beforeEach -> - jasmine.unspy(TextEditor.prototype, "shouldPromptToSave") - beforeUnloadEvent = $.Event(new Event('beforeunload')) - - describe "when pane items are modified", -> - it "prompts user to save and calls atom.workspace.confirmClose", -> - editor = null - spyOn(atom.workspace, 'confirmClose').andCallThrough() - spyOn(atom, "confirm").andReturn(2) - - waitsForPromise -> - atom.workspace.open("sample.js").then (o) -> editor = o - - runs -> - editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) - expect(atom.workspace.confirmClose).toHaveBeenCalled() - expect(atom.confirm).toHaveBeenCalled() - - it "prompts user to save and handler returns true if don't save", -> - editor = null - spyOn(atom, "confirm").andReturn(2) - - waitsForPromise -> - atom.workspace.open("sample.js").then (o) -> editor = o - - runs -> - editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) - expect(atom.confirm).toHaveBeenCalled() - - it "prompts user to save and handler returns false if dialog is canceled", -> - editor = null - spyOn(atom, "confirm").andReturn(1) - waitsForPromise -> - atom.workspace.open("sample.js").then (o) -> editor = o - - runs -> - editor.insertText("I look different, I feel different.") - $(window).trigger(beforeUnloadEvent) - expect(atom.confirm).toHaveBeenCalled() - - describe "when the same path is modified in multiple panes", -> - it "prompts to save the item", -> - editor = null - filePath = path.join(temp.mkdirSync('atom-file'), 'file.txt') - fs.writeFileSync(filePath, 'hello') - spyOn(atom.workspace, 'confirmClose').andCallThrough() - spyOn(atom, 'confirm').andReturn(0) - - waitsForPromise -> - atom.workspace.open(filePath).then (o) -> editor = o - - runs -> - atom.workspace.getActivePane().splitRight(copyActiveItem: true) - editor.setText('world') - $(window).trigger(beforeUnloadEvent) - expect(atom.workspace.confirmClose).toHaveBeenCalled() - expect(atom.confirm.callCount).toBe 1 - expect(fs.readFileSync(filePath, 'utf8')).toBe 'world' - - describe ".unloadEditorWindow()", -> - it "saves the serialized state of the window so it can be deserialized after reload", -> - workspaceState = atom.workspace.serialize() - syntaxState = atom.grammars.serialize() - projectState = atom.project.serialize() - - atom.unloadEditorWindow() - - expect(atom.state.workspace).toEqual workspaceState - expect(atom.state.grammars).toEqual syntaxState - expect(atom.state.project).toEqual projectState - expect(atom.saveSync).toHaveBeenCalled() - - describe ".removeEditorWindow()", -> - it "unsubscribes from all buffers", -> - waitsForPromise -> - atom.workspace.open("sample.js") - - runs -> - buffer = atom.workspace.getActivePaneItem().buffer - pane = atom.workspace.getActivePane() - pane.splitRight(copyActiveItem: true) - expect(atom.workspace.getTextEditors().length).toBe 2 - - atom.removeEditorWindow() - - expect(buffer.getSubscriptionCount()).toBe 0 - - describe "when a link is clicked", -> - it "opens the http/https links in an external application", -> - shell = require 'shell' - spyOn(shell, 'openExternal') - - $("the website").appendTo(document.body).click().remove() - expect(shell.openExternal).toHaveBeenCalled() - expect(shell.openExternal.argsForCall[0][0]).toBe "http://github.com" - - shell.openExternal.reset() - $("the website").appendTo(document.body).click().remove() - expect(shell.openExternal).toHaveBeenCalled() - expect(shell.openExternal.argsForCall[0][0]).toBe "https://github.com" - - shell.openExternal.reset() - $("the website").appendTo(document.body).click().remove() - expect(shell.openExternal).not.toHaveBeenCalled() - - shell.openExternal.reset() - $("link").appendTo(document.body).click().remove() - expect(shell.openExternal).not.toHaveBeenCalled() - - describe "when a form is submitted", -> - it "prevents the default so that the window's URL isn't changed", -> - submitSpy = jasmine.createSpy('submit') - $(document).on('submit', 'form', submitSpy) - $("
foo
").appendTo(document.body).submit().remove() - expect(submitSpy.callCount).toBe 1 - expect(submitSpy.argsForCall[0][0].isDefaultPrevented()).toBe true - - describe "core:focus-next and core:focus-previous", -> - describe "when there is no currently focused element", -> - it "focuses the element with the lowest/highest tabindex", -> - elements = $$ -> - @div => - @button tabindex: 2 - @input tabindex: 1 - - elements.attachToDom() - - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=1]:focus")).toExist() - - $(":focus").blur() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=2]:focus")).toExist() - - describe "when a tabindex is set on the currently focused element", -> - it "focuses the element with the next highest tabindex", -> - elements = $$ -> - @div => - @input tabindex: 1 - @button tabindex: 2 - @button tabindex: 5 - @input tabindex: -1 - @input tabindex: 3 - @button tabindex: 7 - - elements.attachToDom() - elements.find("[tabindex=1]").focus() - - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=2]:focus")).toExist() - - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=3]:focus")).toExist() - - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=5]:focus")).toExist() - - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=7]:focus")).toExist() - - elements.focus().trigger "core:focus-next" - expect(elements.find("[tabindex=1]:focus")).toExist() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=7]:focus")).toExist() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=5]:focus")).toExist() - - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=3]:focus")).toExist() - - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=2]:focus")).toExist() - - elements.focus().trigger "core:focus-previous" - expect(elements.find("[tabindex=1]:focus")).toExist() - - it "skips disabled elements", -> - elements = $$ -> - @div => - @input tabindex: 1 - @button tabindex: 2, disabled: 'disabled' - @input tabindex: 3 - - elements.attachToDom() - elements.find("[tabindex=1]").focus() - - elements.trigger "core:focus-next" - expect(elements.find("[tabindex=3]:focus")).toExist() - - elements.trigger "core:focus-previous" - expect(elements.find("[tabindex=1]:focus")).toExist() - - describe "the window:open-locations event", -> - beforeEach -> - spyOn(atom.workspace, 'open') - atom.project.setPaths([]) - - describe "when the opened path exists", -> - it "adds it to the project's paths", -> - pathToOpen = __filename - atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - - waitsFor -> - atom.project.getPaths().length is 1 - - runs -> - expect(atom.project.getPaths()[0]).toBe __dirname - - describe "when the opened path does not exist but its parent directory does", -> - it "adds the parent directory to the project paths", -> - pathToOpen = path.join(__dirname, 'this-path-does-not-exist.txt') - atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - - waitsFor -> - atom.project.getPaths().length is 1 - - runs -> - expect(atom.project.getPaths()[0]).toBe __dirname - - describe "when the opened path is a file", -> - it "opens it in the workspace", -> - pathToOpen = __filename - atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - - waitsFor -> - atom.workspace.open.callCount is 1 - - runs -> - expect(atom.workspace.open.mostRecentCall.args[0]).toBe __filename - - - describe "when the opened path is a directory", -> - it "does not open it in the workspace", -> - pathToOpen = __dirname - atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - expect(atom.workspace.open.callCount).toBe 0 diff --git a/spec/workspace-center-spec.js b/spec/workspace-center-spec.js new file mode 100644 index 00000000000..eeed80b6631 --- /dev/null +++ b/spec/workspace-center-spec.js @@ -0,0 +1,34 @@ +/** @babel */ + +const TextEditor = require('../src/text-editor'); + +describe('WorkspaceCenter', () => { + describe('.observeTextEditors()', () => { + it('invokes the observer with current and future text editors', () => { + const workspaceCenter = atom.workspace.getCenter(); + const pane = workspaceCenter.getActivePane(); + const observed = []; + + const editorAddedBeforeRegisteringObserver = new TextEditor(); + const nonEditorItemAddedBeforeRegisteringObserver = document.createElement( + 'div' + ); + pane.activateItem(editorAddedBeforeRegisteringObserver); + pane.activateItem(nonEditorItemAddedBeforeRegisteringObserver); + + workspaceCenter.observeTextEditors(editor => observed.push(editor)); + + const editorAddedAfterRegisteringObserver = new TextEditor(); + const nonEditorItemAddedAfterRegisteringObserver = document.createElement( + 'div' + ); + pane.activateItem(editorAddedAfterRegisteringObserver); + pane.activateItem(nonEditorItemAddedAfterRegisteringObserver); + + expect(observed).toEqual([ + editorAddedBeforeRegisteringObserver, + editorAddedAfterRegisteringObserver + ]); + }); + }); +}); diff --git a/spec/workspace-element-spec.coffee b/spec/workspace-element-spec.coffee deleted file mode 100644 index 1771b2363fa..00000000000 --- a/spec/workspace-element-spec.coffee +++ /dev/null @@ -1,44 +0,0 @@ -ipc = require 'ipc' -path = require 'path' -temp = require('temp').track() - -describe "WorkspaceElement", -> - workspaceElement = null - - beforeEach -> - workspaceElement = atom.views.getView(atom.workspace) - - describe "the 'window:run-package-specs' command", -> - it "runs the package specs for the active item's project path, or the first project path", -> - spyOn(ipc, 'send') - - # No project paths. Don't try to run specs. - atom.commands.dispatch(workspaceElement, "window:run-package-specs") - expect(ipc.send).not.toHaveBeenCalledWith("run-package-specs") - - projectPaths = [temp.mkdirSync("dir1-"), temp.mkdirSync("dir2-")] - atom.project.setPaths(projectPaths) - - # No active item. Use first project directory. - atom.commands.dispatch(workspaceElement, "window:run-package-specs") - expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) - ipc.send.reset() - - # Active item doesn't implement ::getPath(). Use first project directory. - item = document.createElement("div") - atom.workspace.getActivePane().activateItem(item) - atom.commands.dispatch(workspaceElement, "window:run-package-specs") - expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) - ipc.send.reset() - - # Active item has no path. Use first project directory. - item.getPath = -> null - atom.commands.dispatch(workspaceElement, "window:run-package-specs") - expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) - ipc.send.reset() - - # Active item has path. Use project path for item path. - item.getPath = -> path.join(projectPaths[1], "a-file.txt") - atom.commands.dispatch(workspaceElement, "window:run-package-specs") - expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[1], "spec")) - ipc.send.reset() diff --git a/spec/workspace-element-spec.js b/spec/workspace-element-spec.js new file mode 100644 index 00000000000..001f31936c1 --- /dev/null +++ b/spec/workspace-element-spec.js @@ -0,0 +1,1104 @@ +/** @babel */ + +const { ipcRenderer } = require('electron'); +const etch = require('etch'); +const path = require('path'); +const temp = require('temp').track(); +const { Disposable } = require('event-kit'); + +const getNextUpdatePromise = () => etch.getScheduler().nextUpdatePromise; + +describe('WorkspaceElement', () => { + afterEach(() => { + try { + temp.cleanupSync(); + } catch (e) { + // Do nothing + } + }); + + describe('when the workspace element is focused', () => { + it('transfers focus to the active pane', () => { + const workspaceElement = atom.workspace.getElement(); + jasmine.attachToDOM(workspaceElement); + const activePaneElement = atom.workspace.getActivePane().getElement(); + document.body.focus(); + expect(document.activeElement).not.toBe(activePaneElement); + workspaceElement.focus(); + expect(document.activeElement).toBe(activePaneElement); + }); + }); + + describe('when the active pane of an inactive pane container is focused', () => { + it('changes the active pane container', () => { + const dock = atom.workspace.getLeftDock(); + dock.show(); + jasmine.attachToDOM(atom.workspace.getElement()); + expect(atom.workspace.getActivePaneContainer()).toBe( + atom.workspace.getCenter() + ); + dock + .getActivePane() + .getElement() + .focus(); + expect(atom.workspace.getActivePaneContainer()).toBe(dock); + }); + }); + + describe('finding the nearest visible pane in a specific direction', () => { + let nearestPaneElement, + pane1, + pane2, + pane3, + pane4, + pane5, + pane6, + pane7, + pane8, + leftDockPane, + rightDockPane, + bottomDockPane, + workspace, + workspaceElement; + + beforeEach(function() { + atom.config.set('core.destroyEmptyPanes', false); + expect(document.hasFocus()).toBe( + true, + 'Document needs to be focused to run this test' + ); + + workspace = atom.workspace; + + // Set up a workspace center with a grid of 9 panes, in the following + // arrangement, where the numbers correspond to the variable names below. + // + // ------- + // |1|2|3| + // ------- + // |4|5|6| + // ------- + // |7|8|9| + // ------- + + const container = workspace.getActivePaneContainer(); + expect(container.getLocation()).toEqual('center'); + expect(container.getPanes().length).toEqual(1); + + pane1 = container.getActivePane(); + pane4 = pane1.splitDown(); + pane7 = pane4.splitDown(); + + pane2 = pane1.splitRight(); + pane3 = pane2.splitRight(); + + pane5 = pane4.splitRight(); + pane6 = pane5.splitRight(); + + pane8 = pane7.splitRight(); + pane8.splitRight(); + + const leftDock = workspace.getLeftDock(); + const rightDock = workspace.getRightDock(); + const bottomDock = workspace.getBottomDock(); + + expect(leftDock.isVisible()).toBe(false); + expect(rightDock.isVisible()).toBe(false); + expect(bottomDock.isVisible()).toBe(false); + + expect(leftDock.getPanes().length).toBe(1); + expect(rightDock.getPanes().length).toBe(1); + expect(bottomDock.getPanes().length).toBe(1); + + leftDockPane = leftDock.getPanes()[0]; + rightDockPane = rightDock.getPanes()[0]; + bottomDockPane = bottomDock.getPanes()[0]; + + workspaceElement = atom.workspace.getElement(); + workspaceElement.style.height = '400px'; + workspaceElement.style.width = '400px'; + jasmine.attachToDOM(workspaceElement); + }); + + describe('finding the nearest pane above', () => { + describe('when there are multiple rows above the pane', () => { + it('returns the pane in the adjacent row above', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'above', + pane8 + ); + expect(nearestPaneElement).toBe(pane5.getElement()); + }); + }); + + describe('when there are no rows above the pane', () => { + it('returns null', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'above', + pane2 + ); + expect(nearestPaneElement).toBeUndefined(); // TODO Expect toBeNull() + }); + }); + + describe('when the bottom dock contains the pane', () => { + it('returns the pane in the adjacent row above', () => { + workspace.getBottomDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'above', + bottomDockPane + ); + expect(nearestPaneElement).toBe(pane7.getElement()); + }); + }); + }); + + describe('finding the nearest pane below', () => { + describe('when there are multiple rows below the pane', () => { + it('returns the pane in the adjacent row below', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'below', + pane2 + ); + expect(nearestPaneElement).toBe(pane5.getElement()); + }); + }); + + describe('when there are no rows below the pane', () => { + it('returns null', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'below', + pane8 + ); + expect(nearestPaneElement).toBeUndefined(); // TODO Expect toBeNull() + }); + }); + + describe('when the bottom dock is visible', () => { + describe("when the workspace center's bottommost row contains the pane", () => { + it("returns the pane in the bottom dock's adjacent row below", () => { + workspace.getBottomDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'below', + pane8 + ); + expect(nearestPaneElement).toBe(bottomDockPane.getElement()); + }); + }); + }); + }); + + describe('finding the nearest pane to the left', () => { + describe('when there are multiple columns to the left of the pane', () => { + it('returns the pane in the adjacent column to the left', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'left', + pane6 + ); + expect(nearestPaneElement).toBe(pane5.getElement()); + }); + }); + + describe('when there are no columns to the left of the pane', () => { + it('returns null', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'left', + pane4 + ); + expect(nearestPaneElement).toBeUndefined(); // TODO Expect toBeNull() + }); + }); + + describe('when the right dock contains the pane', () => { + it('returns the pane in the adjacent column to the left', () => { + workspace.getRightDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'left', + rightDockPane + ); + expect(nearestPaneElement).toBe(pane3.getElement()); + }); + }); + + describe('when the left dock is visible', () => { + describe("when the workspace center's leftmost column contains the pane", () => { + it("returns the pane in the left dock's adjacent column to the left", () => { + workspace.getLeftDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'left', + pane4 + ); + expect(nearestPaneElement).toBe(leftDockPane.getElement()); + }); + }); + + describe('when the bottom dock contains the pane', () => { + it("returns the pane in the left dock's adjacent column to the left", () => { + workspace.getLeftDock().show(); + workspace.getBottomDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'left', + bottomDockPane + ); + expect(nearestPaneElement).toBe(leftDockPane.getElement()); + }); + }); + }); + }); + + describe('finding the nearest pane to the right', () => { + describe('when there are multiple columns to the right of the pane', () => { + it('returns the pane in the adjacent column to the right', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'right', + pane4 + ); + expect(nearestPaneElement).toBe(pane5.getElement()); + }); + }); + + describe('when there are no columns to the right of the pane', () => { + it('returns null', () => { + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'right', + pane6 + ); + expect(nearestPaneElement).toBeUndefined(); // TODO Expect toBeNull() + }); + }); + + describe('when the left dock contains the pane', () => { + it('returns the pane in the adjacent column to the right', () => { + workspace.getLeftDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'right', + leftDockPane + ); + expect(nearestPaneElement).toBe(pane1.getElement()); + }); + }); + + describe('when the right dock is visible', () => { + describe("when the workspace center's rightmost column contains the pane", () => { + it("returns the pane in the right dock's adjacent column to the right", () => { + workspace.getRightDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'right', + pane6 + ); + expect(nearestPaneElement).toBe(rightDockPane.getElement()); + }); + }); + + describe('when the bottom dock contains the pane', () => { + it("returns the pane in the right dock's adjacent column to the right", () => { + workspace.getRightDock().show(); + workspace.getBottomDock().show(); + nearestPaneElement = workspaceElement.nearestVisiblePaneInDirection( + 'right', + bottomDockPane + ); + expect(nearestPaneElement).toBe(rightDockPane.getElement()); + }); + }); + }); + }); + }); + + describe('changing focus, copying, and moving items directionally between panes', function() { + let workspace, workspaceElement, startingPane; + + beforeEach(function() { + atom.config.set('core.destroyEmptyPanes', false); + expect(document.hasFocus()).toBe( + true, + 'Document needs to be focused to run this test' + ); + + workspace = atom.workspace; + expect(workspace.getLeftDock().isVisible()).toBe(false); + expect(workspace.getRightDock().isVisible()).toBe(false); + expect(workspace.getBottomDock().isVisible()).toBe(false); + + const panes = workspace.getCenter().getPanes(); + expect(panes.length).toEqual(1); + startingPane = panes[0]; + + workspaceElement = atom.workspace.getElement(); + workspaceElement.style.height = '400px'; + workspaceElement.style.width = '400px'; + jasmine.attachToDOM(workspaceElement); + }); + + describe('::focusPaneViewAbove()', function() { + describe('when there is a row above the focused pane', () => + it('focuses up to the adjacent row', function() { + const paneAbove = startingPane.splitUp(); + startingPane.activate(); + workspaceElement.focusPaneViewAbove(); + expect(document.activeElement).toBe(paneAbove.getElement()); + })); + + describe('when there are no rows above the focused pane', () => + it('keeps the current pane focused', function() { + startingPane.activate(); + workspaceElement.focusPaneViewAbove(); + expect(document.activeElement).toBe(startingPane.getElement()); + })); + }); + + describe('::focusPaneViewBelow()', function() { + describe('when there is a row below the focused pane', () => + it('focuses down to the adjacent row', function() { + const paneBelow = startingPane.splitDown(); + startingPane.activate(); + workspaceElement.focusPaneViewBelow(); + expect(document.activeElement).toBe(paneBelow.getElement()); + })); + + describe('when there are no rows below the focused pane', () => + it('keeps the current pane focused', function() { + startingPane.activate(); + workspaceElement.focusPaneViewBelow(); + expect(document.activeElement).toBe(startingPane.getElement()); + })); + }); + + describe('::focusPaneViewOnLeft()', function() { + describe('when there is a column to the left of the focused pane', () => + it('focuses left to the adjacent column', function() { + const paneOnLeft = startingPane.splitLeft(); + startingPane.activate(); + workspaceElement.focusPaneViewOnLeft(); + expect(document.activeElement).toBe(paneOnLeft.getElement()); + })); + + describe('when there are no columns to the left of the focused pane', () => + it('keeps the current pane focused', function() { + startingPane.activate(); + workspaceElement.focusPaneViewOnLeft(); + expect(document.activeElement).toBe(startingPane.getElement()); + })); + }); + + describe('::focusPaneViewOnRight()', function() { + describe('when there is a column to the right of the focused pane', () => + it('focuses right to the adjacent column', function() { + const paneOnRight = startingPane.splitRight(); + startingPane.activate(); + workspaceElement.focusPaneViewOnRight(); + expect(document.activeElement).toBe(paneOnRight.getElement()); + })); + + describe('when there are no columns to the right of the focused pane', () => + it('keeps the current pane focused', function() { + startingPane.activate(); + workspaceElement.focusPaneViewOnRight(); + expect(document.activeElement).toBe(startingPane.getElement()); + })); + }); + + describe('::moveActiveItemToPaneAbove(keepOriginal)', function() { + describe('when there is a row above the focused pane', () => + it('moves the active item up to the adjacent row', function() { + const item = document.createElement('div'); + const paneAbove = startingPane.splitUp(); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneAbove(); + expect(workspace.paneForItem(item)).toBe(paneAbove); + expect(paneAbove.getActiveItem()).toBe(item); + })); + + describe('when there are no rows above the focused pane', () => + it('keeps the active pane focused', function() { + const item = document.createElement('div'); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneAbove(); + expect(workspace.paneForItem(item)).toBe(startingPane); + })); + + describe('when `keepOriginal: true` is passed in the params', () => + it('keeps the item and adds a copy of it to the adjacent pane', function() { + const itemA = document.createElement('div'); + const itemB = document.createElement('div'); + itemA.copy = () => itemB; + const paneAbove = startingPane.splitUp(); + startingPane.activate(); + startingPane.activateItem(itemA); + workspaceElement.moveActiveItemToPaneAbove({ keepOriginal: true }); + expect(workspace.paneForItem(itemA)).toBe(startingPane); + expect(paneAbove.getActiveItem()).toBe(itemB); + })); + }); + + describe('::moveActiveItemToPaneBelow(keepOriginal)', function() { + describe('when there is a row below the focused pane', () => + it('moves the active item down to the adjacent row', function() { + const item = document.createElement('div'); + const paneBelow = startingPane.splitDown(); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneBelow(); + expect(workspace.paneForItem(item)).toBe(paneBelow); + expect(paneBelow.getActiveItem()).toBe(item); + })); + + describe('when there are no rows below the focused pane', () => + it('keeps the active item in the focused pane', function() { + const item = document.createElement('div'); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneBelow(); + expect(workspace.paneForItem(item)).toBe(startingPane); + })); + + describe('when `keepOriginal: true` is passed in the params', () => + it('keeps the item and adds a copy of it to the adjacent pane', function() { + const itemA = document.createElement('div'); + const itemB = document.createElement('div'); + itemA.copy = () => itemB; + const paneBelow = startingPane.splitDown(); + startingPane.activate(); + startingPane.activateItem(itemA); + workspaceElement.moveActiveItemToPaneBelow({ keepOriginal: true }); + expect(workspace.paneForItem(itemA)).toBe(startingPane); + expect(paneBelow.getActiveItem()).toBe(itemB); + })); + }); + + describe('::moveActiveItemToPaneOnLeft(keepOriginal)', function() { + describe('when there is a column to the left of the focused pane', () => + it('moves the active item left to the adjacent column', function() { + const item = document.createElement('div'); + const paneOnLeft = startingPane.splitLeft(); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneOnLeft(); + expect(workspace.paneForItem(item)).toBe(paneOnLeft); + expect(paneOnLeft.getActiveItem()).toBe(item); + })); + + describe('when there are no columns to the left of the focused pane', () => + it('keeps the active item in the focused pane', function() { + const item = document.createElement('div'); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneOnLeft(); + expect(workspace.paneForItem(item)).toBe(startingPane); + })); + + describe('when `keepOriginal: true` is passed in the params', () => + it('keeps the item and adds a copy of it to the adjacent pane', function() { + const itemA = document.createElement('div'); + const itemB = document.createElement('div'); + itemA.copy = () => itemB; + const paneOnLeft = startingPane.splitLeft(); + startingPane.activate(); + startingPane.activateItem(itemA); + workspaceElement.moveActiveItemToPaneOnLeft({ keepOriginal: true }); + expect(workspace.paneForItem(itemA)).toBe(startingPane); + expect(paneOnLeft.getActiveItem()).toBe(itemB); + })); + }); + + describe('::moveActiveItemToPaneOnRight(keepOriginal)', function() { + describe('when there is a column to the right of the focused pane', () => + it('moves the active item right to the adjacent column', function() { + const item = document.createElement('div'); + const paneOnRight = startingPane.splitRight(); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneOnRight(); + expect(workspace.paneForItem(item)).toBe(paneOnRight); + expect(paneOnRight.getActiveItem()).toBe(item); + })); + + describe('when there are no columns to the right of the focused pane', () => + it('keeps the active item in the focused pane', function() { + const item = document.createElement('div'); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToPaneOnRight(); + expect(workspace.paneForItem(item)).toBe(startingPane); + })); + + describe('when `keepOriginal: true` is passed in the params', () => + it('keeps the item and adds a copy of it to the adjacent pane', function() { + const itemA = document.createElement('div'); + const itemB = document.createElement('div'); + itemA.copy = () => itemB; + const paneOnRight = startingPane.splitRight(); + startingPane.activate(); + startingPane.activateItem(itemA); + workspaceElement.moveActiveItemToPaneOnRight({ keepOriginal: true }); + expect(workspace.paneForItem(itemA)).toBe(startingPane); + expect(paneOnRight.getActiveItem()).toBe(itemB); + })); + }); + + describe('::moveActiveItemToNearestPaneInDirection(direction, params)', () => { + describe('when the item is not allowed in nearest pane in the given direction', () => { + it('does not move or copy the active item', function() { + const item = { + element: document.createElement('div'), + getAllowedLocations: () => ['left', 'right'] + }; + + workspace.getBottomDock().show(); + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.moveActiveItemToNearestPaneInDirection('below', { + keepOriginal: false + }); + expect(workspace.paneForItem(item)).toBe(startingPane); + + workspaceElement.moveActiveItemToNearestPaneInDirection('below', { + keepOriginal: true + }); + expect(workspace.paneForItem(item)).toBe(startingPane); + }); + }); + + describe("when the item doesn't implement a `copy` function", () => { + it('does not copy the active item', function() { + const item = document.createElement('div'); + const paneBelow = startingPane.splitDown(); + expect(paneBelow.getItems().length).toEqual(0); + + startingPane.activate(); + startingPane.activateItem(item); + workspaceElement.focusPaneViewAbove(); + workspaceElement.moveActiveItemToNearestPaneInDirection('below', { + keepOriginal: true + }); + expect(workspace.paneForItem(item)).toBe(startingPane); + expect(paneBelow.getItems().length).toEqual(0); + }); + }); + }); + }); + + describe('mousing over docks', () => { + let workspaceElement; + let originalTimeout = jasmine.getEnv().defaultTimeoutInterval; + + beforeEach(() => { + workspaceElement = atom.workspace.getElement(); + workspaceElement.style.width = '600px'; + workspaceElement.style.height = '300px'; + jasmine.attachToDOM(workspaceElement); + + // To isolate this test from unintended events happening on the host machine, + // we remove any listener that could cause interferences. + window.removeEventListener( + 'mousemove', + workspaceElement.handleEdgesMouseMove + ); + workspaceElement.htmlElement.removeEventListener( + 'mouseleave', + workspaceElement.handleCenterLeave + ); + + jasmine.getEnv().defaultTimeoutInterval = 10000; + }); + + afterEach(() => { + jasmine.getEnv().defaultTimeoutInterval = originalTimeout; + + window.addEventListener( + 'mousemove', + workspaceElement.handleEdgesMouseMove + ); + workspaceElement.htmlElement.addEventListener( + 'mouseleave', + workspaceElement.handleCenterLeave + ); + }); + + it('shows the toggle button when the dock is open', async () => { + await Promise.all([ + atom.workspace.open({ + element: document.createElement('div'), + getDefaultLocation() { + return 'left'; + }, + getPreferredWidth() { + return 150; + } + }), + atom.workspace.open({ + element: document.createElement('div'), + getDefaultLocation() { + return 'right'; + }, + getPreferredWidth() { + return 150; + } + }), + atom.workspace.open({ + element: document.createElement('div'), + getDefaultLocation() { + return 'bottom'; + }, + getPreferredHeight() { + return 100; + } + }) + ]); + + const leftDock = atom.workspace.getLeftDock(); + const rightDock = atom.workspace.getRightDock(); + const bottomDock = atom.workspace.getBottomDock(); + + expect(leftDock.isVisible()).toBe(true); + expect(rightDock.isVisible()).toBe(true); + expect(bottomDock.isVisible()).toBe(true); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // --- Right Dock --- + + // Mouse over where the toggle button would be if the dock were hovered + moveMouse({ clientX: 440, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // Mouse over the dock + moveMouse({ clientX: 460, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonVisible(rightDock, 'icon-chevron-right'); + expectToggleButtonHidden(bottomDock); + + // Mouse over the toggle button + moveMouse({ clientX: 440, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonVisible(rightDock, 'icon-chevron-right'); + expectToggleButtonHidden(bottomDock); + + // Click the toggle button + rightDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(rightDock.isVisible()).toBe(false); + expectToggleButtonHidden(rightDock); + + // Mouse to edge of the window + moveMouse({ clientX: 575, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(rightDock); + moveMouse({ clientX: 598, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonVisible(rightDock, 'icon-chevron-left'); + + // Click the toggle button again + rightDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(rightDock.isVisible()).toBe(true); + expectToggleButtonVisible(rightDock, 'icon-chevron-right'); + + // --- Left Dock --- + + // Mouse over where the toggle button would be if the dock were hovered + moveMouse({ clientX: 160, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // Mouse over the dock + moveMouse({ clientX: 140, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonVisible(leftDock, 'icon-chevron-left'); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // Mouse over the toggle button + moveMouse({ clientX: 160, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonVisible(leftDock, 'icon-chevron-left'); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // Click the toggle button + leftDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(leftDock.isVisible()).toBe(false); + expectToggleButtonHidden(leftDock); + + // Mouse to edge of the window + moveMouse({ clientX: 25, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + moveMouse({ clientX: 2, clientY: 150 }); + await getNextUpdatePromise(); + expectToggleButtonVisible(leftDock, 'icon-chevron-right'); + + // Click the toggle button again + leftDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(leftDock.isVisible()).toBe(true); + expectToggleButtonVisible(leftDock, 'icon-chevron-left'); + + // --- Bottom Dock --- + + // Mouse over where the toggle button would be if the dock were hovered + moveMouse({ clientX: 300, clientY: 190 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonHidden(bottomDock); + + // Mouse over the dock + moveMouse({ clientX: 300, clientY: 210 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonVisible(bottomDock, 'icon-chevron-down'); + + // Mouse over the toggle button + moveMouse({ clientX: 300, clientY: 195 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + expectToggleButtonHidden(rightDock); + expectToggleButtonVisible(bottomDock, 'icon-chevron-down'); + + // Click the toggle button + bottomDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(bottomDock.isVisible()).toBe(false); + expectToggleButtonHidden(bottomDock); + + // Mouse to edge of the window + moveMouse({ clientX: 300, clientY: 290 }); + await getNextUpdatePromise(); + expectToggleButtonHidden(leftDock); + moveMouse({ clientX: 300, clientY: 299 }); + await getNextUpdatePromise(); + expectToggleButtonVisible(bottomDock, 'icon-chevron-up'); + + // Click the toggle button again + bottomDock.refs.toggleButton.refs.innerElement.click(); + await getNextUpdatePromise(); + expect(bottomDock.isVisible()).toBe(true); + expectToggleButtonVisible(bottomDock, 'icon-chevron-down'); + }); + + function moveMouse(coordinates) { + // Simulate a mouse move event by calling the method that handles that event. + workspaceElement.updateHoveredDock({ + x: coordinates.clientX, + y: coordinates.clientY + }); + advanceClock(100); + } + + function expectToggleButtonHidden(dock) { + expect(dock.refs.toggleButton.element).not.toHaveClass( + 'atom-dock-toggle-button-visible' + ); + } + + function expectToggleButtonVisible(dock, iconClass) { + expect(dock.refs.toggleButton.element).toHaveClass( + 'atom-dock-toggle-button-visible' + ); + expect(dock.refs.toggleButton.refs.iconElement).toHaveClass(iconClass); + } + }); + + describe('the scrollbar visibility class', () => { + it('has a class based on the style of the scrollbar', () => { + let observeCallback; + const scrollbarStyle = require('scrollbar-style'); + spyOn(scrollbarStyle, 'observePreferredScrollbarStyle').andCallFake( + cb => { + observeCallback = cb; + return new Disposable(() => {}); + } + ); + + const workspaceElement = atom.workspace.getElement(); + observeCallback('legacy'); + expect(workspaceElement.className).toMatch('scrollbars-visible-always'); + + observeCallback('overlay'); + expect(workspaceElement).toHaveClass('scrollbars-visible-when-scrolling'); + }); + }); + + describe('editor font styling', () => { + let editor, editorElement, workspaceElement; + + beforeEach(async () => { + await atom.workspace.open('sample.js'); + + workspaceElement = atom.workspace.getElement(); + jasmine.attachToDOM(workspaceElement); + editor = atom.workspace.getActiveTextEditor(); + editorElement = editor.getElement(); + }); + + it("updates the font-size based on the 'editor.fontSize' config value", async () => { + const initialCharWidth = editor.getDefaultCharWidth(); + expect(getComputedStyle(editorElement).fontSize).toBe( + atom.config.get('editor.fontSize') + 'px' + ); + + atom.config.set( + 'editor.fontSize', + atom.config.get('editor.fontSize') + 5 + ); + await editorElement.component.getNextUpdatePromise(); + expect(getComputedStyle(editorElement).fontSize).toBe( + atom.config.get('editor.fontSize') + 'px' + ); + expect(editor.getDefaultCharWidth()).toBeGreaterThan(initialCharWidth); + }); + + it("updates the font-family based on the 'editor.fontFamily' config value", async () => { + const initialCharWidth = editor.getDefaultCharWidth(); + let fontFamily = atom.config.get('editor.fontFamily'); + expect(getComputedStyle(editorElement).fontFamily).toBe(fontFamily); + + atom.config.set('editor.fontFamily', 'sans-serif'); + fontFamily = atom.config.get('editor.fontFamily'); + await editorElement.component.getNextUpdatePromise(); + expect(getComputedStyle(editorElement).fontFamily).toBe(fontFamily); + expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth); + }); + + it("updates the line-height based on the 'editor.lineHeight' config value", async () => { + const initialLineHeight = editor.getLineHeightInPixels(); + atom.config.set('editor.lineHeight', '30px'); + await editorElement.component.getNextUpdatePromise(); + expect(getComputedStyle(editorElement).lineHeight).toBe( + atom.config.get('editor.lineHeight') + ); + expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeight); + }); + + it('increases or decreases the font size when a ctrl-mousewheel event occurs', () => { + atom.config.set('editor.zoomFontWhenCtrlScrolling', true); + atom.config.set('editor.fontSize', 12); + + // Zoom out + editorElement.querySelector('span').dispatchEvent( + new WheelEvent('mousewheel', { + wheelDeltaY: -10, + ctrlKey: true + }) + ); + expect(atom.config.get('editor.fontSize')).toBe(11); + + // Zoom in + editorElement.querySelector('span').dispatchEvent( + new WheelEvent('mousewheel', { + wheelDeltaY: 10, + ctrlKey: true + }) + ); + expect(atom.config.get('editor.fontSize')).toBe(12); + + // Not on an atom-text-editor + workspaceElement.dispatchEvent( + new WheelEvent('mousewheel', { + wheelDeltaY: 10, + ctrlKey: true + }) + ); + expect(atom.config.get('editor.fontSize')).toBe(12); + + // No ctrl key + editorElement.querySelector('span').dispatchEvent( + new WheelEvent('mousewheel', { + wheelDeltaY: 10 + }) + ); + expect(atom.config.get('editor.fontSize')).toBe(12); + + atom.config.set('editor.zoomFontWhenCtrlScrolling', false); + editorElement.querySelector('span').dispatchEvent( + new WheelEvent('mousewheel', { + wheelDeltaY: 10, + ctrlKey: true + }) + ); + expect(atom.config.get('editor.fontSize')).toBe(12); + }); + }); + + describe('panel containers', () => { + it('inserts panel container elements in the correct places in the DOM', () => { + const workspaceElement = atom.workspace.getElement(); + + const leftContainer = workspaceElement.querySelector( + 'atom-panel-container.left' + ); + const rightContainer = workspaceElement.querySelector( + 'atom-panel-container.right' + ); + expect(leftContainer.nextSibling).toBe(workspaceElement.verticalAxis); + expect(rightContainer.previousSibling).toBe( + workspaceElement.verticalAxis + ); + + const topContainer = workspaceElement.querySelector( + 'atom-panel-container.top' + ); + const bottomContainer = workspaceElement.querySelector( + 'atom-panel-container.bottom' + ); + expect(topContainer.nextSibling).toBe(workspaceElement.paneContainer); + expect(bottomContainer.previousSibling).toBe( + workspaceElement.paneContainer + ); + + const headerContainer = workspaceElement.querySelector( + 'atom-panel-container.header' + ); + const footerContainer = workspaceElement.querySelector( + 'atom-panel-container.footer' + ); + expect(headerContainer.nextSibling).toBe(workspaceElement.horizontalAxis); + expect(footerContainer.previousSibling).toBe( + workspaceElement.horizontalAxis + ); + + const modalContainer = workspaceElement.querySelector( + 'atom-panel-container.modal' + ); + expect(modalContainer.parentNode).toBe(workspaceElement); + }); + + it('stretches header/footer panels to the workspace width', () => { + const workspaceElement = atom.workspace.getElement(); + jasmine.attachToDOM(workspaceElement); + expect(workspaceElement.offsetWidth).toBeGreaterThan(0); + + const headerItem = document.createElement('div'); + atom.workspace.addHeaderPanel({ item: headerItem }); + expect(headerItem.offsetWidth).toEqual(workspaceElement.offsetWidth); + + const footerItem = document.createElement('div'); + atom.workspace.addFooterPanel({ item: footerItem }); + expect(footerItem.offsetWidth).toEqual(workspaceElement.offsetWidth); + }); + + it('shrinks horizontal axis according to header/footer panels height', () => { + const workspaceElement = atom.workspace.getElement(); + workspaceElement.style.height = '100px'; + const horizontalAxisElement = workspaceElement.querySelector( + 'atom-workspace-axis.horizontal' + ); + jasmine.attachToDOM(workspaceElement); + + const originalHorizontalAxisHeight = horizontalAxisElement.offsetHeight; + expect(workspaceElement.offsetHeight).toBeGreaterThan(0); + expect(originalHorizontalAxisHeight).toBeGreaterThan(0); + + const headerItem = document.createElement('div'); + headerItem.style.height = '10px'; + atom.workspace.addHeaderPanel({ item: headerItem }); + expect(headerItem.offsetHeight).toBeGreaterThan(0); + + const footerItem = document.createElement('div'); + footerItem.style.height = '15px'; + atom.workspace.addFooterPanel({ item: footerItem }); + expect(footerItem.offsetHeight).toBeGreaterThan(0); + + expect(horizontalAxisElement.offsetHeight).toEqual( + originalHorizontalAxisHeight - + headerItem.offsetHeight - + footerItem.offsetHeight + ); + }); + }); + + describe("the 'window:toggle-invisibles' command", () => { + it('shows/hides invisibles in all open and future editors', () => { + const workspaceElement = atom.workspace.getElement(); + expect(atom.config.get('editor.showInvisibles')).toBe(false); + atom.commands.dispatch(workspaceElement, 'window:toggle-invisibles'); + expect(atom.config.get('editor.showInvisibles')).toBe(true); + atom.commands.dispatch(workspaceElement, 'window:toggle-invisibles'); + expect(atom.config.get('editor.showInvisibles')).toBe(false); + }); + }); + + describe("the 'window:run-package-specs' command", () => { + it("runs the package specs for the active item's project path, or the first project path", () => { + const workspaceElement = atom.workspace.getElement(); + spyOn(ipcRenderer, 'send'); + + // No project paths. Don't try to run specs. + atom.commands.dispatch(workspaceElement, 'window:run-package-specs'); + expect(ipcRenderer.send).not.toHaveBeenCalledWith('run-package-specs'); + + const projectPaths = [temp.mkdirSync('dir1-'), temp.mkdirSync('dir2-')]; + atom.project.setPaths(projectPaths); + + // No active item. Use first project directory. + atom.commands.dispatch(workspaceElement, 'window:run-package-specs'); + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'run-package-specs', + path.join(projectPaths[0], 'spec'), + {} + ); + ipcRenderer.send.reset(); + + // Active item doesn't implement ::getPath(). Use first project directory. + const item = document.createElement('div'); + atom.workspace.getActivePane().activateItem(item); + atom.commands.dispatch(workspaceElement, 'window:run-package-specs'); + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'run-package-specs', + path.join(projectPaths[0], 'spec'), + {} + ); + ipcRenderer.send.reset(); + + // Active item has no path. Use first project directory. + item.getPath = () => null; + atom.commands.dispatch(workspaceElement, 'window:run-package-specs'); + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'run-package-specs', + path.join(projectPaths[0], 'spec'), + {} + ); + ipcRenderer.send.reset(); + + // Active item has path. Use project path for item path. + item.getPath = () => path.join(projectPaths[1], 'a-file.txt'); + atom.commands.dispatch(workspaceElement, 'window:run-package-specs'); + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'run-package-specs', + path.join(projectPaths[1], 'spec'), + {} + ); + ipcRenderer.send.reset(); + }); + + it('passes additional options to the spec window', () => { + const workspaceElement = atom.workspace.getElement(); + spyOn(ipcRenderer, 'send'); + + const projectPath = temp.mkdirSync('dir1-'); + atom.project.setPaths([projectPath]); + workspaceElement.runPackageSpecs({ + env: { ATOM_GITHUB_BABEL_ENV: 'coverage' } + }); + + expect(ipcRenderer.send).toHaveBeenCalledWith( + 'run-package-specs', + path.join(projectPath, 'spec'), + { env: { ATOM_GITHUB_BABEL_ENV: 'coverage' } } + ); + }); + }); +}); diff --git a/spec/workspace-spec.coffee b/spec/workspace-spec.coffee deleted file mode 100644 index 0b32ff31f0f..00000000000 --- a/spec/workspace-spec.coffee +++ /dev/null @@ -1,1123 +0,0 @@ -path = require 'path' -temp = require 'temp' -Workspace = require '../src/workspace' -Pane = require '../src/pane' -{View} = require '../src/space-pen-extensions' -platform = require './spec-helper-platform' -_ = require 'underscore-plus' -fstream = require 'fstream' -fs = require 'fs-plus' -Grim = require 'grim' - -describe "Workspace", -> - workspace = null - - beforeEach -> - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')]) - atom.workspace = workspace = new Workspace - - describe "::open(uri, options)", -> - openEvents = null - - beforeEach -> - openEvents = [] - workspace.onDidOpen (event) -> openEvents.push(event) - spyOn(workspace.getActivePane(), 'activate').andCallThrough() - - describe "when the 'searchAllPanes' option is false (default)", -> - describe "when called without a uri", -> - it "adds and activates an empty editor on the active pane", -> - [editor1, editor2] = [] - - waitsForPromise -> - workspace.open().then (editor) -> editor1 = editor - - runs -> - expect(editor1.getPath()).toBeUndefined() - expect(workspace.getActivePane().items).toEqual [editor1] - expect(workspace.getActivePaneItem()).toBe editor1 - expect(workspace.getActivePane().activate).toHaveBeenCalled() - expect(openEvents).toEqual [{uri: undefined, pane: workspace.getActivePane(), item: editor1, index: 0}] - openEvents = [] - - waitsForPromise -> - workspace.open().then (editor) -> editor2 = editor - - runs -> - expect(editor2.getPath()).toBeUndefined() - expect(workspace.getActivePane().items).toEqual [editor1, editor2] - expect(workspace.getActivePaneItem()).toBe editor2 - expect(workspace.getActivePane().activate).toHaveBeenCalled() - expect(openEvents).toEqual [{uri: undefined, pane: workspace.getActivePane(), item: editor2, index: 1}] - - describe "when called with a uri", -> - describe "when the active pane already has an editor for the given uri", -> - it "activates the existing editor on the active pane", -> - editor = null - editor1 = null - editor2 = null - - waitsForPromise -> - workspace.open('a').then (o) -> - editor1 = o - workspace.open('b').then (o) -> - editor2 = o - workspace.open('a').then (o) -> - editor = o - - runs -> - expect(editor).toBe editor1 - expect(workspace.getActivePaneItem()).toBe editor - expect(workspace.getActivePane().activate).toHaveBeenCalled() - - expect(openEvents).toEqual [ - { - uri: atom.project.getDirectories()[0]?.resolve('a') - item: editor1 - pane: atom.workspace.getActivePane() - index: 0 - } - { - uri: atom.project.getDirectories()[0]?.resolve('b') - item: editor2 - pane: atom.workspace.getActivePane() - index: 1 - } - { - uri: atom.project.getDirectories()[0]?.resolve('a') - item: editor1 - pane: atom.workspace.getActivePane() - index: 0 - } - ] - - describe "when the active pane does not have an editor for the given uri", -> - it "adds and activates a new editor for the given path on the active pane", -> - editor = null - waitsForPromise -> - workspace.open('a').then (o) -> editor = o - - runs -> - expect(editor.getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(workspace.getActivePaneItem()).toBe editor - expect(workspace.getActivePane().items).toEqual [editor] - expect(workspace.getActivePane().activate).toHaveBeenCalled() - - describe "when the 'searchAllPanes' option is true", -> - describe "when an editor for the given uri is already open on an inactive pane", -> - it "activates the existing editor on the inactive pane, then activates that pane", -> - editor1 = null - editor2 = null - pane1 = workspace.getActivePane() - pane2 = workspace.getActivePane().splitRight() - - waitsForPromise -> - pane1.activate() - workspace.open('a').then (o) -> editor1 = o - - waitsForPromise -> - pane2.activate() - workspace.open('b').then (o) -> editor2 = o - - runs -> - expect(workspace.getActivePaneItem()).toBe editor2 - - waitsForPromise -> - workspace.open('a', searchAllPanes: true) - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(workspace.getActivePaneItem()).toBe editor1 - - describe "when no editor for the given uri is open in any pane", -> - it "opens an editor for the given uri in the active pane", -> - editor = null - waitsForPromise -> - workspace.open('a', searchAllPanes: true).then (o) -> editor = o - - runs -> - expect(workspace.getActivePaneItem()).toBe editor - - describe "when the 'split' option is set", -> - describe "when the 'split' option is 'left'", -> - it "opens the editor in the leftmost pane of the current pane axis", -> - pane1 = workspace.getActivePane() - pane2 = pane1.splitRight() - expect(workspace.getActivePane()).toBe pane2 - - editor = null - waitsForPromise -> - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - # Focus right pane and reopen the file on the left - waitsForPromise -> - pane2.focus() - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - describe "when a pane axis is the leftmost sibling of the current pane", -> - it "opens the new item in the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitLeft() - pane3 = pane2.splitDown() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - - waitsForPromise -> - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - - describe "when the 'split' option is 'right'", -> - it "opens the editor in the rightmost pane of the current pane axis", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = null - waitsForPromise -> - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - pane2 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - # Focus right pane and reopen the file on the right - waitsForPromise -> - pane1.focus() - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - describe "when a pane axis is the rightmost sibling of the current pane", -> - it "opens the new item in a new pane split to the right of the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitRight() - pane3 = pane2.splitDown() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - pane4 = null - - waitsForPromise -> - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - pane4 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane4 - expect(pane4.items).toEqual [editor] - expect(workspace.paneContainer.root.children[0]).toBe pane1 - expect(workspace.paneContainer.root.children[1]).toBe pane4 - - describe "when passed a path that matches a custom opener", -> - it "returns the resource returned by the custom opener", -> - fooOpener = (pathToOpen, options) -> {foo: pathToOpen, options} if pathToOpen?.match(/\.foo/) - barOpener = (pathToOpen) -> {bar: pathToOpen} if pathToOpen?.match(/^bar:\/\//) - workspace.addOpener(fooOpener) - workspace.addOpener(barOpener) - - waitsForPromise -> - pathToOpen = atom.project.getDirectories()[0]?.resolve('a.foo') - workspace.open(pathToOpen, hey: "there").then (item) -> - expect(item).toEqual {foo: pathToOpen, options: {hey: "there"}} - - waitsForPromise -> - workspace.open("bar://baz").then (item) -> - expect(item).toEqual {bar: "bar://baz"} - - it "notifies ::onDidAddTextEditor observers", -> - absolutePath = require.resolve('./fixtures/dir/a') - newEditorHandler = jasmine.createSpy('newEditorHandler') - workspace.onDidAddTextEditor newEditorHandler - - editor = null - waitsForPromise -> - workspace.open(absolutePath).then (e) -> editor = e - - runs -> - expect(newEditorHandler.argsForCall[0][0].textEditor).toBe editor - - it "records a deprecation warning on the appropriate package if the item has a ::getUri method instead of ::getURI", -> - jasmine.snapshotDeprecations() - - waitsForPromise -> atom.packages.activatePackage('package-with-deprecated-pane-item-method') - - waitsForPromise -> - atom.workspace.open("test") - - runs -> - deprecations = Grim.getDeprecations() - expect(deprecations.length).toBe 1 - expect(deprecations[0].message).toBe "Pane item with class `TestItem` should implement `::getURI` instead of `::getUri`." - expect(deprecations[0].getStacks()[0].metadata.packageName).toBe "package-with-deprecated-pane-item-method" - jasmine.restoreDeprecationsSnapshot() - - describe "when there is an error opening the file", -> - notificationSpy = null - beforeEach -> - atom.notifications.onDidAddNotification notificationSpy = jasmine.createSpy() - - describe "when a large file is opened", -> - beforeEach -> - spyOn(fs, 'getSizeSync').andReturn 2 * 1048577 # 2MB - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain '< 2MB' - - describe "when a file does not exist", -> - it "creates an empty buffer for the specified path", -> - waitsForPromise -> - workspace.open('not-a-file.md') - - runs -> - editor = workspace.getActiveTextEditor() - expect(notificationSpy).not.toHaveBeenCalled() - expect(editor.getPath()).toContain 'not-a-file.md' - - describe "when the user does not have access to the file", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EACCES, permission denied '#{path}'") - error.path = path - error.code = 'EACCES' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Permission denied' - expect(notification.getMessage()).toContain 'file1' - - describe "when the the operation is not permitted", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EPERM, operation not permitted '#{path}'") - error.path = path - error.code = 'EPERM' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to open' - expect(notification.getMessage()).toContain 'file1' - - describe "when the the file is already open in windows", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EBUSY, resource busy or locked '#{path}'") - error.path = path - error.code = 'EBUSY' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to open' - expect(notification.getMessage()).toContain 'file1' - - describe "when there is an unhandled error", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - throw new Error("I dont even know what is happening right now!!") - - it "creates a notification", -> - open = -> workspace.open('file1', workspace.getActivePane()) - expect(open).toThrow() - - describe "::reopenItem()", -> - it "opens the uri associated with the last closed pane that isn't currently open", -> - pane = workspace.getActivePane() - waitsForPromise -> - workspace.open('a').then -> - workspace.open('b').then -> - workspace.open('file1').then -> - workspace.open() - - runs -> - # does not reopen items with no uri - expect(workspace.getActivePaneItem().getURI()).toBeUndefined() - pane.destroyActiveItem() - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).not.toBeUndefined() - - # destroy all items - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('file1') - pane.destroyActiveItem() - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('b') - pane.destroyActiveItem() - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - pane.destroyActiveItem() - - # reopens items with uris - expect(workspace.getActivePaneItem()).toBeUndefined() - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - - # does not reopen items that are already open - waitsForPromise -> - workspace.open('b') - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('b') - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('file1') - - describe "::increase/decreaseFontSize()", -> - it "increases/decreases the font size without going below 1", -> - atom.config.set('editor.fontSize', 1) - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 2 - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 3 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 2 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 1 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 1 - - describe "::resetFontSize()", -> - it "resets the font size to the window's starting font size", -> - originalFontSize = atom.config.get('editor.fontSize') - - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize + 1 - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - 1 - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - it "does nothing if the font size has not been changed", -> - originalFontSize = atom.config.get('editor.fontSize') - - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - it "resets the font size when the editor's font size changes", -> - originalFontSize = atom.config.get('editor.fontSize') - - atom.config.set('editor.fontSize', originalFontSize + 1) - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - atom.config.set('editor.fontSize', originalFontSize - 1) - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - describe "::openLicense()", -> - it "opens the license as plain-text in a buffer", -> - waitsForPromise -> workspace.openLicense() - runs -> expect(workspace.getActivePaneItem().getText()).toMatch /Copyright/ - - describe "::observeTextEditors()", -> - it "invokes the observer with current and future text editors", -> - observed = [] - - waitsForPromise -> workspace.open() - waitsForPromise -> workspace.open() - waitsForPromise -> workspace.openLicense() - - runs -> - workspace.observeTextEditors (editor) -> observed.push(editor) - - waitsForPromise -> workspace.open() - - expect(observed).toEqual workspace.getTextEditors() - - describe "when an editor is destroyed", -> - it "removes the editor", -> - editor = null - - waitsForPromise -> - workspace.open("a").then (e) -> editor = e - - runs -> - expect(workspace.getTextEditors()).toHaveLength 1 - editor.destroy() - expect(workspace.getTextEditors()).toHaveLength 0 - - it "stores the active grammars used by all the open editors", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - waitsForPromise -> - atom.packages.activatePackage('language-todo') - - waitsForPromise -> - atom.workspace.open('sample.coffee') - - runs -> - atom.workspace.getActiveTextEditor().setText """ - i = /test/; #FIXME - """ - - state = atom.workspace.serialize() - expect(state.packagesWithActiveGrammars).toEqual ['language-coffee-script', 'language-javascript', 'language-todo'] - - jsPackage = atom.packages.getLoadedPackage('language-javascript') - coffeePackage = atom.packages.getLoadedPackage('language-coffee-script') - spyOn(jsPackage, 'loadGrammarsSync') - spyOn(coffeePackage, 'loadGrammarsSync') - - workspace2 = Workspace.deserialize(state) - expect(jsPackage.loadGrammarsSync.callCount).toBe 1 - expect(coffeePackage.loadGrammarsSync.callCount).toBe 1 - - describe "document.title", -> - describe "when the project has no path", -> - it "sets the title to 'untitled'", -> - atom.project.setPaths([]) - expect(document.title).toBe 'untitled - Atom' - - describe "when the project has a path", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('b') - - describe "when there is an active pane item", -> - it "sets the title to the pane item's title plus the project path", -> - item = atom.workspace.getActivePaneItem() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]} - Atom" - - describe "when the title of the active pane item changes", -> - it "updates the window title based on the item's new title", -> - editor = atom.workspace.getActivePaneItem() - editor.buffer.setPath(path.join(temp.dir, 'hi')) - expect(document.title).toBe "#{editor.getTitle()} - #{atom.project.getPaths()[0]} - Atom" - - describe "when the active pane's item changes", -> - it "updates the title to the new item's title plus the project path", -> - atom.workspace.getActivePane().activateNextItem() - item = atom.workspace.getActivePaneItem() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]} - Atom" - - describe "when the last pane item is removed", -> - it "updates the title to contain the project's path", -> - atom.workspace.getActivePane().destroy() - expect(atom.workspace.getActivePaneItem()).toBeUndefined() - expect(document.title).toBe "#{atom.project.getPaths()[0]} - Atom" - - describe "when an inactive pane's item changes", -> - it "does not update the title", -> - pane = atom.workspace.getActivePane() - pane.splitRight() - initialTitle = document.title - pane.activateNextItem() - expect(document.title).toBe initialTitle - - describe "when the workspace is deserialized", -> - beforeEach -> - waitsForPromise -> atom.workspace.open('a') - - it "updates the title to contain the project's path", -> - document.title = null - workspace2 = atom.workspace.testSerialization() - item = atom.workspace.getActivePaneItem() - expect(document.title).toBe "#{item.getTitle()} - #{atom.project.getPaths()[0]} - Atom" - workspace2.destroy() - - describe "document edited status", -> - [item1, item2] = [] - - beforeEach -> - waitsForPromise -> atom.workspace.open('a') - waitsForPromise -> atom.workspace.open('b') - runs -> - [item1, item2] = atom.workspace.getPaneItems() - spyOn(atom, 'setDocumentEdited') - - it "calls atom.setDocumentEdited when the active item changes", -> - expect(atom.workspace.getActivePaneItem()).toBe item2 - item1.insertText('a') - expect(item1.isModified()).toBe true - atom.workspace.getActivePane().activateNextItem() - - expect(atom.setDocumentEdited).toHaveBeenCalledWith(true) - - it "calls atom.setDocumentEdited when the active item's modified status changes", -> - expect(atom.workspace.getActivePaneItem()).toBe item2 - item2.insertText('a') - advanceClock(item2.getBuffer().getStoppedChangingDelay()) - - expect(item2.isModified()).toBe true - expect(atom.setDocumentEdited).toHaveBeenCalledWith(true) - - item2.undo() - advanceClock(item2.getBuffer().getStoppedChangingDelay()) - - expect(item2.isModified()).toBe false - expect(atom.setDocumentEdited).toHaveBeenCalledWith(false) - - describe "adding panels", -> - class TestItem - - class TestItemElement extends HTMLElement - constructor: -> - initialize: (@model) -> this - getModel: -> @model - - beforeEach -> - atom.views.addViewProvider TestItem, (model) -> - new TestItemElement().initialize(model) - - describe '::addLeftPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getLeftPanels().length).toBe(0) - atom.workspace.panelContainers.left.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addLeftPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getLeftPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addRightPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getRightPanels().length).toBe(0) - atom.workspace.panelContainers.right.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addRightPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getRightPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addTopPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getTopPanels().length).toBe(0) - atom.workspace.panelContainers.top.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addTopPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getTopPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addBottomPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getBottomPanels().length).toBe(0) - atom.workspace.panelContainers.bottom.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addBottomPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getBottomPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addModalPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getModalPanels().length).toBe(0) - atom.workspace.panelContainers.modal.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addModalPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getModalPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe "::panelForItem(item)", -> - it "returns the panel associated with the item", -> - item = new TestItem - panel = atom.workspace.addLeftPanel(item: item) - - itemWithNoPanel = new TestItem - - expect(atom.workspace.panelForItem(item)).toBe panel - expect(atom.workspace.panelForItem(itemWithNoPanel)).toBe null - - describe "::scan(regex, options, callback)", -> - describe "when called with a regex", -> - it "calls the callback with all regex results in all files in the project", -> - results = [] - waitsForPromise -> - atom.workspace.scan /(a)+/, (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength(3) - expect(results[0].filePath).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(results[0].matches).toHaveLength(3) - expect(results[0].matches[0]).toEqual - matchText: 'aaa' - lineText: 'aaa bbb' - lineTextOffset: 0 - range: [[0, 0], [0, 3]] - - it "works with with escaped literals (like $ and ^)", -> - results = [] - waitsForPromise -> - atom.workspace.scan /\$\w+/, (result) -> results.push(result) - - runs -> - expect(results.length).toBe 1 - - {filePath, matches} = results[0] - expect(filePath).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(matches).toHaveLength 1 - expect(matches[0]).toEqual - matchText: '$bill' - lineText: 'dollar$bill' - lineTextOffset: 0 - range: [[2, 6], [2, 11]] - - it "works on evil filenames", -> - platform.generateEvilFiles() - atom.project.setPaths([path.join(__dirname, 'fixtures', 'evil-files')]) - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /evil/, (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - _.each(matches, (m) -> expect(m.matchText).toEqual 'evil') - - if platform.isWindows() - expect(paths.length).toBe 3 - expect(paths[0]).toMatch /a_file_with_utf8.txt$/ - expect(paths[1]).toMatch /file with spaces.txt$/ - expect(path.basename(paths[2])).toBe "utfa\u0306.md" - else - expect(paths.length).toBe 5 - expect(paths[0]).toMatch /a_file_with_utf8.txt$/ - expect(paths[1]).toMatch /file with spaces.txt$/ - expect(paths[2]).toMatch /goddam\nnewlines$/m - expect(paths[3]).toMatch /quote".txt$/m - expect(path.basename(paths[4])).toBe "utfa\u0306.md" - - it "ignores case if the regex includes the `i` flag", -> - results = [] - waitsForPromise -> - atom.workspace.scan /DOLLAR/i, (result) -> results.push(result) - - runs -> - expect(results).toHaveLength 1 - - describe "when the core.excludeVcsIgnoredPaths config is truthy", -> - [projectPath, ignoredPath] = [] - - beforeEach -> - sourceProjectPath = path.join(__dirname, 'fixtures', 'git', 'working-dir') - projectPath = path.join(temp.mkdirSync("atom")) - - writerStream = fstream.Writer(projectPath) - fstream.Reader(sourceProjectPath).pipe(writerStream) - - waitsFor (done) -> - writerStream.on 'close', done - writerStream.on 'error', done - - runs -> - fs.rename(path.join(projectPath, 'git.git'), path.join(projectPath, '.git')) - ignoredPath = path.join(projectPath, 'ignored.txt') - fs.writeFileSync(ignoredPath, 'this match should not be included') - - afterEach -> - fs.removeSync(projectPath) if fs.existsSync(projectPath) - - it "excludes ignored files", -> - atom.project.setPaths([projectPath]) - atom.config.set('core.excludeVcsIgnoredPaths', true) - resultHandler = jasmine.createSpy("result found") - waitsForPromise -> - atom.workspace.scan /match/, (results) -> - resultHandler() - - runs -> - expect(resultHandler).not.toHaveBeenCalled() - - it "includes only files when a directory filter is specified", -> - projectPath = path.join(path.join(__dirname, 'fixtures', 'dir')) - atom.project.setPaths([projectPath]) - - filePath = path.join(projectPath, 'a-dir', 'oh-git') - - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /aaa/, paths: ["a-dir#{path.sep}"], (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - expect(paths.length).toBe 1 - expect(paths[0]).toBe filePath - expect(matches.length).toBe 1 - - it "includes files and folders that begin with a '.'", -> - projectPath = temp.mkdirSync() - filePath = path.join(projectPath, '.text') - fs.writeFileSync(filePath, 'match this') - atom.project.setPaths([projectPath]) - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /match this/, (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - expect(paths.length).toBe 1 - expect(paths[0]).toBe filePath - expect(matches.length).toBe 1 - - it "excludes values in core.ignoredNames", -> - projectPath = path.join(__dirname, 'fixtures', 'git', 'working-dir') - ignoredNames = atom.config.get("core.ignoredNames") - ignoredNames.push("a") - atom.config.set("core.ignoredNames", ignoredNames) - - resultHandler = jasmine.createSpy("result found") - waitsForPromise -> - atom.workspace.scan /dollar/, (results) -> - resultHandler() - - runs -> - expect(resultHandler).not.toHaveBeenCalled() - - it "scans buffer contents if the buffer is modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.project.open('a').then (o) -> - editor = o - editor.setText("Elephant") - - waitsForPromise -> - atom.workspace.scan /a|Elephant/, (result) -> results.push result - - runs -> - expect(results).toHaveLength 3 - resultForA = _.find results, ({filePath}) -> path.basename(filePath) is 'a' - expect(resultForA.matches).toHaveLength 1 - expect(resultForA.matches[0].matchText).toBe 'Elephant' - - it "ignores buffers outside the project", -> - editor = null - results = [] - - waitsForPromise -> - atom.project.open(temp.openSync().path).then (o) -> - editor = o - editor.setText("Elephant") - - waitsForPromise -> - atom.workspace.scan /Elephant/, (result) -> results.push result - - runs -> - expect(results).toHaveLength 0 - - describe "when the project has multiple root directories", -> - [dir1, dir2, file1, file2] = [] - - beforeEach -> - [dir1] = atom.project.getPaths() - file1 = path.join(dir1, "a-dir", "oh-git") - - dir2 = temp.mkdirSync("a-second-dir") - aDir2 = path.join(dir2, "a-dir") - file2 = path.join(aDir2, "a-file") - fs.mkdirSync(aDir2) - fs.writeFileSync(file2, "ccc aaaa") - - atom.project.addPath(dir2) - - it "searches matching files in all of the project's root directories", -> - resultPaths = [] - waitsForPromise -> - atom.workspace.scan /aaaa/, ({filePath}) -> - resultPaths.push(filePath) - - runs -> - expect(resultPaths.sort()).toEqual([file1, file2].sort()) - - describe "when an inclusion path starts with the basename of a root directory", -> - it "interprets the inclusion path as starting from that directory", -> - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: ["dir"], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file1]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.join("dir", "a-dir")], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file1]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.basename(dir2)], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file2]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.join(path.basename(dir2), "a-dir")], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file2]) - - describe "::replace(regex, replacementText, paths, iterator)", -> - [filePath, commentFilePath, sampleContent, sampleCommentContent] = [] - - beforeEach -> - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('../')]) - - filePath = atom.project.getDirectories()[0]?.resolve('sample.js') - commentFilePath = atom.project.getDirectories()[0]?.resolve('sample-with-comments.js') - sampleContent = fs.readFileSync(filePath).toString() - sampleCommentContent = fs.readFileSync(commentFilePath).toString() - - afterEach -> - fs.writeFileSync(filePath, sampleContent) - fs.writeFileSync(commentFilePath, sampleCommentContent) - - describe "when a file doesn't exist", -> - it "calls back with an error", -> - errors = [] - missingPath = path.resolve('/not-a-file.js') - expect(fs.existsSync(missingPath)).toBeFalsy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [missingPath], (result, error) -> - errors.push(error) - - runs -> - expect(errors).toHaveLength 1 - expect(errors[0].path).toBe missingPath - - describe "when called with unopened files", -> - it "replaces properly", -> - results = [] - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - describe "when a buffer is already open", -> - it "replaces properly and saves when not modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.project.open('sample.js').then (o) -> editor = o - - runs -> - expect(editor.isModified()).toBeFalsy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - expect(editor.isModified()).toBeFalsy() - - it "does not replace when the path is not specified", -> - editor = null - results = [] - - waitsForPromise -> - atom.project.open('sample-with-comments.js').then (o) -> editor = o - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [commentFilePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe commentFilePath - - it "does NOT save when modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.project.open('sample.js').then (o) -> editor = o - - runs -> - editor.buffer.setTextInRange([[0,0],[0,0]], 'omg') - expect(editor.isModified()).toBeTruthy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'okthen', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - expect(editor.isModified()).toBeTruthy() - - describe "::saveActivePaneItem()", -> - editor = null - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - describe "when there is an error", -> - it "emits a warning notification when the file cannot be saved", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("'/some/file' is a directory") - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the directory cannot be written to", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'") - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the user does not have permission", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EACCES, permission denied '/Some/dir/and-a-file.js'") - error.code = 'EACCES' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the operation is not permitted", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EPERM, operation not permitted '/Some/dir/and-a-file.js'") - error.code = 'EPERM' - error.path = '/Some/dir/and-a-file.js' - throw error - - it "emits a warning notification when the file is already open by another app", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EBUSY, resource busy or locked '/Some/dir/and-a-file.js'") - error.code = 'EBUSY' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - - notificaiton = addedSpy.mostRecentCall.args[0] - expect(notificaiton.getType()).toBe 'warning' - expect(notificaiton.getMessage()).toContain 'Unable to save' - - it "emits a warning notification when the file system is read-only", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EROFS, read-only file system '/Some/dir/and-a-file.js'") - error.code = 'EROFS' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - - notification = addedSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to save' - - it "emits a warning notification when the file cannot be saved", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("no one knows") - - save = -> atom.workspace.saveActivePaneItem() - expect(save).toThrow() diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js new file mode 100644 index 00000000000..0d33a2ade80 --- /dev/null +++ b/spec/workspace-spec.js @@ -0,0 +1,3994 @@ +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'); +const platform = require('./spec-helper-platform'); +const _ = require('underscore-plus'); +const fstream = require('fstream'); +const fs = require('fs-plus'); +const AtomEnvironment = require('../src/atom-environment'); +const { conditionPromise } = require('./async-spec-helpers'); + +describe('Workspace', () => { + let workspace; + let setDocumentEdited; + + beforeEach(() => { + workspace = atom.workspace; + workspace.resetFontSize(); + spyOn(atom.applicationDelegate, 'confirm'); + setDocumentEdited = spyOn( + atom.applicationDelegate, + 'setWindowDocumentEdited' + ); + atom.project.setPaths([atom.project.getDirectories()[0].resolve('dir')]); + waits(1); + + waitsForPromise(() => atom.workspace.itemLocationStore.clear()); + }); + + afterEach(() => { + try { + temp.cleanupSync(); + } catch (e) { + // Do nothing + } + }); + + function simulateReload() { + waitsForPromise(() => { + const workspaceState = workspace.serialize(); + const projectState = atom.project.serialize({ isUnloading: true }); + workspace.destroy(); + atom.project.destroy(); + atom.project = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm.bind(atom), + applicationDelegate: atom.applicationDelegate, + grammarRegistry: atom.grammars + }); + return atom.project.deserialize(projectState).then(() => { + workspace = atom.workspace = new Workspace({ + config: atom.config, + project: atom.project, + packageManager: atom.packages, + grammarRegistry: atom.grammars, + styleManager: atom.styles, + deserializerManager: atom.deserializers, + notificationManager: atom.notifications, + applicationDelegate: atom.applicationDelegate, + viewRegistry: atom.views, + assert: atom.assert.bind(atom), + textEditorRegistry: atom.textEditors + }); + workspace.deserialize(workspaceState, atom.deserializers); + }); + }); + } + + describe('serialization', () => { + describe('when the workspace contains text editors', () => { + it('constructs the view with the same panes', () => { + const pane1 = atom.workspace.getActivePane(); + const pane2 = pane1.splitRight({ copyActiveItem: true }); + const pane3 = pane2.splitRight({ copyActiveItem: true }); + let pane4 = null; + + waitsForPromise(() => + atom.workspace + .open(null) + .then(editor => editor.setText('An untitled editor.')) + ); + + waitsForPromise(() => + atom.workspace + .open('b') + .then(editor => pane2.activateItem(editor.copy())) + ); + + waitsForPromise(() => + atom.workspace + .open('../sample.js') + .then(editor => pane3.activateItem(editor)) + ); + + runs(() => { + pane3.activeItem.setCursorScreenPosition([2, 4]); + pane4 = pane2.splitDown(); + }); + + waitsForPromise(() => + atom.workspace + .open('../sample.txt') + .then(editor => pane4.activateItem(editor)) + ); + + runs(() => { + pane4.getActiveItem().setCursorScreenPosition([0, 2]); + pane2.activate(); + }); + + simulateReload(); + + runs(() => { + expect(atom.workspace.getTextEditors().length).toBe(5); + const [ + editor1, + editor2, + untitledEditor, + editor3, + editor4 + ] = atom.workspace.getTextEditors(); + const firstDirectory = atom.project.getDirectories()[0]; + expect(firstDirectory).toBeDefined(); + expect(editor1.getPath()).toBe(firstDirectory.resolve('b')); + expect(editor2.getPath()).toBe( + firstDirectory.resolve('../sample.txt') + ); + expect(editor2.getCursorScreenPosition()).toEqual([0, 2]); + expect(editor3.getPath()).toBe(firstDirectory.resolve('b')); + expect(editor4.getPath()).toBe( + firstDirectory.resolve('../sample.js') + ); + expect(editor4.getCursorScreenPosition()).toEqual([2, 4]); + expect(untitledEditor.getPath()).toBeUndefined(); + expect(untitledEditor.getText()).toBe('An untitled editor.'); + + expect(atom.workspace.getActiveTextEditor().getPath()).toBe( + editor3.getPath() + ); + const pathEscaped = fs.tildify( + escapeStringRegex(atom.project.getPaths()[0]) + ); + expect(document.title).toMatch( + new RegExp( + `^${path.basename(editor3.getLongTitle())} \\u2014 ${pathEscaped}` + ) + ); + }); + }); + }); + + describe('where there are no open panes or editors', () => { + it('constructs the view with no open editors', () => { + atom.workspace.getActivePane().destroy(); + expect(atom.workspace.getTextEditors().length).toBe(0); + simulateReload(); + + runs(() => { + expect(atom.workspace.getTextEditors().length).toBe(0); + }); + }); + }); + }); + + describe('::open(itemOrURI, options)', () => { + let openEvents = null; + + beforeEach(() => { + openEvents = []; + workspace.onDidOpen(event => openEvents.push(event)); + spyOn(workspace.getActivePane(), 'activate').andCallThrough(); + }); + + describe("when the 'searchAllPanes' option is false (default)", () => { + describe('when called without a uri or item', () => { + it('adds and activates an empty editor on the active pane', () => { + let editor1; + let editor2; + + waitsForPromise(() => + workspace.open().then(editor => { + editor1 = editor; + }) + ); + + runs(() => { + expect(editor1.getPath()).toBeUndefined(); + expect(workspace.getActivePane().items).toEqual([editor1]); + expect(workspace.getActivePaneItem()).toBe(editor1); + expect(workspace.getActivePane().activate).toHaveBeenCalled(); + expect(openEvents).toEqual([ + { + uri: undefined, + pane: workspace.getActivePane(), + item: editor1, + index: 0 + } + ]); + openEvents = []; + }); + + waitsForPromise(() => + workspace.open().then(editor => { + editor2 = editor; + }) + ); + + runs(() => { + expect(editor2.getPath()).toBeUndefined(); + expect(workspace.getActivePane().items).toEqual([editor1, editor2]); + expect(workspace.getActivePaneItem()).toBe(editor2); + expect(workspace.getActivePane().activate).toHaveBeenCalled(); + expect(openEvents).toEqual([ + { + uri: undefined, + pane: workspace.getActivePane(), + item: editor2, + index: 1 + } + ]); + }); + }); + }); + + describe('when called with a uri', () => { + describe('when the active pane already has an editor for the given uri', () => { + it('activates the existing editor on the active pane', () => { + let editor = null; + let editor1 = null; + let editor2 = null; + + waitsForPromise(() => + workspace.open('a').then(o => { + editor1 = o; + return workspace.open('b').then(o => { + editor2 = o; + return workspace.open('a').then(o => { + editor = o; + }); + }); + }) + ); + + runs(() => { + expect(editor).toBe(editor1); + expect(workspace.getActivePaneItem()).toBe(editor); + expect(workspace.getActivePane().activate).toHaveBeenCalled(); + const firstDirectory = atom.project.getDirectories()[0]; + expect(firstDirectory).toBeDefined(); + expect(openEvents).toEqual([ + { + uri: firstDirectory.resolve('a'), + item: editor1, + pane: atom.workspace.getActivePane(), + index: 0 + }, + { + uri: firstDirectory.resolve('b'), + item: editor2, + pane: atom.workspace.getActivePane(), + index: 1 + }, + { + uri: firstDirectory.resolve('a'), + item: editor1, + pane: atom.workspace.getActivePane(), + index: 0 + } + ]); + }); + }); + + it('finds items in docks', () => { + const dock = atom.workspace.getRightDock(); + const ITEM_URI = 'atom://test'; + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: () => 'left', + getElement: () => document.createElement('div') + }; + dock.getActivePane().addItem(item); + expect(dock.getPaneItems()).toHaveLength(1); + waitsForPromise(() => + atom.workspace.open(ITEM_URI, { searchAllPanes: true }) + ); + runs(() => { + expect(atom.workspace.getPaneItems()).toHaveLength(1); + expect(dock.getPaneItems()).toHaveLength(1); + expect(dock.getPaneItems()[0]).toBe(item); + }); + }); + }); + + describe("when the 'activateItem' option is false", () => { + it('adds the item to the workspace', () => { + let editor; + waitsForPromise(() => workspace.open('a')); + waitsForPromise(() => + workspace.open('b', { activateItem: false }).then(o => { + editor = o; + }) + ); + runs(() => { + expect(workspace.getPaneItems()).toContain(editor); + expect(workspace.getActivePaneItem()).not.toBe(editor); + }); + }); + }); + + describe('when the active pane does not have an editor for the given uri', () => { + beforeEach(() => { + atom.workspace.enablePersistence = true; + }); + + afterEach(async () => { + await atom.workspace.itemLocationStore.clear(); + atom.workspace.enablePersistence = false; + }); + + it('adds and activates a new editor for the given path on the active pane', () => { + let editor = null; + waitsForPromise(() => + workspace.open('a').then(o => { + editor = o; + }) + ); + + runs(() => { + const firstDirectory = atom.project.getDirectories()[0]; + expect(firstDirectory).toBeDefined(); + expect(editor.getURI()).toBe(firstDirectory.resolve('a')); + expect(workspace.getActivePaneItem()).toBe(editor); + expect(workspace.getActivePane().items).toEqual([editor]); + expect(workspace.getActivePane().activate).toHaveBeenCalled(); + }); + }); + + it('discovers existing editors that are still opening', () => { + let editor0 = null; + let editor1 = null; + + waitsForPromise(() => + Promise.all([ + workspace.open('spartacus.txt').then(o0 => { + editor0 = o0; + }), + workspace.open('spartacus.txt').then(o1 => { + editor1 = o1; + }) + ]) + ); + + runs(() => { + expect(editor0).toEqual(editor1); + expect(workspace.getActivePane().items).toEqual([editor0]); + }); + }); + + it("uses the location specified by the model's `getDefaultLocation()` method", () => { + const item = { + getDefaultLocation: jasmine.createSpy().andReturn('right'), + getElement: () => document.createElement('div') + }; + const opener = jasmine.createSpy().andReturn(item); + const dock = atom.workspace.getRightDock(); + spyOn(atom.workspace.itemLocationStore, 'load').andReturn( + Promise.resolve() + ); + spyOn(atom.workspace, 'getOpeners').andReturn([opener]); + expect(dock.getPaneItems()).toHaveLength(0); + waitsForPromise(() => atom.workspace.open('a')); + runs(() => { + expect(dock.getPaneItems()).toHaveLength(1); + expect(opener).toHaveBeenCalled(); + expect(item.getDefaultLocation).toHaveBeenCalled(); + }); + }); + + it('prefers the last location the user used for that item', () => { + const ITEM_URI = 'atom://test'; + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: () => 'left', + getElement: () => document.createElement('div') + }; + const opener = uri => (uri === ITEM_URI ? item : null); + const dock = atom.workspace.getRightDock(); + spyOn(atom.workspace.itemLocationStore, 'load').andCallFake(uri => + uri === 'atom://test' + ? Promise.resolve('right') + : Promise.resolve() + ); + spyOn(atom.workspace, 'getOpeners').andReturn([opener]); + expect(dock.getPaneItems()).toHaveLength(0); + waitsForPromise(() => atom.workspace.open(ITEM_URI)); + runs(() => { + expect(dock.getPaneItems()).toHaveLength(1); + expect(dock.getPaneItems()[0]).toBe(item); + }); + }); + }); + }); + + describe('when an item with the given uri exists in an inactive pane container', () => { + it("activates that item if it is in that container's active pane", async () => { + const item = await atom.workspace.open('a'); + atom.workspace.getLeftDock().activate(); + expect( + await atom.workspace.open('a', { searchAllPanes: false }) + ).toBe(item); + expect(atom.workspace.getActivePaneContainer().getLocation()).toBe( + 'center' + ); + expect(atom.workspace.getPaneItems()).toEqual([item]); + + atom.workspace.getActivePane().splitRight(); + atom.workspace.getLeftDock().activate(); + const item2 = await atom.workspace.open('a', { + searchAllPanes: false + }); + expect(item2).not.toBe(item); + expect(atom.workspace.getActivePaneContainer().getLocation()).toBe( + 'center' + ); + expect(atom.workspace.getPaneItems()).toEqual([item, item2]); + }); + }); + }); + + describe("when the 'searchAllPanes' option is true", () => { + describe('when an editor for the given uri is already open on an inactive pane', () => { + it('activates the existing editor on the inactive pane, then activates that pane', () => { + let editor1 = null; + let editor2 = null; + const pane1 = workspace.getActivePane(); + const pane2 = workspace.getActivePane().splitRight(); + + waitsForPromise(() => { + pane1.activate(); + return workspace.open('a').then(o => { + editor1 = o; + }); + }); + + waitsForPromise(() => { + pane2.activate(); + return workspace.open('b').then(o => { + editor2 = o; + }); + }); + + runs(() => expect(workspace.getActivePaneItem()).toBe(editor2)); + + waitsForPromise(() => workspace.open('a', { searchAllPanes: true })); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(workspace.getActivePaneItem()).toBe(editor1); + }); + }); + + it('discovers existing editors that are still opening in an inactive pane', () => { + let editor0 = null; + let editor1 = null; + const pane0 = workspace.getActivePane(); + const pane1 = workspace.getActivePane().splitRight(); + + pane0.activate(); + const promise0 = workspace + .open('spartacus.txt', { searchAllPanes: true }) + .then(o0 => { + editor0 = o0; + }); + pane1.activate(); + const promise1 = workspace + .open('spartacus.txt', { searchAllPanes: true }) + .then(o1 => { + editor1 = o1; + }); + + waitsForPromise(() => Promise.all([promise0, promise1])); + + runs(() => { + expect(editor0).toBeDefined(); + expect(editor1).toBeDefined(); + + expect(editor0).toEqual(editor1); + expect(workspace.getActivePane().items).toEqual([editor0]); + }); + }); + + it('activates the pane in the dock with the matching item', () => { + const dock = atom.workspace.getRightDock(); + const ITEM_URI = 'atom://test'; + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: jasmine.createSpy().andReturn('left'), + getElement: () => document.createElement('div') + }; + dock.getActivePane().addItem(item); + spyOn(dock.paneForItem(item), 'activate'); + waitsForPromise(() => + atom.workspace.open(ITEM_URI, { searchAllPanes: true }) + ); + runs(() => + expect(dock.paneForItem(item).activate).toHaveBeenCalled() + ); + }); + }); + + describe('when no editor for the given uri is open in any pane', () => { + it('opens an editor for the given uri in the active pane', () => { + let editor = null; + waitsForPromise(() => + workspace.open('a', { searchAllPanes: true }).then(o => { + editor = o; + }) + ); + + runs(() => expect(workspace.getActivePaneItem()).toBe(editor)); + }); + }); + }); + + describe('when attempting to open an editor in a dock', () => { + it('opens the editor in the workspace center', async () => { + await atom.workspace.open('sample.txt', { location: 'right' }); + expect( + atom.workspace + .getCenter() + .getActivePaneItem() + .getFileName() + ).toEqual('sample.txt'); + }); + }); + + describe('when called with an item rather than a URI', () => { + it('adds the item itself to the workspace', async () => { + const item = document.createElement('div'); + await atom.workspace.open(item); + expect(atom.workspace.getActivePaneItem()).toBe(item); + }); + + describe('when the active pane already contains the item', () => { + it('activates the item', async () => { + const item = document.createElement('div'); + + await atom.workspace.open(item); + await atom.workspace.open(); + expect(atom.workspace.getActivePaneItem()).not.toBe(item); + expect(atom.workspace.getActivePane().getItems().length).toBe(2); + + await atom.workspace.open(item); + expect(atom.workspace.getActivePaneItem()).toBe(item); + expect(atom.workspace.getActivePane().getItems().length).toBe(2); + }); + }); + + describe('when the item already exists in another pane', () => { + it('rejects the promise', async () => { + const item = document.createElement('div'); + + await atom.workspace.open(item); + await atom.workspace.open(null, { split: 'right' }); + expect(atom.workspace.getActivePaneItem()).not.toBe(item); + expect(atom.workspace.getActivePane().getItems().length).toBe(1); + + let rejection; + try { + await atom.workspace.open(item); + } catch (error) { + rejection = error; + } + + expect(rejection.message).toMatch( + /The workspace can only contain one instance of item/ + ); + }); + }); + }); + + describe("when the 'split' option is set", () => { + describe("when the 'split' option is 'left'", () => { + it('opens the editor in the leftmost pane of the current pane axis', () => { + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitRight(); + expect(workspace.getActivePane()).toBe(pane2); + + let editor = null; + waitsForPromise(() => + workspace.open('a', { split: 'left' }).then(o => { + editor = o; + }) + ); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + expect(pane2.items).toEqual([]); + }); + + // Focus right pane and reopen the file on the left + waitsForPromise(() => { + pane2.focus(); + return workspace.open('a', { split: 'left' }).then(o => { + editor = o; + }); + }); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + expect(pane2.items).toEqual([]); + }); + }); + }); + + describe('when a pane axis is the leftmost sibling of the current pane', () => { + it('opens the new item in the current pane', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitLeft(); + pane2.splitDown(); + pane1.activate(); + expect(workspace.getActivePane()).toBe(pane1); + + waitsForPromise(() => + workspace.open('a', { split: 'left' }).then(o => { + editor = o; + }) + ); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + }); + }); + }); + + describe("when the 'split' option is 'right'", () => { + it('opens the editor in the rightmost pane of the current pane axis', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + let pane2 = null; + waitsForPromise(() => + workspace.open('a', { split: 'right' }).then(o => { + editor = o; + }) + ); + + runs(() => { + pane2 = workspace.getPanes().filter(p => p !== pane1)[0]; + expect(workspace.getActivePane()).toBe(pane2); + expect(pane1.items).toEqual([]); + expect(pane2.items).toEqual([editor]); + }); + + // Focus right pane and reopen the file on the right + waitsForPromise(() => { + pane1.focus(); + return workspace.open('a', { split: 'right' }).then(o => { + editor = o; + }); + }); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane2); + expect(pane1.items).toEqual([]); + expect(pane2.items).toEqual([editor]); + }); + }); + + describe('when a pane axis is the rightmost sibling of the current pane', () => { + it('opens the new item in a new pane split to the right of the current pane', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitRight(); + pane2.splitDown(); + pane1.activate(); + expect(workspace.getActivePane()).toBe(pane1); + let pane4 = null; + + waitsForPromise(() => + workspace.open('a', { split: 'right' }).then(o => { + editor = o; + }) + ); + + runs(() => { + pane4 = workspace.getPanes().filter(p => p !== pane1)[0]; + expect(workspace.getActivePane()).toBe(pane4); + expect(pane4.items).toEqual([editor]); + expect(workspace.getCenter().paneContainer.root.children[0]).toBe( + pane1 + ); + expect(workspace.getCenter().paneContainer.root.children[1]).toBe( + pane4 + ); + }); + }); + }); + }); + + describe("when the 'split' option is 'up'", () => { + it('opens the editor in the topmost pane of the current pane axis', () => { + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitDown(); + expect(workspace.getActivePane()).toBe(pane2); + + let editor = null; + waitsForPromise(() => + workspace.open('a', { split: 'up' }).then(o => { + editor = o; + }) + ); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + expect(pane2.items).toEqual([]); + }); + + // Focus bottom pane and reopen the file on the top + waitsForPromise(() => { + pane2.focus(); + return workspace.open('a', { split: 'up' }).then(o => { + editor = o; + }); + }); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + expect(pane2.items).toEqual([]); + }); + }); + }); + + describe('when a pane axis is the topmost sibling of the current pane', () => { + it('opens the new item in the current pane', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitUp(); + pane2.splitRight(); + pane1.activate(); + expect(workspace.getActivePane()).toBe(pane1); + + waitsForPromise(() => + workspace.open('a', { split: 'up' }).then(o => { + editor = o; + }) + ); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1); + expect(pane1.items).toEqual([editor]); + }); + }); + }); + + describe("when the 'split' option is 'down'", () => { + it('opens the editor in the bottommost pane of the current pane axis', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + let pane2 = null; + waitsForPromise(() => + workspace.open('a', { split: 'down' }).then(o => { + editor = o; + }) + ); + + runs(() => { + pane2 = workspace.getPanes().filter(p => p !== pane1)[0]; + expect(workspace.getActivePane()).toBe(pane2); + expect(pane1.items).toEqual([]); + expect(pane2.items).toEqual([editor]); + }); + + // Focus bottom pane and reopen the file on the right + waitsForPromise(() => { + pane1.focus(); + return workspace.open('a', { split: 'down' }).then(o => { + editor = o; + }); + }); + + runs(() => { + expect(workspace.getActivePane()).toBe(pane2); + expect(pane1.items).toEqual([]); + expect(pane2.items).toEqual([editor]); + }); + }); + + describe('when a pane axis is the bottommost sibling of the current pane', () => { + it('opens the new item in a new pane split to the bottom of the current pane', () => { + let editor = null; + const pane1 = workspace.getActivePane(); + const pane2 = pane1.splitDown(); + pane1.activate(); + expect(workspace.getActivePane()).toBe(pane1); + let pane4 = null; + + waitsForPromise(() => + workspace.open('a', { split: 'down' }).then(o => { + editor = o; + }) + ); + + runs(() => { + pane4 = workspace.getPanes().filter(p => p !== pane1)[0]; + expect(workspace.getActivePane()).toBe(pane4); + expect(pane4.items).toEqual([editor]); + expect(workspace.getCenter().paneContainer.root.children[0]).toBe( + pane1 + ); + expect(workspace.getCenter().paneContainer.root.children[1]).toBe( + pane2 + ); + }); + }); + }); + }); + }); + + describe('when an initialLine and initialColumn are specified', () => { + it('moves the cursor to the indicated location', () => { + waitsForPromise(() => + workspace.open('a', { initialLine: 1, initialColumn: 5 }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([1, 5]) + ); + + waitsForPromise(() => + workspace.open('a', { initialLine: 2, initialColumn: 4 }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([2, 4]) + ); + + waitsForPromise(() => + workspace.open('a', { initialLine: 0, initialColumn: 0 }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([0, 0]) + ); + + waitsForPromise(() => + workspace.open('a', { initialLine: NaN, initialColumn: 4 }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([0, 4]) + ); + + waitsForPromise(() => + workspace.open('a', { initialLine: 2, initialColumn: NaN }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([2, 0]) + ); + + waitsForPromise(() => + workspace.open('a', { + initialLine: Infinity, + initialColumn: Infinity + }) + ); + + runs(() => + expect( + workspace.getActiveTextEditor().getCursorBufferPosition() + ).toEqual([2, 11]) + ); + }); + + it('unfolds the fold containing the line', async () => { + let editor; + + await workspace.open('../sample-with-many-folds.js'); + editor = workspace.getActiveTextEditor(); + editor.foldBufferRow(2); + expect(editor.isFoldedAtBufferRow(2)).toBe(true); + expect(editor.isFoldedAtBufferRow(3)).toBe(true); + + await workspace.open('../sample-with-many-folds.js', { + initialLine: 2 + }); + expect(editor.isFoldedAtBufferRow(2)).toBe(false); + expect(editor.isFoldedAtBufferRow(3)).toBe(false); + }); + }); + + describe('when the file size is over the limit defined in `core.warnOnLargeFileLimit`', () => { + const shouldPromptForFileOfSize = async (size, shouldPrompt) => { + spyOn(fs, 'getSizeSync').andReturn(size * 1048577); + + let selectedButtonIndex = 1; // cancel + atom.applicationDelegate.confirm.andCallFake((options, callback) => + callback(selectedButtonIndex) + ); + + let editor = await workspace.open('sample.js'); + if (shouldPrompt) { + expect(editor).toBeUndefined(); + expect(atom.applicationDelegate.confirm).toHaveBeenCalled(); + + atom.applicationDelegate.confirm.reset(); + selectedButtonIndex = 0; // open the file + + editor = await workspace.open('sample.js'); + + expect(atom.applicationDelegate.confirm).toHaveBeenCalled(); + } else { + expect(editor).not.toBeUndefined(); + } + }; + + it('prompts before opening the file', async () => { + atom.config.set('core.warnOnLargeFileLimit', 20); + await shouldPromptForFileOfSize(20, true); + }); + + it("doesn't prompt on files below the limit", async () => { + atom.config.set('core.warnOnLargeFileLimit', 30); + await shouldPromptForFileOfSize(20, false); + }); + + it('prompts for smaller files with a lower limit', async () => { + atom.config.set('core.warnOnLargeFileLimit', 5); + await shouldPromptForFileOfSize(10, true); + }); + }); + + describe('when passed a path that matches a custom opener', () => { + it('returns the resource returned by the custom opener', () => { + const fooOpener = (pathToOpen, options) => { + if (pathToOpen != null ? pathToOpen.match(/\.foo/) : undefined) { + return { foo: pathToOpen, options }; + } + }; + const barOpener = pathToOpen => { + if (pathToOpen != null ? pathToOpen.match(/^bar:\/\//) : undefined) { + return { bar: pathToOpen }; + } + }; + workspace.addOpener(fooOpener); + workspace.addOpener(barOpener); + + waitsForPromise(() => { + const pathToOpen = atom.project.getDirectories()[0].resolve('a.foo'); + return workspace.open(pathToOpen, { hey: 'there' }).then(item => + expect(item).toEqual({ + foo: pathToOpen, + options: { hey: 'there' } + }) + ); + }); + + waitsForPromise(() => + workspace + .open('bar://baz') + .then(item => expect(item).toEqual({ bar: 'bar://baz' })) + ); + }); + }); + + it("adds the file to the application's recent documents list", () => { + if (process.platform !== 'darwin') { + return; + } // Feature only supported on macOS + spyOn(atom.applicationDelegate, 'addRecentDocument'); + + waitsForPromise(() => workspace.open()); + + runs(() => + expect( + atom.applicationDelegate.addRecentDocument + ).not.toHaveBeenCalled() + ); + + waitsForPromise(() => workspace.open('something://a/url')); + + runs(() => + expect( + atom.applicationDelegate.addRecentDocument + ).not.toHaveBeenCalled() + ); + + waitsForPromise(() => workspace.open(__filename)); + + runs(() => + expect(atom.applicationDelegate.addRecentDocument).toHaveBeenCalledWith( + __filename + ) + ); + }); + + it('notifies ::onDidAddTextEditor observers', () => { + const absolutePath = require.resolve('./fixtures/dir/a'); + const newEditorHandler = jasmine.createSpy('newEditorHandler'); + workspace.onDidAddTextEditor(newEditorHandler); + + let editor = null; + waitsForPromise(() => + workspace.open(absolutePath).then(e => { + editor = e; + }) + ); + + runs(() => + expect(newEditorHandler.argsForCall[0][0].textEditor).toBe(editor) + ); + }); + + describe('when there is an error opening the file', () => { + let notificationSpy = null; + beforeEach(() => + atom.notifications.onDidAddNotification( + (notificationSpy = jasmine.createSpy()) + ) + ); + + describe('when a file does not exist', () => { + it('creates an empty buffer for the specified path', () => { + waitsForPromise(() => workspace.open('not-a-file.md')); + + runs(() => { + const editor = workspace.getActiveTextEditor(); + expect(notificationSpy).not.toHaveBeenCalled(); + expect(editor.getPath()).toContain('not-a-file.md'); + }); + }); + }); + + describe('when the user does not have access to the file', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EACCES, permission denied '${path}'`); + error.path = path; + error.code = 'EACCES'; + throw error; + }) + ); + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')); + + runs(() => { + expect(notificationSpy).toHaveBeenCalled(); + const notification = notificationSpy.mostRecentCall.args[0]; + expect(notification.getType()).toBe('warning'); + expect(notification.getMessage()).toContain('Permission denied'); + expect(notification.getMessage()).toContain('file1'); + }); + }); + }); + + describe('when the the operation is not permitted', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EPERM, operation not permitted '${path}'`); + error.path = path; + error.code = 'EPERM'; + throw error; + }) + ); + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')); + + runs(() => { + expect(notificationSpy).toHaveBeenCalled(); + const notification = notificationSpy.mostRecentCall.args[0]; + expect(notification.getType()).toBe('warning'); + expect(notification.getMessage()).toContain('Unable to open'); + expect(notification.getMessage()).toContain('file1'); + }); + }); + }); + + describe('when the the file is already open in windows', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EBUSY, resource busy or locked '${path}'`); + error.path = path; + error.code = 'EBUSY'; + throw error; + }) + ); + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')); + + runs(() => { + expect(notificationSpy).toHaveBeenCalled(); + const notification = notificationSpy.mostRecentCall.args[0]; + expect(notification.getType()).toBe('warning'); + expect(notification.getMessage()).toContain('Unable to open'); + expect(notification.getMessage()).toContain('file1'); + }); + }); + }); + + describe('when there is an unhandled error', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + throw new Error('I dont even know what is happening right now!!'); + }) + ); + + it('rejects the promise', () => { + waitsFor(done => { + workspace.open('file1').catch(error => { + expect(error.message).toBe( + 'I dont even know what is happening right now!!' + ); + done(); + }); + }); + }); + }); + }); + + describe('when the file is already open in pending state', () => { + it('should terminate the pending state', () => { + let editor = null; + let pane = null; + + waitsForPromise(() => + atom.workspace.open('sample.js', { pending: true }).then(o => { + editor = o; + pane = atom.workspace.getActivePane(); + }) + ); + + runs(() => expect(pane.getPendingItem()).toEqual(editor)); + + waitsForPromise(() => atom.workspace.open('sample.js')); + + runs(() => expect(pane.getPendingItem()).toBeNull()); + }); + }); + + describe('when opening will switch from a pending tab to a permanent tab', () => { + it('keeps the pending tab open', () => { + let editor1 = null; + let editor2 = null; + + waitsForPromise(() => + atom.workspace.open('sample.txt').then(o => { + editor1 = o; + }) + ); + + waitsForPromise(() => + atom.workspace.open('sample2.txt', { pending: true }).then(o => { + editor2 = o; + }) + ); + + runs(() => { + const pane = atom.workspace.getActivePane(); + pane.activateItem(editor1); + expect(pane.getItems().length).toBe(2); + expect(pane.getItems()).toEqual([editor1, editor2]); + }); + }); + }); + + describe('when replacing a pending item which is the last item in a second pane', () => { + it('does not destroy the pane even if core.destroyEmptyPanes is on', () => { + atom.config.set('core.destroyEmptyPanes', true); + let editor1 = null; + let editor2 = null; + const leftPane = atom.workspace.getActivePane(); + let rightPane = null; + + waitsForPromise(() => + atom.workspace + .open('sample.js', { pending: true, split: 'right' }) + .then(o => { + editor1 = o; + rightPane = atom.workspace.getActivePane(); + spyOn(rightPane, 'destroy').andCallThrough(); + }) + ); + + runs(() => { + expect(leftPane).not.toBe(rightPane); + expect(atom.workspace.getActivePane()).toBe(rightPane); + expect(atom.workspace.getActivePane().getItems().length).toBe(1); + expect(rightPane.getPendingItem()).toBe(editor1); + }); + + waitsForPromise(() => + atom.workspace.open('sample.txt', { pending: true }).then(o => { + editor2 = o; + }) + ); + + runs(() => { + expect(rightPane.getPendingItem()).toBe(editor2); + expect(rightPane.destroy.callCount).toBe(0); + }); + }); + }); + + 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', () => { + it('can identify the pane and pane container for a given item or URI', () => { + const uri = 'atom://test-pane-for-item'; + const item = { + element: document.createElement('div'), + getURI() { + return uri; + } + }; + + atom.workspace.getActivePane().activateItem(item); + expect(atom.workspace.paneForItem(item)).toBe( + atom.workspace.getCenter().getActivePane() + ); + expect(atom.workspace.paneContainerForItem(item)).toBe( + atom.workspace.getCenter() + ); + expect(atom.workspace.paneForURI(uri)).toBe( + atom.workspace.getCenter().getActivePane() + ); + expect(atom.workspace.paneContainerForURI(uri)).toBe( + atom.workspace.getCenter() + ); + + atom.workspace.getActivePane().destroyActiveItem(); + atom.workspace + .getLeftDock() + .getActivePane() + .activateItem(item); + expect(atom.workspace.paneForItem(item)).toBe( + atom.workspace.getLeftDock().getActivePane() + ); + expect(atom.workspace.paneContainerForItem(item)).toBe( + atom.workspace.getLeftDock() + ); + expect(atom.workspace.paneForURI(uri)).toBe( + atom.workspace.getLeftDock().getActivePane() + ); + expect(atom.workspace.paneContainerForURI(uri)).toBe( + atom.workspace.getLeftDock() + ); + }); + }); + + describe('::hide(uri)', () => { + let item; + const URI = 'atom://hide-test'; + + beforeEach(() => { + const el = document.createElement('div'); + item = { + getTitle: () => 'Item', + getElement: () => el, + getURI: () => URI + }; + }); + + describe('when called with a URI', () => { + it('if the item for the given URI is in the center, removes it', () => { + const pane = atom.workspace.getActivePane(); + pane.addItem(item); + atom.workspace.hide(URI); + expect(pane.getItems().length).toBe(0); + }); + + it('if the item for the given URI is in a dock, hides the dock', () => { + const dock = atom.workspace.getLeftDock(); + const pane = dock.getActivePane(); + pane.addItem(item); + dock.activate(); + expect(dock.isVisible()).toBe(true); + const itemFound = atom.workspace.hide(URI); + expect(itemFound).toBe(true); + expect(dock.isVisible()).toBe(false); + }); + }); + + describe('when called with an item', () => { + it('if the item is in the center, removes it', () => { + const pane = atom.workspace.getActivePane(); + pane.addItem(item); + atom.workspace.hide(item); + expect(pane.getItems().length).toBe(0); + }); + + it('if the item is in a dock, hides the dock', () => { + const dock = atom.workspace.getLeftDock(); + const pane = dock.getActivePane(); + pane.addItem(item); + dock.activate(); + expect(dock.isVisible()).toBe(true); + const itemFound = atom.workspace.hide(item); + expect(itemFound).toBe(true); + expect(dock.isVisible()).toBe(false); + }); + }); + }); + + describe('::toggle(itemOrUri)', () => { + describe('when the location resolves to a dock', () => { + it('adds or shows the item and its dock if it is not currently visible, and otherwise hides the containing dock', async () => { + const item1 = { + getDefaultLocation() { + return 'left'; + }, + getElement() { + return (this.element = document.createElement('div')); + } + }; + + const item2 = { + getDefaultLocation() { + return 'left'; + }, + getElement() { + return (this.element = document.createElement('div')); + } + }; + + const dock = workspace.getLeftDock(); + expect(dock.isVisible()).toBe(false); + + await workspace.toggle(item1); + expect(dock.isVisible()).toBe(true); + expect(dock.getActivePaneItem()).toBe(item1); + + await workspace.toggle(item2); + expect(dock.isVisible()).toBe(true); + expect(dock.getActivePaneItem()).toBe(item2); + + await workspace.toggle(item1); + expect(dock.isVisible()).toBe(true); + expect(dock.getActivePaneItem()).toBe(item1); + + await workspace.toggle(item1); + expect(dock.isVisible()).toBe(false); + expect(dock.getActivePaneItem()).toBe(item1); + + await workspace.toggle(item2); + expect(dock.isVisible()).toBe(true); + expect(dock.getActivePaneItem()).toBe(item2); + }); + }); + + describe('when the location resolves to the center', () => { + it('adds or shows the item if it is not currently the active pane item, and otherwise removes the item', async () => { + const item1 = { + getDefaultLocation() { + return 'center'; + }, + getElement() { + return (this.element = document.createElement('div')); + } + }; + + const item2 = { + getDefaultLocation() { + return 'center'; + }, + getElement() { + return (this.element = document.createElement('div')); + } + }; + + expect(workspace.getActivePaneItem()).toBeUndefined(); + await workspace.toggle(item1); + expect(workspace.getActivePaneItem()).toBe(item1); + await workspace.toggle(item2); + expect(workspace.getActivePaneItem()).toBe(item2); + await workspace.toggle(item1); + expect(workspace.getActivePaneItem()).toBe(item1); + await workspace.toggle(item1); + expect(workspace.paneForItem(item1)).toBeUndefined(); + expect(workspace.getActivePaneItem()).toBe(item2); + }); + }); + }); + + describe('active pane containers', () => { + it('maintains the active pane and item globally across active pane containers', () => { + const leftDock = workspace.getLeftDock(); + const leftItem1 = { element: document.createElement('div') }; + const leftItem2 = { element: document.createElement('div') }; + const leftItem3 = { element: document.createElement('div') }; + const leftPane1 = leftDock.getActivePane(); + leftPane1.addItems([leftItem1, leftItem2]); + const leftPane2 = leftPane1.splitDown({ items: [leftItem3] }); + + const rightDock = workspace.getRightDock(); + const rightItem1 = { element: document.createElement('div') }; + const rightItem2 = { element: document.createElement('div') }; + const rightItem3 = { element: document.createElement('div') }; + const rightPane1 = rightDock.getActivePane(); + rightPane1.addItems([rightItem1, rightItem2]); + const rightPane2 = rightPane1.splitDown({ items: [rightItem3] }); + + const bottomDock = workspace.getBottomDock(); + const bottomItem1 = { element: document.createElement('div') }; + const bottomItem2 = { element: document.createElement('div') }; + const bottomItem3 = { element: document.createElement('div') }; + const bottomPane1 = bottomDock.getActivePane(); + bottomPane1.addItems([bottomItem1, bottomItem2]); + const bottomPane2 = bottomPane1.splitDown({ items: [bottomItem3] }); + + const center = workspace.getCenter(); + const centerItem1 = { element: document.createElement('div') }; + const centerItem2 = { element: document.createElement('div') }; + const centerItem3 = { element: document.createElement('div') }; + const centerPane1 = center.getActivePane(); + centerPane1.addItems([centerItem1, centerItem2]); + const centerPane2 = centerPane1.splitDown({ items: [centerItem3] }); + + const activePaneContainers = []; + const activePanes = []; + const activeItems = []; + workspace.onDidChangeActivePaneContainer(container => + activePaneContainers.push(container) + ); + workspace.onDidChangeActivePane(pane => activePanes.push(pane)); + workspace.onDidChangeActivePaneItem(item => activeItems.push(item)); + function clearEvents() { + activePaneContainers.length = 0; + activePanes.length = 0; + activeItems.length = 0; + } + + expect(workspace.getActivePaneContainer()).toBe(center); + expect(workspace.getActivePane()).toBe(centerPane2); + expect(workspace.getActivePaneItem()).toBe(centerItem3); + + leftDock.activate(); + expect(workspace.getActivePaneContainer()).toBe(leftDock); + expect(workspace.getActivePane()).toBe(leftPane2); + expect(workspace.getActivePaneItem()).toBe(leftItem3); + expect(activePaneContainers).toEqual([leftDock]); + expect(activePanes).toEqual([leftPane2]); + expect(activeItems).toEqual([leftItem3]); + + clearEvents(); + leftPane1.activate(); + leftPane1.activate(); + expect(workspace.getActivePaneContainer()).toBe(leftDock); + expect(workspace.getActivePane()).toBe(leftPane1); + expect(workspace.getActivePaneItem()).toBe(leftItem1); + expect(activePaneContainers).toEqual([]); + expect(activePanes).toEqual([leftPane1]); + expect(activeItems).toEqual([leftItem1]); + + clearEvents(); + leftPane1.activateItem(leftItem2); + leftPane1.activateItem(leftItem2); + expect(workspace.getActivePaneContainer()).toBe(leftDock); + expect(workspace.getActivePane()).toBe(leftPane1); + expect(workspace.getActivePaneItem()).toBe(leftItem2); + expect(activePaneContainers).toEqual([]); + expect(activePanes).toEqual([]); + expect(activeItems).toEqual([leftItem2]); + + clearEvents(); + expect(rightDock.getActivePane()).toBe(rightPane2); + rightPane1.activate(); + rightPane1.activate(); + expect(workspace.getActivePaneContainer()).toBe(rightDock); + expect(workspace.getActivePane()).toBe(rightPane1); + expect(workspace.getActivePaneItem()).toBe(rightItem1); + expect(activePaneContainers).toEqual([rightDock]); + expect(activePanes).toEqual([rightPane1]); + expect(activeItems).toEqual([rightItem1]); + + clearEvents(); + rightPane1.activateItem(rightItem2); + expect(workspace.getActivePaneContainer()).toBe(rightDock); + expect(workspace.getActivePane()).toBe(rightPane1); + expect(workspace.getActivePaneItem()).toBe(rightItem2); + expect(activePaneContainers).toEqual([]); + expect(activePanes).toEqual([]); + expect(activeItems).toEqual([rightItem2]); + + clearEvents(); + expect(bottomDock.getActivePane()).toBe(bottomPane2); + bottomPane2.activate(); + bottomPane2.activate(); + expect(workspace.getActivePaneContainer()).toBe(bottomDock); + expect(workspace.getActivePane()).toBe(bottomPane2); + expect(workspace.getActivePaneItem()).toBe(bottomItem3); + expect(activePaneContainers).toEqual([bottomDock]); + expect(activePanes).toEqual([bottomPane2]); + expect(activeItems).toEqual([bottomItem3]); + + clearEvents(); + center.activate(); + center.activate(); + expect(workspace.getActivePaneContainer()).toBe(center); + expect(workspace.getActivePane()).toBe(centerPane2); + expect(workspace.getActivePaneItem()).toBe(centerItem3); + expect(activePaneContainers).toEqual([center]); + expect(activePanes).toEqual([centerPane2]); + expect(activeItems).toEqual([centerItem3]); + + clearEvents(); + centerPane1.activate(); + centerPane1.activate(); + expect(workspace.getActivePaneContainer()).toBe(center); + expect(workspace.getActivePane()).toBe(centerPane1); + expect(workspace.getActivePaneItem()).toBe(centerItem1); + expect(activePaneContainers).toEqual([]); + expect(activePanes).toEqual([centerPane1]); + expect(activeItems).toEqual([centerItem1]); + }); + }); + + 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')); + + const emittedItems = []; + atom.workspace.onDidStopChangingActivePaneItem(item => + emittedItems.push(item) + ); + + pane2.activateNextItem(); + pane2.activateNextItem(); + pane1.activate(); + atom.workspace.getLeftDock().activate(); + + advanceClock(100); + expect(emittedItems).toEqual([ + atom.workspace.getLeftDock().getActivePaneItem() + ]); + }); + }); + + describe('the grammar-used hook', () => { + it('fires when opening a file or changing the grammar of an open file', async () => { + await atom.packages.activatePackage('language-javascript'); + await atom.packages.activatePackage('language-coffee-script'); + + const observeTextEditorsSpy = jasmine.createSpy('observeTextEditors'); + const javascriptGrammarUsed = jasmine.createSpy('javascript'); + const coffeeScriptGrammarUsed = jasmine.createSpy('coffeescript'); + + atom.packages.triggerDeferredActivationHooks(); + atom.packages.onDidTriggerActivationHook( + 'language-javascript:grammar-used', + () => { + atom.workspace.observeTextEditors(observeTextEditorsSpy); + javascriptGrammarUsed(); + } + ); + atom.packages.onDidTriggerActivationHook( + 'language-coffee-script:grammar-used', + coffeeScriptGrammarUsed + ); + + expect(javascriptGrammarUsed).not.toHaveBeenCalled(); + expect(observeTextEditorsSpy).not.toHaveBeenCalled(); + const editor = await atom.workspace.open('sample.js', { + autoIndent: false + }); + expect(javascriptGrammarUsed).toHaveBeenCalled(); + expect(observeTextEditorsSpy.callCount).toBe(1); + + expect(coffeeScriptGrammarUsed).not.toHaveBeenCalled(); + atom.grammars.assignLanguageMode(editor, 'source.coffee'); + expect(coffeeScriptGrammarUsed).toHaveBeenCalled(); + }); + }); + + describe('the root-scope-used hook', () => { + it('fires when opening a file or changing the grammar of an open file', async () => { + await atom.packages.activatePackage('language-javascript'); + await atom.packages.activatePackage('language-coffee-script'); + + const observeTextEditorsSpy = jasmine.createSpy('observeTextEditors'); + const javascriptGrammarUsed = jasmine.createSpy('javascript'); + const coffeeScriptGrammarUsed = jasmine.createSpy('coffeescript'); + + atom.packages.triggerDeferredActivationHooks(); + atom.packages.onDidTriggerActivationHook( + 'source.js:root-scope-used', + () => { + atom.workspace.observeTextEditors(observeTextEditorsSpy); + javascriptGrammarUsed(); + } + ); + atom.packages.onDidTriggerActivationHook( + 'source.coffee:root-scope-used', + coffeeScriptGrammarUsed + ); + + expect(javascriptGrammarUsed).not.toHaveBeenCalled(); + expect(observeTextEditorsSpy).not.toHaveBeenCalled(); + const editor = await atom.workspace.open('sample.js', { + autoIndent: false + }); + expect(javascriptGrammarUsed).toHaveBeenCalled(); + expect(observeTextEditorsSpy.callCount).toBe(1); + + expect(coffeeScriptGrammarUsed).not.toHaveBeenCalled(); + atom.grammars.assignLanguageMode(editor, 'source.coffee'); + expect(coffeeScriptGrammarUsed).toHaveBeenCalled(); + }); + }); + + describe('::reopenItem()', () => { + it("opens the uri associated with the last closed pane that isn't currently open", () => { + const pane = workspace.getActivePane(); + waitsForPromise(() => + workspace + .open('a') + .then(() => + workspace + .open('b') + .then(() => workspace.open('file1').then(() => workspace.open())) + ) + ); + + runs(() => { + // does not reopen items with no uri + expect(workspace.getActivePaneItem().getURI()).toBeUndefined(); + pane.destroyActiveItem(); + }); + + waitsForPromise(() => workspace.reopenItem()); + + const firstDirectory = atom.project.getDirectories()[0]; + expect(firstDirectory).toBeDefined(); + + runs(() => { + expect(workspace.getActivePaneItem().getURI()).not.toBeUndefined(); + + // destroy all items + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('file1') + ); + pane.destroyActiveItem(); + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('b') + ); + pane.destroyActiveItem(); + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('a') + ); + pane.destroyActiveItem(); + + // reopens items with uris + expect(workspace.getActivePaneItem()).toBeUndefined(); + }); + + waitsForPromise(() => workspace.reopenItem()); + + runs(() => + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('a') + ) + ); + + // does not reopen items that are already open + waitsForPromise(() => workspace.open('b')); + + runs(() => + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('b') + ) + ); + + waitsForPromise(() => workspace.reopenItem()); + + runs(() => + expect(workspace.getActivePaneItem().getURI()).toBe( + firstDirectory.resolve('file1') + ) + ); + }); + }); + + describe('::increase/decreaseFontSize()', () => { + it('increases/decreases the font size without going below 1', () => { + atom.config.set('editor.fontSize', 1); + workspace.increaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(2); + workspace.increaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(3); + workspace.decreaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(2); + workspace.decreaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(1); + workspace.decreaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(1); + }); + }); + + describe('::resetFontSize()', () => { + it("resets the font size to the window's default font size", () => { + const defaultFontSize = atom.config.get('editor.defaultFontSize'); + + workspace.increaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(defaultFontSize + 1); + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(defaultFontSize); + workspace.decreaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(defaultFontSize - 1); + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(defaultFontSize); + }); + + it('resets the font size the default font size when it is changed', () => { + const defaultFontSize = atom.config.get('editor.defaultFontSize'); + workspace.increaseFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(defaultFontSize + 1); + atom.config.set('editor.defaultFontSize', 14); + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(14); + }); + + it('does nothing if the font size has not been changed', () => { + const originalFontSize = atom.config.get('editor.fontSize'); + + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize); + }); + + it("resets the font size when the editor's font size changes", () => { + const originalFontSize = atom.config.get('editor.fontSize'); + + atom.config.set('editor.fontSize', originalFontSize + 1); + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize); + atom.config.set('editor.fontSize', originalFontSize - 1); + workspace.resetFontSize(); + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize); + }); + }); + + describe('::openLicense()', () => { + it('opens the license as plain-text in a buffer', () => { + waitsForPromise(() => workspace.openLicense()); + runs(() => + expect(workspace.getActivePaneItem().getText()).toMatch(/Copyright/) + ); + }); + }); + + describe('::isTextEditor(obj)', () => { + it('returns true when the passed object is an instance of `TextEditor`', () => { + expect(workspace.isTextEditor(new TextEditor())).toBe(true); + expect(workspace.isTextEditor({ getText: () => null })).toBe(false); + expect(workspace.isTextEditor(null)).toBe(false); + expect(workspace.isTextEditor(undefined)).toBe(false); + }); + }); + + describe('::getActiveTextEditor()', () => { + describe("when the workspace center's active pane item is a text editor", () => { + describe('when the workspace center has focus', () => { + it('returns the text editor', () => { + const workspaceCenter = workspace.getCenter(); + const editor = new TextEditor(); + workspaceCenter.getActivePane().activateItem(editor); + workspaceCenter.activate(); + + expect(workspace.getActiveTextEditor()).toBe(editor); + }); + }); + + describe('when a dock has focus', () => { + it('returns the text editor', () => { + const workspaceCenter = workspace.getCenter(); + const editor = new TextEditor(); + workspaceCenter.getActivePane().activateItem(editor); + workspace.getLeftDock().activate(); + + expect(workspace.getActiveTextEditor()).toBe(editor); + }); + }); + }); + + describe("when the workspace center's active pane item is not a text editor", () => { + it('returns undefined', () => { + const workspaceCenter = workspace.getCenter(); + const nonEditorItem = document.createElement('div'); + workspaceCenter.getActivePane().activateItem(nonEditorItem); + + expect(workspace.getActiveTextEditor()).toBeUndefined(); + }); + }); + }); + + describe('::observeTextEditors()', () => { + it('invokes the observer with current and future text editors', () => { + const observed = []; + + waitsForPromise(() => workspace.open()); + waitsForPromise(() => workspace.open()); + waitsForPromise(() => workspace.openLicense()); + + runs(() => workspace.observeTextEditors(editor => observed.push(editor))); + + waitsForPromise(() => workspace.open()); + + expect(observed).toEqual(workspace.getTextEditors()); + }); + }); + + describe('::observeActiveTextEditor()', () => { + it('invokes the observer with current active text editor and each time a different text editor becomes active', () => { + const pane = workspace.getCenter().getActivePane(); + const observed = []; + + const inactiveEditorBeforeRegisteringObserver = new TextEditor(); + const activeEditorBeforeRegisteringObserver = new TextEditor(); + pane.activateItem(inactiveEditorBeforeRegisteringObserver); + pane.activateItem(activeEditorBeforeRegisteringObserver); + + workspace.observeActiveTextEditor(editor => observed.push(editor)); + + const editorAddedAfterRegisteringObserver = new TextEditor(); + pane.activateItem(editorAddedAfterRegisteringObserver); + + expect(observed).toEqual([ + activeEditorBeforeRegisteringObserver, + editorAddedAfterRegisteringObserver + ]); + }); + }); + + describe('::onDidChangeActiveTextEditor()', () => { + let center, pane, observed; + + beforeEach(() => { + center = workspace.getCenter(); + pane = center.getActivePane(); + observed = []; + }); + + it("invokes the observer when a text editor becomes the workspace center's active pane item while a dock has focus", () => { + workspace.onDidChangeActiveTextEditor(editor => observed.push(editor)); + + const dock = workspace.getLeftDock(); + dock.activate(); + expect(atom.workspace.getActivePaneContainer()).toBe(dock); + + const editor = new TextEditor(); + center.getActivePane().activateItem(editor); + expect(atom.workspace.getActivePaneContainer()).toBe(dock); + + expect(observed).toEqual([editor]); + }); + + it('invokes the observer when the last text editor is closed', () => { + const editor = new TextEditor(); + pane.activateItem(editor); + + workspace.onDidChangeActiveTextEditor(editor => observed.push(editor)); + pane.destroyItem(editor); + expect(observed).toEqual([undefined]); + }); + + it("invokes the observer when the workspace center's active pane item changes from an editor item to a non-editor item", () => { + const editor = new TextEditor(); + const nonEditorItem = document.createElement('div'); + pane.activateItem(editor); + + workspace.onDidChangeActiveTextEditor(editor => observed.push(editor)); + pane.activateItem(nonEditorItem); + expect(observed).toEqual([undefined]); + }); + + it("does not invoke the observer when the workspace center's active pane item changes from a non-editor item to another non-editor item", () => { + workspace.onDidChangeActiveTextEditor(editor => observed.push(editor)); + + const nonEditorItem1 = document.createElement('div'); + const nonEditorItem2 = document.createElement('div'); + pane.activateItem(nonEditorItem1); + pane.activateItem(nonEditorItem2); + + expect(observed).toEqual([]); + }); + + it('invokes the observer when closing the one and only text editor after deserialization', async () => { + pane.activateItem(new TextEditor()); + + simulateReload(); + + runs(() => { + workspace.onDidChangeActiveTextEditor(editor => observed.push(editor)); + workspace.closeActivePaneItemOrEmptyPaneOrWindow(); + expect(observed).toEqual([undefined]); + }); + }); + }); + + describe('when an editor is destroyed', () => { + 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', async () => { + await atom.packages.activatePackage('language-javascript'); + + const editor = await workspace.open('a'); + + 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'); + }); + }); + + it('stores the active grammars used by all the open editors', () => { + waitsForPromise(() => atom.packages.activatePackage('language-javascript')); + + waitsForPromise(() => + atom.packages.activatePackage('language-coffee-script') + ); + + waitsForPromise(() => atom.packages.activatePackage('language-todo')); + + waitsForPromise(() => atom.workspace.open('sample.coffee')); + + runs(() => { + atom.workspace.getActiveTextEditor().setText(dedent` + i = /test/; #FIXME\ + `); + + const atom2 = new AtomEnvironment({ + applicationDelegate: atom.applicationDelegate + }); + atom2.initialize({ + window: document.createElement('div'), + document: Object.assign(document.createElement('div'), { + body: document.createElement('div'), + head: document.createElement('div') + }) + }); + + atom2.packages.loadPackage('language-javascript'); + atom2.packages.loadPackage('language-coffee-script'); + atom2.packages.loadPackage('language-todo'); + atom2.project.deserialize(atom.project.serialize()); + atom2.workspace.deserialize( + atom.workspace.serialize(), + atom2.deserializers + ); + + expect( + atom2.grammars + .getGrammars({ includeTreeSitter: true }) + .map(grammar => grammar.scopeName) + .sort() + ).toEqual([ + 'source.coffee', + 'source.js', // Tree-sitter grammars also load + 'source.js', + 'source.js.regexp', + 'source.js.regexp', + 'source.js.regexp.replacement', + 'source.jsdoc', + 'source.jsdoc', + 'source.litcoffee', + 'text.plain.null-grammar', + 'text.todo' + ]); + + atom2.destroy(); + }); + }); + + describe('document.title', () => { + describe('when there is no item open', () => { + it('sets the title to the project path', () => + expect(document.title).toMatch( + escapeStringRegex(fs.tildify(atom.project.getPaths()[0])) + )); + + it("sets the title to 'untitled' if there is no project path", () => { + atom.project.setPaths([]); + expect(document.title).toMatch(/^untitled/); + }); + }); + + describe("when the active pane item's path is not inside a project path", () => { + beforeEach(() => + waitsForPromise(() => + atom.workspace.open('b').then(() => atom.project.setPaths([])) + ) + ); + + it("sets the title to the pane item's title plus the item's path", () => { + const item = atom.workspace.getActivePaneItem(); + const pathEscaped = fs.tildify( + escapeStringRegex(path.dirname(item.getPath())) + ); + expect(document.title).toMatch( + new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + + describe('when the title of the active pane item changes', () => { + it("updates the window title based on the item's new title", () => { + const editor = atom.workspace.getActivePaneItem(); + editor.buffer.setPath(path.join(temp.dir, 'hi')); + const pathEscaped = fs.tildify( + escapeStringRegex(path.dirname(editor.getPath())) + ); + expect(document.title).toMatch( + new RegExp(`^${editor.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + }); + + describe("when the active pane's item changes", () => { + it("updates the title to the new item's title plus the project path", () => { + atom.workspace.getActivePane().activateNextItem(); + const item = atom.workspace.getActivePaneItem(); + const pathEscaped = fs.tildify( + escapeStringRegex(path.dirname(item.getPath())) + ); + expect(document.title).toMatch( + new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + }); + + describe("when an inactive pane's item changes", () => { + it('does not update the title', () => { + const pane = atom.workspace.getActivePane(); + pane.splitRight(); + const initialTitle = document.title; + pane.activateNextItem(); + expect(document.title).toBe(initialTitle); + }); + }); + }); + + describe('when the active pane item is inside a project path', () => { + beforeEach(() => waitsForPromise(() => atom.workspace.open('b'))); + + describe('when there is an active pane item', () => { + it("sets the title to the pane item's title plus the project path", () => { + const item = atom.workspace.getActivePaneItem(); + const pathEscaped = fs.tildify( + escapeStringRegex(atom.project.getPaths()[0]) + ); + expect(document.title).toMatch( + new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + }); + + describe('when the title of the active pane item changes', () => { + it("updates the window title based on the item's new title", () => { + const editor = atom.workspace.getActivePaneItem(); + editor.buffer.setPath(path.join(atom.project.getPaths()[0], 'hi')); + const pathEscaped = fs.tildify( + escapeStringRegex(atom.project.getPaths()[0]) + ); + expect(document.title).toMatch( + new RegExp(`^${editor.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + }); + + describe("when the active pane's item changes", () => { + it("updates the title to the new item's title plus the project path", () => { + atom.workspace.getActivePane().activateNextItem(); + const item = atom.workspace.getActivePaneItem(); + const pathEscaped = fs.tildify( + escapeStringRegex(atom.project.getPaths()[0]) + ); + expect(document.title).toMatch( + new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`) + ); + }); + }); + + describe('when the last pane item is removed', () => { + it("updates the title to the project's first path", () => { + atom.workspace.getActivePane().destroy(); + expect(atom.workspace.getActivePaneItem()).toBeUndefined(); + expect(document.title).toMatch( + escapeStringRegex(fs.tildify(atom.project.getPaths()[0])) + ); + }); + }); + + describe("when an inactive pane's item changes", () => { + it('does not update the title', () => { + const pane = atom.workspace.getActivePane(); + pane.splitRight(); + const initialTitle = document.title; + pane.activateNextItem(); + expect(document.title).toBe(initialTitle); + }); + }); + }); + + describe('when the workspace is deserialized', () => { + beforeEach(() => waitsForPromise(() => atom.workspace.open('a'))); + + it("updates the title to contain the project's path", () => { + document.title = null; + + const atom2 = new AtomEnvironment({ + applicationDelegate: atom.applicationDelegate + }); + atom2.initialize({ + window: document.createElement('div'), + document: Object.assign(document.createElement('div'), { + body: document.createElement('div'), + head: document.createElement('div') + }) + }); + + waitsForPromise(() => + atom2.project.deserialize(atom.project.serialize()) + ); + + runs(() => { + atom2.workspace.deserialize( + atom.workspace.serialize(), + atom2.deserializers + ); + const item = atom2.workspace.getActivePaneItem(); + const pathEscaped = fs.tildify( + escapeStringRegex(atom.project.getPaths()[0]) + ); + expect(document.title).toMatch( + new RegExp(`^${item.getLongTitle()} \\u2014 ${pathEscaped}`) + ); + + atom2.destroy(); + }); + }); + }); + }); + + describe('document edited status', () => { + let item1; + let item2; + + beforeEach(() => { + waitsForPromise(() => atom.workspace.open('a')); + waitsForPromise(() => atom.workspace.open('b')); + runs(() => { + [item1, item2] = atom.workspace.getPaneItems(); + }); + }); + + it('calls setDocumentEdited when the active item changes', () => { + expect(atom.workspace.getActivePaneItem()).toBe(item2); + item1.insertText('a'); + expect(item1.isModified()).toBe(true); + atom.workspace.getActivePane().activateNextItem(); + + expect(setDocumentEdited).toHaveBeenCalledWith(true); + }); + + it("calls atom.setDocumentEdited when the active item's modified status changes", () => { + expect(atom.workspace.getActivePaneItem()).toBe(item2); + item2.insertText('a'); + advanceClock(item2.getBuffer().getStoppedChangingDelay()); + + expect(item2.isModified()).toBe(true); + expect(setDocumentEdited).toHaveBeenCalledWith(true); + + item2.undo(); + advanceClock(item2.getBuffer().getStoppedChangingDelay()); + + expect(item2.isModified()).toBe(false); + expect(setDocumentEdited).toHaveBeenCalledWith(false); + }); + }); + + describe('adding panels', () => { + class TestItem {} + + // Don't use ES6 classes because then we'll have to call `super()` which we can't do with + // HTMLElement + function TestItemElement() { + this.constructor = TestItemElement; + } + function Ctor() { + this.constructor = TestItemElement; + } + Ctor.prototype = HTMLElement.prototype; + TestItemElement.prototype = new Ctor(); + TestItemElement.__super__ = HTMLElement.prototype; + TestItemElement.prototype.initialize = function(model) { + this.model = model; + return this; + }; + TestItemElement.prototype.getModel = function() { + return this.model; + }; + + beforeEach(() => + atom.views.addViewProvider(TestItem, model => + new TestItemElement().initialize(model) + ) + ); + + describe('::addLeftPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getLeftPanels().length).toBe(0); + atom.workspace.panelContainers.left.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addLeftPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getLeftPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addRightPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getRightPanels().length).toBe(0); + atom.workspace.panelContainers.right.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addRightPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getRightPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addTopPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getTopPanels().length).toBe(0); + atom.workspace.panelContainers.top.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addTopPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getTopPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addBottomPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getBottomPanels().length).toBe(0); + atom.workspace.panelContainers.bottom.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addBottomPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getBottomPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addHeaderPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getHeaderPanels().length).toBe(0); + atom.workspace.panelContainers.header.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addHeaderPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getHeaderPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addFooterPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getFooterPanels().length).toBe(0); + atom.workspace.panelContainers.footer.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addFooterPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getFooterPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::addModalPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy; + expect(atom.workspace.getModalPanels().length).toBe(0); + atom.workspace.panelContainers.modal.onDidAddPanel( + (addPanelSpy = jasmine.createSpy()) + ); + + const model = new TestItem(); + const panel = atom.workspace.addModalPanel({ item: model }); + + expect(panel).toBeDefined(); + expect(addPanelSpy).toHaveBeenCalledWith({ panel, index: 0 }); + + const itemView = atom.views.getView( + atom.workspace.getModalPanels()[0].getItem() + ); + expect(itemView instanceof TestItemElement).toBe(true); + expect(itemView.getModel()).toBe(model); + }); + }); + + describe('::panelForItem(item)', () => { + it('returns the panel associated with the item', () => { + const item = new TestItem(); + const panel = atom.workspace.addLeftPanel({ item }); + + const itemWithNoPanel = new TestItem(); + + expect(atom.workspace.panelForItem(item)).toBe(panel); + expect(atom.workspace.panelForItem(itemWithNoPanel)).toBe(null); + }); + }); + }); + + for (const ripgrep of [true, false]) { + describe(`::scan(regex, options, callback) { ripgrep: ${ripgrep} }`, () => { + function scan(regex, options, iterator) { + return atom.workspace.scan(regex, { ...options, ripgrep }, iterator); + } + + describe('when called with a regex', () => { + it('calls the callback with all regex results in all files in the project', async () => { + const results = []; + await scan( + /(a)+/, + { leadingContextLineCount: 1, trailingContextLineCount: 1 }, + result => results.push(result) + ); + + results.sort((a, b) => a.filePath.localeCompare(b.filePath)); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].filePath).toBe( + atom.project.getDirectories()[0].resolve('a') + ); + expect(results[0].matches).toHaveLength(3); + expect(results[0].matches[0]).toEqual({ + matchText: 'aaa', + lineText: 'aaa bbb', + lineTextOffset: 0, + range: [[0, 0], [0, 3]], + leadingContextLines: [], + trailingContextLines: ['cc aa cc'] + }); + }); + + it('works with with escaped literals (like $ and ^)', async () => { + const results = []; + await scan( + /\$\w+/, + { leadingContextLineCount: 1, trailingContextLineCount: 1 }, + result => results.push(result) + ); + + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe(atom.project.getDirectories()[0].resolve('a')); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: '$bill', + lineText: 'dollar$bill', + lineTextOffset: 0, + range: [[2, 6], [2, 11]], + leadingContextLines: ['cc aa cc'], + trailingContextLines: [] + }); + }); + + it('works on evil filenames', async () => { + atom.config.set('core.excludeVcsIgnoredPaths', false); + platform.generateEvilFiles(); + atom.project.setPaths([ + path.join(__dirname, 'fixtures', 'evil-files') + ]); + const paths = []; + let matches = []; + + await scan(/evil/, {}, result => { + paths.push(result.filePath); + matches = matches.concat(result.matches); + }); + + // Sort the paths to make the test deterministic. + paths.sort(); + + _.each(matches, m => expect(m.matchText).toEqual('evil')); + + if (platform.isWindows()) { + expect(paths.length).toBe(3); + expect(paths[0]).toMatch(/a_file_with_utf8.txt$/); + expect(paths[1]).toMatch(/file with spaces.txt$/); + expect(path.basename(paths[2])).toBe('utfa\u0306.md'); + } else { + expect(paths.length).toBe(5); + expect(paths[0]).toMatch(/a_file_with_utf8.txt$/); + expect(paths[1]).toMatch(/file with spaces.txt$/); + expect(paths[2]).toMatch(/goddam\nnewlines$/m); + expect(paths[3]).toMatch(/quote".txt$/m); + expect(path.basename(paths[4])).toBe('utfa\u0306.md'); + } + }); + + it('ignores case if the regex includes the `i` flag', async () => { + const results = []; + await scan(/DOLLAR/i, {}, result => results.push(result)); + + expect(results).toHaveLength(1); + }); + + if (ripgrep) { + it('returns empty text matches', async () => { + const results = []; + await scan( + /^\s{0}/, + { + paths: [`oh-git`] + }, + result => results.push(result) + ); + + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve(path.join('a-dir', 'oh-git')) + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: '', + lineText: 'bbb aaaa', + lineTextOffset: 0, + range: [[0, 0], [0, 0]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + + describe('newlines on regexps', async () => { + it('returns multiline results from regexps', async () => { + const results = []; + + await scan(/first\nsecond/, {}, result => results.push(result)); + + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve('file-with-newline-literal') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'first\nsecond', + lineText: 'first\nsecond\\nthird', + lineTextOffset: 0, + range: [[3, 0], [4, 6]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + + it('returns correctly the context lines', async () => { + const results = []; + + await scan( + /first\nsecond/, + { + leadingContextLineCount: 2, + trailingContextLineCount: 2 + }, + result => results.push(result) + ); + + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve('file-with-newline-literal') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'first\nsecond', + lineText: 'first\nsecond\\nthird', + lineTextOffset: 0, + range: [[3, 0], [4, 6]], + leadingContextLines: ['newline2', 'newline3'], + trailingContextLines: ['newline4', 'newline5'] + }); + }); + + it('returns multiple results from the same line', async () => { + const results = []; + + await scan(/line\d\nne/, {}, result => results.push(result)); + + results.sort((a, b) => a.filePath.localeCompare(b.filePath)); + + expect(results.length).toBe(1); + + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve('file-with-newline-literal') + ); + expect(matches).toHaveLength(3); + expect(matches[0]).toEqual({ + matchText: 'line1\nne', + lineText: 'newline1\nnewline2', + lineTextOffset: 0, + range: [[0, 3], [1, 2]], + leadingContextLines: [], + trailingContextLines: [] + }); + expect(matches[1]).toEqual({ + matchText: 'line2\nne', + lineText: 'newline2\nnewline3', + lineTextOffset: 0, + range: [[1, 3], [2, 2]], + leadingContextLines: [], + trailingContextLines: [] + }); + expect(matches[2]).toEqual({ + matchText: 'line4\nne', + lineText: 'newline4\nnewline5', + lineTextOffset: 0, + range: [[5, 3], [6, 2]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + + it('works with escaped newlines', async () => { + const results = []; + + await scan(/second\\nthird/, {}, result => results.push(result)); + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve('file-with-newline-literal') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'second\\nthird', + lineText: 'second\\nthird', + lineTextOffset: 0, + range: [[4, 0], [4, 13]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + + it('matches a regexp ending with a newline', async () => { + const results = []; + + await scan(/newline3\n/, {}, result => results.push(result)); + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project + .getDirectories()[0] + .resolve('file-with-newline-literal') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'newline3\n', + lineText: 'newline3', + lineTextOffset: 0, + range: [[2, 0], [3, 0]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + }); + describe('pcre2 enabled', async () => { + it('supports lookbehind searches', async () => { + const results = []; + + await scan(/(? + results.push(result) + ); + + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project.getDirectories()[0].resolve('a') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'aa', + lineText: 'cc aa cc', + lineTextOffset: 0, + range: [[1, 3], [1, 5]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + }); + } + + it('returns results on lines with unicode strings', async () => { + const results = []; + + await scan(/line with unico/, {}, result => results.push(result)); + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project.getDirectories()[0].resolve('file-with-unicode') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'line with unico', + lineText: 'ДДДДДДДДДДДДДДДДДД line with unicode', + lineTextOffset: 0, + range: [[0, 19], [0, 34]], + leadingContextLines: [], + trailingContextLines: [] + }); + }); + + it('returns results on files detected as binary', async () => { + const results = []; + + await scan( + /asciiProperty=Foo/, + { + trailingContextLineCount: 2 + }, + result => results.push(result) + ); + expect(results.length).toBe(1); + const { filePath, matches } = results[0]; + expect(filePath).toBe( + atom.project.getDirectories()[0].resolve('file-detected-as-binary') + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toEqual({ + matchText: 'asciiProperty=Foo', + lineText: 'asciiProperty=Foo', + lineTextOffset: 0, + range: [[0, 0], [0, 17]], + leadingContextLines: [], + trailingContextLines: ['utf8Property=Fòò', 'latin1Property=F��'] + }); + }); + + describe('when the core.excludeVcsIgnoredPaths config is used', () => { + let projectPath; + let ignoredPath; + + beforeEach(async () => { + const sourceProjectPath = path.join( + __dirname, + 'fixtures', + 'git', + 'working-dir' + ); + projectPath = path.join(temp.mkdirSync('atom')); + + const writerStream = fstream.Writer(projectPath); + fstream.Reader(sourceProjectPath).pipe(writerStream); + + await new Promise(resolve => { + writerStream.on('close', resolve); + writerStream.on('error', resolve); + }); + + fs.renameSync( + path.join(projectPath, 'git.git'), + path.join(projectPath, '.git') + ); + ignoredPath = path.join(projectPath, 'ignored.txt'); + fs.writeFileSync(ignoredPath, 'this match should not be included'); + }); + + afterEach(() => { + if (fs.existsSync(projectPath)) { + fs.removeSync(projectPath); + } + }); + + it('excludes ignored files when core.excludeVcsIgnoredPaths is true', async () => { + atom.project.setPaths([projectPath]); + atom.config.set('core.excludeVcsIgnoredPaths', true); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/match/, {}, ({ filePath }) => resultHandler(filePath)); + + expect(resultHandler).not.toHaveBeenCalled(); + }); + + it('does not exclude ignored files when core.excludeVcsIgnoredPaths is false', async () => { + atom.project.setPaths([projectPath]); + atom.config.set('core.excludeVcsIgnoredPaths', false); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/match/, {}, ({ filePath }) => resultHandler(filePath)); + + expect(resultHandler).toHaveBeenCalledWith( + path.join(projectPath, 'ignored.txt') + ); + }); + + it('does not exclude files when searching on an ignored folder even when core.excludeVcsIgnoredPaths is true', async () => { + fs.mkdirSync(path.join(projectPath, 'poop')); + ignoredPath = path.join( + path.join(projectPath, 'poop', 'whatever.txt') + ); + fs.writeFileSync(ignoredPath, 'this match should be included'); + + atom.project.setPaths([projectPath]); + atom.config.set('core.excludeVcsIgnoredPaths', true); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/match/, { paths: ['poop'] }, ({ filePath }) => + resultHandler(filePath) + ); + + expect(resultHandler).toHaveBeenCalledWith(ignoredPath); + }); + }); + + describe('when the core.followSymlinks config is used', () => { + let projectPath; + + beforeEach(async () => { + const sourceProjectPath = path.join( + __dirname, + 'fixtures', + 'dir', + 'a-dir' + ); + projectPath = path.join(temp.mkdirSync('atom')); + + const writerStream = fstream.Writer(projectPath); + fstream.Reader(sourceProjectPath).pipe(writerStream); + + await new Promise(resolve => { + writerStream.on('close', resolve); + writerStream.on('error', resolve); + }); + + fs.symlinkSync( + path.join(__dirname, 'fixtures', 'dir', 'b'), + path.join(projectPath, 'symlink') + ); + }); + + afterEach(() => { + if (fs.existsSync(projectPath)) { + fs.removeSync(projectPath); + } + }); + + it('follows symlinks when core.followSymlinks is true', async () => { + atom.project.setPaths([projectPath]); + atom.config.set('core.followSymlinks', true); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/ccc/, {}, ({ filePath }) => resultHandler(filePath)); + + expect(resultHandler).toHaveBeenCalledWith( + path.join(projectPath, 'symlink') + ); + }); + + it('does not follow symlinks when core.followSymlinks is false', async () => { + atom.project.setPaths([projectPath]); + atom.config.set('core.followSymlinks', false); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/ccc/, {}, ({ filePath }) => resultHandler(filePath)); + + expect(resultHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when there are hidden files', () => { + let projectPath; + + beforeEach(async () => { + const sourceProjectPath = path.join( + __dirname, + 'fixtures', + 'dir', + 'a-dir' + ); + projectPath = path.join(temp.mkdirSync('atom')); + + const writerStream = fstream.Writer(projectPath); + fstream.Reader(sourceProjectPath).pipe(writerStream); + + await new Promise(resolve => { + writerStream.on('close', resolve); + writerStream.on('error', resolve); + }); + + // Note: This won't create a hidden file on Windows, in order to more + // accurately test this behaviour there, we should either use a package + // like `fswin` or manually spawn an `ATTRIB` command. + fs.writeFileSync(path.join(projectPath, '.hidden'), 'ccc'); + }); + + afterEach(() => { + if (fs.existsSync(projectPath)) { + fs.removeSync(projectPath); + } + }); + + it('searches on hidden files', async () => { + atom.project.setPaths([projectPath]); + const resultHandler = jasmine.createSpy('result found'); + + await scan(/ccc/, {}, ({ filePath }) => resultHandler(filePath)); + + expect(resultHandler).toHaveBeenCalledWith( + path.join(projectPath, '.hidden') + ); + }); + }); + + it('includes only files when a directory filter is specified', async () => { + const projectPath = path.join( + path.join(__dirname, 'fixtures', 'dir') + ); + atom.project.setPaths([projectPath]); + + const filePath = path.join(projectPath, 'a-dir', 'oh-git'); + + const paths = []; + let matches = []; + + await scan(/aaa/, { paths: [`a-dir${path.sep}`] }, result => { + paths.push(result.filePath); + matches = matches.concat(result.matches); + }); + + expect(paths.length).toBe(1); + expect(paths[0]).toBe(filePath); + expect(matches.length).toBe(1); + }); + + it("includes files and folders that begin with a '.'", async () => { + const projectPath = temp.mkdirSync('atom-spec-workspace'); + const filePath = path.join(projectPath, '.text'); + fs.writeFileSync(filePath, 'match this'); + atom.project.setPaths([projectPath]); + const paths = []; + let matches = []; + + await scan(/match this/, {}, result => { + paths.push(result.filePath); + matches = matches.concat(result.matches); + }); + + expect(paths.length).toBe(1); + expect(paths[0]).toBe(filePath); + expect(matches.length).toBe(1); + }); + + it('excludes values in core.ignoredNames', async () => { + const ignoredNames = atom.config.get('core.ignoredNames'); + ignoredNames.push('a'); + atom.config.set('core.ignoredNames', ignoredNames); + + const resultHandler = jasmine.createSpy('result found'); + await scan(/dollar/, {}, () => resultHandler()); + + expect(resultHandler).not.toHaveBeenCalled(); + }); + + it('scans buffer contents if the buffer is modified', async () => { + const results = []; + const editor = await atom.workspace.open('a'); + + editor.setText('Elephant'); + + await scan(/a|Elephant/, {}, result => results.push(result)); + + expect(results.length).toBeGreaterThan(0); + const resultForA = _.find( + results, + ({ filePath }) => path.basename(filePath) === 'a' + ); + expect(resultForA.matches).toHaveLength(1); + expect(resultForA.matches[0].matchText).toBe('Elephant'); + }); + + it('ignores buffers outside the project', async () => { + const results = []; + const editor = await atom.workspace.open(temp.openSync().path); + + editor.setText('Elephant'); + + await scan(/Elephant/, {}, result => results.push(result)); + + expect(results).toHaveLength(0); + }); + + describe('when the project has multiple root directories', () => { + let dir1; + let dir2; + let file1; + let file2; + + beforeEach(() => { + dir1 = atom.project.getPaths()[0]; + file1 = path.join(dir1, 'a-dir', 'oh-git'); + + dir2 = temp.mkdirSync('a-second-dir'); + const aDir2 = path.join(dir2, 'a-dir'); + file2 = path.join(aDir2, 'a-file'); + fs.mkdirSync(aDir2); + fs.writeFileSync(file2, 'ccc aaaa'); + + atom.project.addPath(dir2); + }); + + it("searches matching files in all of the project's root directories", async () => { + const resultPaths = []; + + await scan(/aaaa/, {}, ({ filePath }) => + resultPaths.push(filePath) + ); + + expect(resultPaths.sort()).toEqual([file1, file2].sort()); + }); + + describe('when an inclusion path starts with the basename of a root directory', () => { + it('interprets the inclusion path as starting from that directory', async () => { + let resultPaths = []; + await scan(/aaaa/, { paths: ['dir'] }, ({ filePath }) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath); + } + }); + + expect(resultPaths).toEqual([file1]); + + resultPaths = []; + await scan( + /aaaa/, + { paths: [path.join('dir', 'a-dir')] }, + ({ filePath }) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath); + } + } + ); + + expect(resultPaths).toEqual([file1]); + + resultPaths = []; + await scan( + /aaaa/, + { paths: [path.basename(dir2)] }, + ({ filePath }) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath); + } + } + ); + + expect(resultPaths).toEqual([file2]); + + resultPaths = []; + await scan( + /aaaa/, + { paths: [path.join(path.basename(dir2), 'a-dir')] }, + ({ filePath }) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath); + } + } + ); + + expect(resultPaths).toEqual([file2]); + }); + }); + + describe('when a custom directory searcher is registered', () => { + let fakeSearch = null; + // Function that is invoked once all of the fields on fakeSearch are set. + let onFakeSearchCreated = null; + + class FakeSearch { + constructor(options) { + // Note that hoisting resolve and reject in this way is generally frowned upon. + this.options = options; + this.promise = new Promise((resolve, reject) => { + this.hoistedResolve = resolve; + this.hoistedReject = reject; + if (typeof onFakeSearchCreated === 'function') { + onFakeSearchCreated(this); + } + }); + } + then(...args) { + return this.promise.then.apply(this.promise, args); + } + cancel() { + this.cancelled = true; + // According to the spec for a DirectorySearcher, invoking `cancel()` should + // resolve the thenable rather than reject it. + this.hoistedResolve(); + } + } + + beforeEach(() => { + fakeSearch = null; + onFakeSearchCreated = null; + atom.packages.serviceHub.provide( + 'atom.directory-searcher', + '0.1.0', + { + canSearchDirectory(directory) { + return directory.getPath() === dir1; + }, + search(directory, regex, options) { + fakeSearch = new FakeSearch(options); + return fakeSearch; + } + } + ); + + waitsFor(() => atom.workspace.directorySearchers.length > 0); + }); + + it('can override the DefaultDirectorySearcher on a per-directory basis', async () => { + const foreignFilePath = 'ssh://foreign-directory:8080/hello.txt'; + const numPathsSearchedInDir2 = 1; + const numPathsToPretendToSearchInCustomDirectorySearcher = 10; + const searchResult = { + filePath: foreignFilePath, + matches: [ + { + lineText: 'Hello world', + lineTextOffset: 0, + matchText: 'Hello', + range: [[0, 0], [0, 5]] + } + ] + }; + onFakeSearchCreated = fakeSearch => { + fakeSearch.options.didMatch(searchResult); + fakeSearch.options.didSearchPaths( + numPathsToPretendToSearchInCustomDirectorySearcher + ); + fakeSearch.hoistedResolve(); + }; + + const resultPaths = []; + const onPathsSearched = jasmine.createSpy('onPathsSearched'); + + await scan(/aaaa/, { onPathsSearched }, ({ filePath }) => + resultPaths.push(filePath) + ); + + expect(resultPaths.sort()).toEqual( + [foreignFilePath, file2].sort() + ); + // onPathsSearched should be called once by each DirectorySearcher. The order is not + // guaranteed, so we can only verify the total number of paths searched is correct + // after the second call. + expect(onPathsSearched.callCount).toBe(2); + expect(onPathsSearched.mostRecentCall.args[0]).toBe( + numPathsToPretendToSearchInCustomDirectorySearcher + + numPathsSearchedInDir2 + ); + }); + + it('can be cancelled when the object returned by scan() has its cancel() method invoked', async () => { + const thenable = scan(/aaaa/, {}, () => {}); + let resultOfPromiseSearch = null; + + waitsFor('fakeSearch to be defined', () => fakeSearch != null); + + runs(() => { + expect(fakeSearch.cancelled).toBe(undefined); + thenable.cancel(); + expect(fakeSearch.cancelled).toBe(true); + }); + + waitsForPromise(() => + thenable.then(promiseResult => { + resultOfPromiseSearch = promiseResult; + }) + ); + + runs(() => expect(resultOfPromiseSearch).toBe('cancelled')); + }); + + it('will have the side-effect of failing the overall search if it fails', () => { + // This provider's search should be cancelled when the first provider fails + let cancelableSearch; + let fakeSearch2 = null; + atom.packages.serviceHub.provide( + 'atom.directory-searcher', + '0.1.0', + { + canSearchDirectory(directory) { + return directory.getPath() === dir2; + }, + search(directory, regex, options) { + fakeSearch2 = new FakeSearch(options); + return fakeSearch2; + } + } + ); + + let didReject = false; + const promise = (cancelableSearch = scan(/aaaa/, () => {})); + waitsFor('fakeSearch to be defined', () => fakeSearch != null); + + runs(() => fakeSearch.hoistedReject()); + + waitsForPromise(() => + cancelableSearch.catch(() => { + didReject = true; + }) + ); + + waitsFor(done => promise.then(null, done)); + + runs(() => { + expect(didReject).toBe(true); + expect(fakeSearch2.cancelled).toBe(true); + }); + }); + }); + }); + }); + + describe('leadingContextLineCount and trailingContextLineCount options', () => { + async function search({ + leadingContextLineCount, + trailingContextLineCount + }) { + const results = []; + await scan( + /result/, + { leadingContextLineCount, trailingContextLineCount }, + result => results.push(result) + ); + + return { + leadingContext: results[0].matches.map( + result => result.leadingContextLines + ), + trailingContext: results[0].matches.map( + result => result.trailingContextLines + ) + }; + } + + const expectedLeadingContext = [ + ['line 1', 'line 2', 'line 3', 'line 4', 'line 5'], + ['line 6', 'line 7', 'line 8', 'line 9', 'line 10'], + ['line 7', 'line 8', 'line 9', 'line 10', 'result 2'], + ['line 10', 'result 2', 'result 3', 'line 11', 'line 12'] + ]; + const expectedTrailingContext = [ + ['line 6', 'line 7', 'line 8', 'line 9', 'line 10'], + ['result 3', 'line 11', 'line 12', 'result 4', 'line 13'], + ['line 11', 'line 12', 'result 4', 'line 13', 'line 14'], + ['line 13', 'line 14', 'line 15'] + ]; + + it('returns valid contexts no matter how many lines are requested', async () => { + expect(await search({})).toEqual({ + leadingContext: [[], [], [], []], + trailingContext: [[], [], [], []] + }); + + expect( + await search({ + leadingContextLineCount: 1, + trailingContextLineCount: 1 + }) + ).toEqual({ + leadingContext: expectedLeadingContext.map(result => + result.slice(-1) + ), + trailingContext: expectedTrailingContext.map(result => + result.slice(0, 1) + ) + }); + + expect( + await search({ + leadingContextLineCount: 2, + trailingContextLineCount: 2 + }) + ).toEqual({ + leadingContext: expectedLeadingContext.map(result => + result.slice(-2) + ), + trailingContext: expectedTrailingContext.map(result => + result.slice(0, 2) + ) + }); + + expect( + await search({ + leadingContextLineCount: 5, + trailingContextLineCount: 5 + }) + ).toEqual({ + leadingContext: expectedLeadingContext.map(result => + result.slice(-5) + ), + trailingContext: expectedTrailingContext.map(result => + result.slice(0, 5) + ) + }); + + expect( + await search({ + leadingContextLineCount: 2, + trailingContextLineCount: 3 + }) + ).toEqual({ + leadingContext: expectedLeadingContext.map(result => + result.slice(-2) + ), + trailingContext: expectedTrailingContext.map(result => + result.slice(0, 3) + ) + }); + }); + }); + }); // Cancels other ongoing searches + } + + describe('::replace(regex, replacementText, paths, iterator)', () => { + let fixturesDir, projectDir; + + beforeEach(() => { + fixturesDir = path.dirname(atom.project.getPaths()[0]); + projectDir = temp.mkdirSync('atom'); + atom.project.setPaths([projectDir]); + }); + + describe("when a file doesn't exist", () => { + it('calls back with an error', () => { + const errors = []; + const missingPath = path.resolve('/not-a-file.js'); + expect(fs.existsSync(missingPath)).toBeFalsy(); + + waitsForPromise(() => + atom.workspace.replace( + /items/gi, + 'items', + [missingPath], + (result, error) => errors.push(error) + ) + ); + + runs(() => { + expect(errors).toHaveLength(1); + expect(errors[0].path).toBe(missingPath); + }); + }); + }); + + describe('when called with unopened files', () => { + it('replaces properly', () => { + const filePath = path.join(projectDir, 'sample.js'); + fs.copyFileSync(path.join(fixturesDir, 'sample.js'), filePath); + + const results = []; + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [filePath], result => + results.push(result) + ) + ); + + runs(() => { + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].replacements).toBe(6); + }); + }); + + it('does not discard the multiline flag', () => { + const filePath = path.join(projectDir, 'sample.js'); + fs.copyFileSync(path.join(fixturesDir, 'sample.js'), filePath); + + const results = []; + waitsForPromise(() => + atom.workspace.replace(/;$/gim, 'items', [filePath], result => + results.push(result) + ) + ); + + runs(() => { + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].replacements).toBe(8); + }); + }); + }); + + describe('when a buffer is already open', () => { + it('replaces properly and saves when not modified', () => { + const filePath = path.join(projectDir, 'sample.js'); + fs.copyFileSync( + path.join(fixturesDir, 'sample.js'), + path.join(projectDir, 'sample.js') + ); + + let editor = null; + const results = []; + + waitsForPromise(() => + atom.workspace.open('sample.js').then(o => { + editor = o; + }) + ); + + runs(() => expect(editor.isModified()).toBeFalsy()); + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [filePath], result => + results.push(result) + ) + ); + + runs(() => { + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].replacements).toBe(6); + + expect(editor.isModified()).toBeFalsy(); + }); + }); + + it('does not replace when the path is not specified', () => { + const filePath = path.join(projectDir, 'sample.js'); + const commentFilePath = path.join( + projectDir, + 'sample-with-comments.js' + ); + fs.copyFileSync(path.join(fixturesDir, 'sample.js'), filePath); + fs.copyFileSync( + path.join(fixturesDir, 'sample-with-comments.js'), + path.join(projectDir, 'sample-with-comments.js') + ); + const results = []; + + waitsForPromise(() => atom.workspace.open('sample-with-comments.js')); + + waitsForPromise(() => + atom.workspace.replace( + /items/gi, + 'items', + [commentFilePath], + result => results.push(result) + ) + ); + + runs(() => { + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(commentFilePath); + }); + }); + + it('does NOT save when modified', () => { + const filePath = path.join(projectDir, 'sample.js'); + fs.copyFileSync(path.join(fixturesDir, 'sample.js'), filePath); + + let editor = null; + const results = []; + + waitsForPromise(() => + atom.workspace.open('sample.js').then(o => { + editor = o; + }) + ); + + runs(() => { + editor.buffer.setTextInRange([[0, 0], [0, 0]], 'omg'); + expect(editor.isModified()).toBeTruthy(); + }); + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'okthen', [filePath], result => + results.push(result) + ) + ); + + runs(() => { + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].replacements).toBe(6); + + expect(editor.isModified()).toBeTruthy(); + }); + }); + }); + }); + + describe('::saveActivePaneItem()', () => { + let editor, notificationSpy; + + beforeEach(() => { + waitsForPromise(() => + atom.workspace.open('sample.js').then(o => { + editor = o; + }) + ); + + notificationSpy = jasmine.createSpy('did-add-notification'); + atom.notifications.onDidAddNotification(notificationSpy); + }); + + describe('when there is an error', () => { + it('emits a warning notification when the file cannot be saved', () => { + spyOn(editor, 'save').andCallFake(() => { + throw new Error("'/some/file' is a directory"); + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the directory cannot be written to', () => { + spyOn(editor, 'save').andCallFake(() => { + throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'"); + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the user does not have permission', () => { + spyOn(editor, 'save').andCallFake(() => { + const error = new Error( + "EACCES, permission denied '/Some/dir/and-a-file.js'" + ); + error.code = 'EACCES'; + error.path = '/Some/dir/and-a-file.js'; + throw error; + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the operation is not permitted', () => { + spyOn(editor, 'save').andCallFake(() => { + const error = new Error( + "EPERM, operation not permitted '/Some/dir/and-a-file.js'" + ); + error.code = 'EPERM'; + error.path = '/Some/dir/and-a-file.js'; + throw error; + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the file is already open by another app', () => { + spyOn(editor, 'save').andCallFake(() => { + const error = new Error( + "EBUSY, resource busy or locked '/Some/dir/and-a-file.js'" + ); + error.code = 'EBUSY'; + error.path = '/Some/dir/and-a-file.js'; + throw error; + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the file system is read-only', () => { + spyOn(editor, 'save').andCallFake(() => { + const error = new Error( + "EROFS, read-only file system '/Some/dir/and-a-file.js'" + ); + error.code = 'EROFS'; + error.path = '/Some/dir/and-a-file.js'; + throw error; + }); + + waitsForPromise(() => + atom.workspace.saveActivePaneItem().then(() => { + expect(notificationSpy).toHaveBeenCalled(); + expect(notificationSpy.mostRecentCall.args[0].getType()).toBe( + 'warning' + ); + expect( + notificationSpy.mostRecentCall.args[0].getMessage() + ).toContain('Unable to save'); + }) + ); + }); + + it('emits a warning notification when the file cannot be saved', () => { + spyOn(editor, 'save').andCallFake(() => { + throw new Error('no one knows'); + }); + + waitsForPromise({ shouldReject: true }, () => + atom.workspace.saveActivePaneItem() + ); + }); + }); + }); + + describe('::closeActivePaneItemOrEmptyPaneOrWindow', () => { + beforeEach(() => { + spyOn(atom, 'close'); + waitsForPromise(() => atom.workspace.open()); + }); + + it('closes the active center pane item, or the active center pane if it is empty, or the current window if there is only the empty root pane in the center', async () => { + atom.config.set('core.destroyEmptyPanes', false); + + const pane1 = atom.workspace.getActivePane(); + const pane2 = pane1.splitRight({ copyActiveItem: true }); + + expect(atom.workspace.getCenter().getPanes().length).toBe(2); + expect(pane2.getItems().length).toBe(1); + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow(); + + expect(atom.workspace.getCenter().getPanes().length).toBe(2); + expect(pane2.getItems().length).toBe(0); + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow(); + + expect(atom.workspace.getCenter().getPanes().length).toBe(1); + expect(pane1.getItems().length).toBe(1); + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow(); + expect(atom.workspace.getCenter().getPanes().length).toBe(1); + expect(pane1.getItems().length).toBe(0); + expect(atom.workspace.getCenter().getPanes().length).toBe(1); + + // The dock items should not be closed + await atom.workspace.open({ + getTitle: () => 'Permanent Dock Item', + element: document.createElement('div'), + getDefaultLocation: () => 'left', + isPermanentDockItem: () => true + }); + await atom.workspace.open({ + getTitle: () => 'Impermanent Dock Item', + element: document.createElement('div'), + getDefaultLocation: () => 'left' + }); + + expect(atom.workspace.getLeftDock().getPaneItems().length).toBe(2); + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow(); + expect(atom.close).toHaveBeenCalled(); + }); + }); + + describe('::activateNextPane', () => { + describe('when the active workspace pane is inside a dock', () => { + it('activates the next pane in the dock', () => { + const dock = atom.workspace.getLeftDock(); + const dockPane1 = dock.getPanes()[0]; + const dockPane2 = dockPane1.splitRight(); + + dockPane2.focus(); + expect(atom.workspace.getActivePane()).toBe(dockPane2); + atom.workspace.activateNextPane(); + expect(atom.workspace.getActivePane()).toBe(dockPane1); + }); + }); + + describe('when the active workspace pane is inside the workspace center', () => { + it('activates the next pane in the workspace center', () => { + const center = atom.workspace.getCenter(); + const centerPane1 = center.getPanes()[0]; + const centerPane2 = centerPane1.splitRight(); + + centerPane2.focus(); + expect(atom.workspace.getActivePane()).toBe(centerPane2); + atom.workspace.activateNextPane(); + expect(atom.workspace.getActivePane()).toBe(centerPane1); + }); + }); + }); + + describe('::activatePreviousPane', () => { + describe('when the active workspace pane is inside a dock', () => { + it('activates the previous pane in the dock', () => { + const dock = atom.workspace.getLeftDock(); + const dockPane1 = dock.getPanes()[0]; + const dockPane2 = dockPane1.splitRight(); + + dockPane1.focus(); + expect(atom.workspace.getActivePane()).toBe(dockPane1); + atom.workspace.activatePreviousPane(); + expect(atom.workspace.getActivePane()).toBe(dockPane2); + }); + }); + + describe('when the active workspace pane is inside the workspace center', () => { + it('activates the previous pane in the workspace center', () => { + const center = atom.workspace.getCenter(); + const centerPane1 = center.getPanes()[0]; + const centerPane2 = centerPane1.splitRight(); + + centerPane1.focus(); + expect(atom.workspace.getActivePane()).toBe(centerPane1); + atom.workspace.activatePreviousPane(); + expect(atom.workspace.getActivePane()).toBe(centerPane2); + }); + }); + }); + + describe('::getVisiblePanes', () => { + it('returns all panes in visible pane containers', () => { + const center = workspace.getCenter(); + const leftDock = workspace.getLeftDock(); + const rightDock = workspace.getRightDock(); + const bottomDock = workspace.getBottomDock(); + + const centerPane = center.getPanes()[0]; + const leftDockPane = leftDock.getPanes()[0]; + const rightDockPane = rightDock.getPanes()[0]; + const bottomDockPane = bottomDock.getPanes()[0]; + + leftDock.hide(); + rightDock.hide(); + bottomDock.hide(); + expect(workspace.getVisiblePanes()).toContain(centerPane); + expect(workspace.getVisiblePanes()).not.toContain(leftDockPane); + expect(workspace.getVisiblePanes()).not.toContain(rightDockPane); + expect(workspace.getVisiblePanes()).not.toContain(bottomDockPane); + + leftDock.show(); + expect(workspace.getVisiblePanes()).toContain(centerPane); + expect(workspace.getVisiblePanes()).toContain(leftDockPane); + expect(workspace.getVisiblePanes()).not.toContain(rightDockPane); + expect(workspace.getVisiblePanes()).not.toContain(bottomDockPane); + + rightDock.show(); + expect(workspace.getVisiblePanes()).toContain(centerPane); + expect(workspace.getVisiblePanes()).toContain(leftDockPane); + expect(workspace.getVisiblePanes()).toContain(rightDockPane); + expect(workspace.getVisiblePanes()).not.toContain(bottomDockPane); + + bottomDock.show(); + expect(workspace.getVisiblePanes()).toContain(centerPane); + expect(workspace.getVisiblePanes()).toContain(leftDockPane); + expect(workspace.getVisiblePanes()).toContain(rightDockPane); + expect(workspace.getVisiblePanes()).toContain(bottomDockPane); + }); + }); + + describe('::getVisiblePaneContainers', () => { + it('returns all visible pane containers', () => { + const center = workspace.getCenter(); + const leftDock = workspace.getLeftDock(); + const rightDock = workspace.getRightDock(); + const bottomDock = workspace.getBottomDock(); + + leftDock.hide(); + rightDock.hide(); + bottomDock.hide(); + expect(workspace.getVisiblePaneContainers()).toEqual([center]); + + leftDock.show(); + expect(workspace.getVisiblePaneContainers().sort()).toEqual([ + center, + leftDock + ]); + + rightDock.show(); + expect(workspace.getVisiblePaneContainers().sort()).toEqual([ + center, + leftDock, + rightDock + ]); + + bottomDock.show(); + expect(workspace.getVisiblePaneContainers().sort()).toEqual([ + center, + leftDock, + rightDock, + bottomDock + ]); + }); + }); + + describe('when the core.allowPendingPaneItems option is falsy', () => { + it('does not open item with `pending: true` option as pending', () => { + let pane = null; + atom.config.set('core.allowPendingPaneItems', false); + + waitsForPromise(() => + atom.workspace.open('sample.js', { pending: true }).then(() => { + pane = atom.workspace.getActivePane(); + }) + ); + + runs(() => expect(pane.getPendingItem()).toBeFalsy()); + }); + }); + + describe('grammar activation', () => { + it('notifies the workspace of which grammar is used', async () => { + atom.packages.triggerDeferredActivationHooks(); + + const javascriptGrammarUsed = jasmine.createSpy('js grammar used'); + const rubyGrammarUsed = jasmine.createSpy('ruby grammar used'); + const cGrammarUsed = jasmine.createSpy('c grammar used'); + + atom.packages.onDidTriggerActivationHook( + 'language-javascript:grammar-used', + javascriptGrammarUsed + ); + atom.packages.onDidTriggerActivationHook( + 'language-ruby:grammar-used', + rubyGrammarUsed + ); + atom.packages.onDidTriggerActivationHook( + 'language-c:grammar-used', + cGrammarUsed + ); + + 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'); + + // Hooks are triggered when opening new editors + expect(javascriptGrammarUsed).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.grammars.assignLanguageMode( + atom.workspace.getActiveTextEditor(), + 'source.ruby' + ); + expect(rubyGrammarUsed).toHaveBeenCalled(); + }); + }); + + describe('.checkoutHeadRevision()', () => { + let editor = null; + beforeEach(async () => { + jasmine.useRealClock(); + atom.config.set('editor.confirmCheckoutHeadRevision', false); + + editor = await atom.workspace.open('sample-with-comments.js'); + }); + + 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('---'); + + atom.workspace.checkoutHeadRevision(editor); + + await conditionPromise(() => editor.lineTextForBufferRow(0) === ''); + }); + + describe("when there's no repository for the editor's file", () => { + it("doesn't do anything", async () => { + editor = new TextEditor(); + editor.setText('stuff'); + atom.workspace.checkoutHeadRevision(editor); + + atom.workspace.checkoutHeadRevision(editor); + }); + }); + }); + + describe('when an item is moved', () => { + beforeEach(() => { + atom.workspace.enablePersistence = true; + }); + + afterEach(async () => { + await atom.workspace.itemLocationStore.clear(); + atom.workspace.enablePersistence = false; + }); + + it("stores the new location if it's not the default", () => { + const ITEM_URI = 'atom://test'; + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: () => 'left', + getElement: () => document.createElement('div') + }; + const centerPane = workspace.getActivePane(); + centerPane.addItem(item); + const dockPane = atom.workspace.getRightDock().getActivePane(); + spyOn(workspace.itemLocationStore, 'save'); + centerPane.moveItemToPane(item, dockPane); + expect(workspace.itemLocationStore.save).toHaveBeenCalledWith( + ITEM_URI, + 'right' + ); + }); + + it("clears the location if it's the default", () => { + const ITEM_URI = 'atom://test'; + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: () => 'right', + getElement: () => document.createElement('div') + }; + const centerPane = workspace.getActivePane(); + centerPane.addItem(item); + const dockPane = atom.workspace.getRightDock().getActivePane(); + spyOn(workspace.itemLocationStore, 'save'); + spyOn(workspace.itemLocationStore, 'delete'); + centerPane.moveItemToPane(item, dockPane); + expect(workspace.itemLocationStore.delete).toHaveBeenCalledWith(ITEM_URI); + expect(workspace.itemLocationStore.save).not.toHaveBeenCalled(); + }); + }); +}); + +function escapeStringRegex(string) { + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); +} diff --git a/spec/workspace-view-spec.coffee b/spec/workspace-view-spec.coffee deleted file mode 100644 index a160cdb393a..00000000000 --- a/spec/workspace-view-spec.coffee +++ /dev/null @@ -1,301 +0,0 @@ -{$, $$, View} = require '../src/space-pen-extensions' -Q = require 'q' -path = require 'path' -temp = require 'temp' -TextEditorView = require '../src/text-editor-view' -PaneView = require '../src/pane-view' -Workspace = require '../src/workspace' - -describe "WorkspaceView", -> - pathToOpen = null - - beforeEach -> - jasmine.snapshotDeprecations() - - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')]) - pathToOpen = atom.project.getDirectories()[0]?.resolve('a') - atom.workspace = new Workspace - atom.workspaceView = atom.views.getView(atom.workspace).__spacePenView - atom.workspaceView.enableKeymap() - atom.workspaceView.focus() - - waitsForPromise -> - atom.workspace.open(pathToOpen) - - afterEach -> - jasmine.restoreDeprecationsSnapshot() - - describe "@deserialize()", -> - viewState = null - - simulateReload = -> - workspaceState = atom.workspace.serialize() - projectState = atom.project.serialize() - atom.workspaceView.remove() - atom.project = atom.deserializers.deserialize(projectState) - atom.workspace = Workspace.deserialize(workspaceState) - atom.workspaceView = atom.views.getView(atom.workspace).__spacePenView - atom.workspaceView.attachToDom() - - describe "when the serialized WorkspaceView has an unsaved buffer", -> - it "constructs the view with the same panes", -> - atom.workspaceView.attachToDom() - - waitsForPromise -> - atom.workspace.open() - - runs -> - editorView1 = atom.workspaceView.getActiveView() - buffer = editorView1.getEditor().getBuffer() - editorView1.getPaneView().getModel().splitRight(copyActiveItem: true) - expect(atom.workspaceView.getActivePaneView()).toBe atom.workspaceView.getPaneViews()[1] - - simulateReload() - - expect(atom.workspaceView.getEditorViews().length).toBe 2 - expect(atom.workspaceView.getActivePaneView()).toBe atom.workspaceView.getPaneViews()[1] - expect(document.title).toBe "untitled - #{atom.project.getPaths()[0]} - Atom" - - describe "when there are open editors", -> - it "constructs the view with the same panes", -> - atom.workspaceView.attachToDom() - pane1 = atom.workspaceView.getActivePaneView() - pane2 = pane1.splitRight() - pane3 = pane2.splitRight() - pane4 = null - - waitsForPromise -> - atom.workspace.open('b').then (editor) -> - pane2.activateItem(editor.copy()) - - waitsForPromise -> - atom.workspace.open('../sample.js').then (editor) -> - pane3.activateItem(editor) - - runs -> - pane3.activeItem.setCursorScreenPosition([2, 4]) - pane4 = pane2.splitDown() - - waitsForPromise -> - atom.workspace.open('../sample.txt').then (editor) -> - pane4.activateItem(editor) - - runs -> - pane4.activeItem.setCursorScreenPosition([0, 2]) - pane2.focus() - - simulateReload() - - expect(atom.workspaceView.getEditorViews().length).toBe 4 - editorView1 = atom.workspaceView.panes.find('atom-pane-axis.horizontal > atom-pane atom-text-editor:eq(0)').view() - editorView3 = atom.workspaceView.panes.find('atom-pane-axis.horizontal > atom-pane atom-text-editor:eq(1)').view() - editorView2 = atom.workspaceView.panes.find('atom-pane-axis.horizontal > atom-pane-axis.vertical > atom-pane atom-text-editor:eq(0)').view() - editorView4 = atom.workspaceView.panes.find('atom-pane-axis.horizontal > atom-pane-axis.vertical > atom-pane atom-text-editor:eq(1)').view() - - expect(editorView1.getEditor().getPath()).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(editorView2.getEditor().getPath()).toBe atom.project.getDirectories()[0]?.resolve('b') - expect(editorView3.getEditor().getPath()).toBe atom.project.getDirectories()[0]?.resolve('../sample.js') - expect(editorView3.getEditor().getCursorScreenPosition()).toEqual [2, 4] - expect(editorView4.getEditor().getPath()).toBe atom.project.getDirectories()[0]?.resolve('../sample.txt') - expect(editorView4.getEditor().getCursorScreenPosition()).toEqual [0, 2] - - # ensure adjust pane dimensions is called - expect(editorView1.width()).toBeGreaterThan 0 - expect(editorView2.width()).toBeGreaterThan 0 - expect(editorView3.width()).toBeGreaterThan 0 - expect(editorView4.width()).toBeGreaterThan 0 - - # ensure correct editorView is focused again - expect(editorView2).toHaveFocus() - expect(editorView1).not.toHaveFocus() - expect(editorView3).not.toHaveFocus() - expect(editorView4).not.toHaveFocus() - - expect(document.title).toBe "#{path.basename(editorView2.getEditor().getPath())} - #{atom.project.getPaths()[0]} - Atom" - - describe "where there are no open editors", -> - it "constructs the view with no open editors", -> - atom.workspaceView.getActivePaneView().remove() - expect(atom.workspaceView.getEditorViews().length).toBe 0 - simulateReload() - expect(atom.workspaceView.getEditorViews().length).toBe 0 - - describe "focus", -> - beforeEach -> - atom.workspaceView.attachToDom() - - it "hands off focus to the active pane", -> - activePane = atom.workspaceView.getActivePaneView() - $('body').focus() - expect(activePane).not.toHaveFocus() - atom.workspaceView.focus() - expect(activePane).toHaveFocus() - - describe "keymap wiring", -> - describe "when a keydown event is triggered in the WorkspaceView", -> - it "triggers matching keybindings for that event", -> - commandHandler = jasmine.createSpy('commandHandler') - atom.workspaceView.on('foo-command', commandHandler) - atom.keymaps.add('name', '*': {'x': 'foo-command'}) - event = keydownEvent 'x', target: atom.workspaceView[0] - - atom.workspaceView.trigger(event) - expect(commandHandler).toHaveBeenCalled() - - describe "window:toggle-invisibles event", -> - it "shows/hides invisibles in all open and future editors", -> - atom.workspaceView.height(200) - atom.workspaceView.attachToDom() - rightEditorView = atom.workspaceView.getActiveView() - rightEditorView.getEditor().setText("\t \n") - rightEditorView.getPaneView().getModel().splitLeft(copyActiveItem: true) - leftEditorView = atom.workspaceView.getActiveView() - expect(rightEditorView.find(".line:first").text()).toBe " " - expect(leftEditorView.find(".line:first").text()).toBe " " - - {space, tab, eol} = atom.config.get('editor.invisibles') - withInvisiblesShowing = "#{tab} #{space}#{space}#{eol}" - - atom.workspaceView.trigger "window:toggle-invisibles" - expect(rightEditorView.find(".line:first").text()).toBe withInvisiblesShowing - expect(leftEditorView.find(".line:first").text()).toBe withInvisiblesShowing - - leftEditorView.getPaneView().getModel().splitDown(copyActiveItem: true) - lowerLeftEditorView = atom.workspaceView.getActiveView() - expect(lowerLeftEditorView.find(".line:first").text()).toBe withInvisiblesShowing - - atom.workspaceView.trigger "window:toggle-invisibles" - expect(rightEditorView.find(".line:first").text()).toBe " " - expect(leftEditorView.find(".line:first").text()).toBe " " - - rightEditorView.getPaneView().getModel().splitDown(copyActiveItem: true) - lowerRightEditorView = atom.workspaceView.getActiveView() - expect(lowerRightEditorView.find(".line:first").text()).toBe " " - - describe ".eachEditorView(callback)", -> - beforeEach -> - atom.workspaceView.attachToDom() - - it "invokes the callback for existing editor", -> - count = 0 - callbackEditor = null - callback = (editor) -> - callbackEditor = editor - count++ - atom.workspaceView.eachEditorView(callback) - expect(count).toBe 1 - expect(callbackEditor).toBe atom.workspaceView.getActiveView() - - it "invokes the callback for new editor", -> - count = 0 - callbackEditor = null - callback = (editor) -> - callbackEditor = editor - count++ - - atom.workspaceView.eachEditorView(callback) - count = 0 - callbackEditor = null - atom.workspaceView.getActiveView().getPaneView().getModel().splitRight(copyActiveItem: true) - expect(count).toBe 1 - expect(callbackEditor).toBe atom.workspaceView.getActiveView() - - it "does not invoke the callback for mini editors", -> - editorViewCreatedHandler = jasmine.createSpy('editorViewCreatedHandler') - atom.workspaceView.eachEditorView(editorViewCreatedHandler) - editorViewCreatedHandler.reset() - miniEditor = new TextEditorView(mini: true) - atom.workspaceView.append(miniEditor) - expect(editorViewCreatedHandler).not.toHaveBeenCalled() - - it "returns a subscription that can be disabled", -> - count = 0 - callback = (editor) -> count++ - - subscription = atom.workspaceView.eachEditorView(callback) - expect(count).toBe 1 - atom.workspaceView.getActiveView().getPaneView().getModel().splitRight(copyActiveItem: true) - expect(count).toBe 2 - subscription.off() - atom.workspaceView.getActiveView().getPaneView().getModel().splitRight(copyActiveItem: true) - expect(count).toBe 2 - - describe "core:close", -> - it "closes the active pane item until all that remains is a single empty pane", -> - atom.config.set('core.destroyEmptyPanes', true) - - paneView1 = atom.workspaceView.getActivePaneView() - editorView = atom.workspaceView.getActiveView() - editorView.getPaneView().getModel().splitRight(copyActiveItem: true) - paneView2 = atom.workspaceView.getActivePaneView() - - expect(paneView1).not.toBe paneView2 - expect(atom.workspaceView.getPaneViews()).toHaveLength 2 - atom.workspaceView.trigger('core:close') - - expect(atom.workspaceView.getActivePaneView().getItems()).toHaveLength 1 - expect(atom.workspaceView.getPaneViews()).toHaveLength 1 - atom.workspaceView.trigger('core:close') - - expect(atom.workspaceView.getActivePaneView().getItems()).toHaveLength 0 - expect(atom.workspaceView.getPaneViews()).toHaveLength 1 - - describe "the scrollbar visibility class", -> - it "has a class based on the style of the scrollbar", -> - style = 'legacy' - scrollbarStyle = require 'scrollbar-style' - spyOn(scrollbarStyle, 'getPreferredScrollbarStyle').andCallFake -> style - - atom.workspaceView.element.observeScrollbarStyle() - expect(atom.workspaceView).toHaveClass 'scrollbars-visible-always' - - style = 'overlay' - atom.workspaceView.element.observeScrollbarStyle() - expect(atom.workspaceView).toHaveClass 'scrollbars-visible-when-scrolling' - - describe "editor font styling", -> - [editorNode, editor] = [] - - beforeEach -> - atom.workspaceView.attachToDom() - editorNode = atom.workspaceView.find('atom-text-editor')[0] - editor = atom.workspaceView.find('atom-text-editor').view().getEditor() - - it "updates the font-size based on the 'editor.fontSize' config value", -> - initialCharWidth = editor.getDefaultCharWidth() - expect(getComputedStyle(editorNode).fontSize).toBe atom.config.get('editor.fontSize') + 'px' - atom.config.set('editor.fontSize', atom.config.get('editor.fontSize') + 5) - expect(getComputedStyle(editorNode).fontSize).toBe atom.config.get('editor.fontSize') + 'px' - expect(editor.getDefaultCharWidth()).toBeGreaterThan initialCharWidth - - it "updates the font-family based on the 'editor.fontFamily' config value", -> - initialCharWidth = editor.getDefaultCharWidth() - expect(getComputedStyle(editorNode).fontFamily).toBe atom.config.get('editor.fontFamily') - atom.config.set('editor.fontFamily', 'sans-serif') - expect(getComputedStyle(editorNode).fontFamily).toBe atom.config.get('editor.fontFamily') - expect(editor.getDefaultCharWidth()).not.toBe initialCharWidth - - it "updates the line-height based on the 'editor.lineHeight' config value", -> - initialLineHeight = editor.getLineHeightInPixels() - atom.config.set('editor.lineHeight', '30px') - expect(getComputedStyle(editorNode).lineHeight).toBe atom.config.get('editor.lineHeight') - expect(editor.getLineHeightInPixels()).not.toBe initialLineHeight - - describe 'panel containers', -> - workspaceElement = null - beforeEach -> - workspaceElement = atom.views.getView(atom.workspace) - - it 'inserts panel container elements in the correct places in the DOM', -> - leftContainer = workspaceElement.querySelector('atom-panel-container.left') - rightContainer = workspaceElement.querySelector('atom-panel-container.right') - expect(leftContainer.nextSibling).toBe workspaceElement.verticalAxis - expect(rightContainer.previousSibling).toBe workspaceElement.verticalAxis - - topContainer = workspaceElement.querySelector('atom-panel-container.top') - bottomContainer = workspaceElement.querySelector('atom-panel-container.bottom') - expect(topContainer.nextSibling).toBe workspaceElement.paneContainer - expect(bottomContainer.previousSibling).toBe workspaceElement.paneContainer - - modalContainer = workspaceElement.querySelector('atom-panel-container.modal') - expect(modalContainer.parentNode).toBe workspaceElement diff --git a/src/application-delegate.js b/src/application-delegate.js new file mode 100644 index 00000000000..f3c4b60c91f --- /dev/null +++ b/src/application-delegate.js @@ -0,0 +1,415 @@ +const { ipcRenderer, remote, shell } = require('electron'); +const ipcHelpers = require('./ipc-helpers'); +const { Emitter, Disposable } = require('event-kit'); +const getWindowLoadSettings = require('./get-window-load-settings'); + +module.exports = class ApplicationDelegate { + constructor() { + this.pendingSettingsUpdateCount = 0; + this._ipcMessageEmitter = null; + } + + ipcMessageEmitter() { + if (!this._ipcMessageEmitter) { + this._ipcMessageEmitter = new Emitter(); + ipcRenderer.on('message', (event, message, detail) => { + this._ipcMessageEmitter.emit(message, detail); + }); + } + return this._ipcMessageEmitter; + } + + 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 stateJSON && 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); + } + + setProjectRoots(paths) { + return ipcHelpers.call('window-method', 'setProjectRoots', 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); + } + + async setUserSettings(config, configFilePath) { + this.pendingSettingsUpdateCount++; + try { + await ipcHelpers.call( + 'set-user-settings', + JSON.stringify(config), + configFilePath + ); + } finally { + this.pendingSettingsUpdateCount--; + } + } + + onDidChangeUserSettings(callback) { + return this.ipcMessageEmitter().on('did-change-user-settings', detail => { + if (this.pendingSettingsUpdateCount === 0) callback(detail); + }); + } + + onDidFailToReadUserSettings(callback) { + return this.ipcMessageEmitter().on( + 'did-fail-to-read-user-setting', + callback + ); + } + + 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) + .then(result => { + callback(result.response, result.checkboxChecked); + }); + } 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.showMessageBoxSync( + 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') return callback(); + } + } + } + + showMessageDialog(params) {} + + showSaveDialog(options, callback) { + if (typeof callback === 'function') { + // Async + this.getCurrentWindow().showSaveDialog(options, callback); + } else { + // Sync + if (typeof options === 'string') { + options = { defaultPath: options }; + } + return this.getCurrentWindow().showSaveDialog(options); + } + } + + playBeepSound() { + return shell.beep(); + } + + onDidOpenLocations(callback) { + return this.ipcMessageEmitter().on('open-locations', callback); + } + + onUpdateAvailable(callback) { + // 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. + return this.ipcMessageEmitter().on( + 'did-begin-downloading-update', + callback + ); + } + + onDidBeginDownloadingUpdate(callback) { + return this.onUpdateAvailable(callback); + } + + onDidBeginCheckingForUpdate(callback) { + return this.ipcMessageEmitter().on('checking-for-update', callback); + } + + onDidCompleteDownloadingUpdate(callback) { + return this.ipcMessageEmitter().on('update-available', callback); + } + + onUpdateNotAvailable(callback) { + return this.ipcMessageEmitter().on('update-not-available', callback); + } + + onUpdateError(callback) { + return this.ipcMessageEmitter().on('update-error', callback); + } + + 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 ipcHelpers.call('will-save-path', path); + } + + emitDidSavePath(path) { + return ipcHelpers.call('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 new file mode 100644 index 00000000000..ac7196437dd --- /dev/null +++ b/src/atom-environment.js @@ -0,0 +1,1772 @@ +const crypto = require('crypto'); +const path = require('path'); +const util = require('util'); +const { ipcRenderer } = require('electron'); + +const _ = require('underscore-plus'); +const { deprecate } = require('grim'); +const { CompositeDisposable, Disposable, Emitter } = require('event-kit'); +const fs = require('fs-plus'); +const { mapSourcePosition } = require('@atom/source-map-support'); +const WindowEventHandler = require('./window-event-handler'); +const StateStore = require('./state-store'); +const registerDefaultCommands = require('./register-default-commands'); +const { updateProcessEnv } = require('./update-process-env'); +const ConfigSchema = require('./config-schema'); + +const DeserializerManager = require('./deserializer-manager'); +const ViewRegistry = require('./view-registry'); +const NotificationManager = require('./notification-manager'); +const Config = require('./config'); +const KeymapManager = require('./keymap-extensions'); +const TooltipManager = require('./tooltip-manager'); +const CommandRegistry = require('./command-registry'); +const URIHandlerRegistry = require('./uri-handler-registry'); +const GrammarRegistry = require('./grammar-registry'); +const { HistoryManager } = require('./history-manager'); +const ReopenProjectMenuManager = require('./reopen-project-menu-manager'); +const StyleManager = require('./style-manager'); +const PackageManager = require('./package-manager'); +const ThemeManager = require('./theme-manager'); +const MenuManager = require('./menu-manager'); +const ContextMenuManager = require('./context-menu-manager'); +const CommandInstaller = require('./command-installer'); +const CoreURIHandlers = require('./core-uri-handlers'); +const ProtocolHandlerInstaller = require('./protocol-handler-installer'); +const Project = require('./project'); +const TitleBar = require('./title-bar'); +const Workspace = require('./workspace'); +const PaneContainer = require('./pane-container'); +const PaneAxis = require('./pane-axis'); +const Pane = require('./pane'); +const Dock = require('./dock'); +const TextEditor = require('./text-editor'); +const TextBuffer = require('text-buffer'); +const TextEditorRegistry = require('./text-editor-registry'); +const AutoUpdateManager = require('./auto-update-manager'); +const StartupTime = require('./startup-time'); +const getReleaseChannel = require('./get-release-channel'); + +const stat = util.promisify(fs.stat); + +let nextId = 0; + +// Essential: Atom global for dealing with packages, themes, menus, and the window. +// +// An instance of this class is always available as the `atom` global. +class AtomEnvironment { + /* + Section: Properties + */ + + 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; + this.applicationDelegate = params.applicationDelegate; + + this.nextProxyRequestId = 0; + this.unloading = false; + this.loadTime = null; + this.emitter = new Emitter(); + this.disposables = new CompositeDisposable(); + this.pathsWithWaitSessions = new Set(); + + // Public: A {DeserializerManager} instance + this.deserializers = new DeserializerManager(this); + this.deserializeTimings = {}; + + // Public: A {ViewRegistry} instance + this.views = new ViewRegistry(this); + + // Public: A {NotificationManager} instance + this.notifications = new NotificationManager(); + + this.stateStore = new StateStore('AtomEnvironments', 1); + + // Public: A {Config} instance + this.config = new Config({ + saveCallback: settings => { + if (this.enablePersistence) { + this.applicationDelegate.setUserSettings( + settings, + this.config.getUserConfigPath() + ); + } + } + }); + 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, + commandRegistry: this.commands, + keymapManager: this.keymaps, + notificationManager: this.notifications, + grammarRegistry: this.grammars, + deserializerManager: this.deserializers, + viewRegistry: this.views, + uriHandlerRegistry: this.uriHandlerRegistry + }); + + // Public: A {ThemeManager} instance + this.themes = new ThemeManager({ + packageManager: this.packages, + config: this.config, + styleManager: this.styles, + notificationManager: this.notifications, + viewRegistry: this.views + }); + + // Public: A {MenuManager} instance + this.menu = new MenuManager({ + keymapManager: this.keymaps, + packageManager: this.packages + }); + + // Public: A {ContextMenuManager} instance + this.contextMenu = new ContextMenuManager({ keymapManager: this.keymaps }); + + this.packages.setMenuManager(this.menu); + this.packages.setContextMenuManager(this.contextMenu); + this.packages.setThemeManager(this.themes); + + // Public: A {Project} instance + this.project = new Project({ + notificationManager: this.notifications, + packageManager: this.packages, + 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, + assert: this.assert.bind(this), + packageManager: this.packages + }); + + // Public: A {Workspace} instance + this.workspace = new Workspace({ + config: this.config, + project: this.project, + packageManager: this.packages, + grammarRegistry: this.grammars, + deserializerManager: this.deserializers, + notificationManager: this.notifications, + applicationDelegate: this.applicationDelegate, + viewRegistry: this.views, + assert: this.assert.bind(this), + textEditorRegistry: this.textEditors, + styleManager: this.styles, + enablePersistence: this.enablePersistence + }); + + this.themes.workspace = this.workspace; + + this.autoUpdater = new AutoUpdateManager({ + applicationDelegate: this.applicationDelegate + }); + + if (this.keymaps.canLoadBundledKeymapsFromMemory()) { + this.keymaps.loadBundledKeymaps(); + } + + this.registerDefaultCommands(); + this.registerDefaultOpeners(); + this.registerDefaultDeserializers(); + + 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(); + }) + ); + } + + initialize(params = {}) { + // This will force TextEditorElement to register the custom element, so that + // using `document.createElement('atom-text-editor')` works if it's called + // before opening a buffer. + require('./text-editor-element'); + + this.window = params.window; + this.document = params.document; + this.blobStore = params.blobStore; + this.configDirPath = params.configDirPath; + + const { + devMode, + safeMode, + resourcePath, + userSettings, + projectSpecification + } = this.getLoadSettings(); + + 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({ + mainSource: + this.enablePersistence && path.join(this.configDirPath, 'config.cson'), + projectHomeSchema: ConfigSchema.projectHome + }); + this.config.resetUserSettings(userSettings); + + if (projectSpecification != null && projectSpecification.config != null) { + this.project.replace(projectSpecification); + } + + this.menu.initialize({ resourcePath }); + this.contextMenu.initialize({ resourcePath, devMode }); + + this.keymaps.configDirPath = this.configDirPath; + this.keymaps.resourcePath = resourcePath; + this.keymaps.devMode = devMode; + if (!this.keymaps.canLoadBundledKeymapsFromMemory()) { + this.keymaps.loadBundledKeymaps(); + } + + this.commands.attach(this.window); + + this.styles.initialize({ configDirPath: this.configDirPath }); + this.packages.initialize({ + devMode, + configDirPath: this.configDirPath, + resourcePath, + safeMode + }); + this.themes.initialize({ + configDirPath: this.configDirPath, + resourcePath, + safeMode, + devMode + }); + + this.commandInstaller.initialize(this.getVersion()); + this.uriHandlerRegistry.registerHostHandler( + 'core', + CoreURIHandlers.create(this) + ); + this.autoUpdater.initialize(); + + this.protocolHandlerInstaller.initialize(this.config, this.notifications); + + this.themes.loadBaseStylesheets(); + this.initialStyleElements = this.styles.getSnapshot(); + if (params.onlyLoadBaseStyleSheets) this.themes.initialLoadComplete = true; + this.setBodyPlatformClass(); + + this.stylesElement = this.styles.buildStylesElement(); + this.document.head.appendChild(this.stylesElement); + + this.keymaps.subscribeToFileReadFailure(); + + this.installUncaughtErrorHandler(); + this.attachSaveStateListeners(); + this.windowEventHandler.initialize(this.window, this.document); + + this.workspace.initialize(); + + const didChangeStyles = this.didChangeStyles.bind(this); + this.disposables.add(this.styles.onDidAddStyleElement(didChangeStyles)); + this.disposables.add(this.styles.onDidUpdateStyleElement(didChangeStyles)); + this.disposables.add(this.styles.onDidRemoveStyleElement(didChangeStyles)); + + this.observeAutoHideMenuBar(); + + this.disposables.add( + this.applicationDelegate.onDidChangeHistoryManager(() => + this.history.loadState() + ) + ); + } + + preloadPackages() { + return this.packages.preloadPackages(); + } + + attachSaveStateListeners() { + const saveState = _.debounce(() => { + this.window.requestIdleCallback(() => { + if (!this.unloading) this.saveState({ isUnloading: false }); + }); + }, this.saveStateDebounceInterval); + this.document.addEventListener('mousedown', saveState, { capture: true }); + this.document.addEventListener('keydown', saveState, { capture: true }); + this.disposables.add( + new Disposable(() => { + this.document.removeEventListener('mousedown', saveState, { + capture: true + }); + this.document.removeEventListener('keydown', saveState, { + capture: true + }); + }) + ); + } + + registerDefaultDeserializers() { + this.deserializers.add(Workspace); + this.deserializers.add(PaneContainer); + this.deserializers.add(PaneAxis); + this.deserializers.add(Pane); + this.deserializers.add(Dock); + this.deserializers.add(Project); + this.deserializers.add(TextEditor); + this.deserializers.add(TextBuffer); + } + + registerDefaultCommands() { + registerDefaultCommands({ + commandRegistry: this.commands, + config: this.config, + commandInstaller: this.commandInstaller, + notificationManager: this.notifications, + project: this.project, + clipboard: this.clipboard + }); + } + + registerDefaultOpeners() { + this.workspace.addOpener(uri => { + switch (uri) { + case 'atom://.atom/stylesheet': + return this.workspace.openTextFile( + this.styles.getUserStyleSheetPath() + ); + case 'atom://.atom/keymap': + return this.workspace.openTextFile(this.keymaps.getUserKeymapPath()); + case 'atom://.atom/config': + return this.workspace.openTextFile(this.config.getUserConfigPath()); + case 'atom://.atom/init-script': + return this.workspace.openTextFile(this.getUserInitScriptPath()); + } + }); + } + + registerDefaultTargetForKeymaps() { + this.keymaps.defaultTarget = this.workspace.getElement(); + } + + observeAutoHideMenuBar() { + this.disposables.add( + this.config.onDidChange('core.autoHideMenuBar', ({ newValue }) => { + this.setAutoHideMenuBar(newValue); + }) + ); + if (this.config.get('core.autoHideMenuBar')) this.setAutoHideMenuBar(true); + } + + async reset() { + this.deserializers.clear(); + this.registerDefaultDeserializers(); + + this.config.clear(); + this.config.setSchema(null, { + type: 'object', + properties: _.clone(ConfigSchema) + }); + + this.keymaps.clear(); + this.keymaps.loadBundledKeymaps(); + + this.commands.clear(); + this.registerDefaultCommands(); + + this.styles.restoreSnapshot(this.initialStyleElements); + + this.menu.clear(); + + this.clipboard.reset(); + + this.notifications.clear(); + + this.contextMenu.clear(); + + await this.packages.reset(); + this.workspace.reset(this.packages); + this.registerDefaultOpeners(); + this.project.reset(this.packages); + this.workspace.initialize(); + this.grammars.clear(); + this.textEditors.clear(); + this.views.clear(); + this.pathsWithWaitSessions.clear(); + } + + destroy() { + if (!this.project) return; + + this.disposables.dispose(); + if (this.workspace) this.workspace.destroy(); + this.workspace = null; + this.themes.workspace = null; + if (this.project) this.project.destroy(); + this.project = null; + this.commands.clear(); + if (this.stylesElement) this.stylesElement.remove(); + this.autoUpdater.destroy(); + this.uriHandlerRegistry.destroy(); + + this.uninstallWindowEventHandler(); + } + + /* + Section: Event Subscription + */ + + // Extended: Invoke the given callback whenever {::beep} is called. + // + // * `callback` {Function} to be called whenever {::beep} is called. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidBeep(callback) { + return this.emitter.on('did-beep', callback); + } + + // Extended: Invoke the given callback when there is an unhandled error, but + // before the devtools pop open + // + // * `callback` {Function} to be called whenever there is an unhandled error + // * `event` {Object} + // * `originalError` {Object} the original error object + // * `message` {String} the original error object + // * `url` {String} Url to the file where the error originated. + // * `line` {Number} + // * `column` {Number} + // * `preventDefault` {Function} call this to avoid popping up the dev tools. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillThrowError(callback) { + return this.emitter.on('will-throw-error', callback); + } + + // Extended: Invoke the given callback whenever there is an unhandled error. + // + // * `callback` {Function} to be called whenever there is an unhandled error + // * `event` {Object} + // * `originalError` {Object} the original error object + // * `message` {String} the original error object + // * `url` {String} Url to the file where the error originated. + // * `line` {Number} + // * `column` {Number} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidThrowError(callback) { + return this.emitter.on('did-throw-error', callback); + } + + // TODO: Make this part of the public API. We should make onDidThrowError + // match the interface by only yielding an exception object to the handler + // and deprecating the old behavior. + onDidFailAssertion(callback) { + return this.emitter.on('did-fail-assertion', callback); + } + + // Extended: Invoke the given callback as soon as the shell environment is + // loaded (or immediately if it was already loaded). + // + // * `callback` {Function} to be called whenever there is an unhandled error + whenShellEnvironmentLoaded(callback) { + if (this.shellEnvironmentLoaded) { + callback(); + return new Disposable(); + } else { + return this.emitter.once('loaded-shell-environment', callback); + } + } + + /* + Section: Atom Details + */ + + // Public: Returns a {Boolean} that is `true` if the current window is in development mode. + inDevMode() { + if (this.devMode == null) this.devMode = this.getLoadSettings().devMode; + return this.devMode; + } + + // Public: Returns a {Boolean} that is `true` if the current window is in safe mode. + inSafeMode() { + if (this.safeMode == null) this.safeMode = this.getLoadSettings().safeMode; + return this.safeMode; + } + + // Public: Returns a {Boolean} that is `true` if the current window is running specs. + inSpecMode() { + if (this.specMode == null) this.specMode = this.getLoadSettings().isSpec; + return this.specMode; + } + + // Returns a {Boolean} indicating whether this the first time the window's been + // loaded. + isFirstLoad() { + if (this.firstLoad == null) + this.firstLoad = this.getLoadSettings().firstLoad; + return this.firstLoad; + } + + // Public: Get the full name of this Atom release (e.g. "Atom", "Atom Beta") + // + // Returns the app name {String}. + getAppName() { + if (this.appName == null) this.appName = this.getLoadSettings().appName; + return this.appName; + } + + // Public: Get the version of the Atom application. + // + // Returns the version text {String}. + getVersion() { + if (this.appVersion == null) + this.appVersion = this.getLoadSettings().appVersion; + return this.appVersion; + } + + // Public: Gets the release channel of the Atom application. + // + // Returns the release channel as a {String}. Will return a specific release channel + // name like 'beta' or 'nightly' if one is found in the Atom version or 'stable' + // otherwise. + getReleaseChannel() { + return getReleaseChannel(this.getVersion()); + } + + // Public: Returns a {Boolean} that is `true` if the current version is an official release. + isReleasedVersion() { + return this.getReleaseChannel().match(/stable|beta|nightly/) != null; + } + + // Public: Get the time taken to completely load the current window. + // + // This time include things like loading and activating packages, creating + // DOM elements for the editor, and reading the config. + // + // Returns the {Number} of milliseconds taken to load the window or null + // if the window hasn't finished loading yet. + getWindowLoadTime() { + return this.loadTime; + } + + // Public: Get the all the markers with the information about startup time. + // + // Returns an array of timing markers. + // Each timing is an object with two keys: + // * `label`: string + // * `time`: Time since the `startTime` (in milliseconds). + getStartupMarkers() { + const data = StartupTime.exportData(); + + return data ? data.markers : []; + } + + // Public: Get the load settings for the current window. + // + // Returns an {Object} containing all the load setting key/value pairs. + getLoadSettings() { + return this.applicationDelegate.getWindowLoadSettings(); + } + + /* + Section: Managing The Atom Window + */ + + // Essential: Open a new Atom window using the given options. + // + // Calling this method without an options parameter will open a prompt to pick + // a file/folder to open in the new window. + // + // * `params` An {Object} with the following keys: + // * `pathsToOpen` An {Array} of {String} paths to open. + // * `newWindow` A {Boolean}, true to always open a new window instead of + // reusing existing windows depending on the paths to open. + // * `devMode` A {Boolean}, true to open the window in development mode. + // Development mode loads the Atom source from the locally cloned + // repository and also loads all the packages in ~/.atom/dev/packages + // * `safeMode` A {Boolean}, true to open the window in safe mode. Safe + // mode prevents all packages installed to ~/.atom/packages from loading. + open(params) { + return this.applicationDelegate.open(params); + } + + // Extended: Prompt the user to select one or more folders. + // + // * `callback` A {Function} to call once the user has confirmed the selection. + // * `paths` An {Array} of {String} paths that the user selected, or `null` + // if the user dismissed the dialog. + pickFolder(callback) { + return this.applicationDelegate.pickFolder(callback); + } + + // Essential: Close the current window. + close() { + return this.applicationDelegate.closeWindow(); + } + + // Essential: Get the size of current window. + // + // Returns an {Object} in the format `{width: 1000, height: 700}` + getSize() { + return this.applicationDelegate.getWindowSize(); + } + + // Essential: Set the size of current window. + // + // * `width` The {Number} of pixels. + // * `height` The {Number} of pixels. + setSize(width, height) { + return this.applicationDelegate.setWindowSize(width, height); + } + + // Essential: Get the position of current window. + // + // Returns an {Object} in the format `{x: 10, y: 20}` + getPosition() { + return this.applicationDelegate.getWindowPosition(); + } + + // Essential: Set the position of current window. + // + // * `x` The {Number} of pixels. + // * `y` The {Number} of pixels. + setPosition(x, y) { + return this.applicationDelegate.setWindowPosition(x, y); + } + + // Extended: Get the current window + getCurrentWindow() { + return this.applicationDelegate.getCurrentWindow(); + } + + // Extended: Move current window to the center of the screen. + center() { + return this.applicationDelegate.centerWindow(); + } + + // Extended: Focus the current window. + focus() { + this.applicationDelegate.focusWindow(); + return this.window.focus(); + } + + // Extended: Show the current window. + show() { + return this.applicationDelegate.showWindow(); + } + + // Extended: Hide the current window. + hide() { + return this.applicationDelegate.hideWindow(); + } + + // Extended: Reload the current window. + reload() { + return this.applicationDelegate.reloadWindow(); + } + + // Extended: Relaunch the entire application. + restartApplication() { + return this.applicationDelegate.restartApplication(); + } + + // Extended: Returns a {Boolean} that is `true` if the current window is maximized. + isMaximized() { + return this.applicationDelegate.isWindowMaximized(); + } + + maximize() { + return this.applicationDelegate.maximizeWindow(); + } + + // Extended: Returns a {Boolean} that is `true` if the current window is in full screen mode. + isFullScreen() { + return this.applicationDelegate.isWindowFullScreen(); + } + + // Extended: Set the full screen state of the current window. + setFullScreen(fullScreen = false) { + return this.applicationDelegate.setWindowFullScreen(fullScreen); + } + + // Extended: Toggle the full screen state of the current window. + toggleFullScreen() { + return this.setFullScreen(!this.isFullScreen()); + } + + // Restore the window to its previous dimensions and show it. + // + // Restores the full screen and maximized state after the window has resized to + // prevent resize glitches. + async displayWindow() { + await this.restoreWindowDimensions(); + const steps = [this.restoreWindowBackground(), this.show(), this.focus()]; + if (this.windowDimensions && this.windowDimensions.fullScreen) { + steps.push(this.setFullScreen(true)); + } + if ( + this.windowDimensions && + this.windowDimensions.maximized && + process.platform !== 'darwin' + ) { + steps.push(this.maximize()); + } + await Promise.all(steps); + } + + // Get the dimensions of this window. + // + // Returns an {Object} with the following keys: + // * `x` The window's x-position {Number}. + // * `y` The window's y-position {Number}. + // * `width` The window's width {Number}. + // * `height` The window's height {Number}. + getWindowDimensions() { + const browserWindow = this.getCurrentWindow(); + const [x, y] = browserWindow.getPosition(); + const [width, height] = browserWindow.getSize(); + const maximized = browserWindow.isMaximized(); + return { x, y, width, height, maximized }; + } + + // Set the dimensions of the window. + // + // The window will be centered if either the x or y coordinate is not set + // in the dimensions parameter. If x or y are omitted the window will be + // centered. If height or width are omitted only the position will be changed. + // + // * `dimensions` An {Object} with the following keys: + // * `x` The new x coordinate. + // * `y` The new y coordinate. + // * `width` The new width. + // * `height` The new height. + setWindowDimensions({ x, y, width, height }) { + const steps = []; + if (width != null && height != null) { + steps.push(this.setSize(width, height)); + } + if (x != null && y != null) { + steps.push(this.setPosition(x, y)); + } else { + steps.push(this.center()); + } + return Promise.all(steps); + } + + // Returns true if the dimensions are useable, false if they should be ignored. + // Work around for https://github.com/atom/atom-shell/issues/473 + isValidDimensions({ x, y, width, height } = {}) { + return width > 0 && height > 0 && x + width > 0 && y + height > 0; + } + + storeWindowDimensions() { + this.windowDimensions = this.getWindowDimensions(); + if (this.isValidDimensions(this.windowDimensions)) { + localStorage.setItem( + 'defaultWindowDimensions', + JSON.stringify(this.windowDimensions) + ); + } + } + + getDefaultWindowDimensions() { + const { windowDimensions } = this.getLoadSettings(); + if (windowDimensions) return windowDimensions; + + let dimensions; + try { + dimensions = JSON.parse(localStorage.getItem('defaultWindowDimensions')); + } catch (error) { + console.warn('Error parsing default window dimensions', error); + localStorage.removeItem('defaultWindowDimensions'); + } + + if (dimensions && this.isValidDimensions(dimensions)) { + return dimensions; + } else { + const { + width, + height + } = this.applicationDelegate.getPrimaryDisplayWorkAreaSize(); + return { x: 0, y: 0, width: Math.min(1024, width), height }; + } + } + + async restoreWindowDimensions() { + if ( + !this.windowDimensions || + !this.isValidDimensions(this.windowDimensions) + ) { + this.windowDimensions = this.getDefaultWindowDimensions(); + } + await this.setWindowDimensions(this.windowDimensions); + return this.windowDimensions; + } + + restoreWindowBackground() { + const backgroundColor = window.localStorage.getItem( + 'atom:window-background-color' + ); + if (backgroundColor) { + this.backgroundStylesheet = document.createElement('style'); + this.backgroundStylesheet.type = 'text/css'; + this.backgroundStylesheet.innerText = `html, body { background: ${backgroundColor} !important; }`; + document.head.appendChild(this.backgroundStylesheet); + } + } + + storeWindowBackground() { + if (this.inSpecMode()) return; + + const backgroundColor = this.window.getComputedStyle( + this.workspace.getElement() + )['background-color']; + this.window.localStorage.setItem( + 'atom:window-background-color', + backgroundColor + ); + } + + // Call this method when establishing a real application window. + async startEditorWindow() { + StartupTime.addMarker('window:environment:start-editor-window:start'); + + if (this.getLoadSettings().clearWindowState) { + await this.stateStore.clear(); + } + + this.unloading = false; + + const updateProcessEnvPromise = this.updateProcessEnvAndTriggerHooks(); + + const loadStatePromise = this.loadState().then(async state => { + this.windowDimensions = state && state.windowDimensions; + if (!this.getLoadSettings().headless) { + StartupTime.addMarker( + 'window:environment:start-editor-window:display-window' + ); + await this.displayWindow(); + } + this.commandInstaller.installAtomCommand(false, error => { + if (error) console.warn(error.message); + }); + this.commandInstaller.installApmCommand(false, error => { + if (error) console.warn(error.message); + }); + + this.disposables.add( + this.applicationDelegate.onDidChangeUserSettings(settings => + this.config.resetUserSettings(settings) + ) + ); + this.disposables.add( + this.applicationDelegate.onDidFailToReadUserSettings(message => + this.notifications.addError(message) + ) + ); + + this.disposables.add( + this.applicationDelegate.onDidOpenLocations( + this.openLocations.bind(this) + ) + ); + this.disposables.add( + this.applicationDelegate.onApplicationMenuCommand( + this.dispatchApplicationMenuCommand.bind(this) + ) + ); + this.disposables.add( + this.applicationDelegate.onContextMenuCommand( + this.dispatchContextMenuCommand.bind(this) + ) + ); + this.disposables.add( + this.applicationDelegate.onURIMessage( + this.dispatchURIMessage.bind(this) + ) + ); + this.disposables.add( + this.applicationDelegate.onDidRequestUnload( + this.prepareToUnloadEditorWindow.bind(this) + ) + ); + + this.listenForUpdates(); + + this.registerDefaultTargetForKeymaps(); + + StartupTime.addMarker( + 'window:environment:start-editor-window:load-packages' + ); + this.packages.loadPackages(); + + const startTime = Date.now(); + StartupTime.addMarker( + 'window:environment:start-editor-window:deserialize-state' + ); + await this.deserialize(state); + this.deserializeTimings.atom = Date.now() - startTime; + + if ( + process.platform === 'darwin' && + this.config.get('core.titleBar') === 'custom' + ) { + this.workspace.addHeaderPanel({ + item: new TitleBar({ + workspace: this.workspace, + themes: this.themes, + applicationDelegate: this.applicationDelegate + }) + }); + this.document.body.classList.add('custom-title-bar'); + } + if ( + process.platform === 'darwin' && + this.config.get('core.titleBar') === 'custom-inset' + ) { + this.workspace.addHeaderPanel({ + item: new TitleBar({ + workspace: this.workspace, + themes: this.themes, + applicationDelegate: this.applicationDelegate + }) + }); + this.document.body.classList.add('custom-inset-title-bar'); + } + if ( + process.platform === 'darwin' && + this.config.get('core.titleBar') === 'hidden' + ) { + this.document.body.classList.add('hidden-title-bar'); + } + + this.document.body.appendChild(this.workspace.getElement()); + if (this.backgroundStylesheet) this.backgroundStylesheet.remove(); + + 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.setProjectRoots(newPaths); + }) + ); + this.disposables.add( + this.workspace.onDidDestroyPaneItem(({ item }) => { + const path = item.getPath && item.getPath(); + if (this.pathsWithWaitSessions.has(path)) { + this.applicationDelegate.didClosePathWithWaitSession(path); + } + }) + ); + + StartupTime.addMarker( + 'window:environment:start-editor-window:activate-packages' + ); + this.packages.activate(); + this.keymaps.loadUserKeymap(); + if (!this.getLoadSettings().safeMode) this.requireUserInitScript(); + + this.menu.update(); + + StartupTime.addMarker( + 'window:environment:start-editor-window:open-editor' + ); + await this.openInitialEmptyEditorIfNecessary(); + }); + + const loadHistoryPromise = this.history.loadState().then(() => { + this.reopenProjectMenuManager = new ReopenProjectMenuManager({ + menu: this.menu, + commands: this.commands, + history: this.history, + config: this.config, + open: paths => + this.open({ + pathsToOpen: paths, + safeMode: this.inSafeMode(), + devMode: this.inDevMode() + }) + }); + this.reopenProjectMenuManager.update(); + }); + + const output = await Promise.all([ + loadStatePromise, + loadHistoryPromise, + updateProcessEnvPromise + ]); + + StartupTime.addMarker('window:environment:start-editor-window:end'); + + return output; + } + + serialize(options) { + return { + version: this.constructor.version, + project: this.project.serialize(options), + workspace: this.workspace.serialize(), + packageStates: this.packages.serialize(), + grammars: this.grammars.serialize(), + fullScreen: this.isFullScreen(), + windowDimensions: this.windowDimensions + }; + } + + async prepareToUnloadEditorWindow() { + try { + await this.saveState({ isUnloading: true }); + } catch (error) { + console.error(error); + } + + const closing = + !this.workspace || + (await this.workspace.confirmClose({ + windowCloseRequested: true, + projectHasPaths: this.project.getPaths().length > 0 + })); + + if (closing) { + this.unloading = true; + await this.packages.deactivatePackages(); + } + return closing; + } + + unloadEditorWindow() { + if (!this.project) return; + + this.storeWindowBackground(); + this.saveBlobStoreSync(); + } + + saveBlobStoreSync() { + if (this.enablePersistence) { + this.blobStore.save(); + } + } + + openInitialEmptyEditorIfNecessary() { + if (!this.config.get('core.openEmptyEditorOnStart')) return; + const { hasOpenFiles } = this.getLoadSettings(); + if (!hasOpenFiles && this.workspace.getPaneItems().length === 0) { + return this.workspace.open(null, { pending: true }); + } + } + + installUncaughtErrorHandler() { + this.previousWindowErrorHandler = this.window.onerror; + this.window.onerror = (message, url, line, column, originalError) => { + const mapping = mapSourcePosition({ source: url, line, column }); + line = mapping.line; + column = mapping.column; + if (url === '') url = mapping.source; + + const eventObject = { message, url, line, column, originalError }; + + let openDevTools = true; + eventObject.preventDefault = () => { + openDevTools = false; + }; + + this.emitter.emit('will-throw-error', eventObject); + + if (openDevTools) { + this.openDevTools().then(() => + this.executeJavaScriptInDevTools('DevToolsAPI.showPanel("console")') + ); + } + + this.emitter.emit('did-throw-error', { + message, + url, + line, + column, + originalError + }); + }; + } + + uninstallUncaughtErrorHandler() { + this.window.onerror = this.previousWindowErrorHandler; + } + + installWindowEventHandler() { + this.windowEventHandler = new WindowEventHandler({ + atomEnvironment: this, + applicationDelegate: this.applicationDelegate + }); + this.windowEventHandler.initialize(this.window, this.document); + } + + uninstallWindowEventHandler() { + if (this.windowEventHandler) { + this.windowEventHandler.unsubscribe(); + } + this.windowEventHandler = null; + } + + didChangeStyles(styleElement) { + TextEditor.didUpdateStyles(); + if (styleElement.textContent.indexOf('scrollbar') >= 0) { + TextEditor.didUpdateScrollbarStyles(); + } + } + + async updateProcessEnvAndTriggerHooks() { + await this.updateProcessEnv(this.getLoadSettings().env); + this.shellEnvironmentLoaded = true; + this.emitter.emit('loaded-shell-environment'); + this.packages.triggerActivationHook('core:loaded-shell-environment'); + } + + /* + Section: Messaging the User + */ + + // Essential: Visually and audibly trigger a beep. + beep() { + if (this.config.get('core.audioBeep')) + this.applicationDelegate.playBeepSound(); + this.emitter.emit('did-beep'); + } + + // 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 + // + // ```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 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 {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. + // 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); + } + } + + /* + Section: Managing the Dev Tools + */ + + // Extended: Open the dev tools for the current window. + // + // Returns a {Promise} that resolves when the DevTools have been opened. + openDevTools() { + return this.applicationDelegate.openWindowDevTools(); + } + + // Extended: Toggle the visibility of the dev tools for the current window. + // + // Returns a {Promise} that resolves when the DevTools have been opened or + // closed. + toggleDevTools() { + return this.applicationDelegate.toggleWindowDevTools(); + } + + // Extended: Execute code in dev tools. + executeJavaScriptInDevTools(code) { + return this.applicationDelegate.executeJavaScriptInWindowDevTools(code); + } + + /* + Section: Private + */ + + assert(condition, message, callbackOrMetadata) { + if (condition) return true; + + const error = new Error(`Assertion failed: ${message}`); + Error.captureStackTrace(error, this.assert); + + if (callbackOrMetadata) { + if (typeof callbackOrMetadata === 'function') { + callbackOrMetadata(error); + } else { + error.metadata = callbackOrMetadata; + } + } + + this.emitter.emit('did-fail-assertion', error); + if (!this.isReleasedVersion()) throw error; + + return false; + } + + loadThemes() { + return this.themes.load(); + } + + setDocumentEdited(edited) { + if ( + typeof this.applicationDelegate.setWindowDocumentEdited === 'function' + ) { + this.applicationDelegate.setWindowDocumentEdited(edited); + } + } + + setRepresentedFilename(filename) { + if ( + typeof this.applicationDelegate.setWindowRepresentedFilename === + 'function' + ) { + this.applicationDelegate.setWindowRepresentedFilename(filename); + } + } + + addProjectFolder() { + return new Promise(resolve => { + this.pickFolder(selectedPaths => { + this.addToProject(selectedPaths || []).then(resolve); + }); + }); + } + + async addToProject(projectPaths) { + const state = await this.loadState(this.getStateKey(projectPaths)); + if (state && this.project.getPaths().length === 0) { + this.attemptRestoreProjectStateForPaths(state, projectPaths); + } else { + projectPaths.map(folder => this.project.addPath(folder)); + } + } + + async attemptRestoreProjectStateForPaths( + state, + projectPaths, + filesToOpen = [] + ) { + const center = this.workspace.getCenter(); + const windowIsUnused = () => { + for (let container of this.workspace.getPaneContainers()) { + for (let item of container.getPaneItems()) { + if (item instanceof TextEditor) { + if (item.getPath() || item.isModified()) return false; + } else { + if (container === center) return false; + } + } + } + return true; + }; + + if (windowIsUnused()) { + 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'; + this.confirm( + { + message: 'Previous automatically-saved project state detected', + 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' + ] + }, + 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 discardStatePromise; + } + } + + restoreStateIntoThisEnvironment(state) { + state.fullScreen = this.isFullScreen(); + for (let pane of this.workspace.getPanes()) { + pane.destroy(); + } + return this.deserialize(state); + } + + showSaveDialogSync(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) { + if (this.enablePersistence && this.project) { + const state = this.serialize(options); + if (!storageKey) + storageKey = this.getStateKey(this.project && this.project.getPaths()); + if (storageKey) { + await this.stateStore.save(storageKey, state); + } else { + await this.applicationDelegate.setTemporaryWindowState(state); + } + } + } + + loadState(stateKey) { + if (this.enablePersistence) { + if (!stateKey) + stateKey = this.getStateKey(this.getLoadSettings().initialProjectRoots); + if (stateKey) { + return this.stateStore.load(stateKey); + } else { + return this.applicationDelegate.getTemporaryWindowState(); + } + } else { + return Promise.resolve(null); + } + } + + async deserialize(state) { + if (!state) return Promise.resolve(); + + this.setFullScreen(state.fullScreen); + + const missingProjectPaths = []; + + this.packages.packageStates = state.packageStates || {}; + + let startTime = Date.now(); + if (state.project) { + try { + await this.project.deserialize(state.project, this.deserializers); + } catch (error) { + // We handle the missingProjectPaths case in openLocations(). + if (!error.missingProjectPaths) { + this.notifications.addError('Unable to deserialize project', { + description: error.message, + stack: error.stack + }); + } + } + } + + this.deserializeTimings.project = Date.now() - startTime; + + if (state.grammars) this.grammars.deserialize(state.grammars); + + startTime = Date.now(); + if (state.workspace) + this.workspace.deserialize(state.workspace, this.deserializers); + this.deserializeTimings.workspace = Date.now() - startTime; + + if (missingProjectPaths.length > 0) { + const count = + missingProjectPaths.length === 1 + ? '' + : missingProjectPaths.length + ' '; + const noun = missingProjectPaths.length === 1 ? 'folder' : 'folders'; + const toBe = missingProjectPaths.length === 1 ? 'is' : 'are'; + const escaped = missingProjectPaths.map( + projectPath => `\`${projectPath}\`` + ); + let group; + switch (escaped.length) { + case 1: + group = escaped[0]; + break; + case 2: + group = `${escaped[0]} and ${escaped[1]}`; + break; + default: + group = + escaped.slice(0, -1).join(', ') + + `, and ${escaped[escaped.length - 1]}`; + } + + this.notifications.addError(`Unable to open ${count}project ${noun}`, { + description: `Project ${noun} ${group} ${toBe} no longer on disk.` + }); + } + } + + getStateKey(paths) { + if (paths && paths.length > 0) { + const sha1 = crypto + .createHash('sha1') + .update( + paths + .slice() + .sort() + .join('\n') + ) + .digest('hex'); + return `editor-${sha1}`; + } else { + return null; + } + } + + getConfigDirPath() { + if (!this.configDirPath) this.configDirPath = process.env.ATOM_HOME; + return this.configDirPath; + } + + getUserInitScriptPath() { + const initScriptPath = fs.resolve(this.getConfigDirPath(), 'init', [ + 'js', + 'coffee' + ]); + return initScriptPath || path.join(this.getConfigDirPath(), 'init.coffee'); + } + + requireUserInitScript() { + const userInitScriptPath = this.getUserInitScriptPath(); + if (userInitScriptPath) { + try { + if (fs.isFileSync(userInitScriptPath)) require(userInitScriptPath); + } catch (error) { + this.notifications.addError( + `Failed to load \`${userInitScriptPath}\``, + { + detail: error.message, + dismissable: true + } + ); + } + } + } + + // TODO: We should deprecate the update events here, and use `atom.autoUpdater` instead + onUpdateAvailable(callback) { + return this.emitter.on('update-available', callback); + } + + updateAvailable(details) { + return this.emitter.emit('update-available', details); + } + + listenForUpdates() { + // listen for updates available locally (that have been successfully downloaded) + this.disposables.add( + this.autoUpdater.onDidCompleteDownloadingUpdate( + this.updateAvailable.bind(this) + ) + ); + } + + setBodyPlatformClass() { + this.document.body.classList.add(`platform-${process.platform}`); + } + + setAutoHideMenuBar(autoHide) { + this.applicationDelegate.setAutoHideWindowMenuBar(autoHide); + this.applicationDelegate.setWindowMenuBarVisibility(!autoHide); + } + + dispatchApplicationMenuCommand(command, arg) { + let { activeElement } = this.document; + // Use the workspace element if body has focus + if (activeElement === this.document.body) { + activeElement = this.workspace.getElement(); + } + this.commands.dispatch(activeElement, command, arg); + } + + dispatchContextMenuCommand(command, ...args) { + this.commands.dispatch(this.contextMenu.activeElement, command, args); + } + + dispatchURIMessage(uri) { + if (this.packages.hasLoadedInitialPackages()) { + this.uriHandlerRegistry.handleURI(uri); + } else { + let subscription = this.packages.onDidLoadInitialPackages(() => { + subscription.dispose(); + this.uriHandlerRegistry.handleURI(uri); + }); + } + } + + async openLocations(locations) { + const needsProjectPaths = + this.project && this.project.getPaths().length === 0; + const foldersToAddToProject = new Set(); + const fileLocationsToOpen = []; + const missingFolders = []; + + // Asynchronously fetch stat information about each requested path to open. + const locationStats = await Promise.all( + locations.map(async location => { + const stats = location.pathToOpen + ? await stat(location.pathToOpen).catch(() => null) + : null; + return { location, stats }; + }) + ); + + for (const { location, stats } of locationStats) { + const { pathToOpen } = location; + if (!pathToOpen) { + // Untitled buffer + fileLocationsToOpen.push(location); + continue; + } + + if (stats !== null) { + // Path exists + if (stats.isDirectory()) { + // Directory: add as a project folder + foldersToAddToProject.add( + this.project.getDirectoryForProjectPath(pathToOpen).getPath() + ); + } else if (stats.isFile()) { + if (location.isDirectory) { + // File: no longer a directory + missingFolders.push(location); + } else { + // File: add as a file location + fileLocationsToOpen.push(location); + } + } + } else { + // Path does not exist + // Attempt to interpret as a URI from a non-default directory provider + const directory = this.project.getProvidedDirectoryForProjectPath( + pathToOpen + ); + if (directory) { + // Found: add as a project folder + foldersToAddToProject.add(directory.getPath()); + } else if (location.isDirectory) { + // Not found and must be a directory: add to missing list and use to derive state key + missingFolders.push(location); + } else { + // Not found: open as a new file + fileLocationsToOpen.push(location); + } + } + + if (location.hasWaitSession) this.pathsWithWaitSessions.add(pathToOpen); + } + + let restoredState = false; + if (foldersToAddToProject.size > 0 || missingFolders.length > 0) { + // Include missing folders in the state key so that sessions restored with no-longer-present project root folders + // don't lose data. + const foldersForStateKey = Array.from(foldersToAddToProject).concat( + missingFolders.map(location => location.pathToOpen) + ); + const state = await this.loadState( + this.getStateKey(Array.from(foldersForStateKey)) + ); + + // only restore state if this is the first path added to the project + if (state && needsProjectPaths) { + const files = fileLocationsToOpen.map(location => location.pathToOpen); + await this.attemptRestoreProjectStateForPaths( + state, + Array.from(foldersToAddToProject), + files + ); + restoredState = true; + } else { + for (let folder of foldersToAddToProject) { + this.project.addPath(folder); + } + } + } + + if (!restoredState) { + const fileOpenPromises = []; + for (const { + pathToOpen, + initialLine, + initialColumn + } of fileLocationsToOpen) { + fileOpenPromises.push( + this.workspace && + this.workspace.open(pathToOpen, { initialLine, initialColumn }) + ); + } + await Promise.all(fileOpenPromises); + } + + if (missingFolders.length > 0) { + let message = 'Unable to open project folder'; + if (missingFolders.length > 1) { + message += 's'; + } + + let description = 'The '; + if (missingFolders.length === 1) { + description += 'directory `'; + description += missingFolders[0].pathToOpen; + description += '` does not exist.'; + } else if (missingFolders.length === 2) { + description += `directories \`${missingFolders[0].pathToOpen}\` `; + description += `and \`${missingFolders[1].pathToOpen}\` do not exist.`; + } else { + description += 'directories '; + description += missingFolders + .slice(0, -1) + .map(location => location.pathToOpen) + .map(pathToOpen => '`' + pathToOpen + '`, ') + .join(''); + description += + 'and `' + + missingFolders[missingFolders.length - 1].pathToOpen + + '` do not exist.'; + } + + this.notifications.addWarning(message, { description }); + } + + ipcRenderer.send('window-command', 'window:locations-opened'); + } + + resolveProxy(url) { + return new Promise((resolve, reject) => { + const requestId = this.nextProxyRequestId++; + const disposable = this.applicationDelegate.onDidResolveProxy( + (id, proxy) => { + if (id === requestId) { + disposable.dispose(); + resolve(proxy); + } + } + ); + + return this.applicationDelegate.resolveProxy(requestId, url); + }); + } +} + +AtomEnvironment.version = 1; +AtomEnvironment.prototype.saveStateDebounceInterval = 1000; +module.exports = AtomEnvironment; + +/* eslint-disable */ + +// Preserve this deprecation until 2.0. Sorry. Should have removed Q sooner. +Promise.prototype.done = function (callback) { + deprecate('Atom now uses ES6 Promises instead of Q. Call promise.then instead of promise.done') + return this.then(callback) +} + +/* eslint-enable */ diff --git a/src/atom-paths.js b/src/atom-paths.js new file mode 100644 index 00000000000..8091fc02d06 --- /dev/null +++ b/src/atom-paths.js @@ -0,0 +1,70 @@ +const fs = require('fs-plus'); +const path = require('path'); + +const hasWriteAccess = dir => { + const testFilePath = path.join(dir, 'write.test'); + try { + fs.writeFileSync(testFilePath, new Date().toISOString(), { flag: 'w+' }); + fs.unlinkSync(testFilePath); + return true; + } catch (err) { + return false; + } +}; + +const getAppDirectory = () => { + switch (process.platform) { + case 'darwin': + return process.execPath.substring( + 0, + process.execPath.indexOf('.app') + 4 + ); + case 'linux': + case 'win32': + return path.join(process.execPath, '..'); + } +}; + +module.exports = { + setAtomHome: homePath => { + // When a read-writeable .atom folder exists above app use that + const portableHomePath = path.join(getAppDirectory(), '..', '.atom'); + if (fs.existsSync(portableHomePath)) { + if (hasWriteAccess(portableHomePath)) { + process.env.ATOM_HOME = portableHomePath; + } else { + // A path exists so it was intended to be used but we didn't have rights, so warn. + console.log( + `Insufficient permission to portable Atom home "${portableHomePath}".` + ); + } + } + + // Check ATOM_HOME environment variable next + if (process.env.ATOM_HOME !== undefined) { + return; + } + + // Fall back to default .atom folder in users home folder + process.env.ATOM_HOME = path.join(homePath, '.atom'); + }, + + setUserData: app => { + const electronUserDataPath = path.join( + process.env.ATOM_HOME, + 'electronUserData' + ); + if (fs.existsSync(electronUserDataPath)) { + if (hasWriteAccess(electronUserDataPath)) { + app.setPath('userData', electronUserDataPath); + } else { + // A path exists so it was intended to be used but we didn't have rights, so warn. + console.log( + `Insufficient permission to Electron user data "${electronUserDataPath}".` + ); + } + } + }, + + getAppDirectory: getAppDirectory +}; diff --git a/src/atom.coffee b/src/atom.coffee deleted file mode 100644 index 0d029b61df1..00000000000 --- a/src/atom.coffee +++ /dev/null @@ -1,859 +0,0 @@ -crypto = require 'crypto' -ipc = require 'ipc' -os = require 'os' -path = require 'path' -remote = require 'remote' -shell = require 'shell' - -_ = require 'underscore-plus' -{deprecate, includeDeprecatedAPIs} = require 'grim' -{CompositeDisposable, Emitter} = require 'event-kit' -fs = require 'fs-plus' -{convertStackTrace, convertLine} = require 'coffeestack' -Model = require './model' -{$} = require './space-pen-extensions' -WindowEventHandler = require './window-event-handler' -StylesElement = require './styles-element' -StorageFolder = require './storage-folder' - -# Essential: Atom global for dealing with packages, themes, menus, and the window. -# -# An instance of this class is always available as the `atom` global. -module.exports = -class Atom extends Model - @version: 1 # Increment this when the serialization format changes - - # Load or create the Atom environment in the given mode. - # - # * `mode` A {String} mode that is either 'editor' or 'spec' depending on the - # kind of environment you want to build. - # - # Returns an Atom instance, fully initialized - @loadOrCreate: (mode) -> - startTime = Date.now() - atom = @deserialize(@loadState(mode)) ? new this({mode, @version}) - atom.deserializeTimings.atom = Date.now() - startTime - - if includeDeprecatedAPIs - workspaceViewDeprecationMessage = """ - atom.workspaceView is no longer available. - In most cases you will not need the view. See the Workspace docs for - alternatives: https://atom.io/docs/api/latest/Workspace. - If you do need the view, please use `atom.views.getView(atom.workspace)`, - which returns an HTMLElement. - """ - - serviceHubDeprecationMessage = """ - atom.services is no longer available. To register service providers and - consumers, use the `providedServices` and `consumedServices` fields in - your package's package.json. - """ - - Object.defineProperty atom, 'workspaceView', - get: -> - deprecate(workspaceViewDeprecationMessage) - atom.__workspaceView - set: (newValue) -> - deprecate(workspaceViewDeprecationMessage) - atom.__workspaceView = newValue - - Object.defineProperty atom, 'services', - get: -> - deprecate(serviceHubDeprecationMessage) - atom.packages.serviceHub - set: (newValue) -> - deprecate(serviceHubDeprecationMessage) - atom.packages.serviceHub = newValue - - atom - - # Deserializes the Atom environment from a state object - @deserialize: (state) -> - new this(state) if state?.version is @version - - # Loads and returns the serialized state corresponding to this window - # if it exists; otherwise returns undefined. - @loadState: (mode) -> - if stateKey = @getStateKey(@getLoadSettings().initialPaths, mode) - if state = @getStorageFolder().load(stateKey) - return state - - if windowState = @getLoadSettings().windowState - try - JSON.parse(@getLoadSettings().windowState) - catch error - console.warn "Error parsing window state: #{statePath} #{error.stack}", error - - # Returns the path where the state for the current window will be - # located if it exists. - @getStateKey: (paths, mode) -> - if mode is 'spec' - 'spec' - else if mode is 'editor' and paths?.length > 0 - sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex') - "editor-#{sha1}" - else - null - - # Get the directory path to Atom's configuration area. - # - # Returns the absolute path to ~/.atom - @getConfigDirPath: -> - @configDirPath ?= process.env.ATOM_HOME - - @getStorageFolder: -> - @storageFolder ?= new StorageFolder(@getConfigDirPath()) - - # Returns the load settings hash associated with the current window. - @getLoadSettings: -> - @loadSettings ?= JSON.parse(decodeURIComponent(location.hash.substr(1))) - cloned = _.deepClone(@loadSettings) - # The loadSettings.windowState could be large, request it only when needed. - cloned.__defineGetter__ 'windowState', => - @getCurrentWindow().loadSettings.windowState - cloned.__defineSetter__ 'windowState', (value) => - @getCurrentWindow().loadSettings.windowState = value - cloned - - @updateLoadSetting: (key, value) -> - @getLoadSettings() - @loadSettings[key] = value - location.hash = encodeURIComponent(JSON.stringify(@loadSettings)) - - @getCurrentWindow: -> - remote.getCurrentWindow() - - workspaceViewParentSelector: 'body' - lastUncaughtError: null - - ### - Section: Properties - ### - - # Public: A {CommandRegistry} instance - commands: null - - # Public: A {Config} instance - config: null - - # Public: A {Clipboard} instance - clipboard: null - - # Public: A {ContextMenuManager} instance - contextMenu: null - - # Public: A {MenuManager} instance - menu: null - - # Public: A {KeymapManager} instance - keymaps: null - - # Public: A {TooltipManager} instance - tooltips: null - - # Public: A {NotificationManager} instance - notifications: null - - # Public: A {Project} instance - project: null - - # Public: A {GrammarRegistry} instance - grammars: null - - # Public: A {PackageManager} instance - packages: null - - # Public: A {ThemeManager} instance - themes: null - - # Public: A {StyleManager} instance - styles: null - - # Public: A {DeserializerManager} instance - deserializers: null - - # Public: A {ViewRegistry} instance - views: null - - # Public: A {Workspace} instance - workspace: null - - ### - Section: Construction and Destruction - ### - - # Call .loadOrCreate instead - constructor: (@state) -> - @emitter = new Emitter - @disposables = new CompositeDisposable - {@mode} = @state - DeserializerManager = require './deserializer-manager' - @deserializers = new DeserializerManager() - @deserializeTimings = {} - - # Sets up the basic services that should be available in all modes - # (both spec and application). - # - # Call after this instance has been assigned to the `atom` global. - initialize: -> - sourceMapCache = {} - - window.onerror = => - @lastUncaughtError = Array::slice.call(arguments) - [message, url, line, column, originalError] = @lastUncaughtError - - convertedLine = convertLine(url, line, column, sourceMapCache) - {line, column} = convertedLine if convertedLine? - originalError.stack = convertStackTrace(originalError.stack, sourceMapCache) if originalError - - eventObject = {message, url, line, column, originalError} - - openDevTools = true - eventObject.preventDefault = -> openDevTools = false - - @emitter.emit 'will-throw-error', eventObject - - if openDevTools - @openDevTools() - @executeJavaScriptInDevTools('DevToolsAPI.showConsole()') - - @emit 'uncaught-error', arguments... if includeDeprecatedAPIs - @emitter.emit 'did-throw-error', {message, url, line, column, originalError} - - @disposables?.dispose() - @disposables = new CompositeDisposable - - @displayWindow() unless @inSpecMode() - - @setBodyPlatformClass() - - @loadTime = null - - Config = require './config' - KeymapManager = require './keymap-extensions' - ViewRegistry = require './view-registry' - CommandRegistry = require './command-registry' - TooltipManager = require './tooltip-manager' - NotificationManager = require './notification-manager' - PackageManager = require './package-manager' - Clipboard = require './clipboard' - GrammarRegistry = require './grammar-registry' - ThemeManager = require './theme-manager' - StyleManager = require './style-manager' - ContextMenuManager = require './context-menu-manager' - MenuManager = require './menu-manager' - {devMode, safeMode, resourcePath} = @getLoadSettings() - configDirPath = @getConfigDirPath() - - # Add 'exports' to module search path. - exportsPath = path.join(resourcePath, 'exports') - require('module').globalPaths.push(exportsPath) - # Still set NODE_PATH since tasks may need it. - process.env.NODE_PATH = exportsPath - - # Make react.js faster - process.env.NODE_ENV ?= 'production' unless devMode - - @config = new Config({configDirPath, resourcePath}) - @keymaps = new KeymapManager({configDirPath, resourcePath}) - - if includeDeprecatedAPIs - @keymap = @keymaps # Deprecated - - @keymaps.subscribeToFileReadFailure() - @tooltips = new TooltipManager - @notifications = new NotificationManager - @commands = new CommandRegistry - @views = new ViewRegistry - @packages = new PackageManager({devMode, configDirPath, resourcePath, safeMode}) - @styles = new StyleManager - document.head.appendChild(new StylesElement) - @themes = new ThemeManager({packageManager: @packages, configDirPath, resourcePath, safeMode}) - @contextMenu = new ContextMenuManager({resourcePath, devMode}) - @menu = new MenuManager({resourcePath}) - @clipboard = new Clipboard() - - @grammars = @deserializers.deserialize(@state.grammars ? @state.syntax) ? new GrammarRegistry() - - if includeDeprecatedAPIs - Object.defineProperty this, 'syntax', get: -> - deprecate "The atom.syntax global is deprecated. Use atom.grammars instead." - @grammars - - @disposables.add @packages.onDidActivateInitialPackages => @watchThemes() - - Project = require './project' - TextBuffer = require 'text-buffer' - @deserializers.add(TextBuffer) - TokenizedBuffer = require './tokenized-buffer' - DisplayBuffer = require './display-buffer' - TextEditor = require './text-editor' - - @windowEventHandler = new WindowEventHandler - - ### - Section: Event Subscription - ### - - # Extended: Invoke the given callback whenever {::beep} is called. - # - # * `callback` {Function} to be called whenever {::beep} is called. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidBeep: (callback) -> - @emitter.on 'did-beep', callback - - # Extended: Invoke the given callback when there is an unhandled error, but - # before the devtools pop open - # - # * `callback` {Function} to be called whenever there is an unhandled error - # * `event` {Object} - # * `originalError` {Object} the original error object - # * `message` {String} the original error object - # * `url` {String} Url to the file where the error originated. - # * `line` {Number} - # * `column` {Number} - # * `preventDefault` {Function} call this to avoid popping up the dev tools. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillThrowError: (callback) -> - @emitter.on 'will-throw-error', callback - - # Extended: Invoke the given callback whenever there is an unhandled error. - # - # * `callback` {Function} to be called whenever there is an unhandled error - # * `event` {Object} - # * `originalError` {Object} the original error object - # * `message` {String} the original error object - # * `url` {String} Url to the file where the error originated. - # * `line` {Number} - # * `column` {Number} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidThrowError: (callback) -> - @emitter.on 'did-throw-error', callback - - ### - Section: Atom Details - ### - - # Public: Is the current window in development mode? - inDevMode: -> - @devMode ?= @getLoadSettings().devMode - - # Public: Is the current window in safe mode? - inSafeMode: -> - @safeMode ?= @getLoadSettings().safeMode - - # Public: Is the current window running specs? - inSpecMode: -> - @specMode ?= @getLoadSettings().isSpec - - # Public: Get the version of the Atom application. - # - # Returns the version text {String}. - getVersion: -> - @appVersion ?= @getLoadSettings().appVersion - - # Public: Determine whether the current version is an official release. - isReleasedVersion: -> - not /\w{7}/.test(@getVersion()) # Check if the release is a 7-character SHA prefix - - # Public: Get the directory path to Atom's configuration area. - # - # Returns the absolute path to `~/.atom`. - getConfigDirPath: -> - @constructor.getConfigDirPath() - - # Public: Get the time taken to completely load the current window. - # - # This time include things like loading and activating packages, creating - # DOM elements for the editor, and reading the config. - # - # Returns the {Number} of milliseconds taken to load the window or null - # if the window hasn't finished loading yet. - getWindowLoadTime: -> - @loadTime - - # Public: Get the load settings for the current window. - # - # Returns an {Object} containing all the load setting key/value pairs. - getLoadSettings: -> - @constructor.getLoadSettings() - - ### - Section: Managing The Atom Window - ### - - # Essential: Open a new Atom window using the given options. - # - # Calling this method without an options parameter will open a prompt to pick - # a file/folder to open in the new window. - # - # * `options` An {Object} with the following keys: - # * `pathsToOpen` An {Array} of {String} paths to open. - # * `newWindow` A {Boolean}, true to always open a new window instead of - # reusing existing windows depending on the paths to open. - # * `devMode` A {Boolean}, true to open the window in development mode. - # Development mode loads the Atom source from the locally cloned - # repository and also loads all the packages in ~/.atom/dev/packages - # * `safeMode` A {Boolean}, true to open the window in safe mode. Safe - # mode prevents all packages installed to ~/.atom/packages from loading. - open: (options) -> - ipc.send('open', options) - - # Extended: Prompt the user to select one or more folders. - # - # * `callback` A {Function} to call once the user has confirmed the selection. - # * `paths` An {Array} of {String} paths that the user selected, or `null` - # if the user dismissed the dialog. - pickFolder: (callback) -> - responseChannel = "atom-pick-folder-response" - ipc.on responseChannel, (path) -> - ipc.removeAllListeners(responseChannel) - callback(path) - ipc.send("pick-folder", responseChannel) - - # Essential: Close the current window. - close: -> - @getCurrentWindow().close() - - # Essential: Get the size of current window. - # - # Returns an {Object} in the format `{width: 1000, height: 700}` - getSize: -> - [width, height] = @getCurrentWindow().getSize() - {width, height} - - # Essential: Set the size of current window. - # - # * `width` The {Number} of pixels. - # * `height` The {Number} of pixels. - setSize: (width, height) -> - @getCurrentWindow().setSize(width, height) - - # Essential: Get the position of current window. - # - # Returns an {Object} in the format `{x: 10, y: 20}` - getPosition: -> - [x, y] = @getCurrentWindow().getPosition() - {x, y} - - # Essential: Set the position of current window. - # - # * `x` The {Number} of pixels. - # * `y` The {Number} of pixels. - setPosition: (x, y) -> - ipc.send('call-window-method', 'setPosition', x, y) - - # Extended: Get the current window - getCurrentWindow: -> - @constructor.getCurrentWindow() - - # Extended: Move current window to the center of the screen. - center: -> - ipc.send('call-window-method', 'center') - - # Extended: Focus the current window. - focus: -> - ipc.send('call-window-method', 'focus') - $(window).focus() - - # Extended: Show the current window. - show: -> - ipc.send('call-window-method', 'show') - - # Extended: Hide the current window. - hide: -> - ipc.send('call-window-method', 'hide') - - # Extended: Reload the current window. - reload: -> - ipc.send('call-window-method', 'restart') - - # Extended: Returns a {Boolean} true when the current window is maximized. - isMaximixed: -> - @getCurrentWindow().isMaximized() - - maximize: -> - ipc.send('call-window-method', 'maximize') - - # Extended: Is the current window in full screen mode? - isFullScreen: -> - @getCurrentWindow().isFullScreen() - - # Extended: Set the full screen state of the current window. - setFullScreen: (fullScreen=false) -> - ipc.send('call-window-method', 'setFullScreen', fullScreen) - if fullScreen - document.body.classList.add("fullscreen") - else - document.body.classList.remove("fullscreen") - - # Extended: Toggle the full screen state of the current window. - toggleFullScreen: -> - @setFullScreen(not @isFullScreen()) - - # Restore the window to its previous dimensions and show it. - # - # Also restores the full screen and maximized state on the next tick to - # prevent resize glitches. - displayWindow: -> - dimensions = @restoreWindowDimensions() - @show() - - setImmediate => - @focus() - @setFullScreen(true) if @workspace?.fullScreen - @maximize() if dimensions?.maximized and process.platform isnt 'darwin' - - # Get the dimensions of this window. - # - # Returns an {Object} with the following keys: - # * `x` The window's x-position {Number}. - # * `y` The window's y-position {Number}. - # * `width` The window's width {Number}. - # * `height` The window's height {Number}. - getWindowDimensions: -> - browserWindow = @getCurrentWindow() - [x, y] = browserWindow.getPosition() - [width, height] = browserWindow.getSize() - maximized = browserWindow.isMaximized() - {x, y, width, height, maximized} - - # Set the dimensions of the window. - # - # The window will be centered if either the x or y coordinate is not set - # in the dimensions parameter. If x or y are omitted the window will be - # centered. If height or width are omitted only the position will be changed. - # - # * `dimensions` An {Object} with the following keys: - # * `x` The new x coordinate. - # * `y` The new y coordinate. - # * `width` The new width. - # * `height` The new height. - setWindowDimensions: ({x, y, width, height}) -> - if width? and height? - @setSize(width, height) - if x? and y? - @setPosition(x, y) - else - @center() - - # Returns true if the dimensions are useable, false if they should be ignored. - # Work around for https://github.com/atom/atom-shell/issues/473 - isValidDimensions: ({x, y, width, height}={}) -> - width > 0 and height > 0 and x + width > 0 and y + height > 0 - - storeDefaultWindowDimensions: -> - dimensions = @getWindowDimensions() - if @isValidDimensions(dimensions) - localStorage.setItem("defaultWindowDimensions", JSON.stringify(dimensions)) - - getDefaultWindowDimensions: -> - {windowDimensions} = @getLoadSettings() - return windowDimensions if windowDimensions? - - dimensions = null - try - dimensions = JSON.parse(localStorage.getItem("defaultWindowDimensions")) - catch error - console.warn "Error parsing default window dimensions", error - localStorage.removeItem("defaultWindowDimensions") - - if @isValidDimensions(dimensions) - dimensions - else - screen = remote.require 'screen' - {width, height} = screen.getPrimaryDisplay().workAreaSize - {x: 0, y: 0, width: Math.min(1024, width), height} - - restoreWindowDimensions: -> - dimensions = @state.windowDimensions - unless @isValidDimensions(dimensions) - dimensions = @getDefaultWindowDimensions() - @setWindowDimensions(dimensions) - dimensions - - storeWindowDimensions: -> - dimensions = @getWindowDimensions() - @state.windowDimensions = dimensions if @isValidDimensions(dimensions) - - storeWindowBackground: -> - return if @inSpecMode() - - workspaceElement = @views.getView(@workspace) - backgroundColor = window.getComputedStyle(workspaceElement)['background-color'] - window.localStorage.setItem('atom:window-background-color', backgroundColor) - - # Call this method when establishing a real application window. - startEditorWindow: -> - {safeMode} = @getLoadSettings() - - CommandInstaller = require './command-installer' - CommandInstaller.installAtomCommand false, (error) -> - console.warn error.message if error? - CommandInstaller.installApmCommand false, (error) -> - console.warn error.message if error? - - @loadConfig() - @keymaps.loadBundledKeymaps() - @themes.loadBaseStylesheets() - @packages.loadPackages() - @deserializeEditorWindow() - - @watchProjectPath() - - @packages.activate() - @keymaps.loadUserKeymap() - @requireUserInitScript() unless safeMode - - @menu.update() - @disposables.add @config.onDidChange 'core.autoHideMenuBar', ({newValue}) => - @setAutoHideMenuBar(newValue) - @setAutoHideMenuBar(true) if @config.get('core.autoHideMenuBar') - - @openInitialEmptyEditorIfNecessary() - - unloadEditorWindow: -> - return if not @project - - @storeWindowBackground() - @state.grammars = @grammars.serialize() - @state.project = @project.serialize() - @state.workspace = @workspace.serialize() - @packages.deactivatePackages() - @state.packageStates = @packages.packageStates - @saveSync() - @windowState = null - - removeEditorWindow: -> - return if not @project - - @workspace?.destroy() - @workspace = null - @project?.destroy() - @project = null - - @windowEventHandler?.unsubscribe() - - openInitialEmptyEditorIfNecessary: -> - if @getLoadSettings().initialPaths?.length is 0 and @workspace.getPaneItems().length is 0 - @workspace.open(null) - - ### - Section: Messaging the User - ### - - # Essential: Visually and audibly trigger a beep. - beep: -> - shell.beep() if @config.get('core.audioBeep') - @__workspaceView?.trigger 'beep' - @emitter.emit 'did-beep' - - # Essential: A flexible way to open a dialog akin to an alert dialog. - # - # ## Examples - # - # ```coffee - # 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: - # * `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. - # - # Returns the chosen button index {Number} if the buttons option was an array. - confirm: ({message, detailedMessage, buttons}={}) -> - buttons ?= {} - if _.isArray(buttons) - buttonLabels = buttons - else - buttonLabels = Object.keys(buttons) - - dialog = remote.require('dialog') - chosen = dialog.showMessageBox @getCurrentWindow(), - type: 'info' - message: message - detail: detailedMessage - buttons: buttonLabels - - if _.isArray(buttons) - chosen - else - callback = buttons[buttonLabels[chosen]] - callback?() - - ### - Section: Managing the Dev Tools - ### - - # Extended: Open the dev tools for the current window. - openDevTools: -> - ipc.send('call-window-method', 'openDevTools') - - # Extended: Toggle the visibility of the dev tools for the current window. - toggleDevTools: -> - ipc.send('call-window-method', 'toggleDevTools') - - # Extended: Execute code in dev tools. - executeJavaScriptInDevTools: (code) -> - ipc.send('call-window-method', 'executeJavaScriptInDevTools', code) - - ### - Section: Private - ### - - deserializeProject: -> - Project = require './project' - - startTime = Date.now() - @project ?= @deserializers.deserialize(@state.project) ? new Project() - @deserializeTimings.project = Date.now() - startTime - - deserializeWorkspaceView: -> - Workspace = require './workspace' - - if includeDeprecatedAPIs - WorkspaceView = require './workspace-view' - - startTime = Date.now() - @workspace = Workspace.deserialize(@state.workspace) ? new Workspace - - workspaceElement = @views.getView(@workspace) - - if includeDeprecatedAPIs - @__workspaceView = workspaceElement.__spacePenView - - @deserializeTimings.workspace = Date.now() - startTime - - @keymaps.defaultTarget = workspaceElement - document.querySelector(@workspaceViewParentSelector).appendChild(workspaceElement) - - deserializePackageStates: -> - @packages.packageStates = @state.packageStates ? {} - delete @state.packageStates - - deserializeEditorWindow: -> - @deserializePackageStates() - @deserializeProject() - @deserializeWorkspaceView() - - loadConfig: -> - @config.setSchema null, {type: 'object', properties: _.clone(require('./config-schema'))} - @config.load() - - loadThemes: -> - @themes.load() - - watchThemes: -> - @themes.onDidChangeActiveThemes => - # Only reload stylesheets from non-theme packages - for pack in @packages.getActivePackages() when pack.getType() isnt 'theme' - pack.reloadStylesheets?() - return - - # Notify the browser project of the window's current project path - watchProjectPath: -> - @disposables.add @project.onDidChangePaths => - @constructor.updateLoadSetting('initialPaths', @project.getPaths()) - - exit: (status) -> - app = remote.require('app') - app.emit('will-exit') - remote.process.exit(status) - - setDocumentEdited: (edited) -> - ipc.send('call-window-method', 'setDocumentEdited', edited) - - setRepresentedFilename: (filename) -> - ipc.send('call-window-method', 'setRepresentedFilename', filename) - - addProjectFolder: -> - @pickFolder (selectedPaths = []) => - @project.addPath(selectedPath) for selectedPath in selectedPaths - - showSaveDialog: (callback) -> - callback(showSaveDialogSync()) - - showSaveDialogSync: (defaultPath) -> - defaultPath ?= @project?.getPaths()[0] - currentWindow = @getCurrentWindow() - dialog = remote.require('dialog') - dialog.showSaveDialog currentWindow, {title: 'Save File', defaultPath} - - saveSync: -> - if storageKey = @constructor.getStateKey(@project?.getPaths(), @mode) - @constructor.getStorageFolder().store(storageKey, @state) - else - @getCurrentWindow().loadSettings.windowState = JSON.stringify(@state) - - crashMainProcess: -> - remote.process.crash() - - crashRenderProcess: -> - process.crash() - - getUserInitScriptPath: -> - initScriptPath = fs.resolve(@getConfigDirPath(), 'init', ['js', 'coffee']) - initScriptPath ? path.join(@getConfigDirPath(), 'init.coffee') - - requireUserInitScript: -> - if userInitScriptPath = @getUserInitScriptPath() - try - require(userInitScriptPath) if fs.isFileSync(userInitScriptPath) - catch error - atom.notifications.addError "Failed to load `#{userInitScriptPath}`", - detail: error.message - dismissable: true - - # Require the module with the given globals. - # - # The globals will be set on the `window` object and removed after the - # require completes. - # - # * `id` The {String} module name or path. - # * `globals` An optional {Object} to set as globals during require. - requireWithGlobals: (id, globals={}) -> - existingGlobals = {} - for key, value of globals - existingGlobals[key] = window[key] - window[key] = value - - require(id) - - for key, value of existingGlobals - if value is undefined - delete window[key] - else - window[key] = value - return - - onUpdateAvailable: (callback) -> - @emitter.on 'update-available', callback - - updateAvailable: (details) -> - @emitter.emit 'update-available', details - - setBodyPlatformClass: -> - document.body.classList.add("platform-#{process.platform}") - - setAutoHideMenuBar: (autoHide) -> - ipc.send('call-window-method', 'setAutoHideMenuBar', autoHide) - ipc.send('call-window-method', 'setMenuBarVisibility', not autoHide) - -if includeDeprecatedAPIs - # Deprecated: Callers should be converted to use atom.deserializers - Atom::registerRepresentationClass = -> - deprecate("Callers should be converted to use atom.deserializers") - - # Deprecated: Callers should be converted to use atom.deserializers - Atom::registerRepresentationClasses = -> - deprecate("Callers should be converted to use atom.deserializers") diff --git a/src/auto-update-manager.js b/src/auto-update-manager.js new file mode 100644 index 00000000000..fe1d3ef0e3b --- /dev/null +++ b/src/auto-update-manager.js @@ -0,0 +1,86 @@ +const { Emitter, CompositeDisposable } = require('event-kit'); + +module.exports = class AutoUpdateManager { + constructor({ applicationDelegate }) { + this.applicationDelegate = applicationDelegate; + this.subscriptions = new CompositeDisposable(); + this.emitter = new Emitter(); + } + + initialize() { + this.subscriptions.add( + this.applicationDelegate.onDidBeginCheckingForUpdate(() => { + this.emitter.emit('did-begin-checking-for-update'); + }), + this.applicationDelegate.onDidBeginDownloadingUpdate(() => { + this.emitter.emit('did-begin-downloading-update'); + }), + this.applicationDelegate.onDidCompleteDownloadingUpdate(details => { + this.emitter.emit('did-complete-downloading-update', details); + }), + this.applicationDelegate.onUpdateNotAvailable(() => { + this.emitter.emit('update-not-available'); + }), + this.applicationDelegate.onUpdateError(() => { + this.emitter.emit('update-error'); + }) + ); + } + + destroy() { + this.subscriptions.dispose(); + this.emitter.dispose(); + } + + checkForUpdate() { + this.applicationDelegate.checkForUpdate(); + } + + restartAndInstallUpdate() { + this.applicationDelegate.restartAndInstallUpdate(); + } + + getState() { + return this.applicationDelegate.getAutoUpdateManagerState(); + } + + getErrorMessage() { + return this.applicationDelegate.getAutoUpdateManagerErrorMessage(); + } + + platformSupportsUpdates() { + return ( + atom.getReleaseChannel() !== 'dev' && this.getState() !== 'unsupported' + ); + } + + onDidBeginCheckingForUpdate(callback) { + return this.emitter.on('did-begin-checking-for-update', callback); + } + + onDidBeginDownloadingUpdate(callback) { + return this.emitter.on('did-begin-downloading-update', callback); + } + + onDidCompleteDownloadingUpdate(callback) { + return this.emitter.on('did-complete-downloading-update', callback); + } + + // TODO: When https://github.com/atom/electron/issues/4587 is closed, we can + // add an update-available event. + // onUpdateAvailable (callback) { + // return this.emitter.on('update-available', callback) + // } + + onUpdateNotAvailable(callback) { + return this.emitter.on('update-not-available', callback); + } + + onUpdateError(callback) { + return this.emitter.on('update-error', callback); + } + + getPlatform() { + return process.platform; + } +}; diff --git a/src/babel.coffee b/src/babel.coffee deleted file mode 100644 index 7058c85c23f..00000000000 --- a/src/babel.coffee +++ /dev/null @@ -1,187 +0,0 @@ -### -Cache for source code transpiled by Babel. - -Inspired by https://github.com/atom/atom/blob/6b963a562f8d495fbebe6abdbafbc7caf705f2c3/src/coffee-cache.coffee. -### - -crypto = require 'crypto' -fs = require 'fs-plus' -path = require 'path' -babel = null # Defer until used -Grim = null # Defer until used - -stats = - hits: 0 - misses: 0 - -defaultOptions = - # The Chrome dev tools will show the original version of the file - # when the source map is inlined. - sourceMap: 'inline' - - # Blacklisted features do not get transpiled. Features that are - # natively supported in the target environment should be listed - # here. Because Atom uses a bleeding edge version of Node/io.js, - # I think this can include es6.arrowFunctions, es6.classes, and - # possibly others, but I want to be conservative. - blacklist: [ - 'useStrict' - ] - - optional: [ - # Target a version of the regenerator runtime that - # supports yield so the transpiled code is cleaner/smaller. - 'asyncToGenerator' - ] - - # Includes support for es7 features listed at: - # http://babeljs.io/docs/usage/experimental/. - stage: 0 - - -### -shasum - Hash with an update() method. -value - Must be a value that could be returned by JSON.parse(). -### -updateDigestForJsonValue = (shasum, value) -> - # Implmentation is similar to that of pretty-printing a JSON object, except: - # * Strings are not escaped. - # * No effort is made to avoid trailing commas. - # These shortcuts should not affect the correctness of this function. - type = typeof value - if type is 'string' - shasum.update('"', 'utf8') - shasum.update(value, 'utf8') - shasum.update('"', 'utf8') - else if type in ['boolean', 'number'] - shasum.update(value.toString(), 'utf8') - else if value is null - shasum.update('null', 'utf8') - else if Array.isArray value - shasum.update('[', 'utf8') - for item in value - updateDigestForJsonValue(shasum, item) - shasum.update(',', 'utf8') - shasum.update(']', 'utf8') - else - # value must be an object: be sure to sort the keys. - keys = Object.keys value - keys.sort() - - shasum.update('{', 'utf8') - for key in keys - updateDigestForJsonValue(shasum, key) - shasum.update(': ', 'utf8') - updateDigestForJsonValue(shasum, value[key]) - shasum.update(',', 'utf8') - shasum.update('}', 'utf8') - -createBabelVersionAndOptionsDigest = (version, options) -> - shasum = crypto.createHash('sha1') - # Include the version of babel in the hash. - shasum.update('babel-core', 'utf8') - shasum.update('\0', 'utf8') - shasum.update(version, 'utf8') - shasum.update('\0', 'utf8') - updateDigestForJsonValue(shasum, options) - shasum.digest('hex') - -cacheDir = null -jsCacheDir = null - -getCachePath = (sourceCode) -> - digest = crypto.createHash('sha1').update(sourceCode, 'utf8').digest('hex') - - unless jsCacheDir? - to5Version = require('babel-core/package.json').version - jsCacheDir = path.join(cacheDir, createBabelVersionAndOptionsDigest(to5Version, defaultOptions)) - - path.join(jsCacheDir, "#{digest}.js") - -getCachedJavaScript = (cachePath) -> - if fs.isFileSync(cachePath) - try - cachedJavaScript = fs.readFileSync(cachePath, 'utf8') - stats.hits++ - return cachedJavaScript - null - -# Returns the babel options that should be used to transpile filePath. -createOptions = (filePath) -> - options = filename: filePath - for key, value of defaultOptions - options[key] = value - options - -transpile = (sourceCode, filePath, cachePath) -> - options = createOptions(filePath) - babel ?= require 'babel-core' - js = babel.transform(sourceCode, options).code - stats.misses++ - - try - fs.writeFileSync(cachePath, js) - - js - -# Function that obeys the contract of an entry in the require.extensions map. -# Returns the transpiled version of the JavaScript code at filePath, which is -# either generated on the fly or pulled from cache. -loadFile = (module, filePath) -> - sourceCode = fs.readFileSync(filePath, 'utf8') - if sourceCode.startsWith('"use babel"') or sourceCode.startsWith("'use babel'") - # Continue. - else if sourceCode.startsWith('"use 6to5"') or sourceCode.startsWith("'use 6to5'") - # Create a manual deprecation since the stack is too deep to use Grim - # which limits the depth to 3 - Grim ?= require 'grim' - stack = [ - { - fileName: __filename - functionName: 'loadFile' - location: "#{__filename}:161:5" - } - { - fileName: filePath - functionName: '' - location: "#{filePath}:1:1" - } - ] - deprecation = - message: "Use the 'use babel' pragma instead of 'use 6to5'" - stacks: [stack] - Grim.addSerializedDeprecation(deprecation) - else - return module._compile(sourceCode, filePath) - - cachePath = getCachePath(sourceCode) - js = getCachedJavaScript(cachePath) ? transpile(sourceCode, filePath, cachePath) - module._compile(js, filePath) - -register = -> - Object.defineProperty(require.extensions, '.js', { - enumerable: true - writable: false - value: loadFile - }) - -setCacheDirectory = (newCacheDir) -> - if cacheDir isnt newCacheDir - cacheDir = newCacheDir - jsCacheDir = null - -module.exports = - register: register - setCacheDirectory: setCacheDirectory - getCacheMisses: -> stats.misses - getCacheHits: -> stats.hits - - # Visible for testing. - createBabelVersionAndOptionsDigest: createBabelVersionAndOptionsDigest - - addPathToCache: (filePath) -> - return if path.extname(filePath) isnt '.js' - - sourceCode = fs.readFileSync(filePath, 'utf8') - cachePath = getCachePath(sourceCode) - transpile(sourceCode, filePath, cachePath) diff --git a/src/babel.js b/src/babel.js new file mode 100644 index 00000000000..0e52acb1023 --- /dev/null +++ b/src/babel.js @@ -0,0 +1,80 @@ +'use strict'; + +const crypto = require('crypto'); +const path = require('path'); +const defaultOptions = require('../static/babelrc.json'); + +let babel = null; +let babelVersionDirectory = null; + +const PREFIXES = [ + '/** @babel */', + '"use babel"', + "'use babel'", + '/* @flow */', + '// @flow' +]; + +const PREFIX_LENGTH = Math.max.apply( + Math, + PREFIXES.map(function(prefix) { + return prefix.length; + }) +); + +exports.shouldCompile = function(sourceCode) { + const start = sourceCode.substr(0, PREFIX_LENGTH); + return PREFIXES.some(function(prefix) { + return start.indexOf(prefix) === 0; + }); +}; + +exports.getCachePath = function(sourceCode) { + if (babelVersionDirectory == null) { + const babelVersion = require('babel-core/package.json').version; + babelVersionDirectory = path.join( + 'js', + 'babel', + createVersionAndOptionsDigest(babelVersion, defaultOptions) + ); + } + + return path.join( + babelVersionDirectory, + crypto + .createHash('sha1') + .update(sourceCode, 'utf8') + .digest('hex') + '.js' + ); +}; + +exports.compile = function(sourceCode, filePath) { + if (!babel) { + babel = require('babel-core'); + const Logger = require('babel-core/lib/transformation/file/logger'); + const noop = function() {}; + Logger.prototype.debug = noop; + Logger.prototype.verbose = noop; + } + + if (process.platform === 'win32') { + filePath = 'file:///' + path.resolve(filePath).replace(/\\/g, '/'); + } + + const options = { filename: filePath }; + for (const key in defaultOptions) { + options[key] = defaultOptions[key]; + } + return babel.transform(sourceCode, options).code; +}; + +function createVersionAndOptionsDigest(version, options) { + return crypto + .createHash('sha1') + .update('babel-core', 'utf8') + .update('\0', 'utf8') + .update(version, 'utf8') + .update('\0', 'utf8') + .update(JSON.stringify(options), 'utf8') + .digest('hex'); +} diff --git a/src/browser/application-menu.coffee b/src/browser/application-menu.coffee deleted file mode 100644 index 74da80e434b..00000000000 --- a/src/browser/application-menu.coffee +++ /dev/null @@ -1,174 +0,0 @@ -app = require 'app' -ipc = require 'ipc' -Menu = require 'menu' -_ = require 'underscore-plus' - -# 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.windows, (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.metadata.windowSpecific = true unless /^application:/.test(item.command) - @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 atom shell to provide nice icons where available. - acceleratorForCommand: (command, keystrokesByCommand) -> - firstKeystroke = keystrokesByCommand[command]?[0] - return null unless firstKeystroke - - modifiers = firstKeystroke.split(/-(?=.)/) - key = modifiers.pop().toUpperCase().replace('+', 'Plus') - - modifiers = modifiers.map (modifier) -> - modifier.replace(/shift/ig, "Shift") - .replace(/cmd/ig, "Command") - .replace(/ctrl/ig, "Ctrl") - .replace(/alt/ig, "Alt") - - keys = modifiers.concat([key]) - keys.join("+") diff --git a/src/browser/atom-application.coffee b/src/browser/atom-application.coffee deleted file mode 100644 index d81660a49c7..00000000000 --- a/src/browser/atom-application.coffee +++ /dev/null @@ -1,576 +0,0 @@ -AtomWindow = require './atom-window' -ApplicationMenu = require './application-menu' -AtomProtocolHandler = require './atom-protocol-handler' -AutoUpdateManager = require './auto-update-manager' -BrowserWindow = require 'browser-window' -StorageFolder = require '../storage-folder' -Menu = require 'menu' -app = require 'app' -fs = require 'fs-plus' -ipc = require 'ipc' -path = require 'path' -os = require 'os' -net = require 'net' -url = require 'url' -{EventEmitter} = require 'events' -_ = require 'underscore-plus' - -DefaultSocketPath = - if process.platform is 'win32' - '\\\\.\\pipe\\atom-sock' - else - path.join(os.tmpdir(), "atom-#{process.env.USER}.sock") - -# 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 - _.extend @prototype, EventEmitter.prototype - - # Public: The entry point into the Atom application. - @open: (options) -> - options.socketPath ?= DefaultSocketPath - - createAtomApplication = -> new AtomApplication(options) - - # 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 - createAtomApplication() - return - - client = net.connect {path: options.socketPath}, -> - client.write JSON.stringify(options), -> - client.end() - app.terminate() - - client.on 'error', createAtomApplication - - windows: null - applicationMenu: null - atomProtocolHandler: null - resourcePath: null - version: null - quitting: false - - exit: (status) -> app.exit(status) - - constructor: (options) -> - {@resourcePath, @version, @devMode, @safeMode, @apiPreviewMode, @socketPath} = options - - # Normalize to make sure drive letter case is consistent on Windows - @resourcePath = path.normalize(@resourcePath) if @resourcePath - - global.atomApplication = this - - @pidsToOpenWindows = {} - @pathsToOpen ?= [] - @windows = [] - - @autoUpdateManager = new AutoUpdateManager(@version, options.test) - @applicationMenu = new ApplicationMenu(@version, @autoUpdateManager) - @atomProtocolHandler = new AtomProtocolHandler(@resourcePath, @safeMode) - - @listenForArgumentsFromNewProcess() - @setupJavaScriptArguments() - @handleEvents() - @storageFolder = new StorageFolder(process.env.ATOM_HOME) - - if options.pathsToOpen?.length > 0 or options.urlsToOpen?.length > 0 or options.test - @openWithOptions(options) - else - @loadState() or @openPath(options) - - openWithOptions: ({pathsToOpen, urlsToOpen, test, pidToKillWhenClosed, devMode, safeMode, apiPreviewMode, newWindow, specDirectory, logFile, profileStartup}) -> - if test - @runSpecs({exitWhenDone: true, @resourcePath, specDirectory, logFile, apiPreviewMode}) - else if pathsToOpen.length > 0 - @openPaths({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, apiPreviewMode, profileStartup}) - else if urlsToOpen.length > 0 - @openUrl({urlToOpen, devMode, safeMode, apiPreviewMode}) for urlToOpen in urlsToOpen - else - # Always open a editor window if this is the first instance of Atom. - @openPath({pidToKillWhenClosed, newWindow, devMode, safeMode, apiPreviewMode, profileStartup}) - - # Public: Removes the {AtomWindow} from the global window list. - removeWindow: (window) -> - @windows.splice @windows.indexOf(window), 1 - if @windows.length is 0 - @applicationMenu?.enableWindowSpecificItems(false) - if process.platform in ['win32', 'linux'] - app.quit() - return - @saveState() unless window.isSpec or @quitting - - # Public: Adds the {AtomWindow} to the global window list. - addWindow: (window) -> - @windows.push window - @applicationMenu?.addWindow(window.browserWindow) - window.once 'window:loaded', => - @autoUpdateManager.emitUpdateAvailableEvent(window) - - unless window.isSpec - focusHandler = => @lastFocusedWindow = window - blurHandler = => @saveState() - window.browserWindow.on 'focus', focusHandler - window.browserWindow.on 'blur', blurHandler - window.browserWindow.once 'closed', => - @lastFocusedWindow = null if window is @lastFocusedWindow - window.browserWindow.removeListener 'focus', focusHandler - window.browserWindow.removeListener 'blur', blurHandler - window.browserWindow.webContents.once 'did-finish-load', => @saveState() - - # 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: -> - @deleteSocketFile() - server = net.createServer (connection) => - connection.on 'data', (data) => - @openWithOptions(JSON.parse(data)) - - server.listen @socketPath - server.on 'error', (error) -> console.error 'Application server failed', error - - deleteSocketFile: -> - return if process.platform is 'win32' - - 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' - - # Configures required javascript environment flags. - setupJavaScriptArguments: -> - app.commandLine.appendSwitch 'js-flags', '--harmony' - - # Registers basic application commands, non-idempotent. - handleEvents: -> - getLoadSettings = => - devMode: @focusedWindow()?.devMode - safeMode: @focusedWindow()?.safeMode - apiPreviewMode: @focusedWindow()?.apiPreviewMode - - @on 'application:run-all-specs', -> @runSpecs(exitWhenDone: false, resourcePath: global.devResourcePath, safeMode: @focusedWindow()?.safeMode) - @on 'application:run-benchmarks', -> @runBenchmarks() - @on 'application:quit', -> app.quit() - @on 'application:new-window', -> @openPath(_.extend(windowDimensions: @focusedWindow()?.getDimensions(), getLoadSettings())) - @on 'application:new-file', -> (@focusedWindow() ? this).openPath() - @on 'application:open', -> @promptForPathToOpen('all', getLoadSettings()) - @on 'application:open-file', -> @promptForPathToOpen('file', getLoadSettings()) - @on 'application:open-folder', -> @promptForPathToOpen('folder', getLoadSettings()) - @on 'application:open-dev', -> @promptForPathToOpen('all', devMode: true) - @on 'application:open-safe', -> @promptForPathToOpen('all', safeMode: true) - @on 'application:open-api-preview', -> @promptForPathToOpen('all', apiPreviewMode: true) - @on 'application:open-dev-api-preview', -> @promptForPathToOpen('all', {apiPreviewMode: true, devMode: true}) - @on 'application:inspect', ({x,y, atomWindow}) -> - atomWindow ?= @focusedWindow() - atomWindow?.browserWindow.inspectElement(x, y) - - @on 'application:open-documentation', -> require('shell').openExternal('https://atom.io/docs/latest/?app') - @on 'application:open-discussions', -> require('shell').openExternal('https://discuss.atom.io') - @on 'application:open-roadmap', -> require('shell').openExternal('https://atom.io/roadmap?app') - @on 'application:open-faq', -> require('shell').openExternal('https://atom.io/faq') - @on 'application:open-terms-of-use', -> require('shell').openExternal('https://atom.io/terms') - @on 'application:report-issue', -> require('shell').openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#submitting-issues') - @on 'application:search-issues', -> require('shell').openExternal('https://github.com/issues?q=+is%3Aissue+user%3Aatom') - - @on 'application:install-update', => @autoUpdateManager.install() - @on 'application:check-for-update', => @autoUpdateManager.check() - - if process.platform is 'darwin' - @on 'application:about', -> Menu.sendActionToFirstResponder('orderFrontStandardAboutPanel:') - @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: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')) - - app.on 'before-quit', => - @quitting = true - - app.on 'will-quit', => - @killAllProcesses() - @deleteSocketFile() - - app.on 'will-exit', => - @saveState() unless @windows.every (window) -> window.isSpec - @killAllProcesses() - @deleteSocketFile() - - app.on 'open-file', (event, pathToOpen) => - event.preventDefault() - @openPath({pathToOpen}) - - app.on 'open-url', (event, urlToOpen) => - event.preventDefault() - @openUrl({urlToOpen, @devMode, @safeMode, @apiPreviewMode}) - - app.on 'activate-with-no-open-windows', (event) => - event.preventDefault() - @emit('application:new-window') - - # A request from the associated render process to open a new render process. - ipc.on 'open', (event, options) => - window = @windowForEvent(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(options) - else - @promptForPathToOpen('all', {window}) - - ipc.on 'update-application-menu', (event, template, keystrokesByCommand) => - win = BrowserWindow.fromWebContents(event.sender) - @applicationMenu.update(win, template, keystrokesByCommand) - - ipc.on 'run-package-specs', (event, specDirectory) => - @runSpecs({resourcePath: global.devResourcePath, specDirectory: specDirectory, exitWhenDone: false}) - - ipc.on 'command', (event, command) => - @emit(command) - - ipc.on 'window-command', (event, command, args...) -> - win = BrowserWindow.fromWebContents(event.sender) - win.emit(command, args...) - - ipc.on 'call-window-method', (event, method, args...) -> - win = BrowserWindow.fromWebContents(event.sender) - win[method](args...) - - ipc.on 'pick-folder', (event, responseChannel) => - @promptForPath "folder", (selectedPaths) -> - event.sender.send(responseChannel, selectedPaths) - - clipboard = null - ipc.on 'write-text-to-selection-clipboard', (event, selectedText) -> - clipboard ?= require '../safe-clipboard' - clipboard.writeText(selectedText, 'selection') - - # 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 OS X 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 @windows, (atomWindow) -> - atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen) - - # Returns the {AtomWindow} for the given ipc event. - windowForEvent: ({sender}) -> - window = BrowserWindow.fromWebContents(sender) - _.find @windows, ({browserWindow}) -> window is browserWindow - - # Public: Returns the currently focused {AtomWindow} or undefined if none. - focusedWindow: -> - _.find @windows, (atomWindow) -> atomWindow.isFocused() - - # 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. - # :apiPreviewMode - Boolean to control the opened window's 1.0 API preview mode. - # :profileStartup - Boolean to control creating a profile of the startup time. - # :window - {AtomWindow} to open file paths in. - openPath: ({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, apiPreviewMode, profileStartup, window}) -> - @openPaths({pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, apiPreviewMode, profileStartup, window}) - - # 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. - # :apiPreviewMode - Boolean to control the opened window's 1.0 API preview mode. - # :windowDimensions - Object with height and width keys. - # :window - {AtomWindow} to open file paths in. - openPaths: ({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, apiPreviewMode, windowDimensions, profileStartup, window}={}) -> - pathsToOpen = (fs.normalize(pathToOpen) for pathToOpen in pathsToOpen) - locationsToOpen = (@locationForPathToOpen(pathToOpen) for pathToOpen in pathsToOpen) - - unless pidToKillWhenClosed or newWindow - existingWindow = @windowForPaths(pathsToOpen, devMode) - - # Default to using the specified window or the last focused window - currentWindow = window ? @lastFocusedWindow - stats = (fs.statSyncNoException(pathToOpen) for pathToOpen in pathsToOpen) - existingWindow ?= currentWindow if ( - 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() - else - if devMode - try - bootstrapScript = require.resolve(path.join(global.devResourcePath, 'src', 'window-bootstrap')) - resourcePath = global.devResourcePath - - bootstrapScript ?= require.resolve('../window-bootstrap') - resourcePath ?= @resourcePath - openedWindow = new AtomWindow({locationsToOpen, bootstrapScript, resourcePath, devMode, safeMode, apiPreviewMode, windowDimensions, profileStartup}) - - if pidToKillWhenClosed? - @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow - - openedWindow.browserWindow.once 'closed', => - @killProcessForWindow(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: -> - states = [] - for window in @windows - unless window.isSpec - if loadSettings = window.getLoadSettings() - states.push(initialPaths: loadSettings.initialPaths) - @storageFolder.store('application.json', states) - - loadState: -> - if (states = @storageFolder.load('application.json'))?.length > 0 - for state in states - @openWithOptions({ - pathsToOpen: state.initialPaths - urlsToOpen: [] - devMode: @devMode - safeMode: @safeMode - apiPreviewMode: @apiPreviewMode - }) - true - else - false - - # 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}) -> - unless @packages? - PackageManager = require '../package-manager' - @packages = new PackageManager - configDirPath: process.env.ATOM_HOME - devMode: devMode - resourcePath: @resourcePath - - packageName = url.parse(urlToOpen).host - pack = _.find @packages.getAvailablePackageMetadata(), ({name}) -> name is packageName - if pack? - if pack.urlMain - packagePath = @packages.resolvePackagePath(packageName) - bootstrapScript = path.resolve(packagePath, pack.urlMain) - windowDimensions = @focusedWindow()?.getDimensions() - new AtomWindow({bootstrapScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions}) - else - console.log "Package '#{pack.name}' does not have a url main: #{urlToOpen}" - else - console.log "Opening unknown url: #{urlToOpen}" - - # Opens up a new {AtomWindow} to run specs within. - # - # options - - # :exitWhenDone - 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. - runSpecs: ({exitWhenDone, resourcePath, specDirectory, logFile, safeMode, apiPreviewMode}) -> - if resourcePath isnt @resourcePath and not fs.existsSync(resourcePath) - resourcePath = @resourcePath - - try - bootstrapScript = require.resolve(path.resolve(global.devResourcePath, 'spec', 'spec-bootstrap')) - catch error - bootstrapScript = require.resolve(path.resolve(__dirname, '..', '..', 'spec', 'spec-bootstrap')) - - isSpec = true - devMode = true - safeMode ?= false - apiPreviewMode ?= false - new AtomWindow({bootstrapScript, resourcePath, exitWhenDone, isSpec, devMode, specDirectory, logFile, safeMode, apiPreviewMode}) - - runBenchmarks: ({exitWhenDone, specDirectory}={}) -> - try - bootstrapScript = require.resolve(path.resolve(global.devResourcePath, 'benchmark', 'benchmark-bootstrap')) - catch error - bootstrapScript = require.resolve(path.resolve(__dirname, '..', '..', 'benchmark', 'benchmark-bootstrap')) - - specDirectory ?= path.dirname(bootstrapScript) - - isSpec = true - devMode = true - new AtomWindow({bootstrapScript, @resourcePath, exitWhenDone, isSpec, specDirectory, devMode}) - - locationForPathToOpen: (pathToOpen) -> - return {pathToOpen} unless pathToOpen - return {pathToOpen} if fs.existsSync(pathToOpen) - - pathToOpen = pathToOpen.replace(/[:\s]+$/, '') - - [fileToOpen, initialLine, initialColumn] = path.basename(pathToOpen).split(':') - return {pathToOpen} unless initialLine - return {pathToOpen} unless parseInt(initialLine) >= 0 - - # Convert line numbers to a base of 0 - initialLine = Math.max(0, initialLine - 1) if initialLine - initialColumn = Math.max(0, initialColumn - 1) if initialColumn - pathToOpen = path.join(path.dirname(pathToOpen), fileToOpen) - {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 OS X. - # :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. - promptForPathToOpen: (type, {devMode, safeMode, apiPreviewMode, window}) -> - @promptForPath type, (pathsToOpen) => - @openPaths({pathsToOpen, devMode, safeMode, apiPreviewMode, window}) - - promptForPath: (type, callback) -> - 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 OS X. This matches most native apps. - parentWindow = - if process.platform is 'darwin' - null - else - BrowserWindow.getFocusedWindow() - - openOptions = - properties: properties.concat(['multiSelections', 'createDirectory']) - title: 'Open' - - if process.platform is 'linux' - if projectPath = @lastFocusedWindow?.projectPath - openOptions.defaultPath = projectPath - - dialog = require 'dialog' - dialog.showOpenDialog(parentWindow, openOptions, callback) diff --git a/src/browser/atom-protocol-handler.coffee b/src/browser/atom-protocol-handler.coffee deleted file mode 100644 index 0865f3ad8f5..00000000000 --- a/src/browser/atom-protocol-handler.coffee +++ /dev/null @@ -1,44 +0,0 @@ -app = require 'app' -fs = require 'fs' -path = require 'path' -protocol = require 'protocol' - -# 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.registerProtocol 'atom', (request) => - 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?() - - new protocol.RequestFileJob(filePath) diff --git a/src/browser/atom-window.coffee b/src/browser/atom-window.coffee deleted file mode 100644 index 6a6abb4b90a..00000000000 --- a/src/browser/atom-window.coffee +++ /dev/null @@ -1,222 +0,0 @@ -BrowserWindow = require 'browser-window' -app = require 'app' -path = require 'path' -fs = require 'fs' -url = require 'url' -_ = require 'underscore-plus' -{EventEmitter} = require 'events' - -module.exports = -class AtomWindow - _.extend @prototype, EventEmitter.prototype - - @iconPath: path.resolve(__dirname, '..', '..', 'resources', 'atom.png') - @includeShellLoadTime: true - - browserWindow: null - loaded: null - isSpec: null - - constructor: (settings={}) -> - {@resourcePath, pathToOpen, locationsToOpen, @isSpec, @exitWhenDone, @safeMode, @devMode, @apiPreviewMode} = settings - locationsToOpen ?= [{pathToOpen}] if pathToOpen - locationsToOpen ?= [] - - # Normalize to make sure drive letter case is consistent on Windows - @resourcePath = path.normalize(@resourcePath) if @resourcePath - - options = - show: false - title: 'Atom' - 'web-preferences': - 'direct-write': true - 'subpixel-font-scaling': false - # 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 - - @browserWindow = new BrowserWindow options - global.atomApplication.addWindow(this) - - @handleEvents() - - loadSettings = _.extend({}, settings) - loadSettings.windowState ?= '{}' - loadSettings.appVersion = app.getVersion() - loadSettings.resourcePath = @resourcePath - loadSettings.devMode ?= false - loadSettings.safeMode ?= false - loadSettings.apiPreviewMode ?= false - - # Only send to the first non-spec window created - if @constructor.includeShellLoadTime and not @isSpec - @constructor.includeShellLoadTime = false - loadSettings.shellLoadTime ?= Date.now() - global.shellStartTime - - loadSettings.initialPaths = - for {pathToOpen} in locationsToOpen when pathToOpen - if fs.statSyncNoException(pathToOpen).isFile?() - path.dirname(pathToOpen) - else - pathToOpen - - loadSettings.initialPaths.sort() - - @browserWindow.loadSettings = loadSettings - @browserWindow.once 'window:loaded', => - @emit 'window:loaded' - @loaded = true - - @setLoadSettings(loadSettings) - @browserWindow.focusOnWebView() if @isSpec - - hasPathToOpen = not (locationsToOpen.length is 1 and not locationsToOpen[0].pathToOpen?) - @openLocations(locationsToOpen) if hasPathToOpen and not @isSpecWindow() - - setLoadSettings: (loadSettingsObj) -> - # Ignore the windowState when passing loadSettings via URL, since it could - # be quite large. - loadSettings = _.clone(loadSettingsObj) - delete loadSettings['windowState'] - - @browserWindow.loadUrl url.format - protocol: 'file' - pathname: "#{@resourcePath}/static/index.html" - slashes: true - hash: encodeURIComponent(JSON.stringify(loadSettings)) - - getLoadSettings: -> - if @browserWindow.webContents.loaded - hash = url.parse(@browserWindow.webContents.getUrl()).hash.substr(1) - JSON.parse(decodeURIComponent(hash)) - - hasProjectPath: -> @getLoadSettings().initialPaths?.length > 0 - - setupContextMenu: -> - ContextMenu = null - - @browserWindow.on 'context-menu', (menuTemplate) => - ContextMenu ?= require './context-menu' - new ContextMenu(menuTemplate, this) - - containsPaths: (paths) -> - for pathToCheck in paths - return false unless @containsPath(pathToCheck) - true - - containsPath: (pathToCheck) -> - @getLoadSettings()?.initialPaths?.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 'closed', => - global.atomApplication.removeWindow(this) - - @browserWindow.on 'unresponsive', => - return if @isSpec - - dialog = require 'dialog' - chosen = dialog.showMessageBox @browserWindow, - type: 'warning' - buttons: ['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', => - global.atomApplication.exit(100) if @exitWhenDone - - dialog = require 'dialog' - 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.restart() - - @setupContextMenu() - - if @isSpec - # Workaround for https://github.com/atom/atom-shell/issues/380 - # Don't focus the window when it is being blurred during close or - # else the app will crash on Windows. - if process.platform is 'win32' - @browserWindow.on 'close', => @isWindowClosing = true - - # Spec window's web view should always have focus - @browserWindow.on 'blur', => - @browserWindow.focusOnWebView() unless @isWindowClosing - - openPath: (pathToOpen, initialLine, initialColumn) -> - @openLocations([{pathToOpen, initialLine, initialColumn}]) - - openLocations: (locationsToOpen) -> - if @loaded - @focus() - @sendMessage 'open-locations', locationsToOpen - else - @browserWindow.once 'window:loaded', => @openLocations(locationsToOpen) - - sendMessage: (message, detail) -> - @browserWindow.webContents.send 'message', message, detail - - sendCommand: (command, args...) -> - if @isSpecWindow() - unless global.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 global.atomApplication.sendCommandToFirstResponder(command) - @sendCommandToBrowserWindow(command, args...) - - 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} - - close: -> @browserWindow.close() - - focus: -> @browserWindow.focus() - - minimize: -> @browserWindow.minimize() - - maximize: -> @browserWindow.maximize() - - restore: -> @browserWindow.restore() - - handlesAtomCommands: -> - not @isSpecWindow() and @isWebViewFocused() - - isFocused: -> @browserWindow.isFocused() - - isMinimized: -> @browserWindow.isMinimized() - - isWebViewFocused: -> @browserWindow.isWebViewFocused() - - isSpecWindow: -> @isSpec - - reload: -> @browserWindow.restart() - - toggleDevTools: -> @browserWindow.toggleDevTools() diff --git a/src/browser/auto-update-manager.coffee b/src/browser/auto-update-manager.coffee deleted file mode 100644 index a2c23978905..00000000000 --- a/src/browser/auto-update-manager.coffee +++ /dev/null @@ -1,117 +0,0 @@ -autoUpdater = null -_ = require 'underscore-plus' -{EventEmitter} = require 'events' -path = require 'path' - -IdleState = 'idle' -CheckingState = 'checking' -DownladingState = 'downloading' -UpdateAvailableState = 'update-available' -NoUpdateAvailableState = 'no-update-available' -UnsupportedState = 'unsupported' -ErrorState = 'error' - -module.exports = -class AutoUpdateManager - _.extend @prototype, EventEmitter.prototype - - constructor: (@version, @testMode) -> - @state = IdleState - if process.platform is 'win32' - # Squirrel for Windows can't handle query params - # https://github.com/Squirrel/Squirrel.Windows/issues/132 - @feedUrl = 'https://atom.io/api/updates' - else - @iconPath = path.resolve(__dirname, '..', '..', 'resources', 'atom.png') - @feedUrl = "https://atom.io/api/updates?version=#{@version}" - - process.nextTick => @setupAutoUpdater() - - setupAutoUpdater: -> - if process.platform is 'win32' - autoUpdater = require './auto-updater-win32' - else - autoUpdater = require 'auto-updater' - - autoUpdater.on 'error', (event, message) => - @setState(ErrorState) - console.error "Error Downloading Update: #{message}" - - autoUpdater.setFeedUrl @feedUrl - - autoUpdater.on 'checking-for-update', => - @setState(CheckingState) - - autoUpdater.on 'update-not-available', => - @setState(NoUpdateAvailableState) - - autoUpdater.on 'update-available', => - @setState(DownladingState) - - autoUpdater.on 'update-downloaded', (event, releaseNotes, @releaseVersion) => - @setState(UpdateAvailableState) - @emitUpdateAvailableEvent(@getWindows()...) - - # Only released versions should check for updates. - @scheduleUpdateCheck() unless /\w{7}/.test(@version) - - switch process.platform - when 'win32' - @setState(UnsupportedState) unless autoUpdater.supportsUpdates() - when 'linux' - @setState(UnsupportedState) - - emitUpdateAvailableEvent: (windows...) -> - return unless @releaseVersion? - for atomWindow in windows - atomWindow.sendMessage('update-available', {@releaseVersion}) - return - - setState: (state) -> - return if @state is state - @state = state - @emit 'state-changed', @state - - getState: -> - @state - - scheduleUpdateCheck: -> - checkForUpdates = => @check(hidePopups: true) - fourHours = 1000 * 60 * 60 * 4 - setInterval(checkForUpdates, fourHours) - checkForUpdates() - - check: ({hidePopups}={}) -> - unless hidePopups - autoUpdater.once 'update-not-available', @onUpdateNotAvailable - autoUpdater.once 'error', @onUpdateError - - autoUpdater.checkForUpdates() unless @testMode - - install: -> - autoUpdater.quitAndInstall() unless @testMode - - onUpdateNotAvailable: => - autoUpdater.removeListener 'error', @onUpdateError - dialog = require 'dialog' - dialog.showMessageBox - type: 'info' - buttons: ['OK'] - icon: @iconPath - message: 'No update available.' - title: 'No Update Available' - detail: "Version #{@version} is the latest version." - - onUpdateError: (event, message) => - autoUpdater.removeListener 'update-not-available', @onUpdateNotAvailable - dialog = require 'dialog' - dialog.showMessageBox - type: 'warning' - buttons: ['OK'] - icon: @iconPath - message: 'There was an error checking for updates.' - title: 'Update Error' - detail: message - - getWindows: -> - global.atomApplication.windows diff --git a/src/browser/auto-updater-win32.coffee b/src/browser/auto-updater-win32.coffee deleted file mode 100644 index 89018a39607..00000000000 --- a/src/browser/auto-updater-win32.coffee +++ /dev/null @@ -1,62 +0,0 @@ -{EventEmitter} = require 'events' -_ = require 'underscore-plus' -SquirrelUpdate = require './squirrel-update' - -class AutoUpdater - _.extend @prototype, EventEmitter.prototype - - setFeedUrl: (@updateUrl) -> - - quitAndInstall: -> - if SquirrelUpdate.existsSync() - SquirrelUpdate.restartAtom(require('app')) - else - require('auto-updater').quitAndInstall() - - downloadUpdate: (callback) -> - SquirrelUpdate.spawn ['--download', @updateUrl], (error, stdout) -> - return callback(error) if error? - - try - # Last line of output is the JSON details about the releases - json = stdout.trim().split('\n').pop() - update = JSON.parse(json)?.releasesToApply?.pop?() - catch error - error.stdout = stdout - return callback(error) - - callback(null, update) - - installUpdate: (callback) -> - SquirrelUpdate.spawn(['--update', @updateUrl], callback) - - supportsUpdates: -> - SquirrelUpdate.existsSync() - - checkForUpdates: -> - throw new Error('Update URL is not set') unless @updateUrl - - @emit 'checking-for-update' - - unless SquirrelUpdate.existsSync() - @emit 'update-not-available' - return - - @downloadUpdate (error, update) => - if error? - @emit 'update-not-available' - return - - unless update? - @emit 'update-not-available' - return - - @installUpdate (error) => - if error? - @emit 'update-not-available' - return - - @emit 'update-available' - @emit 'update-downloaded', {}, update.releaseNotes, update.version, new Date(), 'https://atom.io', => @quitAndInstall() - -module.exports = new AutoUpdater() diff --git a/src/browser/context-menu.coffee b/src/browser/context-menu.coffee deleted file mode 100644 index 44b57cdc909..00000000000 --- a/src/browser/context-menu.coffee +++ /dev/null @@ -1,24 +0,0 @@ -Menu = require 'menu' - -module.exports = -class ContextMenu - constructor: (template, @atomWindow) -> - template = @createClickHandlers(template) - menu = Menu.buildFromTemplate(template) - menu.popup(@atomWindow.browserWindow) - - # It's necessary to build the event handlers in this process, otherwise - # closures are dragged across processes and failed to be garbage collected - # appropriately. - createClickHandlers: (template) -> - for item in template - if item.command - item.commandDetail ?= {} - item.commandDetail.contextCommand = true - item.commandDetail.atomWindow = @atomWindow - do (item) => - item.click = => - global.atomApplication.sendCommandToWindow(item.command, @atomWindow, item.commandDetail) - else if item.submenu - @createClickHandlers(item.submenu) - item diff --git a/src/browser/main.coffee b/src/browser/main.coffee deleted file mode 100644 index 1af0526ddd9..00000000000 --- a/src/browser/main.coffee +++ /dev/null @@ -1,171 +0,0 @@ -global.shellStartTime = Date.now() - -crashReporter = require 'crash-reporter' -app = require 'app' -fs = require 'fs-plus' -path = require 'path' -yargs = require 'yargs' -nslog = require 'nslog' - -console.log = nslog - -process.on 'uncaughtException', (error={}) -> - nslog(error.message) if error.message? - nslog(error.stack) if error.stack? - -start = -> - setupAtomHome() - setupCoffeeCache() - - if process.platform is 'win32' - SquirrelUpdate = require './squirrel-update' - squirrelCommand = process.argv[1] - return if SquirrelUpdate.handleStartupEvent(app, squirrelCommand) - - args = parseCommandLine() - - addPathToOpen = (event, pathToOpen) -> - event.preventDefault() - args.pathsToOpen.push(pathToOpen) - - args.urlsToOpen = [] - addUrlToOpen = (event, urlToOpen) -> - event.preventDefault() - args.urlsToOpen.push(urlToOpen) - - app.on 'open-file', addPathToOpen - app.on 'open-url', addUrlToOpen - - app.on 'will-finish-launching', -> - setupCrashReporter() - - app.on 'ready', -> - app.removeListener 'open-file', addPathToOpen - app.removeListener 'open-url', addUrlToOpen - - cwd = args.executedFrom?.toString() or process.cwd() - args.pathsToOpen = args.pathsToOpen.map (pathToOpen) -> - pathToOpen = fs.normalize(pathToOpen) - if cwd - path.resolve(cwd, pathToOpen) - else - path.resolve(pathToOpen) - - if args.devMode - AtomApplication = require path.join(args.resourcePath, 'src', 'browser', 'atom-application') - else - AtomApplication = require './atom-application' - - AtomApplication.open(args) - console.log("App load time: #{Date.now() - global.shellStartTime}ms") unless args.test - -global.devResourcePath = process.env.ATOM_DEV_RESOURCE_PATH ? path.join(app.getHomeDir(), 'github', 'atom') -# Normalize to make sure drive letter case is consistent on Windows -global.devResourcePath = path.normalize(global.devResourcePath) if global.devResourcePath - -setupCrashReporter = -> - crashReporter.start(productName: 'Atom', companyName: 'GitHub') - -setupAtomHome = -> - return if process.env.ATOM_HOME - - atomHome = path.join(app.getHomeDir(), '.atom') - try - atomHome = fs.realpathSync(atomHome) - process.env.ATOM_HOME = atomHome - -setupCoffeeCache = -> - CoffeeCache = require 'coffee-cash' - cacheDir = path.join(process.env.ATOM_HOME, 'compile-cache') - # Use separate compile cache when sudo'ing as root to avoid permission issues - if process.env.USER is 'root' and process.env.SUDO_USER and process.env.SUDO_USER isnt process.env.USER - cacheDir = path.join(cacheDir, 'root') - CoffeeCache.setCacheDirectory(path.join(cacheDir, 'coffee')) - CoffeeCache.register() - -parseCommandLine = -> - version = app.getVersion() - options = yargs(process.argv[1..]).wrap(100) - options.usage """ - Atom Editor v#{version} - - Usage: atom [options] [path ...] - - One or more paths to files or folders may be specified. If there is an - existing Atom window that contains all of the given folders, the paths - will be opened in that window. Otherwise, they will be opened in a new - window. - - Environment Variables: - - ATOM_DEV_RESOURCE_PATH The path from which Atom loads source code in dev mode. - Defaults to `~/github/atom`. - - ATOM_HOME The root path for all configuration files and folders. - Defaults to `~/.atom`. - """ - options.alias('1', 'one').boolean('1').describe('1', 'Run in 1.0 API preview mode.') - options.alias('d', 'dev').boolean('d').describe('d', 'Run in development mode.') - options.alias('f', 'foreground').boolean('f').describe('f', 'Keep the browser process in the foreground.') - options.alias('h', 'help').boolean('h').describe('h', 'Print this usage message.') - options.alias('l', 'log-file').string('l').describe('l', 'Log all output to file.') - options.alias('n', 'new-window').boolean('n').describe('n', 'Open a new window.') - options.boolean('profile-startup').describe('profile-startup', 'Create a profile of the startup execution time.') - options.alias('r', 'resource-path').string('r').describe('r', 'Set the path to the Atom source directory and enable dev-mode.') - options.alias('s', 'spec-directory').string('s').describe('s', 'Set the directory from which to run package specs (default: Atom\'s spec directory).') - options.boolean('safe').describe('safe', 'Do not load packages from ~/.atom/packages or ~/.atom/dev/packages.') - options.alias('t', 'test').boolean('t').describe('t', 'Run the specified specs and exit with error code on failures.') - options.alias('v', 'version').boolean('v').describe('v', 'Print the version.') - options.alias('w', 'wait').boolean('w').describe('w', 'Wait for window to be closed before returning.') - options.string('socket-path') - args = options.argv - - if args.help - process.stdout.write(options.help()) - process.exit(0) - - if args.version - process.stdout.write("#{version}\n") - process.exit(0) - - executedFrom = args['executed-from'] - devMode = args['dev'] - safeMode = args['safe'] - apiPreviewMode = args['one'] - pathsToOpen = args._ - test = args['test'] - specDirectory = args['spec-directory'] - newWindow = args['new-window'] - pidToKillWhenClosed = args['pid'] if args['wait'] - logFile = args['log-file'] - socketPath = args['socket-path'] - profileStartup = args['profile-startup'] - - if args['resource-path'] - devMode = true - resourcePath = args['resource-path'] - else - # Set resourcePath based on the specDirectory if running specs on atom core - if specDirectory? - packageDirectoryPath = path.join(specDirectory, '..') - packageManifestPath = path.join(packageDirectoryPath, 'package.json') - if fs.statSyncNoException(packageManifestPath) - try - packageManifest = JSON.parse(fs.readFileSync(packageManifestPath)) - resourcePath = packageDirectoryPath if packageManifest.name is 'atom' - - if devMode - resourcePath ?= global.devResourcePath - - unless fs.statSyncNoException(resourcePath) - resourcePath = path.dirname(path.dirname(__dirname)) - - # On Yosemite the $PATH is not inherited by the "open" command, so we have to - # explicitly pass it by command line, see http://git.io/YC8_Ew. - process.env.PATH = args['path-environment'] if args['path-environment'] - - {resourcePath, pathsToOpen, executedFrom, test, version, pidToKillWhenClosed, - devMode, apiPreviewMode, safeMode, newWindow, specDirectory, logFile, - socketPath, profileStartup} - -start() diff --git a/src/browser/squirrel-update.coffee b/src/browser/squirrel-update.coffee deleted file mode 100644 index 1603a7c0a14..00000000000 --- a/src/browser/squirrel-update.coffee +++ /dev/null @@ -1,254 +0,0 @@ -ChildProcess = require 'child_process' -fs = require 'fs-plus' -path = require 'path' - -appFolder = path.resolve(process.execPath, '..') -rootAtomFolder = path.resolve(appFolder, '..') -binFolder = path.join(rootAtomFolder, 'bin') -updateDotExe = path.join(rootAtomFolder, 'Update.exe') -exeName = path.basename(process.execPath) - -if process.env.SystemRoot - system32Path = path.join(process.env.SystemRoot, 'System32') - regPath = path.join(system32Path, 'reg.exe') - setxPath = path.join(system32Path, 'setx.exe') -else - regPath = 'reg.exe' - setxPath = 'setx.exe' - -# Registry keys used for context menu -fileKeyPath = 'HKCU\\Software\\Classes\\*\\shell\\Atom' -directoryKeyPath = 'HKCU\\Software\\Classes\\directory\\shell\\Atom' -backgroundKeyPath = 'HKCU\\Software\\Classes\\directory\\background\\shell\\Atom' -environmentKeyPath = 'HKCU\\Environment' - -# Spawn a command and invoke the callback when it completes with an error -# and the output from standard out. -spawn = (command, args, callback) -> - stdout = '' - - try - spawnedProcess = ChildProcess.spawn(command, args) - catch error - # Spawn can throw an error - process.nextTick -> callback?(error, stdout) - return - - spawnedProcess.stdout.on 'data', (data) -> stdout += data - - error = null - spawnedProcess.on 'error', (processError) -> error ?= processError - spawnedProcess.on 'close', (code, signal) -> - error ?= new Error("Command failed: #{signal ? code}") if code isnt 0 - error?.code ?= code - error?.stdout ?= stdout - callback?(error, stdout) - -# Spawn reg.exe and callback when it completes -spawnReg = (args, callback) -> - spawn(regPath, args, callback) - -# Spawn setx.exe and callback when it completes -spawnSetx = (args, callback) -> - spawn(setxPath, args, callback) - -# Spawn the Update.exe with the given arguments and invoke the callback when -# the command completes. -spawnUpdate = (args, callback) -> - spawn(updateDotExe, args, callback) - -# Install the Open with Atom explorer context menu items via the registry. -installContextMenu = (callback) -> - addToRegistry = (args, callback) -> - args.unshift('add') - args.push('/f') - spawnReg(args, callback) - - installMenu = (keyPath, arg, callback) -> - args = [keyPath, '/ve', '/d', 'Open with Atom'] - addToRegistry args, -> - args = [keyPath, '/v', 'Icon', '/d', process.execPath] - addToRegistry args, -> - args = ["#{keyPath}\\command", '/ve', '/d', "#{process.execPath} \"#{arg}\""] - addToRegistry(args, callback) - - installMenu fileKeyPath, '%1', -> - installMenu directoryKeyPath, '%1', -> - installMenu(backgroundKeyPath, '%V', callback) - -isAscii = (text) -> - index = 0 - while index < text.length - return false if text.charCodeAt(index) > 127 - index++ - true - -# Get the user's PATH environment variable registry value. -getPath = (callback) -> - spawnReg ['query', environmentKeyPath, '/v', 'Path'], (error, stdout) -> - if error? - if error.code is 1 - # FIXME Don't overwrite path when reading value is disabled - # https://github.com/atom/atom/issues/5092 - if stdout.indexOf('ERROR: Registry editing has been disabled by your administrator.') isnt -1 - return callback(error) - - # The query failed so the Path does not exist yet in the registry - return callback(null, '') - else - return callback(error) - - # Registry query output is in the form: - # - # HKEY_CURRENT_USER\Environment - # Path REG_SZ C:\a\folder\on\the\path;C\another\folder - # - - lines = stdout.split(/[\r\n]+/).filter (line) -> line - segments = lines[lines.length - 1]?.split(' ') - if segments[1] is 'Path' and segments.length >= 3 - pathEnv = segments?[3..].join(' ') - if isAscii(pathEnv) - callback(null, pathEnv) - else - # FIXME Don't corrupt non-ASCII PATH values - # https://github.com/atom/atom/issues/5063 - callback(new Error('PATH contains non-ASCII values')) - else - callback(new Error('Registry query for PATH failed')) - -# Uninstall the Open with Atom explorer context menu items via the registry. -uninstallContextMenu = (callback) -> - deleteFromRegistry = (keyPath, callback) -> - spawnReg(['delete', keyPath, '/f'], callback) - - deleteFromRegistry fileKeyPath, -> - deleteFromRegistry directoryKeyPath, -> - deleteFromRegistry(backgroundKeyPath, callback) - -# Add atom and apm to the PATH -# -# This is done by adding .cmd shims to the root bin folder in the Atom -# install directory that point to the newly installed versions inside -# the versioned app directories. -addCommandsToPath = (callback) -> - installCommands = (callback) -> - atomCommandPath = path.join(binFolder, 'atom.cmd') - relativeAtomPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'atom.cmd')) - atomCommand = "@echo off\r\n\"%~dp0\\#{relativeAtomPath}\" %*" - - atomShCommandPath = path.join(binFolder, 'atom') - relativeAtomShPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'atom.sh')) - atomShCommand = "#!/bin/sh\r\n\"$0/../#{relativeAtomShPath.replace(/\\/g, '/')}\" \"$@\"" - - apmCommandPath = path.join(binFolder, 'apm.cmd') - relativeApmPath = path.relative(binFolder, path.join(process.resourcesPath, 'app', 'apm', 'bin', 'apm.cmd')) - apmCommand = "@echo off\r\n\"%~dp0\\#{relativeApmPath}\" %*" - - apmShCommandPath = path.join(binFolder, 'apm') - relativeApmShPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'apm.sh')) - apmShCommand = "#!/bin/sh\r\n\"$0/../#{relativeApmShPath.replace(/\\/g, '/')}\" \"$@\"" - - fs.writeFile atomCommandPath, atomCommand, -> - fs.writeFile atomShCommandPath, atomShCommand, -> - fs.writeFile apmCommandPath, apmCommand, -> - fs.writeFile apmShCommandPath, apmShCommand, -> - callback() - - addBinToPath = (pathSegments, callback) -> - pathSegments.push(binFolder) - newPathEnv = pathSegments.join(';') - spawnSetx(['Path', newPathEnv], callback) - - installCommands (error) -> - return callback(error) if error? - - getPath (error, pathEnv) -> - return callback(error) if error? - - pathSegments = pathEnv.split(/;+/).filter (pathSegment) -> pathSegment - if pathSegments.indexOf(binFolder) is -1 - addBinToPath(pathSegments, callback) - else - callback() - -# Remove atom and apm from the PATH -removeCommandsFromPath = (callback) -> - getPath (error, pathEnv) -> - return callback(error) if error? - - pathSegments = pathEnv.split(/;+/).filter (pathSegment) -> - pathSegment and pathSegment isnt binFolder - newPathEnv = pathSegments.join(';') - - if pathEnv isnt newPathEnv - spawnSetx(['Path', newPathEnv], callback) - else - callback() - -# Create a desktop and start menu shortcut by using the command line API -# provided by Squirrel's Update.exe -createShortcuts = (callback) -> - spawnUpdate(['--createShortcut', exeName], callback) - -# Update the desktop and start menu shortcuts by using the command line API -# provided by Squirrel's Update.exe -updateShortcuts = (callback) -> - if homeDirectory = fs.getHomeDirectory() - desktopShortcutPath = path.join(homeDirectory, 'Desktop', 'Atom.lnk') - # Check if the desktop shortcut has been previously deleted and - # and keep it deleted if it was - fs.exists desktopShortcutPath, (desktopShortcutExists) -> - createShortcuts -> - if desktopShortcutExists - callback() - else - # Remove the unwanted desktop shortcut that was recreated - fs.unlink(desktopShortcutPath, callback) - else - createShortcuts(callback) - -# Remove the desktop and start menu shortcuts by using the command line API -# provided by Squirrel's Update.exe -removeShortcuts = (callback) -> - spawnUpdate(['--removeShortcut', exeName], callback) - -exports.spawn = spawnUpdate - -# Is the Update.exe installed with Atom? -exports.existsSync = -> - fs.existsSync(updateDotExe) - -# Restart Atom using the version pointed to by the atom.cmd shim -exports.restartAtom = (app) -> - if projectPath = global.atomApplication?.lastFocusedWindow?.projectPath - args = [projectPath] - app.once 'will-quit', -> spawn(path.join(binFolder, 'atom.cmd'), args) - app.quit() - -# Handle squirrel events denoted by --squirrel-* command line arguments. -exports.handleStartupEvent = (app, squirrelCommand) -> - switch squirrelCommand - when '--squirrel-install' - createShortcuts -> - installContextMenu -> - addCommandsToPath -> - app.quit() - true - when '--squirrel-updated' - updateShortcuts -> - installContextMenu -> - addCommandsToPath -> - app.quit() - true - when '--squirrel-uninstall' - removeShortcuts -> - uninstallContextMenu -> - removeCommandsFromPath -> - app.quit() - true - when '--squirrel-obsolete' - app.quit() - true - else - false diff --git a/src/buffered-node-process.coffee b/src/buffered-node-process.coffee deleted file mode 100644 index bb1a1c655ea..00000000000 --- a/src/buffered-node-process.coffee +++ /dev/null @@ -1,55 +0,0 @@ -BufferedProcess = require './buffered-process' -path = require 'path' - -# Extended: Like {BufferedProcess}, but accepts a Node script as the command -# to run. -# -# This is necessary on Windows since it doesn't support shebang `#!` lines. -# -# ## Examples -# -# ```coffee -# {BufferedNodeProcess} = require 'atom' -# ``` -module.exports = -class BufferedNodeProcess extends BufferedProcess - - # Public: Runs the given Node script by spawning a new child process. - # - # * `options` An {Object} with the following keys: - # * `command` The {String} path to the JavaScript script to execute. - # * `args` The {Array} of arguments to pass to the script (optional). - # * `options` The options {Object} to pass to Node's `ChildProcess.spawn` - # method (optional). - # * `stdout` The callback {Function} that receives a single argument which - # contains the standard output from the command. The callback is - # called as data is received but it's buffered to ensure only - # complete lines are passed until the source stream closes. After - # the source stream has closed all remaining data is sent in a - # final call (optional). - # * `stderr` The callback {Function} that receives a single argument which - # contains the standard error output from the command. The - # callback is called as data is received but it's buffered to - # ensure only complete lines are passed until the source stream - # closes. After the source stream has closed all remaining data - # is sent in a final call (optional). - # * `exit` The callback {Function} which receives a single argument - # containing the exit status (optional). - constructor: ({command, args, options, stdout, stderr, exit}) -> - node = - if process.platform is 'darwin' - # Use a helper to prevent an icon from appearing on the Dock - path.resolve(process.resourcesPath, '..', 'Frameworks', - 'Atom Helper.app', 'Contents', 'MacOS', 'Atom Helper') - else - process.execPath - - options ?= {} - options.env ?= Object.create(process.env) - options.env['ATOM_SHELL_INTERNAL_RUN_AS_NODE'] = 1 - - args = args?.slice() ? [] - args.unshift(command) - args.unshift('--no-deprecation') - - super({command: node, args, options, stdout, stderr, exit}) diff --git a/src/buffered-node-process.js b/src/buffered-node-process.js new file mode 100644 index 00000000000..9f3ca0d64cd --- /dev/null +++ b/src/buffered-node-process.js @@ -0,0 +1,53 @@ +const BufferedProcess = require('./buffered-process'); + +// Extended: Like {BufferedProcess}, but accepts a Node script as the command +// to run. +// +// This is necessary on Windows since it doesn't support shebang `#!` lines. +// +// ## Examples +// +// ```js +// const {BufferedNodeProcess} = require('atom') +// ``` +module.exports = class BufferedNodeProcess extends BufferedProcess { + // Public: Runs the given Node script by spawning a new child process. + // + // * `options` An {Object} with the following keys: + // * `command` The {String} path to the JavaScript script to execute. + // * `args` The {Array} of arguments to pass to the script (optional). + // * `options` The options {Object} to pass to Node's `ChildProcess.spawn` + // method (optional). + // * `stdout` The callback {Function} that receives a single argument which + // contains the standard output from the command. The callback is + // called as data is received but it's buffered to ensure only + // complete lines are passed until the source stream closes. After + // the source stream has closed all remaining data is sent in a + // final call (optional). + // * `stderr` The callback {Function} that receives a single argument which + // contains the standard error output from the command. The + // callback is called as data is received but it's buffered to + // ensure only complete lines are passed until the source stream + // closes. After the source stream has closed all remaining data + // is sent in a final call (optional). + // * `exit` The callback {Function} which receives a single argument + // containing the exit status (optional). + constructor({ command, args, options = {}, stdout, stderr, exit }) { + options.env = options.env || Object.create(process.env); + options.env.ELECTRON_RUN_AS_NODE = 1; + options.env.ELECTRON_NO_ATTACH_CONSOLE = 1; + + args = args ? args.slice() : []; + args.unshift(command); + args.unshift('--no-deprecation'); + + super({ + command: process.execPath, + args, + options, + stdout, + stderr, + exit + }); + } +}; diff --git a/src/buffered-process.coffee b/src/buffered-process.coffee deleted file mode 100644 index c7097d711cb..00000000000 --- a/src/buffered-process.coffee +++ /dev/null @@ -1,244 +0,0 @@ -_ = require 'underscore-plus' -ChildProcess = require 'child_process' -{Emitter} = require 'event-kit' -path = require 'path' - -# Extended: A wrapper which provides standard error/output line buffering for -# Node's ChildProcess. -# -# ## Examples -# -# ```coffee -# {BufferedProcess} = require 'atom' -# -# command = 'ps' -# args = ['-ef'] -# stdout = (output) -> console.log(output) -# exit = (code) -> console.log("ps -ef exited with #{code}") -# process = new BufferedProcess({command, args, stdout, exit}) -# ``` -module.exports = -class BufferedProcess - ### - Section: Construction - ### - - # Public: Runs the given command by spawning a new child process. - # - # * `options` An {Object} with the following keys: - # * `command` The {String} command to execute. - # * `args` The {Array} of arguments to pass to the command (optional). - # * `options` {Object} (optional) The options {Object} to pass to Node's - # `ChildProcess.spawn` method. - # * `stdout` {Function} (optional) The callback that receives a single - # argument which contains the standard output from the command. The - # callback is called as data is received but it's buffered to ensure only - # complete lines are passed until the source stream closes. After the - # source stream has closed all remaining data is sent in a final call. - # * `data` {String} - # * `stderr` {Function} (optional) The callback that receives a single - # argument which contains the standard error output from the command. The - # callback is called as data is received but it's buffered to ensure only - # complete lines are passed until the source stream closes. After the - # source stream has closed all remaining data is sent in a final call. - # * `data` {String} - # * `exit` {Function} (optional) The callback which receives a single - # argument containing the exit status. - # * `code` {Number} - constructor: ({command, args, options, stdout, stderr, exit}={}) -> - @emitter = new Emitter - options ?= {} - @command = command - # Related to joyent/node#2318 - if process.platform is 'win32' - # Quote all arguments and escapes inner quotes - if args? - cmdArgs = args.filter (arg) -> arg? - cmdArgs = cmdArgs.map (arg) => - if @isExplorerCommand(command) and /^\/[a-zA-Z]+,.*$/.test(arg) - # Don't wrap /root,C:\folder style arguments to explorer calls in - # quotes since they will not be interpreted correctly if they are - arg - else - "\"#{arg.toString().replace(/"/g, '\\"')}\"" - else - cmdArgs = [] - if /\s/.test(command) - cmdArgs.unshift("\"#{command}\"") - else - cmdArgs.unshift(command) - cmdArgs = ['/s', '/c', "\"#{cmdArgs.join(' ')}\""] - cmdOptions = _.clone(options) - cmdOptions.windowsVerbatimArguments = true - @spawn(@getCmdPath(), cmdArgs, cmdOptions) - else - @spawn(command, args, options) - - @killed = false - @handleEvents(stdout, stderr, exit) - - ### - Section: Event Subscription - ### - - # Public: Will call your callback when an error will be raised by the process. - # Usually this is due to the command not being available or not on the PATH. - # You can call `handle()` on the object passed to your callback to indicate - # that you have handled this error. - # - # * `callback` {Function} callback - # * `errorObject` {Object} - # * `error` {Object} the error object - # * `handle` {Function} call this to indicate you have handled the error. - # The error will not be thrown if this function is called. - # - # Returns a {Disposable} - onWillThrowError: (callback) -> - @emitter.on 'will-throw-error', callback - - ### - Section: Helper Methods - ### - - # Helper method to pass data line by line. - # - # * `stream` The Stream to read from. - # * `onLines` The callback to call with each line of data. - # * `onDone` The callback to call when the stream has closed. - bufferStream: (stream, onLines, onDone) -> - stream.setEncoding('utf8') - buffered = '' - - stream.on 'data', (data) => - return if @killed - buffered += data - lastNewlineIndex = buffered.lastIndexOf('\n') - if lastNewlineIndex isnt -1 - onLines(buffered.substring(0, lastNewlineIndex + 1)) - buffered = buffered.substring(lastNewlineIndex + 1) - - stream.on 'close', => - return if @killed - onLines(buffered) if buffered.length > 0 - onDone() - - # Kill all child processes of the spawned cmd.exe process on Windows. - # - # This is required since killing the cmd.exe does not terminate child - # processes. - killOnWindows: -> - return unless @process? - - parentPid = @process.pid - cmd = 'wmic' - args = [ - 'process' - 'where' - "(ParentProcessId=#{parentPid})" - 'get' - 'processid' - ] - - try - wmicProcess = ChildProcess.spawn(cmd, args) - catch spawnError - @killProcess() - return - - wmicProcess.on 'error', -> # ignore errors - output = '' - wmicProcess.stdout.on 'data', (data) -> output += data - wmicProcess.stdout.on 'close', => - pidsToKill = output.split(/\s+/) - .filter (pid) -> /^\d+$/.test(pid) - .map (pid) -> parseInt(pid) - .filter (pid) -> pid isnt parentPid and 0 < pid < Infinity - - for pid in pidsToKill - try - process.kill(pid) - @killProcess() - - killProcess: -> - @process?.kill() - @process = null - - isExplorerCommand: (command) -> - if command is 'explorer.exe' or command is 'explorer' - true - else if process.env.SystemRoot - command is path.join(process.env.SystemRoot, 'explorer.exe') or command is path.join(process.env.SystemRoot, 'explorer') - else - false - - getCmdPath: -> - if process.env.comspec - process.env.comspec - else if process.env.SystemRoot - path.join(process.env.SystemRoot, 'System32', 'cmd.exe') - else - 'cmd.exe' - - # Public: Terminate the process. - kill: -> - return if @killed - - @killed = true - if process.platform is 'win32' - @killOnWindows() - else - @killProcess() - - undefined - - spawn: (command, args, options) -> - try - @process = ChildProcess.spawn(command, args, options) - catch spawnError - process.nextTick => @handleError(spawnError) - - handleEvents: (stdout, stderr, exit) -> - return unless @process? - - stdoutClosed = true - stderrClosed = true - processExited = true - exitCode = 0 - triggerExitCallback = -> - return if @killed - if stdoutClosed and stderrClosed and processExited - exit?(exitCode) - - if stdout - stdoutClosed = false - @bufferStream @process.stdout, stdout, -> - stdoutClosed = true - triggerExitCallback() - - if stderr - stderrClosed = false - @bufferStream @process.stderr, stderr, -> - stderrClosed = true - triggerExitCallback() - - if exit - processExited = false - @process.on 'exit', (code) -> - exitCode = code - processExited = true - triggerExitCallback() - - @process.on 'error', (error) => @handleError(error) - return - - handleError: (error) -> - handled = false - handle = -> handled = true - - @emitter.emit 'will-throw-error', {error, handle} - - if error.code is 'ENOENT' and error.syscall.indexOf('spawn') is 0 - error = new Error("Failed to spawn command `#{@command}`. Make sure `#{@command}` is installed and on your PATH", error.path) - error.name = 'BufferedProcessError' - - throw error unless handled diff --git a/src/buffered-process.js b/src/buffered-process.js new file mode 100644 index 00000000000..2acd8fda7df --- /dev/null +++ b/src/buffered-process.js @@ -0,0 +1,340 @@ +const _ = require('underscore-plus'); +const ChildProcess = require('child_process'); +const { Emitter } = require('event-kit'); +const path = require('path'); + +// Extended: A wrapper which provides standard error/output line buffering for +// Node's ChildProcess. +// +// ## Examples +// +// ```js +// {BufferedProcess} = require('atom') +// +// const command = 'ps' +// const args = ['-ef'] +// const stdout = (output) => console.log(output) +// const exit = (code) => console.log("ps -ef exited with #{code}") +// const process = new BufferedProcess({command, args, stdout, exit}) +// ``` +module.exports = class BufferedProcess { + /* + Section: Construction + */ + + // Public: Runs the given command by spawning a new child process. + // + // * `options` An {Object} with the following keys: + // * `command` The {String} command to execute. + // * `args` The {Array} of arguments to pass to the command (optional). + // * `options` {Object} (optional) The options {Object} to pass to Node's + // `ChildProcess.spawn` method. + // * `stdout` {Function} (optional) The callback that receives a single + // argument which contains the standard output from the command. The + // callback is called as data is received but it's buffered to ensure only + // complete lines are passed until the source stream closes. After the + // source stream has closed all remaining data is sent in a final call. + // * `data` {String} + // * `stderr` {Function} (optional) The callback that receives a single + // argument which contains the standard error output from the command. The + // callback is called as data is received but it's buffered to ensure only + // complete lines are passed until the source stream closes. After the + // source stream has closed all remaining data is sent in a final call. + // * `data` {String} + // * `exit` {Function} (optional) The callback which receives a single + // argument containing the exit status. + // * `code` {Number} + // * `autoStart` {Boolean} (optional) Whether the command will automatically start + // when this BufferedProcess is created. Defaults to true. When set to false you + // must call the `start` method to start the process. + constructor({ + command, + args, + options = {}, + stdout, + stderr, + exit, + autoStart = true + } = {}) { + this.emitter = new Emitter(); + this.command = command; + this.args = args; + this.options = options; + this.stdout = stdout; + this.stderr = stderr; + this.exit = exit; + if (autoStart === true) { + this.start(); + } + this.killed = false; + } + + start() { + if (this.started === true) return; + + this.started = true; + // Related to joyent/node#2318 + if (process.platform === 'win32' && this.options.shell === undefined) { + this.spawnWithEscapedWindowsArgs(this.command, this.args, this.options); + } else { + this.spawn(this.command, this.args, this.options); + } + this.handleEvents(this.stdout, this.stderr, this.exit); + } + + // Windows has a bunch of special rules that node still doesn't take care of for you + spawnWithEscapedWindowsArgs(command, args, options) { + let cmdArgs = []; + // Quote all arguments and escapes inner quotes + if (args) { + cmdArgs = args + .filter(arg => arg != null) + .map(arg => { + if (this.isExplorerCommand(command) && /^\/[a-zA-Z]+,.*$/.test(arg)) { + // Don't wrap /root,C:\folder style arguments to explorer calls in + // quotes since they will not be interpreted correctly if they are + return arg; + } else { + // Escape double quotes by putting a backslash in front of them + return `"${arg.toString().replace(/"/g, '\\"')}"`; + } + }); + } + + // The command itself is quoted if it contains spaces, &, ^, | or # chars + cmdArgs.unshift( + /\s|&|\^|\(|\)|\||#/.test(command) ? `"${command}"` : command + ); + + const cmdOptions = _.clone(options); + cmdOptions.windowsVerbatimArguments = true; + + this.spawn( + this.getCmdPath(), + ['/s', '/d', '/c', `"${cmdArgs.join(' ')}"`], + cmdOptions + ); + } + + /* + Section: Event Subscription + */ + + // Public: Will call your callback when an error will be raised by the process. + // Usually this is due to the command not being available or not on the PATH. + // You can call `handle()` on the object passed to your callback to indicate + // that you have handled this error. + // + // * `callback` {Function} callback + // * `errorObject` {Object} + // * `error` {Object} the error object + // * `handle` {Function} call this to indicate you have handled the error. + // The error will not be thrown if this function is called. + // + // Returns a {Disposable} + onWillThrowError(callback) { + return this.emitter.on('will-throw-error', callback); + } + + /* + Section: Helper Methods + */ + + // Helper method to pass data line by line. + // + // * `stream` The Stream to read from. + // * `onLines` The callback to call with each line of data. + // * `onDone` The callback to call when the stream has closed. + bufferStream(stream, onLines, onDone) { + stream.setEncoding('utf8'); + let buffered = ''; + + stream.on('data', data => { + if (this.killed) return; + + let bufferedLength = buffered.length; + buffered += data; + let lastNewlineIndex = data.lastIndexOf('\n'); + + if (lastNewlineIndex !== -1) { + let lineLength = lastNewlineIndex + bufferedLength + 1; + onLines(buffered.substring(0, lineLength)); + buffered = buffered.substring(lineLength); + } + }); + + stream.on('close', () => { + if (this.killed) return; + if (buffered.length > 0) onLines(buffered); + onDone(); + }); + } + + // Kill all child processes of the spawned cmd.exe process on Windows. + // + // This is required since killing the cmd.exe does not terminate child + // processes. + killOnWindows() { + if (!this.process) return; + + const parentPid = this.process.pid; + const cmd = 'wmic'; + const args = [ + 'process', + 'where', + `(ParentProcessId=${parentPid})`, + 'get', + 'processid' + ]; + + let wmicProcess; + + try { + wmicProcess = ChildProcess.spawn(cmd, args); + } catch (spawnError) { + this.killProcess(); + return; + } + + wmicProcess.on('error', () => {}); // ignore errors + + let output = ''; + wmicProcess.stdout.on('data', data => { + output += data; + }); + wmicProcess.stdout.on('close', () => { + for (let pid of output.split(/\s+/)) { + if (!/^\d{1,10}$/.test(pid)) continue; + pid = parseInt(pid, 10); + + if (!pid || pid === parentPid) continue; + + try { + process.kill(pid); + } catch (error) {} + } + + this.killProcess(); + }); + } + + killProcess() { + if (this.process) this.process.kill(); + this.process = null; + } + + isExplorerCommand(command) { + if (command === 'explorer.exe' || command === 'explorer') { + return true; + } else if (process.env.SystemRoot) { + return ( + command === path.join(process.env.SystemRoot, 'explorer.exe') || + command === path.join(process.env.SystemRoot, 'explorer') + ); + } else { + return false; + } + } + + getCmdPath() { + if (process.env.comspec) { + return process.env.comspec; + } else if (process.env.SystemRoot) { + return path.join(process.env.SystemRoot, 'System32', 'cmd.exe'); + } else { + return 'cmd.exe'; + } + } + + // Public: Terminate the process. + kill() { + if (this.killed) return; + + this.killed = true; + if (process.platform === 'win32') { + this.killOnWindows(); + } else { + this.killProcess(); + } + } + + spawn(command, args, options) { + try { + this.process = ChildProcess.spawn(command, args, options); + } catch (spawnError) { + process.nextTick(() => this.handleError(spawnError)); + } + } + + handleEvents(stdout, stderr, exit) { + if (!this.process) return; + + const triggerExitCallback = () => { + if (this.killed) return; + if ( + stdoutClosed && + stderrClosed && + processExited && + typeof exit === 'function' + ) { + exit(exitCode); + } + }; + + let stdoutClosed = true; + let stderrClosed = true; + let processExited = true; + let exitCode = 0; + + if (stdout) { + stdoutClosed = false; + this.bufferStream(this.process.stdout, stdout, () => { + stdoutClosed = true; + triggerExitCallback(); + }); + } + + if (stderr) { + stderrClosed = false; + this.bufferStream(this.process.stderr, stderr, () => { + stderrClosed = true; + triggerExitCallback(); + }); + } + + if (exit) { + processExited = false; + this.process.on('exit', code => { + exitCode = code; + processExited = true; + triggerExitCallback(); + }); + } + + this.process.on('error', error => { + this.handleError(error); + }); + } + + handleError(error) { + let handled = false; + + const handle = () => { + handled = true; + }; + + this.emitter.emit('will-throw-error', { error, handle }); + + if (error.code === 'ENOENT' && error.syscall.indexOf('spawn') === 0) { + error = new Error( + `Failed to spawn command \`${this.command}\`. Make sure \`${ + this.command + }\` is installed and on your PATH`, + error.path + ); + error.name = 'BufferedProcessError'; + } + + if (!handled) throw error; + } +}; diff --git a/src/clipboard.coffee b/src/clipboard.coffee deleted file mode 100644 index 2412394a689..00000000000 --- a/src/clipboard.coffee +++ /dev/null @@ -1,57 +0,0 @@ -crypto = require 'crypto' -clipboard = require './safe-clipboard' - -# Extended: Represents the clipboard used for copying and pasting in Atom. -# -# An instance of this class is always available as the `atom.clipboard` global. -# -# ## Examples -# -# ```coffee -# atom.clipboard.write('hello') -# -# console.log(atom.clipboard.read()) # 'hello' -# ``` -module.exports = -class Clipboard - metadata: null - signatureForMetadata: null - - # Creates an `md5` hash of some text. - # - # * `text` A {String} to hash. - # - # Returns a hashed {String}. - md5: (text) -> - crypto.createHash('md5').update(text, 'utf8').digest('hex') - - # Public: Write the given text to the clipboard. - # - # The metadata associated with the text is available by calling - # {::readWithMetadata}. - # - # * `text` The {String} to store. - # * `metadata` The additional info to associate with the text. - write: (text, metadata) -> - @signatureForMetadata = @md5(text) - @metadata = metadata - clipboard.writeText(text) - - # Public: Read the text from the clipboard. - # - # Returns a {String}. - read: -> - clipboard.readText() - - # Public: Read the text from the clipboard and return both the text and the - # associated metadata. - # - # Returns an {Object} with the following keys: - # * `text` The {String} clipboard text. - # * `metadata` The metadata stored by an earlier call to {::write}. - readWithMetadata: -> - text = @read() - if @signatureForMetadata is @md5(text) - {text, @metadata} - else - {text} diff --git a/src/clipboard.js b/src/clipboard.js new file mode 100644 index 00000000000..e4f40851b9b --- /dev/null +++ b/src/clipboard.js @@ -0,0 +1,85 @@ +const crypto = require('crypto'); +const { clipboard } = require('electron'); + +// Extended: Represents the clipboard used for copying and pasting in Atom. +// +// An instance of this class is always available as the `atom.clipboard` global. +// +// ## Examples +// +// ```js +// atom.clipboard.write('hello') +// +// console.log(atom.clipboard.read()) // 'hello' +// ``` +module.exports = class Clipboard { + constructor() { + this.reset(); + } + + reset() { + this.metadata = null; + this.signatureForMetadata = null; + } + + // Creates an `md5` hash of some text. + // + // * `text` A {String} to hash. + // + // Returns a hashed {String}. + md5(text) { + return crypto + .createHash('md5') + .update(text, 'utf8') + .digest('hex'); + } + + // Public: Write the given text to the clipboard. + // + // The metadata associated with the text is available by calling + // {::readWithMetadata}. + // + // * `text` The {String} to store. + // * `metadata` (optional) The additional info to associate with the text. + write(text, metadata) { + text = text.replace(/\r?\n/g, process.platform === 'win32' ? '\r\n' : '\n'); + + this.signatureForMetadata = this.md5(text); + this.metadata = metadata; + clipboard.writeText(text); + } + + // Public: Read the text from the clipboard. + // + // Returns a {String}. + read() { + return clipboard.readText(); + } + + // Public: Write the given text to the macOS find pasteboard + writeFindText(text) { + clipboard.writeFindText(text); + } + + // Public: Read the text from the macOS find pasteboard. + // + // Returns a {String}. + readFindText() { + return clipboard.readFindText(); + } + + // Public: Read the text from the clipboard and return both the text and the + // associated metadata. + // + // Returns an {Object} with the following keys: + // * `text` The {String} clipboard text. + // * `metadata` The metadata stored by an earlier call to {::write}. + readWithMetadata() { + const text = this.read(); + if (this.signatureForMetadata === this.md5(text)) { + return { text, metadata: this.metadata }; + } else { + return { text }; + } + } +}; diff --git a/src/coffee-script.js b/src/coffee-script.js new file mode 100644 index 00000000000..dcd93ca2ef9 --- /dev/null +++ b/src/coffee-script.js @@ -0,0 +1,45 @@ +'use strict'; + +const crypto = require('crypto'); +const path = require('path'); +let CoffeeScript = null; + +exports.shouldCompile = function() { + return true; +}; + +exports.getCachePath = function(sourceCode) { + return path.join( + 'coffee', + crypto + .createHash('sha1') + .update(sourceCode, 'utf8') + .digest('hex') + '.js' + ); +}; + +exports.compile = function(sourceCode, filePath) { + if (!CoffeeScript) { + const previousPrepareStackTrace = Error.prepareStackTrace; + CoffeeScript = require('coffee-script'); + + // When it loads, coffee-script reassigns Error.prepareStackTrace. We have + // already reassigned it via the 'source-map-support' module, so we need + // to set it back. + Error.prepareStackTrace = previousPrepareStackTrace; + } + + if (process.platform === 'win32') { + filePath = 'file:///' + path.resolve(filePath).replace(/\\/g, '/'); + } + + const output = CoffeeScript.compile(sourceCode, { + filename: filePath, + sourceFiles: [filePath], + inlineMap: true + }); + + // Strip sourceURL from output so there wouldn't be duplicate entries + // in devtools. + return output.replace(/\/\/# sourceURL=[^'"\n]+\s*$/, ''); +}; diff --git a/src/color.coffee b/src/color.coffee deleted file mode 100644 index fc751ce4254..00000000000 --- a/src/color.coffee +++ /dev/null @@ -1,89 +0,0 @@ -_ = require 'underscore-plus' -ParsedColor = null - -# Essential: A simple color class returned from {Config::get} when the value -# at the key path is of type 'color'. -module.exports = -class Color - # Essential: Parse a {String} or {Object} into a {Color}. - # - # * `value` A {String} such as `'white'`, `#ff00ff`, or - # `'rgba(255, 15, 60, .75)'` or an {Object} with `red`, `green`, `blue`, - # and `alpha` properties. - # - # Returns a {Color} or `null` if it cannot be parsed. - @parse: (value) -> - return null if _.isArray(value) or _.isFunction(value) - return null unless _.isObject(value) or _.isString(value) - - ParsedColor ?= require 'color' - - try - parsedColor = new ParsedColor(value) - catch error - return null - - new Color(parsedColor.red(), parsedColor.green(), parsedColor.blue(), parsedColor.alpha()) - - constructor: (red, green, blue, alpha) -> - Object.defineProperties this, - red: - set: (newRed) -> red = parseColor(newRed) - get: -> red - enumerable: true - configurable: false - green: - set: (newGreen) -> green = parseColor(newGreen) - get: -> green - enumerable: true - configurable: false - blue: - set: (newBlue) -> blue = parseColor(newBlue) - get: -> blue - enumerable: true - configurable: false - alpha: - set: (newAlpha) -> alpha = parseAlpha(newAlpha) - get: -> alpha - enumerable: true - configurable: false - - @red = red - @green = green - @blue = blue - @alpha = alpha - - # Essential: Returns a {String} in the form `'#abcdef'`. - toHexString: -> - "##{numberToHexString(@red)}#{numberToHexString(@green)}#{numberToHexString(@blue)}" - - # Essential: Returns a {String} in the form `'rgba(25, 50, 75, .9)'`. - toRGBAString: -> - "rgba(#{@red}, #{@green}, #{@blue}, #{@alpha})" - - isEqual: (color) -> - return true if this is color - color = Color.parse(color) unless color instanceof Color - return false unless color? - color.red is @red and color.blue is @blue and color.green is @green and color.alpha is @alpha - - clone: -> new Color(@red, @green, @blue, @alpha) - -parseColor = (color) -> - color = parseInt(color) - color = 0 if isNaN(color) - color = Math.max(color, 0) - color = Math.min(color, 255) - color - -parseAlpha = (alpha) -> - alpha = parseFloat(alpha) - alpha = 1 if isNaN(alpha) - alpha = Math.max(alpha, 0) - alpha = Math.min(alpha, 1) - alpha - -numberToHexString = (number) -> - hex = number.toString(16) - hex = "0#{hex}" if number < 10 - hex diff --git a/src/color.js b/src/color.js new file mode 100644 index 00000000000..71d37a32897 --- /dev/null +++ b/src/color.js @@ -0,0 +1,143 @@ +let ParsedColor = null; + +// Essential: A simple color class returned from {Config::get} when the value +// at the key path is of type 'color'. +module.exports = class Color { + // Essential: Parse a {String} or {Object} into a {Color}. + // + // * `value` A {String} such as `'white'`, `#ff00ff`, or + // `'rgba(255, 15, 60, .75)'` or an {Object} with `red`, `green`, `blue`, + // and `alpha` properties. + // + // Returns a {Color} or `null` if it cannot be parsed. + static parse(value) { + switch (typeof value) { + case 'string': + break; + case 'object': + if (Array.isArray(value)) { + return null; + } + value = Object.values(value); + break; + default: + return null; + } + + if (!ParsedColor) { + ParsedColor = require('color'); + } + + try { + var parsedColor = ParsedColor(value); + } catch (error) { + return null; + } + + return new Color( + parsedColor.red(), + parsedColor.green(), + parsedColor.blue(), + parsedColor.alpha() + ); + } + + constructor(red, green, blue, alpha) { + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; + } + + set red(red) { + this._red = parseColor(red); + } + + set green(green) { + this._green = parseColor(green); + } + + set blue(blue) { + this._blue = parseColor(blue); + } + + set alpha(alpha) { + this._alpha = parseAlpha(alpha); + } + + get red() { + return this._red; + } + + get green() { + return this._green; + } + + get blue() { + return this._blue; + } + + get alpha() { + return this._alpha; + } + + // Essential: Returns a {String} in the form `'#abcdef'`. + toHexString() { + return `#${numberToHexString(this.red)}${numberToHexString( + this.green + )}${numberToHexString(this.blue)}`; + } + + // Essential: Returns a {String} in the form `'rgba(25, 50, 75, .9)'`. + toRGBAString() { + return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`; + } + + toJSON() { + return this.alpha === 1 ? this.toHexString() : this.toRGBAString(); + } + + toString() { + return this.toRGBAString(); + } + + isEqual(color) { + if (this === color) { + return true; + } + + if (!(color instanceof Color)) { + color = Color.parse(color); + } + + if (color == null) { + return false; + } + + return ( + color.red === this.red && + color.blue === this.blue && + color.green === this.green && + color.alpha === this.alpha + ); + } + + clone() { + return new Color(this.red, this.green, this.blue, this.alpha); + } +}; + +function parseColor(colorString) { + const color = parseInt(colorString, 10); + return isNaN(color) ? 0 : Math.min(Math.max(color, 0), 255); +} + +function parseAlpha(alphaString) { + const alpha = parseFloat(alphaString); + return isNaN(alpha) ? 1 : Math.min(Math.max(alpha, 0), 1); +} + +function numberToHexString(number) { + const hex = number.toString(16); + return number < 16 ? `0${hex}` : hex; +} diff --git a/src/command-installer.coffee b/src/command-installer.coffee deleted file mode 100644 index afd5000c10a..00000000000 --- a/src/command-installer.coffee +++ /dev/null @@ -1,75 +0,0 @@ -path = require 'path' -fs = require 'fs-plus' -runas = null # defer until used - -symlinkCommand = (sourcePath, destinationPath, callback) -> - fs.unlink destinationPath, (error) -> - if error? and error?.code isnt 'ENOENT' - callback(error) - else - fs.makeTree path.dirname(destinationPath), (error) -> - if error? - callback(error) - else - fs.symlink sourcePath, destinationPath, callback - -symlinkCommandWithPrivilegeSync = (sourcePath, destinationPath) -> - runas ?= require 'runas' - if runas('/bin/rm', ['-f', destinationPath], admin: true) isnt 0 - throw new Error("Failed to remove '#{destinationPath}'") - - if runas('/bin/mkdir', ['-p', path.dirname(destinationPath)], admin: true) isnt 0 - throw new Error("Failed to create directory '#{destinationPath}'") - - if runas('/bin/ln', ['-s', sourcePath, destinationPath], admin: true) isnt 0 - throw new Error("Failed to symlink '#{sourcePath}' to '#{destinationPath}'") - -module.exports = - getInstallDirectory: -> - "/usr/local/bin" - - installShellCommandsInteractively: -> - showErrorDialog = (error) -> - atom.confirm - message: "Failed to install shell commands" - detailedMessage: error.message - - @installAtomCommand true, (error) => - if error? - showErrorDialog(error) - else - @installApmCommand true, (error) -> - if error? - showErrorDialog(error) - else - atom.confirm - message: "Commands installed." - detailedMessage: "The shell commands `atom` and `apm` are installed." - - installAtomCommand: (askForPrivilege, callback) -> - commandPath = path.join(process.resourcesPath, 'app', 'atom.sh') - @createSymlink commandPath, askForPrivilege, callback - - installApmCommand: (askForPrivilege, callback) -> - commandPath = path.join(process.resourcesPath, 'app', 'apm', 'node_modules', '.bin', 'apm') - @createSymlink commandPath, askForPrivilege, callback - - createSymlink: (commandPath, askForPrivilege, callback) -> - return unless process.platform is 'darwin' - - commandName = path.basename(commandPath, path.extname(commandPath)) - destinationPath = path.join(@getInstallDirectory(), commandName) - - fs.readlink destinationPath, (error, realpath) -> - if realpath is commandPath - callback() - return - - symlinkCommand commandPath, destinationPath, (error) -> - if askForPrivilege and error?.code is 'EACCES' - try - error = null - symlinkCommandWithPrivilegeSync(commandPath, destinationPath) - catch error - - callback?(error) diff --git a/src/command-installer.js b/src/command-installer.js new file mode 100644 index 00000000000..2bbc707968f --- /dev/null +++ b/src/command-installer.js @@ -0,0 +1,116 @@ +const path = require('path'); +const fs = require('fs-plus'); + +module.exports = class CommandInstaller { + constructor(applicationDelegate) { + this.applicationDelegate = applicationDelegate; + } + + initialize(appVersion) { + this.appVersion = appVersion; + } + + getInstallDirectory() { + return '/usr/local/bin'; + } + + getResourcesDirectory() { + return process.resourcesPath; + } + + installShellCommandsInteractively() { + const showErrorDialog = error => { + this.applicationDelegate.confirm( + { + message: 'Failed to install shell commands', + detail: error.message + }, + () => {} + ); + }; + + this.installAtomCommand(true, (error, atomCommandName) => { + if (error) return showErrorDialog(error); + this.installApmCommand(true, (error, apmCommandName) => { + if (error) return showErrorDialog(error); + this.applicationDelegate.confirm( + { + message: 'Commands installed.', + detail: `The shell commands \`${atomCommandName}\` and \`${apmCommandName}\` are installed.` + }, + () => {} + ); + }); + }); + } + + getCommandNameForChannel(commandName) { + let channelMatch = this.appVersion.match(/beta|nightly/); + let channel = channelMatch ? channelMatch[0] : ''; + + switch (channel) { + case 'beta': + return `${commandName}-beta`; + case 'nightly': + return `${commandName}-nightly`; + default: + return commandName; + } + } + + installAtomCommand(askForPrivilege, callback) { + this.installCommand( + path.join(this.getResourcesDirectory(), 'app', 'atom.sh'), + this.getCommandNameForChannel('atom'), + askForPrivilege, + callback + ); + } + + installApmCommand(askForPrivilege, callback) { + this.installCommand( + path.join( + this.getResourcesDirectory(), + 'app', + 'apm', + 'node_modules', + '.bin', + 'apm' + ), + this.getCommandNameForChannel('apm'), + askForPrivilege, + callback + ); + } + + installCommand(commandPath, commandName, askForPrivilege, callback) { + if (process.platform !== 'darwin') return callback(); + + const destinationPath = path.join(this.getInstallDirectory(), commandName); + + fs.readlink(destinationPath, (error, realpath) => { + if (error && error.code !== 'ENOENT') return callback(error); + if (realpath === commandPath) return callback(null, commandName); + this.createSymlink(fs, commandPath, destinationPath, error => { + if (error && error.code === 'EACCES' && askForPrivilege) { + const fsAdmin = require('fs-admin'); + this.createSymlink(fsAdmin, commandPath, destinationPath, error => { + callback(error, commandName); + }); + } else { + callback(error); + } + }); + }); + } + + createSymlink(fs, sourcePath, destinationPath, callback) { + fs.unlink(destinationPath, error => { + if (error && error.code !== 'ENOENT') return callback(error); + fs.makeTree(path.dirname(destinationPath), error => { + if (error) return callback(error); + fs.symlink(sourcePath, destinationPath, callback); + }); + }); + } +}; diff --git a/src/command-registry.coffee b/src/command-registry.coffee deleted file mode 100644 index 3969dc28391..00000000000 --- a/src/command-registry.coffee +++ /dev/null @@ -1,250 +0,0 @@ -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -{calculateSpecificity, validateSelector} = require 'clear-cut' -_ = require 'underscore-plus' -{$} = require './space-pen-extensions' - -SequenceCount = 0 - -# Public: Associates listener functions with commands in a -# context-sensitive way using CSS selectors. You can access a global instance of -# this class via `atom.commands`, and commands registered there will be -# presented in the command palette. -# -# The global command registry facilitates a style of event handling known as -# *event delegation* that was popularized by jQuery. Atom commands are expressed -# as custom DOM events that can be invoked on the currently focused element via -# a key binding or manually via the command palette. Rather than binding -# listeners for command events directly to DOM nodes, you instead register -# command event listeners globally on `atom.commands` and constrain them to -# specific kinds of elements with CSS selectors. -# -# As the event bubbles upward through the DOM, all registered event listeners -# with matching selectors are invoked in order of specificity. In the event of a -# specificity tie, the most recently registered listener is invoked first. This -# mirrors the "cascade" semantics of CSS. Event listeners are invoked in the -# context of the current DOM node, meaning `this` always points at -# `event.currentTarget`. As is normally the case with DOM events, -# `stopPropagation` and `stopImmediatePropagation` can be used to terminate the -# bubbling process and prevent invocation of additional listeners. -# -# ## Example -# -# Here is a command that inserts the current date in an editor: -# -# ```coffee -# atom.commands.add 'atom-text-editor', -# 'user:insert-date': (event) -> -# editor = @getModel() -# editor.insertText(new Date().toLocaleString()) -# ``` -module.exports = -class CommandRegistry - constructor: (@rootNode) -> - @registeredCommands = {} - @selectorBasedListenersByCommandName = {} - @inlineListenersByCommandName = {} - @emitter = new Emitter - - destroy: -> - for commandName of @registeredCommands - window.removeEventListener(commandName, @handleCommandEvent, true) - return - - # Public: Add one or more command listeners associated with a selector. - # - # ## Arguments: Registering One Command - # - # * `target` A {String} containing a CSS selector or a DOM element. If you - # pass a selector, the command will be globally associated with all matching - # elements. The `,` combinator is not currently supported. If you pass a - # DOM element, the command will be associated with just that element. - # * `commandName` A {String} containing the name of a command you want to - # handle such as `user:insert-date`. - # * `callback` A {Function} to call when the given command is invoked on an - # element matching the selector. It will be called with `this` referencing - # the matching DOM node. - # * `event` A standard DOM event instance. Call `stopPropagation` or - # `stopImmediatePropagation` to terminate bubbling early. - # - # ## Arguments: Registering Multiple Commands - # - # * `target` A {String} containing a CSS selector or a DOM element. If you - # pass a selector, the commands will be globally associated with all - # matching elements. The `,` combinator is not currently supported. - # If you pass a DOM element, the command will be associated with just that - # element. - # * `commands` An {Object} mapping command names like `user:insert-date` to - # listener {Function}s. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # added command handler(s). - add: (target, commandName, callback) -> - if typeof commandName is 'object' - commands = commandName - disposable = new CompositeDisposable - for commandName, callback of commands - disposable.add @add(target, commandName, callback) - return disposable - - if typeof target is 'string' - validateSelector(target) - @addSelectorBasedListener(target, commandName, callback) - else - @addInlineListener(target, commandName, callback) - - addSelectorBasedListener: (selector, commandName, callback) -> - @selectorBasedListenersByCommandName[commandName] ?= [] - listenersForCommand = @selectorBasedListenersByCommandName[commandName] - listener = new SelectorBasedListener(selector, callback) - listenersForCommand.push(listener) - - @commandRegistered(commandName) - - new Disposable => - listenersForCommand.splice(listenersForCommand.indexOf(listener), 1) - delete @selectorBasedListenersByCommandName[commandName] if listenersForCommand.length is 0 - - addInlineListener: (element, commandName, callback) -> - @inlineListenersByCommandName[commandName] ?= new WeakMap - - listenersForCommand = @inlineListenersByCommandName[commandName] - unless listenersForElement = listenersForCommand.get(element) - listenersForElement = [] - listenersForCommand.set(element, listenersForElement) - listener = new InlineListener(callback) - listenersForElement.push(listener) - - @commandRegistered(commandName) - - new Disposable -> - listenersForElement.splice(listenersForElement.indexOf(listener), 1) - listenersForCommand.delete(element) if listenersForElement.length is 0 - - # Public: Find all registered commands matching a query. - # - # * `params` An {Object} containing one or more of the following keys: - # * `target` A DOM node that is the hypothetical target of a given command. - # - # Returns an {Array} of {Object}s containing the following keys: - # * `name` The name of the command. For example, `user:insert-date`. - # * `displayName` The display name of the command. For example, - # `User: Insert Date`. - # * `jQuery` Present if the command was registered with the legacy - # `$::command` method. - findCommands: ({target}) -> - commandNames = new Set - commands = [] - currentTarget = target - loop - for name, listeners of @inlineListenersByCommandName - if listeners.has(currentTarget) and not commandNames.has(name) - commandNames.add(name) - commands.push({name, displayName: _.humanizeEventName(name)}) - - for commandName, listeners of @selectorBasedListenersByCommandName - for listener in listeners - if currentTarget.webkitMatchesSelector?(listener.selector) - unless commandNames.has(commandName) - commandNames.add(commandName) - commands.push - name: commandName - displayName: _.humanizeEventName(commandName) - - break if currentTarget is window - currentTarget = currentTarget.parentNode ? window - - commands - - # Public: Simulate the dispatch of a command on a DOM node. - # - # This can be useful for testing when you want to simulate the invocation of a - # command on a detached DOM node. Otherwise, the DOM node in question needs to - # be attached to the document so the event bubbles up to the root node to be - # processed. - # - # * `target` The DOM node at which to start bubbling the command event. - # * `commandName` {String} indicating the name of the command to dispatch. - dispatch: (target, commandName, detail) -> - event = new CustomEvent(commandName, {bubbles: true, detail}) - eventWithTarget = Object.create event, - target: value: target - preventDefault: value: -> - stopPropagation: value: -> - stopImmediatePropagation: value: -> - @handleCommandEvent(eventWithTarget) - - onWillDispatch: (callback) -> - @emitter.on 'will-dispatch', callback - - getSnapshot: -> - snapshot = {} - for commandName, listeners of @selectorBasedListenersByCommandName - snapshot[commandName] = listeners.slice() - snapshot - - restoreSnapshot: (snapshot) -> - @selectorBasedListenersByCommandName = {} - for commandName, listeners of snapshot - @selectorBasedListenersByCommandName[commandName] = listeners.slice() - return - - handleCommandEvent: (originalEvent) => - propagationStopped = false - immediatePropagationStopped = false - matched = false - currentTarget = originalEvent.target - - syntheticEvent = Object.create originalEvent, - eventPhase: value: Event.BUBBLING_PHASE - currentTarget: get: -> currentTarget - preventDefault: value: -> - originalEvent.preventDefault() - stopPropagation: value: -> - originalEvent.stopPropagation() - propagationStopped = true - stopImmediatePropagation: value: -> - originalEvent.stopImmediatePropagation() - propagationStopped = true - immediatePropagationStopped = true - abortKeyBinding: value: -> - originalEvent.abortKeyBinding?() - - @emitter.emit 'will-dispatch', syntheticEvent - - loop - listeners = @inlineListenersByCommandName[originalEvent.type]?.get(currentTarget) ? [] - if currentTarget.webkitMatchesSelector? - selectorBasedListeners = - (@selectorBasedListenersByCommandName[originalEvent.type] ? []) - .filter (listener) -> currentTarget.webkitMatchesSelector(listener.selector) - .sort (a, b) -> a.compare(b) - listeners = listeners.concat(selectorBasedListeners) - - matched = true if listeners.length > 0 - - for listener in listeners - break if immediatePropagationStopped - listener.callback.call(currentTarget, syntheticEvent) - - break if currentTarget is window - break if propagationStopped - currentTarget = currentTarget.parentNode ? window - - matched - - commandRegistered: (commandName) -> - unless @registeredCommands[commandName] - window.addEventListener(commandName, @handleCommandEvent, true) - @registeredCommands[commandName] = true - -class SelectorBasedListener - constructor: (@selector, @callback) -> - @specificity = calculateSpecificity(@selector) - @sequenceNumber = SequenceCount++ - - compare: (other) -> - other.specificity - @specificity or - other.sequenceNumber - @sequenceNumber - -class InlineListener - constructor: (@callback) -> diff --git a/src/command-registry.js b/src/command-registry.js new file mode 100644 index 00000000000..2cd3f91c137 --- /dev/null +++ b/src/command-registry.js @@ -0,0 +1,482 @@ +'use strict'; + +const { Emitter, Disposable, CompositeDisposable } = require('event-kit'); +const { calculateSpecificity, validateSelector } = require('clear-cut'); +const _ = require('underscore-plus'); + +let SequenceCount = 0; + +// Public: Associates listener functions with commands in a +// context-sensitive way using CSS selectors. You can access a global instance of +// this class via `atom.commands`, and commands registered there will be +// presented in the command palette. +// +// The global command registry facilitates a style of event handling known as +// *event delegation* that was popularized by jQuery. Atom commands are expressed +// as custom DOM events that can be invoked on the currently focused element via +// a key binding or manually via the command palette. Rather than binding +// listeners for command events directly to DOM nodes, you instead register +// command event listeners globally on `atom.commands` and constrain them to +// specific kinds of elements with CSS selectors. +// +// Command names must follow the `namespace:action` pattern, where `namespace` +// will typically be the name of your package, and `action` describes the +// behavior of your command. If either part consists of multiple words, these +// must be separated by hyphens. E.g. `awesome-package:turn-it-up-to-eleven`. +// All words should be lowercased. +// +// As the event bubbles upward through the DOM, all registered event listeners +// with matching selectors are invoked in order of specificity. In the event of a +// specificity tie, the most recently registered listener is invoked first. This +// mirrors the "cascade" semantics of CSS. Event listeners are invoked in the +// context of the current DOM node, meaning `this` always points at +// `event.currentTarget`. As is normally the case with DOM events, +// `stopPropagation` and `stopImmediatePropagation` can be used to terminate the +// bubbling process and prevent invocation of additional listeners. +// +// ## Example +// +// Here is a command that inserts the current date in an editor: +// +// ```coffee +// atom.commands.add 'atom-text-editor', +// 'user:insert-date': (event) -> +// editor = @getModel() +// editor.insertText(new Date().toLocaleString()) +// ``` +module.exports = class CommandRegistry { + constructor() { + this.handleCommandEvent = this.handleCommandEvent.bind(this); + this.rootNode = null; + this.clear(); + } + + clear() { + this.registeredCommands = {}; + this.selectorBasedListenersByCommandName = {}; + this.inlineListenersByCommandName = {}; + this.emitter = new Emitter(); + } + + attach(rootNode) { + this.rootNode = rootNode; + for (const command in this.selectorBasedListenersByCommandName) { + this.commandRegistered(command); + } + + for (const command in this.inlineListenersByCommandName) { + this.commandRegistered(command); + } + } + + destroy() { + for (const commandName in this.registeredCommands) { + this.rootNode.removeEventListener( + commandName, + this.handleCommandEvent, + true + ); + } + } + + // Public: Add one or more command listeners associated with a selector. + // + // ## Arguments: Registering One Command + // + // * `target` A {String} containing a CSS selector or a DOM element. If you + // pass a selector, the command will be globally associated with all matching + // elements. The `,` combinator is not currently supported. If you pass a + // DOM element, the command will be associated with just that element. + // * `commandName` A {String} containing the name of a command you want to + // handle such as `user:insert-date`. + // * `listener` A listener which handles the event. Either a {Function} to + // call when the given command is invoked on an element matching the + // selector, or an {Object} with a `didDispatch` property which is such a + // function. + // + // The function (`listener` itself if it is a function, or the `didDispatch` + // method if `listener` is an object) will be called with `this` referencing + // the matching DOM node and the following argument: + // * `event`: A standard DOM event instance. Call `stopPropagation` or + // `stopImmediatePropagation` to terminate bubbling early. + // + // Additionally, `listener` may have additional properties which are returned + // to those who query using `atom.commands.findCommands`, as well as several + // meaningful metadata properties: + // * `displayName`: Overrides any generated `displayName` that would + // otherwise be generated from the event name. + // * `description`: Used by consumers to display detailed information about + // the command. + // * `hiddenInCommandPalette`: If `true`, this command will not appear in + // the bundled command palette by default, but can still be shown with. + // the `Command Palette: Show Hidden Commands` command. This is a good + // option when you need to register large numbers of commands that don't + // make sense to be executed from the command palette. Please use this + // option conservatively, as it could reduce the discoverability of your + // package's commands. + // + // ## Arguments: Registering Multiple Commands + // + // * `target` A {String} containing a CSS selector or a DOM element. If you + // pass a selector, the commands will be globally associated with all + // matching elements. The `,` combinator is not currently supported. + // If you pass a DOM element, the command will be associated with just that + // element. + // * `commands` An {Object} mapping command names like `user:insert-date` to + // listener {Function}s. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // added command handler(s). + add(target, commandName, listener, throwOnInvalidSelector = true) { + if (typeof commandName === 'object') { + const commands = commandName; + throwOnInvalidSelector = listener; + const disposable = new CompositeDisposable(); + for (commandName in commands) { + listener = commands[commandName]; + disposable.add( + this.add(target, commandName, listener, throwOnInvalidSelector) + ); + } + return disposable; + } + + if (listener == null) { + throw new Error('Cannot register a command with a null listener.'); + } + + // type Listener = ((e: CustomEvent) => void) | { + // displayName?: string, + // description?: string, + // didDispatch(e: CustomEvent): void, + // } + if ( + typeof listener !== 'function' && + typeof listener.didDispatch !== 'function' + ) { + throw new Error( + 'Listener must be a callback function or an object with a didDispatch method.' + ); + } + + if (typeof target === 'string') { + if (throwOnInvalidSelector) { + validateSelector(target); + } + return this.addSelectorBasedListener(target, commandName, listener); + } else { + return this.addInlineListener(target, commandName, listener); + } + } + + addSelectorBasedListener(selector, commandName, listener) { + if (this.selectorBasedListenersByCommandName[commandName] == null) { + this.selectorBasedListenersByCommandName[commandName] = []; + } + const listenersForCommand = this.selectorBasedListenersByCommandName[ + commandName + ]; + const selectorListener = new SelectorBasedListener( + selector, + commandName, + listener + ); + listenersForCommand.push(selectorListener); + + this.commandRegistered(commandName); + + return new Disposable(() => { + listenersForCommand.splice( + listenersForCommand.indexOf(selectorListener), + 1 + ); + if (listenersForCommand.length === 0) { + delete this.selectorBasedListenersByCommandName[commandName]; + } + }); + } + + addInlineListener(element, commandName, listener) { + if (this.inlineListenersByCommandName[commandName] == null) { + this.inlineListenersByCommandName[commandName] = new WeakMap(); + } + + const listenersForCommand = this.inlineListenersByCommandName[commandName]; + let listenersForElement = listenersForCommand.get(element); + if (!listenersForElement) { + listenersForElement = []; + listenersForCommand.set(element, listenersForElement); + } + const inlineListener = new InlineListener(commandName, listener); + listenersForElement.push(inlineListener); + + this.commandRegistered(commandName); + + return new Disposable(() => { + listenersForElement.splice( + listenersForElement.indexOf(inlineListener), + 1 + ); + if (listenersForElement.length === 0) { + listenersForCommand.delete(element); + } + }); + } + + // Public: Find all registered commands matching a query. + // + // * `params` An {Object} containing one or more of the following keys: + // * `target` A DOM node that is the hypothetical target of a given command. + // + // Returns an {Array} of `CommandDescriptor` {Object}s containing the following keys: + // * `name` The name of the command. For example, `user:insert-date`. + // * `displayName` The display name of the command. For example, + // `User: Insert Date`. + // Additional metadata may also be present in the returned descriptor: + // * `description` a {String} describing the function of the command in more + // detail than the title + // * `tags` an {Array} of {String}s that describe keywords related to the + // command + // Any additional nonstandard metadata provided when the command was `add`ed + // may also be present in the returned descriptor. + findCommands({ target }) { + const commandNames = new Set(); + const commands = []; + let currentTarget = target; + while (true) { + let listeners; + for (const name in this.inlineListenersByCommandName) { + listeners = this.inlineListenersByCommandName[name]; + if (listeners.has(currentTarget) && !commandNames.has(name)) { + commandNames.add(name); + const targetListeners = listeners.get(currentTarget); + commands.push( + ...targetListeners.map(listener => listener.descriptor) + ); + } + } + + for (const commandName in this.selectorBasedListenersByCommandName) { + listeners = this.selectorBasedListenersByCommandName[commandName]; + for (const listener of listeners) { + if (listener.matchesTarget(currentTarget)) { + if (!commandNames.has(commandName)) { + commandNames.add(commandName); + commands.push(listener.descriptor); + } + } + } + } + + if (currentTarget === window) { + break; + } + currentTarget = currentTarget.parentNode || window; + } + + return commands; + } + + // Public: Simulate the dispatch of a command on a DOM node. + // + // This can be useful for testing when you want to simulate the invocation of a + // command on a detached DOM node. Otherwise, the DOM node in question needs to + // be attached to the document so the event bubbles up to the root node to be + // processed. + // + // * `target` The DOM node at which to start bubbling the command event. + // * `commandName` {String} indicating the name of the command to dispatch. + dispatch(target, commandName, detail) { + const event = new CustomEvent(commandName, { bubbles: true, detail }); + Object.defineProperty(event, 'target', { value: target }); + return this.handleCommandEvent(event); + } + + // Public: Invoke the given callback before dispatching a command event. + // + // * `callback` {Function} to be called before dispatching each command + // * `event` The Event that will be dispatched + onWillDispatch(callback) { + return this.emitter.on('will-dispatch', callback); + } + + // Public: Invoke the given callback after dispatching a command event. + // + // * `callback` {Function} to be called after dispatching each command + // * `event` The Event that was dispatched + onDidDispatch(callback) { + return this.emitter.on('did-dispatch', callback); + } + + getSnapshot() { + const snapshot = {}; + for (const commandName in this.selectorBasedListenersByCommandName) { + const listeners = this.selectorBasedListenersByCommandName[commandName]; + snapshot[commandName] = listeners.slice(); + } + return snapshot; + } + + restoreSnapshot(snapshot) { + this.selectorBasedListenersByCommandName = {}; + for (const commandName in snapshot) { + const listeners = snapshot[commandName]; + this.selectorBasedListenersByCommandName[commandName] = listeners.slice(); + } + } + + handleCommandEvent(event) { + let propagationStopped = false; + let immediatePropagationStopped = false; + let matched = []; + let currentTarget = event.target; + + const dispatchedEvent = new CustomEvent(event.type, { + bubbles: true, + detail: event.detail + }); + Object.defineProperty(dispatchedEvent, 'eventPhase', { + value: Event.BUBBLING_PHASE + }); + Object.defineProperty(dispatchedEvent, 'currentTarget', { + get() { + return currentTarget; + } + }); + Object.defineProperty(dispatchedEvent, 'target', { value: currentTarget }); + Object.defineProperty(dispatchedEvent, 'preventDefault', { + value() { + return event.preventDefault(); + } + }); + Object.defineProperty(dispatchedEvent, 'stopPropagation', { + value() { + event.stopPropagation(); + propagationStopped = true; + } + }); + Object.defineProperty(dispatchedEvent, 'stopImmediatePropagation', { + value() { + event.stopImmediatePropagation(); + propagationStopped = true; + immediatePropagationStopped = true; + } + }); + Object.defineProperty(dispatchedEvent, 'abortKeyBinding', { + value() { + if (typeof event.abortKeyBinding === 'function') { + event.abortKeyBinding(); + } + } + }); + + for (const key of Object.keys(event)) { + if (!(key in dispatchedEvent)) { + dispatchedEvent[key] = event[key]; + } + } + + this.emitter.emit('will-dispatch', dispatchedEvent); + + while (true) { + const commandInlineListeners = this.inlineListenersByCommandName[ + event.type + ] + ? this.inlineListenersByCommandName[event.type].get(currentTarget) + : null; + let listeners = commandInlineListeners || []; + if (currentTarget.webkitMatchesSelector != null) { + const selectorBasedListeners = ( + this.selectorBasedListenersByCommandName[event.type] || [] + ) + .filter(listener => listener.matchesTarget(currentTarget)) + .sort((a, b) => a.compare(b)); + listeners = selectorBasedListeners.concat(listeners); + } + + // Call inline listeners first in reverse registration order, + // and selector-based listeners by specificity and reverse + // registration order. + for (let i = listeners.length - 1; i >= 0; i--) { + const listener = listeners[i]; + if (immediatePropagationStopped) { + break; + } + matched.push(listener.didDispatch.call(currentTarget, dispatchedEvent)); + } + + if (currentTarget === window) { + break; + } + if (propagationStopped) { + break; + } + currentTarget = currentTarget.parentNode || window; + } + + this.emitter.emit('did-dispatch', dispatchedEvent); + + return matched.length > 0 ? Promise.all(matched) : null; + } + + commandRegistered(commandName) { + if (this.rootNode != null && !this.registeredCommands[commandName]) { + this.rootNode.addEventListener(commandName, this.handleCommandEvent, { + capture: true + }); + return (this.registeredCommands[commandName] = true); + } + } +}; + +// type Listener = { +// descriptor: CommandDescriptor, +// extractDidDispatch: (e: CustomEvent) => void, +// }; +class SelectorBasedListener { + constructor(selector, commandName, listener) { + this.selector = selector; + this.didDispatch = extractDidDispatch(listener); + this.descriptor = extractDescriptor(commandName, listener); + this.specificity = calculateSpecificity(this.selector); + this.sequenceNumber = SequenceCount++; + } + + compare(other) { + return ( + this.specificity - other.specificity || + this.sequenceNumber - other.sequenceNumber + ); + } + + matchesTarget(target) { + return ( + target.webkitMatchesSelector && + target.webkitMatchesSelector(this.selector) + ); + } +} + +class InlineListener { + constructor(commandName, listener) { + this.didDispatch = extractDidDispatch(listener); + this.descriptor = extractDescriptor(commandName, listener); + } +} + +// type CommandDescriptor = { +// name: string, +// displayName: string, +// }; +function extractDescriptor(name, listener) { + return Object.assign(_.omit(listener, 'didDispatch'), { + name, + displayName: listener.displayName + ? listener.displayName + : _.humanizeEventName(name) + }); +} + +function extractDidDispatch(listener) { + return typeof listener === 'function' ? listener : listener.didDispatch; +} diff --git a/src/compile-cache.coffee b/src/compile-cache.coffee deleted file mode 100644 index 8fe8d67110f..00000000000 --- a/src/compile-cache.coffee +++ /dev/null @@ -1,30 +0,0 @@ -path = require 'path' -CSON = require 'season' -CoffeeCache = require 'coffee-cash' -babel = require './babel' -typescript = require './typescript' - -# This file is required directly by apm so that files can be cached during -# package install so that the first package load in Atom doesn't have to -# compile anything. -exports.addPathToCache = (filePath, atomHome) -> - atomHome ?= process.env.ATOM_HOME - cacheDir = path.join(atomHome, 'compile-cache') - # Use separate compile cache when sudo'ing as root to avoid permission issues - if process.env.USER is 'root' and process.env.SUDO_USER and process.env.SUDO_USER isnt process.env.USER - cacheDir = path.join(cacheDir, 'root') - - CoffeeCache.setCacheDirectory(path.join(cacheDir, 'coffee')) - CSON.setCacheDir(path.join(cacheDir, 'cson')) - babel.setCacheDirectory(path.join(cacheDir, 'js', 'babel')) - typescript.setCacheDirectory(path.join(cacheDir, 'ts')) - - switch path.extname(filePath) - when '.coffee' - CoffeeCache.addPathToCache(filePath) - when '.cson' - CSON.readFileSync(filePath) - when '.js' - babel.addPathToCache(filePath) - when '.ts' - typescript.addPathToCache(filePath) diff --git a/src/compile-cache.js b/src/compile-cache.js new file mode 100644 index 00000000000..cf7e6711c63 --- /dev/null +++ b/src/compile-cache.js @@ -0,0 +1,259 @@ +'use strict'; + +// Atom's compile-cache when installing or updating packages, called by apm's Node-js + +const path = require('path'); +const fs = require('fs-plus'); +const sourceMapSupport = require('@atom/source-map-support'); + +const PackageTranspilationRegistry = require('./package-transpilation-registry'); +let CSON = null; + +const packageTranspilationRegistry = new PackageTranspilationRegistry(); + +const COMPILERS = { + '.js': packageTranspilationRegistry.wrapTranspiler(require('./babel')), + '.ts': packageTranspilationRegistry.wrapTranspiler(require('./typescript')), + '.tsx': packageTranspilationRegistry.wrapTranspiler(require('./typescript')), + '.coffee': packageTranspilationRegistry.wrapTranspiler( + require('./coffee-script') + ) +}; + +exports.addTranspilerConfigForPath = function( + packagePath, + packageName, + packageMeta, + config +) { + packagePath = fs.realpathSync(packagePath); + packageTranspilationRegistry.addTranspilerConfigForPath( + packagePath, + packageName, + packageMeta, + config + ); +}; + +exports.removeTranspilerConfigForPath = function(packagePath) { + packagePath = fs.realpathSync(packagePath); + packageTranspilationRegistry.removeTranspilerConfigForPath(packagePath); +}; + +const cacheStats = {}; +let cacheDirectory = null; + +exports.setAtomHomeDirectory = function(atomHome) { + let cacheDir = path.join(atomHome, 'compile-cache'); + if ( + process.env.USER === 'root' && + process.env.SUDO_USER && + process.env.SUDO_USER !== process.env.USER + ) { + cacheDir = path.join(cacheDir, 'root'); + } + this.setCacheDirectory(cacheDir); +}; + +exports.setCacheDirectory = function(directory) { + cacheDirectory = directory; +}; + +exports.getCacheDirectory = function() { + return cacheDirectory; +}; + +exports.addPathToCache = function(filePath, atomHome) { + this.setAtomHomeDirectory(atomHome); + const extension = path.extname(filePath); + + if (extension === '.cson') { + if (!CSON) { + CSON = require('season'); + CSON.setCacheDir(this.getCacheDirectory()); + } + return CSON.readFileSync(filePath); + } else { + const compiler = COMPILERS[extension]; + if (compiler) { + return compileFileAtPath(compiler, filePath, extension); + } + } +}; + +exports.getCacheStats = function() { + return cacheStats; +}; + +exports.resetCacheStats = function() { + Object.keys(COMPILERS).forEach(function(extension) { + cacheStats[extension] = { + hits: 0, + misses: 0 + }; + }); +}; + +function compileFileAtPath(compiler, filePath, extension) { + const sourceCode = fs.readFileSync(filePath, 'utf8'); + if (compiler.shouldCompile(sourceCode, filePath)) { + const cachePath = compiler.getCachePath(sourceCode, filePath); + let compiledCode = readCachedJavaScript(cachePath); + if (compiledCode != null) { + cacheStats[extension].hits++; + } else { + cacheStats[extension].misses++; + compiledCode = compiler.compile(sourceCode, filePath); + writeCachedJavaScript(cachePath, compiledCode); + } + return compiledCode; + } + return sourceCode; +} + +function readCachedJavaScript(relativeCachePath) { + const cachePath = path.join(cacheDirectory, relativeCachePath); + if (fs.isFileSync(cachePath)) { + try { + return fs.readFileSync(cachePath, 'utf8'); + } catch (error) {} + } + return null; +} + +function writeCachedJavaScript(relativeCachePath, code) { + const cachePath = path.join(cacheDirectory, relativeCachePath); + fs.writeFileSync(cachePath, code, 'utf8'); +} + +const INLINE_SOURCE_MAP_REGEXP = /\/\/[#@]\s*sourceMappingURL=([^'"\n]+)\s*$/gm; + +exports.install = function(resourcesPath, nodeRequire) { + const snapshotSourceMapConsumer = { + originalPositionFor({ line, column }) { + const { relativePath, row } = snapshotResult.translateSnapshotRow(line); + return { + column, + line: row, + source: path.join(resourcesPath, 'app', 'static', relativePath), + name: null + }; + } + }; + + sourceMapSupport.install({ + handleUncaughtExceptions: false, + + // Most of this logic is the same as the default implementation in the + // source-map-support module, but we've overridden it to read the javascript + // code from our cache directory. + retrieveSourceMap: function(filePath) { + if (filePath === '') { + return { map: snapshotSourceMapConsumer }; + } + + if (!cacheDirectory || !fs.isFileSync(filePath)) { + return null; + } + + try { + var sourceCode = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + console.warn('Error reading source file', error.stack); + return null; + } + + let compiler = COMPILERS[path.extname(filePath)]; + if (!compiler) compiler = COMPILERS['.js']; + + try { + var fileData = readCachedJavaScript( + compiler.getCachePath(sourceCode, filePath) + ); + } catch (error) { + console.warn('Error reading compiled file', error.stack); + return null; + } + + if (fileData == null) { + return null; + } + + let match, lastMatch; + INLINE_SOURCE_MAP_REGEXP.lastIndex = 0; + while ((match = INLINE_SOURCE_MAP_REGEXP.exec(fileData))) { + lastMatch = match; + } + if (lastMatch == null) { + return null; + } + + const sourceMappingURL = lastMatch[1]; + const rawData = sourceMappingURL.slice(sourceMappingURL.indexOf(',') + 1); + + try { + var sourceMap = JSON.parse(Buffer.from(rawData, 'base64')); + } catch (error) { + console.warn('Error parsing source map', error.stack); + return null; + } + + return { + map: sourceMap, + url: null + }; + } + }); + + const prepareStackTraceWithSourceMapping = Error.prepareStackTrace; + var prepareStackTrace = prepareStackTraceWithSourceMapping; + + function prepareStackTraceWithRawStackAssignment(error, frames) { + if (error.rawStack) { + // avoid infinite recursion + return prepareStackTraceWithSourceMapping(error, frames); + } else { + error.rawStack = frames; + return prepareStackTrace(error, frames); + } + } + + Error.stackTraceLimit = 30; + + Object.defineProperty(Error, 'prepareStackTrace', { + get: function() { + return prepareStackTraceWithRawStackAssignment; + }, + + set: function(newValue) { + prepareStackTrace = newValue; + process.nextTick(function() { + prepareStackTrace = prepareStackTraceWithSourceMapping; + }); + } + }); + + // eslint-disable-next-line no-extend-native + Error.prototype.getRawStack = function() { + // Access this.stack to ensure prepareStackTrace has been run on this error + // because it assigns this.rawStack as a side-effect + this.stack; // eslint-disable-line no-unused-expressions + return this.rawStack; + }; + + Object.keys(COMPILERS).forEach(function(extension) { + const compiler = COMPILERS[extension]; + + Object.defineProperty(nodeRequire.extensions, extension, { + enumerable: true, + writable: false, + value: function(module, filePath) { + const code = compileFileAtPath(compiler, filePath, extension); + return module._compile(code, filePath); + } + }); + }); +}; + +exports.supportedExtensions = Object.keys(COMPILERS); +exports.resetCacheStats(); diff --git a/src/config-file.js b/src/config-file.js new file mode 100644 index 00000000000..098178d42b5 --- /dev/null +++ b/src/config-file.js @@ -0,0 +1,123 @@ +const _ = require('underscore-plus'); +const fs = require('fs-plus'); +const dedent = require('dedent'); +const { Disposable, Emitter } = require('event-kit'); +const { watchPath } = require('./path-watcher'); +const CSON = require('season'); +const Path = require('path'); +const asyncQueue = require('async/queue'); + +const EVENT_TYPES = new Set(['created', 'modified', 'renamed']); + +module.exports = class ConfigFile { + static at(path) { + if (!this._known) { + this._known = new Map(); + } + + const existing = this._known.get(path); + if (existing) { + return existing; + } + + const created = new ConfigFile(path); + this._known.set(path, created); + return created; + } + + constructor(path) { + this.path = path; + this.emitter = new Emitter(); + this.value = {}; + this.reloadCallbacks = []; + + // Use a queue to prevent multiple concurrent write to the same file. + const writeQueue = asyncQueue((data, callback) => + CSON.writeFile(this.path, data, error => { + if (error) { + this.emitter.emit( + 'did-error', + dedent` + Failed to write \`${Path.basename(this.path)}\`. + + ${error.message} + ` + ); + } + callback(); + }) + ); + + this.requestLoad = _.debounce(() => this.reload(), 200); + this.requestSave = _.debounce(data => writeQueue.push(data), 200); + } + + get() { + return this.value; + } + + update(value) { + return new Promise(resolve => { + this.requestSave(value); + this.reloadCallbacks.push(resolve); + }); + } + + async watch(callback) { + if (!fs.existsSync(this.path)) { + fs.makeTreeSync(Path.dirname(this.path)); + CSON.writeFileSync(this.path, {}, { flag: 'wx' }); + } + + await this.reload(); + + try { + return await watchPath(this.path, {}, events => { + if (events.some(event => EVENT_TYPES.has(event.action))) + this.requestLoad(); + }); + } catch (error) { + this.emitter.emit( + 'did-error', + dedent` + Unable to watch path: \`${Path.basename(this.path)}\`. + + Make sure you have permissions to \`${this.path}\`. + On linux there are currently problems with watch sizes. + See [this document][watches] for more info. + + [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\ + ` + ); + return new Disposable(); + } + } + + onDidChange(callback) { + return this.emitter.on('did-change', callback); + } + + onDidError(callback) { + return this.emitter.on('did-error', callback); + } + + reload() { + return new Promise(resolve => { + CSON.readFile(this.path, (error, data) => { + if (error) { + this.emitter.emit( + 'did-error', + `Failed to load \`${Path.basename(this.path)}\` - ${error.message}` + ); + } else { + this.value = data || {}; + this.emitter.emit('did-change', this.value); + + for (const callback of this.reloadCallbacks) callback(); + this.reloadCallbacks.length = 0; + } + resolve(); + }); + }); + } +}; diff --git a/src/config-schema.coffee b/src/config-schema.coffee deleted file mode 100644 index 548b35fa8e0..00000000000 --- a/src/config-schema.coffee +++ /dev/null @@ -1,198 +0,0 @@ -path = require 'path' -fs = require 'fs-plus' - -# This is loaded by atom.coffee. See https://atom.io/docs/api/latest/Config for -# more information about config schemas. -module.exports = - core: - type: 'object' - properties: - ignoredNames: - type: 'array' - default: [".git", ".hg", ".svn", ".DS_Store", "._*", "Thumbs.db"] - items: - type: 'string' - excludeVcsIgnoredPaths: - type: 'boolean' - default: true - title: 'Exclude VCS Ignored Paths' - followSymlinks: - type: 'boolean' - default: true - title: 'Follow symlinks' - description: 'Used when searching and when opening files with the fuzzy finder.' - disabledPackages: - type: 'array' - default: [] - items: - type: 'string' - themes: - type: 'array' - default: ['one-dark-ui', 'one-dark-syntax'] - items: - type: 'string' - projectHome: - type: 'string' - default: path.join(fs.getHomeDirectory(), 'github') - audioBeep: - type: 'boolean' - default: true - destroyEmptyPanes: - type: 'boolean' - default: true - fileEncoding: - description: 'Default character set encoding to use when reading and writing files.' - type: 'string' - default: 'utf8' - enum: [ - 'cp437', - 'eucjp', - 'euckr', - 'gbk', - 'iso88591', - 'iso885910', - 'iso885913', - 'iso885914', - 'iso885915', - 'iso885916', - 'iso88592', - 'iso88593', - 'iso88594', - 'iso88595', - 'iso88596', - 'iso88597', - 'iso88597', - 'iso88598', - 'koi8r', - 'koi8u', - 'macroman', - 'shiftjis', - 'utf16be', - 'utf16le', - 'utf8', - 'windows1250', - 'windows1251', - 'windows1252', - 'windows1253', - 'windows1254', - 'windows1255', - 'windows1256', - 'windows1257', - 'windows1258', - 'windows866' - ] - - editor: - type: 'object' - properties: - # These settings are used in scoped fashion only. No defaults. - commentStart: - type: ['string', 'null'] - commentEnd: - type: ['string', 'null'] - increaseIndentPattern: - type: ['string', 'null'] - decreaseIndentPattern: - type: ['string', 'null'] - foldEndPattern: - type: ['string', 'null'] - - # These can be used as globals or scoped, thus defaults. - fontFamily: - type: 'string' - default: '' - fontSize: - type: 'integer' - default: 16 - minimum: 1 - maximum: 100 - lineHeight: - type: ['string', 'number'] - default: 1.3 - showInvisibles: - type: 'boolean' - default: false - showIndentGuide: - type: 'boolean' - default: false - showLineNumbers: - type: 'boolean' - default: true - autoIndent: - type: 'boolean' - default: true - description: 'Automatically indent the cursor when inserting a newline' - autoIndentOnPaste: - type: 'boolean' - default: true - nonWordCharacters: - type: 'string' - default: "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-" - preferredLineLength: - type: 'integer' - default: 80 - minimum: 1 - tabLength: - type: 'integer' - default: 2 - enum: [1, 2, 3, 4, 6, 8] - softWrap: - type: 'boolean' - default: false - softTabs: - type: 'boolean' - default: true - softWrapAtPreferredLineLength: - type: 'boolean' - default: false - softWrapHangingIndent: - type: 'integer' - default: 0 - minimum: 0 - scrollSensitivity: - type: 'integer' - default: 40 - minimum: 10 - maximum: 200 - scrollPastEnd: - type: 'boolean' - default: false - undoGroupingInterval: - type: 'integer' - default: 300 - minimum: 0 - description: 'Time interval in milliseconds within which operations will be grouped together in the undo history' - useShadowDOM: - type: 'boolean' - default: true - title: 'Use Shadow DOM' - description: 'Disable if you experience styling issues with packages or themes. Be sure to open an issue on the relevant package or theme, because this option is going away eventually.' - confirmCheckoutHeadRevision: - type: 'boolean' - default: true - title: 'Confirm Checkout HEAD Revision' - invisibles: - type: 'object' - properties: - eol: - type: ['boolean', 'string'] - default: '\u00ac' - space: - type: ['boolean', 'string'] - default: '\u00b7' - tab: - type: ['boolean', 'string'] - default: '\u00bb' - cr: - type: ['boolean', 'string'] - default: '\u00a4' - zoomFontWhenCtrlScrolling: - type: 'boolean' - default: process.platform isnt 'darwin' - description: 'Increase/decrease the editor font size when pressing the Ctrl key and scrolling the mouse up/down.' - -if process.platform in ['win32', 'linux'] - module.exports.core.properties.autoHideMenuBar = - type: 'boolean' - default: false - description: 'Automatically hide the menu bar and toggle it by pressing Alt. This is only supported on Windows & Linux.' diff --git a/src/config-schema.js b/src/config-schema.js new file mode 100644 index 00000000000..f1a8ff3b73b --- /dev/null +++ b/src/config-schema.js @@ -0,0 +1,671 @@ +// This is loaded by atom-environment.coffee. See +// https://atom.io/docs/api/latest/Config for more information about config +// schemas. +const configSchema = { + core: { + type: 'object', + properties: { + ignoredNames: { + type: 'array', + default: [ + '.git', + '.hg', + '.svn', + '.DS_Store', + '._*', + 'Thumbs.db', + 'desktop.ini' + ], + items: { + type: 'string' + }, + description: + 'List of [glob patterns](https://en.wikipedia.org/wiki/Glob_%28programming%29). Files and directories matching these patterns will be ignored by some packages, such as the fuzzy finder and tree view. Individual packages might have additional config settings for ignoring names.' + }, + excludeVcsIgnoredPaths: { + type: 'boolean', + default: true, + title: 'Exclude VCS Ignored Paths', + description: + "Files and directories ignored by the current project's VCS will be ignored by some packages, such as the fuzzy finder and find and replace. For example, projects using Git have these paths defined in the .gitignore file. Individual packages might have additional config settings for ignoring VCS ignored files and folders." + }, + followSymlinks: { + type: 'boolean', + default: true, + description: + 'Follow symbolic links when searching files and when opening files with the fuzzy finder.' + }, + disabledPackages: { + type: 'array', + default: [], + + items: { + type: 'string' + }, + + description: + 'List of names of installed packages which are not loaded at startup.' + }, + titleBar: { + type: 'string', + default: 'native', + enum: ['native', 'hidden'], + description: + 'Experimental: The title bar can be completely `hidden`.
This setting will require a relaunch of Atom to take effect.' + }, + versionPinnedPackages: { + type: 'array', + default: [], + + items: { + type: 'string' + }, + + description: + 'List of names of installed packages which are not automatically updated.' + }, + customFileTypes: { + type: 'object', + default: {}, + description: + 'Associates scope names (e.g. `"source.js"`) with arrays of file extensions and file names (e.g. `["Somefile", ".js2"]`)', + additionalProperties: { + type: 'array', + items: { + type: 'string' + } + } + }, + uriHandlerRegistration: { + type: 'string', + default: 'prompt', + description: + 'When should Atom register itself as the default handler for atom:// URIs', + enum: [ + { + value: 'prompt', + description: + 'Prompt to register Atom as the default atom:// URI handler' + }, + { + value: 'always', + description: + 'Always become the default atom:// URI handler automatically' + }, + { + value: 'never', + description: 'Never become the default atom:// URI handler' + } + ] + }, + themes: { + type: 'array', + default: ['one-dark-ui', 'one-dark-syntax'], + items: { + type: 'string' + }, + description: + 'Names of UI and syntax themes which will be used when Atom starts.' + }, + audioBeep: { + type: 'boolean', + default: true, + description: + "Trigger the system's beep sound when certain actions cannot be executed or there are no results." + }, + closeDeletedFileTabs: { + type: 'boolean', + default: false, + title: 'Close Deleted File Tabs', + description: + 'Close corresponding editors when a file is deleted outside Atom.' + }, + destroyEmptyPanes: { + type: 'boolean', + default: true, + title: 'Remove Empty Panes', + description: + 'When the last tab of a pane is closed, remove that pane as well.' + }, + closeEmptyWindows: { + type: 'boolean', + default: true, + description: + "When a window with no open tabs or panes is given the 'Close Tab' command, close that window." + }, + fileEncoding: { + description: + 'Default character set encoding to use when reading and writing files.', + type: 'string', + default: 'utf8', + enum: [ + { + value: 'iso88596', + description: 'Arabic (ISO 8859-6)' + }, + { + value: 'windows1256', + description: 'Arabic (Windows 1256)' + }, + { + value: 'iso88594', + description: 'Baltic (ISO 8859-4)' + }, + { + value: 'windows1257', + description: 'Baltic (Windows 1257)' + }, + { + value: 'iso885914', + description: 'Celtic (ISO 8859-14)' + }, + { + value: 'iso88592', + description: 'Central European (ISO 8859-2)' + }, + { + value: 'windows1250', + description: 'Central European (Windows 1250)' + }, + { + value: 'gb18030', + description: 'Chinese (GB18030)' + }, + { + value: 'gbk', + description: 'Chinese (GBK)' + }, + { + value: 'cp950', + description: 'Traditional Chinese (Big5)' + }, + { + value: 'big5hkscs', + description: 'Traditional Chinese (Big5-HKSCS)' + }, + { + value: 'cp866', + description: 'Cyrillic (CP 866)' + }, + { + value: 'iso88595', + description: 'Cyrillic (ISO 8859-5)' + }, + { + value: 'koi8r', + description: 'Cyrillic (KOI8-R)' + }, + { + value: 'koi8u', + description: 'Cyrillic (KOI8-U)' + }, + { + value: 'windows1251', + description: 'Cyrillic (Windows 1251)' + }, + { + value: 'cp437', + description: 'DOS (CP 437)' + }, + { + value: 'cp850', + description: 'DOS (CP 850)' + }, + { + value: 'iso885913', + description: 'Estonian (ISO 8859-13)' + }, + { + value: 'iso88597', + description: 'Greek (ISO 8859-7)' + }, + { + value: 'windows1253', + description: 'Greek (Windows 1253)' + }, + { + value: 'iso88598', + description: 'Hebrew (ISO 8859-8)' + }, + { + value: 'windows1255', + description: 'Hebrew (Windows 1255)' + }, + { + value: 'cp932', + description: 'Japanese (CP 932)' + }, + { + value: 'eucjp', + description: 'Japanese (EUC-JP)' + }, + { + value: 'shiftjis', + description: 'Japanese (Shift JIS)' + }, + { + value: 'euckr', + description: 'Korean (EUC-KR)' + }, + { + value: 'iso885910', + description: 'Nordic (ISO 8859-10)' + }, + { + value: 'iso885916', + description: 'Romanian (ISO 8859-16)' + }, + { + value: 'iso88599', + description: 'Turkish (ISO 8859-9)' + }, + { + value: 'windows1254', + description: 'Turkish (Windows 1254)' + }, + { + value: 'utf8', + description: 'Unicode (UTF-8)' + }, + { + value: 'utf16le', + description: 'Unicode (UTF-16 LE)' + }, + { + value: 'utf16be', + description: 'Unicode (UTF-16 BE)' + }, + { + value: 'windows1258', + description: 'Vietnamese (Windows 1258)' + }, + { + value: 'iso88591', + description: 'Western (ISO 8859-1)' + }, + { + value: 'iso88593', + description: 'Western (ISO 8859-3)' + }, + { + value: 'iso885915', + description: 'Western (ISO 8859-15)' + }, + { + value: 'macroman', + description: 'Western (Mac Roman)' + }, + { + value: 'windows1252', + description: 'Western (Windows 1252)' + } + ] + }, + openEmptyEditorOnStart: { + description: + 'When checked opens an untitled editor when loading a blank environment (such as with _File > New Window_ or when "Restore Previous Windows On Start" is unchecked); otherwise no editor is opened when loading a blank environment. This setting has no effect when restoring a previous state.', + type: 'boolean', + default: true + }, + restorePreviousWindowsOnStart: { + type: 'string', + enum: ['no', 'yes', 'always'], + default: 'yes', + description: + "When selected 'no', a blank environment is loaded. When selected 'yes' and Atom is started from the icon or `atom` by itself from the command line, restores the last state of all Atom windows; otherwise a blank environment is loaded. When selected 'always', restores the last state of all Atom windows always, no matter how Atom is started." + }, + reopenProjectMenuCount: { + description: + 'How many recent projects to show in the Reopen Project menu.', + type: 'integer', + default: 15 + }, + automaticallyUpdate: { + description: + 'Automatically update Atom when a new release is available.', + type: 'boolean', + default: true + }, + useProxySettingsWhenCallingApm: { + title: 'Use Proxy Settings When Calling APM', + description: + 'Use detected proxy settings when calling the `apm` command-line tool.', + type: 'boolean', + default: true + }, + allowPendingPaneItems: { + description: + 'Allow items to be previewed without adding them to a pane permanently, such as when single clicking files in the tree view.', + type: 'boolean', + default: true + }, + telemetryConsent: { + description: + 'Allow usage statistics and exception reports to be sent to the Atom team to help improve the product.', + title: 'Send Telemetry to the Atom Team', + type: 'string', + default: 'undecided', + enum: [ + { + value: 'limited', + description: + 'Allow limited anonymous usage stats, exception and crash reporting' + }, + { + value: 'no', + description: 'Do not send any telemetry data' + }, + { + value: 'undecided', + description: + 'Undecided (Atom will ask again next time it is launched)' + } + ] + }, + warnOnLargeFileLimit: { + description: + 'Warn before opening files larger than this number of megabytes.', + type: 'number', + default: 40 + }, + fileSystemWatcher: { + description: + 'Choose the underlying implementation used to watch for filesystem changes. Emulating changes will miss any events caused by applications other than Atom, but may help prevent crashes or freezes.', + type: 'string', + default: 'native', + enum: [ + { + value: 'native', + description: 'Native operating system APIs' + }, + { + value: 'experimental', + description: 'Experimental filesystem watching library' + }, + { + value: 'poll', + description: 'Polling' + }, + { + value: 'atom', + description: 'Emulated with Atom events' + } + ] + }, + useTreeSitterParsers: { + type: 'boolean', + default: true, + description: 'Use Tree-sitter parsers for supported languages.' + }, + colorProfile: { + description: + "Specify whether Atom should use the operating system's color profile (recommended) or an alternative color profile.
Changing this setting will require a relaunch of Atom to take effect.", + type: 'string', + default: 'default', + enum: [ + { + value: 'default', + description: 'Use color profile configured in the operating system' + }, + { + value: 'srgb', + description: 'Use sRGB color profile' + } + ] + } + } + }, + editor: { + type: 'object', + // These settings are used in scoped fashion only. No defaults. + properties: { + commentStart: { + type: ['string', 'null'] + }, + commentEnd: { + type: ['string', 'null'] + }, + increaseIndentPattern: { + type: ['string', 'null'] + }, + decreaseIndentPattern: { + type: ['string', 'null'] + }, + foldEndPattern: { + type: ['string', 'null'] + }, + // These can be used as globals or scoped, thus defaults. + fontFamily: { + type: 'string', + default: 'Menlo, Consolas, DejaVu Sans Mono, monospace', + description: 'The name of the font family used for editor text.' + }, + fontSize: { + type: 'integer', + default: 14, + minimum: 1, + maximum: 100, + description: 'Height in pixels of editor text.' + }, + defaultFontSize: { + type: 'integer', + default: 14, + minimum: 1, + maximum: 100, + description: + 'Default height in pixels of the editor text. Useful when resetting font size' + }, + lineHeight: { + type: ['string', 'number'], + default: 1.5, + description: 'Height of editor lines, as a multiplier of font size.' + }, + showCursorOnSelection: { + type: 'boolean', + default: true, + description: 'Show cursor while there is a selection.' + }, + showInvisibles: { + type: 'boolean', + default: false, + description: + 'Render placeholders for invisible characters, such as tabs, spaces and newlines.' + }, + showIndentGuide: { + type: 'boolean', + default: false, + description: 'Show indentation indicators in the editor.' + }, + showLineNumbers: { + type: 'boolean', + default: true, + description: "Show line numbers in the editor's gutter." + }, + atomicSoftTabs: { + type: 'boolean', + default: true, + description: + 'Skip over tab-length runs of leading whitespace when moving the cursor.' + }, + autoIndent: { + type: 'boolean', + default: true, + description: 'Automatically indent the cursor when inserting a newline.' + }, + autoIndentOnPaste: { + type: 'boolean', + default: true, + description: + 'Automatically indent pasted text based on the indentation of the previous line.' + }, + nonWordCharacters: { + type: 'string', + default: '/\\()"\':,.;<>~!@#$%^&*|+=[]{}`?-…', + description: + 'A string of non-word characters to define word boundaries.' + }, + preferredLineLength: { + type: 'integer', + default: 80, + minimum: 1, + description: + 'Identifies the length of a line which is used when wrapping text with the `Soft Wrap At Preferred Line Length` setting enabled, in number of characters.' + }, + maxScreenLineLength: { + type: 'integer', + default: 500, + minimum: 500, + description: + 'Defines the maximum width of the editor window before soft wrapping is enforced, in number of characters.' + }, + tabLength: { + type: 'integer', + default: 2, + minimum: 1, + description: 'Number of spaces used to represent a tab.' + }, + softWrap: { + type: 'boolean', + default: false, + description: + 'Wraps lines that exceed the width of the window. When `Soft Wrap At Preferred Line Length` is set, it will wrap to the number of characters defined by the `Preferred Line Length` setting.' + }, + softTabs: { + type: 'boolean', + default: true, + description: + 'If the `Tab Type` config setting is set to "auto" and autodetection of tab type from buffer content fails, then this config setting determines whether a soft tab or a hard tab will be inserted when the Tab key is pressed.' + }, + tabType: { + type: 'string', + default: 'auto', + enum: ['auto', 'soft', 'hard'], + description: + 'Determine character inserted when Tab key is pressed. Possible values: "auto", "soft" and "hard". When set to "soft" or "hard", soft tabs (spaces) or hard tabs (tab characters) are used. When set to "auto", the editor auto-detects the tab type based on the contents of the buffer (it uses the first leading whitespace on a non-comment line), or uses the value of the Soft Tabs config setting if auto-detection fails.' + }, + softWrapAtPreferredLineLength: { + type: 'boolean', + default: false, + description: + "Instead of wrapping lines to the window's width, wrap lines to the number of characters defined by the `Preferred Line Length` setting. This will only take effect when the soft wrap config setting is enabled globally or for the current language. **Note:** If you want to hide the wrap guide (the vertical line) you can disable the `wrap-guide` package." + }, + softWrapHangingIndent: { + type: 'integer', + default: 0, + minimum: 0, + description: + 'When soft wrap is enabled, defines length of additional indentation applied to wrapped lines, in number of characters.' + }, + scrollSensitivity: { + type: 'integer', + default: 40, + minimum: 10, + maximum: 200, + description: + 'Determines how fast the editor scrolls when using a mouse or trackpad.' + }, + scrollPastEnd: { + type: 'boolean', + default: false, + description: + 'Allow the editor to be scrolled past the end of the last line.' + }, + undoGroupingInterval: { + type: 'integer', + default: 300, + minimum: 0, + description: + 'Time interval in milliseconds within which text editing operations will be grouped together in the undo history.' + }, + confirmCheckoutHeadRevision: { + type: 'boolean', + default: true, + title: 'Confirm Checkout HEAD Revision', + description: + 'Show confirmation dialog when checking out the HEAD revision and discarding changes to current file since last commit.' + }, + invisibles: { + type: 'object', + description: + 'A hash of characters Atom will use to render whitespace characters. Keys are whitespace character types, values are rendered characters (use value false to turn off individual whitespace character types).', + properties: { + eol: { + type: ['boolean', 'string'], + default: '¬', + maximumLength: 1, + description: + 'Character used to render newline characters (\\n) when the `Show Invisibles` setting is enabled. ' + }, + space: { + type: ['boolean', 'string'], + default: '·', + maximumLength: 1, + description: + 'Character used to render leading and trailing space characters when the `Show Invisibles` setting is enabled.' + }, + tab: { + type: ['boolean', 'string'], + default: '»', + maximumLength: 1, + description: + 'Character used to render hard tab characters (\\t) when the `Show Invisibles` setting is enabled.' + }, + cr: { + type: ['boolean', 'string'], + default: '¤', + maximumLength: 1, + description: + 'Character used to render carriage return characters (for Microsoft-style line endings) when the `Show Invisibles` setting is enabled.' + } + } + }, + zoomFontWhenCtrlScrolling: { + type: 'boolean', + default: process.platform !== 'darwin', + description: + 'Change the editor font size when pressing the Ctrl key and scrolling the mouse up/down.' + }, + multiCursorOnClick: { + type: 'boolean', + default: true, + description: + 'Add multiple cursors when pressing the Ctrl key (Command key on MacOS) and clicking the editor.' + } + } + } +}; + +if (['win32', 'linux'].includes(process.platform)) { + configSchema.core.properties.autoHideMenuBar = { + type: 'boolean', + default: false, + description: + 'Automatically hide the menu bar and toggle it by pressing Alt. This is only supported on Windows & Linux.' + }; +} + +if (process.platform === 'darwin') { + configSchema.core.properties.titleBar = { + type: 'string', + default: 'native', + enum: ['native', 'custom', 'custom-inset', 'hidden'], + description: + 'Experimental: A `custom` title bar adapts to theme colors. Choosing `custom-inset` adds a bit more padding. The title bar can also be completely `hidden`.
Note: Switching to a custom or hidden title bar will compromise some functionality.
This setting will require a relaunch of Atom to take effect.' + }; + configSchema.core.properties.simpleFullScreenWindows = { + type: 'boolean', + default: false, + description: + 'Use pre-Lion fullscreen on macOS. This does not create a new desktop space for the atom on fullscreen mode.' + }; +} + +if (process.platform === 'linux') { + configSchema.editor.properties.selectionClipboard = { + type: 'boolean', + default: true, + description: 'Enable pasting on middle mouse button click' + }; +} + +module.exports = configSchema; diff --git a/src/config.coffee b/src/config.coffee deleted file mode 100644 index b97ce99ec0e..00000000000 --- a/src/config.coffee +++ /dev/null @@ -1,1220 +0,0 @@ -_ = require 'underscore-plus' -fs = require 'fs-plus' -{CompositeDisposable, Disposable, Emitter} = require 'event-kit' -CSON = require 'season' -path = require 'path' -async = require 'async' -pathWatcher = require 'pathwatcher' -Grim = require 'grim' - -Color = require './color' -ScopedPropertyStore = require 'scoped-property-store' -ScopeDescriptor = require './scope-descriptor' - -# Essential: Used to access all of Atom's configuration details. -# -# An instance of this class is always available as the `atom.config` global. -# -# ## Getting and setting config settings. -# -# ```coffee -# # Note that with no value set, ::get returns the setting's default value. -# atom.config.get('my-package.myKey') # -> 'defaultValue' -# -# atom.config.set('my-package.myKey', 'value') -# atom.config.get('my-package.myKey') # -> 'value' -# ``` -# -# You may want to watch for changes. Use {::observe} to catch changes to the setting. -# -# ```coffee -# atom.config.set('my-package.myKey', 'value') -# atom.config.observe 'my-package.myKey', (newValue) -> -# # `observe` calls immediately and every time the value is changed -# console.log 'My configuration changed:', newValue -# ``` -# -# If you want a notification only when the value changes, use {::onDidChange}. -# -# ```coffee -# atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) -> -# console.log 'My configuration changed:', newValue, oldValue -# ``` -# -# ### Value Coercion -# -# Config settings each have a type specified by way of a -# [schema](json-schema.org). For example we might an integer setting that only -# allows integers greater than `0`: -# -# ```coffee -# # When no value has been set, `::get` returns the setting's default value -# atom.config.get('my-package.anInt') # -> 12 -# -# # The string will be coerced to the integer 123 -# atom.config.set('my-package.anInt', '123') -# atom.config.get('my-package.anInt') # -> 123 -# -# # The string will be coerced to an integer, but it must be greater than 0, so is set to 1 -# atom.config.set('my-package.anInt', '-20') -# atom.config.get('my-package.anInt') # -> 1 -# ``` -# -# ## Defining settings for your package -# -# Define a schema under a `config` key in your package main. -# -# ```coffee -# module.exports = -# # Your config schema -# config: -# someInt: -# type: 'integer' -# default: 23 -# minimum: 1 -# -# activate: (state) -> # ... -# # ... -# ``` -# -# See [package docs](https://atom.io/docs/latest/hacking-atom-package-word-count) for -# more info. -# -# ## Config Schemas -# -# We use [json schema](http://json-schema.org) which allows you to define your value's -# default, the type it should be, etc. A simple example: -# -# ```coffee -# # We want to provide an `enableThing`, and a `thingVolume` -# config: -# enableThing: -# type: 'boolean' -# default: false -# thingVolume: -# type: 'integer' -# default: 5 -# minimum: 1 -# maximum: 11 -# ``` -# -# The type keyword allows for type coercion and validation. If a `thingVolume` is -# set to a string `'10'`, it will be coerced into an integer. -# -# ```coffee -# atom.config.set('my-package.thingVolume', '10') -# atom.config.get('my-package.thingVolume') # -> 10 -# -# # It respects the min / max -# atom.config.set('my-package.thingVolume', '400') -# atom.config.get('my-package.thingVolume') # -> 11 -# -# # If it cannot be coerced, the value will not be set -# atom.config.set('my-package.thingVolume', 'cats') -# atom.config.get('my-package.thingVolume') # -> 11 -# ``` -# -# ### Supported Types -# -# The `type` keyword can be a string with any one of the following. You can also -# chain them by specifying multiple in an an array. For example -# -# ```coffee -# config: -# someSetting: -# type: ['boolean', 'integer'] -# default: 5 -# -# # Then -# atom.config.set('my-package.someSetting', 'true') -# atom.config.get('my-package.someSetting') # -> true -# -# atom.config.set('my-package.someSetting', '12') -# atom.config.get('my-package.someSetting') # -> 12 -# ``` -# -# #### string -# -# Values must be a string. -# -# ```coffee -# config: -# someSetting: -# type: 'string' -# default: 'hello' -# ``` -# -# #### integer -# -# Values will be coerced into integer. Supports the (optional) `minimum` and -# `maximum` keys. -# -# ```coffee -# config: -# someSetting: -# type: 'integer' -# default: 5 -# minimum: 1 -# maximum: 11 -# ``` -# -# #### number -# -# Values will be coerced into a number, including real numbers. Supports the -# (optional) `minimum` and `maximum` keys. -# -# ```coffee -# config: -# someSetting: -# type: 'number' -# default: 5.3 -# minimum: 1.5 -# maximum: 11.5 -# ``` -# -# #### boolean -# -# Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into -# a boolean. Numbers, arrays, objects, and anything else will not be coerced. -# -# ```coffee -# config: -# someSetting: -# type: 'boolean' -# default: false -# ``` -# -# #### array -# -# Value must be an Array. The types of the values can be specified by a -# subschema in the `items` key. -# -# ```coffee -# config: -# someSetting: -# type: 'array' -# default: [1, 2, 3] -# items: -# type: 'integer' -# minimum: 1.5 -# maximum: 11.5 -# ``` -# -# #### object -# -# Value must be an object. This allows you to nest config options. Sub options -# must be under a `properties key` -# -# ```coffee -# config: -# someSetting: -# type: 'object' -# properties: -# myChildIntOption: -# type: 'integer' -# minimum: 1.5 -# maximum: 11.5 -# ``` -# -# #### color -# -# Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha` -# properties that all have numeric values. `red`, `green`, `blue` will be in -# the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any -# valid CSS color format such as `#abc`, `#abcdef`, `white`, -# `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`. -# -# ```coffee -# config: -# someSetting: -# type: 'color' -# default: 'white' -# ``` -# -# ### Other Supported Keys -# -# #### enum -# -# All types support an `enum` key. The enum key lets you specify all values -# that the config setting can possibly be. `enum` _must_ be an array of values -# of your specified type. Schema: -# -# ```coffee -# config: -# someSetting: -# type: 'integer' -# default: 4 -# enum: [2, 4, 6, 8] -# ``` -# -# Usage: -# -# ```coffee -# atom.config.set('my-package.someSetting', '2') -# atom.config.get('my-package.someSetting') # -> 2 -# -# # will not set values outside of the enum values -# atom.config.set('my-package.someSetting', '3') -# atom.config.get('my-package.someSetting') # -> 2 -# -# # If it cannot be coerced, the value will not be set -# atom.config.set('my-package.someSetting', '4') -# atom.config.get('my-package.someSetting') # -> 4 -# ``` -# -# #### title and description -# -# The settings view will use the `title` and `description` keys to display your -# config setting in a readable way. By default the settings view humanizes your -# config key, so `someSetting` becomes `Some Setting`. In some cases, this is -# confusing for users, and a more descriptive title is useful. -# -# Descriptions will be displayed below the title in the settings view. -# -# ```coffee -# config: -# someSetting: -# title: 'Setting Magnitude' -# description: 'This will affect the blah and the other blah' -# type: 'integer' -# default: 4 -# ``` -# -# __Note__: You should strive to be so clear in your naming of the setting that -# you do not need to specify a title or description! -# -# ## Best practices -# -# * Don't depend on (or write to) configuration keys outside of your keypath. -# -module.exports = -class Config - @schemaEnforcers = {} - - @addSchemaEnforcer: (typeName, enforcerFunction) -> - @schemaEnforcers[typeName] ?= [] - @schemaEnforcers[typeName].push(enforcerFunction) - - @addSchemaEnforcers: (filters) -> - for typeName, functions of filters - for name, enforcerFunction of functions - @addSchemaEnforcer(typeName, enforcerFunction) - return - - @executeSchemaEnforcers: (keyPath, value, schema) -> - error = null - types = schema.type - types = [types] unless Array.isArray(types) - for type in types - try - enforcerFunctions = @schemaEnforcers[type].concat(@schemaEnforcers['*']) - for enforcer in enforcerFunctions - # At some point in one's life, one must call upon an enforcer. - value = enforcer.call(this, keyPath, value, schema) - error = null - break - catch e - error = e - - throw error if error? - value - - # Created during initialization, available as `atom.config` - constructor: ({@configDirPath, @resourcePath}={}) -> - @emitter = new Emitter - @schema = - type: 'object' - properties: {} - @defaultSettings = {} - @settings = {} - @scopedSettingsStore = new ScopedPropertyStore - @configFileHasErrors = false - @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson']) - @configFilePath ?= path.join(@configDirPath, 'config.cson') - @transactDepth = 0 - @savePending = false - - @requestLoad = _.debounce(@loadUserConfig, 100) - @requestSave = => - @savePending = true - debouncedSave.call(this) - save = => - @savePending = false - @save() - debouncedSave = _.debounce(save, 100) - - ### - Section: Config Subscription - ### - - # Essential: Add a listener for changes to a given key path. This is different - # than {::onDidChange} in that it will immediately call your callback with the - # current value of the config entry. - # - # ### Examples - # - # You might want to be notified when the themes change. We'll watch - # `core.themes` for changes - # - # ```coffee - # atom.config.observe 'core.themes', (value) -> - # # do stuff with value - # ``` - # - # * `keyPath` {String} name of the key to observe - # * `options` {Object} - # * `scopeDescriptor` (optional) {ScopeDescriptor} describing a path from - # the root of the syntax tree to a token. Get one by calling - # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. - # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) - # for more information. - # * `callback` {Function} to call when the value of the key changes. - # * `value` the new value of the key - # - # Returns a {Disposable} with the following keys on which you can call - # `.dispose()` to unsubscribe. - observe: -> - if arguments.length is 2 - [keyPath, callback] = arguments - else if Grim.includeDeprecatedAPIs and arguments.length is 3 and (_.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor) - Grim.deprecate """ - Passing a scope descriptor as the first argument to Config::observe is deprecated. - Pass a `scope` in an options hash as the third argument instead. - """ - [scopeDescriptor, keyPath, callback] = arguments - else if arguments.length is 3 and (_.isString(arguments[0]) and _.isObject(arguments[1])) - [keyPath, options, callback] = arguments - scopeDescriptor = options.scope - if Grim.includeDeprecatedAPIs and options.callNow? - Grim.deprecate """ - Config::observe no longer takes a `callNow` option. Use ::onDidChange instead. - Note that ::onDidChange passes its callback different arguments. - See https://atom.io/docs/api/latest/Config - """ - else - console.error 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details' - return - - if scopeDescriptor? - @observeScopedKeyPath(scopeDescriptor, keyPath, callback) - else - @observeKeyPath(keyPath, options ? {}, callback) - - # Essential: Add a listener for changes to a given key path. If `keyPath` is - # not specified, your callback will be called on changes to any key. - # - # * `keyPath` (optional) {String} name of the key to observe. Must be - # specified if `scopeDescriptor` is specified. - # * `optional` (optional) {Object} - # * `scopeDescriptor` (optional) {ScopeDescriptor} describing a path from - # the root of the syntax tree to a token. Get one by calling - # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. - # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) - # for more information. - # * `callback` {Function} to call when the value of the key changes. - # * `event` {Object} - # * `newValue` the new value of the key - # * `oldValue` the prior value of the key. - # * `keyPath` the keyPath of the changed key - # - # Returns a {Disposable} with the following keys on which you can call - # `.dispose()` to unsubscribe. - onDidChange: -> - if arguments.length is 1 - [callback] = arguments - else if arguments.length is 2 - [keyPath, callback] = arguments - else if Grim.includeDeprecatedAPIs and _.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor - Grim.deprecate """ - Passing a scope descriptor as the first argument to Config::onDidChange is deprecated. - Pass a `scope` in an options hash as the third argument instead. - """ - [scopeDescriptor, keyPath, callback] = arguments - else - [keyPath, options, callback] = arguments - scopeDescriptor = options.scope - - if scopeDescriptor? - @onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback) - else - @onDidChangeKeyPath(keyPath, callback) - - ### - Section: Managing Settings - ### - - # Essential: Retrieves the setting for the given key. - # - # ### Examples - # - # You might want to know what themes are enabled, so check `core.themes` - # - # ```coffee - # atom.config.get('core.themes') - # ``` - # - # With scope descriptors you can get settings within a specific editor - # scope. For example, you might want to know `editor.tabLength` for ruby - # files. - # - # ```coffee - # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 - # ``` - # - # This setting in ruby files might be different than the global tabLength setting - # - # ```coffee - # atom.config.get('editor.tabLength') # => 4 - # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 - # ``` - # - # You can get the language scope descriptor via - # {TextEditor::getRootScopeDescriptor}. This will get the setting specifically - # for the editor's language. - # - # ```coffee - # atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2 - # ``` - # - # Additionally, you can get the setting at the specific cursor position. - # - # ```coffee - # scopeDescriptor = @editor.getLastCursor().getScopeDescriptor() - # atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2 - # ``` - # - # * `keyPath` The {String} name of the key to retrieve. - # * `options` (optional) {Object} - # * `sources` (optional) {Array} of {String} source names. If provided, only - # values that were associated with these sources during {::set} will be used. - # * `excludeSources` (optional) {Array} of {String} source names. If provided, - # values that were associated with these sources during {::set} will not - # be used. - # * `scope` (optional) {ScopeDescriptor} describing a path from - # the root of the syntax tree to a token. Get one by calling - # {editor.getLastCursor().getScopeDescriptor()} - # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) - # for more information. - # - # Returns the value from Atom's default settings, the user's configuration - # file in the type specified by the configuration schema. - get: -> - if arguments.length > 1 - if typeof arguments[0] is 'string' or not arguments[0]? - [keyPath, options] = arguments - {scope} = options - else if Grim.includeDeprecatedAPIs - Grim.deprecate """ - Passing a scope descriptor as the first argument to Config::get is deprecated. - Pass a `scope` in an options hash as the final argument instead. - """ - [scope, keyPath] = arguments - else - [keyPath] = arguments - - if scope? - value = @getRawScopedValue(scope, keyPath, options) - value ? @getRawValue(keyPath, options) - else - @getRawValue(keyPath, options) - - # Extended: Get all of the values for the given key-path, along with their - # associated scope selector. - # - # * `keyPath` The {String} name of the key to retrieve - # * `options` (optional) {Object} see the `options` argument to {::get} - # - # Returns an {Array} of {Object}s with the following keys: - # * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated - # * `value` The value for the key-path - getAll: (keyPath, options) -> - {scope, sources} = options if options? - result = [] - - if scope? - scopeDescriptor = ScopeDescriptor.fromObject(scope) - result = result.concat @scopedSettingsStore.getAll(scopeDescriptor.getScopeChain(), keyPath, options) - - if globalValue = @getRawValue(keyPath, options) - result.push(scopeSelector: '*', value: globalValue) - - result - - # Essential: Sets the value for a configuration setting. - # - # This value is stored in Atom's internal configuration file. - # - # ### Examples - # - # You might want to change the themes programmatically: - # - # ```coffee - # atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax']) - # ``` - # - # You can also set scoped settings. For example, you might want change the - # `editor.tabLength` only for ruby files. - # - # ```coffee - # atom.config.get('editor.tabLength') # => 4 - # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4 - # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 - # - # # Set ruby to 2 - # atom.config.set('editor.tabLength', 2, scopeSelector: 'source.ruby') # => true - # - # # Notice it's only set to 2 in the case of ruby - # atom.config.get('editor.tabLength') # => 4 - # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 - # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 - # ``` - # - # * `keyPath` The {String} name of the key. - # * `value` The value of the setting. Passing `undefined` will revert the - # setting to the default value. - # * `options` (optional) {Object} - # * `scopeSelector` (optional) {String}. eg. '.source.ruby' - # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) - # for more information. - # * `source` (optional) {String} The name of a file with which the setting - # is associated. Defaults to the user's config file. - # - # Returns a {Boolean} - # * `true` if the value was set. - # * `false` if the value was not able to be coerced to the type specified in the setting's schema. - set: -> - if Grim.includeDeprecatedAPIs and arguments[0]?[0] is '.' - Grim.deprecate """ - Passing a scope selector as the first argument to Config::set is deprecated. - Pass a `scopeSelector` in an options hash as the final argument instead. - """ - [scopeSelector, keyPath, value] = arguments - shouldSave = true - else - [keyPath, value, options] = arguments - scopeSelector = options?.scopeSelector - source = options?.source - shouldSave = options?.save ? true - - if source and not scopeSelector - throw new Error("::set with a 'source' and no 'sourceSelector' is not yet implemented!") - - source ?= @getUserConfigPath() - - unless value is undefined - try - value = @makeValueConformToSchema(keyPath, value) - catch e - return false - - if scopeSelector? - @setRawScopedValue(keyPath, value, source, scopeSelector) - else - @setRawValue(keyPath, value) - - @requestSave() if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors - true - - # Essential: Restore the setting at `keyPath` to its default value. - # - # * `keyPath` The {String} name of the key. - # * `options` (optional) {Object} - # * `scopeSelector` (optional) {String}. See {::set} - # * `source` (optional) {String}. See {::set} - unset: (keyPath, options) -> - if Grim.includeDeprecatedAPIs and typeof options is 'string' - Grim.deprecate """ - Passing a scope selector as the first argument to Config::unset is deprecated. - Pass a `scopeSelector` in an options hash as the second argument instead. - """ - scopeSelector = keyPath - keyPath = options - else - {scopeSelector, source} = options ? {} - - source ?= @getUserConfigPath() - - if scopeSelector? - if keyPath? - settings = @scopedSettingsStore.propertiesForSourceAndSelector(source, scopeSelector) - if _.valueForKeyPath(settings, keyPath)? - @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector) - _.setValueForKeyPath(settings, keyPath, undefined) - settings = withoutEmptyObjects(settings) - @set(null, settings, {scopeSelector, source, priority: @priorityForSource(source)}) if settings? - @requestSave() - else - @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector) - @emitChangeEvent() - else - for scopeSelector of @scopedSettingsStore.propertiesForSource(source) - @unset(keyPath, {scopeSelector, source}) - if keyPath? and source is @getUserConfigPath() - @set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath)) - - # Extended: Get an {Array} of all of the `source` {String}s with which - # settings have been added via {::set}. - getSources: -> - _.uniq(_.pluck(@scopedSettingsStore.propertySets, 'source')).sort() - - # Extended: Retrieve the schema for a specific key path. The schema will tell - # you what type the keyPath expects, and other metadata about the config - # option. - # - # * `keyPath` The {String} name of the key. - # - # Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`. - # Returns `null` when the keyPath has no schema specified. - getSchema: (keyPath) -> - keys = splitKeyPath(keyPath) - schema = @schema - for key in keys - break unless schema? - schema = schema.properties?[key] - schema - - # Extended: Get the {String} path to the config file being used. - getUserConfigPath: -> - @configFilePath - - # Extended: Suppress calls to handler functions registered with {::onDidChange} - # and {::observe} for the duration of `callback`. After `callback` executes, - # handlers will be called once if the value for their key-path has changed. - # - # * `callback` {Function} to execute while suppressing calls to handlers. - transact: (callback) -> - @transactDepth++ - try - callback() - finally - @transactDepth-- - @emitChangeEvent() - - ### - Section: Internal methods used by core - ### - - pushAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = arrayValue.push(value) - @set(keyPath, arrayValue) - result - - unshiftAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = arrayValue.unshift(value) - @set(keyPath, arrayValue) - result - - removeAtKeyPath: (keyPath, value) -> - arrayValue = @get(keyPath) ? [] - result = _.remove(arrayValue, value) - @set(keyPath, arrayValue) - result - - setSchema: (keyPath, schema) -> - unless isPlainObject(schema) - throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!") - - unless typeof schema.type? - throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute") - - rootSchema = @schema - if keyPath - for key in splitKeyPath(keyPath) - rootSchema.type = 'object' - rootSchema.properties ?= {} - properties = rootSchema.properties - properties[key] ?= {} - rootSchema = properties[key] - - _.extend rootSchema, schema - @setDefaults(keyPath, @extractDefaultsFromSchema(schema)) - @setScopedDefaultsFromSchema(keyPath, schema) - @resetSettingsForSchemaChange() - - load: -> - @initializeConfigDirectory() - @loadUserConfig() - @observeUserConfig() - - ### - Section: Private methods managing the user's config file - ### - - initializeConfigDirectory: (done) -> - return if fs.existsSync(@configDirPath) - - fs.makeTreeSync(@configDirPath) - - queue = async.queue ({sourcePath, destinationPath}, callback) -> - fs.copy(sourcePath, destinationPath, callback) - queue.drain = done - - templateConfigDirPath = fs.resolve(@resourcePath, 'dot-atom') - onConfigDirFile = (sourcePath) => - relativePath = sourcePath.substring(templateConfigDirPath.length + 1) - destinationPath = path.join(@configDirPath, relativePath) - queue.push({sourcePath, destinationPath}) - fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true) - - loadUserConfig: -> - unless fs.existsSync(@configFilePath) - fs.makeTreeSync(path.dirname(@configFilePath)) - CSON.writeFileSync(@configFilePath, {}) - - try - unless @savePending - userConfig = CSON.readFileSync(@configFilePath) - @resetUserSettings(userConfig) - @configFileHasErrors = false - catch error - @configFileHasErrors = true - message = "Failed to load `#{path.basename(@configFilePath)}`" - - detail = if error.location? - # stack is the output from CSON in this case - error.stack - else - # message will be EACCES permission denied, et al - error.message - - @notifyFailure(message, detail) - - observeUserConfig: -> - try - @watchSubscription ?= pathWatcher.watch @configFilePath, (eventType) => - @requestLoad() if eventType is 'change' and @watchSubscription? - catch error - @notifyFailure """ - Unable to watch path: `#{path.basename(@configFilePath)}`. Make sure you have permissions to - `#{@configFilePath}`. On linux there are currently problems with watch - sizes. See [this document][watches] for more info. - [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path - """ - - unobserveUserConfig: -> - @watchSubscription?.close() - @watchSubscription = null - - notifyFailure: (errorMessage, detail) -> - atom.notifications.addError(errorMessage, {detail, dismissable: true}) - - save: -> - allSettings = {'*': @settings} - allSettings = _.extend allSettings, @scopedSettingsStore.propertiesForSource(@getUserConfigPath()) - try - CSON.writeFileSync(@configFilePath, allSettings) - catch error - message = "Failed to save `#{path.basename(@configFilePath)}`" - detail = error.message - @notifyFailure(message, detail) - - ### - Section: Private methods managing global settings - ### - - resetUserSettings: (newSettings) -> - unless isPlainObject(newSettings) - @settings = {} - @emitChangeEvent() - return - - if newSettings.global? - newSettings['*'] = newSettings.global - delete newSettings.global - - if newSettings['*']? - scopedSettings = newSettings - newSettings = newSettings['*'] - delete scopedSettings['*'] - @resetUserScopedSettings(scopedSettings) - - @transact => - @settings = {} - @set(key, value, save: false) for key, value of newSettings - return - - getRawValue: (keyPath, options) -> - unless options?.excludeSources?.indexOf(@getUserConfigPath()) >= 0 - value = _.valueForKeyPath(@settings, keyPath) - unless options?.sources?.length > 0 - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - - if value? - value = @deepClone(value) - _.defaults(value, defaultValue) if isPlainObject(value) and isPlainObject(defaultValue) - else - value = @deepClone(defaultValue) - - value - - setRawValue: (keyPath, value) -> - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - value = undefined if _.isEqual(defaultValue, value) - - if keyPath? - _.setValueForKeyPath(@settings, keyPath, value) - else - @settings = value - @emitChangeEvent() - - observeKeyPath: (keyPath, options, callback) -> - callback(@get(keyPath)) - @onDidChangeKeyPath keyPath, (event) -> callback(event.newValue) - - onDidChangeKeyPath: (keyPath, callback) -> - oldValue = @get(keyPath) - @emitter.on 'did-change', => - newValue = @get(keyPath) - unless _.isEqual(oldValue, newValue) - event = {oldValue, newValue} - oldValue = newValue - callback(event) - - isSubKeyPath: (keyPath, subKeyPath) -> - return false unless keyPath? and subKeyPath? - pathSubTokens = splitKeyPath(subKeyPath) - pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length) - _.isEqual(pathTokens, pathSubTokens) - - setRawDefault: (keyPath, value) -> - _.setValueForKeyPath(@defaultSettings, keyPath, value) - @emitChangeEvent() - - setDefaults: (keyPath, defaults) -> - if defaults? and isPlainObject(defaults) - keys = splitKeyPath(keyPath) - for key, childValue of defaults - continue unless defaults.hasOwnProperty(key) - @setDefaults(keys.concat([key]).join('.'), childValue) - else - try - defaults = @makeValueConformToSchema(keyPath, defaults) - @setRawDefault(keyPath, defaults) - catch e - console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") - return - - deepClone: (object) -> - if object instanceof Color - object.clone() - else if _.isArray(object) - object.map (value) => @deepClone(value) - else if isPlainObject(object) - _.mapObject object, (key, value) => [key, @deepClone(value)] - else - object - - # `schema` will look something like this - # - # ```coffee - # type: 'string' - # default: 'ok' - # scopes: - # '.source.js': - # default: 'omg' - # ``` - setScopedDefaultsFromSchema: (keyPath, schema) -> - if schema.scopes? and isPlainObject(schema.scopes) - scopedDefaults = {} - for scope, scopeSchema of schema.scopes - continue unless scopeSchema.hasOwnProperty('default') - scopedDefaults[scope] = {} - _.setValueForKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default) - @scopedSettingsStore.addProperties('schema-default', scopedDefaults) - - if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties) - keys = splitKeyPath(keyPath) - for key, childValue of schema.properties - continue unless schema.properties.hasOwnProperty(key) - @setScopedDefaultsFromSchema(keys.concat([key]).join('.'), childValue) - - return - - extractDefaultsFromSchema: (schema) -> - if schema.default? - schema.default - else if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties) - defaults = {} - properties = schema.properties or {} - defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties - defaults - - makeValueConformToSchema: (keyPath, value, options) -> - if options?.suppressException - try - @makeValueConformToSchema(keyPath, value) - catch e - undefined - else - value = @constructor.executeSchemaEnforcers(keyPath, value, schema) if schema = @getSchema(keyPath) - value - - # When the schema is changed / added, there may be values set in the config - # that do not conform to the schema. This will reset make them conform. - resetSettingsForSchemaChange: (source=@getUserConfigPath()) -> - @transact => - @settings = @makeValueConformToSchema(null, @settings, suppressException: true) - priority = @priorityForSource(source) - selectorsAndSettings = @scopedSettingsStore.propertiesForSource(source) - @scopedSettingsStore.removePropertiesForSource(source) - for scopeSelector, settings of selectorsAndSettings - settings = @makeValueConformToSchema(null, settings, suppressException: true) - @setRawScopedValue(null, settings, source, scopeSelector) - return - - ### - Section: Private Scoped Settings - ### - - priorityForSource: (source) -> - if source is @getUserConfigPath() - 1000 - else - 0 - - emitChangeEvent: -> - @emitter.emit 'did-change' unless @transactDepth > 0 - - resetUserScopedSettings: (newScopedSettings) -> - source = @getUserConfigPath() - priority = @priorityForSource(source) - @scopedSettingsStore.removePropertiesForSource(source) - - for scopeSelector, settings of newScopedSettings - settings = @makeValueConformToSchema(null, settings, suppressException: true) - validatedSettings = {} - validatedSettings[scopeSelector] = withoutEmptyObjects(settings) - @scopedSettingsStore.addProperties(source, validatedSettings, {priority}) if validatedSettings[scopeSelector]? - - @emitChangeEvent() - - setRawScopedValue: (keyPath, value, source, selector, options) -> - if keyPath? - newValue = {} - _.setValueForKeyPath(newValue, keyPath, value) - value = newValue - - settingsBySelector = {} - settingsBySelector[selector] = value - @scopedSettingsStore.addProperties(source, settingsBySelector, priority: @priorityForSource(source)) - @emitChangeEvent() - - getRawScopedValue: (scopeDescriptor, keyPath, options) -> - scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor) - @scopedSettingsStore.getPropertyValue(scopeDescriptor.getScopeChain(), keyPath, options) - - observeScopedKeyPath: (scope, keyPath, callback) -> - callback(@get(keyPath, {scope})) - @onDidChangeScopedKeyPath scope, keyPath, (event) -> callback(event.newValue) - - onDidChangeScopedKeyPath: (scope, keyPath, callback) -> - oldValue = @get(keyPath, {scope}) - @emitter.on 'did-change', => - newValue = @get(keyPath, {scope}) - unless _.isEqual(oldValue, newValue) - event = {oldValue, newValue} - oldValue = newValue - callback(event) - -# 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. -# -# Enforcers are run from most specific to least. For a schema with type -# `integer`, all the enforcers for the `integer` type will be run first, in -# order of specification. Then the `*` enforcers will be run, in order of -# specification. -Config.addSchemaEnforcers - 'integer': - coerce: (keyPath, value, schema) -> - value = parseInt(value) - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value) - value - - 'number': - coerce: (keyPath, value, schema) -> - value = parseFloat(value) - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value) - value - - 'boolean': - coerce: (keyPath, value, schema) -> - switch typeof value - when 'string' - if value.toLowerCase() is 'true' - true - else if value.toLowerCase() is 'false' - false - else - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") - when 'boolean' - value - else - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") - - 'string': - validate: (keyPath, value, schema) -> - unless typeof value is 'string' - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string") - value - - 'null': - # null sort of isnt supported. It will just unset in this case - coerce: (keyPath, value, schema) -> - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value in [undefined, null] - value - - 'object': - coerce: (keyPath, value, schema) -> - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value) - return value unless schema.properties? - - newValue = {} - for prop, propValue of value - childSchema = schema.properties[prop] - if childSchema? - try - newValue[prop] = @executeSchemaEnforcers("#{keyPath}.#{prop}", propValue, childSchema) - catch error - console.warn "Error setting item in object: #{error.message}" - else - # Just pass through un-schema'd values - newValue[prop] = propValue - - newValue - - 'array': - coerce: (keyPath, value, schema) -> - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value) - itemSchema = schema.items - if itemSchema? - newValue = [] - for item in value - try - newValue.push @executeSchemaEnforcers(keyPath, item, itemSchema) - catch error - console.warn "Error setting item in array: #{error.message}" - newValue - else - value - - 'color': - coerce: (keyPath, value, schema) -> - color = Color.parse(value) - unless color? - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a color") - color - - '*': - coerceMinimumAndMaximum: (keyPath, value, schema) -> - return value unless typeof value is 'number' - if schema.minimum? and typeof schema.minimum is 'number' - value = Math.max(value, schema.minimum) - if schema.maximum? and typeof schema.maximum is 'number' - value = Math.min(value, schema.maximum) - value - - validateEnum: (keyPath, value, schema) -> - possibleValues = schema.enum - return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length - - for possibleValue in possibleValues - # Using `isEqual` for possibility of placing enums on array and object schemas - return value if _.isEqual(possibleValue, value) - - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}") - -isPlainObject = (value) -> - _.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value) and not (value instanceof Color) - -splitKeyPath = (keyPath) -> - return [] unless keyPath? - startIndex = 0 - keyPathArray = [] - for char, i in keyPath - if char is '.' and (i is 0 or keyPath[i-1] isnt '\\') - keyPathArray.push keyPath.substring(startIndex, i) - startIndex = i + 1 - keyPathArray.push keyPath.substr(startIndex, keyPath.length) - keyPathArray - -withoutEmptyObjects = (object) -> - resultObject = undefined - if isPlainObject(object) - for key, value of object - newValue = withoutEmptyObjects(value) - if newValue? - resultObject ?= {} - resultObject[key] = newValue - else - resultObject = object - resultObject - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(Config) - - Config::restoreDefault = (scopeSelector, keyPath) -> - Grim.deprecate("Use ::unset instead.") - @unset(scopeSelector, keyPath) - @get(keyPath) - - Config::getDefault = -> - Grim.deprecate("Use `::get(keyPath, {scope, excludeSources: [atom.config.getUserConfigPath()]})` instead") - if arguments.length is 1 - [keyPath] = arguments - else - [scopeSelector, keyPath] = arguments - scope = [scopeSelector] - @get(keyPath, {scope, excludeSources: [@getUserConfigPath()]}) - - Config::isDefault = -> - Grim.deprecate("Use `not ::get(keyPath, {scope, sources: [atom.config.getUserConfigPath()]})?` instead") - if arguments.length is 1 - [keyPath] = arguments - else - [scopeSelector, keyPath] = arguments - scope = [scopeSelector] - not @get(keyPath, {scope, sources: [@getUserConfigPath()]})? - - Config::getSettings = -> - Grim.deprecate "Use ::get(keyPath) instead" - _.deepExtend({}, @settings, @defaultSettings) - - Config::getInt = (keyPath) -> - Grim.deprecate '''Config::getInt is no longer necessary. Use ::get instead. - Make sure the config option you are accessing has specified an `integer` - schema. See the schema section of - https://atom.io/docs/api/latest/Config for more info.''' - parseInt(@get(keyPath)) - - Config::getPositiveInt = (keyPath, defaultValue=0) -> - Grim.deprecate '''Config::getPositiveInt is no longer necessary. Use ::get instead. - Make sure the config option you are accessing has specified an `integer` - schema with `minimum: 1`. See the schema section of - https://atom.io/docs/api/latest/Config for more info.''' - Math.max(@getInt(keyPath), 0) or defaultValue - - Config::toggle = (keyPath) -> - Grim.deprecate 'Config::toggle is no longer supported. Please remove from your code.' - @set(keyPath, not @get(keyPath)) - - Config::unobserve = (keyPath) -> - Grim.deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.' - - Config::addScopedSettings = (source, selector, value, options) -> - Grim.deprecate("Use ::set instead") - settingsBySelector = {} - settingsBySelector[selector] = value - disposable = @scopedSettingsStore.addProperties(source, settingsBySelector, options) - @emitChangeEvent() - new Disposable => - disposable.dispose() - @emitChangeEvent() - - Config::settingsForScopeDescriptor = (scopeDescriptor, keyPath) -> - Grim.deprecate("Use Config::getAll instead") - entries = @getAll(null, scope: scopeDescriptor) - value for {value} in entries when _.valueForKeyPath(value, keyPath)? diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000000..157cc8b69de --- /dev/null +++ b/src/config.js @@ -0,0 +1,1715 @@ +const _ = require('underscore-plus'); +const { Emitter } = require('event-kit'); +const { + getValueAtKeyPath, + setValueAtKeyPath, + deleteValueAtKeyPath, + pushKeyPath, + splitKeyPath +} = require('key-path-helpers'); +const Color = require('./color'); +const ScopedPropertyStore = require('scoped-property-store'); +const ScopeDescriptor = require('./scope-descriptor'); + +const schemaEnforcers = {}; + +// Essential: Used to access all of Atom's configuration details. +// +// An instance of this class is always available as the `atom.config` global. +// +// ## Getting and setting config settings. +// +// ```coffee +// # Note that with no value set, ::get returns the setting's default value. +// atom.config.get('my-package.myKey') # -> 'defaultValue' +// +// atom.config.set('my-package.myKey', 'value') +// atom.config.get('my-package.myKey') # -> 'value' +// ``` +// +// You may want to watch for changes. Use {::observe} to catch changes to the setting. +// +// ```coffee +// atom.config.set('my-package.myKey', 'value') +// atom.config.observe 'my-package.myKey', (newValue) -> +// # `observe` calls immediately and every time the value is changed +// console.log 'My configuration changed:', newValue +// ``` +// +// If you want a notification only when the value changes, use {::onDidChange}. +// +// ```coffee +// atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) -> +// console.log 'My configuration changed:', newValue, oldValue +// ``` +// +// ### Value Coercion +// +// Config settings each have a type specified by way of a +// [schema](json-schema.org). For example we might want an integer setting that only +// allows integers greater than `0`: +// +// ```coffee +// # When no value has been set, `::get` returns the setting's default value +// atom.config.get('my-package.anInt') # -> 12 +// +// # The string will be coerced to the integer 123 +// atom.config.set('my-package.anInt', '123') +// atom.config.get('my-package.anInt') # -> 123 +// +// # The string will be coerced to an integer, but it must be greater than 0, so is set to 1 +// atom.config.set('my-package.anInt', '-20') +// atom.config.get('my-package.anInt') # -> 1 +// ``` +// +// ## Defining settings for your package +// +// Define a schema under a `config` key in your package main. +// +// ```coffee +// module.exports = +// # Your config schema +// config: +// someInt: +// type: 'integer' +// default: 23 +// minimum: 1 +// +// activate: (state) -> # ... +// # ... +// ``` +// +// See [package docs](http://flight-manual.atom.io/hacking-atom/sections/package-word-count/) for +// more info. +// +// ## Config Schemas +// +// We use [json schema](http://json-schema.org) which allows you to define your value's +// default, the type it should be, etc. A simple example: +// +// ```coffee +// # We want to provide an `enableThing`, and a `thingVolume` +// config: +// enableThing: +// type: 'boolean' +// default: false +// thingVolume: +// type: 'integer' +// default: 5 +// minimum: 1 +// maximum: 11 +// ``` +// +// The type keyword allows for type coercion and validation. If a `thingVolume` is +// set to a string `'10'`, it will be coerced into an integer. +// +// ```coffee +// atom.config.set('my-package.thingVolume', '10') +// atom.config.get('my-package.thingVolume') # -> 10 +// +// # It respects the min / max +// atom.config.set('my-package.thingVolume', '400') +// atom.config.get('my-package.thingVolume') # -> 11 +// +// # If it cannot be coerced, the value will not be set +// atom.config.set('my-package.thingVolume', 'cats') +// atom.config.get('my-package.thingVolume') # -> 11 +// ``` +// +// ### Supported Types +// +// The `type` keyword can be a string with any one of the following. You can also +// chain them by specifying multiple in an an array. For example +// +// ```coffee +// config: +// someSetting: +// type: ['boolean', 'integer'] +// default: 5 +// +// # Then +// atom.config.set('my-package.someSetting', 'true') +// atom.config.get('my-package.someSetting') # -> true +// +// atom.config.set('my-package.someSetting', '12') +// atom.config.get('my-package.someSetting') # -> 12 +// ``` +// +// #### string +// +// Values must be a string. +// +// ```coffee +// config: +// someSetting: +// type: 'string' +// default: 'hello' +// ``` +// +// #### integer +// +// Values will be coerced into integer. Supports the (optional) `minimum` and +// `maximum` keys. +// +// ```coffee +// config: +// someSetting: +// type: 'integer' +// default: 5 +// minimum: 1 +// maximum: 11 +// ``` +// +// #### number +// +// Values will be coerced into a number, including real numbers. Supports the +// (optional) `minimum` and `maximum` keys. +// +// ```coffee +// config: +// someSetting: +// type: 'number' +// default: 5.3 +// minimum: 1.5 +// maximum: 11.5 +// ``` +// +// #### boolean +// +// Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into +// a boolean. Numbers, arrays, objects, and anything else will not be coerced. +// +// ```coffee +// config: +// someSetting: +// type: 'boolean' +// default: false +// ``` +// +// #### array +// +// Value must be an Array. The types of the values can be specified by a +// subschema in the `items` key. +// +// ```coffee +// config: +// someSetting: +// type: 'array' +// default: [1, 2, 3] +// items: +// type: 'integer' +// minimum: 1.5 +// maximum: 11.5 +// ``` +// +// #### color +// +// Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha` +// properties that all have numeric values. `red`, `green`, `blue` will be in +// the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any +// valid CSS color format such as `#abc`, `#abcdef`, `white`, +// `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`. +// +// ```coffee +// config: +// someSetting: +// type: 'color' +// default: 'white' +// ``` +// +// #### object / Grouping other types +// +// A config setting with the type `object` allows grouping a set of config +// settings. The group will be visually separated and has its own group headline. +// The sub options must be listed under a `properties` key. +// +// ```coffee +// config: +// someSetting: +// type: 'object' +// properties: +// myChildIntOption: +// type: 'integer' +// minimum: 1.5 +// maximum: 11.5 +// ``` +// +// ### Other Supported Keys +// +// #### enum +// +// All types support an `enum` key, which lets you specify all the values the +// setting can take. `enum` may be an array of allowed values (of the specified +// type), or an array of objects with `value` and `description` properties, where +// the `value` is an allowed value, and the `description` is a descriptive string +// used in the settings view. +// +// In this example, the setting must be one of the 4 integers: +// +// ```coffee +// config: +// someSetting: +// type: 'integer' +// default: 4 +// enum: [2, 4, 6, 8] +// ``` +// +// In this example, the setting must be either 'foo' or 'bar', which are +// presented using the provided descriptions in the settings pane: +// +// ```coffee +// config: +// someSetting: +// type: 'string' +// default: 'foo' +// enum: [ +// {value: 'foo', description: 'Foo mode. You want this.'} +// {value: 'bar', description: 'Bar mode. Nobody wants that!'} +// ] +// ``` +// +// If you only have a few elements, you can display your enum as a list of +// radio buttons in the settings view rather than a select list. To do so, +// specify `radio: true` as a sibling property to the `enum` array. +// +// ```coffee +// config: +// someSetting: +// type: 'string' +// default: 'foo' +// enum: [ +// {value: 'foo', description: 'Foo mode. You want this.'} +// {value: 'bar', description: 'Bar mode. Nobody wants that!'} +// ] +// radio: true +// ``` +// +// Usage: +// +// ```coffee +// atom.config.set('my-package.someSetting', '2') +// atom.config.get('my-package.someSetting') # -> 2 +// +// # will not set values outside of the enum values +// atom.config.set('my-package.someSetting', '3') +// atom.config.get('my-package.someSetting') # -> 2 +// +// # If it cannot be coerced, the value will not be set +// atom.config.set('my-package.someSetting', '4') +// atom.config.get('my-package.someSetting') # -> 4 +// ``` +// +// #### title and description +// +// The settings view will use the `title` and `description` keys to display your +// config setting in a readable way. By default the settings view humanizes your +// config key, so `someSetting` becomes `Some Setting`. In some cases, this is +// confusing for users, and a more descriptive title is useful. +// +// Descriptions will be displayed below the title in the settings view. +// +// For a group of config settings the humanized key or the title and the +// description are used for the group headline. +// +// ```coffee +// config: +// someSetting: +// title: 'Setting Magnitude' +// description: 'This will affect the blah and the other blah' +// type: 'integer' +// default: 4 +// ``` +// +// __Note__: You should strive to be so clear in your naming of the setting that +// you do not need to specify a title or description! +// +// Descriptions allow a subset of +// [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/). +// Specifically, you may use the following in configuration setting descriptions: +// +// * **bold** - `**bold**` +// * *italics* - `*italics*` +// * [links](https://atom.io) - `[links](https://atom.io)` +// * `code spans` - `` `code spans` `` +// * line breaks - `line breaks
` +// * ~~strikethrough~~ - `~~strikethrough~~` +// +// #### order +// +// The settings view orders your settings alphabetically. You can override this +// ordering with the order key. +// +// ```coffee +// config: +// zSetting: +// type: 'integer' +// default: 4 +// order: 1 +// aSetting: +// type: 'integer' +// default: 4 +// order: 2 +// ``` +// +// ## Manipulating values outside your configuration schema +// +// It is possible to manipulate(`get`, `set`, `observe` etc) values that do not +// appear in your configuration schema. For example, if the config schema of the +// package 'some-package' is +// +// ```coffee +// config: +// someSetting: +// type: 'boolean' +// default: false +// ``` +// +// You can still do the following +// +// ```coffee +// let otherSetting = atom.config.get('some-package.otherSetting') +// atom.config.set('some-package.stillAnotherSetting', otherSetting * 5) +// ``` +// +// In other words, if a function asks for a `key-path`, that path doesn't have to +// be described in the config schema for the package or any package. However, as +// highlighted in the best practices section, you are advised against doing the +// above. +// +// ## Best practices +// +// * Don't depend on (or write to) configuration keys outside of your keypath. +// +class Config { + static addSchemaEnforcer(typeName, enforcerFunction) { + if (schemaEnforcers[typeName] == null) { + schemaEnforcers[typeName] = []; + } + return schemaEnforcers[typeName].push(enforcerFunction); + } + + static addSchemaEnforcers(filters) { + for (let typeName in filters) { + const functions = filters[typeName]; + for (let name in functions) { + const enforcerFunction = functions[name]; + this.addSchemaEnforcer(typeName, enforcerFunction); + } + } + } + + static executeSchemaEnforcers(keyPath, value, schema) { + let error = null; + let types = schema.type; + if (!Array.isArray(types)) { + types = [types]; + } + for (let type of types) { + try { + const enforcerFunctions = schemaEnforcers[type].concat( + schemaEnforcers['*'] + ); + for (let enforcer of enforcerFunctions) { + // At some point in one's life, one must call upon an enforcer. + value = enforcer.call(this, keyPath, value, schema); + } + error = null; + break; + } catch (e) { + error = e; + } + } + + if (error != null) { + throw error; + } + return value; + } + + // Created during initialization, available as `atom.config` + constructor(params = {}) { + this.clear(); + this.initialize(params); + } + + initialize({ saveCallback, mainSource, projectHomeSchema }) { + if (saveCallback) { + this.saveCallback = saveCallback; + } + if (mainSource) this.mainSource = mainSource; + if (projectHomeSchema) { + this.schema.properties.core.properties.projectHome = projectHomeSchema; + this.defaultSettings.core.projectHome = projectHomeSchema.default; + } + } + + clear() { + this.emitter = new Emitter(); + this.schema = { + type: 'object', + properties: {} + }; + + this.defaultSettings = {}; + this.settings = {}; + this.projectSettings = {}; + this.projectFile = null; + + this.scopedSettingsStore = new ScopedPropertyStore(); + + this.settingsLoaded = false; + this.transactDepth = 0; + this.pendingOperations = []; + this.legacyScopeAliases = new Map(); + this.requestSave = _.debounce(() => this.save(), 1); + } + + /* + Section: Config Subscription + */ + + // Essential: Add a listener for changes to a given key path. This is different + // than {::onDidChange} in that it will immediately call your callback with the + // current value of the config entry. + // + // ### Examples + // + // You might want to be notified when the themes change. We'll watch + // `core.themes` for changes + // + // ```coffee + // atom.config.observe 'core.themes', (value) -> + // # do stuff with value + // ``` + // + // * `keyPath` {String} name of the key to observe + // * `options` (optional) {Object} + // * `scope` (optional) {ScopeDescriptor} describing a path from + // the root of the syntax tree to a token. Get one by calling + // {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. + // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/) + // for more information. + // * `callback` {Function} to call when the value of the key changes. + // * `value` the new value of the key + // + // Returns a {Disposable} with the following keys on which you can call + // `.dispose()` to unsubscribe. + observe(...args) { + let callback, keyPath, options, scopeDescriptor; + if (args.length === 2) { + [keyPath, callback] = args; + } else if ( + args.length === 3 && + (_.isString(args[0]) && _.isObject(args[1])) + ) { + [keyPath, options, callback] = args; + scopeDescriptor = options.scope; + } else { + console.error( + 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details' + ); + return; + } + + if (scopeDescriptor != null) { + return this.observeScopedKeyPath(scopeDescriptor, keyPath, callback); + } else { + return this.observeKeyPath( + keyPath, + options != null ? options : {}, + callback + ); + } + } + + // Essential: Add a listener for changes to a given key path. If `keyPath` is + // not specified, your callback will be called on changes to any key. + // + // * `keyPath` (optional) {String} name of the key to observe. Must be + // specified if `scopeDescriptor` is specified. + // * `options` (optional) {Object} + // * `scope` (optional) {ScopeDescriptor} describing a path from + // the root of the syntax tree to a token. Get one by calling + // {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. + // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/) + // for more information. + // * `callback` {Function} to call when the value of the key changes. + // * `event` {Object} + // * `newValue` the new value of the key + // * `oldValue` the prior value of the key. + // + // Returns a {Disposable} with the following keys on which you can call + // `.dispose()` to unsubscribe. + onDidChange(...args) { + let callback, keyPath, scopeDescriptor; + if (args.length === 1) { + [callback] = args; + } else if (args.length === 2) { + [keyPath, callback] = args; + } else { + let options; + [keyPath, options, callback] = args; + scopeDescriptor = options.scope; + } + + if (scopeDescriptor != null) { + return this.onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback); + } else { + return this.onDidChangeKeyPath(keyPath, callback); + } + } + + /* + Section: Managing Settings + */ + + // Essential: Retrieves the setting for the given key. + // + // ### Examples + // + // You might want to know what themes are enabled, so check `core.themes` + // + // ```coffee + // atom.config.get('core.themes') + // ``` + // + // With scope descriptors you can get settings within a specific editor + // scope. For example, you might want to know `editor.tabLength` for ruby + // files. + // + // ```coffee + // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + // ``` + // + // This setting in ruby files might be different than the global tabLength setting + // + // ```coffee + // atom.config.get('editor.tabLength') # => 4 + // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + // ``` + // + // You can get the language scope descriptor via + // {TextEditor::getRootScopeDescriptor}. This will get the setting specifically + // for the editor's language. + // + // ```coffee + // atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2 + // ``` + // + // Additionally, you can get the setting at the specific cursor position. + // + // ```coffee + // scopeDescriptor = @editor.getLastCursor().getScopeDescriptor() + // atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2 + // ``` + // + // * `keyPath` The {String} name of the key to retrieve. + // * `options` (optional) {Object} + // * `sources` (optional) {Array} of {String} source names. If provided, only + // values that were associated with these sources during {::set} will be used. + // * `excludeSources` (optional) {Array} of {String} source names. If provided, + // values that were associated with these sources during {::set} will not + // be used. + // * `scope` (optional) {ScopeDescriptor} describing a path from + // the root of the syntax tree to a token. Get one by calling + // {editor.getLastCursor().getScopeDescriptor()} + // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/) + // for more information. + // + // Returns the value from Atom's default settings, the user's configuration + // file in the type specified by the configuration schema. + get(...args) { + let keyPath, options, scope; + if (args.length > 1) { + if (typeof args[0] === 'string' || args[0] == null) { + [keyPath, options] = args; + ({ scope } = options); + } + } else { + [keyPath] = args; + } + + if (scope != null) { + const value = this.getRawScopedValue(scope, keyPath, options); + return value != null ? value : this.getRawValue(keyPath, options); + } else { + return this.getRawValue(keyPath, options); + } + } + + // Extended: Get all of the values for the given key-path, along with their + // associated scope selector. + // + // * `keyPath` The {String} name of the key to retrieve + // * `options` (optional) {Object} see the `options` argument to {::get} + // + // Returns an {Array} of {Object}s with the following keys: + // * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated + // * `value` The value for the key-path + getAll(keyPath, options) { + let globalValue, result, scope; + if (options != null) { + ({ scope } = options); + } + + if (scope != null) { + let legacyScopeDescriptor; + const scopeDescriptor = ScopeDescriptor.fromObject(scope); + result = this.scopedSettingsStore.getAll( + scopeDescriptor.getScopeChain(), + keyPath, + options + ); + legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor( + scopeDescriptor + ); + if (legacyScopeDescriptor) { + result.push( + ...Array.from( + this.scopedSettingsStore.getAll( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + ) || [] + ) + ); + } + } else { + result = []; + } + + globalValue = this.getRawValue(keyPath, options); + if (globalValue) { + result.push({ scopeSelector: '*', value: globalValue }); + } + + return result; + } + + // Essential: Sets the value for a configuration setting. + // + // This value is stored in Atom's internal configuration file. + // + // ### Examples + // + // You might want to change the themes programmatically: + // + // ```coffee + // atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax']) + // ``` + // + // You can also set scoped settings. For example, you might want change the + // `editor.tabLength` only for ruby files. + // + // ```coffee + // atom.config.get('editor.tabLength') # => 4 + // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4 + // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 + // + // # Set ruby to 2 + // atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true + // + // # Notice it's only set to 2 in the case of ruby + // atom.config.get('editor.tabLength') # => 4 + // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 + // ``` + // + // * `keyPath` The {String} name of the key. + // * `value` The value of the setting. Passing `undefined` will revert the + // setting to the default value. + // * `options` (optional) {Object} + // * `scopeSelector` (optional) {String}. eg. '.source.ruby' + // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/) + // for more information. + // * `source` (optional) {String} The name of a file with which the setting + // is associated. Defaults to the user's config file. + // + // Returns a {Boolean} + // * `true` if the value was set. + // * `false` if the value was not able to be coerced to the type specified in the setting's schema. + set(...args) { + let [keyPath, value, options = {}] = args; + + if (!this.settingsLoaded) { + this.pendingOperations.push(() => this.set(keyPath, value, options)); + } + + // We should never use the scoped store to set global settings, since they are kept directly + // in the config object. + const scopeSelector = + options.scopeSelector !== '*' ? options.scopeSelector : undefined; + let source = options.source; + const shouldSave = options.save != null ? options.save : true; + + if (source && !scopeSelector && source !== this.projectFile) { + throw new Error( + "::set with a 'source' and no 'sourceSelector' is not yet implemented!" + ); + } + + if (!source) source = this.mainSource; + + if (value !== undefined) { + try { + value = this.makeValueConformToSchema(keyPath, value); + } catch (e) { + return false; + } + } + + if (scopeSelector != null) { + this.setRawScopedValue(keyPath, value, source, scopeSelector); + } else { + this.setRawValue(keyPath, value, { source }); + } + + if (source === this.mainSource && shouldSave && this.settingsLoaded) { + this.requestSave(); + } + return true; + } + + // Essential: Restore the setting at `keyPath` to its default value. + // + // * `keyPath` The {String} name of the key. + // * `options` (optional) {Object} + // * `scopeSelector` (optional) {String}. See {::set} + // * `source` (optional) {String}. See {::set} + unset(keyPath, options) { + if (!this.settingsLoaded) { + this.pendingOperations.push(() => this.unset(keyPath, options)); + } + + let { scopeSelector, source } = options != null ? options : {}; + if (source == null) { + source = this.mainSource; + } + + if (scopeSelector != null) { + if (keyPath != null) { + let settings = this.scopedSettingsStore.propertiesForSourceAndSelector( + source, + scopeSelector + ); + if (getValueAtKeyPath(settings, keyPath) != null) { + this.scopedSettingsStore.removePropertiesForSourceAndSelector( + source, + scopeSelector + ); + setValueAtKeyPath(settings, keyPath, undefined); + settings = withoutEmptyObjects(settings); + if (settings != null) { + this.set(null, settings, { + scopeSelector, + source, + priority: this.priorityForSource(source) + }); + } + + const configIsReady = + source === this.mainSource && this.settingsLoaded; + if (configIsReady) { + return this.requestSave(); + } + } + } else { + this.scopedSettingsStore.removePropertiesForSourceAndSelector( + source, + scopeSelector + ); + return this.emitChangeEvent(); + } + } else { + for (scopeSelector in this.scopedSettingsStore.propertiesForSource( + source + )) { + this.unset(keyPath, { scopeSelector, source }); + } + if (keyPath != null && source === this.mainSource) { + return this.set( + keyPath, + getValueAtKeyPath(this.defaultSettings, keyPath) + ); + } + } + } + + // Extended: Get an {Array} of all of the `source` {String}s with which + // settings have been added via {::set}. + getSources() { + return _.uniq( + _.pluck(this.scopedSettingsStore.propertySets, 'source') + ).sort(); + } + + // Extended: Retrieve the schema for a specific key path. The schema will tell + // you what type the keyPath expects, and other metadata about the config + // option. + // + // * `keyPath` The {String} name of the key. + // + // Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`. + // Returns `null` when the keyPath has no schema specified, but is accessible + // from the root schema. + getSchema(keyPath) { + const keys = splitKeyPath(keyPath); + let { schema } = this; + for (let key of keys) { + let childSchema; + if (schema.type === 'object') { + childSchema = + schema.properties != null ? schema.properties[key] : undefined; + if (childSchema == null) { + if (isPlainObject(schema.additionalProperties)) { + childSchema = schema.additionalProperties; + } else if (schema.additionalProperties === false) { + return null; + } else { + return { type: 'any' }; + } + } + } else { + return null; + } + schema = childSchema; + } + return schema; + } + + getUserConfigPath() { + return this.mainSource; + } + + // Extended: Suppress calls to handler functions registered with {::onDidChange} + // and {::observe} for the duration of `callback`. After `callback` executes, + // handlers will be called once if the value for their key-path has changed. + // + // * `callback` {Function} to execute while suppressing calls to handlers. + transact(callback) { + this.beginTransaction(); + try { + return callback(); + } finally { + this.endTransaction(); + } + } + + getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor) { + return null; + } + + /* + Section: Internal methods used by core + */ + + // Private: Suppress calls to handler functions registered with {::onDidChange} + // and {::observe} for the duration of the {Promise} returned by `callback`. + // After the {Promise} is either resolved or rejected, handlers will be called + // once if the value for their key-path has changed. + // + // * `callback` {Function} that returns a {Promise}, which will be executed + // while suppressing calls to handlers. + // + // Returns a {Promise} that is either resolved or rejected according to the + // `{Promise}` returned by `callback`. If `callback` throws an error, a + // rejected {Promise} will be returned instead. + transactAsync(callback) { + let endTransaction; + this.beginTransaction(); + try { + endTransaction = fn => (...args) => { + this.endTransaction(); + return fn(...args); + }; + const result = callback(); + return new Promise((resolve, reject) => { + return result + .then(endTransaction(resolve)) + .catch(endTransaction(reject)); + }); + } catch (error) { + this.endTransaction(); + return Promise.reject(error); + } + } + + beginTransaction() { + this.transactDepth++; + } + + endTransaction() { + this.transactDepth--; + this.emitChangeEvent(); + } + + pushAtKeyPath(keyPath, value) { + const left = this.get(keyPath); + const arrayValue = left == null ? [] : left; + const result = arrayValue.push(value); + this.set(keyPath, arrayValue); + return result; + } + + unshiftAtKeyPath(keyPath, value) { + const left = this.get(keyPath); + const arrayValue = left == null ? [] : left; + const result = arrayValue.unshift(value); + this.set(keyPath, arrayValue); + return result; + } + + removeAtKeyPath(keyPath, value) { + const left = this.get(keyPath); + const arrayValue = left == null ? [] : left; + const result = _.remove(arrayValue, value); + this.set(keyPath, arrayValue); + return result; + } + + setSchema(keyPath, schema) { + if (!isPlainObject(schema)) { + throw new Error( + `Error loading schema for ${keyPath}: schemas can only be objects!` + ); + } + + if (schema.type == null) { + throw new Error( + `Error loading schema for ${keyPath}: schema objects must have a type attribute` + ); + } + + let rootSchema = this.schema; + if (keyPath) { + for (let key of splitKeyPath(keyPath)) { + rootSchema.type = 'object'; + if (rootSchema.properties == null) { + rootSchema.properties = {}; + } + const { properties } = rootSchema; + if (properties[key] == null) { + properties[key] = {}; + } + rootSchema = properties[key]; + } + } + + Object.assign(rootSchema, schema); + this.transact(() => { + this.setDefaults(keyPath, this.extractDefaultsFromSchema(schema)); + this.setScopedDefaultsFromSchema(keyPath, schema); + this.resetSettingsForSchemaChange(); + }); + } + + save() { + if (this.saveCallback) { + let allSettings = { '*': this.settings }; + allSettings = Object.assign( + allSettings, + this.scopedSettingsStore.propertiesForSource(this.mainSource) + ); + allSettings = sortObject(allSettings); + this.saveCallback(allSettings); + } + } + + /* + Section: Private methods managing global settings + */ + + resetUserSettings(newSettings, options = {}) { + this._resetSettings(newSettings, options); + } + + _resetSettings(newSettings, options = {}) { + const source = options.source; + newSettings = Object.assign({}, newSettings); + if (newSettings.global != null) { + newSettings['*'] = newSettings.global; + delete newSettings.global; + } + + if (newSettings['*'] != null) { + const scopedSettings = newSettings; + newSettings = newSettings['*']; + delete scopedSettings['*']; + this.resetScopedSettings(scopedSettings, { source }); + } + + return this.transact(() => { + this._clearUnscopedSettingsForSource(source); + this.settingsLoaded = true; + for (let key in newSettings) { + const value = newSettings[key]; + this.set(key, value, { save: false, source }); + } + if (this.pendingOperations.length) { + for (let op of this.pendingOperations) { + op(); + } + this.pendingOperations = []; + } + }); + } + + _clearUnscopedSettingsForSource(source) { + if (source === this.projectFile) { + this.projectSettings = {}; + } else { + this.settings = {}; + } + } + + resetProjectSettings(newSettings, projectFile) { + // Sets the scope and source of all project settings to `path`. + newSettings = Object.assign({}, newSettings); + const oldProjectFile = this.projectFile; + this.projectFile = projectFile; + if (this.projectFile != null) { + this._resetSettings(newSettings, { source: this.projectFile }); + } else { + this.scopedSettingsStore.removePropertiesForSource(oldProjectFile); + this.projectSettings = {}; + } + } + + clearProjectSettings() { + this.resetProjectSettings({}, null); + } + + getRawValue(keyPath, options = {}) { + let value; + if ( + !options.excludeSources || + !options.excludeSources.includes(this.mainSource) + ) { + value = getValueAtKeyPath(this.settings, keyPath); + if (this.projectFile != null) { + const projectValue = getValueAtKeyPath(this.projectSettings, keyPath); + value = projectValue === undefined ? value : projectValue; + } + } + + let defaultValue; + if (!options.sources || options.sources.length === 0) { + defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath); + } + + if (value != null) { + value = this.deepClone(value); + if (isPlainObject(value) && isPlainObject(defaultValue)) { + this.deepDefaults(value, defaultValue); + } + return value; + } else { + return this.deepClone(defaultValue); + } + } + + setRawValue(keyPath, value, options = {}) { + const source = options.source ? options.source : undefined; + const settingsToChange = + source === this.projectFile ? 'projectSettings' : 'settings'; + const defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath); + + if (_.isEqual(defaultValue, value)) { + if (keyPath != null) { + deleteValueAtKeyPath(this[settingsToChange], keyPath); + } else { + this[settingsToChange] = null; + } + } else { + if (keyPath != null) { + setValueAtKeyPath(this[settingsToChange], keyPath, value); + } else { + this[settingsToChange] = value; + } + } + return this.emitChangeEvent(); + } + + observeKeyPath(keyPath, options, callback) { + callback(this.get(keyPath)); + return this.onDidChangeKeyPath(keyPath, event => callback(event.newValue)); + } + + onDidChangeKeyPath(keyPath, callback) { + let oldValue = this.get(keyPath); + return this.emitter.on('did-change', () => { + const newValue = this.get(keyPath); + if (!_.isEqual(oldValue, newValue)) { + const event = { oldValue, newValue }; + oldValue = newValue; + return callback(event); + } + }); + } + + isSubKeyPath(keyPath, subKeyPath) { + if (keyPath == null || subKeyPath == null) { + return false; + } + const pathSubTokens = splitKeyPath(subKeyPath); + const pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length); + return _.isEqual(pathTokens, pathSubTokens); + } + + setRawDefault(keyPath, value) { + setValueAtKeyPath(this.defaultSettings, keyPath, value); + return this.emitChangeEvent(); + } + + setDefaults(keyPath, defaults) { + if (defaults != null && isPlainObject(defaults)) { + const keys = splitKeyPath(keyPath); + this.transact(() => { + const result = []; + for (let key in defaults) { + const childValue = defaults[key]; + if (!defaults.hasOwnProperty(key)) { + continue; + } + result.push( + this.setDefaults(keys.concat([key]).join('.'), childValue) + ); + } + return result; + }); + } else { + try { + defaults = this.makeValueConformToSchema(keyPath, defaults); + this.setRawDefault(keyPath, defaults); + } catch (e) { + console.warn( + `'${keyPath}' could not set the default. Attempted default: ${JSON.stringify( + defaults + )}; Schema: ${JSON.stringify(this.getSchema(keyPath))}` + ); + } + } + } + + deepClone(object) { + if (object instanceof Color) { + return object.clone(); + } else if (Array.isArray(object)) { + return object.map(value => this.deepClone(value)); + } else if (isPlainObject(object)) { + return _.mapObject(object, (key, value) => [key, this.deepClone(value)]); + } else { + return object; + } + } + + deepDefaults(target) { + let result = target; + let i = 0; + while (++i < arguments.length) { + const object = arguments[i]; + if (isPlainObject(result) && isPlainObject(object)) { + for (let key of Object.keys(object)) { + result[key] = this.deepDefaults(result[key], object[key]); + } + } else { + if (result == null) { + result = this.deepClone(object); + } + } + } + return result; + } + + // `schema` will look something like this + // + // ```coffee + // type: 'string' + // default: 'ok' + // scopes: + // '.source.js': + // default: 'omg' + // ``` + setScopedDefaultsFromSchema(keyPath, schema) { + if (schema.scopes != null && isPlainObject(schema.scopes)) { + const scopedDefaults = {}; + for (let scope in schema.scopes) { + const scopeSchema = schema.scopes[scope]; + if (!scopeSchema.hasOwnProperty('default')) { + continue; + } + scopedDefaults[scope] = {}; + setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default); + } + this.scopedSettingsStore.addProperties('schema-default', scopedDefaults); + } + + if ( + schema.type === 'object' && + schema.properties != null && + isPlainObject(schema.properties) + ) { + const keys = splitKeyPath(keyPath); + for (let key in schema.properties) { + const childValue = schema.properties[key]; + if (!schema.properties.hasOwnProperty(key)) { + continue; + } + this.setScopedDefaultsFromSchema( + keys.concat([key]).join('.'), + childValue + ); + } + } + } + + extractDefaultsFromSchema(schema) { + if (schema.default != null) { + return schema.default; + } else if ( + schema.type === 'object' && + schema.properties != null && + isPlainObject(schema.properties) + ) { + const defaults = {}; + const properties = schema.properties || {}; + for (let key in properties) { + const value = properties[key]; + defaults[key] = this.extractDefaultsFromSchema(value); + } + return defaults; + } + } + + makeValueConformToSchema(keyPath, value, options) { + if (options != null ? options.suppressException : undefined) { + try { + return this.makeValueConformToSchema(keyPath, value); + } catch (e) { + return undefined; + } + } else { + let schema; + if ((schema = this.getSchema(keyPath)) == null) { + if (schema === false) { + throw new Error(`Illegal key path ${keyPath}`); + } + } + return this.constructor.executeSchemaEnforcers(keyPath, value, schema); + } + } + + // When the schema is changed / added, there may be values set in the config + // that do not conform to the schema. This will reset make them conform. + resetSettingsForSchemaChange(source) { + if (source == null) { + source = this.mainSource; + } + return this.transact(() => { + this.settings = this.makeValueConformToSchema(null, this.settings, { + suppressException: true + }); + const selectorsAndSettings = this.scopedSettingsStore.propertiesForSource( + source + ); + this.scopedSettingsStore.removePropertiesForSource(source); + for (let scopeSelector in selectorsAndSettings) { + let settings = selectorsAndSettings[scopeSelector]; + settings = this.makeValueConformToSchema(null, settings, { + suppressException: true + }); + this.setRawScopedValue(null, settings, source, scopeSelector); + } + }); + } + + /* + Section: Private Scoped Settings + */ + + priorityForSource(source) { + switch (source) { + case this.mainSource: + return 1000; + case this.projectFile: + return 2000; + default: + return 0; + } + } + + emitChangeEvent() { + if (this.transactDepth <= 0) { + return this.emitter.emit('did-change'); + } + } + + resetScopedSettings(newScopedSettings, options = {}) { + const source = options.source == null ? this.mainSource : options.source; + const priority = this.priorityForSource(source); + this.scopedSettingsStore.removePropertiesForSource(source); + + for (let scopeSelector in newScopedSettings) { + let settings = newScopedSettings[scopeSelector]; + settings = this.makeValueConformToSchema(null, settings, { + suppressException: true + }); + const validatedSettings = {}; + validatedSettings[scopeSelector] = withoutEmptyObjects(settings); + if (validatedSettings[scopeSelector] != null) { + this.scopedSettingsStore.addProperties(source, validatedSettings, { + priority + }); + } + } + + return this.emitChangeEvent(); + } + + setRawScopedValue(keyPath, value, source, selector, options) { + if (keyPath != null) { + const newValue = {}; + setValueAtKeyPath(newValue, keyPath, value); + value = newValue; + } + + const settingsBySelector = {}; + settingsBySelector[selector] = value; + this.scopedSettingsStore.addProperties(source, settingsBySelector, { + priority: this.priorityForSource(source) + }); + return this.emitChangeEvent(); + } + + getRawScopedValue(scopeDescriptor, keyPath, options) { + scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor); + const result = this.scopedSettingsStore.getPropertyValue( + scopeDescriptor.getScopeChain(), + keyPath, + options + ); + + const legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor( + scopeDescriptor + ); + if (result != null) { + return result; + } else if (legacyScopeDescriptor) { + return this.scopedSettingsStore.getPropertyValue( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + ); + } + } + + observeScopedKeyPath(scope, keyPath, callback) { + callback(this.get(keyPath, { scope })); + return this.onDidChangeScopedKeyPath(scope, keyPath, event => + callback(event.newValue) + ); + } + + onDidChangeScopedKeyPath(scope, keyPath, callback) { + let oldValue = this.get(keyPath, { scope }); + return this.emitter.on('did-change', () => { + const newValue = this.get(keyPath, { scope }); + if (!_.isEqual(oldValue, newValue)) { + const event = { oldValue, newValue }; + oldValue = newValue; + callback(event); + } + }); + } +} + +// 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. +// +// Enforcers are run from most specific to least. For a schema with type +// `integer`, all the enforcers for the `integer` type will be run first, in +// order of specification. Then the `*` enforcers will be run, in order of +// specification. +Config.addSchemaEnforcers({ + any: { + coerce(keyPath, value, schema) { + return value; + } + }, + + integer: { + coerce(keyPath, value, schema) { + value = parseInt(value); + if (isNaN(value) || !isFinite(value)) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} cannot be coerced into an int` + ); + } + return value; + } + }, + + number: { + coerce(keyPath, value, schema) { + value = parseFloat(value); + if (isNaN(value) || !isFinite(value)) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} cannot be coerced into a number` + ); + } + return value; + } + }, + + boolean: { + coerce(keyPath, value, schema) { + switch (typeof value) { + case 'string': + if (value.toLowerCase() === 'true') { + return true; + } else if (value.toLowerCase() === 'false') { + return false; + } else { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be a boolean or the string 'true' or 'false'` + ); + } + case 'boolean': + return value; + default: + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be a boolean or the string 'true' or 'false'` + ); + } + } + }, + + string: { + validate(keyPath, value, schema) { + if (typeof value !== 'string') { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be a string` + ); + } + return value; + }, + + validateMaximumLength(keyPath, value, schema) { + if ( + typeof schema.maximumLength === 'number' && + value.length > schema.maximumLength + ) { + return value.slice(0, schema.maximumLength); + } else { + return value; + } + } + }, + + null: { + // null sort of isnt supported. It will just unset in this case + coerce(keyPath, value, schema) { + if (![undefined, null].includes(value)) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be null` + ); + } + return value; + } + }, + + object: { + coerce(keyPath, value, schema) { + if (!isPlainObject(value)) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be an object` + ); + } + if (schema.properties == null) { + return value; + } + + let defaultChildSchema = null; + let allowsAdditionalProperties = true; + if (isPlainObject(schema.additionalProperties)) { + defaultChildSchema = schema.additionalProperties; + } + if (schema.additionalProperties === false) { + allowsAdditionalProperties = false; + } + + const newValue = {}; + for (let prop in value) { + const propValue = value[prop]; + const childSchema = + schema.properties[prop] != null + ? schema.properties[prop] + : defaultChildSchema; + if (childSchema != null) { + try { + newValue[prop] = this.executeSchemaEnforcers( + pushKeyPath(keyPath, prop), + propValue, + childSchema + ); + } catch (error) { + console.warn(`Error setting item in object: ${error.message}`); + } + } else if (allowsAdditionalProperties) { + // Just pass through un-schema'd values + newValue[prop] = propValue; + } else { + console.warn(`Illegal object key: ${keyPath}.${prop}`); + } + } + + return newValue; + } + }, + + array: { + coerce(keyPath, value, schema) { + if (!Array.isArray(value)) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} must be an array` + ); + } + const itemSchema = schema.items; + if (itemSchema != null) { + const newValue = []; + for (let item of value) { + try { + newValue.push( + this.executeSchemaEnforcers(keyPath, item, itemSchema) + ); + } catch (error) { + console.warn(`Error setting item in array: ${error.message}`); + } + } + return newValue; + } else { + return value; + } + } + }, + + color: { + coerce(keyPath, value, schema) { + const color = Color.parse(value); + if (color == null) { + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} cannot be coerced into a color` + ); + } + return color; + } + }, + + '*': { + coerceMinimumAndMaximum(keyPath, value, schema) { + if (typeof value !== 'number') { + return value; + } + if (schema.minimum != null && typeof schema.minimum === 'number') { + value = Math.max(value, schema.minimum); + } + if (schema.maximum != null && typeof schema.maximum === 'number') { + value = Math.min(value, schema.maximum); + } + return value; + }, + + validateEnum(keyPath, value, schema) { + let possibleValues = schema.enum; + + if (Array.isArray(possibleValues)) { + possibleValues = possibleValues.map(value => { + if (value.hasOwnProperty('value')) { + return value.value; + } else { + return value; + } + }); + } + + if ( + possibleValues == null || + !Array.isArray(possibleValues) || + !possibleValues.length + ) { + return value; + } + + for (let possibleValue of possibleValues) { + // Using `isEqual` for possibility of placing enums on array and object schemas + if (_.isEqual(possibleValue, value)) { + return value; + } + } + + throw new Error( + `Validation failed at ${keyPath}, ${JSON.stringify( + value + )} is not one of ${JSON.stringify(possibleValues)}` + ); + } + } +}); + +let isPlainObject = value => + _.isObject(value) && + !Array.isArray(value) && + !_.isFunction(value) && + !_.isString(value) && + !(value instanceof Color); + +let sortObject = value => { + if (!isPlainObject(value)) { + return value; + } + const result = {}; + for (let key of Object.keys(value).sort()) { + result[key] = sortObject(value[key]); + } + return result; +}; + +const withoutEmptyObjects = object => { + let resultObject; + if (isPlainObject(object)) { + for (let key in object) { + const value = object[key]; + const newValue = withoutEmptyObjects(value); + if (newValue != null) { + if (resultObject == null) { + resultObject = {}; + } + resultObject[key] = newValue; + } + } + } else { + resultObject = object; + } + return resultObject; +}; + +module.exports = Config; diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee index 0fdcf4ab684..78c7862c2f7 100644 --- a/src/context-menu-manager.coffee +++ b/src/context-menu-manager.coffee @@ -1,11 +1,12 @@ -_ = require 'underscore-plus' path = require 'path' CSON = require 'season' fs = require 'fs-plus' {calculateSpecificity, validateSelector} = require 'clear-cut' {Disposable} = require 'event-kit' -Grim = require 'grim' +{remote} = require 'electron' MenuHelpers = require './menu-helpers' +{sortMenuItems} = require './menu-sort-helpers' +_ = require 'underscore-plus' platformContextMenu = require('../package.json')?._atomMenu?['context-menu'] @@ -41,15 +42,17 @@ platformContextMenu = require('../package.json')?._atomMenu?['context-menu'] # {::add} for more information. module.exports = class ContextMenuManager - constructor: ({@resourcePath, @devMode}) -> + constructor: ({@keymapManager}) -> @definitions = {'.overlayer': []} # TODO: Remove once color picker package stops touching private data @clear() - atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() + @keymapManager.onDidLoadBundledKeymaps => @loadPlatformItems() + + initialize: ({@resourcePath, @devMode}) -> loadPlatformItems: -> if platformContextMenu? - @add(platformContextMenu) + @add(platformContextMenu, @devMode ? false) else menusDirPath = path.join(@resourcePath, 'menus') platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) @@ -83,49 +86,37 @@ class ContextMenuManager # # * `itemsBySelector` An {Object} whose keys are CSS selectors and whose # values are {Array}s of item {Object}s containing the following keys: - # * `label` (Optional) A {String} containing the menu item's label. - # * `command` (Optional) A {String} containing the command to invoke on the + # * `label` (optional) A {String} containing the menu item's label. + # * `command` (optional) A {String} containing the command to invoke on the # target of the right click that invoked the context menu. - # * `submenu` (Optional) An {Array} of additional items. - # * `type` (Optional) If you want to create a separator, provide an item + # * `enabled` (optional) A {Boolean} indicating whether the menu item + # should be clickable. Disabled menu items typically appear grayed out. + # Defaults to `true`. + # * `submenu` (optional) An {Array} of additional items. + # * `type` (optional) If you want to create a separator, provide an item # with `type: 'separator'` and no other keys. - # * `created` (Optional) A {Function} that is called on the item each time a + # * `visible` (optional) A {Boolean} indicating whether the menu item + # should appear in the menu. Defaults to `true`. + # * `created` (optional) A {Function} that is called on the item each time a # context menu is created via a right click. You can assign properties to # `this` to dynamically compute the command, label, etc. This method is # actually called on a clone of the original item template to prevent state # from leaking across context menu deployments. Called with the following # argument: # * `event` The click event that deployed the context menu. - # * `shouldDisplay` (Optional) A {Function} that is called to determine + # * `shouldDisplay` (optional) A {Function} that is called to determine # whether to display this item on a given context menu deployment. Called # with the following argument: # * `event` The click event that deployed the context menu. - add: (itemsBySelector) -> - if Grim.includeDeprecatedAPIs - # Detect deprecated file path as first argument - if itemsBySelector? and typeof itemsBySelector isnt 'object' - Grim.deprecate """ - `ContextMenuManager::add` has changed to take a single object as its - argument. Please see - https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format for more info. - """ - itemsBySelector = arguments[1] - devMode = arguments[2]?.devMode - - # Detect deprecated format for items object - for key, value of itemsBySelector - unless _.isArray(value) - Grim.deprecate """ - `ContextMenuManager::add` has changed to take a single object as its - argument. Please see - https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format for more info. - """ - itemsBySelector = @convertLegacyItemsBySelector(itemsBySelector, devMode) - + # + # * `id` (internal) A {String} containing the menu item's id. + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # added menu items. + add: (itemsBySelector, throwOnInvalidSelector = true) -> addedItemSets = [] for selector, items of itemsBySelector - validateSelector(selector) + validateSelector(selector) if throwOnInvalidSelector itemSet = new ContextMenuItemSet(selector, items) addedItemSets.push(itemSet) @itemSets.push(itemSet) @@ -149,60 +140,93 @@ class ContextMenuManager for itemSet in matchingItemSets for item in itemSet.items - continue if item.devMode and not @devMode - item = Object.create(item) - if typeof item.shouldDisplay is 'function' - continue unless item.shouldDisplay(event) - item.created?(event) - MenuHelpers.merge(currentTargetItems, item, itemSet.specificity) + itemForEvent = @cloneItemForEvent(item, event) + if itemForEvent + MenuHelpers.merge(currentTargetItems, itemForEvent, itemSet.specificity) for item in currentTargetItems MenuHelpers.merge(template, item, false) currentTarget = currentTarget.parentElement - template - - convertLegacyItemsBySelector: (legacyItemsBySelector, devMode) -> - itemsBySelector = {} - - for selector, commandsByLabel of legacyItemsBySelector - itemsBySelector[selector] = @convertLegacyItems(commandsByLabel, devMode) - - itemsBySelector - - convertLegacyItems: (legacyItems, devMode) -> - items = [] - - for label, commandOrSubmenu of legacyItems - if typeof commandOrSubmenu is 'object' - items.push({label, submenu: @convertLegacyItems(commandOrSubmenu, devMode), devMode}) - else if commandOrSubmenu is '-' - items.push({type: 'separator'}) + @pruneRedundantSeparators(template) + @addAccelerators(template) + + return @sortTemplate(template) + + # Adds an `accelerator` property to items that have key bindings. Electron + # uses this property to surface the relevant keymaps in the context menu. + addAccelerators: (template) -> + for id, item of template + if item.command + keymaps = @keymapManager.findKeyBindings({command: item.command, target: document.activeElement}) + keystrokes = keymaps?[0]?.keystrokes + if keystrokes + # Electron does not support multi-keystroke accelerators. Therefore, + # when the command maps to a multi-stroke key binding, show the + # keystrokes next to the item's label. + if keystrokes.includes(' ') + item.label += " [#{_.humanizeKeystroke(keystrokes)}]" + else + item.accelerator = MenuHelpers.acceleratorForKeystroke(keystrokes) + if Array.isArray(item.submenu) + @addAccelerators(item.submenu) + + pruneRedundantSeparators: (menu) -> + keepNextItemIfSeparator = false + index = 0 + while index < menu.length + if menu[index].type is 'separator' + if not keepNextItemIfSeparator or index is menu.length - 1 + menu.splice(index, 1) + else + index++ else - items.push({label, command: commandOrSubmenu, devMode}) - - items + keepNextItemIfSeparator = true + index++ + + sortTemplate: (template) -> + template = sortMenuItems(template) + for id, item of template + if Array.isArray(item.submenu) + item.submenu = @sortTemplate(item.submenu) + return template + + # Returns an object compatible with `::add()` or `null`. + cloneItemForEvent: (item, event) -> + return null if item.devMode and not @devMode + item = Object.create(item) + if typeof item.shouldDisplay is 'function' + return null unless item.shouldDisplay(event) + item.created?(event) + if Array.isArray(item.submenu) + item.submenu = item.submenu + .map((submenuItem) => @cloneItemForEvent(submenuItem, event)) + .filter((submenuItem) -> submenuItem isnt null) + return item showForEvent: (event) -> @activeElement = event.target menuTemplate = @templateForEvent(event) return unless menuTemplate?.length > 0 - atom.getCurrentWindow().emit('context-menu', menuTemplate) + remote.getCurrentWindow().emit('context-menu', menuTemplate) return clear: -> @activeElement = null @itemSets = [] - @add 'atom-workspace': [{ - label: 'Inspect Element' - command: 'application:inspect' - devMode: true - created: (event) -> - {pageX, pageY} = event - @commandDetail = {x: pageX, y: pageY} - }] + inspectElement = { + 'atom-workspace': [{ + label: 'Inspect Element' + command: 'application:inspect' + devMode: true + created: (event) -> + {pageX, pageY} = event + @commandDetail = {x: pageX, y: pageY} + }] + } + @add(inspectElement, false) class ContextMenuItemSet constructor: (@selector, @items) -> diff --git a/src/core-uri-handlers.js b/src/core-uri-handlers.js new file mode 100644 index 00000000000..d09a9af980e --- /dev/null +++ b/src/core-uri-handlers.js @@ -0,0 +1,55 @@ +const fs = require('fs-plus'); + +// 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: getLineColNumber(line), + initialColumn: getLineColNumber(column), + searchAllPanes: true + }); +} + +function windowShouldOpenFile({ query }) { + const { filename } = query; + const stat = fs.statSyncNoException(filename); + + return win => + win.containsLocation({ + pathToOpen: filename, + exists: Boolean(stat), + isFile: stat.isFile(), + isDirectory: stat.isDirectory() + }); +} + +const ROUTER = { + '/open/file': { handler: openFile, getWindowPredicate: windowShouldOpenFile } +}; + +module.exports = { + create(atomEnv) { + return function coreURIHandler(parsed) { + const config = ROUTER[parsed.pathname]; + if (config) { + config.handler(atomEnv, parsed); + } + }; + }, + + windowPredicate(parsed) { + const config = ROUTER[parsed.pathname]; + if (config && config.getWindowPredicate) { + return config.getWindowPredicate(parsed); + } else { + return () => true; + } + } +}; diff --git a/src/crash-reporter-start.js b/src/crash-reporter-start.js new file mode 100644 index 00000000000..d34fc664a43 --- /dev/null +++ b/src/crash-reporter-start.js @@ -0,0 +1,17 @@ +module.exports = function(params) { + const { crashReporter } = require('electron'); + const os = require('os'); + const platformRelease = os.release(); + const arch = os.arch(); + const { uploadToServer, releaseChannel } = params; + + const parsedUploadToServer = uploadToServer !== null ? uploadToServer : false; + + crashReporter.start({ + productName: 'Atom', + companyName: 'GitHub', + submitURL: 'https://atom.io/crash_reports', + parsedUploadToServer, + extra: { platformRelease, arch, releaseChannel } + }); +}; diff --git a/src/cursor.coffee b/src/cursor.coffee deleted file mode 100644 index b54cc6bcdfd..00000000000 --- a/src/cursor.coffee +++ /dev/null @@ -1,692 +0,0 @@ -{Point, Range} = require 'text-buffer' -{Emitter} = require 'event-kit' -_ = require 'underscore-plus' -Grim = require 'grim' -Model = require './model' - -# Extended: The `Cursor` class represents the little blinking line identifying -# where text can be inserted. -# -# Cursors belong to {TextEditor}s and have some metadata attached in the form -# of a {Marker}. -module.exports = -class Cursor extends Model - screenPosition: null - bufferPosition: null - goalColumn: null - visible: true - - # Instantiated by a {TextEditor} - constructor: ({@editor, @marker, id}) -> - @emitter = new Emitter - - @assignId(id) - @updateVisibility() - @marker.onDidChange (e) => - @updateVisibility() - {oldHeadScreenPosition, newHeadScreenPosition} = e - {oldHeadBufferPosition, newHeadBufferPosition} = e - {textChanged} = e - return if oldHeadScreenPosition.isEqual(newHeadScreenPosition) - - @goalColumn = null - - movedEvent = - oldBufferPosition: oldHeadBufferPosition - oldScreenPosition: oldHeadScreenPosition - newBufferPosition: newHeadBufferPosition - newScreenPosition: newHeadScreenPosition - textChanged: textChanged - cursor: this - - @emit 'moved', movedEvent if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-position', movedEvent - @editor.cursorMoved(movedEvent) - @marker.onDidDestroy => - @destroyed = true - @editor.removeCursor(this) - @emit 'destroyed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-destroy' - @emitter.dispose() - - destroy: -> - @marker.destroy() - - ### - Section: Event Subscription - ### - - # Public: Calls your `callback` when the cursor has been moved. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferPosition` {Point} - # * `oldScreenPosition` {Point} - # * `newBufferPosition` {Point} - # * `newScreenPosition` {Point} - # * `textChanged` {Boolean} - # * `Cursor` {Cursor} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePosition: (callback) -> - @emitter.on 'did-change-position', callback - - # Public: Calls your `callback` when the cursor is destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - # Public: Calls your `callback` when the cursor's visibility has changed - # - # * `callback` {Function} - # * `visibility` {Boolean} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeVisibility: (callback) -> - @emitter.on 'did-change-visibility', callback - - on: (eventName) -> - return unless Grim.includeDeprecatedAPIs - - switch eventName - when 'moved' - Grim.deprecate("Use Cursor::onDidChangePosition instead") - when 'destroyed' - Grim.deprecate("Use Cursor::onDidDestroy instead") - else - Grim.deprecate("::on is no longer supported. Use the event subscription methods instead") - super - - ### - Section: Managing Cursor Position - ### - - # Public: Moves a cursor to a given screen position. - # - # * `screenPosition` {Array} of two numbers: the screen row, and the screen column. - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever - # the cursor moves to. - setScreenPosition: (screenPosition, options={}) -> - @changePosition options, => - @marker.setHeadScreenPosition(screenPosition, options) - - # Public: Returns the screen position of the cursor as an Array. - getScreenPosition: -> - @marker.getHeadScreenPosition() - - # Public: Moves a cursor to a given buffer position. - # - # * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # position. Defaults to `true` if this is the most recently added cursor, - # `false` otherwise. - setBufferPosition: (bufferPosition, options={}) -> - @changePosition options, => - @marker.setHeadBufferPosition(bufferPosition, options) - - # Public: Returns the current buffer position as an Array. - getBufferPosition: -> - @marker.getHeadBufferPosition() - - # Public: Returns the cursor's current screen row. - getScreenRow: -> - @getScreenPosition().row - - # Public: Returns the cursor's current screen column. - getScreenColumn: -> - @getScreenPosition().column - - # Public: Retrieves the cursor's current buffer row. - getBufferRow: -> - @getBufferPosition().row - - # Public: Returns the cursor's current buffer column. - getBufferColumn: -> - @getBufferPosition().column - - # Public: Returns the cursor's current buffer row of text excluding its line - # ending. - getCurrentBufferLine: -> - @editor.lineTextForBufferRow(@getBufferRow()) - - # Public: Returns whether the cursor is at the start of a line. - isAtBeginningOfLine: -> - @getBufferPosition().column is 0 - - # Public: Returns whether the cursor is on the line return character. - isAtEndOfLine: -> - @getBufferPosition().isEqual(@getCurrentLineBufferRange().end) - - ### - Section: Cursor Position Details - ### - - # Public: Returns the underlying {Marker} for the cursor. - # Useful with overlay {Decoration}s. - getMarker: -> @marker - - # Public: Identifies if the cursor is surrounded by whitespace. - # - # "Surrounded" here means that the character directly before and after the - # cursor are both whitespace. - # - # Returns a {Boolean}. - isSurroundedByWhitespace: -> - {row, column} = @getBufferPosition() - range = [[row, column - 1], [row, column + 1]] - /^\s+$/.test @editor.getTextInBufferRange(range) - - # Public: Returns whether the cursor is currently between a word and non-word - # character. The non-word characters are defined by the - # `editor.nonWordCharacters` config value. - # - # This method returns false if the character before or after the cursor is - # whitespace. - # - # Returns a Boolean. - isBetweenWordAndNonWord: -> - return false if @isAtBeginningOfLine() or @isAtEndOfLine() - - {row, column} = @getBufferPosition() - range = [[row, column - 1], [row, column + 1]] - [before, after] = @editor.getTextInBufferRange(range) - return false if /\s/.test(before) or /\s/.test(after) - - nonWordCharacters = atom.config.get('editor.nonWordCharacters', scope: @getScopeDescriptor()).split('') - _.contains(nonWordCharacters, before) isnt _.contains(nonWordCharacters, after) - - # Public: Returns whether this cursor is between a word's start and end. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # - # Returns a {Boolean} - isInsideWord: (options) -> - {row, column} = @getBufferPosition() - range = [[row, column], [row, Infinity]] - @editor.getTextInBufferRange(range).search(options?.wordRegex ? @wordRegExp()) is 0 - - # Public: Returns the indentation level of the current line. - getIndentLevel: -> - if @editor.getSoftTabs() - @getBufferColumn() / @editor.getTabLength() - else - @getBufferColumn() - - # Public: Retrieves the scope descriptor for the cursor's current position. - # - # Returns a {ScopeDescriptor} - getScopeDescriptor: -> - @editor.scopeDescriptorForBufferPosition(@getBufferPosition()) - - # Public: Returns true if this cursor has no non-whitespace characters before - # its current position. - hasPrecedingCharactersOnLine: -> - bufferPosition = @getBufferPosition() - line = @editor.lineTextForBufferRow(bufferPosition.row) - firstCharacterColumn = line.search(/\S/) - - if firstCharacterColumn is -1 - false - else - bufferPosition.column > firstCharacterColumn - - # Public: Identifies if this cursor is the last in the {TextEditor}. - # - # "Last" is defined as the most recently added cursor. - # - # Returns a {Boolean}. - isLastCursor: -> - this is @editor.getLastCursor() - - ### - Section: Moving the Cursor - ### - - # Public: Moves the cursor up one screen row. - # - # * `rowCount` (optional) {Number} number of rows to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveUp: (rowCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - {row, column} = range.start - else - {row, column} = @getScreenPosition() - - column = @goalColumn if @goalColumn? - @setScreenPosition({row: row - rowCount, column: column}, skipSoftWrapIndentation: true) - @goalColumn = column - - # Public: Moves the cursor down one screen row. - # - # * `rowCount` (optional) {Number} number of rows to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveDown: (rowCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - {row, column} = range.end - else - {row, column} = @getScreenPosition() - - column = @goalColumn if @goalColumn? - @setScreenPosition({row: row + rowCount, column: column}, skipSoftWrapIndentation: true) - @goalColumn = column - - # Public: Moves the cursor left one screen column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the left of the selection if a - # selection exists. - moveLeft: (columnCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - @setScreenPosition(range.start) - else - {row, column} = @getScreenPosition() - - while columnCount > column and row > 0 - columnCount -= column - column = @editor.lineTextForScreenRow(--row).length - columnCount-- # subtract 1 for the row move - - column = column - columnCount - @setScreenPosition({row, column}, clip: 'backward') - - # Public: Moves the cursor right one screen column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - # * `options` (optional) {Object} with the following keys: - # * `moveToEndOfSelection` if true, move to the right of the selection if a - # selection exists. - moveRight: (columnCount=1, {moveToEndOfSelection}={}) -> - range = @marker.getScreenRange() - if moveToEndOfSelection and not range.isEmpty() - @setScreenPosition(range.end) - else - {row, column} = @getScreenPosition() - maxLines = @editor.getScreenLineCount() - rowLength = @editor.lineTextForScreenRow(row).length - columnsRemainingInLine = rowLength - column - - while columnCount > columnsRemainingInLine and row < maxLines - 1 - columnCount -= columnsRemainingInLine - columnCount-- # subtract 1 for the row move - - column = 0 - rowLength = @editor.lineTextForScreenRow(++row).length - columnsRemainingInLine = rowLength - - column = column + columnCount - @setScreenPosition({row, column}, clip: 'forward', wrapBeyondNewlines: true, wrapAtSoftNewlines: true) - - # Public: Moves the cursor to the top of the buffer. - moveToTop: -> - @setBufferPosition([0,0]) - - # Public: Moves the cursor to the bottom of the buffer. - moveToBottom: -> - @setBufferPosition(@editor.getEofBufferPosition()) - - # Public: Moves the cursor to the beginning of the line. - moveToBeginningOfScreenLine: -> - @setScreenPosition([@getScreenRow(), 0]) - - # Public: Moves the cursor to the beginning of the buffer line. - moveToBeginningOfLine: -> - @setBufferPosition([@getBufferRow(), 0]) - - # Public: Moves the cursor to the beginning of the first character in the - # line. - moveToFirstCharacterOfLine: -> - screenRow = @getScreenRow() - screenLineStart = @editor.clipScreenPosition([screenRow, 0], skipSoftWrapIndentation: true) - screenLineEnd = [screenRow, Infinity] - screenLineBufferRange = @editor.bufferRangeForScreenRange([screenLineStart, screenLineEnd]) - - firstCharacterColumn = null - @editor.scanInBufferRange /\S/, screenLineBufferRange, ({range, stop}) -> - firstCharacterColumn = range.start.column - stop() - - if firstCharacterColumn? and firstCharacterColumn isnt @getBufferColumn() - targetBufferColumn = firstCharacterColumn - else - targetBufferColumn = screenLineBufferRange.start.column - - @setBufferPosition([screenLineBufferRange.start.row, targetBufferColumn]) - - # Public: Moves the cursor to the end of the line. - moveToEndOfScreenLine: -> - @setScreenPosition([@getScreenRow(), Infinity]) - - # Public: Moves the cursor to the end of the buffer line. - moveToEndOfLine: -> - @setBufferPosition([@getBufferRow(), Infinity]) - - # Public: Moves the cursor to the beginning of the word. - moveToBeginningOfWord: -> - @setBufferPosition(@getBeginningOfCurrentWordBufferPosition()) - - # Public: Moves the cursor to the end of the word. - moveToEndOfWord: -> - if position = @getEndOfCurrentWordBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the next word. - moveToBeginningOfNextWord: -> - if position = @getBeginningOfNextWordBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the previous word boundary. - moveToPreviousWordBoundary: -> - if position = @getPreviousWordBoundaryBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the next word boundary. - moveToNextWordBoundary: -> - if position = @getNextWordBoundaryBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the buffer line, skipping all - # whitespace. - skipLeadingWhitespace: -> - position = @getBufferPosition() - scanRange = @getCurrentLineBufferRange() - endOfLeadingWhitespace = null - @editor.scanInBufferRange /^[ \t]*/, scanRange, ({range}) -> - endOfLeadingWhitespace = range.end - - @setBufferPosition(endOfLeadingWhitespace) if endOfLeadingWhitespace.isGreaterThan(position) - - # Public: Moves the cursor to the beginning of the next paragraph - moveToBeginningOfNextParagraph: -> - if position = @getBeginningOfNextParagraphBufferPosition() - @setBufferPosition(position) - - # Public: Moves the cursor to the beginning of the previous paragraph - moveToBeginningOfPreviousParagraph: -> - if position = @getBeginningOfPreviousParagraphBufferPosition() - @setBufferPosition(position) - - ### - Section: Local Positions and Ranges - ### - - # Public: Returns buffer position of previous word boundary. It might be on - # the current word, or the previous word. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - getPreviousWordBoundaryBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - previousNonBlankRow = @editor.buffer.previousNonBlankRow(currentBufferPosition.row) - scanRange = [[previousNonBlankRow, 0], currentBufferPosition] - - beginningOfWordPosition = null - @editor.backwardsScanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - if range.start.row < currentBufferPosition.row and currentBufferPosition.column > 0 - # force it to stop at the beginning of each line - beginningOfWordPosition = new Point(currentBufferPosition.row, 0) - else if range.end.isLessThan(currentBufferPosition) - beginningOfWordPosition = range.end - else - beginningOfWordPosition = range.start - - if not beginningOfWordPosition?.isEqual(currentBufferPosition) - stop() - - beginningOfWordPosition or currentBufferPosition - - # Public: Returns buffer position of the next word boundary. It might be on - # the current word, or the previous word. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - getNextWordBoundaryBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - scanRange = [currentBufferPosition, @editor.getEofBufferPosition()] - - endOfWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - if range.start.row > currentBufferPosition.row - # force it to stop at the beginning of each line - endOfWordPosition = new Point(range.start.row, 0) - else if range.start.isGreaterThan(currentBufferPosition) - endOfWordPosition = range.start - else - endOfWordPosition = range.end - - if not endOfWordPosition?.isEqual(currentBufferPosition) - stop() - - endOfWordPosition or currentBufferPosition - - # Public: Retrieves the buffer position of where the current word starts. - # - # * `options` (optional) An {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # * `includeNonWordCharacters` A {Boolean} indicating whether to include - # non-word characters in the default word regex. - # Has no effect if wordRegex is set. - # * `allowPrevious` A {Boolean} indicating whether the beginning of the - # previous word can be returned. - # - # Returns a {Range}. - getBeginningOfCurrentWordBufferPosition: (options = {}) -> - allowPrevious = options.allowPrevious ? true - currentBufferPosition = @getBufferPosition() - previousNonBlankRow = @editor.buffer.previousNonBlankRow(currentBufferPosition.row) ? 0 - scanRange = [[previousNonBlankRow, 0], currentBufferPosition] - - beginningOfWordPosition = null - @editor.backwardsScanInBufferRange (options.wordRegex ? @wordRegExp(options)), scanRange, ({range, stop}) -> - if range.end.isGreaterThanOrEqual(currentBufferPosition) or allowPrevious - beginningOfWordPosition = range.start - if not beginningOfWordPosition?.isEqual(currentBufferPosition) - stop() - - if beginningOfWordPosition? - beginningOfWordPosition - else if allowPrevious - new Point(0, 0) - else - currentBufferPosition - - # Public: Retrieves the buffer position of where the current word ends. - # - # * `options` (optional) {Object} with the following keys: - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}) - # * `includeNonWordCharacters` A Boolean indicating whether to include - # non-word characters in the default word regex. Has no effect if - # wordRegex is set. - # - # Returns a {Range}. - getEndOfCurrentWordBufferPosition: (options = {}) -> - allowNext = options.allowNext ? true - currentBufferPosition = @getBufferPosition() - scanRange = [currentBufferPosition, @editor.getEofBufferPosition()] - - endOfWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp(options)), scanRange, ({range, stop}) -> - if range.start.isLessThanOrEqual(currentBufferPosition) or allowNext - endOfWordPosition = range.end - if not endOfWordPosition?.isEqual(currentBufferPosition) - stop() - - endOfWordPosition ? currentBufferPosition - - # Public: Retrieves the buffer position of where the next word starts. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - # - # Returns a {Range} - getBeginningOfNextWordBufferPosition: (options = {}) -> - currentBufferPosition = @getBufferPosition() - start = if @isInsideWord(options) then @getEndOfCurrentWordBufferPosition(options) else currentBufferPosition - scanRange = [start, @editor.getEofBufferPosition()] - - beginningOfNextWordPosition = null - @editor.scanInBufferRange (options.wordRegex ? @wordRegExp()), scanRange, ({range, stop}) -> - beginningOfNextWordPosition = range.start - stop() - - beginningOfNextWordPosition or currentBufferPosition - - # Public: Returns the buffer Range occupied by the word located under the cursor. - # - # * `options` (optional) {Object} - # * `wordRegex` A {RegExp} indicating what constitutes a "word" - # (default: {::wordRegExp}). - getCurrentWordBufferRange: (options={}) -> - startOptions = _.extend(_.clone(options), allowPrevious: false) - endOptions = _.extend(_.clone(options), allowNext: false) - new Range(@getBeginningOfCurrentWordBufferPosition(startOptions), @getEndOfCurrentWordBufferPosition(endOptions)) - - # Public: Returns the buffer Range for the current line. - # - # * `options` (optional) {Object} - # * `includeNewline` A {Boolean} which controls whether the Range should - # include the newline. - getCurrentLineBufferRange: (options) -> - @editor.bufferRangeForBufferRow(@getBufferRow(), options) - - # Public: Retrieves the range for the current paragraph. - # - # A paragraph is defined as a block of text surrounded by empty lines. - # - # Returns a {Range}. - getCurrentParagraphBufferRange: -> - @editor.languageMode.rowRangeForParagraphAtBufferRow(@getBufferRow()) - - # Public: Returns the characters preceding the cursor in the current word. - getCurrentWordPrefix: -> - @editor.getTextInBufferRange([@getBeginningOfCurrentWordBufferPosition(), @getBufferPosition()]) - - ### - Section: Visibility - ### - - # Public: Sets whether the cursor is visible. - setVisible: (visible) -> - if @visible isnt visible - @visible = visible - @emit 'visibility-changed', @visible if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-visibility', @visible - - # Public: Returns the visibility of the cursor. - isVisible: -> @visible - - updateVisibility: -> - @setVisible(@marker.getBufferRange().isEmpty()) - - ### - Section: Comparing to another cursor - ### - - # Public: Compare this cursor's buffer position to another cursor's buffer position. - # - # See {Point::compare} for more details. - # - # * `otherCursor`{Cursor} to compare against - compare: (otherCursor) -> - @getBufferPosition().compare(otherCursor.getBufferPosition()) - - ### - Section: Utilities - ### - - # Public: Prevents this cursor from causing scrolling. - clearAutoscroll: -> - - # Public: Deselects the current selection. - clearSelection: (options) -> - @selection?.clear(options) - - # Public: Get the RegExp used by the cursor to determine what a "word" is. - # - # * `options` (optional) {Object} with the following keys: - # * `includeNonWordCharacters` A {Boolean} indicating whether to include - # non-word characters in the regex. (default: true) - # - # Returns a {RegExp}. - wordRegExp: ({includeNonWordCharacters}={}) -> - includeNonWordCharacters ?= true - nonWordCharacters = atom.config.get('editor.nonWordCharacters', scope: @getScopeDescriptor()) - segments = ["^[\t ]*$"] - segments.push("[^\\s#{_.escapeRegExp(nonWordCharacters)}]+") - if includeNonWordCharacters - segments.push("[#{_.escapeRegExp(nonWordCharacters)}]+") - new RegExp(segments.join("|"), "g") - - ### - Section: Private - ### - - changePosition: (options, fn) -> - @clearSelection(autoscroll: false) - fn() - @autoscroll() if options.autoscroll ? @isLastCursor() - - getPixelRect: -> - @editor.pixelRectForScreenRange(@getScreenRange()) - - getScreenRange: -> - {row, column} = @getScreenPosition() - new Range(new Point(row, column), new Point(row, column + 1)) - - autoscroll: (options) -> - @editor.scrollToScreenRange(@getScreenRange(), options) - - getBeginningOfNextParagraphBufferPosition: -> - start = @getBufferPosition() - eof = @editor.getEofBufferPosition() - scanRange = [start, eof] - - {row, column} = eof - position = new Point(row, column - 1) - - @editor.scanInBufferRange /^\n*$/g, scanRange, ({range, stop}) -> - unless range.start.isEqual(start) - position = range.start - stop() - position - - getBeginningOfPreviousParagraphBufferPosition: -> - start = @getBufferPosition() - - {row, column} = start - scanRange = [[row-1, column], [0,0]] - position = new Point(0, 0) - zero = new Point(0,0) - @editor.backwardsScanInBufferRange /^\n*$/g, scanRange, ({range, stop}) -> - unless range.start.isEqual(zero) - position = range.start - stop() - position - -if Grim.includeDeprecatedAPIs - Cursor::getScopes = -> - Grim.deprecate 'Use Cursor::getScopeDescriptor() instead' - @getScopeDescriptor().getScopesArray() - - Cursor::getMoveNextWordBoundaryBufferPosition = (options) -> - Grim.deprecate 'Use `::getNextWordBoundaryBufferPosition(options)` instead' - @getNextWordBoundaryBufferPosition(options) diff --git a/src/cursor.js b/src/cursor.js new file mode 100644 index 00000000000..334c5b0d813 --- /dev/null +++ b/src/cursor.js @@ -0,0 +1,833 @@ +const { Point, Range } = require('text-buffer'); +const { Emitter } = require('event-kit'); +const _ = require('underscore-plus'); +const Model = require('./model'); + +const EmptyLineRegExp = /(\r\n[\t ]*\r\n)|(\n[\t ]*\n)/g; + +// Extended: The `Cursor` class represents the little blinking line identifying +// where text can be inserted. +// +// Cursors belong to {TextEditor}s and have some metadata attached in the form +// of a {DisplayMarker}. +module.exports = class Cursor extends Model { + // Instantiated by a {TextEditor} + constructor(params) { + super(params); + this.editor = params.editor; + this.marker = params.marker; + this.emitter = new Emitter(); + } + + destroy() { + this.marker.destroy(); + } + + /* + Section: Event Subscription + */ + + // Public: Calls your `callback` when the cursor has been moved. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferPosition` {Point} + // * `oldScreenPosition` {Point} + // * `newBufferPosition` {Point} + // * `newScreenPosition` {Point} + // * `textChanged` {Boolean} + // * `cursor` {Cursor} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePosition(callback) { + return this.emitter.on('did-change-position', callback); + } + + // Public: Calls your `callback` when the cursor is destroyed + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Managing Cursor Position + */ + + // Public: Moves a cursor to a given screen position. + // + // * `screenPosition` {Array} of two numbers: the screen row, and the screen column. + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever + // the cursor moves to. + setScreenPosition(screenPosition, options = {}) { + this.changePosition(options, () => { + this.marker.setHeadScreenPosition(screenPosition, options); + }); + } + + // Public: Returns the screen position of the cursor as a {Point}. + getScreenPosition() { + return this.marker.getHeadScreenPosition(); + } + + // Public: Moves a cursor to a given buffer position. + // + // * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // position. Defaults to `true` if this is the most recently added cursor, + // `false` otherwise. + setBufferPosition(bufferPosition, options = {}) { + this.changePosition(options, () => { + this.marker.setHeadBufferPosition(bufferPosition, options); + }); + } + + // Public: Returns the current buffer position as an Array. + getBufferPosition() { + return this.marker.getHeadBufferPosition(); + } + + // Public: Returns the cursor's current screen row. + getScreenRow() { + return this.getScreenPosition().row; + } + + // Public: Returns the cursor's current screen column. + getScreenColumn() { + return this.getScreenPosition().column; + } + + // Public: Retrieves the cursor's current buffer row. + getBufferRow() { + return this.getBufferPosition().row; + } + + // Public: Returns the cursor's current buffer column. + getBufferColumn() { + return this.getBufferPosition().column; + } + + // Public: Returns the cursor's current buffer row of text excluding its line + // ending. + getCurrentBufferLine() { + return this.editor.lineTextForBufferRow(this.getBufferRow()); + } + + // Public: Returns whether the cursor is at the start of a line. + isAtBeginningOfLine() { + return this.getBufferPosition().column === 0; + } + + // Public: Returns whether the cursor is on the line return character. + isAtEndOfLine() { + return this.getBufferPosition().isEqual( + this.getCurrentLineBufferRange().end + ); + } + + /* + Section: Cursor Position Details + */ + + // Public: Returns the underlying {DisplayMarker} for the cursor. + // Useful with overlay {Decoration}s. + getMarker() { + return this.marker; + } + + // Public: Identifies if the cursor is surrounded by whitespace. + // + // "Surrounded" here means that the character directly before and after the + // cursor are both whitespace. + // + // Returns a {Boolean}. + isSurroundedByWhitespace() { + const { row, column } = this.getBufferPosition(); + const range = [[row, column - 1], [row, column + 1]]; + return /^\s+$/.test(this.editor.getTextInBufferRange(range)); + } + + // Public: Returns whether the cursor is currently between a word and non-word + // character. The non-word characters are defined by the + // `editor.nonWordCharacters` config value. + // + // This method returns false if the character before or after the cursor is + // whitespace. + // + // Returns a Boolean. + isBetweenWordAndNonWord() { + if (this.isAtBeginningOfLine() || this.isAtEndOfLine()) return false; + + const { row, column } = this.getBufferPosition(); + const range = [[row, column - 1], [row, column + 1]]; + const text = this.editor.getTextInBufferRange(range); + if (/\s/.test(text[0]) || /\s/.test(text[1])) return false; + + const nonWordCharacters = this.getNonWordCharacters(); + return ( + nonWordCharacters.includes(text[0]) !== + nonWordCharacters.includes(text[1]) + ); + } + + // Public: Returns whether this cursor is between a word's start and end. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // + // Returns a {Boolean} + isInsideWord(options) { + const { row, column } = this.getBufferPosition(); + const range = [[row, column], [row, Infinity]]; + const text = this.editor.getTextInBufferRange(range); + return ( + text.search((options && options.wordRegex) || this.wordRegExp()) === 0 + ); + } + + // Public: Returns the indentation level of the current line. + getIndentLevel() { + if (this.editor.getSoftTabs()) { + return this.getBufferColumn() / this.editor.getTabLength(); + } else { + return this.getBufferColumn(); + } + } + + // Public: Retrieves the scope descriptor for the cursor's current position. + // + // Returns a {ScopeDescriptor} + getScopeDescriptor() { + return this.editor.scopeDescriptorForBufferPosition( + this.getBufferPosition() + ); + } + + // Public: Retrieves the syntax tree scope descriptor for the cursor's current position. + // + // Returns a {ScopeDescriptor} + getSyntaxTreeScopeDescriptor() { + return this.editor.syntaxTreeScopeDescriptorForBufferPosition( + this.getBufferPosition() + ); + } + + // Public: Returns true if this cursor has no non-whitespace characters before + // its current position. + hasPrecedingCharactersOnLine() { + const bufferPosition = this.getBufferPosition(); + const line = this.editor.lineTextForBufferRow(bufferPosition.row); + const firstCharacterColumn = line.search(/\S/); + + if (firstCharacterColumn === -1) { + return false; + } else { + return bufferPosition.column > firstCharacterColumn; + } + } + + // Public: Identifies if this cursor is the last in the {TextEditor}. + // + // "Last" is defined as the most recently added cursor. + // + // Returns a {Boolean}. + isLastCursor() { + return this === this.editor.getLastCursor(); + } + + /* + Section: Moving the Cursor + */ + + // Public: Moves the cursor up one screen row. + // + // * `rowCount` (optional) {Number} number of rows to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveUp(rowCount = 1, { moveToEndOfSelection } = {}) { + let row, column; + const range = this.marker.getScreenRange(); + if (moveToEndOfSelection && !range.isEmpty()) { + ({ row, column } = range.start); + } else { + ({ row, column } = this.getScreenPosition()); + } + + if (this.goalColumn != null) column = this.goalColumn; + this.setScreenPosition( + { row: row - rowCount, column }, + { skipSoftWrapIndentation: true } + ); + this.goalColumn = column; + } + + // Public: Moves the cursor down one screen row. + // + // * `rowCount` (optional) {Number} number of rows to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveDown(rowCount = 1, { moveToEndOfSelection } = {}) { + let row, column; + const range = this.marker.getScreenRange(); + if (moveToEndOfSelection && !range.isEmpty()) { + ({ row, column } = range.end); + } else { + ({ row, column } = this.getScreenPosition()); + } + + if (this.goalColumn != null) column = this.goalColumn; + this.setScreenPosition( + { row: row + rowCount, column }, + { skipSoftWrapIndentation: true } + ); + this.goalColumn = column; + } + + // Public: Moves the cursor left one screen column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the left of the selection if a + // selection exists. + moveLeft(columnCount = 1, { moveToEndOfSelection } = {}) { + const range = this.marker.getScreenRange(); + if (moveToEndOfSelection && !range.isEmpty()) { + this.setScreenPosition(range.start); + } else { + let { row, column } = this.getScreenPosition(); + + while (columnCount > column && row > 0) { + columnCount -= column; + column = this.editor.lineLengthForScreenRow(--row); + columnCount--; // subtract 1 for the row move + } + + column = column - columnCount; + this.setScreenPosition({ row, column }, { clipDirection: 'backward' }); + } + } + + // Public: Moves the cursor right one screen column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + // * `options` (optional) {Object} with the following keys: + // * `moveToEndOfSelection` if true, move to the right of the selection if a + // selection exists. + moveRight(columnCount = 1, { moveToEndOfSelection } = {}) { + const range = this.marker.getScreenRange(); + if (moveToEndOfSelection && !range.isEmpty()) { + this.setScreenPosition(range.end); + } else { + let { row, column } = this.getScreenPosition(); + const maxLines = this.editor.getScreenLineCount(); + let rowLength = this.editor.lineLengthForScreenRow(row); + let columnsRemainingInLine = rowLength - column; + + while (columnCount > columnsRemainingInLine && row < maxLines - 1) { + columnCount -= columnsRemainingInLine; + columnCount--; // subtract 1 for the row move + + column = 0; + rowLength = this.editor.lineLengthForScreenRow(++row); + columnsRemainingInLine = rowLength; + } + + column = column + columnCount; + this.setScreenPosition({ row, column }, { clipDirection: 'forward' }); + } + } + + // Public: Moves the cursor to the top of the buffer. + moveToTop() { + this.setBufferPosition([0, 0]); + } + + // Public: Moves the cursor to the bottom of the buffer. + moveToBottom() { + const column = this.goalColumn; + this.setBufferPosition(this.editor.getEofBufferPosition()); + this.goalColumn = column; + } + + // Public: Moves the cursor to the beginning of the line. + moveToBeginningOfScreenLine() { + this.setScreenPosition([this.getScreenRow(), 0]); + } + + // Public: Moves the cursor to the beginning of the buffer line. + moveToBeginningOfLine() { + this.setBufferPosition([this.getBufferRow(), 0]); + } + + // Public: Moves the cursor to the beginning of the first character in the + // line. + moveToFirstCharacterOfLine() { + let targetBufferColumn; + const screenRow = this.getScreenRow(); + const screenLineStart = this.editor.clipScreenPosition([screenRow, 0], { + skipSoftWrapIndentation: true + }); + const screenLineEnd = [screenRow, Infinity]; + const screenLineBufferRange = this.editor.bufferRangeForScreenRange([ + screenLineStart, + screenLineEnd + ]); + + let firstCharacterColumn = null; + this.editor.scanInBufferRange( + /\S/, + screenLineBufferRange, + ({ range, stop }) => { + firstCharacterColumn = range.start.column; + stop(); + } + ); + + if ( + firstCharacterColumn != null && + firstCharacterColumn !== this.getBufferColumn() + ) { + targetBufferColumn = firstCharacterColumn; + } else { + targetBufferColumn = screenLineBufferRange.start.column; + } + + this.setBufferPosition([ + screenLineBufferRange.start.row, + targetBufferColumn + ]); + } + + // Public: Moves the cursor to the end of the line. + moveToEndOfScreenLine() { + this.setScreenPosition([this.getScreenRow(), Infinity]); + } + + // Public: Moves the cursor to the end of the buffer line. + moveToEndOfLine() { + this.setBufferPosition([this.getBufferRow(), Infinity]); + } + + // Public: Moves the cursor to the beginning of the word. + moveToBeginningOfWord() { + this.setBufferPosition(this.getBeginningOfCurrentWordBufferPosition()); + } + + // Public: Moves the cursor to the end of the word. + moveToEndOfWord() { + const position = this.getEndOfCurrentWordBufferPosition(); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the beginning of the next word. + moveToBeginningOfNextWord() { + const position = this.getBeginningOfNextWordBufferPosition(); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the previous word boundary. + moveToPreviousWordBoundary() { + const position = this.getPreviousWordBoundaryBufferPosition(); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the next word boundary. + moveToNextWordBoundary() { + const position = this.getNextWordBoundaryBufferPosition(); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the previous subword boundary. + moveToPreviousSubwordBoundary() { + const options = { wordRegex: this.subwordRegExp({ backwards: true }) }; + const position = this.getPreviousWordBoundaryBufferPosition(options); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the next subword boundary. + moveToNextSubwordBoundary() { + const options = { wordRegex: this.subwordRegExp() }; + const position = this.getNextWordBoundaryBufferPosition(options); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the beginning of the buffer line, skipping all + // whitespace. + skipLeadingWhitespace() { + const position = this.getBufferPosition(); + const scanRange = this.getCurrentLineBufferRange(); + let endOfLeadingWhitespace = null; + this.editor.scanInBufferRange(/^[ \t]*/, scanRange, ({ range }) => { + endOfLeadingWhitespace = range.end; + }); + + if (endOfLeadingWhitespace.isGreaterThan(position)) + this.setBufferPosition(endOfLeadingWhitespace); + } + + // Public: Moves the cursor to the beginning of the next paragraph + moveToBeginningOfNextParagraph() { + const position = this.getBeginningOfNextParagraphBufferPosition(); + if (position) this.setBufferPosition(position); + } + + // Public: Moves the cursor to the beginning of the previous paragraph + moveToBeginningOfPreviousParagraph() { + const position = this.getBeginningOfPreviousParagraphBufferPosition(); + if (position) this.setBufferPosition(position); + } + + /* + Section: Local Positions and Ranges + */ + + // Public: Returns buffer position of previous word boundary. It might be on + // the current word, or the previous word. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + getPreviousWordBoundaryBufferPosition(options = {}) { + const currentBufferPosition = this.getBufferPosition(); + const previousNonBlankRow = this.editor.buffer.previousNonBlankRow( + currentBufferPosition.row + ); + const scanRange = Range( + Point(previousNonBlankRow || 0, 0), + currentBufferPosition + ); + + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(), + scanRange + ); + + const range = ranges[ranges.length - 1]; + if (range) { + if ( + range.start.row < currentBufferPosition.row && + currentBufferPosition.column > 0 + ) { + return Point(currentBufferPosition.row, 0); + } else if (currentBufferPosition.isGreaterThan(range.end)) { + return Point.fromObject(range.end); + } else { + return Point.fromObject(range.start); + } + } else { + return currentBufferPosition; + } + } + + // Public: Returns buffer position of the next word boundary. It might be on + // the current word, or the previous word. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + getNextWordBoundaryBufferPosition(options = {}) { + const currentBufferPosition = this.getBufferPosition(); + const scanRange = Range( + currentBufferPosition, + this.editor.getEofBufferPosition() + ); + + const range = this.editor.buffer.findInRangeSync( + options.wordRegex || this.wordRegExp(), + scanRange + ); + + if (range) { + if (range.start.row > currentBufferPosition.row) { + return Point(range.start.row, 0); + } else if (currentBufferPosition.isLessThan(range.start)) { + return Point.fromObject(range.start); + } else { + return Point.fromObject(range.end); + } + } else { + return currentBufferPosition; + } + } + + // Public: Retrieves the buffer position of where the current word starts. + // + // * `options` (optional) An {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // * `includeNonWordCharacters` A {Boolean} indicating whether to include + // non-word characters in the default word regex. + // Has no effect if wordRegex is set. + // * `allowPrevious` A {Boolean} indicating whether the beginning of the + // previous word can be returned. + // + // Returns a {Range}. + getBeginningOfCurrentWordBufferPosition(options = {}) { + const allowPrevious = options.allowPrevious !== false; + const position = this.getBufferPosition(); + + const scanRange = allowPrevious + ? new Range(new Point(position.row - 1, 0), position) + : new Range(new Point(position.row, 0), position); + + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(options), + scanRange + ); + + let result; + for (let range of ranges) { + if (position.isLessThanOrEqual(range.start)) break; + if (allowPrevious || position.isLessThanOrEqual(range.end)) + result = Point.fromObject(range.start); + } + + return result || (allowPrevious ? new Point(0, 0) : position); + } + + // Public: Retrieves the buffer position of where the current word ends. + // + // * `options` (optional) {Object} with the following keys: + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}) + // * `includeNonWordCharacters` A Boolean indicating whether to include + // non-word characters in the default word regex. Has no effect if + // wordRegex is set. + // + // Returns a {Range}. + getEndOfCurrentWordBufferPosition(options = {}) { + const allowNext = options.allowNext !== false; + const position = this.getBufferPosition(); + + const scanRange = allowNext + ? new Range(position, new Point(position.row + 2, 0)) + : new Range(position, new Point(position.row, Infinity)); + + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(options), + scanRange + ); + + for (let range of ranges) { + if (position.isLessThan(range.start) && !allowNext) break; + if (position.isLessThan(range.end)) return Point.fromObject(range.end); + } + + return allowNext ? this.editor.getEofBufferPosition() : position; + } + + // Public: Retrieves the buffer position of where the next word starts. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + // + // Returns a {Range} + getBeginningOfNextWordBufferPosition(options = {}) { + const currentBufferPosition = this.getBufferPosition(); + const start = this.isInsideWord(options) + ? this.getEndOfCurrentWordBufferPosition(options) + : currentBufferPosition; + const scanRange = [start, this.editor.getEofBufferPosition()]; + + let beginningOfNextWordPosition; + this.editor.scanInBufferRange( + options.wordRegex || this.wordRegExp(), + scanRange, + ({ range, stop }) => { + beginningOfNextWordPosition = range.start; + stop(); + } + ); + + return beginningOfNextWordPosition || currentBufferPosition; + } + + // Public: Returns the buffer Range occupied by the word located under the cursor. + // + // * `options` (optional) {Object} + // * `wordRegex` A {RegExp} indicating what constitutes a "word" + // (default: {::wordRegExp}). + getCurrentWordBufferRange(options = {}) { + const position = this.getBufferPosition(); + const ranges = this.editor.buffer.findAllInRangeSync( + options.wordRegex || this.wordRegExp(options), + new Range(new Point(position.row, 0), new Point(position.row, Infinity)) + ); + const range = ranges.find( + range => + range.end.column >= position.column && + range.start.column <= position.column + ); + return range ? Range.fromObject(range) : new Range(position, position); + } + + // Public: Returns the buffer Range for the current line. + // + // * `options` (optional) {Object} + // * `includeNewline` A {Boolean} which controls whether the Range should + // include the newline. + getCurrentLineBufferRange(options) { + return this.editor.bufferRangeForBufferRow(this.getBufferRow(), options); + } + + // Public: Retrieves the range for the current paragraph. + // + // A paragraph is defined as a block of text surrounded by empty lines or comments. + // + // Returns a {Range}. + getCurrentParagraphBufferRange() { + return this.editor.rowRangeForParagraphAtBufferRow(this.getBufferRow()); + } + + // Public: Returns the characters preceding the cursor in the current word. + getCurrentWordPrefix() { + return this.editor.getTextInBufferRange([ + this.getBeginningOfCurrentWordBufferPosition(), + this.getBufferPosition() + ]); + } + + /* + Section: Visibility + */ + + /* + Section: Comparing to another cursor + */ + + // Public: Compare this cursor's buffer position to another cursor's buffer position. + // + // See {Point::compare} for more details. + // + // * `otherCursor`{Cursor} to compare against + compare(otherCursor) { + return this.getBufferPosition().compare(otherCursor.getBufferPosition()); + } + + /* + Section: Utilities + */ + + // Public: Deselects the current selection. + clearSelection(options) { + if (this.selection) this.selection.clear(options); + } + + // Public: Get the RegExp used by the cursor to determine what a "word" is. + // + // * `options` (optional) {Object} with the following keys: + // * `includeNonWordCharacters` A {Boolean} indicating whether to include + // non-word characters in the regex. (default: true) + // + // Returns a {RegExp}. + wordRegExp(options) { + const nonWordCharacters = _.escapeRegExp(this.getNonWordCharacters()); + let source = `^[\t ]*$|[^\\s${nonWordCharacters}]+`; + if (!options || options.includeNonWordCharacters !== false) { + source += `|${`[${nonWordCharacters}]+`}`; + } + return new RegExp(source, 'g'); + } + + // Public: Get the RegExp used by the cursor to determine what a "subword" is. + // + // * `options` (optional) {Object} with the following keys: + // * `backwards` A {Boolean} indicating whether to look forwards or backwards + // for the next subword. (default: false) + // + // Returns a {RegExp}. + subwordRegExp(options = {}) { + const nonWordCharacters = this.getNonWordCharacters(); + const lowercaseLetters = 'a-z\\u00DF-\\u00F6\\u00F8-\\u00FF'; + const uppercaseLetters = 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE'; + const snakeCamelSegment = `[${uppercaseLetters}]?[${lowercaseLetters}]+`; + const segments = [ + '^[\t ]+', + '[\t ]+$', + `[${uppercaseLetters}]+(?![${lowercaseLetters}])`, + '\\d+' + ]; + if (options.backwards) { + segments.push(`${snakeCamelSegment}_*`); + segments.push(`[${_.escapeRegExp(nonWordCharacters)}]+\\s*`); + } else { + segments.push(`_*${snakeCamelSegment}`); + segments.push(`\\s*[${_.escapeRegExp(nonWordCharacters)}]+`); + } + segments.push('_+'); + return new RegExp(segments.join('|'), 'g'); + } + + /* + Section: Private + */ + + getNonWordCharacters() { + return this.editor.getNonWordCharacters(this.getBufferPosition()); + } + + changePosition(options, fn) { + this.clearSelection({ autoscroll: false }); + fn(); + this.goalColumn = null; + const autoscroll = + options && options.autoscroll != null + ? options.autoscroll + : this.isLastCursor(); + if (autoscroll) this.autoscroll(); + } + + getScreenRange() { + const { row, column } = this.getScreenPosition(); + return new Range(new Point(row, column), new Point(row, column + 1)); + } + + autoscroll(options = {}) { + options.clip = false; + this.editor.scrollToScreenRange(this.getScreenRange(), options); + } + + getBeginningOfNextParagraphBufferPosition() { + const start = this.getBufferPosition(); + const eof = this.editor.getEofBufferPosition(); + const scanRange = [start, eof]; + + const { row, column } = eof; + let position = new Point(row, column - 1); + + this.editor.scanInBufferRange( + EmptyLineRegExp, + scanRange, + ({ range, stop }) => { + position = range.start.traverse(Point(1, 0)); + if (!position.isEqual(start)) stop(); + } + ); + return position; + } + + getBeginningOfPreviousParagraphBufferPosition() { + const start = this.getBufferPosition(); + + const { row, column } = start; + const scanRange = [[row - 1, column], [0, 0]]; + let position = new Point(0, 0); + this.editor.backwardsScanInBufferRange( + EmptyLineRegExp, + scanRange, + ({ range, stop }) => { + position = range.start.traverse(Point(1, 0)); + if (!position.isEqual(start)) stop(); + } + ); + return position; + } +}; diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee deleted file mode 100644 index 2de4f1ede76..00000000000 --- a/src/cursors-component.coffee +++ /dev/null @@ -1,58 +0,0 @@ -module.exports = -class CursorsComponent - oldState: null - - constructor: -> - @cursorNodesById = {} - @domNode = document.createElement('div') - @domNode.classList.add('cursors') - - getDomNode: -> - @domNode - - updateSync: (state) -> - newState = state.content - @oldState ?= {cursors: {}} - - # update blink class - if newState.cursorsVisible isnt @oldState.cursorsVisible - if newState.cursorsVisible - @domNode.classList.remove 'blink-off' - else - @domNode.classList.add 'blink-off' - @oldState.cursorsVisible = newState.cursorsVisible - - # remove cursors - for id of @oldState.cursors - unless newState.cursors[id]? - @cursorNodesById[id].remove() - delete @cursorNodesById[id] - delete @oldState.cursors[id] - - # add or update cursors - for id, cursorState of newState.cursors - unless @oldState.cursors[id]? - cursorNode = document.createElement('div') - cursorNode.classList.add('cursor') - @cursorNodesById[id] = cursorNode - @domNode.appendChild(cursorNode) - @updateCursorNode(id, cursorState) - - return - - updateCursorNode: (id, newCursorState) -> - cursorNode = @cursorNodesById[id] - oldCursorState = (@oldState.cursors[id] ?= {}) - - if newCursorState.top isnt oldCursorState.top or newCursorState.left isnt oldCursorState.left - cursorNode.style['-webkit-transform'] = "translate(#{newCursorState.left}px, #{newCursorState.top}px)" - oldCursorState.top = newCursorState.top - oldCursorState.left = newCursorState.left - - if newCursorState.height isnt oldCursorState.height - cursorNode.style.height = newCursorState.height + 'px' - oldCursorState.height = newCursorState.height - - if newCursorState.width isnt oldCursorState.width - cursorNode.style.width = newCursorState.width + 'px' - oldCursorState.width = newCursorState.width diff --git a/src/custom-event-mixin.coffee b/src/custom-event-mixin.coffee deleted file mode 100644 index 12785c89c45..00000000000 --- a/src/custom-event-mixin.coffee +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = -CustomEventMixin = - componentWillMount: -> - @customEventListeners = {} - - componentWillUnmount: -> - for name, listeners in @customEventListeners - for listener in listeners - @getDOMNode().removeEventListener(name, listener) - return - - addCustomEventListeners: (customEventListeners) -> - for name, listener of customEventListeners - @customEventListeners[name] ?= [] - @customEventListeners[name].push(listener) - @getDOMNode().addEventListener(name, listener) - return diff --git a/src/custom-gutter-component.coffee b/src/custom-gutter-component.coffee deleted file mode 100644 index 39f5a80a172..00000000000 --- a/src/custom-gutter-component.coffee +++ /dev/null @@ -1,110 +0,0 @@ -{setDimensionsAndBackground} = require './gutter-component-helpers' - -# This class represents a gutter other than the 'line-numbers' gutter. -# The contents of this gutter may be specified by Decorations. - -module.exports = -class CustomGutterComponent - - constructor: ({@gutter}) -> - @decorationNodesById = {} - @decorationItemsById = {} - @visible = true - - @domNode = atom.views.getView(@gutter) - @decorationsNode = @domNode.firstChild - # Clear the contents in case the domNode is being reused. - @decorationsNode.innerHTML = '' - - getDomNode: -> - @domNode - - hideNode: -> - if @visible - @domNode.style.display = 'none' - @visible = false - - showNode: -> - if not @visible - @domNode.style.removeProperty('display') - @visible = true - - # `state` is a subset of the TextEditorPresenter state that is specific - # to this line number gutter. - updateSync: (state) -> - @oldDimensionsAndBackgroundState ?= {} - setDimensionsAndBackground(@oldDimensionsAndBackgroundState, state.styles, @decorationsNode) - - @oldDecorationPositionState ?= {} - decorationState = state.content - - updatedDecorationIds = new Set - for decorationId, decorationInfo of decorationState - updatedDecorationIds.add(decorationId) - existingDecoration = @decorationNodesById[decorationId] - if existingDecoration - @updateDecorationNode(existingDecoration, decorationId, decorationInfo) - else - newNode = @buildDecorationNode(decorationId, decorationInfo) - @decorationNodesById[decorationId] = newNode - @decorationsNode.appendChild(newNode) - - for decorationId, decorationNode of @decorationNodesById - if not updatedDecorationIds.has(decorationId) - decorationNode.remove() - delete @decorationNodesById[decorationId] - delete @decorationItemsById[decorationId] - delete @oldDecorationPositionState[decorationId] - - ### - Section: Private Methods - ### - - # Builds and returns an HTMLElement to represent the specified decoration. - buildDecorationNode: (decorationId, decorationInfo) -> - @oldDecorationPositionState[decorationId] = {} - newNode = document.createElement('div') - newNode.style.position = 'absolute' - @updateDecorationNode(newNode, decorationId, decorationInfo) - newNode - - # Updates the existing HTMLNode with the new decoration info. Attempts to - # minimize changes to the DOM. - updateDecorationNode: (node, decorationId, newDecorationInfo) -> - oldPositionState = @oldDecorationPositionState[decorationId] - - if oldPositionState.top isnt newDecorationInfo.top + 'px' - node.style.top = newDecorationInfo.top + 'px' - oldPositionState.top = newDecorationInfo.top + 'px' - - if oldPositionState.height isnt newDecorationInfo.height + 'px' - node.style.height = newDecorationInfo.height + 'px' - oldPositionState.height = newDecorationInfo.height + 'px' - - if newDecorationInfo.class and not node.classList.contains(newDecorationInfo.class) - node.className = 'decoration' - node.classList.add(newDecorationInfo.class) - else if not newDecorationInfo.class - node.className = 'decoration' - - @setDecorationItem(newDecorationInfo.item, newDecorationInfo.height, decorationId, node) - - # Sets the decorationItem on the decorationNode. - # If `decorationItem` is undefined, the decorationNode's child item will be cleared. - setDecorationItem: (newItem, decorationHeight, decorationId, decorationNode) -> - if newItem isnt @decorationItemsById[decorationId] - while decorationNode.firstChild - decorationNode.removeChild(decorationNode.firstChild) - delete @decorationItemsById[decorationId] - - if newItem - # `item` should be either an HTMLElement or a space-pen View. - newItemNode = null - if newItem instanceof HTMLElement - newItemNode = newItem - else - newItemNode = newItem.element - - newItemNode.style.height = decorationHeight + 'px' - decorationNode.appendChild(newItemNode) - @decorationItemsById[decorationId] = newItem diff --git a/src/decoration-manager.js b/src/decoration-manager.js new file mode 100644 index 00000000000..5bb8b46fccf --- /dev/null +++ b/src/decoration-manager.js @@ -0,0 +1,328 @@ +const { Emitter } = require('event-kit'); +const Decoration = require('./decoration'); +const LayerDecoration = require('./layer-decoration'); + +module.exports = class DecorationManager { + constructor(editor) { + this.editor = editor; + this.displayLayer = this.editor.displayLayer; + + this.emitter = new Emitter(); + this.decorationCountsByLayer = new Map(); + this.markerDecorationCountsByLayer = new Map(); + this.decorationsByMarker = new Map(); + this.layerDecorationsByMarkerLayer = new Map(); + this.overlayDecorations = new Set(); + this.layerUpdateDisposablesByLayer = new WeakMap(); + } + + observeDecorations(callback) { + const decorations = this.getDecorations(); + for (let i = 0; i < decorations.length; i++) { + callback(decorations[i]); + } + return this.onDidAddDecoration(callback); + } + + onDidAddDecoration(callback) { + return this.emitter.on('did-add-decoration', callback); + } + + onDidRemoveDecoration(callback) { + return this.emitter.on('did-remove-decoration', callback); + } + + onDidUpdateDecorations(callback) { + return this.emitter.on('did-update-decorations', callback); + } + + getDecorations(propertyFilter) { + let allDecorations = []; + + this.decorationsByMarker.forEach(decorations => { + decorations.forEach(decoration => allDecorations.push(decoration)); + }); + if (propertyFilter != null) { + allDecorations = allDecorations.filter(function(decoration) { + for (let key in propertyFilter) { + const value = propertyFilter[key]; + if (decoration.properties[key] !== value) return false; + } + return true; + }); + } + return allDecorations; + } + + getLineDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => + decoration.isType('line') + ); + } + + getLineNumberDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => + decoration.isType('line-number') + ); + } + + getHighlightDecorations(propertyFilter) { + return this.getDecorations(propertyFilter).filter(decoration => + decoration.isType('highlight') + ); + } + + getOverlayDecorations(propertyFilter) { + const result = []; + result.push(...Array.from(this.overlayDecorations)); + if (propertyFilter != null) { + return result.filter(function(decoration) { + for (let key in propertyFilter) { + const value = propertyFilter[key]; + if (decoration.properties[key] !== value) { + return false; + } + } + return true; + }); + } else { + return result; + } + } + + decorationPropertiesByMarkerForScreenRowRange(startScreenRow, endScreenRow) { + const decorationPropertiesByMarker = new Map(); + + this.decorationCountsByLayer.forEach((count, markerLayer) => { + const markers = markerLayer.findMarkers({ + intersectsScreenRowRange: [startScreenRow, endScreenRow - 1] + }); + const layerDecorations = this.layerDecorationsByMarkerLayer.get( + markerLayer + ); + const hasMarkerDecorations = + this.markerDecorationCountsByLayer.get(markerLayer) > 0; + + for (let i = 0; i < markers.length; i++) { + const marker = markers[i]; + if (!marker.isValid()) continue; + + let decorationPropertiesForMarker = decorationPropertiesByMarker.get( + marker + ); + if (decorationPropertiesForMarker == null) { + decorationPropertiesForMarker = []; + decorationPropertiesByMarker.set( + marker, + decorationPropertiesForMarker + ); + } + + if (layerDecorations) { + layerDecorations.forEach(layerDecoration => { + const properties = + layerDecoration.getPropertiesForMarker(marker) || + layerDecoration.getProperties(); + decorationPropertiesForMarker.push(properties); + }); + } + + if (hasMarkerDecorations) { + const decorationsForMarker = this.decorationsByMarker.get(marker); + if (decorationsForMarker) { + decorationsForMarker.forEach(decoration => { + decorationPropertiesForMarker.push(decoration.getProperties()); + }); + } + } + } + }); + + return decorationPropertiesByMarker; + } + + decorationsForScreenRowRange(startScreenRow, endScreenRow) { + const decorationsByMarkerId = {}; + for (const layer of this.decorationCountsByLayer.keys()) { + for (const marker of layer.findMarkers({ + intersectsScreenRowRange: [startScreenRow, endScreenRow] + })) { + const decorations = this.decorationsByMarker.get(marker); + if (decorations) { + decorationsByMarkerId[marker.id] = Array.from(decorations); + } + } + } + return decorationsByMarkerId; + } + + decorationsStateForScreenRowRange(startScreenRow, endScreenRow) { + const decorationsState = {}; + + for (const layer of this.decorationCountsByLayer.keys()) { + for (const marker of layer.findMarkers({ + intersectsScreenRowRange: [startScreenRow, endScreenRow] + })) { + if (marker.isValid()) { + const screenRange = marker.getScreenRange(); + const bufferRange = marker.getBufferRange(); + const rangeIsReversed = marker.isReversed(); + + const decorations = this.decorationsByMarker.get(marker); + if (decorations) { + decorations.forEach(decoration => { + decorationsState[decoration.id] = { + properties: decoration.properties, + screenRange, + bufferRange, + rangeIsReversed + }; + }); + } + + const layerDecorations = this.layerDecorationsByMarkerLayer.get( + layer + ); + if (layerDecorations) { + layerDecorations.forEach(layerDecoration => { + const properties = + layerDecoration.getPropertiesForMarker(marker) || + layerDecoration.getProperties(); + decorationsState[`${layerDecoration.id}-${marker.id}`] = { + properties, + screenRange, + bufferRange, + rangeIsReversed + }; + }); + } + } + } + } + + return decorationsState; + } + + decorateMarker(marker, decorationParams) { + if (marker.isDestroyed()) { + const error = new Error('Cannot decorate a destroyed marker'); + error.metadata = { markerLayerIsDestroyed: marker.layer.isDestroyed() }; + if (marker.destroyStackTrace != null) { + error.metadata.destroyStackTrace = marker.destroyStackTrace; + } + if ( + marker.bufferMarker != null && + marker.bufferMarker.destroyStackTrace != null + ) { + error.metadata.destroyStackTrace = + marker.bufferMarker.destroyStackTrace; + } + throw error; + } + marker = this.displayLayer + .getMarkerLayer(marker.layer.id) + .getMarker(marker.id); + const decoration = new Decoration(marker, this, decorationParams); + let decorationsForMarker = this.decorationsByMarker.get(marker); + if (!decorationsForMarker) { + decorationsForMarker = new Set(); + this.decorationsByMarker.set(marker, decorationsForMarker); + } + decorationsForMarker.add(decoration); + if (decoration.isType('overlay')) this.overlayDecorations.add(decoration); + this.observeDecoratedLayer(marker.layer, true); + this.editor.didAddDecoration(decoration); + this.emitDidUpdateDecorations(); + this.emitter.emit('did-add-decoration', decoration); + return decoration; + } + + decorateMarkerLayer(markerLayer, decorationParams) { + if (markerLayer.isDestroyed()) { + throw new Error('Cannot decorate a destroyed marker layer'); + } + markerLayer = this.displayLayer.getMarkerLayer(markerLayer.id); + const decoration = new LayerDecoration(markerLayer, this, decorationParams); + let layerDecorations = this.layerDecorationsByMarkerLayer.get(markerLayer); + if (layerDecorations == null) { + layerDecorations = new Set(); + this.layerDecorationsByMarkerLayer.set(markerLayer, layerDecorations); + } + layerDecorations.add(decoration); + this.observeDecoratedLayer(markerLayer, false); + this.emitDidUpdateDecorations(); + return decoration; + } + + emitDidUpdateDecorations() { + this.editor.scheduleComponentUpdate(); + this.emitter.emit('did-update-decorations'); + } + + decorationDidChangeType(decoration) { + if (decoration.isType('overlay')) { + this.overlayDecorations.add(decoration); + } else { + this.overlayDecorations.delete(decoration); + } + } + + didDestroyMarkerDecoration(decoration) { + const { marker } = decoration; + const decorations = this.decorationsByMarker.get(marker); + if (decorations && decorations.has(decoration)) { + decorations.delete(decoration); + if (decorations.size === 0) this.decorationsByMarker.delete(marker); + this.overlayDecorations.delete(decoration); + this.unobserveDecoratedLayer(marker.layer, true); + this.emitter.emit('did-remove-decoration', decoration); + this.emitDidUpdateDecorations(); + } + } + + didDestroyLayerDecoration(decoration) { + const { markerLayer } = decoration; + const decorations = this.layerDecorationsByMarkerLayer.get(markerLayer); + + if (decorations && decorations.has(decoration)) { + decorations.delete(decoration); + if (decorations.size === 0) { + this.layerDecorationsByMarkerLayer.delete(markerLayer); + } + this.unobserveDecoratedLayer(markerLayer, true); + this.emitDidUpdateDecorations(); + } + } + + observeDecoratedLayer(layer, isMarkerDecoration) { + const newCount = (this.decorationCountsByLayer.get(layer) || 0) + 1; + this.decorationCountsByLayer.set(layer, newCount); + if (newCount === 1) { + this.layerUpdateDisposablesByLayer.set( + layer, + layer.onDidUpdate(this.emitDidUpdateDecorations.bind(this)) + ); + } + if (isMarkerDecoration) { + this.markerDecorationCountsByLayer.set( + layer, + (this.markerDecorationCountsByLayer.get(layer) || 0) + 1 + ); + } + } + + unobserveDecoratedLayer(layer, isMarkerDecoration) { + const newCount = this.decorationCountsByLayer.get(layer) - 1; + if (newCount === 0) { + this.layerUpdateDisposablesByLayer.get(layer).dispose(); + this.decorationCountsByLayer.delete(layer); + } else { + this.decorationCountsByLayer.set(layer, newCount); + } + if (isMarkerDecoration) { + this.markerDecorationCountsByLayer.set( + this.markerDecorationCountsByLayer.get(layer) - 1 + ); + } + } +}; diff --git a/src/decoration.coffee b/src/decoration.coffee deleted file mode 100644 index bc3a2174883..00000000000 --- a/src/decoration.coffee +++ /dev/null @@ -1,206 +0,0 @@ -_ = require 'underscore-plus' -{Emitter} = require 'event-kit' -Grim = require 'grim' - -idCounter = 0 -nextId = -> idCounter++ - -# Applies changes to a decorationsParam {Object} to make it possible to -# differentiate decorations on custom gutters versus the line-number gutter. -translateDecorationParamsOldToNew = (decorationParams) -> - if decorationParams.type is 'line-number' - decorationParams.gutterName = 'line-number' - decorationParams - -# Essential: Represents a decoration that follows a {Marker}. A decoration is -# basically a visual representation of a marker. It allows you to add CSS -# classes to line numbers in the gutter, lines, and add selection-line regions -# around marked ranges of text. -# -# {Decoration} objects are not meant to be created directly, but created with -# {TextEditor::decorateMarker}. eg. -# -# ```coffee -# range = editor.getSelectedBufferRange() # any range you like -# marker = editor.markBufferRange(range) -# decoration = editor.decorateMarker(marker, {type: 'line', class: 'my-line-class'}) -# ``` -# -# Best practice for destroying the decoration is by destroying the {Marker}. -# -# ```coffee -# marker.destroy() -# ``` -# -# You should only use {Decoration::destroy} when you still need or do not own -# the marker. -module.exports = -class Decoration - - # Private: Check if the `decorationProperties.type` matches `type` - # - # * `decorationProperties` {Object} eg. `{type: 'line-number', class: 'my-new-class'}` - # * `type` {String} type like `'line-number'`, `'line'`, etc. `type` can also - # be an {Array} of {String}s, where it will return true if the decoration's - # type matches any in the array. - # - # Returns {Boolean} - # Note: 'line-number' is a special subtype of the 'gutter' type. I.e., a - # 'line-number' is a 'gutter', but a 'gutter' is not a 'line-number'. - @isType: (decorationProperties, type) -> - # 'line-number' is a special case of 'gutter'. - if _.isArray(decorationProperties.type) - return true if type in decorationProperties.type - if type is 'gutter' - return true if 'line-number' in decorationProperties.type - return false - else - if type is 'gutter' - return true if decorationProperties.type in ['gutter', 'line-number'] - else - type is decorationProperties.type - - ### - Section: Construction and Destruction - ### - - constructor: (@marker, @displayBuffer, properties) -> - @emitter = new Emitter - @id = nextId() - @setProperties properties - @properties.id = @id - @flashQueue = null - @destroyed = false - @markerDestroyDisposable = @marker.onDidDestroy => @destroy() - - # Essential: Destroy this marker. - # - # If you own the marker, you should use {Marker::destroy} which will destroy - # this decoration. - destroy: -> - return if @destroyed - @markerDestroyDisposable.dispose() - @markerDestroyDisposable = null - @destroyed = true - @emit 'destroyed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-destroy' - @emitter.dispose() - - isDestroyed: -> @destroyed - - ### - Section: Event Subscription - ### - - # Essential: When the {Decoration} is updated via {Decoration::update}. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldProperties` {Object} the old parameters the decoration used to have - # * `newProperties` {Object} the new parameters the decoration now has - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeProperties: (callback) -> - @emitter.on 'did-change-properties', callback - - # Essential: Invoke the given callback when the {Decoration} is destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Decoration Details - ### - - # Essential: An id unique across all {Decoration} objects - getId: -> @id - - # Essential: Returns the marker associated with this {Decoration} - getMarker: -> @marker - - # Public: Check if this decoration is of type `type` - # - # * `type` {String} type like `'line-number'`, `'line'`, etc. `type` can also - # be an {Array} of {String}s, where it will return true if the decoration's - # type matches any in the array. - # - # Returns {Boolean} - isType: (type) -> - Decoration.isType(@properties, type) - - ### - Section: Properties - ### - - # Essential: Returns the {Decoration}'s properties. - getProperties: -> - @properties - - # Essential: Update the marker with new Properties. Allows you to change the decoration's class. - # - # ## Examples - # - # ```coffee - # decoration.update({type: 'line-number', class: 'my-new-class'}) - # ``` - # - # * `newProperties` {Object} eg. `{type: 'line-number', class: 'my-new-class'}` - setProperties: (newProperties) -> - return if @destroyed - oldProperties = @properties - @properties = translateDecorationParamsOldToNew(newProperties) - @properties.id = @id - @emit 'updated', {oldParams: oldProperties, newParams: newProperties} if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-properties', {oldProperties, newProperties} - - ### - Section: Private methods - ### - - matchesPattern: (decorationPattern) -> - return false unless decorationPattern? - for key, value of decorationPattern - return false if @properties[key] isnt value - true - - onDidFlash: (callback) -> - @emitter.on 'did-flash', callback - - flash: (klass, duration=500) -> - flashObject = {class: klass, duration} - @flashQueue ?= [] - @flashQueue.push(flashObject) - @emit 'flash' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-flash' - - consumeNextFlash: -> - return @flashQueue.shift() if @flashQueue?.length > 0 - null - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(Decoration) - - Decoration::on = (eventName) -> - switch eventName - when 'updated' - Grim.deprecate 'Use Decoration::onDidChangeProperties instead' - when 'destroyed' - Grim.deprecate 'Use Decoration::onDidDestroy instead' - when 'flash' - Grim.deprecate 'Use Decoration::onDidFlash instead' - else - Grim.deprecate 'Decoration::on is deprecated. Use event subscription methods instead.' - - EmitterMixin::on.apply(this, arguments) - - Decoration::getParams = -> - Grim.deprecate 'Use Decoration::getProperties instead' - @getProperties() - - Decoration::update = (newProperties) -> - Grim.deprecate 'Use Decoration::setProperties instead' - @setProperties(newProperties) diff --git a/src/decoration.js b/src/decoration.js new file mode 100644 index 00000000000..1263259c853 --- /dev/null +++ b/src/decoration.js @@ -0,0 +1,235 @@ +const { Emitter } = require('event-kit'); + +let idCounter = 0; +const nextId = () => idCounter++; + +const normalizeDecorationProperties = function(decoration, decorationParams) { + decorationParams.id = decoration.id; + + if ( + decorationParams.type === 'line-number' && + decorationParams.gutterName == null + ) { + decorationParams.gutterName = 'line-number'; + } + + if (decorationParams.order == null) { + decorationParams.order = Infinity; + } + + return decorationParams; +}; + +// Essential: Represents a decoration that follows a {DisplayMarker}. A decoration is +// basically a visual representation of a marker. It allows you to add CSS +// classes to line numbers in the gutter, lines, and add selection-line regions +// around marked ranges of text. +// +// {Decoration} objects are not meant to be created directly, but created with +// {TextEditor::decorateMarker}. eg. +// +// ```coffee +// range = editor.getSelectedBufferRange() # any range you like +// marker = editor.markBufferRange(range) +// decoration = editor.decorateMarker(marker, {type: 'line', class: 'my-line-class'}) +// ``` +// +// Best practice for destroying the decoration is by destroying the {DisplayMarker}. +// +// ```coffee +// marker.destroy() +// ``` +// +// You should only use {Decoration::destroy} when you still need or do not own +// the marker. +module.exports = class Decoration { + // Private: Check if the `decorationProperties.type` matches `type` + // + // * `decorationProperties` {Object} eg. `{type: 'line-number', class: 'my-new-class'}` + // * `type` {String} type like `'line-number'`, `'line'`, etc. `type` can also + // be an {Array} of {String}s, where it will return true if the decoration's + // type matches any in the array. + // + // Returns {Boolean} + // Note: 'line-number' is a special subtype of the 'gutter' type. I.e., a + // 'line-number' is a 'gutter', but a 'gutter' is not a 'line-number'. + static isType(decorationProperties, type) { + // 'line-number' is a special case of 'gutter'. + if (Array.isArray(decorationProperties.type)) { + if (decorationProperties.type.includes(type)) { + return true; + } + + if ( + type === 'gutter' && + decorationProperties.type.includes('line-number') + ) { + return true; + } + + return false; + } else { + if (type === 'gutter') { + return ['gutter', 'line-number'].includes(decorationProperties.type); + } else { + return type === decorationProperties.type; + } + } + } + + /* + Section: Construction and Destruction + */ + + constructor(marker, decorationManager, properties) { + this.marker = marker; + this.decorationManager = decorationManager; + this.emitter = new Emitter(); + this.id = nextId(); + this.setProperties(properties); + this.destroyed = false; + this.markerDestroyDisposable = this.marker.onDidDestroy(() => + this.destroy() + ); + } + + // Essential: Destroy this marker decoration. + // + // You can also destroy the marker if you own it, which will destroy this + // decoration. + destroy() { + if (this.destroyed) { + return; + } + this.markerDestroyDisposable.dispose(); + this.markerDestroyDisposable = null; + this.destroyed = true; + this.decorationManager.didDestroyMarkerDecoration(this); + this.emitter.emit('did-destroy'); + return this.emitter.dispose(); + } + + isDestroyed() { + return this.destroyed; + } + + /* + Section: Event Subscription + */ + + // Essential: When the {Decoration} is updated via {Decoration::update}. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldProperties` {Object} the old parameters the decoration used to have + // * `newProperties` {Object} the new parameters the decoration now has + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeProperties(callback) { + return this.emitter.on('did-change-properties', callback); + } + + // Essential: Invoke the given callback when the {Decoration} is destroyed + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Decoration Details + */ + + // Essential: An id unique across all {Decoration} objects + getId() { + return this.id; + } + + // Essential: Returns the marker associated with this {Decoration} + getMarker() { + return this.marker; + } + + // Public: Check if this decoration is of type `type` + // + // * `type` {String} type like `'line-number'`, `'line'`, etc. `type` can also + // be an {Array} of {String}s, where it will return true if the decoration's + // type matches any in the array. + // + // Returns {Boolean} + isType(type) { + return Decoration.isType(this.properties, type); + } + + /* + Section: Properties + */ + + // Essential: Returns the {Decoration}'s properties. + getProperties() { + return this.properties; + } + + // Essential: Update the marker with new Properties. Allows you to change the decoration's class. + // + // ## Examples + // + // ```coffee + // decoration.setProperties({type: 'line-number', class: 'my-new-class'}) + // ``` + // + // * `newProperties` {Object} eg. `{type: 'line-number', class: 'my-new-class'}` + setProperties(newProperties) { + if (this.destroyed) { + return; + } + const oldProperties = this.properties; + this.properties = normalizeDecorationProperties(this, newProperties); + if (newProperties.type != null) { + this.decorationManager.decorationDidChangeType(this); + } + this.decorationManager.emitDidUpdateDecorations(); + return this.emitter.emit('did-change-properties', { + oldProperties, + newProperties + }); + } + + /* + Section: Utility + */ + + inspect() { + return ``; + } + + /* + Section: Private methods + */ + + matchesPattern(decorationPattern) { + if (decorationPattern == null) { + return false; + } + for (let key in decorationPattern) { + const value = decorationPattern[key]; + if (this.properties[key] !== value) { + return false; + } + } + return true; + } + + flash(klass, duration) { + if (duration == null) { + duration = 500; + } + this.properties.flashRequested = true; + this.properties.flashClass = klass; + this.properties.flashDuration = duration; + this.decorationManager.emitDidUpdateDecorations(); + return this.emitter.emit('did-flash'); + } +}; diff --git a/src/default-directory-provider.coffee b/src/default-directory-provider.coffee index 9e25e097b8f..e55379092c5 100644 --- a/src/default-directory-provider.coffee +++ b/src/default-directory-provider.coffee @@ -1,6 +1,7 @@ {Directory} = require 'pathwatcher' fs = require 'fs-plus' path = require 'path' +url = require 'url' module.exports = class DefaultDirectoryProvider @@ -12,24 +13,46 @@ class DefaultDirectoryProvider # # Returns: # * {Directory} if the given URI is compatible with this provider. - # * `null` if the given URI is not compatibile with this provider. + # * `null` if the given URI is not compatible with this provider. directoryForURISync: (uri) -> - projectPath = path.normalize(uri) - - directoryPath = if not fs.isDirectorySync(projectPath) and fs.isDirectorySync(path.dirname(projectPath)) - path.dirname(projectPath) + normalizedPath = @normalizePath(uri) + {host} = url.parse(uri) + directoryPath = if host + uri + else if not fs.isDirectorySync(normalizedPath) and fs.isDirectorySync(path.dirname(normalizedPath)) + path.dirname(normalizedPath) else - projectPath + normalizedPath - new Directory(directoryPath) + # TODO: Stop normalizing the path in pathwatcher's Directory. + directory = new Directory(directoryPath) + if host + directory.path = directoryPath + if fs.isCaseInsensitive() + directory.lowerCasePath = directoryPath.toLowerCase() + directory # Public: Create a Directory that corresponds to the specified URI. # # * `uri` {String} The path to the directory to add. This is guaranteed not to # be contained by a {Directory} in `atom.project`. # - # Returns a Promise that resolves to: + # Returns a {Promise} that resolves to: # * {Directory} if the given URI is compatible with this provider. - # * `null` if the given URI is not compatibile with this provider. + # * `null` if the given URI is not compatible with this provider. directoryForURI: (uri) -> Promise.resolve(@directoryForURISync(uri)) + + # Public: Normalizes path. + # + # * `uri` {String} The path that should be normalized. + # + # Returns a {String} with normalized path. + normalizePath: (uri) -> + # Normalize disk drive letter on Windows to avoid opening two buffers for the same file + pathWithNormalizedDiskDriveLetter = + if process.platform is 'win32' and matchData = uri.match(/^([a-z]):/) + "#{matchData[1].toUpperCase()}#{uri.slice(1)}" + else + uri + path.normalize(pathWithNormalizedDiskDriveLetter) diff --git a/src/default-directory-searcher.js b/src/default-directory-searcher.js new file mode 100644 index 00000000000..0946ec304bd --- /dev/null +++ b/src/default-directory-searcher.js @@ -0,0 +1,114 @@ +const Task = require('./task'); + +// Searches local files for lines matching a specified regex. Implements `.then()` +// so that it can be used with `Promise.all()`. +class DirectorySearch { + constructor(rootPaths, regex, options) { + const scanHandlerOptions = { + ignoreCase: regex.ignoreCase, + inclusions: options.inclusions, + includeHidden: options.includeHidden, + excludeVcsIgnores: options.excludeVcsIgnores, + globalExclusions: options.exclusions, + follow: options.follow + }; + const searchOptions = { + leadingContextLineCount: options.leadingContextLineCount, + trailingContextLineCount: options.trailingContextLineCount + }; + this.task = new Task(require.resolve('./scan-handler')); + this.task.on('scan:result-found', options.didMatch); + this.task.on('scan:file-error', options.didError); + this.task.on('scan:paths-searched', options.didSearchPaths); + this.promise = new Promise((resolve, reject) => { + this.task.on('task:cancelled', reject); + this.task.start( + rootPaths, + regex.source, + scanHandlerOptions, + searchOptions, + () => { + this.task.terminate(); + resolve(); + } + ); + }); + } + + then(...args) { + return this.promise.then.apply(this.promise, args); + } + + cancel() { + // This will cause @promise to reject. + this.task.cancel(); + } +} + +// Default provider for the `atom.directory-searcher` service. +module.exports = class DefaultDirectorySearcher { + // Determines whether this object supports search for a `Directory`. + // + // * `directory` {Directory} whose search needs might be supported by this object. + // + // Returns a `boolean` indicating whether this object can search this `Directory`. + canSearchDirectory(directory) { + return true; + } + + // Performs a text search for files in the specified `Directory`, subject to the + // specified parameters. + // + // Results are streamed back to the caller by invoking methods on the specified `options`, + // such as `didMatch` and `didError`. + // + // * `directories` {Array} of {Directory} objects to search, all of which have been accepted by + // this searcher's `canSearchDirectory()` predicate. + // * `regex` {RegExp} to search with. + // * `options` {Object} with the following properties: + // * `didMatch` {Function} call with a search result structured as follows: + // * `searchResult` {Object} with the following keys: + // * `filePath` {String} absolute path to the matching file. + // * `matches` {Array} with object elements with the following keys: + // * `lineText` {String} The full text of the matching line (without a line terminator character). + // * `lineTextOffset` {Number} If > 0, the provided line text is truncated and starts at this offset + // * `matchText` {String} The text that matched the `regex` used for the search. + // * `range` {Range} Identifies the matching region in the file. (Likely as an array of numeric arrays.) + // * `didError` {Function} call with an Error if there is a problem during the search. + // * `didSearchPaths` {Function} periodically call with the number of paths searched thus far. + // * `inclusions` {Array} of glob patterns (as strings) to search within. Note that this + // array may be empty, indicating that all files should be searched. + // + // Each item in the array is a file/directory pattern, e.g., `src` to search in the "src" + // directory or `*.js` to search all JavaScript files. In practice, this often comes from the + // comma-delimited list of patterns in the bottom text input of the ProjectFindView dialog. + // * `includeHidden` {boolean} whether to ignore hidden files. + // * `excludeVcsIgnores` {boolean} whether to exclude VCS ignored paths. + // * `exclusions` {Array} similar to inclusions + // * `follow` {boolean} whether symlinks should be followed. + // + // Returns a *thenable* `DirectorySearch` that includes a `cancel()` method. If `cancel()` is + // invoked before the `DirectorySearch` is determined, it will resolve the `DirectorySearch`. + search(directories, regex, options) { + const rootPaths = directories.map(directory => directory.getPath()); + let isCancelled = false; + const directorySearch = new DirectorySearch(rootPaths, regex, options); + const promise = new Promise(function(resolve, reject) { + directorySearch.then(resolve, function() { + if (isCancelled) { + resolve(); + } else { + reject(); // eslint-disable-line prefer-promise-reject-errors + } + }); + }); + return { + then: promise.then.bind(promise), + catch: promise.catch.bind(promise), + cancel() { + isCancelled = true; + directorySearch.cancel(); + } + }; + } +}; diff --git a/src/delegated-listener.js b/src/delegated-listener.js new file mode 100644 index 00000000000..f2cd33b4d2f --- /dev/null +++ b/src/delegated-listener.js @@ -0,0 +1,40 @@ +const EventKit = require('event-kit'); + +module.exports = function listen(element, eventName, selector, handler) { + const innerHandler = function(event) { + if (selector) { + var currentTarget = event.target; + while (currentTarget) { + if (currentTarget.matches && currentTarget.matches(selector)) { + handler({ + type: event.type, + currentTarget: currentTarget, + target: event.target, + preventDefault: function() { + event.preventDefault(); + }, + originalEvent: event + }); + } + if (currentTarget === element) break; + currentTarget = currentTarget.parentNode; + } + } else { + handler({ + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + preventDefault: function() { + event.preventDefault(); + }, + originalEvent: event + }); + } + }; + + element.addEventListener(eventName, innerHandler); + + return new EventKit.Disposable(function() { + element.removeEventListener(eventName, innerHandler); + }); +}; diff --git a/src/deprecated-syntax-selectors.js b/src/deprecated-syntax-selectors.js new file mode 100644 index 00000000000..4debbdad05a --- /dev/null +++ b/src/deprecated-syntax-selectors.js @@ -0,0 +1,5476 @@ +module.exports = new Set([ + 'AFDKO', + 'AFKDO', + 'ASS', + 'AVX', + 'AVX2', + 'AVX512', + 'AVX512BW', + 'AVX512DQ', + 'Alignment', + 'Alpha', + 'AlphaLevel', + 'Angle', + 'Animation', + 'AnimationGroup', + 'ArchaeologyDigSiteFrame', + 'Arrow__', + 'AtLilyPond', + 'AttrBaseType', + 'AttrSetVal__', + 'BackColour', + 'Banner', + 'Bold', + 'Bonlang', + 'BorderStyle', + 'Browser', + 'Button', + 'C99', + 'CALCULATE', + 'CharacterSet', + 'ChatScript', + 'Chatscript', + 'CheckButton', + 'ClipboardFormat', + 'ClipboardType', + 'Clipboard__', + 'CodePage', + 'Codepages__', + 'Collisions', + 'ColorSelect', + 'ColourActual', + 'ColourLogical', + 'ColourReal', + 'ColourScheme', + 'ColourSize', + 'Column', + 'Comment', + 'ConfCachePolicy', + 'ControlPoint', + 'Cooldown', + 'DBE', + 'DDL', + 'DML', + 'DSC', + 'Database__', + 'DdcMode', + 'Dialogue', + 'DiscussionFilterType', + 'DiscussionStatus', + 'DisplaySchemes', + 'Document-Structuring-Comment', + 'DressUpModel', + 'Edit', + 'EditBox', + 'Effect', + 'Encoding', + 'End', + 'ExternalLinkBehaviour', + 'ExternalLinkDirection', + 'F16c', + 'FMA', + 'FilterType', + 'Font', + 'FontInstance', + 'FontString', + 'Fontname', + 'Fonts__', + 'Fontsize', + 'Format', + 'Frame', + 'GameTooltip', + 'GroupList', + 'HLE', + 'HeaderEvent', + 'HistoryType', + 'HttpVerb', + 'II', + 'IO', + 'Icon', + 'IconID', + 'InPlaceBox__', + 'InPlaceEditEvent', + 'Info', + 'Italic', + 'JSXEndTagStart', + 'JSXStartTagEnd', + 'KNC', + 'KeyModifier', + 'Kotlin', + 'LUW', + 'Language', + 'Layer', + 'LayeredRegion', + 'LdapItemList', + 'LineSpacing', + 'LinkFilter', + 'LinkLimit', + 'ListView', + 'Locales__', + 'Lock', + 'LoginPolicy', + 'MA_End__', + 'MA_StdCombo__', + 'MA_StdItem__', + 'MA_StdMenu__', + 'MISSING', + 'Mapping', + 'MarginL', + 'MarginR', + 'MarginV', + 'Marked', + 'MessageFrame', + 'Minimap', + 'MovieFrame', + 'Name', + 'Outline', + 'OutlineColour', + 'ParentedObject', + 'Path', + 'Permission', + 'PlayRes', + 'PlayerModel', + 'PrimaryColour', + 'Proof', + 'QuestPOIFrame', + 'RTM', + 'RecentModule__', + 'Regexp', + 'Region', + 'Rotation', + 'SCADABasic', + 'SSA', + 'Scale', + 'ScaleX', + 'ScaleY', + 'ScaledBorderAndShadow', + 'ScenarioPOIFrame', + 'ScriptObject', + 'Script__', + 'Scroll', + 'ScrollEvent', + 'ScrollFrame', + 'ScrollSide', + 'ScrollingMessageFrame', + 'SecondaryColour', + 'Sensitivity', + 'Shadow', + 'SimpleHTML', + 'Slider', + 'Spacing', + 'Start', + 'StatusBar', + 'Stream', + 'StrikeOut', + 'Style', + 'TIS', + 'TODO', + 'TabardModel', + 'Text', + 'Texture', + 'Timer', + 'ToolType', + 'Translation', + 'TreeView', + 'TriggerStatus', + 'UIObject', + 'Underline', + 'UserClass', + 'UserList', + 'UserNotifyList', + 'VisibleRegion', + 'Vplus', + 'WrapStyle', + 'XHPEndTagStart', + 'XHPStartTagEnd', + 'ZipType', + '__package-name__', + '_c', + '_function', + 'a', + 'a10networks', + 'aaa', + 'abaqus', + 'abbrev', + 'abbreviated', + 'abbreviation', + 'abcnotation', + 'abl', + 'abnf', + 'abp', + 'absolute', + 'abstract', + 'academic', + 'access', + 'access-control', + 'access-qualifiers', + 'accessed', + 'accessor', + 'account', + 'accumulator', + 'ace', + 'ace3', + 'acl', + 'acos', + 'act', + 'action', + 'action-map', + 'actionhandler', + 'actionpack', + 'actions', + 'actionscript', + 'activerecord', + 'activesupport', + 'actual', + 'acute-accent', + 'ada', + 'add', + 'adddon', + 'added', + 'addition', + 'additional-character', + 'additive', + 'addon', + 'address', + 'address-of', + 'address-space', + 'addrfam', + 'adjustment', + 'admonition', + 'adr', + 'adverb', + 'adx', + 'ael', + 'aem', + 'aerospace', + 'aes', + 'aes_functions', + 'aesni', + 'aexLightGreen', + 'af', + 'afii', + 'aflex', + 'after', + 'after-expression', + 'agc', + 'agda', + 'agentspeak', + 'aggregate', + 'aggregation', + 'ahk', + 'ai-connection', + 'ai-player', + 'ai-wheeled-vehicle', + 'aif', + 'alabel', + 'alarms', + 'alda', + 'alert', + 'algebraic-type', + 'alias', + 'aliases', + 'align', + 'align-attribute', + 'alignment', + 'alignment-cue-setting', + 'alignment-mode', + 'all', + 'all-once', + 'all-solutions', + 'allocate', + 'alloy', + 'alloyglobals', + 'alloyxml', + 'alog', + 'alpha', + 'alphabeticalllt', + 'alphabeticallyge', + 'alphabeticallygt', + 'alphabeticallyle', + 'alt', + 'alter', + 'alternate-wysiwyg-string', + 'alternates', + 'alternation', + 'alternatives', + 'am', + 'ambient-audio-manager', + 'ambient-reflectivity', + 'amd', + 'amd3DNow', + 'amdnops', + 'ameter', + 'amount', + 'amp', + 'ampersand', + 'ampl', + 'ampscript', + 'an', + 'analysis', + 'analytics', + 'anb', + 'anchor', + 'and', + 'andop', + 'angelscript', + 'angle', + 'angle-brackets', + 'angular', + 'animation', + 'annot', + 'annotated', + 'annotation', + 'annotation-arguments', + 'anon', + 'anonymous', + 'another', + 'ansi', + 'ansi-c', + 'ansi-colored', + 'ansi-escape-code', + 'ansi-formatted', + 'ansi2', + 'ansible', + 'answer', + 'antialiasing', + 'antl', + 'antlr', + 'antlr4', + 'anubis', + 'any', + 'any-method', + 'anyclass', + 'aolserver', + 'apa', + 'apache', + 'apache-config', + 'apc', + 'apdl', + 'apex', + 'api', + 'api-notation', + 'apiary', + 'apib', + 'apl', + 'apostrophe', + 'appcache', + 'applescript', + 'application', + 'application-name', + 'application-process', + 'approx-equal', + 'aql', + 'aqua', + 'ar', + 'arbitrary-radix', + 'arbitrary-repetition', + 'arbitrary-repitition', + 'arch', + 'arch_specification', + 'architecture', + 'archive', + 'archives', + 'arduino', + 'area-code', + 'arendelle', + 'argcount', + 'args', + 'argument', + 'argument-label', + 'argument-separator', + 'argument-seperator', + 'argument-type', + 'arguments', + 'arith', + 'arithmetic', + 'arithmetical', + 'arithmeticcql', + 'ark', + 'arm', + 'arma', + 'armaConfig', + 'arnoldc', + 'arp', + 'arpop', + 'arr', + 'array', + 'array-expression', + 'array-literal', + 'arrays', + 'arrow', + 'articulation', + 'artihmetic', + 'arvo', + 'aryop', + 'as', + 'as4', + 'ascii', + 'asciidoc', + 'asdoc', + 'ash', + 'ashx', + 'asl', + 'asm', + 'asm-instruction', + 'asm-type-prefix', + 'asn', + 'asp', + 'asp-core-2', + 'aspx', + 'ass', + 'assembly', + 'assert', + 'assertion', + 'assigment', + 'assign', + 'assign-class', + 'assigned', + 'assigned-class', + 'assigned-value', + 'assignee', + 'assignement', + 'assignment', + 'assignmentforge-config', + 'associate', + 'association', + 'associativity', + 'assocs', + 'asterisk', + 'async', + 'at-marker', + 'at-root', + 'at-rule', + 'at-sign', + 'atmark', + 'atml3', + 'atoemp', + 'atom', + 'atom-term-processing', + 'atomic', + 'atomscript', + 'att', + 'attachment', + 'attr', + 'attribute', + 'attribute-entry', + 'attribute-expression', + 'attribute-key-value', + 'attribute-list', + 'attribute-lookup', + 'attribute-name', + 'attribute-reference', + 'attribute-selector', + 'attribute-value', + 'attribute-values', + 'attribute-with-value', + 'attribute_list', + 'attribute_value', + 'attribute_value2', + 'attributelist', + 'attributes', + 'attrset', + 'attrset-or-function', + 'audio', + 'audio-file', + 'auditor', + 'augmented', + 'auth', + 'auth_basic', + 'author', + 'author-names', + 'authorization', + 'auto', + 'auto-event', + 'autoconf', + 'autoindex', + 'autoit', + 'automake', + 'automatic', + 'autotools', + 'autovar', + 'aux', + 'auxiliary', + 'avdl', + 'avra', + 'avrasm', + 'avrdisasm', + 'avs', + 'avx', + 'avx2', + 'avx512', + 'awk', + 'axes_group', + 'axis', + 'axl', + 'b', + 'b-spline-patch', + 'babel', + 'back', + 'back-from', + 'back-reference', + 'back-slash', + 'backend', + 'background', + 'backreference', + 'backslash', + 'backslash-bar', + 'backslash-g', + 'backspace', + 'backtick', + 'bad-ampersand', + 'bad-angle-bracket', + 'bad-assignment', + 'bad-comments-or-CDATA', + 'bad-escape', + 'bad-octal', + 'bad-var', + 'bang', + 'banner', + 'bar', + 'bareword', + 'barline', + 'base', + 'base-11', + 'base-12', + 'base-13', + 'base-14', + 'base-15', + 'base-16', + 'base-17', + 'base-18', + 'base-19', + 'base-20', + 'base-21', + 'base-22', + 'base-23', + 'base-24', + 'base-25', + 'base-26', + 'base-27', + 'base-28', + 'base-29', + 'base-3', + 'base-30', + 'base-31', + 'base-32', + 'base-33', + 'base-34', + 'base-35', + 'base-36', + 'base-4', + 'base-5', + 'base-6', + 'base-7', + 'base-9', + 'base-call', + 'base-integer', + 'base64', + 'base85', + 'base_pound_number_pound', + 'basetype', + 'basic', + 'basic-arithmetic', + 'basic-type', + 'basic_functions', + 'basicblock', + 'basis-matrix', + 'bat', + 'batch', + 'batchfile', + 'battlesim', + 'bb', + 'bbcode', + 'bcmath', + 'be', + 'beam', + 'beamer', + 'beancount', + 'before', + 'begin', + 'begin-document', + 'begin-emphasis', + 'begin-end', + 'begin-end-group', + 'begin-literal', + 'begin-symbolic', + 'begintimeblock', + 'behaviour', + 'bem', + 'between-tag-pair', + 'bevel', + 'bezier-patch', + 'bfeac', + 'bff', + 'bg', + 'bg-black', + 'bg-blue', + 'bg-cyan', + 'bg-green', + 'bg-normal', + 'bg-purple', + 'bg-red', + 'bg-white', + 'bg-yellow', + 'bhtml', + 'bhv', + 'bibitem', + 'bibliography-anchor', + 'biblioref', + 'bibpaper', + 'bibtex', + 'bif', + 'big-arrow', + 'big-arrow-left', + 'bigdecimal', + 'bigint', + 'biicode', + 'biiconf', + 'bin', + 'binOp', + 'binary', + 'binary-arithmetic', + 'bind', + 'binder', + 'binding', + 'binding-prefix', + 'bindings', + 'binop', + 'bioinformatics', + 'biosphere', + 'bird-track', + 'bis', + 'bison', + 'bit', + 'bit-and-byte', + 'bit-range', + 'bit-wise', + 'bitarray', + 'bitop', + 'bits-mov', + 'bitvector', + 'bitwise', + 'black', + 'blade', + 'blanks', + 'blaze', + 'blenc', + 'blend', + 'blending', + 'blendtype', + 'blendu', + 'blendv', + 'blip', + 'block', + 'block-attribute', + 'block-dartdoc', + 'block-data', + 'block-level', + 'blockid', + 'blockname', + 'blockquote', + 'blocktitle', + 'blue', + 'blueprint', + 'bluespec', + 'blur', + 'bm', + 'bmi', + 'bmi1', + 'bmi2', + 'bnd', + 'bnf', + 'body', + 'body-statement', + 'bold', + 'bold-italic-text', + 'bold-text', + 'bolt', + 'bond', + 'bonlang', + 'boo', + 'boogie', + 'bool', + 'boolean', + 'boolean-test', + 'boost', + 'boot', + 'bord', + 'border', + 'botml', + 'bottom', + 'boundary', + 'bounded', + 'bounds', + 'bow', + 'box', + 'bpl', + 'bpr', + 'bqparam', + 'brace', + 'braced', + 'braces', + 'bracket', + 'bracketed', + 'brackets', + 'brainfuck', + 'branch', + 'branch-point', + 'break', + 'breakpoint', + 'breakpoints', + 'breaks', + 'bridle', + 'brightscript', + 'bro', + 'broken', + 'browser', + 'browsers', + 'bs', + 'bsl', + 'btw', + 'buffered', + 'buffers', + 'bugzilla-number', + 'build', + 'buildin', + 'buildout', + 'built-in', + 'built-in-variable', + 'built-ins', + 'builtin', + 'builtin-comparison', + 'builtins', + 'bullet', + 'bullet-point', + 'bump', + 'bump-multiplier', + 'bundle', + 'but', + 'button', + 'buttons', + 'by', + 'by-name', + 'by-number', + 'byref', + 'byte', + 'bytearray', + 'bz2', + 'bzl', + 'c', + 'c-style', + 'c0', + 'c1', + 'c2hs', + 'ca', + 'cabal', + 'cabal-keyword', + 'cache', + 'cache-management', + 'cacheability-control', + 'cake', + 'calc', + 'calca', + 'calendar', + 'call', + 'callable', + 'callback', + 'caller', + 'calling', + 'callmethod', + 'callout', + 'callparent', + 'camera', + 'camlp4', + 'camlp4-stream', + 'canonicalized-program-name', + 'canopen', + 'capability', + 'capnp', + 'cappuccino', + 'caps', + 'caption', + 'capture', + 'capturename', + 'cardinal-curve', + 'cardinal-patch', + 'cascade', + 'case', + 'case-block', + 'case-body', + 'case-class', + 'case-clause', + 'case-clause-body', + 'case-expression', + 'case-modifier', + 'case-pattern', + 'case-statement', + 'case-terminator', + 'case-value', + 'cassius', + 'cast', + 'catch', + 'catch-exception', + 'catcode', + 'categories', + 'categort', + 'category', + 'cba', + 'cbmbasic', + 'cbot', + 'cbs', + 'cc', + 'cc65', + 'ccml', + 'cdata', + 'cdef', + 'cdtor', + 'ceiling', + 'cell', + 'cellcontents', + 'cellwall', + 'ceq', + 'ces', + 'cet', + 'cexpr', + 'cextern', + 'ceylon', + 'ceylondoc', + 'cf', + 'cfdg', + 'cfengine', + 'cfg', + 'cfml', + 'cfscript', + 'cfunction', + 'cg', + 'cgi', + 'cgx', + 'chain', + 'chained', + 'chaining', + 'chainname', + 'changed', + 'changelogs', + 'changes', + 'channel', + 'chapel', + 'chapter', + 'char', + 'characater', + 'character', + 'character-class', + 'character-data-not-allowed-here', + 'character-literal', + 'character-literal-too-long', + 'character-not-allowed-here', + 'character-range', + 'character-reference', + 'character-token', + 'character_not_allowed', + 'character_not_allowed_here', + 'characters', + 'chars', + 'chars-and-bytes-io', + 'charset', + 'check', + 'check-identifier', + 'checkboxes', + 'checker', + 'chef', + 'chem', + 'chemical', + 'children', + 'choice', + 'choicescript', + 'chord', + 'chorus', + 'chuck', + 'chunk', + 'ciexyz', + 'circle', + 'circle-jot', + 'cirru', + 'cisco', + 'cisco-ios-config', + 'citation', + 'cite', + 'citrine', + 'cjam', + 'cjson', + 'clamp', + 'clamping', + 'class', + 'class-constraint', + 'class-constraints', + 'class-declaration', + 'class-definition', + 'class-fns', + 'class-instance', + 'class-list', + 'class-struct-block', + 'class-type', + 'class-type-definition', + 'classcode', + 'classes', + 'classic', + 'classicalb', + 'classmethods', + 'classobj', + 'classtree', + 'clause', + 'clause-head-body', + 'clauses', + 'clear', + 'clear-argument', + 'cleared', + 'clflushopt', + 'click', + 'client', + 'client-server', + 'clip', + 'clipboard', + 'clips', + 'clmul', + 'clock', + 'clojure', + 'cloned', + 'close', + 'closed', + 'closing', + 'closing-text', + 'closure', + 'clothes-body', + 'cm', + 'cmake', + 'cmb', + 'cmd', + 'cnet', + 'cns', + 'cobject', + 'cocoa', + 'cocor', + 'cod4mp', + 'code', + 'code-example', + 'codeblock', + 'codepoint', + 'codimension', + 'codstr', + 'coffee', + 'coffeescript', + 'coffeescript-preview', + 'coil', + 'collection', + 'collision', + 'colon', + 'colons', + 'color', + 'color-adjustment', + 'coloring', + 'colour', + 'colour-correction', + 'colour-interpolation', + 'colour-name', + 'colour-scheme', + 'colspan', + 'column', + 'column-divider', + 'column-specials', + 'com', + 'combinators', + 'comboboxes', + 'comma', + 'comma-bar', + 'comma-parenthesis', + 'command', + 'command-name', + 'command-synopsis', + 'commandline', + 'commands', + 'comment', + 'comment-ish', + 'comment-italic', + 'commented-out', + 'commit-command', + 'commit-message', + 'commodity', + 'common', + 'commonform', + 'communications', + 'community', + 'commute', + 'comnd', + 'compare', + 'compareOp', + 'comparison', + 'compile', + 'compile-only', + 'compiled', + 'compiled-papyrus', + 'compiler', + 'compiler-directive', + 'compiletime', + 'compiling-and-loading', + 'complement', + 'complete', + 'completed', + 'complex', + 'component', + 'component-separator', + 'component_instantiation', + 'compositor', + 'compound', + 'compound-assignment', + 'compress', + 'computer', + 'computercraft', + 'concat', + 'concatenated-arguments', + 'concatenation', + 'concatenator', + 'concatination', + 'concealed', + 'concise', + 'concrete', + 'condition', + 'conditional', + 'conditional-directive', + 'conditional-short', + 'conditionals', + 'conditions', + 'conf', + 'config', + 'configuration', + 'configure', + 'confluence', + 'conftype', + 'conjunction', + 'conky', + 'connect', + 'connection-state', + 'connectivity', + 'connstate', + 'cons', + 'consecutive-tags', + 'considering', + 'console', + 'const', + 'const-data', + 'constant', + 'constants', + 'constrained', + 'constraint', + 'constraints', + 'construct', + 'constructor', + 'constructor-list', + 'constructs', + 'consult', + 'contacts', + 'container', + 'containers-raycast', + 'contains', + 'content', + 'content-detective', + 'contentSupplying', + 'contentitem', + 'context', + 'context-free', + 'context-signature', + 'continuation', + 'continuations', + 'continue', + 'continued', + 'continuum', + 'contol', + 'contract', + 'contracts', + 'contrl', + 'control', + 'control-char', + 'control-handlers', + 'control-management', + 'control-systems', + 'control-transfer', + 'controller', + 'controlline', + 'controls', + 'contstant', + 'conventional', + 'conversion', + 'convert-type', + 'cookie', + 'cool', + 'coord1', + 'coord2', + 'coord3', + 'coordinates', + 'copy', + 'copying', + 'coq', + 'core', + 'core-parse', + 'coreutils', + 'correct', + 'cos', + 'counter', + 'counters', + 'cover', + 'cplkg', + 'cplusplus', + 'cpm', + 'cpp', + 'cpp-include', + 'cpp-type', + 'cpp_type', + 'cpu12', + 'cql', + 'cram', + 'crc32', + 'create', + 'creation', + 'critic', + 'crl', + 'crontab', + 'crypto', + 'crystal', + 'cs', + 'csharp', + 'cshtml', + 'csi', + 'csjs', + 'csound', + 'csound-document', + 'csound-score', + 'cspm', + 'css', + 'csv', + 'csx', + 'ct', + 'ctkey', + 'ctor', + 'ctxvar', + 'ctxvarbracket', + 'ctype', + 'cubic-bezier', + 'cucumber', + 'cuda', + 'cue-identifier', + 'cue-timings', + 'cuesheet', + 'cup', + 'cupsym', + 'curl', + 'curley', + 'curly', + 'currency', + 'current', + 'current-escape-char', + 'curve', + 'curve-2d', + 'curve-fitting', + 'curve-reference', + 'curve-technique', + 'custom', + 'customevent', + 'cut', + 'cve-number', + 'cvs', + 'cw', + 'cxx', + 'cy-GB', + 'cyan', + 'cyc', + 'cycle', + 'cypher', + 'cyrix', + 'cython', + 'd', + 'da', + 'daml', + 'dana', + 'danger', + 'danmakufu', + 'dark_aqua', + 'dark_blue', + 'dark_gray', + 'dark_green', + 'dark_purple', + 'dark_red', + 'dart', + 'dartdoc', + 'dash', + 'dasm', + 'data', + 'data-acquisition', + 'data-extension', + 'data-integrity', + 'data-item', + 'data-step', + 'data-transfer', + 'database', + 'database-name', + 'datablock', + 'datablocks', + 'datafeed', + 'datatype', + 'datatypes', + 'date', + 'date-time', + 'datetime', + 'dav', + 'day', + 'dayofmonth', + 'dayofweek', + 'db', + 'dba', + 'dbx', + 'dc', + 'dcon', + 'dd', + 'ddp', + 'de', + 'dealii', + 'deallocate', + 'deb-control', + 'debian', + 'debris', + 'debug', + 'debug-specification', + 'debugger', + 'debugging', + 'debugging-comment', + 'dec', + 'decal', + 'decimal', + 'decimal-arithmetic', + 'decision', + 'decl', + 'declaration', + 'declaration-expr', + 'declaration-prod', + 'declarations', + 'declarator', + 'declaratyion', + 'declare', + 'decode', + 'decoration', + 'decorator', + 'decreasing', + 'decrement', + 'def', + 'default', + 'define', + 'define-colour', + 'defined', + 'definedness', + 'definingobj', + 'definition', + 'definitions', + 'defintions', + 'deflate', + 'delay', + 'delegated', + 'delete', + 'deleted', + 'deletion', + 'delimeter', + 'delimited', + 'delimiter', + 'delimiter-too-long', + 'delimiters', + 'dense', + 'deprecated', + 'depricated', + 'dereference', + 'derived-type', + 'deriving', + 'desc', + 'describe', + 'description', + 'descriptors', + 'design', + 'desktop', + 'destination', + 'destructor', + 'destructured', + 'determ', + 'developer', + 'device', + 'device-io', + 'dformat', + 'dg', + 'dhcp', + 'diagnostic', + 'dialogue', + 'diamond', + 'dict', + 'dictionary', + 'dictionaryname', + 'diff', + 'difference', + 'different', + 'diffuse-reflectivity', + 'digdag', + 'digit-width', + 'dim', + 'dimension', + 'dip', + 'dir', + 'dir-target', + 'dircolors', + 'direct', + 'direction', + 'directive', + 'directive-option', + 'directives', + 'directory', + 'dirjs', + 'dirtyblue', + 'dirtygreen', + 'disable', + 'disable-markdown', + 'disable-todo', + 'discarded', + 'discusson', + 'disjunction', + 'disk', + 'disk-folder-file', + 'dism', + 'displacement', + 'display', + 'dissolve', + 'dissolve-interpolation', + 'distribution', + 'diverging-function', + 'divert', + 'divide', + 'divider', + 'django', + 'dl', + 'dlv', + 'dm', + 'dmf', + 'dml', + 'do', + 'dobody', + 'doc', + 'doc-comment', + 'docRoot', + 'dockerfile', + 'dockerignore', + 'doconce', + 'docstring', + 'doctest', + 'doctree-option', + 'doctype', + 'document', + 'documentation', + 'documentroot', + 'does', + 'dogescript', + 'doki', + 'dollar', + 'dollar-quote', + 'dollar_variable', + 'dom', + 'domain', + 'dontcollect', + 'doors', + 'dop', + 'dot', + 'dot-access', + 'dotenv', + 'dotfiles', + 'dothandout', + 'dotnet', + 'dotnote', + 'dots', + 'dotted', + 'dotted-circle', + 'dotted-del', + 'dotted-greater', + 'dotted-tack-up', + 'double', + 'double-arrow', + 'double-colon', + 'double-dash', + 'double-dash-not-allowed', + 'double-dot', + 'double-number-sign', + 'double-percentage', + 'double-qoute', + 'double-quote', + 'double-quoted', + 'double-quoted-string', + 'double-semicolon', + 'double-slash', + 'doublequote', + 'doubleslash', + 'dougle', + 'down', + 'download', + 'downwards', + 'doxyfile', + 'doxygen', + 'dragdrop', + 'drawing', + 'drive', + 'droiuby', + 'drop', + 'drop-shadow', + 'droplevel', + 'drummode', + 'drupal', + 'dsl', + 'dsv', + 'dt', + 'dtl', + 'due', + 'dummy', + 'dummy-variable', + 'dump', + 'duration', + 'dust', + 'dust_Conditional', + 'dust_end_section_tag', + 'dust_filter', + 'dust_partial', + 'dust_partial_not_self_closing', + 'dust_ref', + 'dust_ref_name', + 'dust_section_context', + 'dust_section_name', + 'dust_section_params', + 'dust_self_closing_section_tag', + 'dust_special', + 'dust_start_section_tag', + 'dustjs', + 'dut', + 'dwscript', + 'dxl', + 'dylan', + 'dynamic', + 'dyndoc', + 'dyon', + 'e', + 'e3globals', + 'each', + 'eachin', + 'earl-grey', + 'ebnf', + 'ebuild', + 'echo', + 'eclass', + 'ecmascript', + 'eco', + 'ecr', + 'ect', + 'ect2', + 'ect3', + 'ect4', + 'edasm', + 'edge', + 'edit-manager', + 'editfields', + 'editors', + 'ee', + 'eex', + 'effect', + 'effectgroup', + 'effective_routine_body', + 'effects', + 'eiffel', + 'eight', + 'eio', + 'eiz', + 'ejectors', + 'el', + 'elasticsearch', + 'elasticsearch2', + 'element', + 'elements', + 'elemnt', + 'elif', + 'elipse', + 'elision', + 'elixir', + 'ellipsis', + 'elm', + 'elmx', + 'else', + 'else-condition', + 'else-if', + 'elseif', + 'elseif-condition', + 'elsewhere', + 'eltype', + 'elvis', + 'em', + 'email', + 'embed', + 'embed-diversion', + 'embedded', + 'embedded-c', + 'embedded-ruby', + 'embedded2', + 'embeded', + 'ember', + 'emberscript', + 'emblem', + 'embperl', + 'emissive-colour', + 'eml', + 'emlist', + 'emoji', + 'emojicode', + 'emp', + 'emph', + 'emphasis', + 'empty', + 'empty-dictionary', + 'empty-list', + 'empty-parenthesis', + 'empty-start', + 'empty-string', + 'empty-tag', + 'empty-tuple', + 'empty-typing-pair', + 'empty_gif', + 'emptyelement', + 'en', + 'en-Scouse', + 'en-au', + 'en-lol', + 'en-old', + 'en-pirate', + 'enable', + 'enc', + 'enchant', + 'enclose', + 'encode', + 'encoding', + 'encryption', + 'end', + 'end-block-data', + 'end-definition', + 'end-document', + 'end-enum', + 'end-footnote', + 'end-of-line', + 'end-statement', + 'end-value', + 'endassociate', + 'endcode', + 'enddo', + 'endfile', + 'endforall', + 'endfunction', + 'endian', + 'endianness', + 'endif', + 'endinfo', + 'ending', + 'ending-space', + 'endinterface', + 'endlocaltable', + 'endmodule', + 'endobject', + 'endobjecttable', + 'endparamtable', + 'endprogram', + 'endproperty', + 'endpropertygroup', + 'endpropertygrouptable', + 'endpropertytable', + 'endselect', + 'endstate', + 'endstatetable', + 'endstruct', + 'endstructtable', + 'endsubmodule', + 'endsubroutine', + 'endtimeblock', + 'endtype', + 'enduserflagsref', + 'endvariable', + 'endvariabletable', + 'endwhere', + 'engine', + 'enterprise', + 'entity', + 'entity-creation-and-abolishing', + 'entity_instantiation', + 'entry', + 'entry-definition', + 'entry-key', + 'entry-type', + 'entrypoint', + 'enum', + 'enum-block', + 'enum-declaration', + 'enumeration', + 'enumerator', + 'enumerator-specification', + 'env', + 'environment', + 'environment-variable', + 'eo', + 'eof', + 'epatch', + 'eq', + 'eqn', + 'eqnarray', + 'equal', + 'equal-or-greater', + 'equal-or-less', + 'equalexpr', + 'equality', + 'equals', + 'equals-sign', + 'equation', + 'equation-label', + 'erb', + 'ereg', + 'erlang', + 'error', + 'error-control', + 'errorfunc', + 'errorstop', + 'es', + 'es6', + 'es6import', + 'esc', + 'escape', + 'escape-char', + 'escape-code', + 'escape-sequence', + 'escape-unicode', + 'escaped', + 'escapes', + 'escript', + 'eso-lua', + 'eso-txt', + 'essence', + 'et', + 'eth', + 'ethaddr', + 'etml', + 'etpl', + 'eudoc', + 'euler', + 'euphoria', + 'european', + 'evaled', + 'evaluable', + 'evaluation', + 'even-tab', + 'event', + 'event-call', + 'event-handler', + 'event-handling', + 'event-schedulling', + 'eventType', + 'eventb', + 'eventend', + 'events', + 'evnd', + 'exactly', + 'example', + 'exampleText', + 'examples', + 'exceeding-sections', + 'excel-link', + 'exception', + 'exceptions', + 'exclaimation-point', + 'exclamation', + 'exec', + 'exec-command', + 'execution-context', + 'exif', + 'existential', + 'exit', + 'exp', + 'expand-register', + 'expanded', + 'expansion', + 'expected-array-separator', + 'expected-dictionary-separator', + 'expected-extends', + 'expected-implements', + 'expected-range-separator', + 'experimental', + 'expires', + 'expl3', + 'explosion', + 'exponent', + 'exponential', + 'export', + 'exports', + 'expr', + 'expression', + 'expression-separator', + 'expression-seperator', + 'expressions', + 'expressions-and-types', + 'exprwrap', + 'ext', + 'extempore', + 'extend', + 'extended', + 'extends', + 'extension', + 'extension-specification', + 'extensions', + 'extern', + 'extern-block', + 'external', + 'external-call', + 'external-signature', + 'extersk', + 'extglob', + 'extra', + 'extra-characters', + 'extra-equals-sign', + 'extracted', + 'extras', + 'extrassk', + 'exxample', + 'eztpl', + 'f', + 'f5networks', + 'fa', + 'face', + 'fact', + 'factor', + 'factorial', + 'fadeawayheight', + 'fadeawaywidth', + 'fail', + 'fakeroot', + 'fallback', + 'fallout4', + 'false', + 'fandoc', + 'fann', + 'fantom', + 'fastcgi', + 'fbaccidental', + 'fbfigure', + 'fbgroupclose', + 'fbgroupopen', + 'fbp', + 'fctn', + 'fe', + 'feature', + 'features', + 'feedrate', + 'fenced', + 'fftwfn', + 'fhem', + 'fi', + 'field', + 'field-assignment', + 'field-completions', + 'field-id', + 'field-level-comment', + 'field-name', + 'field-tag', + 'fields', + 'figbassmode', + 'figure', + 'figuregroup', + 'filder-design-hdl-coder', + 'file', + 'file-i-o', + 'file-io', + 'file-name', + 'file-object', + 'file-path', + 'fileinfo', + 'filename', + 'filepath', + 'filetest', + 'filter', + 'filter-pipe', + 'filteredtranscludeblock', + 'filters', + 'final', + 'final-procedure', + 'finally', + 'financial', + 'financial-derivatives', + 'find', + 'find-in-files', + 'find-m', + 'finder', + 'finish', + 'finn', + 'fire', + 'firebug', + 'first', + 'first-class', + 'first-line', + 'fish', + 'fitnesse', + 'five', + 'fix_this_later', + 'fixed', + 'fixed-income', + 'fixed-point', + 'fixme', + 'fl', + 'flag', + 'flag-control', + 'flags', + 'flash', + 'flatbuffers', + 'flex-config', + 'fload', + 'float', + 'float-exponent', + 'float_exp', + 'floating-point', + 'floating_point', + 'floor', + 'flow', + 'flow-control', + 'flowcontrol', + 'flows', + 'flowtype', + 'flush', + 'fma', + 'fma4', + 'fmod', + 'fn', + 'fold', + 'folder', + 'folder-actions', + 'following', + 'font', + 'font-cache', + 'font-face', + 'font-name', + 'font-size', + 'fontface', + 'fontforge', + 'foobar', + 'footer', + 'footnote', + 'for', + 'for-in-loop', + 'for-loop', + 'for-quantity', + 'forall', + 'force', + 'foreach', + 'foreign', + 'forever', + 'forge-config', + 'forin', + 'form', + 'form-feed', + 'formal', + 'format', + 'format-register', + 'format-verb', + 'formatted', + 'formatter', + 'formatting', + 'forth', + 'fortran', + 'forward', + 'foundation', + 'fountain', + 'four', + 'fourd-command', + 'fourd-constant', + 'fourd-constant-hex', + 'fourd-constant-number', + 'fourd-constant-string', + 'fourd-control-begin', + 'fourd-control-end', + 'fourd-declaration', + 'fourd-declaration-array', + 'fourd-local-variable', + 'fourd-parameter', + 'fourd-table', + 'fourd-tag', + 'fourd-variable', + 'fpm', + 'fpu', + 'fpu_x87', + 'fr', + 'fragment', + 'frame', + 'frames', + 'frametitle', + 'framexml', + 'free', + 'free-form', + 'freebasic', + 'freefem', + 'freespace2', + 'from', + 'from-file', + 'front-matter', + 'fs', + 'fs2', + 'fsc', + 'fsgsbase', + 'fsharp', + 'fsi', + 'fsl', + 'fsm', + 'fsp', + 'fsx', + 'fth', + 'ftl', + 'ftl20n', + 'full-line', + 'full-stop', + 'fun', + 'funarg', + 'func-tag', + 'func_call', + 'funchand', + 'function', + 'function-arity', + 'function-attribute', + 'function-call', + 'function-definition', + 'function-literal', + 'function-parameter', + 'function-recursive', + 'function-return', + 'function-type', + 'functionDeclaration', + 'functionDefinition', + 'function_definition', + 'function_prototype', + 'functional_test', + 'functionend', + 'functions', + 'functionstart', + 'fundimental', + 'funk', + 'funtion-definition', + 'fus', + 'future', + 'futures', + 'fuzzy-logic', + 'fx', + 'fx-foliage-replicator', + 'fx-light', + 'fx-shape-replicator', + 'fx-sun-light', + 'g', + 'g-code', + 'ga', + 'gain', + 'galaxy', + 'gallery', + 'game-base', + 'game-connection', + 'game-server', + 'gamebusk', + 'gamescript', + 'gams', + 'gams-lst', + 'gap', + 'garch', + 'gather', + 'gcode', + 'gdb', + 'gdscript', + 'gdx', + 'ge', + 'geant4-macro', + 'geck', + 'geck-keyword', + 'general', + 'general-purpose', + 'generate', + 'generator', + 'generic', + 'generic-config', + 'generic-spec', + 'generic-type', + 'generic_list', + 'genericcall', + 'generics', + 'genetic-algorithms', + 'geo', + 'geometric', + 'geometry', + 'geometry-adjustment', + 'get', + 'getproperty', + 'getsec', + 'getset', + 'getter', + 'gettext', + 'getword', + 'gfm', + 'gfm-todotxt', + 'gfx', + 'gh-number', + 'gherkin', + 'gisdk', + 'git', + 'git-attributes', + 'git-commit', + 'git-config', + 'git-rebase', + 'gitignore', + 'given', + 'gj', + 'gl', + 'glob', + 'global', + 'global-functions', + 'globals', + 'globalsection', + 'glsl', + 'glue', + 'glyph_class_name', + 'glyphname-value', + 'gml', + 'gmp', + 'gmsh', + 'gmx', + 'gn', + 'gnu', + 'gnuplot', + 'go', + 'goal', + 'goatee', + 'godmode', + 'gohtml', + 'gold', + 'golo', + 'google', + 'gosub', + 'gotemplate', + 'goto', + 'goto-label', + 'gpd', + 'gpd_note', + 'gpp', + 'grace', + 'grade-down', + 'grade-up', + 'gradient', + 'gradle', + 'grails', + 'grammar', + 'grammar-rule', + 'grammar_production', + 'grap', + 'grapahql', + 'graph', + 'graphics', + 'graphql', + 'grave-accent', + 'gray', + 'greater', + 'greater-equal', + 'greater-or-equal', + 'greek', + 'greek-letter', + 'green', + 'gremlin', + 'grey', + 'grg', + 'grid-table', + 'gridlists', + 'grog', + 'groovy', + 'groovy-properties', + 'group', + 'group-level-comment', + 'group-name', + 'group-number', + 'group-reference', + 'group-title', + 'group1', + 'group10', + 'group11', + 'group2', + 'group3', + 'group4', + 'group5', + 'group6', + 'group7', + 'group8', + 'group9', + 'groupend', + 'groupflag', + 'grouping-statement', + 'groupname', + 'groupstart', + 'growl', + 'grr', + 'gs', + 'gsc', + 'gsp', + 'gt', + 'guard', + 'guards', + 'gui', + 'gui-bitmap-ctrl', + 'gui-button-base-ctrl', + 'gui-canvas', + 'gui-control', + 'gui-filter-ctrl', + 'gui-frameset-ctrl', + 'gui-menu-bar', + 'gui-message-vector-ctrl', + 'gui-ml-text-ctrl', + 'gui-popup-menu-ctrl', + 'gui-scroll-ctrl', + 'gui-slider-ctrl', + 'gui-text-ctrl', + 'gui-text-edit-ctrl', + 'gui-text-list-ctrl', + 'guid', + 'guillemot', + 'guis', + 'gzip', + 'gzip_static', + 'h', + 'h1', + 'hack', + 'hackfragment', + 'haddock', + 'hairpin', + 'ham', + 'haml', + 'hamlbars', + 'hamlc', + 'hamlet', + 'hamlpy', + 'handlebar', + 'handlebars', + 'handler', + 'hanging-paragraph', + 'haproxy-config', + 'harbou', + 'harbour', + 'hard-break', + 'hardlinebreaks', + 'hash', + 'hash-tick', + 'hashbang', + 'hashicorp', + 'hashkey', + 'haskell', + 'haxe', + 'hbs', + 'hcl', + 'hdl', + 'hdr', + 'he', + 'header', + 'header-continuation', + 'header-value', + 'headername', + 'headers', + 'heading', + 'heading-0', + 'heading-1', + 'heading-2', + 'heading-3', + 'heading-4', + 'heading-5', + 'heading-6', + 'height', + 'helen', + 'help', + 'helper', + 'helpers', + 'heredoc', + 'heredoc-token', + 'herestring', + 'heritage', + 'hex', + 'hex-ascii', + 'hex-byte', + 'hex-literal', + 'hex-old', + 'hex-string', + 'hex-value', + 'hex8', + 'hexadecimal', + 'hexidecimal', + 'hexprefix', + 'hg-commit', + 'hgignore', + 'hi', + 'hidden', + 'hide', + 'high-minus', + 'highlight-end', + 'highlight-group', + 'highlight-start', + 'hint', + 'history', + 'hive', + 'hive-name', + 'hjson', + 'hl7', + 'hlsl', + 'hn', + 'hoa', + 'hoc', + 'hocharacter', + 'hocomment', + 'hocon', + 'hoconstant', + 'hocontinuation', + 'hocontrol', + 'hombrew-formula', + 'homebrew', + 'homematic', + 'hook', + 'hoon', + 'horizontal-blending', + 'horizontal-packed-arithmetic', + 'horizontal-rule', + 'hostname', + 'hosts', + 'hour', + 'hours', + 'hps', + 'hql', + 'hr', + 'hrm', + 'hs', + 'hsc2hs', + 'ht', + 'htaccess', + 'htl', + 'html', + 'html_entity', + 'htmlbars', + 'http', + 'hu', + 'hungary', + 'hxml', + 'hy', + 'hydrant', + 'hydrogen', + 'hyperbolic', + 'hyperlink', + 'hyphen', + 'hyphenation', + 'hyphenation-char', + 'i', + 'i-beam', + 'i18n', + 'iRev', + 'ice', + 'icinga2', + 'icmc', + 'icmptype', + 'icmpv6type', + 'icmpxtype', + 'iconv', + 'id', + 'id-type', + 'id-with-protocol', + 'idd', + 'ideal', + 'identical', + 'identifer', + 'identified', + 'identifier', + 'identifier-type', + 'identifiers-and-DTDs', + 'identity', + 'idf', + 'idl', + 'idris', + 'ieee', + 'if', + 'if-block', + 'if-branch', + 'if-condition', + 'if-else', + 'if-then', + 'ifacespec', + 'ifdef', + 'ifname', + 'ifndef', + 'ignore', + 'ignore-eol', + 'ignore-errors', + 'ignorebii', + 'ignored', + 'ignored-binding', + 'ignoring', + 'iisfunc', + 'ijk', + 'ilasm', + 'illagal', + 'illeagal', + 'illegal', + 'illumination-model', + 'image', + 'image-acquisition', + 'image-alignment', + 'image-option', + 'image-processing', + 'images', + 'imap', + 'imba', + 'imfchan', + 'img', + 'immediate', + 'immediately-evaluated', + 'immutable', + 'impex', + 'implementation', + 'implementation-defined-hooks', + 'implemented', + 'implements', + 'implicit', + 'import', + 'import-all', + 'importall', + 'important', + 'in', + 'in-block', + 'in-module', + 'in-out', + 'inappropriate', + 'include', + 'include-statement', + 'includefile', + 'incomplete', + 'incomplete-variable-assignment', + 'inconsistent', + 'increment', + 'increment-decrement', + 'indent', + 'indented', + 'indented-paragraph', + 'indepimage', + 'index', + 'index-seperator', + 'indexed', + 'indexer', + 'indexes', + 'indicator', + 'indices', + 'indirect', + 'indirection', + 'individual-enum-definition', + 'individual-rpc-call', + 'inet', + 'inetprototype', + 'inferred', + 'infes', + 'infinity', + 'infix', + 'info', + 'inform', + 'inform6', + 'inform7', + 'infotype', + 'ingore-eol', + 'inherit', + 'inheritDoc', + 'inheritance', + 'inherited', + 'inherited-class', + 'inherited-struct', + 'inherits', + 'ini', + 'init', + 'initial-lowercase', + 'initial-uppercase', + 'initial-value', + 'initialization', + 'initialize', + 'initializer-list', + 'ink', + 'inline', + 'inline-data', + 'inlineConditionalBranchSeparator', + 'inlineConditionalClause', + 'inlineConditionalEnd', + 'inlineConditionalStart', + 'inlineLogicEnd', + 'inlineLogicStart', + 'inlineSequenceEnd', + 'inlineSequenceSeparator', + 'inlineSequenceStart', + 'inlineSequenceTypeChar', + 'inlineblock', + 'inlinecode', + 'inlinecomment', + 'inlinetag', + 'inner', + 'inner-class', + 'inno', + 'ino', + 'inout', + 'input', + 'inquire', + 'inserted', + 'insertion', + 'insertion-and-extraction', + 'inside', + 'install', + 'instance', + 'instancemethods', + 'instanceof', + 'instances', + 'instantiation', + 'instruction', + 'instruction-pointer', + 'instructions', + 'instrument', + 'instrument-block', + 'instrument-control', + 'instrument-declaration', + 'int', + 'int32', + 'int64', + 'integer', + 'integer-float', + 'intel', + 'intel-hex', + 'intent', + 'intepreted', + 'interaction', + 'interbase', + 'interface', + 'interface-block', + 'interface-or-protocol', + 'interfaces', + 'interior-instance', + 'interiors', + 'interlink', + 'internal', + 'internet', + 'interpolate-argument', + 'interpolate-string', + 'interpolate-variable', + 'interpolated', + 'interpolation', + 'interrupt', + 'intersection', + 'interval', + 'intervalOrList', + 'intl', + 'intrinsic', + 'intuicio4', + 'invalid', + 'invalid-character', + 'invalid-character-escape', + 'invalid-inequality', + 'invalid-quote', + 'invalid-variable-name', + 'invariant', + 'invocation', + 'invoke', + 'invokee', + 'io', + 'ior', + 'iota', + 'ip', + 'ip-port', + 'ip6', + 'ipkg', + 'ipsec', + 'ipv4', + 'ipv6', + 'ipynb', + 'irct', + 'irule', + 'is', + 'isa', + 'isc', + 'iscexport', + 'isclass', + 'isml', + 'issue', + 'it', + 'italic', + 'italic-text', + 'item', + 'item-access', + 'itemlevel', + 'items', + 'iteration', + 'itunes', + 'ivar', + 'ja', + 'jack', + 'jade', + 'jakefile', + 'jasmin', + 'java', + 'java-properties', + 'java-props', + 'javadoc', + 'javascript', + 'jbeam', + 'jekyll', + 'jflex', + 'jibo-rule', + 'jinja', + 'jison', + 'jisonlex', + 'jmp', + 'joint', + 'joker', + 'jolie', + 'jot', + 'journaling', + 'jpl', + 'jq', + 'jquery', + 'js', + 'js-label', + 'jsdoc', + 'jsduck', + 'jsim', + 'json', + 'json5', + 'jsoniq', + 'jsonnet', + 'jsont', + 'jsp', + 'jsx', + 'julia', + 'julius', + 'jump', + 'juniper', + 'juniper-junos-config', + 'junit-test-report', + 'junos', + 'juttle', + 'jv', + 'jxa', + 'k', + 'kag', + 'kagex', + 'kb', + 'kbd', + 'kconfig', + 'kerboscript', + 'kernel', + 'kevs', + 'kevscript', + 'kewyword', + 'key', + 'key-assignment', + 'key-letter', + 'key-pair', + 'key-path', + 'key-value', + 'keyboard', + 'keyframe', + 'keyframes', + 'keygroup', + 'keyname', + 'keyspace', + 'keyspace-name', + 'keyvalue', + 'keyword', + 'keyword-parameter', + 'keyword1', + 'keyword2', + 'keyword3', + 'keyword4', + 'keyword5', + 'keyword6', + 'keyword7', + 'keyword8', + 'keyword_arrays', + 'keyword_objects', + 'keyword_roots', + 'keyword_string', + 'keywords', + 'keywork', + 'kickstart', + 'kind', + 'kmd', + 'kn', + 'knitr', + 'knockout', + 'knot', + 'ko', + 'ko-virtual', + 'kos', + 'kotlin', + 'krl', + 'ksp-cfg', + 'kspcfg', + 'kurumin', + 'kv', + 'kxi', + 'kxigauge', + 'l', + 'l20n', + 'l4proto', + 'label', + 'label-expression', + 'labeled', + 'labeled-parameter', + 'labelled-thing', + 'lagda', + 'lambda', + 'lambda-function', + 'lammps', + 'langref', + 'language', + 'language-range', + 'languagebabel', + 'langversion', + 'largesk', + 'lasso', + 'last', + 'last-paren-match', + 'latex', + 'latex2', + 'latino', + 'latte', + 'launch', + 'layout', + 'layoutbii', + 'lbsearch', + 'lc', + 'lc-3', + 'lcb', + 'ldap', + 'ldif', + 'le', + 'leader-char', + 'leading', + 'leading-space', + 'leading-tabs', + 'leaf', + 'lean', + 'ledger', + 'left', + 'left-margin', + 'leftshift', + 'lefttoright', + 'legacy', + 'legacy-setting', + 'lemon', + 'len', + 'length', + 'leopard', + 'less', + 'less-equal', + 'less-or-equal', + 'let', + 'letter', + 'level', + 'level-of-detail', + 'level1', + 'level2', + 'level3', + 'level4', + 'level5', + 'level6', + 'levels', + 'lex', + 'lexc', + 'lexical', + 'lf-in-string', + 'lhs', + 'li', + 'lib', + 'libfile', + 'library', + 'libs', + 'libxml', + 'lid', + 'lifetime', + 'ligature', + 'light', + 'light_purple', + 'lighting', + 'lightning', + 'lilypond', + 'lilypond-drummode', + 'lilypond-figbassmode', + 'lilypond-figuregroup', + 'lilypond-internals', + 'lilypond-lyricsmode', + 'lilypond-markupmode', + 'lilypond-notedrum', + 'lilypond-notemode', + 'lilypond-notemode-explicit', + 'lilypond-notenames', + 'lilypond-schememode', + 'limit_zone', + 'line-block', + 'line-break', + 'line-continuation', + 'line-cue-setting', + 'line-statement', + 'line-too-long', + 'linebreak', + 'linenumber', + 'link', + 'link-label', + 'link-text', + 'link-url', + 'linkage', + 'linkage-type', + 'linkedin', + 'linkedsockets', + 'linkplain', + 'linkplain-label', + 'linq', + 'linuxcncgcode', + 'liquid', + 'liquidhaskell', + 'liquidsoap', + 'lisp', + 'lisp-repl', + 'list', + 'list-done', + 'list-separator', + 'list-style-type', + 'list-today', + 'list_item', + 'listing', + 'listnum', + 'listvalues', + 'litaco', + 'litcoffee', + 'literal', + 'literal-string', + 'literate', + 'litword', + 'livecodescript', + 'livescript', + 'livescriptscript', + 'll', + 'llvm', + 'load-constants', + 'load-hint', + 'loader', + 'local', + 'local-variables', + 'localhost', + 'localizable', + 'localized', + 'localname', + 'locals', + 'localtable', + 'location', + 'lock', + 'log', + 'log-debug', + 'log-error', + 'log-failed', + 'log-info', + 'log-patch', + 'log-success', + 'log-verbose', + 'log-warning', + 'logarithm', + 'logging', + 'logic', + 'logicBegin', + 'logical', + 'logical-expression', + 'logicblox', + 'logicode', + 'logo', + 'logstash', + 'logtalk', + 'lol', + 'long', + 'look-ahead', + 'look-behind', + 'lookahead', + 'lookaround', + 'lookbehind', + 'loop', + 'loop-control', + 'low-high', + 'lowercase', + 'lowercase_character_not_allowed_here', + 'lozenge', + 'lparen', + 'lsg', + 'lsl', + 'lst', + 'lst-cpu12', + 'lstdo', + 'lt', + 'lt-gt', + 'lterat', + 'lu', + 'lua', + 'lucee', + 'lucius', + 'lury', + 'lv', + 'lyricsmode', + 'm', + 'm4', + 'm4sh', + 'm65816', + 'm68k', + 'mac-classic', + 'mac-fsaa', + 'machine', + 'machineclause', + 'macro', + 'macro-usage', + 'macro11', + 'macrocallblock', + 'macrocallinline', + 'madoko', + 'magenta', + 'magic', + 'magik', + 'mail', + 'mailer', + 'mailto', + 'main', + 'makefile', + 'makefile2', + 'mako', + 'mamba', + 'man', + 'mantissa', + 'manualmelisma', + 'map', + 'map-library', + 'map-name', + 'mapfile', + 'mapkey', + 'mapping', + 'mapping-type', + 'maprange', + 'marasm', + 'margin', + 'marginpar', + 'mark', + 'mark-input', + 'markdown', + 'marker', + 'marko', + 'marko-attribute', + 'marko-tag', + 'markup', + 'markupmode', + 'mas2j', + 'mask', + 'mason', + 'mat', + 'mata', + 'match', + 'match-bind', + 'match-branch', + 'match-condition', + 'match-definition', + 'match-exception', + 'match-option', + 'match-pattern', + 'material', + 'material-library', + 'material-name', + 'math', + 'math-symbol', + 'math_complex', + 'math_real', + 'mathematic', + 'mathematica', + 'mathematical', + 'mathematical-symbols', + 'mathematics', + 'mathjax', + 'mathml', + 'matlab', + 'matrix', + 'maude', + 'maven', + 'max', + 'max-angle', + 'max-distance', + 'max-length', + 'maxscript', + 'maybe', + 'mb', + 'mbstring', + 'mc', + 'mcc', + 'mccolor', + 'mch', + 'mcn', + 'mcode', + 'mcq', + 'mcr', + 'mcrypt', + 'mcs', + 'md', + 'mdash', + 'mdoc', + 'mdx', + 'me', + 'measure', + 'media', + 'media-feature', + 'media-property', + 'media-type', + 'mediawiki', + 'mei', + 'mel', + 'memaddress', + 'member', + 'member-function-attribute', + 'member-of', + 'membership', + 'memcache', + 'memcached', + 'memoir', + 'memoir-alltt', + 'memoir-fbox', + 'memoir-verbatim', + 'memory', + 'memory-management', + 'memory-protection', + 'memos', + 'menhir', + 'mention', + 'menu', + 'mercury', + 'merge-group', + 'merge-key', + 'merlin', + 'mesgTrigger', + 'mesgType', + 'message', + 'message-declaration', + 'message-forwarding-handler', + 'message-sending', + 'message-vector', + 'messages', + 'meta', + 'meta-conditional', + 'meta-data', + 'meta-file', + 'meta-info', + 'metaclass', + 'metacommand', + 'metadata', + 'metakey', + 'metamodel', + 'metapost', + 'metascript', + 'meteor', + 'method', + 'method-call', + 'method-definition', + 'method-modification', + 'method-mofification', + 'method-parameter', + 'method-parameters', + 'method-restriction', + 'methodcalls', + 'methods', + 'metrics', + 'mhash', + 'microsites', + 'microsoft-dynamics', + 'middle', + 'midi_processing', + 'migration', + 'mime', + 'min', + 'minelua', + 'minetweaker', + 'minitemplate', + 'minitest', + 'minus', + 'minute', + 'mips', + 'mirah', + 'misc', + 'miscellaneous', + 'mismatched', + 'missing', + 'missing-asterisk', + 'missing-inheritance', + 'missing-parameters', + 'missing-section-begin', + 'missingend', + 'mission-area', + 'mixin', + 'mixin-name', + 'mjml', + 'ml', + 'mlab', + 'mls', + 'mm', + 'mml', + 'mmx', + 'mmx_instructions', + 'mn', + 'mnemonic', + 'mobile-messaging', + 'mochi', + 'mod', + 'mod-r', + 'mod_perl', + 'mod_perl_1', + 'modblock', + 'modbus', + 'mode', + 'model', + 'model-based-calibration', + 'model-predictive-control', + 'modelica', + 'modelicascript', + 'modeline', + 'models', + 'modern', + 'modified', + 'modifier', + 'modifiers', + 'modify', + 'modify-range', + 'modifytime', + 'modl', + 'modr', + 'modula-2', + 'module', + 'module-alias', + 'module-binding', + 'module-definition', + 'module-expression', + 'module-function', + 'module-reference', + 'module-rename', + 'module-sum', + 'module-type', + 'module-type-definition', + 'modules', + 'modulo', + 'modx', + 'mojolicious', + 'mojom', + 'moment', + 'mond', + 'money', + 'mongo', + 'mongodb', + 'monicelli', + 'monitor', + 'monkberry', + 'monkey', + 'monospace', + 'monospaced', + 'monte', + 'month', + 'moon', + 'moonscript', + 'moos', + 'moose', + 'moosecpp', + 'motion', + 'mouse', + 'mov', + 'movement', + 'movie', + 'movie-file', + 'mozu', + 'mpw', + 'mpx', + 'mqsc', + 'ms', + 'mscgen', + 'mscript', + 'msg', + 'msgctxt', + 'msgenny', + 'msgid', + 'msgstr', + 'mson', + 'mson-block', + 'mss', + 'mta', + 'mtl', + 'mucow', + 'mult', + 'multi', + 'multi-line', + 'multi-symbol', + 'multi-threading', + 'multiclet', + 'multids-file', + 'multiline', + 'multiline-cell', + 'multiline-text-reference', + 'multiline-tiddler-title', + 'multimethod', + 'multipart', + 'multiplication', + 'multiplicative', + 'multiply', + 'multiverse', + 'mumps', + 'mundosk', + 'music', + 'must_be', + 'mustache', + 'mut', + 'mutable', + 'mutator', + 'mx', + 'mxml', + 'mydsl1', + 'mylanguage', + 'mysql', + 'mysqli', + 'mysqlnd-memcache', + 'mysqlnd-ms', + 'mysqlnd-qc', + 'mysqlnd-uh', + 'mzn', + 'nabla', + 'nagios', + 'name', + 'name-list', + 'name-of-parameter', + 'named', + 'named-char', + 'named-key', + 'named-tuple', + 'nameless-typed', + 'namelist', + 'names', + 'namespace', + 'namespace-block', + 'namespace-definition', + 'namespace-language', + 'namespace-prefix', + 'namespace-reference', + 'namespace-statement', + 'namespaces', + 'nan', + 'nand', + 'nant', + 'nant-build', + 'narration', + 'nas', + 'nasal', + 'nasl', + 'nasm', + 'nastran', + 'nat', + 'native', + 'nativeint', + 'natural', + 'navigation', + 'nbtkey', + 'ncf', + 'ncl', + 'ndash', + 'ne', + 'nearley', + 'neg-ratio', + 'negatable', + 'negate', + 'negated', + 'negation', + 'negative', + 'negative-look-ahead', + 'negative-look-behind', + 'negativity', + 'nesc', + 'nessuskb', + 'nested', + 'nested_braces', + 'nested_brackets', + 'nested_ltgt', + 'nested_parens', + 'nesty', + 'net', + 'net-object', + 'netbios', + 'network', + 'network-value', + 'networking', + 'neural-network', + 'new', + 'new-line', + 'new-object', + 'newline', + 'newline-spacing', + 'newlinetext', + 'newlisp', + 'newobject', + 'nez', + 'nft', + 'ngdoc', + 'nginx', + 'nickname', + 'nil', + 'nim', + 'nine', + 'ninja', + 'ninjaforce', + 'nit', + 'nitro', + 'nix', + 'nl', + 'nlf', + 'nm', + 'nm7', + 'no', + 'no-capture', + 'no-completions', + 'no-content', + 'no-default', + 'no-indent', + 'no-leading-digits', + 'no-trailing-digits', + 'no-validate-params', + 'node', + 'nogc', + 'noindent', + 'nokia-sros-config', + 'non', + 'non-capturing', + 'non-immediate', + 'non-null-typehinted', + 'non-standard', + 'non-terminal', + 'nondir-target', + 'none', + 'none-parameter', + 'nonlocal', + 'nonterminal', + 'noon', + 'noop', + 'nop', + 'noparams', + 'nor', + 'normal', + 'normal_numeric', + 'normal_objects', + 'normal_text', + 'normalised', + 'not', + 'not-a-number', + 'not-equal', + 'not-identical', + 'notation', + 'note', + 'notechord', + 'notemode', + 'notequal', + 'notequalexpr', + 'notes', + 'notidentical', + 'notification', + 'nowdoc', + 'noweb', + 'nrtdrv', + 'nsapi', + 'nscript', + 'nse', + 'nsis', + 'nsl', + 'ntriples', + 'nul', + 'null', + 'nullify', + 'nullological', + 'nulltype', + 'num', + 'number', + 'number-sign', + 'number-sign-equals', + 'numbered', + 'numberic', + 'numbers', + 'numbersign', + 'numeric', + 'numeric_std', + 'numerical', + 'nunjucks', + 'nut', + 'nvatom', + 'nxc', + 'o', + 'obj', + 'objaggregation', + 'objc', + 'objcpp', + 'objdump', + 'object', + 'object-comments', + 'object-definition', + 'object-level-comment', + 'object-name', + 'objects', + 'objectset', + 'objecttable', + 'objectvalues', + 'objj', + 'obsolete', + 'ocaml', + 'ocamllex', + 'occam', + 'oci8', + 'ocmal', + 'oct', + 'octal', + 'octave', + 'octave-change', + 'octave-shift', + 'octet', + 'octo', + 'octobercms', + 'octothorpe', + 'odd-tab', + 'odedsl', + 'ods', + 'of', + 'off', + 'offset', + 'ofx', + 'ogre', + 'ok', + 'ol', + 'old', + 'old-style', + 'omap', + 'omitted', + 'on-background', + 'on-error', + 'once', + 'one', + 'one-sixth-em', + 'one-twelfth-em', + 'oniguruma', + 'oniguruma-comment', + 'only', + 'only-in', + 'onoff', + 'ooc', + 'oot', + 'op-domain', + 'op-range', + 'opa', + 'opaque', + 'opc', + 'opcache', + 'opcode', + 'opcode-argument-types', + 'opcode-declaration', + 'opcode-definition', + 'opcode-details', + 'open', + 'open-gl', + 'openal', + 'openbinding', + 'opencl', + 'opendss', + 'opening', + 'opening-text', + 'openmp', + 'openssl', + 'opentype', + 'operand', + 'operands', + 'operation', + 'operator', + 'operator2', + 'operator3', + 'operators', + 'opmask', + 'opmaskregs', + 'optical-density', + 'optimization', + 'option', + 'option-description', + 'option-toggle', + 'optional', + 'optional-parameter', + 'optional-parameter-assignment', + 'optionals', + 'optionname', + 'options', + 'optiontype', + 'or', + 'oracle', + 'orbbasic', + 'orcam', + 'orchestra', + 'order', + 'ordered', + 'ordered-block', + 'ordinal', + 'organized', + 'orgtype', + 'origin', + 'osiris', + 'other', + 'other-inherited-class', + 'other_buildins', + 'other_keywords', + 'others', + 'otherwise', + 'otherwise-expression', + 'out', + 'outer', + 'output', + 'overload', + 'override', + 'owner', + 'ownership', + 'oz', + 'p', + 'p4', + 'p5', + 'p8', + 'pa', + 'package', + 'package-definition', + 'package_body', + 'packages', + 'packed', + 'packed-arithmetic', + 'packed-blending', + 'packed-comparison', + 'packed-conversion', + 'packed-floating-point', + 'packed-integer', + 'packed-math', + 'packed-mov', + 'packed-other', + 'packed-shift', + 'packed-shuffle', + 'packed-test', + 'padlock', + 'page', + 'page-props', + 'pagebreak', + 'pair', + 'pair-programming', + 'paket', + 'pandoc', + 'papyrus', + 'papyrus-assembly', + 'paragraph', + 'parallel', + 'param', + 'param-list', + 'paramater', + 'paramerised-type', + 'parameter', + 'parameter-entity', + 'parameter-space', + 'parameterless', + 'parameters', + 'paramless', + 'params', + 'paramtable', + 'paramter', + 'paren', + 'paren-group', + 'parens', + 'parent', + 'parent-reference', + 'parent-selector', + 'parent-selector-suffix', + 'parenthases', + 'parentheses', + 'parenthesis', + 'parenthetical', + 'parenthetical_list', + 'parenthetical_pair', + 'parfor', + 'parfor-quantity', + 'parse', + 'parsed', + 'parser', + 'parser-function', + 'parser-token', + 'parser3', + 'part', + 'partial', + 'particle', + 'pascal', + 'pass', + 'pass-through', + 'passive', + 'passthrough', + 'password', + 'password-hash', + 'patch', + 'path', + 'path-camera', + 'path-pattern', + 'pathoperation', + 'paths', + 'pathspec', + 'patientId', + 'pattern', + 'pattern-argument', + 'pattern-binding', + 'pattern-definition', + 'pattern-match', + 'pattern-offset', + 'patterns', + 'pause', + 'payee', + 'payload', + 'pbo', + 'pbtxt', + 'pcdata', + 'pcntl', + 'pdd', + 'pddl', + 'ped', + 'pegcoffee', + 'pegjs', + 'pending', + 'percentage', + 'percentage-sign', + 'percussionnote', + 'period', + 'perl', + 'perl-section', + 'perl6', + 'perl6fe', + 'perlfe', + 'perlt6e', + 'perm', + 'permutations', + 'personalization', + 'pervasive', + 'pf', + 'pflotran', + 'pfm', + 'pfx', + 'pgn', + 'pgsql', + 'phone', + 'phone-number', + 'phonix', + 'php', + 'php-code-in-comment', + 'php_apache', + 'php_dom', + 'php_ftp', + 'php_imap', + 'php_mssql', + 'php_odbc', + 'php_pcre', + 'php_spl', + 'php_zip', + 'phpdoc', + 'phrasemodifiers', + 'phraslur', + 'physical-zone', + 'physics', + 'pi', + 'pic', + 'pick', + 'pickup', + 'picture', + 'pig', + 'pillar', + 'pipe', + 'pipe-sign', + 'pipeline', + 'piratesk', + 'pitch', + 'pixie', + 'pkgbuild', + 'pl', + 'placeholder', + 'placeholder-parts', + 'plain', + 'plainsimple-emphasize', + 'plainsimple-heading', + 'plainsimple-number', + 'plantuml', + 'player', + 'playerversion', + 'pld_modeling', + 'please-build', + 'please-build-defs', + 'plist', + 'plsql', + 'plugin', + 'plus', + 'plztarget', + 'pmc', + 'pml', + 'pmlPhysics-arrangecharacter', + 'pmlPhysics-emphasisequote', + 'pmlPhysics-graphic', + 'pmlPhysics-header', + 'pmlPhysics-htmlencoded', + 'pmlPhysics-links', + 'pmlPhysics-listtable', + 'pmlPhysics-physicalquantity', + 'pmlPhysics-relationships', + 'pmlPhysics-slides', + 'pmlPhysics-slidestacks', + 'pmlPhysics-speech', + 'pmlPhysics-structure', + 'pnt', + 'po', + 'pod', + 'poe', + 'pogoscript', + 'point', + 'point-size', + 'pointer', + 'pointer-arith', + 'pointer-following', + 'points', + 'polarcoord', + 'policiesbii', + 'policy', + 'polydelim', + 'polygonal', + 'polymer', + 'polymorphic', + 'polymorphic-variant', + 'polynomial-degree', + 'polysep', + 'pony', + 'port', + 'port_list', + 'pos-ratio', + 'position-cue-setting', + 'positional', + 'positive', + 'posix', + 'posix-reserved', + 'post-match', + 'postblit', + 'postcss', + 'postfix', + 'postpone', + 'postscript', + 'potigol', + 'potion', + 'pound', + 'pound-sign', + 'povray', + 'power', + 'power_set', + 'powershell', + 'pp', + 'ppc', + 'ppcasm', + 'ppd', + 'praat', + 'pragma', + 'pragma-all-once', + 'pragma-mark', + 'pragma-message', + 'pragma-newline-spacing', + 'pragma-newline-spacing-value', + 'pragma-once', + 'pragma-stg', + 'pragma-stg-value', + 'pre', + 'pre-defined', + 'pre-match', + 'preamble', + 'prec', + 'precedence', + 'precipitation', + 'precision', + 'precision-point', + 'pred', + 'predefined', + 'predicate', + 'prefetch', + 'prefetchwt', + 'prefix', + 'prefixed-uri', + 'prefixes', + 'preinst', + 'prelude', + 'prepare', + 'prepocessor', + 'preposition', + 'prepositional', + 'preprocessor', + 'prerequisites', + 'preset', + 'preview', + 'previous', + 'prg', + 'primary', + 'primitive', + 'primitive-datatypes', + 'primitive-field', + 'print', + 'print-argument', + 'priority', + 'prism', + 'private', + 'privileged', + 'pro', + 'probe', + 'proc', + 'procedure', + 'procedure_definition', + 'procedure_prototype', + 'process', + 'process-id', + 'process-substitution', + 'processes', + 'processing', + 'proctitle', + 'production', + 'profile', + 'profiling', + 'program', + 'program-block', + 'program-name', + 'progressbars', + 'proguard', + 'project', + 'projectile', + 'prolog', + 'prolog-flags', + 'prologue', + 'promoted', + 'prompt', + 'prompt-prefix', + 'prop', + 'properties', + 'properties_literal', + 'property', + 'property-flag', + 'property-list', + 'property-name', + 'property-value', + 'property-with-attributes', + 'propertydef', + 'propertyend', + 'propertygroup', + 'propertygrouptable', + 'propertyset', + 'propertytable', + 'proposition', + 'protection', + 'protections', + 'proto', + 'protobuf', + 'protobufs', + 'protocol', + 'protocol-specification', + 'prototype', + 'provision', + 'proxy', + 'psci', + 'pseudo', + 'pseudo-class', + 'pseudo-element', + 'pseudo-method', + 'pseudo-mnemonic', + 'pseudo-variable', + 'pshdl', + 'pspell', + 'psql', + 'pt', + 'ptc-config', + 'ptc-config-modelcheck', + 'pthread', + 'ptr', + 'ptx', + 'public', + 'pug', + 'punchcard', + 'punctual', + 'punctuation', + 'punctutation', + 'puncuation', + 'puncutation', + 'puntuation', + 'puppet', + 'purebasic', + 'purescript', + 'pweave', + 'pwisa', + 'pwn', + 'py2pml', + 'pyj', + 'pyjade', + 'pymol', + 'pyresttest', + 'python', + 'python-function', + 'q', + 'q-brace', + 'q-bracket', + 'q-ltgt', + 'q-paren', + 'qa', + 'qm', + 'qml', + 'qos', + 'qoute', + 'qq', + 'qq-brace', + 'qq-bracket', + 'qq-ltgt', + 'qq-paren', + 'qry', + 'qtpro', + 'quad', + 'quad-arrow-down', + 'quad-arrow-left', + 'quad-arrow-right', + 'quad-arrow-up', + 'quad-backslash', + 'quad-caret-down', + 'quad-caret-up', + 'quad-circle', + 'quad-colon', + 'quad-del-down', + 'quad-del-up', + 'quad-diamond', + 'quad-divide', + 'quad-equal', + 'quad-jot', + 'quad-less', + 'quad-not-equal', + 'quad-question', + 'quad-quote', + 'quad-slash', + 'quadrigraph', + 'qual', + 'qualified', + 'qualifier', + 'quality', + 'quant', + 'quantifier', + 'quantifiers', + 'quartz', + 'quasi', + 'quasiquote', + 'quasiquotes', + 'query', + 'query-dsl', + 'question', + 'questionmark', + 'quicel', + 'quicktemplate', + 'quicktime-file', + 'quotation', + 'quote', + 'quoted', + 'quoted-identifier', + 'quoted-object', + 'quoted-or-unquoted', + 'quotes', + 'qx', + 'qx-brace', + 'qx-bracket', + 'qx-ltgt', + 'qx-paren', + 'r', + 'r3', + 'rabl', + 'racket', + 'radar', + 'radar-area', + 'radiobuttons', + 'radix', + 'rails', + 'rainmeter', + 'raml', + 'random', + 'random_number', + 'randomsk', + 'range', + 'range-2', + 'rank', + 'rant', + 'rapid', + 'rarity', + 'ratio', + 'rational-form', + 'raw', + 'raw-regex', + 'raxe', + 'rb', + 'rd', + 'rdfs-type', + 'rdrand', + 'rdseed', + 'react', + 'read', + 'readline', + 'readonly', + 'readwrite', + 'real', + 'realip', + 'rebeca', + 'rebol', + 'rec', + 'receive', + 'receive-channel', + 'recipe', + 'recipient-subscriber-list', + 'recode', + 'record', + 'record-field', + 'record-usage', + 'recordfield', + 'recutils', + 'red', + 'redbook-audio', + 'redirect', + 'redirection', + 'redprl', + 'redundancy', + 'ref', + 'refer', + 'reference', + 'referer', + 'refinement', + 'reflection', + 'reg', + 'regex', + 'regexname', + 'regexp', + 'regexp-option', + 'region-anchor-setting', + 'region-cue-setting', + 'region-identifier-setting', + 'region-lines-setting', + 'region-scroll-setting', + 'region-viewport-anchor-setting', + 'region-width-setting', + 'register', + 'register-64', + 'registers', + 'regular', + 'reiny', + 'reject', + 'rejecttype', + 'rel', + 'related', + 'relation', + 'relational', + 'relations', + 'relationship', + 'relationship-name', + 'relationship-pattern', + 'relationship-pattern-end', + 'relationship-pattern-start', + 'relationship-type', + 'relationship-type-or', + 'relationship-type-ored', + 'relationship-type-start', + 'relative', + 'rem', + 'reminder', + 'remote', + 'removed', + 'rename', + 'renamed-from', + 'renamed-to', + 'renaming', + 'render', + 'renpy', + 'reocrd', + 'reparator', + 'repeat', + 'repl-prompt', + 'replace', + 'replaceXXX', + 'replaced', + 'replacement', + 'reply', + 'repo', + 'reporter', + 'reporting', + 'repository', + 'request', + 'request-type', + 'require', + 'required', + 'requiredness', + 'requirement', + 'requirements', + 'rescue', + 'reserved', + 'reset', + 'resolution', + 'resource', + 'resource-manager', + 'response', + 'response-type', + 'rest', + 'rest-args', + 'rester', + 'restriced', + 'restructuredtext', + 'result', + 'result-separator', + 'results', + 'retro', + 'return', + 'return-type', + 'return-value', + 'returns', + 'rev', + 'reverse', + 'reversed', + 'review', + 'rewrite', + 'rewrite-condition', + 'rewrite-operator', + 'rewrite-pattern', + 'rewrite-substitution', + 'rewrite-test', + 'rewritecond', + 'rewriterule', + 'rf', + 'rfc', + 'rgb', + 'rgb-percentage', + 'rgb-value', + 'rhap', + 'rho', + 'rhs', + 'rhtml', + 'richtext', + 'rid', + 'right', + 'ring', + 'riot', + 'rivescript', + 'rjs', + 'rl', + 'rmarkdown', + 'rnc', + 'rng', + 'ro', + 'roboconf', + 'robot', + 'robotc', + 'robust-control', + 'rockerfile', + 'roff', + 'role', + 'rollout-control', + 'root', + 'rotate', + 'rotate-first', + 'rotate-last', + 'round', + 'round-brackets', + 'router', + 'routeros', + 'routes', + 'routine', + 'row', + 'row2', + 'rowspan', + 'roxygen', + 'rparent', + 'rpc', + 'rpc-definition', + 'rpe', + 'rpm-spec', + 'rpmspec', + 'rpt', + 'rq', + 'rrd', + 'rsl', + 'rspec', + 'rtemplate', + 'ru', + 'ruby', + 'rubymotion', + 'rule', + 'rule-identifier', + 'rule-name', + 'rule-pattern', + 'rule-tag', + 'ruleDefinition', + 'rules', + 'run', + 'rune', + 'runoff', + 'runtime', + 'rust', + 'rviz', + 'rx', + 's', + 'safe-call', + 'safe-navigation', + 'safe-trap', + 'safer', + 'safety', + 'sage', + 'salesforce', + 'salt', + 'sampler', + 'sampler-comparison', + 'samplerarg', + 'sampling', + 'sas', + 'sass', + 'sass-script-maps', + 'satcom', + 'satisfies', + 'sblock', + 'scad', + 'scala', + 'scaladoc', + 'scalar', + 'scale', + 'scam', + 'scan', + 'scenario', + 'scenario_outline', + 'scene', + 'scene-object', + 'scheduled', + 'schelp', + 'schem', + 'schema', + 'scheme', + 'schememode', + 'scientific', + 'scilab', + 'sck', + 'scl', + 'scope', + 'scope-name', + 'scope-resolution', + 'scoping', + 'score', + 'screen', + 'scribble', + 'script', + 'script-flag', + 'script-metadata', + 'script-object', + 'script-tag', + 'scripting', + 'scriptlet', + 'scriptlocal', + 'scriptname', + 'scriptname-declaration', + 'scripts', + 'scroll', + 'scrollbars', + 'scrollpanes', + 'scss', + 'scumm', + 'sdbl', + 'sdl', + 'sdo', + 'sealed', + 'search', + 'seawolf', + 'second', + 'secondary', + 'section', + 'section-attribute', + 'sectionname', + 'sections', + 'see', + 'segment', + 'segment-registers', + 'segment-resolution', + 'select', + 'select-block', + 'selector', + 'self', + 'self-binding', + 'self-close', + 'sem', + 'semantic', + 'semanticmodel', + 'semi-colon', + 'semicolon', + 'semicoron', + 'semireserved', + 'send-channel', + 'sender', + 'senum', + 'sep', + 'separator', + 'separatory', + 'sepatator', + 'seperator', + 'sequence', + 'sequences', + 'serial', + 'serpent', + 'server', + 'service', + 'service-declaration', + 'service-rpc', + 'services', + 'session', + 'set', + 'set-colour', + 'set-size', + 'set-variable', + 'setbagmix', + 'setname', + 'setproperty', + 'sets', + 'setter', + 'setting', + 'settings', + 'settype', + 'setword', + 'seven', + 'severity', + 'sexpr', + 'sfd', + 'sfst', + 'sgml', + 'sgx1', + 'sgx2', + 'sha', + 'sha256', + 'sha512', + 'sha_functions', + 'shad', + 'shade', + 'shaderlab', + 'shadow-object', + 'shape', + 'shape-base', + 'shape-base-data', + 'shared', + 'shared-static', + 'sharp', + 'sharpequal', + 'sharpge', + 'sharpgt', + 'sharple', + 'sharplt', + 'sharpness', + 'shebang', + 'shell', + 'shell-function', + 'shell-session', + 'shift', + 'shift-and-rotate', + 'shift-left', + 'shift-right', + 'shine', + 'shinescript', + 'shipflow', + 'shmop', + 'short', + 'shortcut', + 'shortcuts', + 'shorthand', + 'shorthandpropertyname', + 'show', + 'show-argument', + 'shuffle-and-unpack', + 'shutdown', + 'shy', + 'sidebar', + 'sifu', + 'sigdec', + 'sigil', + 'sign-line', + 'signal', + 'signal-processing', + 'signature', + 'signed', + 'signed-int', + 'signedness', + 'signifier', + 'silent', + 'sim-group', + 'sim-object', + 'sim-set', + 'simd', + 'simd-horizontal', + 'simd-integer', + 'simple', + 'simple-delimiter', + 'simple-divider', + 'simple-element', + 'simple_delimiter', + 'simplexml', + 'simplez', + 'simulate', + 'since', + 'singe', + 'single', + 'single-line', + 'single-quote', + 'single-quoted', + 'single_quote', + 'singlequote', + 'singleton', + 'singleword', + 'sites', + 'six', + 'size', + 'size-cue-setting', + 'sized_integer', + 'sizeof', + 'sjs', + 'sjson', + 'sk', + 'skaction', + 'skdragon', + 'skeeland', + 'skellett', + 'sketchplugin', + 'skevolved', + 'skew', + 'skill', + 'skipped', + 'skmorkaz', + 'skquery', + 'skrambled', + 'skrayfall', + 'skript', + 'skrpg', + 'sksharp', + 'skstuff', + 'skutilities', + 'skvoice', + 'sky', + 'skyrim', + 'sl', + 'slash', + 'slash-bar', + 'slash-option', + 'slash-sign', + 'slashes', + 'sleet', + 'slice', + 'slim', + 'slm', + 'sln', + 'slot', + 'slugignore', + 'sma', + 'smali', + 'smalltalk', + 'smarty', + 'smb', + 'smbinternal', + 'smilebasic', + 'sml', + 'smoothing-group', + 'smpte', + 'smtlib', + 'smx', + 'snakeskin', + 'snapshot', + 'snlog', + 'snmp', + 'so', + 'soap', + 'social', + 'socketgroup', + 'sockets', + 'soft', + 'solidity', + 'solve', + 'soma', + 'somearg', + 'something', + 'soql', + 'sort', + 'sorting', + 'souce', + 'sound', + 'sound_processing', + 'sound_synthesys', + 'source', + 'source-constant', + 'soy', + 'sp', + 'space', + 'space-after-command', + 'spacebars', + 'spaces', + 'sparql', + 'spath', + 'spec', + 'special', + 'special-attributes', + 'special-character', + 'special-curve', + 'special-functions', + 'special-hook', + 'special-keyword', + 'special-method', + 'special-point', + 'special-token-sequence', + 'special-tokens', + 'special-type', + 'specification', + 'specifier', + 'spectral-curve', + 'specular-exponent', + 'specular-reflectivity', + 'sphinx', + 'sphinx-domain', + 'spice', + 'spider', + 'spindlespeed', + 'splat', + 'spline', + 'splunk', + 'splunk-conf', + 'splus', + 'spn', + 'spread', + 'spread-line', + 'spreadmap', + 'sprite', + 'sproto', + 'sproutcore', + 'sqf', + 'sql', + 'sqlbuiltin', + 'sqlite', + 'sqlsrv', + 'sqr', + 'sqsp', + 'squad', + 'square', + 'squart', + 'squirrel', + 'sr-Cyrl', + 'sr-Latn', + 'src', + 'srltext', + 'sros', + 'srt', + 'srv', + 'ss', + 'ssa', + 'sse', + 'sse2', + 'sse2_simd', + 'sse3', + 'sse4', + 'sse4_simd', + 'sse5', + 'sse_avx', + 'sse_simd', + 'ssh-config', + 'ssi', + 'ssl', + 'ssn', + 'sstemplate', + 'st', + 'stable', + 'stack', + 'stack-effect', + 'stackframe', + 'stage', + 'stan', + 'standard', + 'standard-key', + 'standard-links', + 'standard-suite', + 'standardadditions', + 'standoc', + 'star', + 'starline', + 'start', + 'start-block', + 'start-condition', + 'start-symbol', + 'start-value', + 'starting-function-params', + 'starting-functions', + 'starting-functions-point', + 'startshape', + 'stata', + 'statamic', + 'state', + 'state-flag', + 'state-management', + 'stateend', + 'stategrouparg', + 'stategroupval', + 'statement', + 'statement-separator', + 'states', + 'statestart', + 'statetable', + 'static', + 'static-assert', + 'static-classes', + 'static-if', + 'static-shape', + 'staticimages', + 'statistics', + 'stats', + 'std', + 'stdWrap', + 'std_logic', + 'std_logic_1164', + 'stderr-write-file', + 'stdint', + 'stdlib', + 'stdlibcall', + 'stdplugin', + 'stem', + 'step', + 'step-size', + 'steps', + 'stg', + 'stile-shoe-left', + 'stile-shoe-up', + 'stile-tilde', + 'stitch', + 'stk', + 'stmt', + 'stochastic', + 'stop', + 'stopping', + 'storage', + 'story', + 'stp', + 'straight-quote', + 'stray', + 'stray-comment-end', + 'stream', + 'stream-selection-and-control', + 'streamsfuncs', + 'streem', + 'strict', + 'strictness', + 'strike', + 'strikethrough', + 'string', + 'string-constant', + 'string-format', + 'string-interpolation', + 'string-long-quote', + 'string-long-single-quote', + 'string-single-quote', + 'stringchar', + 'stringize', + 'strings', + 'strong', + 'struc', + 'struct', + 'struct-union-block', + 'structdef', + 'structend', + 'structs', + 'structstart', + 'structtable', + 'structure', + 'stuff', + 'stupid-goddamn-hack', + 'style', + 'styleblock', + 'styles', + 'stylus', + 'sub', + 'sub-pattern', + 'subchord', + 'subckt', + 'subcmd', + 'subexp', + 'subexpression', + 'subkey', + 'subkeys', + 'subl', + 'submodule', + 'subnet', + 'subnet6', + 'subpattern', + 'subprogram', + 'subroutine', + 'subscript', + 'subsection', + 'subsections', + 'subset', + 'subshell', + 'subsort', + 'substituted', + 'substitution', + 'substitution-definition', + 'subtitle', + 'subtlegradient', + 'subtlegray', + 'subtract', + 'subtraction', + 'subtype', + 'suffix', + 'sugarml', + 'sugarss', + 'sugly', + 'sugly-comparison-operators', + 'sugly-control-keywords', + 'sugly-declare-function', + 'sugly-delcare-operator', + 'sugly-delcare-variable', + 'sugly-else-in-invalid-position', + 'sugly-encode-clause', + 'sugly-function-groups', + 'sugly-function-recursion', + 'sugly-function-variables', + 'sugly-general-functions', + 'sugly-general-operators', + 'sugly-generic-classes', + 'sugly-generic-types', + 'sugly-global-function', + 'sugly-int-constants', + 'sugly-invoke-function', + 'sugly-json-clause', + 'sugly-language-constants', + 'sugly-math-clause', + 'sugly-math-constants', + 'sugly-multiple-parameter-function', + 'sugly-number-constants', + 'sugly-operator-operands', + 'sugly-print-clause', + 'sugly-single-parameter-function', + 'sugly-subject-or-predicate', + 'sugly-type-function', + 'sugly-uri-clause', + 'summary', + 'super', + 'superclass', + 'supercollider', + 'superscript', + 'superset', + 'supervisor', + 'supervisord', + 'supplemental', + 'supplimental', + 'support', + 'suppress-image-or-category', + 'suppressed', + 'surface', + 'surface-technique', + 'sv', + 'svg', + 'svm', + 'svn', + 'swift', + 'swig', + 'switch', + 'switch-block', + 'switch-expression', + 'switch-statement', + 'switchEnd', + 'switchStart', + 'swizzle', + 'sybase', + 'syllableseparator', + 'symbol', + 'symbol-definition', + 'symbol-type', + 'symbolic', + 'symbolic-math', + 'symbols', + 'symmetry', + 'sync-match', + 'sync-mode', + 'sync-mode-location', + 'synchronization', + 'synchronize', + 'synchronized', + 'synergy', + 'synopsis', + 'syntax', + 'syntax-case', + 'syntax-cluster', + 'syntax-conceal', + 'syntax-error', + 'syntax-include', + 'syntax-item', + 'syntax-keywords', + 'syntax-match', + 'syntax-option', + 'syntax-region', + 'syntax-rule', + 'syntax-spellcheck', + 'syntax-sync', + 'sys-types', + 'sysj', + 'syslink', + 'syslog-ng', + 'system', + 'system-events', + 'system-identification', + 'system-table-pointer', + 'systemreference', + 'sytem-events', + 't', + 't3datastructure', + 't4', + 't5', + 't7', + 'ta', + 'tab', + 'table', + 'table-name', + 'tablename', + 'tabpanels', + 'tabs', + 'tabular', + 'tacacs', + 'tack-down', + 'tack-up', + 'taco', + 'tads3', + 'tag', + 'tag-string', + 'tag-value', + 'tagbraces', + 'tagdef', + 'tagged', + 'tagger_script', + 'taglib', + 'tagname', + 'tagnamedjango', + 'tags', + 'taint', + 'take', + 'target', + 'targetobj', + 'targetprop', + 'task', + 'tasks', + 'tbdfile', + 'tbl', + 'tbody', + 'tcl', + 'tcoffee', + 'tcp-object', + 'td', + 'tdl', + 'tea', + 'team', + 'telegram', + 'tell', + 'telnet', + 'temp', + 'template', + 'template-call', + 'template-parameter', + 'templatetag', + 'tempo', + 'temporal', + 'term', + 'term-comparison', + 'term-creation-and-decomposition', + 'term-io', + 'term-testing', + 'term-unification', + 'terminal', + 'terminate', + 'termination', + 'terminator', + 'terms', + 'ternary', + 'ternary-if', + 'terra', + 'terraform', + 'terrain-block', + 'test', + 'testcase', + 'testing', + 'tests', + 'testsuite', + 'testx', + 'tex', + 'texres', + 'texshop', + 'text', + 'text-reference', + 'text-suite', + 'textbf', + 'textcolor', + 'textile', + 'textio', + 'textit', + 'textlabels', + 'textmate', + 'texttt', + 'textual', + 'texture', + 'texture-map', + 'texture-option', + 'tfoot', + 'th', + 'thead', + 'then', + 'therefore', + 'thin', + 'thing1', + 'third', + 'this', + 'thorn', + 'thread', + 'three', + 'thrift', + 'throughput', + 'throw', + 'throwables', + 'throws', + 'tick', + 'ticket-num', + 'ticket-psa', + 'tid-file', + 'tidal', + 'tidalcycles', + 'tiddler', + 'tiddler-field', + 'tiddler-fields', + 'tidy', + 'tier', + 'tieslur', + 'tikz', + 'tilde', + 'time', + 'timeblock', + 'timehrap', + 'timeout', + 'timer', + 'times', + 'timesig', + 'timespan', + 'timespec', + 'timestamp', + 'timing', + 'titanium', + 'title', + 'title-page', + 'title-text', + 'titled-paragraph', + 'tjs', + 'tl', + 'tla', + 'tlh', + 'tmpl', + 'tmsim', + 'tmux', + 'tnote', + 'tnsaudit', + 'to', + 'to-file', + 'to-type', + 'toc', + 'toc-list', + 'todo', + 'todo_extra', + 'todotxt', + 'token', + 'token-def', + 'token-paste', + 'token-type', + 'tokenised', + 'tokenizer', + 'toml', + 'too-many-tildes', + 'tool', + 'toolbox', + 'tooltip', + 'top', + 'top-level', + 'top_level', + 'topas', + 'topic', + 'topic-decoration', + 'topic-title', + 'tornado', + 'torque', + 'torquescript', + 'tosca', + 'total-config', + 'totaljs', + 'tpye', + 'tr', + 'trace', + 'trace-argument', + 'trace-object', + 'traceback', + 'tracing', + 'track_processing', + 'trader', + 'tradersk', + 'trail', + 'trailing', + 'trailing-array-separator', + 'trailing-dictionary-separator', + 'trailing-match', + 'trait', + 'traits', + 'traits-keyword', + 'transaction', + 'transcendental', + 'transcludeblock', + 'transcludeinline', + 'transclusion', + 'transform', + 'transformation', + 'transient', + 'transition', + 'transitionable-property-value', + 'translation', + 'transmission-filter', + 'transparency', + 'transparent-line', + 'transpose', + 'transposed-func', + 'transposed-matrix', + 'transposed-parens', + 'transposed-variable', + 'trap', + 'tree', + 'treetop', + 'trenni', + 'trigEvent_', + 'trigLevelMod_', + 'trigLevel_', + 'trigger', + 'trigger-words', + 'triggermodifier', + 'trigonometry', + 'trimming-loop', + 'triple', + 'triple-dash', + 'triple-slash', + 'triple-star', + 'true', + 'truncate', + 'truncation', + 'truthgreen', + 'try', + 'try-catch', + 'trycatch', + 'ts', + 'tsql', + 'tss', + 'tst', + 'tsv', + 'tsx', + 'tt', + 'ttcn3', + 'ttlextension', + 'ttpmacro', + 'tts', + 'tubaina', + 'tubaina2', + 'tul', + 'tup', + 'tuple', + 'turbulence', + 'turing', + 'turquoise', + 'turtle', + 'tutch', + 'tvml', + 'tw5', + 'twig', + 'twigil', + 'twiki', + 'two', + 'txl', + 'txt', + 'txt2tags', + 'type', + 'type-annotation', + 'type-cast', + 'type-cheat', + 'type-checking', + 'type-constrained', + 'type-constraint', + 'type-declaration', + 'type-def', + 'type-definition', + 'type-definition-group', + 'type-definitions', + 'type-descriptor', + 'type-of', + 'type-or', + 'type-parameter', + 'type-parameters', + 'type-signature', + 'type-spec', + 'type-specialization', + 'type-specifiers', + 'type_2', + 'type_trait', + 'typeabbrev', + 'typeclass', + 'typed', + 'typed-hole', + 'typedblock', + 'typedcoffeescript', + 'typedecl', + 'typedef', + 'typeexp', + 'typehint', + 'typehinted', + 'typeid', + 'typename', + 'types', + 'typesbii', + 'typescriptish', + 'typographic-quotes', + 'typoscript', + 'typoscript2', + 'u', + 'u-degree', + 'u-end', + 'u-offset', + 'u-resolution', + 'u-scale', + 'u-segments', + 'u-size', + 'u-start', + 'u-value', + 'uc', + 'ucicfg', + 'ucicmd', + 'udaf', + 'udf', + 'udl', + 'udp', + 'udtf', + 'ui', + 'ui-block', + 'ui-group', + 'ui-state', + 'ui-subgroup', + 'uintptr', + 'ujm', + 'uk', + 'ul', + 'umbaska', + 'unOp', + 'unary', + 'unbuffered', + 'unchecked', + 'uncleared', + 'unclosed', + 'unclosed-string', + 'unconstrained', + 'undef', + 'undefined', + 'underbar-circle', + 'underbar-diamond', + 'underbar-iota', + 'underbar-jot', + 'underbar-quote', + 'underbar-semicolon', + 'underline', + 'underline-text', + 'underlined', + 'underscore', + 'undocumented', + 'unescaped-quote', + 'unexpected', + 'unexpected-characters', + 'unexpected-extends', + 'unexpected-extends-character', + 'unfiled', + 'unformatted', + 'unicode', + 'unicode-16-bit', + 'unicode-32-bit', + 'unicode-escape', + 'unicode-raw', + 'unicode-raw-regex', + 'unified', + 'unify', + 'unimplemented', + 'unimportant', + 'union', + 'union-declaration', + 'unique-id', + 'unit', + 'unit-checking', + 'unit-test', + 'unit_test', + 'unittest', + 'unity', + 'unityscript', + 'universal-match', + 'unix', + 'unknown', + 'unknown-escape', + 'unknown-method', + 'unknown-property-name', + 'unknown-rune', + 'unlabeled', + 'unless', + 'unnecessary', + 'unnumbered', + 'uno', + 'unoconfig', + 'unop', + 'unoproj', + 'unordered', + 'unordered-block', + 'unosln', + 'unpack', + 'unpacking', + 'unparsed', + 'unqualified', + 'unquoted', + 'unrecognized', + 'unrecognized-character', + 'unrecognized-character-escape', + 'unrecognized-string-escape', + 'unsafe', + 'unsigned', + 'unsigned-int', + 'unsized_integer', + 'unsupplied', + 'until', + 'untitled', + 'untyped', + 'unused', + 'uopz', + 'update', + 'uppercase', + 'upstream', + 'upwards', + 'ur', + 'uri', + 'url', + 'usable', + 'usage', + 'use', + 'use-as', + 'use-map', + 'use-material', + 'usebean', + 'usecase', + 'usecase-block', + 'user', + 'user-defined', + 'user-defined-property', + 'user-defined-type', + 'user-interaction', + 'userflagsref', + 'userid', + 'username', + 'users', + 'using', + 'using-namespace-declaration', + 'using_animtree', + 'util', + 'utilities', + 'utility', + 'utxt', + 'uv-resolution', + 'uvu', + 'uvw', + 'ux', + 'uxc', + 'uxl', + 'uz', + 'v', + 'v-degree', + 'v-end', + 'v-offset', + 'v-resolution', + 'v-scale', + 'v-segments', + 'v-size', + 'v-start', + 'v-value', + 'val', + 'vala', + 'valgrind', + 'valid', + 'valid-ampersand', + 'valid-bracket', + 'valign', + 'value', + 'value-pair', + 'value-signature', + 'value-size', + 'value-type', + 'valuepair', + 'vamos', + 'vamp', + 'vane-down', + 'vane-left', + 'vane-right', + 'vane-up', + 'var', + 'var-single-variable', + 'var1', + 'var2', + 'variable', + 'variable-access', + 'variable-assignment', + 'variable-declaration', + 'variable-definition', + 'variable-modifier', + 'variable-parameter', + 'variable-reference', + 'variable-usage', + 'variables', + 'variabletable', + 'variant', + 'variant-definition', + 'varname', + 'varnish', + 'vars', + 'vb', + 'vbnet', + 'vbs', + 'vc', + 'vcard', + 'vcd', + 'vcl', + 'vcs', + 'vector', + 'vector-load', + 'vectors', + 'vehicle', + 'velocity', + 'vendor-prefix', + 'verb', + 'verbatim', + 'verdict', + 'verilog', + 'version', + 'version-number', + 'version-specification', + 'vertex', + 'vertex-reference', + 'vertical-blending', + 'vertical-span', + 'vertical-text-cue-setting', + 'vex', + 'vhdl', + 'vhost', + 'vi', + 'via', + 'video-texturing', + 'video_processing', + 'view', + 'viewhelpers', + 'vimAugroupKey', + 'vimBehaveModel', + 'vimFTCmd', + 'vimFTOption', + 'vimFuncKey', + 'vimGroupSpecial', + 'vimHiAttrib', + 'vimHiClear', + 'vimMapModKey', + 'vimPattern', + 'vimSynCase', + 'vimSynType', + 'vimSyncC', + 'vimSyncLinecont', + 'vimSyncMatch', + 'vimSyncNone', + 'vimSyncRegion', + 'vimUserAttrbCmplt', + 'vimUserAttrbKey', + 'vimUserCommand', + 'viml', + 'virtual', + 'virtual-host', + 'virtual-reality', + 'visibility', + 'visualforce', + 'visualization', + 'vlanhdr', + 'vle', + 'vmap', + 'vmx', + 'voice', + 'void', + 'volatile', + 'volt', + 'volume', + 'vpath', + 'vplus', + 'vrf', + 'vtt', + 'vue', + 'vue-jade', + 'vue-stylus', + 'w-offset', + 'w-scale', + 'w-value', + 'w3c-extended-color-name', + 'w3c-non-standard-color-name', + 'w3c-standard-color-name', + 'wait', + 'waitress', + 'waitress-config', + 'waitress-rb', + 'warn', + 'warning', + 'warnings', + 'wast', + 'water', + 'watson-todo', + 'wavefront', + 'wavelet', + 'wddx', + 'wdiff', + 'weapon', + 'weave', + 'weaveBracket', + 'weaveBullet', + 'webidl', + 'webspeed', + 'webvtt', + 'weekday', + 'weirdland', + 'wf', + 'wh', + 'whatever', + 'wheeled-vehicle', + 'when', + 'where', + 'while', + 'while-condition', + 'while-loop', + 'whiskey', + 'white', + 'whitespace', + 'widget', + 'width', + 'wiki', + 'wiki-link', + 'wildcard', + 'wildsk', + 'win', + 'window', + 'window-classes', + 'windows', + 'winered', + 'with', + 'with-arg', + 'with-args', + 'with-arguments', + 'with-params', + 'with-prefix', + 'with-side-effects', + 'with-suffix', + 'with-terminator', + 'with-value', + 'with_colon', + 'without-args', + 'without-arguments', + 'wla-dx', + 'word', + 'word-op', + 'wordnet', + 'wordpress', + 'words', + 'workitem', + 'world', + 'wow', + 'wp', + 'write', + 'wrong', + 'wrong-access-type', + 'wrong-division', + 'wrong-division-assignment', + 'ws', + 'www', + 'wxml', + 'wysiwyg-string', + 'x10', + 'x86', + 'x86_64', + 'x86asm', + 'xacro', + 'xbase', + 'xchg', + 'xhp', + 'xhprof', + 'xikij', + 'xml', + 'xml-attr', + 'xmlrpc', + 'xmlwriter', + 'xop', + 'xor', + 'xparse', + 'xq', + 'xquery', + 'xref', + 'xsave', + 'xsd-all', + 'xsd_nillable', + 'xsd_optional', + 'xsl', + 'xslt', + 'xsse3_simd', + 'xst', + 'xtend', + 'xtoy', + 'xtpl', + 'xu', + 'xvc', + 'xve', + 'xyzw', + 'y', + 'y1', + 'y2', + 'yabb', + 'yaml', + 'yaml-ext', + 'yang', + 'yara', + 'yate', + 'yaws', + 'year', + 'yellow', + 'yield', + 'ykk', + 'yorick', + 'you-forgot-semicolon', + 'z', + 'z80', + 'zap', + 'zapper', + 'zep', + 'zepon', + 'zepto', + 'zero', + 'zero-width-marker', + 'zero-width-print', + 'zeroop', + 'zh-CN', + 'zh-TW', + 'zig', + 'zilde', + 'zlib', + 'zoomfilter', + 'zzz' +]); diff --git a/src/deserializer-manager.coffee b/src/deserializer-manager.coffee deleted file mode 100644 index fab84539cad..00000000000 --- a/src/deserializer-manager.coffee +++ /dev/null @@ -1,68 +0,0 @@ -{Disposable} = require 'event-kit' -Grim = require 'grim' - -# Extended: Manages the deserializers used for serialized state -# -# An instance of this class is always available as the `atom.deserializers` -# global. -# -# ## Examples -# -# ```coffee -# class MyPackageView extends View -# atom.deserializers.add(this) -# -# @deserialize: (state) -> -# new MyPackageView(state) -# -# constructor: (@state) -> -# -# serialize: -> -# @state -# ``` -module.exports = -class DeserializerManager - constructor: -> - @deserializers = {} - - # Public: Register the given class(es) as deserializers. - # - # * `deserializers` One or more deserializers to register. A deserializer can - # be any object with a `.name` property and a `.deserialize()` method. A - # common approach is to register a *constructor* as the deserializer for its - # instances by adding a `.deserialize()` class method. - add: (deserializers...) -> - @deserializers[deserializer.name] = deserializer for deserializer in deserializers - new Disposable => - delete @deserializers[deserializer.name] for deserializer in deserializers - return - - # Public: Deserialize the state and params. - # - # * `state` The state {Object} to deserialize. - # * `params` The params {Object} to pass as the second arguments to the - # deserialize method of the deserializer. - deserialize: (state, params) -> - return unless state? - - if deserializer = @get(state) - stateVersion = state.get?('version') ? state.version - return if deserializer.version? and deserializer.version isnt stateVersion - deserializer.deserialize(state, params) - else - console.warn "No deserializer found for", state - - # Get the deserializer for the state. - # - # * `state` The state {Object} being deserialized. - get: (state) -> - return unless state? - - name = state.get?('deserializer') ? state.deserializer - @deserializers[name] - -if Grim.includeDeprecatedAPIs - DeserializerManager::remove = (classes...) -> - Grim.deprecate("Call .dispose() on the Disposable return from ::add instead") - delete @deserializers[name] for {name} in classes - return diff --git a/src/deserializer-manager.js b/src/deserializer-manager.js new file mode 100644 index 00000000000..0a0c63fcf3b --- /dev/null +++ b/src/deserializer-manager.js @@ -0,0 +1,99 @@ +const { Disposable } = require('event-kit'); + +// Extended: Manages the deserializers used for serialized state +// +// An instance of this class is always available as the `atom.deserializers` +// global. +// +// ## Examples +// +// ```coffee +// class MyPackageView extends View +// atom.deserializers.add(this) +// +// @deserialize: (state) -> +// new MyPackageView(state) +// +// constructor: (@state) -> +// +// serialize: -> +// @state +// ``` +module.exports = class DeserializerManager { + constructor(atomEnvironment) { + this.atomEnvironment = atomEnvironment; + this.deserializers = {}; + } + + // Public: Register the given class(es) as deserializers. + // + // * `deserializers` One or more deserializers to register. A deserializer can + // be any object with a `.name` property and a `.deserialize()` method. A + // 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 + // {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++) { + let deserializer = deserializers[i]; + this.deserializers[deserializer.name] = deserializer; + } + + return new Disposable(() => { + for (let j = 0; j < deserializers.length; j++) { + let deserializer = deserializers[j]; + delete this.deserializers[deserializer.name]; + } + }); + } + + getDeserializerCount() { + return Object.keys(this.deserializers).length; + } + + // Public: Deserialize the state and params. + // + // * `state` The state {Object} to deserialize. + deserialize(state) { + if (state == null) { + return; + } + + const deserializer = this.get(state); + if (deserializer) { + let stateVersion = + (typeof state.get === 'function' && state.get('version')) || + state.version; + + if ( + deserializer.version != null && + deserializer.version !== stateVersion + ) { + return; + } + return deserializer.deserialize(state, this.atomEnvironment); + } else { + return console.warn('No deserializer found for', state); + } + } + + // Get the deserializer for the state. + // + // * `state` The state {Object} being deserialized. + get(state) { + if (state == null) { + return; + } + + let stateDeserializer = + (typeof state.get === 'function' && state.get('deserializer')) || + state.deserializer; + + return this.deserializers[stateDeserializer]; + } + + clear() { + this.deserializers = {}; + } +}; diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee deleted file mode 100644 index b156bf98a79..00000000000 --- a/src/display-buffer.coffee +++ /dev/null @@ -1,1282 +0,0 @@ -_ = require 'underscore-plus' -Serializable = require 'serializable' -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = require 'text-buffer' -Grim = require 'grim' -TokenizedBuffer = require './tokenized-buffer' -RowMap = require './row-map' -Fold = require './fold' -Model = require './model' -Token = require './token' -Decoration = require './decoration' -Marker = require './marker' - -class BufferToScreenConversionError extends Error - constructor: (@message, @metadata) -> - super - Error.captureStackTrace(this, BufferToScreenConversionError) - -module.exports = -class DisplayBuffer extends Model - Serializable.includeInto(this) - - verticalScrollMargin: 2 - horizontalScrollMargin: 6 - scopedCharacterWidthsChangeCount: 0 - - constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer, ignoreInvisibles}={}) -> - super - - @emitter = new Emitter - @disposables = new CompositeDisposable - - @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer, ignoreInvisibles}) - @buffer = @tokenizedBuffer.buffer - @charWidthsByScope = {} - @markers = {} - @foldsByMarkerId = {} - @decorationsById = {} - @decorationsByMarkerId = {} - @disposables.add @tokenizedBuffer.observeGrammar @subscribeToScopedConfigSettings - @disposables.add @tokenizedBuffer.onDidChange @handleTokenizedBufferChange - @disposables.add @buffer.onDidCreateMarker @handleBufferMarkerCreated - @updateAllScreenLines() - @foldMarkerAttributes = Object.freeze({class: 'fold', displayBufferId: @id}) - @createFoldForMarker(marker) for marker in @buffer.findMarkers(@getFoldMarkerAttributes()) - - subscribeToScopedConfigSettings: => - @scopedConfigSubscriptions?.dispose() - @scopedConfigSubscriptions = subscriptions = new CompositeDisposable - - scopeDescriptor = @getRootScopeDescriptor() - - oldConfigSettings = @configSettings - @configSettings = - scrollPastEnd: atom.config.get('editor.scrollPastEnd', scope: scopeDescriptor) - softWrap: atom.config.get('editor.softWrap', scope: scopeDescriptor) - softWrapAtPreferredLineLength: atom.config.get('editor.softWrapAtPreferredLineLength', scope: scopeDescriptor) - softWrapHangingIndent: atom.config.get('editor.softWrapHangingIndent', scope: scopeDescriptor) - preferredLineLength: atom.config.get('editor.preferredLineLength', scope: scopeDescriptor) - - subscriptions.add atom.config.onDidChange 'editor.softWrap', scope: scopeDescriptor, ({newValue}) => - @configSettings.softWrap = newValue - @updateWrappedScreenLines() - - subscriptions.add atom.config.onDidChange 'editor.softWrapHangingIndent', scope: scopeDescriptor, ({newValue}) => - @configSettings.softWrapHangingIndent = newValue - @updateWrappedScreenLines() - - subscriptions.add atom.config.onDidChange 'editor.softWrapAtPreferredLineLength', scope: scopeDescriptor, ({newValue}) => - @configSettings.softWrapAtPreferredLineLength = newValue - @updateWrappedScreenLines() if @isSoftWrapped() - - subscriptions.add atom.config.onDidChange 'editor.preferredLineLength', scope: scopeDescriptor, ({newValue}) => - @configSettings.preferredLineLength = newValue - @updateWrappedScreenLines() if @isSoftWrapped() and atom.config.get('editor.softWrapAtPreferredLineLength', scope: scopeDescriptor) - - subscriptions.add atom.config.observe 'editor.scrollPastEnd', scope: scopeDescriptor, (value) => - @configSettings.scrollPastEnd = value - - @updateWrappedScreenLines() if oldConfigSettings? and not _.isEqual(oldConfigSettings, @configSettings) - - serializeParams: -> - id: @id - softWrapped: @isSoftWrapped() - editorWidthInChars: @editorWidthInChars - scrollTop: @scrollTop - scrollLeft: @scrollLeft - tokenizedBuffer: @tokenizedBuffer.serialize() - - deserializeParams: (params) -> - params.tokenizedBuffer = TokenizedBuffer.deserialize(params.tokenizedBuffer) - params - - copy: -> - newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength()}) - newDisplayBuffer.setScrollTop(@getScrollTop()) - newDisplayBuffer.setScrollLeft(@getScrollLeft()) - - for marker in @findMarkers(displayBufferId: @id) - marker.copy(displayBufferId: newDisplayBuffer.id) - newDisplayBuffer - - updateAllScreenLines: -> - @maxLineLength = 0 - @screenLines = [] - @rowMap = new RowMap - @updateScreenLines(0, @buffer.getLineCount(), null, suppressChangeEvent: true) - - onDidChangeSoftWrapped: (callback) -> - @emitter.on 'did-change-soft-wrapped', callback - - onDidChangeGrammar: (callback) -> - @tokenizedBuffer.onDidChangeGrammar(callback) - - onDidTokenize: (callback) -> - @tokenizedBuffer.onDidTokenize(callback) - - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - onDidChangeCharacterWidths: (callback) -> - @emitter.on 'did-change-character-widths', callback - - onDidChangeScrollTop: (callback) -> - @emitter.on 'did-change-scroll-top', callback - - onDidChangeScrollLeft: (callback) -> - @emitter.on 'did-change-scroll-left', callback - - observeScrollTop: (callback) -> - callback(@scrollTop) - @onDidChangeScrollTop(callback) - - observeScrollLeft: (callback) -> - callback(@scrollLeft) - @onDidChangeScrollLeft(callback) - - observeDecorations: (callback) -> - callback(decoration) for decoration in @getDecorations() - @onDidAddDecoration(callback) - - onDidAddDecoration: (callback) -> - @emitter.on 'did-add-decoration', callback - - onDidRemoveDecoration: (callback) -> - @emitter.on 'did-remove-decoration', callback - - onDidCreateMarker: (callback) -> - @emitter.on 'did-create-marker', callback - - onDidUpdateMarkers: (callback) -> - @emitter.on 'did-update-markers', callback - - emitDidChange: (eventProperties, refreshMarkers=true) -> - @emit 'changed', eventProperties if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change', eventProperties - if refreshMarkers - @refreshMarkerScreenPositions() - @emit 'markers-updated' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-update-markers' - - updateWrappedScreenLines: -> - start = 0 - end = @getLastRow() - @updateAllScreenLines() - screenDelta = @getLastRow() - end - bufferDelta = 0 - @emitDidChange({start, end, screenDelta, bufferDelta}) - - # Sets the visibility of the tokenized buffer. - # - # visible - A {Boolean} indicating of the tokenized buffer is shown - setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) - - getVerticalScrollMargin: -> Math.min(@verticalScrollMargin, (@getHeight() - @getLineHeightInPixels()) / 2) - setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin - - getVerticalScrollMarginInPixels: -> - scrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeightInPixels() - maxScrollMarginInPixels = (@getHeight() - @getLineHeightInPixels()) / 2 - Math.min(scrollMarginInPixels, maxScrollMarginInPixels) - - getHorizontalScrollMargin: -> Math.min(@horizontalScrollMargin, (@getWidth() - @getDefaultCharWidth()) / 2) - setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - - getHorizontalScrollMarginInPixels: -> - scrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() - maxScrollMarginInPixels = (@getWidth() - @getDefaultCharWidth()) / 2 - Math.min(scrollMarginInPixels, maxScrollMarginInPixels) - - getHorizontalScrollbarHeight: -> @horizontalScrollbarHeight - setHorizontalScrollbarHeight: (@horizontalScrollbarHeight) -> @horizontalScrollbarHeight - - getVerticalScrollbarWidth: -> @verticalScrollbarWidth - setVerticalScrollbarWidth: (@verticalScrollbarWidth) -> @verticalScrollbarWidth - - getHeight: -> - if @height? - @height - else - if @horizontallyScrollable() - @getScrollHeight() + @getHorizontalScrollbarHeight() - else - @getScrollHeight() - - setHeight: (@height) -> @height - - getClientHeight: (reentrant) -> - if @horizontallyScrollable(reentrant) - @getHeight() - @getHorizontalScrollbarHeight() - else - @getHeight() - - getClientWidth: (reentrant) -> - if @verticallyScrollable(reentrant) - @getWidth() - @getVerticalScrollbarWidth() - else - @getWidth() - - horizontallyScrollable: (reentrant) -> - return false unless @width? - return false if @isSoftWrapped() - if reentrant - @getScrollWidth() > @getWidth() - else - @getScrollWidth() > @getClientWidth(true) - - verticallyScrollable: (reentrant) -> - return false unless @height? - if reentrant - @getScrollHeight() > @getHeight() - else - @getScrollHeight() > @getClientHeight(true) - - getWidth: -> - if @width? - @width - else - if @verticallyScrollable() - @getScrollWidth() + @getVerticalScrollbarWidth() - else - @getScrollWidth() - - setWidth: (newWidth) -> - oldWidth = @width - @width = newWidth - @updateWrappedScreenLines() if newWidth isnt oldWidth and @isSoftWrapped() - @setScrollTop(@getScrollTop()) # Ensure scrollTop is still valid in case horizontal scrollbar disappeared - @width - - getScrollTop: -> @scrollTop - setScrollTop: (scrollTop) -> - scrollTop = Math.round(Math.max(0, Math.min(@getMaxScrollTop(), scrollTop))) - unless scrollTop is @scrollTop - @scrollTop = scrollTop - @emitter.emit 'did-change-scroll-top', @scrollTop - @scrollTop - - getMaxScrollTop: -> - @getScrollHeight() - @getClientHeight() - - getScrollBottom: -> @scrollTop + @getClientHeight() - setScrollBottom: (scrollBottom) -> - @setScrollTop(scrollBottom - @getClientHeight()) - @getScrollBottom() - - getScrollLeft: -> @scrollLeft - setScrollLeft: (scrollLeft) -> - scrollLeft = Math.round(Math.max(0, Math.min(@getScrollWidth() - @getClientWidth(), scrollLeft))) - unless scrollLeft is @scrollLeft - @scrollLeft = scrollLeft - @emitter.emit 'did-change-scroll-left', @scrollLeft - @scrollLeft - - getMaxScrollLeft: -> - @getScrollWidth() - @getClientWidth() - - getScrollRight: -> @scrollLeft + @width - setScrollRight: (scrollRight) -> - @setScrollLeft(scrollRight - @width) - @getScrollRight() - - getLineHeightInPixels: -> @lineHeightInPixels - setLineHeightInPixels: (@lineHeightInPixels) -> @lineHeightInPixels - - getDefaultCharWidth: -> @defaultCharWidth - setDefaultCharWidth: (defaultCharWidth) -> - if defaultCharWidth isnt @defaultCharWidth - @defaultCharWidth = defaultCharWidth - @computeScrollWidth() - defaultCharWidth - - getCursorWidth: -> 1 - - getScopedCharWidth: (scopeNames, char) -> - @getScopedCharWidths(scopeNames)[char] - - getScopedCharWidths: (scopeNames) -> - scope = @charWidthsByScope - for scopeName in scopeNames - scope[scopeName] ?= {} - scope = scope[scopeName] - scope.charWidths ?= {} - scope.charWidths - - batchCharacterMeasurement: (fn) -> - oldChangeCount = @scopedCharacterWidthsChangeCount - @batchingCharacterMeasurement = true - fn() - @batchingCharacterMeasurement = false - @characterWidthsChanged() if oldChangeCount isnt @scopedCharacterWidthsChangeCount - - setScopedCharWidth: (scopeNames, char, width) -> - @getScopedCharWidths(scopeNames)[char] = width - @scopedCharacterWidthsChangeCount++ - @characterWidthsChanged() unless @batchingCharacterMeasurement - - characterWidthsChanged: -> - @computeScrollWidth() - @emit 'character-widths-changed', @scopedCharacterWidthsChangeCount if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-character-widths', @scopedCharacterWidthsChangeCount - - clearScopedCharWidths: -> - @charWidthsByScope = {} - - getScrollHeight: -> - lineHeight = @getLineHeightInPixels() - return 0 unless lineHeight > 0 - - scrollHeight = @getLineCount() * lineHeight - if @height? and @configSettings.scrollPastEnd - scrollHeight = scrollHeight + @height - (lineHeight * 3) - - scrollHeight - - getScrollWidth: -> - @scrollWidth - - # Returns an {Array} of two numbers representing the first and the last visible rows. - getVisibleRowRange: -> - return [0, 0] unless @getLineHeightInPixels() > 0 - - startRow = Math.floor(@getScrollTop() / @getLineHeightInPixels()) - endRow = Math.ceil((@getScrollTop() + @getHeight()) / @getLineHeightInPixels()) - 1 - endRow = Math.min(@getLineCount(), endRow) - - [startRow, endRow] - - intersectsVisibleRowRange: (startRow, endRow) -> - [visibleStart, visibleEnd] = @getVisibleRowRange() - not (endRow <= visibleStart or visibleEnd <= startRow) - - selectionIntersectsVisibleRowRange: (selection) -> - {start, end} = selection.getScreenRange() - @intersectsVisibleRowRange(start.row, end.row + 1) - - scrollToScreenRange: (screenRange, options) -> - verticalScrollMarginInPixels = @getVerticalScrollMarginInPixels() - horizontalScrollMarginInPixels = @getHorizontalScrollMarginInPixels() - - {top, left} = @pixelRectForScreenRange(new Range(screenRange.start, screenRange.start)) - {top: endTop, left: endLeft, height: endHeight} = @pixelRectForScreenRange(new Range(screenRange.end, screenRange.end)) - bottom = endTop + endHeight - right = endLeft - - if options?.center - desiredScrollCenter = (top + bottom) / 2 - unless @getScrollTop() < desiredScrollCenter < @getScrollBottom() - desiredScrollTop = desiredScrollCenter - @getHeight() / 2 - desiredScrollBottom = desiredScrollCenter + @getHeight() / 2 - else - desiredScrollTop = top - verticalScrollMarginInPixels - desiredScrollBottom = bottom + verticalScrollMarginInPixels - - desiredScrollLeft = left - horizontalScrollMarginInPixels - desiredScrollRight = right + horizontalScrollMarginInPixels - - if options?.reversed ? true - if desiredScrollBottom > @getScrollBottom() - @setScrollBottom(desiredScrollBottom) - if desiredScrollTop < @getScrollTop() - @setScrollTop(desiredScrollTop) - - if desiredScrollRight > @getScrollRight() - @setScrollRight(desiredScrollRight) - if desiredScrollLeft < @getScrollLeft() - @setScrollLeft(desiredScrollLeft) - else - if desiredScrollTop < @getScrollTop() - @setScrollTop(desiredScrollTop) - if desiredScrollBottom > @getScrollBottom() - @setScrollBottom(desiredScrollBottom) - - if desiredScrollLeft < @getScrollLeft() - @setScrollLeft(desiredScrollLeft) - if desiredScrollRight > @getScrollRight() - @setScrollRight(desiredScrollRight) - - scrollToScreenPosition: (screenPosition, options) -> - @scrollToScreenRange(new Range(screenPosition, screenPosition), options) - - scrollToBufferPosition: (bufferPosition, options) -> - @scrollToScreenPosition(@screenPositionForBufferPosition(bufferPosition), options) - - pixelRectForScreenRange: (screenRange) -> - if screenRange.end.row > screenRange.start.row - top = @pixelPositionForScreenPosition(screenRange.start).top - left = 0 - height = (screenRange.end.row - screenRange.start.row + 1) * @getLineHeightInPixels() - width = @getScrollWidth() - else - {top, left} = @pixelPositionForScreenPosition(screenRange.start, false) - height = @getLineHeightInPixels() - width = @pixelPositionForScreenPosition(screenRange.end, false).left - left - - {top, left, width, height} - - # Retrieves the current tab length. - # - # Returns a {Number}. - getTabLength: -> - @tokenizedBuffer.getTabLength() - - # Specifies the tab length. - # - # tabLength - A {Number} that defines the new tab length. - setTabLength: (tabLength) -> - @tokenizedBuffer.setTabLength(tabLength) - - setIgnoreInvisibles: (ignoreInvisibles) -> - @tokenizedBuffer.setIgnoreInvisibles(ignoreInvisibles) - - setSoftWrapped: (softWrapped) -> - if softWrapped isnt @softWrapped - @softWrapped = softWrapped - @updateWrappedScreenLines() - softWrapped = @isSoftWrapped() - @emit 'soft-wrap-changed', softWrapped if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-soft-wrapped', softWrapped - softWrapped - else - @isSoftWrapped() - - isSoftWrapped: -> - @softWrapped ? @configSettings.softWrap ? false - - # Set the number of characters that fit horizontally in the editor. - # - # editorWidthInChars - A {Number} of characters. - setEditorWidthInChars: (editorWidthInChars) -> - if editorWidthInChars > 0 - previousWidthInChars = @editorWidthInChars - @editorWidthInChars = editorWidthInChars - if editorWidthInChars isnt previousWidthInChars and @isSoftWrapped() - @updateWrappedScreenLines() - - # Returns the editor width in characters for soft wrap. - getEditorWidthInChars: -> - width = @width ? @getScrollWidth() - width -= @getVerticalScrollbarWidth() - if width? and @defaultCharWidth > 0 - Math.max(0, Math.floor(width / @defaultCharWidth)) - else - @editorWidthInChars - - getSoftWrapColumn: -> - if @configSettings.softWrapAtPreferredLineLength - Math.min(@getEditorWidthInChars(), @configSettings.preferredLineLength) - else - @getEditorWidthInChars() - - # Gets the screen line for the given screen row. - # - # * `screenRow` - A {Number} indicating the screen row. - # - # Returns {TokenizedLine} - tokenizedLineForScreenRow: (screenRow) -> - @screenLines[screenRow] - - # Gets the screen lines for the given screen row range. - # - # startRow - A {Number} indicating the beginning screen row. - # endRow - A {Number} indicating the ending screen row. - # - # Returns an {Array} of {TokenizedLine}s. - tokenizedLinesForScreenRows: (startRow, endRow) -> - @screenLines[startRow..endRow] - - # Gets all the screen lines. - # - # Returns an {Array} of {TokenizedLine}s. - getTokenizedLines: -> - new Array(@screenLines...) - - indentLevelForLine: (line) -> - @tokenizedBuffer.indentLevelForLine(line) - - # Given starting and ending screen rows, this returns an array of the - # buffer rows corresponding to every screen row in the range - # - # startScreenRow - The screen row {Number} to start at - # endScreenRow - The screen row {Number} to end at (default: the last screen row) - # - # Returns an {Array} of buffer rows as {Numbers}s. - bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - for screenRow in [startScreenRow..endScreenRow] - @rowMap.bufferRowRangeForScreenRow(screenRow)[0] - - # Creates a new fold between two row numbers. - # - # startRow - The row {Number} to start folding at - # endRow - The row {Number} to end the fold - # - # Returns the new {Fold}. - createFold: (startRow, endRow) -> - foldMarker = - @findFoldMarker({startRow, endRow}) ? - @buffer.markRange([[startRow, 0], [endRow, Infinity]], @getFoldMarkerAttributes()) - @foldForMarker(foldMarker) - - isFoldedAtBufferRow: (bufferRow) -> - @largestFoldContainingBufferRow(bufferRow)? - - isFoldedAtScreenRow: (screenRow) -> - @largestFoldContainingBufferRow(@bufferRowForScreenRow(screenRow))? - - # Destroys the fold with the given id - destroyFoldWithId: (id) -> - @foldsByMarkerId[id]?.destroy() - - # Removes any folds found that contain the given buffer row. - # - # bufferRow - The buffer row {Number} to check against - unfoldBufferRow: (bufferRow) -> - fold.destroy() for fold in @foldsContainingBufferRow(bufferRow) - return - - # Given a buffer row, this returns the largest fold that starts there. - # - # Largest is defined as the fold whose difference between its start and end points - # are the greatest. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns a {Fold} or null if none exists. - largestFoldStartingAtBufferRow: (bufferRow) -> - @foldsStartingAtBufferRow(bufferRow)[0] - - # Public: Given a buffer row, this returns all folds that start there. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns an {Array} of {Fold}s. - foldsStartingAtBufferRow: (bufferRow) -> - for marker in @findFoldMarkers(startRow: bufferRow) - @foldForMarker(marker) - - # Given a screen row, this returns the largest fold that starts there. - # - # Largest is defined as the fold whose difference between its start and end points - # are the greatest. - # - # screenRow - A {Number} indicating the screen row - # - # Returns a {Fold}. - largestFoldStartingAtScreenRow: (screenRow) -> - @largestFoldStartingAtBufferRow(@bufferRowForScreenRow(screenRow)) - - # Given a buffer row, this returns the largest fold that includes it. - # - # Largest is defined as the fold whose difference between its start and end rows - # is the greatest. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns a {Fold}. - largestFoldContainingBufferRow: (bufferRow) -> - @foldsContainingBufferRow(bufferRow)[0] - - # Returns the folds in the given row range (exclusive of end row) that are - # not contained by any other folds. - outermostFoldsInBufferRowRange: (startRow, endRow) -> - @findFoldMarkers(containedInRange: [[startRow, 0], [endRow, 0]]) - .map (marker) => @foldForMarker(marker) - .filter (fold) -> not fold.isInsideLargerFold() - - # Public: Given a buffer row, this returns folds that include it. - # - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns an {Array} of {Fold}s. - foldsContainingBufferRow: (bufferRow) -> - for marker in @findFoldMarkers(intersectsRow: bufferRow) - @foldForMarker(marker) - - # Given a buffer row, this converts it into a screen row. - # - # bufferRow - A {Number} representing a buffer row - # - # Returns a {Number}. - screenRowForBufferRow: (bufferRow) -> - @rowMap.screenRowRangeForBufferRow(bufferRow)[0] - - lastScreenRowForBufferRow: (bufferRow) -> - @rowMap.screenRowRangeForBufferRow(bufferRow)[1] - 1 - - # Given a screen row, this converts it into a buffer row. - # - # screenRow - A {Number} representing a screen row - # - # Returns a {Number}. - bufferRowForScreenRow: (screenRow) -> - @rowMap.bufferRowRangeForScreenRow(screenRow)[0] - - # Given a buffer range, this converts it into a screen position. - # - # bufferRange - The {Range} to convert - # - # Returns a {Range}. - screenRangeForBufferRange: (bufferRange, options) -> - bufferRange = Range.fromObject(bufferRange) - start = @screenPositionForBufferPosition(bufferRange.start, options) - end = @screenPositionForBufferPosition(bufferRange.end, options) - new Range(start, end) - - # Given a screen range, this converts it into a buffer position. - # - # screenRange - The {Range} to convert - # - # Returns a {Range}. - bufferRangeForScreenRange: (screenRange) -> - screenRange = Range.fromObject(screenRange) - start = @bufferPositionForScreenPosition(screenRange.start) - end = @bufferPositionForScreenPosition(screenRange.end) - new Range(start, end) - - pixelRangeForScreenRange: (screenRange, clip=true) -> - {start, end} = Range.fromObject(screenRange) - {start: @pixelPositionForScreenPosition(start, clip), end: @pixelPositionForScreenPosition(end, clip)} - - pixelPositionForScreenPosition: (screenPosition, clip=true) -> - screenPosition = Point.fromObject(screenPosition) - screenPosition = @clipScreenPosition(screenPosition) if clip - - targetRow = screenPosition.row - targetColumn = screenPosition.column - defaultCharWidth = @defaultCharWidth - - top = targetRow * @lineHeightInPixels - left = 0 - column = 0 - - iterator = @tokenizedLineForScreenRow(targetRow).getTokenIterator() - while iterator.next() - charWidths = @getScopedCharWidths(iterator.getScopes()) - valueIndex = 0 - value = iterator.getText() - while valueIndex < value.length - if iterator.isPairedCharacter() - char = value - charLength = 2 - valueIndex += 2 - else - char = value[valueIndex] - charLength = 1 - valueIndex++ - - return {top, left} if column is targetColumn - left += charWidths[char] ? defaultCharWidth unless char is '\0' - column += charLength - {top, left} - - screenPositionForPixelPosition: (pixelPosition) -> - targetTop = pixelPosition.top - targetLeft = pixelPosition.left - defaultCharWidth = @defaultCharWidth - row = Math.floor(targetTop / @getLineHeightInPixels()) - targetLeft = 0 if row < 0 - targetLeft = Infinity if row > @getLastRow() - row = Math.min(row, @getLastRow()) - row = Math.max(0, row) - - left = 0 - column = 0 - - iterator = @tokenizedLineForScreenRow(row).getTokenIterator() - while iterator.next() - charWidths = @getScopedCharWidths(iterator.getScopes()) - value = iterator.getText() - valueIndex = 0 - while valueIndex < value.length - if iterator.isPairedCharacter() - char = value - charLength = 2 - valueIndex += 2 - else - char = value[valueIndex] - charLength = 1 - valueIndex++ - - charWidth = charWidths[char] ? defaultCharWidth - break if targetLeft <= left + (charWidth / 2) - left += charWidth - column += charLength - - new Point(row, column) - - pixelPositionForBufferPosition: (bufferPosition) -> - @pixelPositionForScreenPosition(@screenPositionForBufferPosition(bufferPosition)) - - # Gets the number of screen lines. - # - # Returns a {Number}. - getLineCount: -> - @screenLines.length - - # Gets the number of the last screen line. - # - # Returns a {Number}. - getLastRow: -> - @getLineCount() - 1 - - # Gets the length of the longest screen line. - # - # Returns a {Number}. - getMaxLineLength: -> - @maxLineLength - - # Gets the row number of the longest screen line. - # - # Return a {} - getLongestScreenRow: -> - @longestScreenRow - - # Given a buffer position, this converts it into a screen position. - # - # bufferPosition - An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # options - A hash of options with the following keys: - # wrapBeyondNewlines: - # wrapAtSoftNewlines: - # - # Returns a {Point}. - screenPositionForBufferPosition: (bufferPosition, options) -> - throw new Error("This TextEditor has been destroyed") if @isDestroyed() - - {row, column} = @buffer.clipPosition(bufferPosition) - [startScreenRow, endScreenRow] = @rowMap.screenRowRangeForBufferRow(row) - for screenRow in [startScreenRow...endScreenRow] - screenLine = @screenLines[screenRow] - - unless screenLine? - throw new BufferToScreenConversionError "No screen line exists when converting buffer row to screen row", - softWrapEnabled: @isSoftWrapped() - foldCount: @findFoldMarkers().length - lastBufferRow: @buffer.getLastRow() - lastScreenRow: @getLastRow() - - maxBufferColumn = screenLine.getMaxBufferColumn() - if screenLine.isSoftWrapped() and column > maxBufferColumn - continue - else - if column <= maxBufferColumn - screenColumn = screenLine.screenColumnForBufferColumn(column) - else - screenColumn = Infinity - break - - @clipScreenPosition([screenRow, screenColumn], options) - - # Given a buffer position, this converts it into a screen position. - # - # screenPosition - An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # options - A hash of options with the following keys: - # wrapBeyondNewlines: - # wrapAtSoftNewlines: - # - # Returns a {Point}. - bufferPositionForScreenPosition: (screenPosition, options) -> - {row, column} = @clipScreenPosition(Point.fromObject(screenPosition), options) - [bufferRow] = @rowMap.bufferRowRangeForScreenRow(row) - new Point(bufferRow, @screenLines[row].bufferColumnForScreenColumn(column)) - - # Retrieves the grammar's token scopeDescriptor for a buffer position. - # - # bufferPosition - A {Point} in the {TextBuffer} - # - # Returns a {ScopeDescriptor}. - scopeDescriptorForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) - - bufferRangeForScopeAtPosition: (selector, position) -> - @tokenizedBuffer.bufferRangeForScopeAtPosition(selector, position) - - # Retrieves the grammar's token for a buffer position. - # - # bufferPosition - A {Point} in the {TextBuffer}. - # - # Returns a {Token}. - tokenForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.tokenForPosition(bufferPosition) - - # Get the grammar for this buffer. - # - # Returns the current {Grammar} or the {NullGrammar}. - getGrammar: -> - @tokenizedBuffer.grammar - - # Sets the grammar for the buffer. - # - # grammar - Sets the new grammar rules - setGrammar: (grammar) -> - @tokenizedBuffer.setGrammar(grammar) - - # Reloads the current grammar. - reloadGrammar: -> - @tokenizedBuffer.reloadGrammar() - - # Given a position, this clips it to a real position. - # - # For example, if `position`'s row exceeds the row count of the buffer, - # or if its column goes beyond a line's length, this "sanitizes" the value - # to a real position. - # - # position - The {Point} to clip - # options - A hash with the following values: - # wrapBeyondNewlines: if `true`, continues wrapping past newlines - # wrapAtSoftNewlines: if `true`, continues wrapping past soft newlines - # skipSoftWrapIndentation: if `true`, skips soft wrap indentation without wrapping to the previous line - # screenLine: if `true`, indicates that you're using a line number, not a row number - # - # Returns the new, clipped {Point}. Note that this could be the same as `position` if no clipping was performed. - clipScreenPosition: (screenPosition, options={}) -> - {wrapBeyondNewlines, wrapAtSoftNewlines, skipSoftWrapIndentation} = options - {row, column} = Point.fromObject(screenPosition) - - if row < 0 - row = 0 - column = 0 - else if row > @getLastRow() - row = @getLastRow() - column = Infinity - else if column < 0 - column = 0 - - screenLine = @screenLines[row] - maxScreenColumn = screenLine.getMaxScreenColumn() - - if screenLine.isSoftWrapped() and column >= maxScreenColumn - if wrapAtSoftNewlines - row++ - column = @screenLines[row].clipScreenColumn(0) - else - column = screenLine.clipScreenColumn(maxScreenColumn - 1) - else if screenLine.isColumnInsideSoftWrapIndentation(column) - if skipSoftWrapIndentation - column = screenLine.clipScreenColumn(0) - else - row-- - column = @screenLines[row].getMaxScreenColumn() - 1 - else if wrapBeyondNewlines and column > maxScreenColumn and row < @getLastRow() - row++ - column = 0 - else - column = screenLine.clipScreenColumn(column, options) - new Point(row, column) - - # Clip the start and end of the given range to valid positions on screen. - # See {::clipScreenPosition} for more information. - # - # * `range` The {Range} to clip. - # * `options` (optional) See {::clipScreenPosition} `options`. - # Returns a {Range}. - clipScreenRange: (range, options) -> - start = @clipScreenPosition(range.start, options) - end = @clipScreenPosition(range.end, options) - - new Range(start, end) - - # Calculates a {Range} representing the start of the {TextBuffer} until the end. - # - # Returns a {Range}. - rangeForAllLines: -> - new Range([0, 0], @clipScreenPosition([Infinity, Infinity])) - - decorationForId: (id) -> - @decorationsById[id] - - getDecorations: (propertyFilter) -> - allDecorations = [] - for markerId, decorations of @decorationsByMarkerId - allDecorations.push(decorations...) if decorations? - if propertyFilter? - allDecorations = allDecorations.filter (decoration) -> - for key, value of propertyFilter - return false unless decoration.properties[key] is value - true - allDecorations - - getLineDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('line') - - getLineNumberDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('line-number') - - getHighlightDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('highlight') - - getOverlayDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('overlay') - - decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> - decorationsByMarkerId = {} - for marker in @findMarkers(intersectsScreenRowRange: [startScreenRow, endScreenRow]) - if decorations = @decorationsByMarkerId[marker.id] - decorationsByMarkerId[marker.id] = decorations - decorationsByMarkerId - - decorateMarker: (marker, decorationParams) -> - marker = @getMarker(marker.id) - decoration = new Decoration(marker, this, decorationParams) - @disposables.add decoration.onDidDestroy => @removeDecoration(decoration) - @decorationsByMarkerId[marker.id] ?= [] - @decorationsByMarkerId[marker.id].push(decoration) - @decorationsById[decoration.id] = decoration - @emit 'decoration-added', decoration if Grim.includeDeprecatedAPIs - @emitter.emit 'did-add-decoration', decoration - decoration - - removeDecoration: (decoration) -> - {marker} = decoration - return unless decorations = @decorationsByMarkerId[marker.id] - index = decorations.indexOf(decoration) - - if index > -1 - decorations.splice(index, 1) - delete @decorationsById[decoration.id] - @emit 'decoration-removed', decoration if Grim.includeDeprecatedAPIs - @emitter.emit 'did-remove-decoration', decoration - delete @decorationsByMarkerId[marker.id] if decorations.length is 0 - - # Retrieves a {Marker} based on its id. - # - # id - A {Number} representing a marker id - # - # Returns the {Marker} (if it exists). - getMarker: (id) -> - unless marker = @markers[id] - if bufferMarker = @buffer.getMarker(id) - marker = new Marker({bufferMarker, displayBuffer: this}) - @markers[id] = marker - marker - - # Retrieves the active markers in the buffer. - # - # Returns an {Array} of existing {Marker}s. - getMarkers: -> - @buffer.getMarkers().map ({id}) => @getMarker(id) - - getMarkerCount: -> - @buffer.getMarkerCount() - - # Public: Constructs a new marker at the given screen range. - # - # range - The marker {Range} (representing the distance between the head and tail) - # options - Options to pass to the {Marker} constructor - # - # Returns a {Number} representing the new marker's ID. - markScreenRange: (args...) -> - bufferRange = @bufferRangeForScreenRange(args.shift()) - @markBufferRange(bufferRange, args...) - - # Public: Constructs a new marker at the given buffer range. - # - # range - The marker {Range} (representing the distance between the head and tail) - # options - Options to pass to the {Marker} constructor - # - # Returns a {Number} representing the new marker's ID. - markBufferRange: (range, options) -> - @getMarker(@buffer.markRange(range, options).id) - - # Public: Constructs a new marker at the given screen position. - # - # range - The marker {Range} (representing the distance between the head and tail) - # options - Options to pass to the {Marker} constructor - # - # Returns a {Number} representing the new marker's ID. - markScreenPosition: (screenPosition, options) -> - @markBufferPosition(@bufferPositionForScreenPosition(screenPosition), options) - - # Public: Constructs a new marker at the given buffer position. - # - # range - The marker {Range} (representing the distance between the head and tail) - # options - Options to pass to the {Marker} constructor - # - # Returns a {Number} representing the new marker's ID. - markBufferPosition: (bufferPosition, options) -> - @getMarker(@buffer.markPosition(bufferPosition, options).id) - - # Public: Removes the marker with the given id. - # - # id - The {Number} of the ID to remove - destroyMarker: (id) -> - @buffer.destroyMarker(id) - delete @markers[id] - - # Finds the first marker satisfying the given attributes - # - # Refer to {DisplayBuffer::findMarkers} for details. - # - # Returns a {Marker} or null - findMarker: (params) -> - @findMarkers(params)[0] - - # Public: Find all markers satisfying a set of parameters. - # - # params - An {Object} containing parameters that all returned markers must - # satisfy. Unreserved keys will be compared against the markers' custom - # properties. There are also the following reserved keys with special - # meaning for the query: - # :startBufferRow - A {Number}. Only returns markers starting at this row in - # buffer coordinates. - # :endBufferRow - A {Number}. Only returns markers ending at this row in - # buffer coordinates. - # :containsBufferRange - A {Range} or range-compatible {Array}. Only returns - # markers containing this range in buffer coordinates. - # :containsBufferPosition - A {Point} or point-compatible {Array}. Only - # returns markers containing this position in buffer coordinates. - # :containedInBufferRange - A {Range} or range-compatible {Array}. Only - # returns markers contained within this range. - # - # Returns an {Array} of {Marker}s - findMarkers: (params) -> - params = @translateToBufferMarkerParams(params) - @buffer.findMarkers(params).map (stringMarker) => @getMarker(stringMarker.id) - - translateToBufferMarkerParams: (params) -> - bufferMarkerParams = {} - for key, value of params - switch key - when 'startBufferRow' - key = 'startRow' - when 'endBufferRow' - key = 'endRow' - when 'startScreenRow' - key = 'startRow' - value = @bufferRowForScreenRow(value) - when 'endScreenRow' - key = 'endRow' - value = @bufferRowForScreenRow(value) - when 'intersectsBufferRowRange' - key = 'intersectsRowRange' - when 'intersectsScreenRowRange' - key = 'intersectsRowRange' - [startRow, endRow] = value - value = [@bufferRowForScreenRow(startRow), @bufferRowForScreenRow(endRow)] - when 'containsBufferRange' - key = 'containsRange' - when 'containsBufferPosition' - key = 'containsPosition' - when 'containedInBufferRange' - key = 'containedInRange' - when 'containedInScreenRange' - key = 'containedInRange' - value = @bufferRangeForScreenRange(value) - when 'intersectsBufferRange' - key = 'intersectsRange' - when 'intersectsScreenRange' - key = 'intersectsRange' - value = @bufferRangeForScreenRange(value) - bufferMarkerParams[key] = value - - bufferMarkerParams - - findFoldMarker: (attributes) -> - @findFoldMarkers(attributes)[0] - - findFoldMarkers: (attributes) -> - @buffer.findMarkers(@getFoldMarkerAttributes(attributes)) - - getFoldMarkerAttributes: (attributes) -> - if attributes - _.extend(attributes, @foldMarkerAttributes) - else - @foldMarkerAttributes - - refreshMarkerScreenPositions: -> - for marker in @getMarkers() - marker.notifyObservers(textChanged: false) - return - - destroyed: -> - marker.disposables.dispose() for id, marker of @markers - @scopedConfigSubscriptions.dispose() - @disposables.dispose() - @tokenizedBuffer.destroy() - - logLines: (start=0, end=@getLastRow()) -> - for row in [start..end] - line = @tokenizedLineForScreenRow(row).text - console.log row, @bufferRowForScreenRow(row), line, line.length - return - - getRootScopeDescriptor: -> - @tokenizedBuffer.rootScopeDescriptor - - handleTokenizedBufferChange: (tokenizedBufferChange) => - {start, end, delta, bufferChange} = tokenizedBufferChange - @updateScreenLines(start, end + 1, delta, refreshMarkers: false) - @setScrollTop(Math.min(@getScrollTop(), @getMaxScrollTop())) if delta < 0 - - updateScreenLines: (startBufferRow, endBufferRow, bufferDelta=0, options={}) -> - startBufferRow = @rowMap.bufferRowRangeForBufferRow(startBufferRow)[0] - endBufferRow = @rowMap.bufferRowRangeForBufferRow(endBufferRow - 1)[1] - startScreenRow = @rowMap.screenRowRangeForBufferRow(startBufferRow)[0] - endScreenRow = @rowMap.screenRowRangeForBufferRow(endBufferRow - 1)[1] - {screenLines, regions} = @buildScreenLines(startBufferRow, endBufferRow + bufferDelta) - screenDelta = screenLines.length - (endScreenRow - startScreenRow) - - _.spliceWithArray(@screenLines, startScreenRow, endScreenRow - startScreenRow, screenLines, 10000) - @rowMap.spliceRegions(startBufferRow, endBufferRow - startBufferRow, regions) - @findMaxLineLength(startScreenRow, endScreenRow, screenLines, screenDelta) - - return if options.suppressChangeEvent - - changeEvent = - start: startScreenRow - end: endScreenRow - 1 - screenDelta: screenDelta - bufferDelta: bufferDelta - - @emitDidChange(changeEvent, options.refreshMarkers) - - buildScreenLines: (startBufferRow, endBufferRow) -> - screenLines = [] - regions = [] - rectangularRegion = null - - bufferRow = startBufferRow - while bufferRow < endBufferRow - tokenizedLine = @tokenizedBuffer.tokenizedLineForRow(bufferRow) - - if fold = @largestFoldStartingAtBufferRow(bufferRow) - foldLine = tokenizedLine.copy() - foldLine.fold = fold - screenLines.push(foldLine) - - if rectangularRegion? - regions.push(rectangularRegion) - rectangularRegion = null - - foldedRowCount = fold.getBufferRowCount() - regions.push(bufferRows: foldedRowCount, screenRows: 1) - bufferRow += foldedRowCount - else - softWraps = 0 - if @isSoftWrapped() - while wrapScreenColumn = tokenizedLine.findWrapColumn(@getSoftWrapColumn()) - [wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt( - wrapScreenColumn, - @configSettings.softWrapHangingIndent - ) - break if wrappedLine.hasOnlySoftWrapIndentation() - screenLines.push(wrappedLine) - softWraps++ - screenLines.push(tokenizedLine) - - if softWraps > 0 - if rectangularRegion? - regions.push(rectangularRegion) - rectangularRegion = null - regions.push(bufferRows: 1, screenRows: softWraps + 1) - else - rectangularRegion ?= {bufferRows: 0, screenRows: 0} - rectangularRegion.bufferRows++ - rectangularRegion.screenRows++ - - bufferRow++ - - if rectangularRegion? - regions.push(rectangularRegion) - - {screenLines, regions} - - findMaxLineLength: (startScreenRow, endScreenRow, newScreenLines, screenDelta) -> - oldMaxLineLength = @maxLineLength - - if startScreenRow <= @longestScreenRow < endScreenRow - @longestScreenRow = 0 - @maxLineLength = 0 - maxLengthCandidatesStartRow = 0 - maxLengthCandidates = @screenLines - else - @longestScreenRow += screenDelta if endScreenRow <= @longestScreenRow - maxLengthCandidatesStartRow = startScreenRow - maxLengthCandidates = newScreenLines - - for screenLine, i in maxLengthCandidates - screenRow = maxLengthCandidatesStartRow + i - length = screenLine.text.length - if length > @maxLineLength - @longestScreenRow = screenRow - @maxLineLength = length - - @computeScrollWidth() if oldMaxLineLength isnt @maxLineLength - - computeScrollWidth: -> - @scrollWidth = @pixelPositionForScreenPosition([@longestScreenRow, @maxLineLength]).left - @scrollWidth += 1 unless @isSoftWrapped() - @setScrollLeft(Math.min(@getScrollLeft(), @getMaxScrollLeft())) - - handleBufferMarkerCreated: (textBufferMarker) => - @createFoldForMarker(textBufferMarker) if textBufferMarker.matchesParams(@getFoldMarkerAttributes()) - if marker = @getMarker(textBufferMarker.id) - # The marker might have been removed in some other handler called before - # this one. Only emit when the marker still exists. - @emit 'marker-created', marker if Grim.includeDeprecatedAPIs - @emitter.emit 'did-create-marker', marker - - createFoldForMarker: (marker) -> - @decorateMarker(marker, type: 'line-number', class: 'folded') - new Fold(this, marker) - - foldForMarker: (marker) -> - @foldsByMarkerId[marker.id] - -if Grim.includeDeprecatedAPIs - DisplayBuffer.properties - softWrapped: null - editorWidthInChars: null - lineHeightInPixels: null - defaultCharWidth: null - height: null - width: null - scrollTop: 0 - scrollLeft: 0 - scrollWidth: 0 - verticalScrollbarWidth: 15 - horizontalScrollbarHeight: 15 - - EmitterMixin = require('emissary').Emitter - - DisplayBuffer::on = (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use DisplayBuffer::onDidChange instead") - when 'grammar-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeGrammar instead") - when 'soft-wrap-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeSoftWrap instead") - when 'character-widths-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeCharacterWidths instead") - when 'decoration-added' - Grim.deprecate("Use DisplayBuffer::onDidAddDecoration instead") - when 'decoration-removed' - Grim.deprecate("Use DisplayBuffer::onDidRemoveDecoration instead") - when 'decoration-changed' - Grim.deprecate("Use decoration.getMarker().onDidChange() instead") - when 'decoration-updated' - Grim.deprecate("Use Decoration::onDidChangeProperties instead") - when 'marker-created' - Grim.deprecate("Use Decoration::onDidCreateMarker instead") - when 'markers-updated' - Grim.deprecate("Use Decoration::onDidUpdateMarkers instead") - else - Grim.deprecate("DisplayBuffer::on is deprecated. Use event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) -else - DisplayBuffer::softWrapped = null - DisplayBuffer::editorWidthInChars = null - DisplayBuffer::lineHeightInPixels = null - DisplayBuffer::defaultCharWidth = null - DisplayBuffer::height = null - DisplayBuffer::width = null - DisplayBuffer::scrollTop = 0 - DisplayBuffer::scrollLeft = 0 - DisplayBuffer::scrollWidth = 0 - DisplayBuffer::verticalScrollbarWidth = 15 - DisplayBuffer::horizontalScrollbarHeight = 15 diff --git a/src/dock.js b/src/dock.js new file mode 100644 index 00000000000..5a396d239a0 --- /dev/null +++ b/src/dock.js @@ -0,0 +1,907 @@ +const etch = require('etch'); +const _ = require('underscore-plus'); +const { CompositeDisposable, Emitter } = require('event-kit'); +const PaneContainer = require('./pane-container'); +const TextEditor = require('./text-editor'); +const Grim = require('grim'); + +const $ = etch.dom; +const MINIMUM_SIZE = 100; +const DEFAULT_INITIAL_SIZE = 300; +const SHOULD_ANIMATE_CLASS = 'atom-dock-should-animate'; +const VISIBLE_CLASS = 'atom-dock-open'; +const RESIZE_HANDLE_RESIZABLE_CLASS = 'atom-dock-resize-handle-resizable'; +const TOGGLE_BUTTON_VISIBLE_CLASS = 'atom-dock-toggle-button-visible'; +const CURSOR_OVERLAY_VISIBLE_CLASS = 'atom-dock-cursor-overlay-visible'; + +// Extended: A container at the edges of the editor window capable of holding items. +// You should not create a Dock directly. Instead, access one of the three docks of the workspace +// via {Workspace::getLeftDock}, {Workspace::getRightDock}, and {Workspace::getBottomDock} +// or add an item to a dock via {Workspace::open}. +module.exports = class Dock { + constructor(params) { + this.handleResizeHandleDragStart = this.handleResizeHandleDragStart.bind( + this + ); + this.handleResizeToFit = this.handleResizeToFit.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + this.handleDrag = _.throttle(this.handleDrag.bind(this), 30); + this.handleDragEnd = this.handleDragEnd.bind(this); + this.handleToggleButtonDragEnter = this.handleToggleButtonDragEnter.bind( + this + ); + this.toggle = this.toggle.bind(this); + + this.location = params.location; + this.widthOrHeight = getWidthOrHeight(this.location); + this.config = params.config; + this.applicationDelegate = params.applicationDelegate; + this.deserializerManager = params.deserializerManager; + this.notificationManager = params.notificationManager; + this.viewRegistry = params.viewRegistry; + this.didActivate = params.didActivate; + + this.emitter = new Emitter(); + + this.paneContainer = new PaneContainer({ + location: this.location, + config: this.config, + applicationDelegate: this.applicationDelegate, + deserializerManager: this.deserializerManager, + notificationManager: this.notificationManager, + viewRegistry: this.viewRegistry + }); + + this.state = { + size: null, + visible: false, + shouldAnimate: false + }; + + this.subscriptions = new CompositeDisposable( + this.emitter, + this.paneContainer.onDidActivatePane(() => { + this.show(); + this.didActivate(this); + }), + this.paneContainer.observePanes(pane => { + pane.onDidAddItem(this.handleDidAddPaneItem.bind(this)); + pane.onDidRemoveItem(this.handleDidRemovePaneItem.bind(this)); + }), + this.paneContainer.onDidChangeActivePane(item => + params.didChangeActivePane(this, item) + ), + this.paneContainer.onDidChangeActivePaneItem(item => + params.didChangeActivePaneItem(this, item) + ), + this.paneContainer.onDidDestroyPaneItem(item => + params.didDestroyPaneItem(item) + ) + ); + } + + // This method is called explicitly by the object which adds the Dock to the document. + elementAttached() { + // Re-render when the dock is attached to make sure we remeasure sizes defined in CSS. + etch.updateSync(this); + } + + getElement() { + // Because this code is included in the snapshot, we have to make sure we don't touch the DOM + // during initialization. Therefore, we defer initialization of the component (which creates a + // DOM element) until somebody asks for the element. + if (this.element == null) { + etch.initialize(this); + } + return this.element; + } + + getLocation() { + return this.location; + } + + destroy() { + this.subscriptions.dispose(); + this.paneContainer.destroy(); + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('drag', this.handleDrag); + window.removeEventListener('dragend', this.handleDragEnd); + } + + setHovered(hovered) { + if (hovered === this.state.hovered) return; + this.setState({ hovered }); + } + + setDraggingItem(draggingItem) { + if (draggingItem === this.state.draggingItem) return; + this.setState({ draggingItem }); + } + + // Extended: Show the dock and focus its active {Pane}. + activate() { + this.getActivePane().activate(); + } + + // Extended: Show the dock without focusing it. + show() { + this.setState({ visible: true }); + } + + // Extended: Hide the dock and activate the {WorkspaceCenter} if the dock was + // was previously focused. + hide() { + this.setState({ visible: false }); + } + + // Extended: Toggle the dock's visibility without changing the {Workspace}'s + // active pane container. + toggle() { + const state = { visible: !this.state.visible }; + if (!state.visible) state.hovered = false; + this.setState(state); + } + + // Extended: Check if the dock is visible. + // + // Returns a {Boolean}. + isVisible() { + return this.state.visible; + } + + setState(newState) { + const prevState = this.state; + const nextState = Object.assign({}, prevState, newState); + + // Update the `shouldAnimate` state. This needs to be written to the DOM before updating the + // class that changes the animated property. Normally we'd have to defer the class change a + // frame to ensure the property is animated (or not) appropriately, however we luck out in this + // case because the drag start always happens before the item is dragged into the toggle button. + if (nextState.visible !== prevState.visible) { + // Never animate toggling visibility... + nextState.shouldAnimate = false; + } else if ( + !nextState.visible && + nextState.draggingItem && + !prevState.draggingItem + ) { + // ...but do animate if you start dragging while the panel is hidden. + nextState.shouldAnimate = true; + } + + this.state = nextState; + + const { hovered, visible } = this.state; + + // Render immediately if the dock becomes visible or the size changes in case people are + // measuring after opening, for example. + if (this.element != null) { + if ((visible && !prevState.visible) || this.state.size !== prevState.size) + etch.updateSync(this); + else etch.update(this); + } + + if (hovered !== prevState.hovered) { + this.emitter.emit('did-change-hovered', hovered); + } + if (visible !== prevState.visible) { + this.emitter.emit('did-change-visible', visible); + } + } + + render() { + const innerElementClassList = ['atom-dock-inner', this.location]; + if (this.state.visible) innerElementClassList.push(VISIBLE_CLASS); + + const maskElementClassList = ['atom-dock-mask']; + if (this.state.shouldAnimate) + maskElementClassList.push(SHOULD_ANIMATE_CLASS); + + const cursorOverlayElementClassList = [ + 'atom-dock-cursor-overlay', + this.location + ]; + if (this.state.resizing) + cursorOverlayElementClassList.push(CURSOR_OVERLAY_VISIBLE_CLASS); + + const shouldBeVisible = this.state.visible || this.state.showDropTarget; + const size = Math.max( + MINIMUM_SIZE, + this.state.size || + (this.state.draggingItem && + getPreferredSize(this.state.draggingItem, this.location)) || + DEFAULT_INITIAL_SIZE + ); + + // We need to change the size of the mask... + const maskStyle = { + [this.widthOrHeight]: `${shouldBeVisible ? size : 0}px` + }; + // ...but the content needs to maintain a constant size. + const wrapperStyle = { [this.widthOrHeight]: `${size}px` }; + + return $( + 'atom-dock', + { className: this.location }, + $.div( + { ref: 'innerElement', className: innerElementClassList.join(' ') }, + $.div( + { + className: maskElementClassList.join(' '), + style: maskStyle + }, + $.div( + { + ref: 'wrapperElement', + className: `atom-dock-content-wrapper ${this.location}`, + style: wrapperStyle + }, + $(DockResizeHandle, { + location: this.location, + onResizeStart: this.handleResizeHandleDragStart, + onResizeToFit: this.handleResizeToFit, + dockIsVisible: this.state.visible + }), + $(ElementComponent, { element: this.paneContainer.getElement() }), + $.div({ className: cursorOverlayElementClassList.join(' ') }) + ) + ), + $(DockToggleButton, { + ref: 'toggleButton', + onDragEnter: this.state.draggingItem + ? this.handleToggleButtonDragEnter + : null, + location: this.location, + toggle: this.toggle, + dockIsVisible: shouldBeVisible, + visible: + // Don't show the toggle button if the dock is closed and empty... + (this.state.hovered && + (this.state.visible || this.getPaneItems().length > 0)) || + // ...or if the item can't be dropped in that dock. + (!shouldBeVisible && + this.state.draggingItem && + isItemAllowed(this.state.draggingItem, this.location)) + }) + ) + ); + } + + update(props) { + // Since we're interopping with non-etch stuff, this method's actually never called. + return etch.update(this); + } + + handleDidAddPaneItem() { + if (this.state.size == null) { + this.setState({ size: this.getInitialSize() }); + } + } + + handleDidRemovePaneItem() { + // Hide the dock if you remove the last item. + if (this.paneContainer.getPaneItems().length === 0) { + this.setState({ visible: false, hovered: false, size: null }); + } + } + + handleResizeHandleDragStart() { + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.handleMouseUp); + this.setState({ resizing: true }); + } + + handleResizeToFit() { + const item = this.getActivePaneItem(); + if (item) { + const size = getPreferredSize(item, this.getLocation()); + if (size != null) this.setState({ size }); + } + } + + handleMouseMove(event) { + if (event.buttons === 0) { + // We missed the mouseup event. For some reason it happens on Windows + this.handleMouseUp(event); + return; + } + + let size = 0; + switch (this.location) { + case 'left': + size = event.pageX - this.element.getBoundingClientRect().left; + break; + case 'bottom': + size = this.element.getBoundingClientRect().bottom - event.pageY; + break; + case 'right': + size = this.element.getBoundingClientRect().right - event.pageX; + break; + } + this.setState({ size }); + } + + handleMouseUp(event) { + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + this.setState({ resizing: false }); + } + + handleToggleButtonDragEnter() { + this.setState({ showDropTarget: true }); + window.addEventListener('drag', this.handleDrag); + window.addEventListener('dragend', this.handleDragEnd); + } + + handleDrag(event) { + if (!this.pointWithinHoverArea({ x: event.pageX, y: event.pageY }, true)) { + this.draggedOut(); + } + } + + handleDragEnd() { + this.draggedOut(); + } + + draggedOut() { + this.setState({ showDropTarget: false }); + window.removeEventListener('drag', this.handleDrag); + window.removeEventListener('dragend', this.handleDragEnd); + } + + // Determine whether the cursor is within the dock hover area. This isn't as simple as just using + // mouseenter/leave because we want to be a little more forgiving. For example, if the cursor is + // over the footer, we want to show the bottom dock's toggle button. Also note that our criteria + // for detecting entry are different than detecting exit but, in order for us to avoid jitter, the + // area considered when detecting exit MUST fully encompass the area considered when detecting + // entry. + pointWithinHoverArea(point, detectingExit) { + const dockBounds = this.refs.innerElement.getBoundingClientRect(); + + // Copy the bounds object since we can't mutate it. + const bounds = { + top: dockBounds.top, + right: dockBounds.right, + bottom: dockBounds.bottom, + left: dockBounds.left + }; + + // To provide a minimum target, expand the area toward the center a bit. + switch (this.location) { + case 'right': + bounds.left = Math.min(bounds.left, bounds.right - 2); + break; + case 'bottom': + bounds.top = Math.min(bounds.top, bounds.bottom - 1); + break; + case 'left': + bounds.right = Math.max(bounds.right, bounds.left + 2); + break; + } + + // Further expand the area to include all panels that are closer to the edge than the dock. + switch (this.location) { + case 'right': + bounds.right = Number.POSITIVE_INFINITY; + break; + case 'bottom': + bounds.bottom = Number.POSITIVE_INFINITY; + break; + case 'left': + bounds.left = Number.NEGATIVE_INFINITY; + break; + } + + // If we're in this area, we know we're within the hover area without having to take further + // measurements. + if (rectContainsPoint(bounds, point)) return true; + + // If we're within the toggle button, we're definitely in the hover area. Unfortunately, we + // can't do this measurement conditionally (e.g. only if the toggle button is visible) because + // our knowledge of the toggle's button is incomplete due to CSS animations. (We may think the + // toggle button isn't visible when in actuality it is, but is animating to its hidden state.) + // + // Since `point` is always the current mouse position, one possible optimization would be to + // remove it as an argument and determine whether we're inside the toggle button using + // mouseenter/leave events on it. This class would still need to keep track of the mouse + // position (via a mousemove listener) for the other measurements, though. + const toggleButtonBounds = this.refs.toggleButton.getBounds(); + if (rectContainsPoint(toggleButtonBounds, point)) return true; + + // The area used when detecting exit is actually larger than when detecting entrances. Expand + // our bounds and recheck them. + if (detectingExit) { + const hoverMargin = 20; + switch (this.location) { + case 'right': + bounds.left = + Math.min(bounds.left, toggleButtonBounds.left) - hoverMargin; + break; + case 'bottom': + bounds.top = + Math.min(bounds.top, toggleButtonBounds.top) - hoverMargin; + break; + case 'left': + bounds.right = + Math.max(bounds.right, toggleButtonBounds.right) + hoverMargin; + break; + } + if (rectContainsPoint(bounds, point)) return true; + } + + return false; + } + + getInitialSize() { + // The item may not have been activated yet. If that's the case, just use the first item. + const activePaneItem = + this.paneContainer.getActivePaneItem() || + this.paneContainer.getPaneItems()[0]; + // If there are items, we should have an explicit width; if not, we shouldn't. + return activePaneItem + ? getPreferredSize(activePaneItem, this.location) || DEFAULT_INITIAL_SIZE + : null; + } + + serialize() { + return { + deserializer: 'Dock', + size: this.state.size, + paneContainer: this.paneContainer.serialize(), + visible: this.state.visible + }; + } + + deserialize(serialized, deserializerManager) { + this.paneContainer.deserialize( + serialized.paneContainer, + deserializerManager + ); + this.setState({ + size: serialized.size || this.getInitialSize(), + // If no items could be deserialized, we don't want to show the dock (even if it was visible last time) + visible: + serialized.visible && this.paneContainer.getPaneItems().length > 0 + }); + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback when the visibility of the dock changes. + // + // * `callback` {Function} to be called when the visibility changes. + // * `visible` {Boolean} Is the dock now visible? + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeVisible(callback) { + return this.emitter.on('did-change-visible', callback); + } + + // Essential: Invoke the given callback with the current and all future visibilities of the dock. + // + // * `callback` {Function} to be called when the visibility changes. + // * `visible` {Boolean} Is the dock now visible? + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeVisible(callback) { + callback(this.isVisible()); + return this.onDidChangeVisible(callback); + } + + // Essential: Invoke the given callback with all current and future panes items + // in the dock. + // + // * `callback` {Function} to be called with current and future pane items. + // * `item` An item that is present in {::getPaneItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePaneItems(callback) { + return this.paneContainer.observePaneItems(callback); + } + + // Essential: Invoke the given callback when the active pane item changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider + // {::onDidStopChangingActivePaneItem} to delay operations until after changes + // stop occurring. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePaneItem(callback) { + return this.paneContainer.onDidChangeActivePaneItem(callback); + } + + // Essential: Invoke the given callback when the active pane item stops + // changing. + // + // Observers are called asynchronously 100ms after the last active pane item + // change. Handling changes here rather than in the synchronous + // {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly + // changing or closing tabs and ensures critical UI feedback, like changing the + // highlighted tab, gets priority over work that can be done asynchronously. + // + // * `callback` {Function} to be called when the active pane item stopts + // changing. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChangingActivePaneItem(callback) { + return this.paneContainer.onDidStopChangingActivePaneItem(callback); + } + + // Essential: Invoke the given callback with the current active pane item and + // with all future active pane items in the dock. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The current active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePaneItem(callback) { + return this.paneContainer.observeActivePaneItem(callback); + } + + // Extended: Invoke the given callback when a pane is added to the dock. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `pane` The added pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPane(callback) { + return this.paneContainer.onDidAddPane(callback); + } + + // Extended: Invoke the given callback before a pane is destroyed in the + // dock. + // + // * `callback` {Function} to be called before panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The pane to be destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroyPane(callback) { + return this.paneContainer.onWillDestroyPane(callback); + } + + // Extended: Invoke the given callback when a pane is destroyed in the dock. + // + // * `callback` {Function} to be called panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The destroyed pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroyPane(callback) { + return this.paneContainer.onDidDestroyPane(callback); + } + + // Extended: Invoke the given callback with all current and future panes in the + // dock. + // + // * `callback` {Function} to be called with current and future panes. + // * `pane` A {Pane} that is present in {::getPanes} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePanes(callback) { + return this.paneContainer.observePanes(callback); + } + + // Extended: Invoke the given callback when the active pane changes. + // + // * `callback` {Function} to be called when the active pane changes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePane(callback) { + return this.paneContainer.onDidChangeActivePane(callback); + } + + // Extended: Invoke the given callback with the current active pane and when + // the active pane changes. + // + // * `callback` {Function} to be called with the current and future active# + // panes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePane(callback) { + return this.paneContainer.observeActivePane(callback); + } + + // Extended: Invoke the given callback when a pane item is added to the dock. + // + // * `callback` {Function} to be called when pane items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `pane` {Pane} containing the added item. + // * `index` {Number} indicating the index of the added item in its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPaneItem(callback) { + return this.paneContainer.onDidAddPaneItem(callback); + } + + // Extended: Invoke the given callback when a pane item is about to be + // destroyed, before the user is prompted to save it. + // + // * `callback` {Function} to be called before pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The item to be destroyed. + // * `pane` {Pane} containing the item to be destroyed. + // * `index` {Number} indicating the index of the item to be destroyed in + // its pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onWillDestroyPaneItem(callback) { + return this.paneContainer.onWillDestroyPaneItem(callback); + } + + // Extended: Invoke the given callback when a pane item is destroyed. + // + // * `callback` {Function} to be called when pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The destroyed item. + // * `pane` {Pane} containing the destroyed item. + // * `index` {Number} indicating the index of the destroyed item in its + // pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onDidDestroyPaneItem(callback) { + return this.paneContainer.onDidDestroyPaneItem(callback); + } + + // Extended: Invoke the given callback when the hovered state of the dock changes. + // + // * `callback` {Function} to be called when the hovered state changes. + // * `hovered` {Boolean} Is the dock now hovered? + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeHovered(callback) { + return this.emitter.on('did-change-hovered', callback); + } + + /* + Section: Pane Items + */ + + // Essential: Get all pane items in the dock. + // + // Returns an {Array} of items. + getPaneItems() { + return this.paneContainer.getPaneItems(); + } + + // Essential: Get the active {Pane}'s active item. + // + // Returns an pane item {Object}. + getActivePaneItem() { + return this.paneContainer.getActivePaneItem(); + } + + // Deprecated: Get the active item if it is a {TextEditor}. + // + // Returns a {TextEditor} or `undefined` if the current active item is not a + // {TextEditor}. + getActiveTextEditor() { + Grim.deprecate( + 'Text editors are not allowed in docks. Use atom.workspace.getActiveTextEditor() instead.' + ); + + const activeItem = this.getActivePaneItem(); + if (activeItem instanceof TextEditor) { + return activeItem; + } + } + + // Save all pane items. + saveAll() { + this.paneContainer.saveAll(); + } + + confirmClose(options) { + return this.paneContainer.confirmClose(options); + } + + /* + Section: Panes + */ + + // Extended: Get all panes in the dock. + // + // Returns an {Array} of {Pane}s. + getPanes() { + return this.paneContainer.getPanes(); + } + + // Extended: Get the active {Pane}. + // + // Returns a {Pane}. + getActivePane() { + return this.paneContainer.getActivePane(); + } + + // Extended: Make the next pane active. + activateNextPane() { + return this.paneContainer.activateNextPane(); + } + + // Extended: Make the previous pane active. + activatePreviousPane() { + return this.paneContainer.activatePreviousPane(); + } + + paneForURI(uri) { + return this.paneContainer.paneForURI(uri); + } + + paneForItem(item) { + return this.paneContainer.paneForItem(item); + } + + // Destroy (close) the active pane. + destroyActivePane() { + const activePane = this.getActivePane(); + if (activePane != null) { + activePane.destroy(); + } + } +}; + +class DockResizeHandle { + constructor(props) { + this.props = props; + etch.initialize(this); + } + + render() { + const classList = ['atom-dock-resize-handle', this.props.location]; + if (this.props.dockIsVisible) classList.push(RESIZE_HANDLE_RESIZABLE_CLASS); + + return $.div({ + className: classList.join(' '), + on: { mousedown: this.handleMouseDown } + }); + } + + getElement() { + return this.element; + } + + getSize() { + if (!this.size) { + this.size = this.element.getBoundingClientRect()[ + getWidthOrHeight(this.props.location) + ]; + } + return this.size; + } + + update(newProps) { + this.props = Object.assign({}, this.props, newProps); + return etch.update(this); + } + + handleMouseDown(event) { + if (event.detail === 2) { + this.props.onResizeToFit(); + } else if (this.props.dockIsVisible) { + this.props.onResizeStart(); + } + } +} + +class DockToggleButton { + constructor(props) { + this.props = props; + etch.initialize(this); + } + + render() { + const classList = ['atom-dock-toggle-button', this.props.location]; + if (this.props.visible) classList.push(TOGGLE_BUTTON_VISIBLE_CLASS); + + return $.div( + { className: classList.join(' ') }, + $.div( + { + ref: 'innerElement', + className: `atom-dock-toggle-button-inner ${this.props.location}`, + on: { + click: this.handleClick, + dragenter: this.props.onDragEnter + } + }, + $.span({ + ref: 'iconElement', + className: `icon ${getIconName( + this.props.location, + this.props.dockIsVisible + )}` + }) + ) + ); + } + + getElement() { + return this.element; + } + + getBounds() { + return this.refs.innerElement.getBoundingClientRect(); + } + + update(newProps) { + this.props = Object.assign({}, this.props, newProps); + return etch.update(this); + } + + handleClick() { + this.props.toggle(); + } +} + +// An etch component that doesn't use etch, this component provides a gateway from JSX back into +// the mutable DOM world. +class ElementComponent { + constructor(props) { + this.element = props.element; + } + + update(props) { + this.element = props.element; + } +} + +function getWidthOrHeight(location) { + return location === 'left' || location === 'right' ? 'width' : 'height'; +} + +function getPreferredSize(item, location) { + switch (location) { + case 'left': + case 'right': + return typeof item.getPreferredWidth === 'function' + ? item.getPreferredWidth() + : null; + default: + return typeof item.getPreferredHeight === 'function' + ? item.getPreferredHeight() + : null; + } +} + +function getIconName(location, visible) { + switch (location) { + case 'right': + return visible ? 'icon-chevron-right' : 'icon-chevron-left'; + case 'bottom': + return visible ? 'icon-chevron-down' : 'icon-chevron-up'; + case 'left': + return visible ? 'icon-chevron-left' : 'icon-chevron-right'; + default: + throw new Error(`Invalid location: ${location}`); + } +} + +function rectContainsPoint(rect, point) { + return ( + point.x >= rect.left && + point.y >= rect.top && + point.x <= rect.right && + point.y <= rect.bottom + ); +} + +// Is the item allowed in the given location? +function isItemAllowed(item, location) { + if (typeof item.getAllowedLocations !== 'function') return true; + return item.getAllowedLocations().includes(location); +} diff --git a/src/electron-shims.js b/src/electron-shims.js new file mode 100644 index 00000000000..0c947aa6378 --- /dev/null +++ b/src/electron-shims.js @@ -0,0 +1,97 @@ +const path = require('path'); +const electron = require('electron'); + +const dirname = path.dirname; +path.dirname = function(path) { + if (typeof path !== 'string') { + path = '' + path; + const Grim = require('grim'); + Grim.deprecate('Argument to `path.dirname` must be a string'); + } + + return dirname(path); +}; + +const extname = path.extname; +path.extname = function(path) { + if (typeof path !== 'string') { + path = '' + path; + const Grim = require('grim'); + Grim.deprecate('Argument to `path.extname` must be a string'); + } + + return extname(path); +}; + +const basename = path.basename; +path.basename = function(path, ext) { + if ( + typeof path !== 'string' || + (ext !== undefined && typeof ext !== 'string') + ) { + path = '' + path; + const Grim = require('grim'); + Grim.deprecate('Arguments to `path.basename` must be strings'); + } + + return basename(path, ext); +}; + +electron.ipcRenderer.sendChannel = function() { + const Grim = require('grim'); + Grim.deprecate('Use `ipcRenderer.send` instead of `ipcRenderer.sendChannel`'); + return this.send.apply(this, arguments); +}; + +const remoteRequire = electron.remote.require; +electron.remote.require = function(moduleName) { + const Grim = require('grim'); + switch (moduleName) { + case 'menu': + Grim.deprecate('Use `remote.Menu` instead of `remote.require("menu")`'); + return this.Menu; + case 'menu-item': + Grim.deprecate( + 'Use `remote.MenuItem` instead of `remote.require("menu-item")`' + ); + return this.MenuItem; + case 'browser-window': + Grim.deprecate( + 'Use `remote.BrowserWindow` instead of `remote.require("browser-window")`' + ); + return this.BrowserWindow; + case 'dialog': + Grim.deprecate( + 'Use `remote.Dialog` instead of `remote.require("dialog")`' + ); + return this.Dialog; + case 'app': + Grim.deprecate('Use `remote.app` instead of `remote.require("app")`'); + return this.app; + case 'crash-reporter': + Grim.deprecate( + 'Use `remote.crashReporter` instead of `remote.require("crashReporter")`' + ); + return this.crashReporter; + case 'global-shortcut': + Grim.deprecate( + 'Use `remote.globalShortcut` instead of `remote.require("global-shortcut")`' + ); + return this.globalShortcut; + case 'clipboard': + Grim.deprecate( + 'Use `remote.clipboard` instead of `remote.require("clipboard")`' + ); + return this.clipboard; + case 'native-image': + Grim.deprecate( + 'Use `remote.nativeImage` instead of `remote.require("native-image")`' + ); + return this.nativeImage; + case 'tray': + Grim.deprecate('Use `remote.Tray` instead of `remote.require("tray")`'); + return this.Tray; + default: + return remoteRequire.call(this, moduleName); + } +}; diff --git a/src/file-system-blob-store.js b/src/file-system-blob-store.js new file mode 100644 index 00000000000..6f2aa07c951 --- /dev/null +++ b/src/file-system-blob-store.js @@ -0,0 +1,131 @@ +'use strict'; + +const fs = require('fs-plus'); +const path = require('path'); + +module.exports = class FileSystemBlobStore { + static load(directory) { + let instance = new FileSystemBlobStore(directory); + instance.load(); + return instance; + } + + constructor(directory) { + this.blobFilename = path.join(directory, 'BLOB'); + this.blobMapFilename = path.join(directory, 'MAP'); + this.lockFilename = path.join(directory, 'LOCK'); + this.reset(); + } + + reset() { + this.inMemoryBlobs = new Map(); + this.storedBlob = Buffer.alloc(0); + this.storedBlobMap = {}; + this.usedKeys = new Set(); + } + + load() { + if (!fs.existsSync(this.blobMapFilename)) { + return; + } + if (!fs.existsSync(this.blobFilename)) { + return; + } + + try { + this.storedBlob = fs.readFileSync(this.blobFilename); + this.storedBlobMap = JSON.parse(fs.readFileSync(this.blobMapFilename)); + } catch (e) { + this.reset(); + } + } + + save() { + let dump = this.getDump(); + let blobToStore = Buffer.concat(dump[0]); + let mapToStore = JSON.stringify(dump[1]); + + let acquiredLock = false; + try { + fs.writeFileSync(this.lockFilename, 'LOCK', { flag: 'wx' }); + acquiredLock = true; + + fs.writeFileSync(this.blobFilename, blobToStore); + fs.writeFileSync(this.blobMapFilename, mapToStore); + } catch (error) { + // Swallow the exception silently only if we fail to acquire the lock. + if (error.code !== 'EEXIST') { + throw error; + } + } finally { + if (acquiredLock) { + fs.unlinkSync(this.lockFilename); + } + } + } + + has(key) { + return ( + this.inMemoryBlobs.has(key) || this.storedBlobMap.hasOwnProperty(key) + ); + } + + get(key) { + if (this.has(key)) { + this.usedKeys.add(key); + return this.getFromMemory(key) || this.getFromStorage(key); + } + } + + set(key, buffer) { + this.usedKeys.add(key); + return this.inMemoryBlobs.set(key, buffer); + } + + delete(key) { + this.inMemoryBlobs.delete(key); + delete this.storedBlobMap[key]; + } + + getFromMemory(key) { + return this.inMemoryBlobs.get(key); + } + + getFromStorage(key) { + if (!this.storedBlobMap[key]) { + return; + } + + return this.storedBlob.slice.apply( + this.storedBlob, + this.storedBlobMap[key] + ); + } + + getDump() { + let buffers = []; + let blobMap = {}; + let currentBufferStart = 0; + + function dump(key, getBufferByKey) { + let buffer = getBufferByKey(key); + buffers.push(buffer); + blobMap[key] = [currentBufferStart, currentBufferStart + buffer.length]; + currentBufferStart += buffer.length; + } + + for (let key of this.inMemoryBlobs.keys()) { + if (this.usedKeys.has(key)) { + dump(key, this.getFromMemory.bind(this)); + } + } + + for (let key of Object.keys(this.storedBlobMap)) { + if (!blobMap[key] && this.usedKeys.has(key)) { + dump(key, this.getFromStorage.bind(this)); + } + } + + return [buffers, blobMap]; + } +}; diff --git a/src/first-mate-helpers.js b/src/first-mate-helpers.js new file mode 100644 index 00000000000..bbc827f681f --- /dev/null +++ b/src/first-mate-helpers.js @@ -0,0 +1,11 @@ +module.exports = { + fromFirstMateScopeId(firstMateScopeId) { + let atomScopeId = -firstMateScopeId; + if ((atomScopeId & 1) === 0) atomScopeId--; + return atomScopeId + 256; + }, + + toFirstMateScopeId(atomScopeId) { + return -(atomScopeId - 256); + } +}; diff --git a/src/fold.coffee b/src/fold.coffee deleted file mode 100644 index 4b413bd1264..00000000000 --- a/src/fold.coffee +++ /dev/null @@ -1,84 +0,0 @@ -{Point, Range} = require 'text-buffer' - -# Represents a fold that collapses multiple buffer lines into a single -# line on the screen. -# -# Their creation is managed by the {DisplayBuffer}. -module.exports = -class Fold - id: null - displayBuffer: null - marker: null - - constructor: (@displayBuffer, @marker) -> - @id = @marker.id - @displayBuffer.foldsByMarkerId[@marker.id] = this - @updateDisplayBuffer() - @marker.onDidDestroy => @destroyed() - @marker.onDidChange ({isValid}) => @destroy() unless isValid - - # Returns whether this fold is contained within another fold - isInsideLargerFold: -> - largestContainingFoldMarker = @displayBuffer.findFoldMarker(containsRange: @getBufferRange()) - largestContainingFoldMarker and - not largestContainingFoldMarker.getRange().isEqual(@getBufferRange()) - - # Destroys this fold - destroy: -> - @marker.destroy() - - # Returns the fold's {Range} in buffer coordinates - # - # includeNewline - A {Boolean} which, if `true`, includes the trailing newline - # - # Returns a {Range}. - getBufferRange: ({includeNewline}={}) -> - range = @marker.getRange() - - if range.end.row > range.start.row and nextFold = @displayBuffer.largestFoldStartingAtBufferRow(range.end.row) - nextRange = nextFold.getBufferRange() - range = new Range(range.start, nextRange.end) - - if includeNewline - range = range.copy() - range.end.row++ - range.end.column = 0 - range - - getBufferRowRange: -> - {start, end} = @getBufferRange() - [start.row, end.row] - - # Returns the fold's start row as a {Number}. - getStartRow: -> - @getBufferRange().start.row - - # Returns the fold's end row as a {Number}. - getEndRow: -> - @getBufferRange().end.row - - # Returns a {String} representation of the fold. - inspect: -> - "Fold(#{@getStartRow()}, #{@getEndRow()})" - - # Retrieves the number of buffer rows spanned by the fold. - # - # Returns a {Number}. - getBufferRowCount: -> - @getEndRow() - @getStartRow() + 1 - - # Identifies if a fold is nested within a fold. - # - # fold - A {Fold} to check - # - # Returns a {Boolean}. - isContainedByFold: (fold) -> - @isContainedByRange(fold.getBufferRange()) - - updateDisplayBuffer: -> - unless @isInsideLargerFold() - @displayBuffer.updateScreenLines(@getStartRow(), @getEndRow() + 1, 0, updateMarkers: true) - - destroyed: -> - delete @displayBuffer.foldsByMarkerId[@marker.id] - @updateDisplayBuffer() diff --git a/src/get-app-name.js b/src/get-app-name.js new file mode 100644 index 00000000000..5ad814cefb7 --- /dev/null +++ b/src/get-app-name.js @@ -0,0 +1,19 @@ +const { app } = require('electron'); +const getReleaseChannel = require('./get-release-channel'); + +module.exports = function getAppName() { + if (process.type === 'renderer') { + return atom.getAppName(); + } + + const releaseChannel = getReleaseChannel(app.getVersion()); + const appNameParts = [app.getName()]; + + if (releaseChannel !== 'stable') { + appNameParts.push( + releaseChannel.charAt(0).toUpperCase() + releaseChannel.slice(1) + ); + } + + return appNameParts.join(' '); +}; diff --git a/src/get-release-channel.js b/src/get-release-channel.js new file mode 100644 index 00000000000..e14ec68e2bd --- /dev/null +++ b/src/get-release-channel.js @@ -0,0 +1,12 @@ +module.exports = function(version) { + // This matches stable, dev (with or without commit hash) and any other + // release channel following the pattern '1.00.0-channel0' + const match = version.match(/\d+\.\d+\.\d+(-([a-z]+)(\d+|-\w{4,})?)?$/); + if (!match) { + return 'unrecognized'; + } else if (match[2]) { + return match[2]; + } + + return 'stable'; +}; diff --git a/src/get-window-load-settings.js b/src/get-window-load-settings.js new file mode 100644 index 00000000000..9a0a4b64388 --- /dev/null +++ b/src/get-window-load-settings.js @@ -0,0 +1,10 @@ +const { remote } = require('electron'); + +let windowLoadSettings = null; + +module.exports = () => { + if (!windowLoadSettings) { + windowLoadSettings = JSON.parse(remote.getCurrentWindow().loadSettingsJSON); + } + return windowLoadSettings; +}; diff --git a/src/git-repository-provider.coffee b/src/git-repository-provider.coffee deleted file mode 100644 index 850d30f22ad..00000000000 --- a/src/git-repository-provider.coffee +++ /dev/null @@ -1,70 +0,0 @@ -fs = require 'fs' -GitRepository = require './git-repository' - -# Checks whether a valid `.git` directory is contained within the given -# directory or one of its ancestors. If so, a Directory that corresponds to the -# `.git` folder will be returned. Otherwise, returns `null`. -# -# * `directory` {Directory} to explore whether it is part of a Git repository. -findGitDirectorySync = (directory) -> - # TODO: Fix node-pathwatcher/src/directory.coffee so the following methods - # can return cached values rather than always returning new objects: - # getParent(), getFile(), getSubdirectory(). - gitDir = directory.getSubdirectory('.git') - if gitDir.existsSync?() and isValidGitDirectorySync gitDir - gitDir - else if directory.isRoot() - return null - else - findGitDirectorySync directory.getParent() - -# Returns a boolean indicating whether the specified directory represents a Git -# repository. -# -# * `directory` {Directory} whose base name is `.git`. -isValidGitDirectorySync = (directory) -> - # To decide whether a directory has a valid .git folder, we use - # the heuristic adopted by the valid_repository_path() function defined in - # node_modules/git-utils/deps/libgit2/src/repository.c. - return directory.getSubdirectory('objects').existsSync() and - directory.getFile('HEAD').existsSync() and - directory.getSubdirectory('refs').existsSync() - -# Provider that conforms to the atom.repository-provider@0.1.0 service. -module.exports = -class GitRepositoryProvider - - constructor: (@project) -> - # Keys are real paths that end in `.git`. - # Values are the corresponding GitRepository objects. - @pathToRepository = {} - - # Returns a {Promise} that resolves with either: - # * {GitRepository} if the given directory has a Git repository. - # * `null` if the given directory does not have a Git repository. - repositoryForDirectory: (directory) -> - # TODO: Currently, this method is designed to be async, but it relies on a - # synchronous API. It should be rewritten to be truly async. - Promise.resolve(@repositoryForDirectorySync(directory)) - - # Returns either: - # * {GitRepository} if the given directory has a Git repository. - # * `null` if the given directory does not have a Git repository. - repositoryForDirectorySync: (directory) -> - # Only one GitRepository should be created for each .git folder. Therefore, - # we must check directory and its parent directories to find the nearest - # .git folder. - gitDir = findGitDirectorySync(directory) - unless gitDir - return null - - gitDirPath = gitDir.getPath() - repo = @pathToRepository[gitDirPath] - unless repo - repo = GitRepository.open(gitDirPath, project: @project) - return null unless repo - repo.onDidDestroy(=> delete @pathToRepository[gitDirPath]) - @pathToRepository[gitDirPath] = repo - repo.refreshIndex() - repo.refreshStatus() - repo diff --git a/src/git-repository-provider.js b/src/git-repository-provider.js new file mode 100644 index 00000000000..37d81139939 --- /dev/null +++ b/src/git-repository-provider.js @@ -0,0 +1,207 @@ +const fs = require('fs'); +const { Directory } = require('pathwatcher'); +const GitRepository = require('./git-repository'); + +const GIT_FILE_REGEX = RegExp('^gitdir: (.+)'); + +// Returns the .gitdir path in the agnostic Git symlink .git file given, or +// null if the path is not a valid gitfile. +// +// * `gitFile` {String} path of gitfile to parse +function pathFromGitFileSync(gitFile) { + try { + const gitFileBuff = fs.readFileSync(gitFile, 'utf8'); + return gitFileBuff != null ? gitFileBuff.match(GIT_FILE_REGEX)[1] : null; + } catch (error) {} +} + +// Returns a {Promise} that resolves to the .gitdir path in the agnostic +// Git symlink .git file given, or null if the path is not a valid gitfile. +// +// * `gitFile` {String} path of gitfile to parse +function pathFromGitFile(gitFile) { + return new Promise(resolve => { + fs.readFile(gitFile, 'utf8', (err, gitFileBuff) => { + if (err == null && gitFileBuff != null) { + const result = gitFileBuff.toString().match(GIT_FILE_REGEX); + resolve(result != null ? result[1] : null); + } else { + resolve(null); + } + }); + }); +} + +// Checks whether a valid `.git` directory is contained within the given +// directory or one of its ancestors. If so, a Directory that corresponds to the +// `.git` folder will be returned. Otherwise, returns `null`. +// +// * `directory` {Directory} to explore whether it is part of a Git repository. +function findGitDirectorySync(directory) { + // TODO: Fix node-pathwatcher/src/directory.coffee so the following methods + // can return cached values rather than always returning new objects: + // getParent(), getFile(), getSubdirectory(). + let gitDir = directory.getSubdirectory('.git'); + if (typeof gitDir.getPath === 'function') { + const gitDirPath = pathFromGitFileSync(gitDir.getPath()); + if (gitDirPath) { + gitDir = new Directory(directory.resolve(gitDirPath)); + } + } + if ( + typeof gitDir.existsSync === 'function' && + gitDir.existsSync() && + isValidGitDirectorySync(gitDir) + ) { + return gitDir; + } else if (directory.isRoot()) { + return null; + } else { + return findGitDirectorySync(directory.getParent()); + } +} + +// Checks whether a valid `.git` directory is contained within the given +// directory or one of its ancestors. If so, a Directory that corresponds to the +// `.git` folder will be returned. Otherwise, returns `null`. +// +// Returns a {Promise} that resolves to +// * `directory` {Directory} to explore whether it is part of a Git repository. +async function findGitDirectory(directory) { + // TODO: Fix node-pathwatcher/src/directory.coffee so the following methods + // can return cached values rather than always returning new objects: + // getParent(), getFile(), getSubdirectory(). + let gitDir = directory.getSubdirectory('.git'); + if (typeof gitDir.getPath === 'function') { + const gitDirPath = await pathFromGitFile(gitDir.getPath()); + if (gitDirPath) { + gitDir = new Directory(directory.resolve(gitDirPath)); + } + } + if ( + typeof gitDir.exists === 'function' && + (await gitDir.exists()) && + (await isValidGitDirectory(gitDir)) + ) { + return gitDir; + } else if (directory.isRoot()) { + return null; + } else { + return findGitDirectory(directory.getParent()); + } +} + +// Returns a boolean indicating whether the specified directory represents a Git +// repository. +// +// * `directory` {Directory} whose base name is `.git`. +function isValidGitDirectorySync(directory) { + // To decide whether a directory has a valid .git folder, we use + // the heuristic adopted by the valid_repository_path() function defined in + // node_modules/git-utils/deps/libgit2/src/repository.c. + const commonDirFile = directory.getSubdirectory('commondir'); + let commonDir; + if (commonDirFile.existsSync()) { + const commonDirPathBuff = fs.readFileSync(commonDirFile.getPath()); + const commonDirPathString = commonDirPathBuff.toString().trim(); + commonDir = new Directory(directory.resolve(commonDirPathString)); + if (!commonDir.existsSync()) { + return false; + } + } else { + commonDir = directory; + } + return ( + directory.getFile('HEAD').existsSync() && + commonDir.getSubdirectory('objects').existsSync() && + commonDir.getSubdirectory('refs').existsSync() + ); +} + +// Returns a {Promise} that resolves to a {Boolean} indicating whether the +// specified directory represents a Git repository. +// +// * `directory` {Directory} whose base name is `.git`. +async function isValidGitDirectory(directory) { + // To decide whether a directory has a valid .git folder, we use + // the heuristic adopted by the valid_repository_path() function defined in + // node_modules/git-utils/deps/libgit2/src/repository.c. + const commonDirFile = directory.getSubdirectory('commondir'); + let commonDir; + if (await commonDirFile.exists()) { + const commonDirPathBuff = await fs.readFile(commonDirFile.getPath()); + const commonDirPathString = commonDirPathBuff.toString().trim(); + commonDir = new Directory(directory.resolve(commonDirPathString)); + if (!(await commonDir.exists())) { + return false; + } + } else { + commonDir = directory; + } + return ( + (await directory.getFile('HEAD').exists()) && + (await commonDir.getSubdirectory('objects').exists()) && + commonDir.getSubdirectory('refs').exists() + ); +} + +// Provider that conforms to the atom.repository-provider@0.1.0 service. +class GitRepositoryProvider { + constructor(project, config) { + // Keys are real paths that end in `.git`. + // Values are the corresponding GitRepository objects. + this.project = project; + this.config = config; + this.pathToRepository = {}; + } + + // Returns a {Promise} that resolves with either: + // * {GitRepository} if the given directory has a Git repository. + // * `null` if the given directory does not have a Git repository. + async repositoryForDirectory(directory) { + // Only one GitRepository should be created for each .git folder. Therefore, + // we must check directory and its parent directories to find the nearest + // .git folder. + const gitDir = await findGitDirectory(directory); + return this.repositoryForGitDirectory(gitDir); + } + + // Returns either: + // * {GitRepository} if the given directory has a Git repository. + // * `null` if the given directory does not have a Git repository. + repositoryForDirectorySync(directory) { + // Only one GitRepository should be created for each .git folder. Therefore, + // we must check directory and its parent directories to find the nearest + // .git folder. + const gitDir = findGitDirectorySync(directory); + return this.repositoryForGitDirectory(gitDir); + } + + // Returns either: + // * {GitRepository} if the given Git directory has a Git repository. + // * `null` if the given directory does not have a Git repository. + repositoryForGitDirectory(gitDir) { + if (!gitDir) { + return null; + } + + const gitDirPath = gitDir.getPath(); + let repo = this.pathToRepository[gitDirPath]; + if (!repo) { + repo = GitRepository.open(gitDirPath, { + project: this.project, + config: this.config + }); + if (!repo) { + return null; + } + repo.onDidDestroy(() => delete this.pathToRepository[gitDirPath]); + this.pathToRepository[gitDirPath] = repo; + repo.refreshIndex(); + repo.refreshStatus(); + } + return repo; + } +} + +module.exports = GitRepositoryProvider; diff --git a/src/git-repository.coffee b/src/git-repository.coffee deleted file mode 100644 index 145b2bf9653..00000000000 --- a/src/git-repository.coffee +++ /dev/null @@ -1,496 +0,0 @@ -{basename, join} = require 'path' - -_ = require 'underscore-plus' -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -fs = require 'fs-plus' -GitUtils = require 'git-utils' -{includeDeprecatedAPIs, deprecate} = require 'grim' - -Task = require './task' - -# Extended: Represents the underlying git operations performed by Atom. -# -# This class shouldn't be instantiated directly but instead by accessing the -# `atom.project` global and calling `getRepo()`. Note that this will only be -# available when the project is backed by a Git repository. -# -# This class handles submodules automatically by taking a `path` argument to many -# of the methods. This `path` argument will determine which underlying -# repository is used. -# -# For a repository with submodules this would have the following outcome: -# -# ```coffee -# repo = atom.project.getRepo() -# repo.getShortHead() # 'master' -# repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234' -# ``` -# -# ## Examples -# -# ### Logging the URL of the origin remote -# -# ```coffee -# git = atom.project.getRepo() -# console.log git.getOriginURL() -# ``` -# -# ### Requiring in packages -# -# ```coffee -# {GitRepository} = require 'atom' -# ``` -module.exports = -class GitRepository - @exists: (path) -> - if git = @open(path) - git.destroy() - true - else - false - - ### - Section: Construction and Destruction - ### - - # Public: Creates a new GitRepository instance. - # - # * `path` The {String} path to the Git repository to open. - # * `options` An optional {Object} with the following keys: - # * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and - # statuses when the window is focused. - # - # Returns a {GitRepository} instance or `null` if the repository could not be opened. - @open: (path, options) -> - return null unless path - try - new GitRepository(path, options) - catch - null - - constructor: (path, options={}) -> - @emitter = new Emitter - @subscriptions = new CompositeDisposable - - @repo = GitUtils.open(path) - unless @repo? - throw new Error("No Git repository found searching path: #{path}") - - @statuses = {} - @upstream = {ahead: 0, behind: 0} - for submodulePath, submoduleRepo of @repo.submodules - submoduleRepo.upstream = {ahead: 0, behind: 0} - - {@project, refreshOnWindowFocus} = options - - refreshOnWindowFocus ?= true - if refreshOnWindowFocus - onWindowFocus = => - @refreshIndex() - @refreshStatus() - - window.addEventListener 'focus', onWindowFocus - @subscriptions.add new Disposable(-> window.removeEventListener 'focus', onWindowFocus) - - if @project? - @project.getBuffers().forEach (buffer) => @subscribeToBuffer(buffer) - @subscriptions.add @project.onDidAddBuffer (buffer) => @subscribeToBuffer(buffer) - - # Public: Destroy this {GitRepository} object. - # - # This destroys any tasks and subscriptions and releases the underlying - # libgit2 repository handle. This method is idempotent. - destroy: -> - if @emitter? - @emitter.emit 'did-destroy' - @emitter.dispose() - @emitter = null - - if @statusTask? - @statusTask.terminate() - @statusTask = null - - if @repo? - @repo.release() - @repo = null - - if @subscriptions? - @subscriptions.dispose() - @subscriptions = null - - # Public: Invoke the given callback when this GitRepository's destroy() method - # is invoked. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when a specific file's status has - # changed. When a file is updated, reloaded, etc, and the status changes, this - # will be fired. - # - # * `callback` {Function} - # * `event` {Object} - # * `path` {String} the old parameters the decoration used to have - # * `pathStatus` {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeStatus: (callback) -> - @emitter.on 'did-change-status', callback - - # Public: Invoke the given callback when a multiple files' statuses have - # changed. For example, on window focus, the status of all the paths in the - # repo is checked. If any of them have changed, this will be fired. Call - # {::getPathStatus(path)} to get the status for your path of choice. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeStatuses: (callback) -> - @emitter.on 'did-change-statuses', callback - - ### - Section: Repository Details - ### - - # Public: A {String} indicating the type of version control system used by - # this repository. - # - # Returns `"git"`. - getType: -> 'git' - - # Public: Returns the {String} path of the repository. - getPath: -> - @path ?= fs.absolute(@getRepo().getPath()) - - # Public: Returns the {String} working directory path of the repository. - getWorkingDirectory: -> @getRepo().getWorkingDirectory() - - # Public: Returns true if at the root, false if in a subfolder of the - # repository. - isProjectAtRoot: -> - @projectAtRoot ?= @project?.relativize(@getWorkingDirectory()) is '' - - # Public: Makes a path relative to the repository's working directory. - relativize: (path) -> @getRepo().relativize(path) - - # Public: Returns true if the given branch exists. - hasBranch: (branch) -> @getReferenceTarget("refs/heads/#{branch}")? - - # Public: Retrieves a shortened version of the HEAD reference value. - # - # This removes the leading segments of `refs/heads`, `refs/tags`, or - # `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7 - # characters. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository contains submodules. - # - # Returns a {String}. - getShortHead: (path) -> @getRepo(path).getShortHead() - - # Public: Is the given path a submodule in the repository? - # - # * `path` The {String} path to check. - # - # Returns a {Boolean}. - isSubmodule: (path) -> - return false unless path - - repo = @getRepo(path) - if repo.isSubmodule(repo.relativize(path)) - true - else - # Check if the path is a working directory in a repo that isn't the root. - repo isnt @getRepo() and repo.relativize(join(path, 'dir')) is 'dir' - - # Public: Returns the number of commits behind the current branch is from the - # its upstream remote branch. - # - # * `reference` The {String} branch reference name. - # * `path` The {String} path in the repository to get this information for, - # only needed if the repository contains submodules. - getAheadBehindCount: (reference, path) -> - @getRepo(path).getAheadBehindCount(reference) - - # Public: Get the cached ahead/behind commit counts for the current branch's - # upstream branch. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - # - # Returns an {Object} with the following keys: - # * `ahead` The {Number} of commits ahead. - # * `behind` The {Number} of commits behind. - getCachedUpstreamAheadBehindCount: (path) -> - @getRepo(path).upstream ? @upstream - - # Public: Returns the git configuration value specified by the key. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - getConfigValue: (key, path) -> @getRepo(path).getConfigValue(key) - - # Public: Returns the origin url of the repository. - # - # * `path` (optional) {String} path in the repository to get this information - # for, only needed if the repository has submodules. - getOriginURL: (path) -> @getConfigValue('remote.origin.url', path) - - # Public: Returns the upstream branch for the current HEAD, or null if there - # is no upstream branch for the current HEAD. - # - # * `path` An optional {String} path in the repo to get this information for, - # only needed if the repository contains submodules. - # - # Returns a {String} branch name such as `refs/remotes/origin/master`. - getUpstreamBranch: (path) -> @getRepo(path).getUpstreamBranch() - - # Public: Gets all the local and remote references. - # - # * `path` An optional {String} path in the repository to get this information - # for, only needed if the repository has submodules. - # - # Returns an {Object} with the following keys: - # * `heads` An {Array} of head reference names. - # * `remotes` An {Array} of remote reference names. - # * `tags` An {Array} of tag reference names. - getReferences: (path) -> @getRepo(path).getReferences() - - # Public: Returns the current {String} SHA for the given reference. - # - # * `reference` The {String} reference to get the target of. - # * `path` An optional {String} path in the repo to get the reference target - # for. Only needed if the repository contains submodules. - getReferenceTarget: (reference, path) -> - @getRepo(path).getReferenceTarget(reference) - - ### - Section: Reading Status - ### - - # Public: Returns true if the given path is modified. - isPathModified: (path) -> @isStatusModified(@getPathStatus(path)) - - # Public: Returns true if the given path is new. - isPathNew: (path) -> @isStatusNew(@getPathStatus(path)) - - # Public: Is the given path ignored? - # - # Returns a {Boolean}. - isPathIgnored: (path) -> @getRepo().isIgnored(@relativize(path)) - - # Public: Get the status of a directory in the repository's working directory. - # - # * `path` The {String} path to check. - # - # Returns a {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - getDirectoryStatus: (directoryPath) -> - directoryPath = "#{@relativize(directoryPath)}/" - directoryStatus = 0 - for path, status of @statuses - directoryStatus |= status if path.indexOf(directoryPath) is 0 - directoryStatus - - # Public: Get the status of a single path in the repository. - # - # `path` A {String} repository-relative path. - # - # Returns a {Number} representing the status. This value can be passed to - # {::isStatusModified} or {::isStatusNew} to get more information. - getPathStatus: (path) -> - repo = @getRepo(path) - relativePath = @relativize(path) - currentPathStatus = @statuses[relativePath] ? 0 - pathStatus = repo.getStatus(repo.relativize(path)) ? 0 - pathStatus = 0 if repo.isStatusIgnored(pathStatus) - if pathStatus > 0 - @statuses[relativePath] = pathStatus - else - delete @statuses[relativePath] - if currentPathStatus isnt pathStatus - @emit 'status-changed', path, pathStatus if includeDeprecatedAPIs - @emitter.emit 'did-change-status', {path, pathStatus} - - pathStatus - - # Public: Get the cached status for the given path. - # - # * `path` A {String} path in the repository, relative or absolute. - # - # Returns a status {Number} or null if the path is not in the cache. - getCachedPathStatus: (path) -> - @statuses[@relativize(path)] - - # Public: Returns true if the given status indicates modification. - isStatusModified: (status) -> @getRepo().isStatusModified(status) - - # Public: Returns true if the given status indicates a new path. - isStatusNew: (status) -> @getRepo().isStatusNew(status) - - ### - Section: Retrieving Diffs - ### - - # Public: Retrieves the number of lines added and removed to a path. - # - # This compares the working directory contents of the path to the `HEAD` - # version. - # - # * `path` The {String} path to check. - # - # Returns an {Object} with the following keys: - # * `added` The {Number} of added lines. - # * `deleted` The {Number} of deleted lines. - getDiffStats: (path) -> - repo = @getRepo(path) - repo.getDiffStats(repo.relativize(path)) - - # Public: Retrieves the line diffs comparing the `HEAD` version of the given - # path and the given text. - # - # * `path` The {String} path relative to the repository. - # * `text` The {String} to compare against the `HEAD` contents - # - # Returns an {Array} of hunk {Object}s with the following keys: - # * `oldStart` The line {Number} of the old hunk. - # * `newStart` The line {Number} of the new hunk. - # * `oldLines` The {Number} of lines in the old hunk. - # * `newLines` The {Number} of lines in the new hunk - getLineDiffs: (path, text) -> - # Ignore eol of line differences on windows so that files checked in as - # LF don't report every line modified when the text contains CRLF endings. - options = ignoreEolWhitespace: process.platform is 'win32' - repo = @getRepo(path) - repo.getLineDiffs(repo.relativize(path), text, options) - - ### - Section: Checking Out - ### - - # Public: Restore the contents of a path in the working directory and index - # to the version at `HEAD`. - # - # This is essentially the same as running: - # - # ```sh - # git reset HEAD -- - # git checkout HEAD -- - # ``` - # - # * `path` The {String} path to checkout. - # - # Returns a {Boolean} that's true if the method was successful. - checkoutHead: (path) -> - repo = @getRepo(path) - headCheckedOut = repo.checkoutHead(repo.relativize(path)) - @getPathStatus(path) if headCheckedOut - headCheckedOut - - # Public: Checks out a branch in your repository. - # - # * `reference` The {String} reference to checkout. - # * `create` A {Boolean} value which, if true creates the new reference if - # it doesn't exist. - # - # Returns a Boolean that's true if the method was successful. - checkoutReference: (reference, create) -> - @getRepo().checkoutReference(reference, create) - - ### - Section: Private - ### - - # Subscribes to buffer events. - subscribeToBuffer: (buffer) -> - getBufferPathStatus = => - if path = buffer.getPath() - @getPathStatus(path) - - bufferSubscriptions = new CompositeDisposable - bufferSubscriptions.add buffer.onDidSave(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidReload(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidChangePath(getBufferPathStatus) - bufferSubscriptions.add buffer.onDidDestroy => - bufferSubscriptions.dispose() - @subscriptions.remove(bufferSubscriptions) - @subscriptions.add(bufferSubscriptions) - return - - # Subscribes to editor view event. - checkoutHeadForEditor: (editor) -> - filePath = editor.getPath() - return unless filePath - - fileName = basename(filePath) - - checkoutHead = => - editor.buffer.reload() if editor.buffer.isModified() - @checkoutHead(filePath) - - if atom.config.get('editor.confirmCheckoutHeadRevision') - atom.confirm - message: 'Confirm Checkout HEAD Revision' - detailedMessage: "Are you sure you want to discard all changes to \"#{fileName}\" since the last Git commit?" - buttons: - OK: checkoutHead - Cancel: null - else - checkoutHead() - - # Returns the corresponding {Repository} - getRepo: (path) -> - if @repo? - @repo.submoduleForPath(path) ? @repo - else - throw new Error("Repository has been destroyed") - - # Reread the index to update any values that have changed since the - # last time the index was read. - refreshIndex: -> @getRepo().refreshIndex() - - # Refreshes the current git status in an outside process and asynchronously - # updates the relevant properties. - refreshStatus: -> - @handlerPath ?= require.resolve('./repository-status-handler') - - @statusTask?.terminate() - @statusTask = Task.once @handlerPath, @getPath(), ({statuses, upstream, branch, submodules}) => - statusesUnchanged = _.isEqual(statuses, @statuses) and - _.isEqual(upstream, @upstream) and - _.isEqual(branch, @branch) and - _.isEqual(submodules, @submodules) - - @statuses = statuses - @upstream = upstream - @branch = branch - @submodules = submodules - - for submodulePath, submoduleRepo of @getRepo().submodules - submoduleRepo.upstream = submodules[submodulePath]?.upstream ? {ahead: 0, behind: 0} - - unless statusesUnchanged - @emit 'statuses-changed' if includeDeprecatedAPIs - @emitter.emit 'did-change-statuses' - -if includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(GitRepository) - - GitRepository::on = (eventName) -> - switch eventName - when 'status-changed' - deprecate 'Use GitRepository::onDidChangeStatus instead' - when 'statuses-changed' - deprecate 'Use GitRepository::onDidChangeStatuses instead' - else - deprecate 'GitRepository::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - - GitRepository::getOriginUrl = (path) -> - deprecate 'Use ::getOriginURL instead.' - @getOriginURL(path) diff --git a/src/git-repository.js b/src/git-repository.js new file mode 100644 index 00000000000..878fb67e6a0 --- /dev/null +++ b/src/git-repository.js @@ -0,0 +1,621 @@ +const path = require('path'); +const fs = require('fs-plus'); +const _ = require('underscore-plus'); +const { Emitter, Disposable, CompositeDisposable } = require('event-kit'); +const GitUtils = require('git-utils'); + +let nextId = 0; + +// Extended: Represents the underlying git operations performed by Atom. +// +// This class shouldn't be instantiated directly but instead by accessing the +// `atom.project` global and calling `getRepositories()`. Note that this will +// only be available when the project is backed by a Git repository. +// +// This class handles submodules automatically by taking a `path` argument to many +// of the methods. This `path` argument will determine which underlying +// repository is used. +// +// For a repository with submodules this would have the following outcome: +// +// ```coffee +// repo = atom.project.getRepositories()[0] +// repo.getShortHead() # 'master' +// repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234' +// ``` +// +// ## Examples +// +// ### Logging the URL of the origin remote +// +// ```coffee +// git = atom.project.getRepositories()[0] +// console.log git.getOriginURL() +// ``` +// +// ### Requiring in packages +// +// ```coffee +// {GitRepository} = require 'atom' +// ``` +module.exports = class GitRepository { + static exists(path) { + const git = this.open(path); + if (git) { + git.destroy(); + return true; + } else { + return false; + } + } + + /* + Section: Construction and Destruction + */ + + // Public: Creates a new GitRepository instance. + // + // * `path` The {String} path to the Git repository to open. + // * `options` An optional {Object} with the following keys: + // * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and + // statuses when the window is focused. + // + // Returns a {GitRepository} instance or `null` if the repository could not be opened. + static open(path, options) { + if (!path) { + return null; + } + try { + return new GitRepository(path, options); + } catch (error) { + return null; + } + } + + constructor(path, options = {}) { + this.id = nextId++; + this.emitter = new Emitter(); + this.subscriptions = new CompositeDisposable(); + this.repo = GitUtils.open(path); + if (this.repo == null) { + throw new Error(`No Git repository found searching path: ${path}`); + } + + this.statusRefreshCount = 0; + this.statuses = {}; + this.upstream = { ahead: 0, behind: 0 }; + for (let submodulePath in this.repo.submodules) { + const submoduleRepo = this.repo.submodules[submodulePath]; + submoduleRepo.upstream = { ahead: 0, behind: 0 }; + } + + this.project = options.project; + this.config = options.config; + + if (options.refreshOnWindowFocus || options.refreshOnWindowFocus == null) { + const onWindowFocus = () => { + this.refreshIndex(); + this.refreshStatus(); + }; + + window.addEventListener('focus', onWindowFocus); + this.subscriptions.add( + new Disposable(() => window.removeEventListener('focus', onWindowFocus)) + ); + } + + if (this.project != null) { + this.project + .getBuffers() + .forEach(buffer => this.subscribeToBuffer(buffer)); + this.subscriptions.add( + this.project.onDidAddBuffer(buffer => this.subscribeToBuffer(buffer)) + ); + } + } + + // Public: Destroy this {GitRepository} object. + // + // This destroys any tasks and subscriptions and releases the underlying + // libgit2 repository handle. This method is idempotent. + destroy() { + this.repo = null; + + if (this.emitter) { + this.emitter.emit('did-destroy'); + this.emitter.dispose(); + this.emitter = null; + } + + if (this.subscriptions) { + this.subscriptions.dispose(); + this.subscriptions = null; + } + } + + // Public: Returns a {Boolean} indicating if this repository has been destroyed. + isDestroyed() { + return this.repo == null; + } + + // Public: Invoke the given callback when this GitRepository's destroy() method + // is invoked. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when a specific file's status has + // changed. When a file is updated, reloaded, etc, and the status changes, this + // will be fired. + // + // * `callback` {Function} + // * `event` {Object} + // * `path` {String} the old parameters the decoration used to have + // * `pathStatus` {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeStatus(callback) { + return this.emitter.on('did-change-status', callback); + } + + // Public: Invoke the given callback when a multiple files' statuses have + // changed. For example, on window focus, the status of all the paths in the + // repo is checked. If any of them have changed, this will be fired. Call + // {::getPathStatus} to get the status for your path of choice. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeStatuses(callback) { + return this.emitter.on('did-change-statuses', callback); + } + + /* + Section: Repository Details + */ + + // Public: A {String} indicating the type of version control system used by + // this repository. + // + // Returns `"git"`. + getType() { + return 'git'; + } + + // Public: Returns the {String} path of the repository. + getPath() { + if (this.path == null) { + this.path = fs.absolute(this.getRepo().getPath()); + } + return this.path; + } + + // Public: Returns the {String} working directory path of the repository. + getWorkingDirectory() { + return this.getRepo().getWorkingDirectory(); + } + + // Public: Returns true if at the root, false if in a subfolder of the + // repository. + isProjectAtRoot() { + if (this.projectAtRoot == null) { + this.projectAtRoot = + this.project && + this.project.relativize(this.getWorkingDirectory()) === ''; + } + return this.projectAtRoot; + } + + // Public: Makes a path relative to the repository's working directory. + relativize(path) { + return this.getRepo().relativize(path); + } + + // Public: Returns true if the given branch exists. + hasBranch(branch) { + return this.getReferenceTarget(`refs/heads/${branch}`) != null; + } + + // Public: Retrieves a shortened version of the HEAD reference value. + // + // This removes the leading segments of `refs/heads`, `refs/tags`, or + // `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7 + // characters. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository contains submodules. + // + // Returns a {String}. + getShortHead(path) { + return this.getRepo(path).getShortHead(); + } + + // Public: Is the given path a submodule in the repository? + // + // * `path` The {String} path to check. + // + // Returns a {Boolean}. + isSubmodule(filePath) { + if (!filePath) return false; + + const repo = this.getRepo(filePath); + if (repo.isSubmodule(repo.relativize(filePath))) { + return true; + } else { + // Check if the filePath is a working directory in a repo that isn't the root. + return ( + repo !== this.getRepo() && + repo.relativize(path.join(filePath, 'dir')) === 'dir' + ); + } + } + + // Public: Returns the number of commits behind the current branch is from the + // its upstream remote branch. + // + // * `reference` The {String} branch reference name. + // * `path` The {String} path in the repository to get this information for, + // only needed if the repository contains submodules. + getAheadBehindCount(reference, path) { + return this.getRepo(path).getAheadBehindCount(reference); + } + + // Public: Get the cached ahead/behind commit counts for the current branch's + // upstream branch. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + // + // Returns an {Object} with the following keys: + // * `ahead` The {Number} of commits ahead. + // * `behind` The {Number} of commits behind. + getCachedUpstreamAheadBehindCount(path) { + return this.getRepo(path).upstream || this.upstream; + } + + // Public: Returns the git configuration value specified by the key. + // + // * `key` The {String} key for the configuration to lookup. + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + getConfigValue(key, path) { + return this.getRepo(path).getConfigValue(key); + } + + // Public: Returns the origin url of the repository. + // + // * `path` (optional) {String} path in the repository to get this information + // for, only needed if the repository has submodules. + getOriginURL(path) { + return this.getConfigValue('remote.origin.url', path); + } + + // Public: Returns the upstream branch for the current HEAD, or null if there + // is no upstream branch for the current HEAD. + // + // * `path` An optional {String} path in the repo to get this information for, + // only needed if the repository contains submodules. + // + // Returns a {String} branch name such as `refs/remotes/origin/master`. + getUpstreamBranch(path) { + return this.getRepo(path).getUpstreamBranch(); + } + + // Public: Gets all the local and remote references. + // + // * `path` An optional {String} path in the repository to get this information + // for, only needed if the repository has submodules. + // + // Returns an {Object} with the following keys: + // * `heads` An {Array} of head reference names. + // * `remotes` An {Array} of remote reference names. + // * `tags` An {Array} of tag reference names. + getReferences(path) { + return this.getRepo(path).getReferences(); + } + + // Public: Returns the current {String} SHA for the given reference. + // + // * `reference` The {String} reference to get the target of. + // * `path` An optional {String} path in the repo to get the reference target + // for. Only needed if the repository contains submodules. + getReferenceTarget(reference, path) { + return this.getRepo(path).getReferenceTarget(reference); + } + + /* + Section: Reading Status + */ + + // Public: Returns true if the given path is modified. + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is modified. + isPathModified(path) { + return this.isStatusModified(this.getPathStatus(path)); + } + + // Public: Returns true if the given path is new. + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is new. + isPathNew(path) { + return this.isStatusNew(this.getPathStatus(path)); + } + + // Public: Is the given path ignored? + // + // * `path` The {String} path to check. + // + // Returns a {Boolean} that's true if the `path` is ignored. + isPathIgnored(path) { + return this.getRepo().isIgnored(this.relativize(path)); + } + + // Public: Get the status of a directory in the repository's working directory. + // + // * `path` The {String} path to check. + // + // Returns a {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + getDirectoryStatus(directoryPath) { + directoryPath = `${this.relativize(directoryPath)}/`; + let directoryStatus = 0; + for (let statusPath in this.statuses) { + const status = this.statuses[statusPath]; + if (statusPath.startsWith(directoryPath)) directoryStatus |= status; + } + return directoryStatus; + } + + // Public: Get the status of a single path in the repository. + // + // * `path` A {String} repository-relative path. + // + // Returns a {Number} representing the status. This value can be passed to + // {::isStatusModified} or {::isStatusNew} to get more information. + getPathStatus(path) { + const repo = this.getRepo(path); + const relativePath = this.relativize(path); + const currentPathStatus = this.statuses[relativePath] || 0; + let pathStatus = repo.getStatus(repo.relativize(path)) || 0; + if (repo.isStatusIgnored(pathStatus)) pathStatus = 0; + if (pathStatus > 0) { + this.statuses[relativePath] = pathStatus; + } else { + delete this.statuses[relativePath]; + } + if (currentPathStatus !== pathStatus) { + this.emitter.emit('did-change-status', { path, pathStatus }); + } + + return pathStatus; + } + + // Public: Get the cached status for the given path. + // + // * `path` A {String} path in the repository, relative or absolute. + // + // Returns a status {Number} or null if the path is not in the cache. + getCachedPathStatus(path) { + return this.statuses[this.relativize(path)]; + } + + // Public: Returns true if the given status indicates modification. + // + // * `status` A {Number} representing the status. + // + // Returns a {Boolean} that's true if the `status` indicates modification. + isStatusModified(status) { + return this.getRepo().isStatusModified(status); + } + + // Public: Returns true if the given status indicates a new path. + // + // * `status` A {Number} representing the status. + // + // Returns a {Boolean} that's true if the `status` indicates a new path. + isStatusNew(status) { + return this.getRepo().isStatusNew(status); + } + + /* + Section: Retrieving Diffs + */ + + // Public: Retrieves the number of lines added and removed to a path. + // + // This compares the working directory contents of the path to the `HEAD` + // version. + // + // * `path` The {String} path to check. + // + // Returns an {Object} with the following keys: + // * `added` The {Number} of added lines. + // * `deleted` The {Number} of deleted lines. + getDiffStats(path) { + const repo = this.getRepo(path); + return repo.getDiffStats(repo.relativize(path)); + } + + // Public: Retrieves the line diffs comparing the `HEAD` version of the given + // path and the given text. + // + // * `path` The {String} path relative to the repository. + // * `text` The {String} to compare against the `HEAD` contents + // + // Returns an {Array} of hunk {Object}s with the following keys: + // * `oldStart` The line {Number} of the old hunk. + // * `newStart` The line {Number} of the new hunk. + // * `oldLines` The {Number} of lines in the old hunk. + // * `newLines` The {Number} of lines in the new hunk + getLineDiffs(path, text) { + // Ignore eol of line differences on windows so that files checked in as + // LF don't report every line modified when the text contains CRLF endings. + const options = { ignoreEolWhitespace: process.platform === 'win32' }; + const repo = this.getRepo(path); + return repo.getLineDiffs(repo.relativize(path), text, options); + } + + /* + Section: Checking Out + */ + + // Public: Restore the contents of a path in the working directory and index + // to the version at `HEAD`. + // + // This is essentially the same as running: + // + // ```sh + // git reset HEAD -- + // git checkout HEAD -- + // ``` + // + // * `path` The {String} path to checkout. + // + // Returns a {Boolean} that's true if the method was successful. + checkoutHead(path) { + const repo = this.getRepo(path); + const headCheckedOut = repo.checkoutHead(repo.relativize(path)); + if (headCheckedOut) this.getPathStatus(path); + return headCheckedOut; + } + + // Public: Checks out a branch in your repository. + // + // * `reference` The {String} reference to checkout. + // * `create` A {Boolean} value which, if true creates the new reference if + // it doesn't exist. + // + // Returns a Boolean that's true if the method was successful. + checkoutReference(reference, create) { + return this.getRepo().checkoutReference(reference, create); + } + + /* + Section: Private + */ + + // Subscribes to buffer events. + subscribeToBuffer(buffer) { + const getBufferPathStatus = () => { + const bufferPath = buffer.getPath(); + if (bufferPath) this.getPathStatus(bufferPath); + }; + + getBufferPathStatus(); + const bufferSubscriptions = new CompositeDisposable(); + bufferSubscriptions.add(buffer.onDidSave(getBufferPathStatus)); + bufferSubscriptions.add(buffer.onDidReload(getBufferPathStatus)); + bufferSubscriptions.add(buffer.onDidChangePath(getBufferPathStatus)); + bufferSubscriptions.add( + buffer.onDidDestroy(() => { + bufferSubscriptions.dispose(); + return this.subscriptions.remove(bufferSubscriptions); + }) + ); + this.subscriptions.add(bufferSubscriptions); + } + + // Subscribes to editor view event. + checkoutHeadForEditor(editor) { + const buffer = editor.getBuffer(); + const bufferPath = buffer.getPath(); + if (bufferPath) { + this.checkoutHead(bufferPath); + return buffer.reload(); + } + } + + // Returns the corresponding {Repository} + getRepo(path) { + if (this.repo) { + return this.repo.submoduleForPath(path) || this.repo; + } else { + throw new Error('Repository has been destroyed'); + } + } + + // Reread the index to update any values that have changed since the + // last time the index was read. + refreshIndex() { + return this.getRepo().refreshIndex(); + } + + // Refreshes the current git status in an outside process and asynchronously + // updates the relevant properties. + async refreshStatus() { + const statusRefreshCount = ++this.statusRefreshCount; + const repo = this.getRepo(); + + const relativeProjectPaths = + this.project && + this.project + .getPaths() + .map(projectPath => this.relativize(projectPath)) + .filter( + projectPath => projectPath.length > 0 && !path.isAbsolute(projectPath) + ); + + const branch = await repo.getHeadAsync(); + const upstream = await repo.getAheadBehindCountAsync(); + + const statuses = {}; + const repoStatus = + relativeProjectPaths.length > 0 + ? await repo.getStatusAsync(relativeProjectPaths) + : await repo.getStatusAsync(); + for (let filePath in repoStatus) { + statuses[filePath] = repoStatus[filePath]; + } + + const submodules = {}; + for (let submodulePath in repo.submodules) { + const submoduleRepo = repo.submodules[submodulePath]; + submodules[submodulePath] = { + branch: await submoduleRepo.getHeadAsync(), + upstream: await submoduleRepo.getAheadBehindCountAsync() + }; + + const workingDirectoryPath = submoduleRepo.getWorkingDirectory(); + const submoduleStatus = await submoduleRepo.getStatusAsync(); + for (let filePath in submoduleStatus) { + const absolutePath = path.join(workingDirectoryPath, filePath); + const relativizePath = repo.relativize(absolutePath); + statuses[relativizePath] = submoduleStatus[filePath]; + } + } + + if (this.statusRefreshCount !== statusRefreshCount || this.isDestroyed()) + return; + + const statusesUnchanged = + _.isEqual(branch, this.branch) && + _.isEqual(statuses, this.statuses) && + _.isEqual(upstream, this.upstream) && + _.isEqual(submodules, this.submodules); + + this.branch = branch; + this.statuses = statuses; + this.upstream = upstream; + this.submodules = submodules; + + for (let submodulePath in repo.submodules) { + repo.submodules[submodulePath].upstream = + submodules[submodulePath].upstream; + } + + if (!statusesUnchanged) this.emitter.emit('did-change-statuses'); + } +}; diff --git a/src/grammar-registry.coffee b/src/grammar-registry.coffee deleted file mode 100644 index 7b1ef823f4a..00000000000 --- a/src/grammar-registry.coffee +++ /dev/null @@ -1,84 +0,0 @@ -{Emitter} = require 'event-kit' -{includeDeprecatedAPIs, deprecate} = require 'grim' -FirstMate = require 'first-mate' -Token = require './token' - -# Extended: Syntax class holding 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 - @deserialize: ({grammarOverridesByPath}) -> - grammarRegistry = new GrammarRegistry() - grammarRegistry.grammarOverridesByPath = grammarOverridesByPath - grammarRegistry - - atom.deserializers.add(this) - - constructor: -> - super(maxTokensPerLine: 100) - - serialize: -> - {deserializer: @constructor.name, @grammarOverridesByPath} - - createToken: (value, scopes) -> new Token({value, scopes}) - - # 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 - # each grammar. - # - # * `filePath` A {String} file path. - # * `fileContents` A {String} of text for the file path. - # - # Returns a {Grammar}, never null. - selectGrammar: (filePath, fileContents) -> - bestMatch = null - highestScore = -Infinity - for grammar in @grammars - score = grammar.getScore(filePath, fileContents) - if score > highestScore or not bestMatch? - bestMatch = grammar - highestScore = score - else if score is highestScore and bestMatch?.bundledPackage - bestMatch = grammar unless grammar.bundledPackage - bestMatch - - clearObservers: -> - @off() if includeDeprecatedAPIs - @emitter = new Emitter - -if includeDeprecatedAPIs - PropertyAccessors = require 'property-accessors' - PropertyAccessors.includeInto(GrammarRegistry) - - {Subscriber} = require 'emissary' - Subscriber.includeInto(GrammarRegistry) - - # Support old serialization - atom.deserializers.add(name: 'Syntax', deserialize: GrammarRegistry.deserialize) - - # Deprecated: Used by settings-view to display snippets for packages - GrammarRegistry::accessor 'propertyStore', -> - deprecate("Do not use this. Use a public method on Config") - atom.config.scopedSettingsStore - - GrammarRegistry::addProperties = (args...) -> - args.unshift(null) if args.length is 2 - deprecate 'Consider using atom.config.set() instead. A direct (but private) replacement is available at atom.config.addScopedSettings().' - atom.config.addScopedSettings(args...) - - GrammarRegistry::removeProperties = (name) -> - deprecate 'atom.config.addScopedSettings() now returns a disposable you can call .dispose() on' - atom.config.scopedSettingsStore.removeProperties(name) - - GrammarRegistry::getProperty = (scope, keyPath) -> - deprecate 'A direct (but private) replacement is available at atom.config.getRawScopedValue().' - atom.config.getRawScopedValue(scope, keyPath) - - GrammarRegistry::propertiesForScope = (scope, keyPath) -> - deprecate 'Use atom.config.getAll instead.' - atom.config.settingsForScopeDescriptor(scope, keyPath) diff --git a/src/grammar-registry.js b/src/grammar-registry.js new file mode 100644 index 00000000000..aa530b6a9c4 --- /dev/null +++ b/src/grammar-registry.js @@ -0,0 +1,669 @@ +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 ScopeDescriptor = require('./scope-descriptor'); +const Token = require('./token'); +const fs = require('fs-plus'); +const { Point, Range } = require('text-buffer'); + +const PATH_SPLIT_REGEX = new RegExp('[/.]'); + +// Extended: This class holds the grammars used for tokenizing. +// +// An instance of this class is always available as the `atom.grammars` global. +module.exports = class GrammarRegistry { + constructor({ config } = {}) { + 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); + + this.subscriptions.add( + this.config.onDidChange('core.useTreeSitterParsers', () => { + this.grammarScoresByBuffer.forEach((score, buffer) => { + if (!this.languageOverridesByBufferId.has(buffer.id)) { + this.autoAssignLanguageMode(buffer); + } + }); + }) + ); + } + + 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(); + + 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 !== buffer.getLanguageMode().grammar) { + buffer.setLanguageMode( + this.languageModeForGrammarAndBuffer(grammar, buffer) + ); + } + + return true; + } + + // 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. + // * `grammar` The desired {Grammar}. + // + // Returns a {Boolean} that indicates whether the assignment was successful + assignGrammar(buffer, grammar) { + if (!grammar) return false; + if (buffer.getBuffer) buffer = buffer.getBuffer(); + this.languageOverridesByBufferId.set(buffer.id, grammar.scopeName || null); + this.grammarScoresByBuffer.set(buffer, null); + if (grammar !== buffer.getLanguageMode().grammar) { + buffer.setLanguageMode( + this.languageModeForGrammarAndBuffer(grammar, buffer) + ); + } + return true; + } + + // Extended: Get the `languageId` that has been explicitly assigned to + // the given buffer, if any. + // + // Returns a {String} id of the language + getAssignedLanguageId(buffer) { + return this.languageOverridesByBufferId.get(buffer.id); + } + + // 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 !== buffer.getLanguageMode().grammar) { + buffer.setLanguageMode( + this.languageModeForGrammarAndBuffer(result.grammar, buffer) + ); + } + } + + languageModeForGrammarAndBuffer(grammar, buffer) { + if (grammar instanceof TreeSitterGrammar) { + return new TreeSitterLanguageMode({ + grammar, + buffer, + config: this.config, + grammars: this + }); + } 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 + // each grammar. + // + // * `filePath` A {String} file path. + // * `fileContents` A {String} of text for the file path. + // + // Returns a {Grammar}, never null. + selectGrammar(filePath, fileContents) { + return this.selectGrammarWithScore(filePath, fileContents).grammar; + } + + selectGrammarWithScore(filePath, fileContents) { + let bestMatch = null; + let highestScore = -Infinity; + this.forEachGrammar(grammar => { + const score = this.getGrammarScore(grammar, filePath, fileContents); + 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)) { + contents = fs.readFileSync(filePath, 'utf8'); + } + + // Initially identify matching grammars based on the filename and the first + // line of the file. + let score = this.getGrammarPathScore(grammar, filePath); + if (this.grammarMatchesPrefix(grammar, contents)) score += 0.5; + + // If multiple grammars match by one of the above criteria, break ties. + if (score > 0) { + const isTreeSitter = grammar instanceof TreeSitterGrammar; + + // Prefer either TextMate or Tree-sitter grammars based on the user's settings. + if (isTreeSitter) { + if (this.shouldUseTreeSitterParser(grammar.scopeName)) { + score += 0.1; + } else { + return -Infinity; + } + } + + // Prefer grammars with matching content regexes. Prefer a grammar with no content regex + // over one with a non-matching content regex. + if (grammar.contentRegex) { + const contentMatch = isTreeSitter + ? grammar.contentRegex.test(contents) + : grammar.contentRegex.testSync(contents); + if (contentMatch) { + score += 0.05; + } else { + score -= 0.05; + } + } + + // Prefer grammars that the user has manually installed over bundled grammars. + if (!grammar.bundledPackage) score += 0.01; + } + + return score; + } + + getGrammarPathScore(grammar, filePath) { + if (!filePath) return -1; + if (process.platform === 'win32') { + filePath = filePath.replace(/\\/g, '/'); + } + + const pathComponents = filePath.toLowerCase().split(PATH_SPLIT_REGEX); + let pathScore = 0; + + let customFileTypes; + if (this.config.get('core.customFileTypes')) { + customFileTypes = this.config.get('core.customFileTypes')[ + grammar.scopeName + ]; + } + + let { fileTypes } = grammar; + if (customFileTypes) { + fileTypes = fileTypes.concat(customFileTypes); + } + + for (let i = 0; i < fileTypes.length; i++) { + const fileType = fileTypes[i]; + const fileTypeComponents = fileType.toLowerCase().split(PATH_SPLIT_REGEX); + const pathSuffix = pathComponents.slice(-fileTypeComponents.length); + if (_.isEqual(pathSuffix, fileTypeComponents)) { + pathScore = Math.max(pathScore, fileType.length); + if (i >= grammar.fileTypes.length) { + pathScore += 0.5; + } + } + } + + return pathScore; + } + + grammarMatchesPrefix(grammar, contents) { + if (contents && grammar.firstLineRegex) { + 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 prefix = contents + .split('\n') + .slice(0, numberOfNewlinesInRegex + 1) + .join('\n'); + if (grammar.firstLineRegex.testSync) { + return grammar.firstLineRegex.testSync(prefix); + } else { + return grammar.firstLineRegex.test(prefix); + } + } else { + return false; + } + } + + forEachGrammar(callback) { + this.getGrammars({ includeTreeSitter: true }).forEach(callback); + } + + grammarForId(languageId) { + if (!languageId) return null; + if (this.shouldUseTreeSitterParser(languageId)) { + return ( + this.treeSitterGrammarsById[languageId] || + this.textmateRegistry.grammarForScopeName(languageId) + ); + } else { + return ( + this.textmateRegistry.grammarForScopeName(languageId) || + this.treeSitterGrammarsById[languageId] + ); + } + } + + // Deprecated: Get the grammar override for the given file path. + // + // * `filePath` A {String} file path. + // + // Returns a {String} such as `"source.js"`. + grammarOverrideForPath(filePath) { + Grim.deprecate('Use buffer.getLanguageMode().getLanguageId() instead'); + const buffer = atom.project.findBufferForPath(filePath); + if (buffer) return this.getAssignedLanguageId(buffer); + } + + // Deprecated: Set the grammar override for the given file path. + // + // * `filePath` A non-empty {String} file path. + // * `languageId` A {String} such as `"source.js"`. + // + // Returns undefined. + 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); + } + } + + // Remove the grammar override for the given file path. + // + // * `filePath` A {String} file path. + // + // Returns undefined. + clearGrammarOverrideForPath(filePath) { + Grim.deprecate('Use atom.grammars.autoAssignLanguageMode(buffer) instead'); + const buffer = atom.project.findBufferForPath(filePath); + if (buffer) this.languageOverridesByBufferId.delete(buffer.id); + } + + grammarAddedOrUpdated(grammar) { + if (grammar.scopeName && !grammar.id) grammar.id = grammar.scopeName; + + this.grammarScoresByBuffer.forEach((score, buffer) => { + const languageMode = buffer.getLanguageMode(); + const languageOverride = this.languageOverridesByBufferId.get(buffer.id); + + if ( + grammar === buffer.getLanguageMode().grammar || + grammar === this.grammarForId(languageOverride) + ) { + buffer.setLanguageMode( + this.languageModeForGrammarAndBuffer(grammar, buffer) + ); + return; + } 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); + return; + } + } + + languageMode.updateForInjection(grammar); + }); + } + + // 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); + } + + // Experimental: Specify a type of syntax node that may embed other languages. + // + // * `grammarId` The {String} id of the parent language + // * `injectionPoint` An {Object} with the following keys: + // * `type` The {String} type of syntax node that may embed other languages + // * `language` A {Function} that is called with syntax nodes of the specified `type` and + // returns a {String} that will be tested against other grammars' `injectionRegex` in + // order to determine what language should be embedded. + // * `content` A {Function} that is called with syntax nodes of the specified `type` and + // returns another syntax node or array of syntax nodes that contain the embedded source code. + addInjectionPoint(grammarId, injectionPoint) { + const grammar = this.treeSitterGrammarsById[grammarId]; + if (grammar) { + if (grammar.addInjectionPoint) { + grammar.addInjectionPoint(injectionPoint); + } else { + grammar.injectionPoints.push(injectionPoint); + } + } else { + this.treeSitterGrammarsById[grammarId] = { + injectionPoints: [injectionPoint] + }; + } + return new Disposable(() => { + const grammar = this.treeSitterGrammarsById[grammarId]; + grammar.removeInjectionPoint(injectionPoint); + }); + } + + get nullGrammar() { + return this.textmateRegistry.nullGrammar; + } + + get grammars() { + return this.getGrammars(); + } + + decodeTokens() { + return this.textmateRegistry.decodeTokens.apply( + this.textmateRegistry, + arguments + ); + } + + grammarForScopeName(scopeName) { + return this.grammarForId(scopeName); + } + + addGrammar(grammar) { + if (grammar instanceof TreeSitterGrammar) { + const existingParams = + this.treeSitterGrammarsById[grammar.scopeName] || {}; + if (grammar.scopeName) + this.treeSitterGrammarsById[grammar.scopeName] = grammar; + if (existingParams.injectionPoints) { + for (const injectionPoint of existingParams.injectionPoints) { + grammar.addInjectionPoint(injectionPoint); + } + } + 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.scopeName]; + } 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 occurred. + loadGrammar(grammarPath, callback) { + this.readGrammar(grammarPath, (error, grammar) => { + if (error) return callback(error); + this.addGrammar(grammar); + callback(null, 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 occurred. + // + // 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. + // + // * `options` (optional) {Object} + // * `includeTreeSitter` (optional) {Boolean} Set to include + // [Tree-sitter](https://github.blog/2018-10-31-atoms-new-parsing-system/) grammars + // + // Returns a non-empty {Array} of {Grammar} instances. + getGrammars(params) { + let tmGrammars = this.textmateRegistry.getGrammars(); + if (!(params && params.includeTreeSitter)) return tmGrammars; + + const tsGrammars = Object.values(this.treeSitterGrammarsById).filter( + g => g.scopeName + ); + return tmGrammars.concat(tsGrammars); // NullGrammar is expected to be first + } + + scopeForId(id) { + return this.textmateRegistry.scopeForId(id); + } + + treeSitterGrammarForLanguageString(languageString) { + let longestMatchLength = 0; + let grammarWithLongestMatch = null; + for (const id in this.treeSitterGrammarsById) { + const grammar = this.treeSitterGrammarsById[id]; + if (grammar.injectionRegex) { + const match = languageString.match(grammar.injectionRegex); + if (match) { + const { length } = match[0]; + if (length > longestMatchLength) { + grammarWithLongestMatch = grammar; + longestMatchLength = length; + } + } + } + } + return grammarWithLongestMatch; + } + + shouldUseTreeSitterParser(languageId) { + return this.config.get('core.useTreeSitterParsers', { + scope: new ScopeDescriptor({ scopes: [languageId] }) + }); + } +}; + +function getGrammarSelectionContent(buffer) { + return buffer.getTextInRange( + Range(Point(0, 0), buffer.positionForCharacterIndex(1024)) + ); +} diff --git a/src/gutter-component-helpers.coffee b/src/gutter-component-helpers.coffee deleted file mode 100644 index f3a94c5b49e..00000000000 --- a/src/gutter-component-helpers.coffee +++ /dev/null @@ -1,28 +0,0 @@ -# Helper methods shared among GutterComponent classes. - -module.exports = - createGutterView: (gutterModel) -> - domNode = document.createElement('div') - domNode.classList.add('gutter') - domNode.setAttribute('gutter-name', gutterModel.name) - childNode = document.createElement('div') - if gutterModel.name is 'line-number' - childNode.classList.add('line-numbers') - else - childNode.classList.add('custom-decorations') - domNode.appendChild(childNode) - domNode - - # Sets scrollHeight, scrollTop, and backgroundColor on the given domNode. - setDimensionsAndBackground: (oldState, newState, domNode) -> - if newState.scrollHeight isnt oldState.scrollHeight - domNode.style.height = newState.scrollHeight + 'px' - oldState.scrollHeight = newState.scrollHeight - - if newState.scrollTop isnt oldState.scrollTop - domNode.style['-webkit-transform'] = "translate3d(0px, #{-newState.scrollTop}px, 0px)" - oldState.scrollTop = newState.scrollTop - - if newState.backgroundColor isnt oldState.backgroundColor - domNode.style.backgroundColor = newState.backgroundColor - oldState.backgroundColor = newState.backgroundColor diff --git a/src/gutter-container-component.coffee b/src/gutter-container-component.coffee deleted file mode 100644 index 5fa2f85f4e8..00000000000 --- a/src/gutter-container-component.coffee +++ /dev/null @@ -1,107 +0,0 @@ -_ = require 'underscore-plus' -CustomGutterComponent = require './custom-gutter-component' -LineNumberGutterComponent = require './line-number-gutter-component' - -# The GutterContainerComponent manages the GutterComponents of a particular -# TextEditorComponent. - -module.exports = -class GutterContainerComponent - - constructor: ({@onLineNumberGutterMouseDown, @editor}) -> - # An array of objects of the form: {name: {String}, component: {Object}} - @gutterComponents = [] - @gutterComponentsByGutterName = {} - @lineNumberGutterComponent = null - - @domNode = document.createElement('div') - @domNode.classList.add('gutter-container') - @domNode.style.display = 'flex'; - - getDomNode: -> - @domNode - - getLineNumberGutterComponent: -> - @lineNumberGutterComponent - - updateSync: (state) -> - # The GutterContainerComponent expects the gutters to be sorted in the order - # they should appear. - newState = state.gutters - - newGutterComponents = [] - newGutterComponentsByGutterName = {} - for {gutter, visible, styles, content} in newState - gutterComponent = @gutterComponentsByGutterName[gutter.name] - if not gutterComponent - if gutter.name is 'line-number' - gutterComponent = new LineNumberGutterComponent({onMouseDown: @onLineNumberGutterMouseDown, @editor, gutter}) - @lineNumberGutterComponent = gutterComponent - else - gutterComponent = new CustomGutterComponent({gutter}) - - if visible then gutterComponent.showNode() else gutterComponent.hideNode() - # Pass the gutter only the state that it needs. - if gutter.name is 'line-number' - # For ease of use in the line number gutter component, set the shared - # 'styles' as a field under the 'content'. - gutterSubstate = _.clone(content) - gutterSubstate.styles = styles - else - # Custom gutter 'content' is keyed on gutter name, so we cannot set - # 'styles' as a subfield directly under it. - gutterSubstate = {content, styles} - gutterComponent.updateSync(gutterSubstate) - - newGutterComponents.push({ - name: gutter.name, - component: gutterComponent, - }) - newGutterComponentsByGutterName[gutter.name] = gutterComponent - - @reorderGutters(newGutterComponents, newGutterComponentsByGutterName) - - @gutterComponents = newGutterComponents - @gutterComponentsByGutterName = newGutterComponentsByGutterName - - ### - Section: Private Methods - ### - - reorderGutters: (newGutterComponents, newGutterComponentsByGutterName) -> - # First, insert new gutters into the DOM. - indexInOldGutters = 0 - oldGuttersLength = @gutterComponents.length - - for gutterComponentDescription in newGutterComponents - gutterComponent = gutterComponentDescription.component - gutterName = gutterComponentDescription.name - - if @gutterComponentsByGutterName[gutterName] - # If the gutter existed previously, we first try to move the cursor to - # the point at which it occurs in the previous gutters. - matchingGutterFound = false - while indexInOldGutters < oldGuttersLength - existingGutterComponentDescription = @gutterComponents[indexInOldGutters] - existingGutterComponent = existingGutterComponentDescription.component - indexInOldGutters++ - if existingGutterComponent is gutterComponent - matchingGutterFound = true - break - if not matchingGutterFound - # If we've reached this point, the gutter previously existed, but its - # position has moved. Remove it from the DOM and re-insert it. - gutterComponent.getDomNode().remove() - @domNode.appendChild(gutterComponent.getDomNode()) - - else - if indexInOldGutters is oldGuttersLength - @domNode.appendChild(gutterComponent.getDomNode()) - else - @domNode.insertBefore(gutterComponent.getDomNode(), @domNode.children[indexInOldGutters]) - - # Remove any gutters that were not present in the new gutters state. - for gutterComponentDescription in @gutterComponents - if not newGutterComponentsByGutterName[gutterComponentDescription.name] - gutterComponent = gutterComponentDescription.component - gutterComponent.getDomNode().remove() diff --git a/src/gutter-container.coffee b/src/gutter-container.coffee deleted file mode 100644 index 1be52d51931..00000000000 --- a/src/gutter-container.coffee +++ /dev/null @@ -1,96 +0,0 @@ -{Emitter} = require 'event-kit' -Gutter = require './gutter' - -# This class encapsulates the logic for adding and modifying a set of gutters. - -module.exports = -class GutterContainer - - # * `textEditor` The {TextEditor} to which this {GutterContainer} belongs. - constructor: (textEditor) -> - @gutters = [] - @textEditor = textEditor - @emitter = new Emitter - - destroy: -> - @gutters = null - @emitter.dispose() - - # Creates and returns a {Gutter}. - # * `options` An {Object} with the following fields: - # * `name` (required) A unique {String} to identify this gutter. - # * `priority` (optional) A {Number} that determines stacking order between - # gutters. Lower priority items are forced closer to the edges of the - # window. (default: -100) - # * `visible` (optional) {Boolean} specifying whether the gutter is visible - # initially after being created. (default: true) - addGutter: (options) -> - options = options ? {} - gutterName = options.name - if gutterName is null - throw new Error('A name is required to create a gutter.') - if @gutterWithName(gutterName) - throw new Error('Tried to create a gutter with a name that is already in use.') - newGutter = new Gutter(this, options) - - inserted = false - # Insert the gutter into the gutters array, sorted in ascending order by 'priority'. - # This could be optimized, but there are unlikely to be many gutters. - for i in [0...@gutters.length] - if @gutters[i].priority >= newGutter.priority - @gutters.splice(i, 0, newGutter) - inserted = true - break - if not inserted - @gutters.push newGutter - @emitter.emit 'did-add-gutter', newGutter - return newGutter - - getGutters: -> - @gutters.slice() - - gutterWithName: (name) -> - for gutter in @gutters - if gutter.name is name then return gutter - null - - ### - Section: Event Subscription - ### - - # See {TextEditor::observeGutters} for details. - observeGutters: (callback) -> - callback(gutter) for gutter in @getGutters() - @onDidAddGutter callback - - # See {TextEditor::onDidAddGutter} for details. - onDidAddGutter: (callback) -> - @emitter.on 'did-add-gutter', callback - - # See {TextEditor::onDidRemoveGutter} for details. - onDidRemoveGutter: (callback) -> - @emitter.on 'did-remove-gutter', callback - - ### - Section: Private Methods - ### - - # Processes the destruction of the gutter. Throws an error if this gutter is - # not within this gutterContainer. - removeGutter: (gutter) -> - index = @gutters.indexOf(gutter) - if index > -1 - @gutters.splice(index, 1) - @emitter.emit 'did-remove-gutter', gutter.name - else - throw new Error 'The given gutter cannot be removed because it is not ' + - 'within this GutterContainer.' - - # The public interface is Gutter::decorateMarker or TextEditor::decorateMarker. - addGutterDecoration: (gutter, marker, options) -> - if gutter.name is 'line-number' - options.type = 'line-number' - else - options.type = 'gutter' - options.gutterName = gutter.name - @textEditor.decorateMarker(marker, options) diff --git a/src/gutter-container.js b/src/gutter-container.js new file mode 100644 index 00000000000..19d928f1066 --- /dev/null +++ b/src/gutter-container.js @@ -0,0 +1,117 @@ +const { Emitter } = require('event-kit'); +const Gutter = require('./gutter'); + +module.exports = class GutterContainer { + constructor(textEditor) { + this.gutters = []; + this.textEditor = textEditor; + this.emitter = new Emitter(); + } + + scheduleComponentUpdate() { + this.textEditor.scheduleComponentUpdate(); + } + + destroy() { + // Create a copy, because `Gutter::destroy` removes the gutter from + // GutterContainer's @gutters. + const guttersToDestroy = this.gutters.slice(0); + for (let gutter of guttersToDestroy) { + if (gutter.name !== 'line-number') { + gutter.destroy(); + } + } + this.gutters = []; + this.emitter.dispose(); + } + + addGutter(options) { + options = options || {}; + const gutterName = options.name; + if (gutterName === null) { + throw new Error('A name is required to create a gutter.'); + } + if (this.gutterWithName(gutterName)) { + throw new Error( + 'Tried to create a gutter with a name that is already in use.' + ); + } + const newGutter = new Gutter(this, options); + + let inserted = false; + // Insert the gutter into the gutters array, sorted in ascending order by 'priority'. + // This could be optimized, but there are unlikely to be many gutters. + for (let i = 0; i < this.gutters.length; i++) { + if (this.gutters[i].priority >= newGutter.priority) { + this.gutters.splice(i, 0, newGutter); + inserted = true; + break; + } + } + if (!inserted) { + this.gutters.push(newGutter); + } + this.scheduleComponentUpdate(); + this.emitter.emit('did-add-gutter', newGutter); + return newGutter; + } + + getGutters() { + return this.gutters.slice(); + } + + gutterWithName(name) { + for (let gutter of this.gutters) { + if (gutter.name === name) { + return gutter; + } + } + return null; + } + + observeGutters(callback) { + for (let gutter of this.getGutters()) { + callback(gutter); + } + return this.onDidAddGutter(callback); + } + + onDidAddGutter(callback) { + return this.emitter.on('did-add-gutter', callback); + } + + onDidRemoveGutter(callback) { + return this.emitter.on('did-remove-gutter', callback); + } + + /* + Section: Private Methods + */ + + // Processes the destruction of the gutter. Throws an error if this gutter is + // not within this gutterContainer. + removeGutter(gutter) { + const index = this.gutters.indexOf(gutter); + if (index > -1) { + this.gutters.splice(index, 1); + this.scheduleComponentUpdate(); + this.emitter.emit('did-remove-gutter', gutter.name); + } else { + throw new Error( + 'The given gutter cannot be removed because it is not ' + + 'within this GutterContainer.' + ); + } + } + + // The public interface is Gutter::decorateMarker or TextEditor::decorateMarker. + addGutterDecoration(gutter, marker, options) { + if (gutter.type === 'line-number') { + options.type = 'line-number'; + } else { + options.type = 'gutter'; + } + options.gutterName = gutter.name; + return this.textEditor.decorateMarker(marker, options); + } +}; diff --git a/src/gutter.coffee b/src/gutter.coffee deleted file mode 100644 index cb5c36e9c34..00000000000 --- a/src/gutter.coffee +++ /dev/null @@ -1,69 +0,0 @@ -{Emitter} = require 'event-kit' - -# Public: This class represents a gutter within a TextEditor. - -DefaultPriority = -100 - -module.exports = -class Gutter - # * `gutterContainer` The {GutterContainer} object to which this gutter belongs. - # * `options` An {Object} with the following fields: - # * `name` (required) A unique {String} to identify this gutter. - # * `priority` (optional) A {Number} that determines stacking order between - # gutters. Lower priority items are forced closer to the edges of the - # window. (default: -100) - # * `visible` (optional) {Boolean} specifying whether the gutter is visible - # initially after being created. (default: true) - constructor: (gutterContainer, options) -> - @gutterContainer = gutterContainer - @name = options?.name - @priority = options?.priority ? DefaultPriority - @visible = options?.visible ? true - - @emitter = new Emitter - - destroy: -> - if @name is 'line-number' - throw new Error('The line-number gutter cannot be destroyed.') - else - @gutterContainer.removeGutter(this) - @emitter.emit 'did-destroy' - @emitter.dispose() - - hide: -> - if @visible - @visible = false - @emitter.emit 'did-change-visible', this - - show: -> - if not @visible - @visible = true - @emitter.emit 'did-change-visible', this - - isVisible: -> - @visible - - # * `marker` (required) A Marker object. - # * `options` (optional) An object with the following fields: - # * `class` (optional) - # * `item` (optional) A model {Object} with a corresponding view registered, - # or an {HTMLElement}. - decorateMarker: (marker, options) -> - @gutterContainer.addGutterDecoration(this, marker, options) - - # Calls your `callback` when the {Gutter}'s' visibility changes. - # - # * `callback` {Function} - # * `gutter` The {Gutter} whose visibility changed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeVisible: (callback) -> - @emitter.on 'did-change-visible', callback - - # Calls your `callback` when the {Gutter} is destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback diff --git a/src/gutter.js b/src/gutter.js new file mode 100644 index 00000000000..c3b6214ee49 --- /dev/null +++ b/src/gutter.js @@ -0,0 +1,114 @@ +const { Emitter } = require('event-kit'); + +const DefaultPriority = -100; + +// Extended: Represents a gutter within a {TextEditor}. +// +// See {TextEditor::addGutter} for information on creating a gutter. +module.exports = class Gutter { + constructor(gutterContainer, options) { + this.gutterContainer = gutterContainer; + this.name = options && options.name; + this.priority = + options && options.priority != null ? options.priority : DefaultPriority; + this.visible = options && options.visible != null ? options.visible : true; + this.type = options && options.type != null ? options.type : 'decorated'; + this.labelFn = options && options.labelFn; + this.className = options && options.class; + + this.onMouseDown = options && options.onMouseDown; + this.onMouseMove = options && options.onMouseMove; + + this.emitter = new Emitter(); + } + + /* + Section: Gutter Destruction + */ + + // Essential: Destroys the gutter. + destroy() { + if (this.name === 'line-number') { + throw new Error('The line-number gutter cannot be destroyed.'); + } else { + this.gutterContainer.removeGutter(this); + this.emitter.emit('did-destroy'); + this.emitter.dispose(); + } + } + + /* + Section: Event Subscription + */ + + // Essential: Calls your `callback` when the gutter's visibility changes. + // + // * `callback` {Function} + // * `gutter` The gutter whose visibility changed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeVisible(callback) { + return this.emitter.on('did-change-visible', callback); + } + + // Essential: Calls your `callback` when the gutter is destroyed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Visibility + */ + + // Essential: Hide the gutter. + hide() { + if (this.visible) { + this.visible = false; + this.gutterContainer.scheduleComponentUpdate(); + this.emitter.emit('did-change-visible', this); + } + } + + // Essential: Show the gutter. + show() { + if (!this.visible) { + this.visible = true; + this.gutterContainer.scheduleComponentUpdate(); + this.emitter.emit('did-change-visible', this); + } + } + + // Essential: Determine whether the gutter is visible. + // + // Returns a {Boolean}. + isVisible() { + return this.visible; + } + + // Essential: Add a decoration that tracks a {DisplayMarker}. When the marker moves, + // is invalidated, or is destroyed, the decoration will be updated to reflect + // the marker's state. + // + // ## Arguments + // + // * `marker` A {DisplayMarker} you want this decoration to follow. + // * `decorationParams` An {Object} representing the decoration. It is passed + // to {TextEditor::decorateMarker} as its `decorationParams` and so supports + // all options documented there. + // * `type` __Caveat__: set to `'line-number'` if this is the line-number + // gutter, `'gutter'` otherwise. This cannot be overridden. + // + // Returns a {Decoration} object + decorateMarker(marker, options) { + return this.gutterContainer.addGutterDecoration(this, marker, options); + } + + getElement() { + if (this.element == null) this.element = document.createElement('div'); + return this.element; + } +}; diff --git a/src/highlights-component.coffee b/src/highlights-component.coffee deleted file mode 100644 index 5a5747d4c9b..00000000000 --- a/src/highlights-component.coffee +++ /dev/null @@ -1,124 +0,0 @@ -RegionStyleProperties = ['top', 'left', 'right', 'width', 'height'] -SpaceRegex = /\s+/ - -module.exports = -class HighlightsComponent - oldState: null - - constructor: -> - @highlightNodesById = {} - @regionNodesByHighlightId = {} - - @domNode = document.createElement('div') - @domNode.classList.add('highlights') - - if atom.config.get('editor.useShadowDOM') - insertionPoint = document.createElement('content') - insertionPoint.setAttribute('select', '.underlayer') - @domNode.appendChild(insertionPoint) - - getDomNode: -> - @domNode - - updateSync: (state) -> - newState = state.content.highlights - @oldState ?= {} - - # remove highlights - for id of @oldState - unless newState[id]? - @highlightNodesById[id].remove() - delete @highlightNodesById[id] - delete @regionNodesByHighlightId[id] - delete @oldState[id] - - # add or update highlights - for id, highlightState of newState - unless @oldState[id]? - highlightNode = document.createElement('div') - highlightNode.classList.add('highlight') - @highlightNodesById[id] = highlightNode - @regionNodesByHighlightId[id] = {} - @domNode.appendChild(highlightNode) - @updateHighlightNode(id, highlightState) - - return - - updateHighlightNode: (id, newHighlightState) -> - highlightNode = @highlightNodesById[id] - oldHighlightState = (@oldState[id] ?= {regions: [], flashCount: 0}) - - # update class - if newHighlightState.class isnt oldHighlightState.class - if oldHighlightState.class? - if SpaceRegex.test(oldHighlightState.class) - highlightNode.classList.remove(oldHighlightState.class.split(SpaceRegex)...) - else - highlightNode.classList.remove(oldHighlightState.class) - - if SpaceRegex.test(newHighlightState.class) - highlightNode.classList.add(newHighlightState.class.split(SpaceRegex)...) - else - highlightNode.classList.add(newHighlightState.class) - - oldHighlightState.class = newHighlightState.class - - @updateHighlightRegions(id, newHighlightState) - @flashHighlightNodeIfRequested(id, newHighlightState) - - updateHighlightRegions: (id, newHighlightState) -> - oldHighlightState = @oldState[id] - highlightNode = @highlightNodesById[id] - - # remove regions - while oldHighlightState.regions.length > newHighlightState.regions.length - oldHighlightState.regions.pop() - @regionNodesByHighlightId[id][oldHighlightState.regions.length].remove() - delete @regionNodesByHighlightId[id][oldHighlightState.regions.length] - - # add or update regions - for newRegionState, i in newHighlightState.regions - unless oldHighlightState.regions[i]? - oldHighlightState.regions[i] = {} - regionNode = document.createElement('div') - regionNode.classList.add('region') - regionNode.classList.add(newHighlightState.deprecatedRegionClass) if newHighlightState.deprecatedRegionClass? - @regionNodesByHighlightId[id][i] = regionNode - highlightNode.appendChild(regionNode) - - oldRegionState = oldHighlightState.regions[i] - regionNode = @regionNodesByHighlightId[id][i] - - for property in RegionStyleProperties - if newRegionState[property] isnt oldRegionState[property] - oldRegionState[property] = newRegionState[property] - if newRegionState[property]? - regionNode.style[property] = newRegionState[property] + 'px' - else - regionNode.style[property] = '' - - return - - flashHighlightNodeIfRequested: (id, newHighlightState) -> - oldHighlightState = @oldState[id] - return unless newHighlightState.flashCount > oldHighlightState.flashCount - - highlightNode = @highlightNodesById[id] - - addFlashClass = => - highlightNode.classList.add(newHighlightState.flashClass) - oldHighlightState.flashClass = newHighlightState.flashClass - @flashTimeoutId = setTimeout(removeFlashClass, newHighlightState.flashDuration) - - removeFlashClass = => - highlightNode.classList.remove(oldHighlightState.flashClass) - oldHighlightState.flashClass = null - clearTimeout(@flashTimeoutId) - - if oldHighlightState.flashClass? - removeFlashClass() - requestAnimationFrame(addFlashClass) - else - addFlashClass() - - oldHighlightState.flashCount = newHighlightState.flashCount diff --git a/src/history-manager.js b/src/history-manager.js new file mode 100644 index 00000000000..fce540654c4 --- /dev/null +++ b/src/history-manager.js @@ -0,0 +1,151 @@ +const { Emitter, CompositeDisposable } = require('event-kit'); + +// Extended: History manager for remembering which projects have been opened. +// +// An instance of this class is always available as the `atom.history` global. +// +// The project history is used to enable the 'Reopen Project' menu. +class HistoryManager { + constructor({ project, commands, stateStore }) { + this.stateStore = stateStore; + this.emitter = new Emitter(); + this.projects = []; + this.disposables = new CompositeDisposable(); + this.disposables.add( + commands.add( + 'atom-workspace', + { 'application:clear-project-history': this.clearProjects.bind(this) }, + false + ) + ); + this.disposables.add( + project.onDidChangePaths(projectPaths => this.addProject(projectPaths)) + ); + } + + destroy() { + this.disposables.dispose(); + } + + // Public: Obtain a list of previously opened projects. + // + // Returns an {Array} of {HistoryProject} objects, most recent first. + getProjects() { + return this.projects.map(p => new HistoryProject(p.paths, p.lastOpened)); + } + + // Public: Clear all projects from the history. + // + // Note: This is not a privacy function - other traces will still exist, + // e.g. window state. + // + // Return a {Promise} that resolves when the history has been successfully + // cleared. + async clearProjects() { + this.projects = []; + await this.saveState(); + this.didChangeProjects(); + } + + // Public: Invoke the given callback when the list of projects changes. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeProjects(callback) { + return this.emitter.on('did-change-projects', callback); + } + + didChangeProjects(args = { reloaded: false }) { + this.emitter.emit('did-change-projects', args); + } + + async addProject(paths, lastOpened) { + if (paths.length === 0) return; + + let project = this.getProject(paths); + if (!project) { + project = new HistoryProject(paths); + this.projects.push(project); + } + project.lastOpened = lastOpened || new Date(); + this.projects.sort((a, b) => b.lastOpened - a.lastOpened); + + await this.saveState(); + this.didChangeProjects(); + } + + async removeProject(paths) { + if (paths.length === 0) return; + + let project = this.getProject(paths); + if (!project) return; + + let index = this.projects.indexOf(project); + this.projects.splice(index, 1); + + await this.saveState(); + this.didChangeProjects(); + } + + getProject(paths) { + for (let i = 0; i < this.projects.length; i++) { + if (arrayEquivalent(paths, this.projects[i].paths)) { + return this.projects[i]; + } + } + + return null; + } + + async loadState() { + 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 }); + } else { + this.projects = []; + } + } + + async saveState() { + const projects = this.projects.map(p => ({ + paths: p.paths, + lastOpened: p.lastOpened + })); + await this.stateStore.save('history-manager', { projects }); + } +} + +function arrayEquivalent(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +class HistoryProject { + constructor(paths, lastOpened) { + this.paths = paths; + this.lastOpened = lastOpened || new Date(); + } + + set paths(paths) { + this._paths = paths; + } + get paths() { + return this._paths; + } + + set lastOpened(lastOpened) { + this._lastOpened = lastOpened; + } + get lastOpened() { + return this._lastOpened; + } +} + +module.exports = { HistoryManager, HistoryProject }; diff --git a/src/initialize-application-window.js b/src/initialize-application-window.js new file mode 100644 index 00000000000..f32d7633d3d --- /dev/null +++ b/src/initialize-application-window.js @@ -0,0 +1,119 @@ +const AtomEnvironment = require('./atom-environment'); +const ApplicationDelegate = require('./application-delegate'); +const Clipboard = require('./clipboard'); +const TextEditor = require('./text-editor'); + +require('./text-editor-component'); +require('./file-system-blob-store'); +require('./native-compile-cache'); +require('./compile-cache'); +require('./module-cache'); + +if (global.isGeneratingSnapshot) { + require('about'); + require('archive-view'); + require('autocomplete-atom-api'); + require('autocomplete-css'); + require('autocomplete-html'); + require('autocomplete-plus'); + require('autocomplete-snippets'); + require('autoflow'); + require('autosave'); + require('background-tips'); + require('bookmarks'); + require('bracket-matcher'); + require('command-palette'); + require('deprecation-cop'); + require('dev-live-reload'); + require('encoding-selector'); + require('exception-reporting'); + require('dalek'); + require('find-and-replace'); + require('fuzzy-finder'); + require('github'); + require('git-diff'); + require('go-to-line'); + require('grammar-selector'); + require('image-view'); + require('incompatible-packages'); + require('keybinding-resolver'); + require('language-c'); + require('language-html'); + require('language-javascript'); + require('language-ruby'); + require('language-rust-bundled'); + require('language-typescript'); + require('line-ending-selector'); + require('link'); + require('markdown-preview'); + require('metrics'); + require('notifications'); + require('open-on-github'); + require('package-generator'); + require('settings-view'); + require('snippets'); + require('spell-check'); + require('status-bar'); + require('styleguide'); + require('symbols-view'); + require('tabs'); + require('timecop'); + require('tree-view'); + require('update-package-dependencies'); + require('welcome'); + require('whitespace'); + require('wrap-guide'); +} + +const clipboard = new Clipboard(); +TextEditor.setClipboard(clipboard); +TextEditor.viewForItem = item => atom.views.getView(item); + +global.atom = new AtomEnvironment({ + clipboard, + applicationDelegate: new ApplicationDelegate(), + enablePersistence: true +}); + +TextEditor.setScheduler(global.atom.views); +global.atom.preloadPackages(); + +// Like sands through the hourglass, so are the days of our lives. +module.exports = function({ blobStore }) { + const { updateProcessEnv } = require('./update-process-env'); + const path = require('path'); + require('./window'); + const getWindowLoadSettings = require('./get-window-load-settings'); + const { ipcRenderer } = require('electron'); + const { resourcePath, devMode } = getWindowLoadSettings(); + require('./electron-shims'); + + // Add application-specific exports to module search path. + const exportsPath = path.join(resourcePath, 'exports'); + require('module').globalPaths.push(exportsPath); + process.env.NODE_PATH = exportsPath; + + // Make React faster + if (!devMode && process.env.NODE_ENV == null) { + process.env.NODE_ENV = 'production'; + } + + global.atom.initialize({ + window, + document, + blobStore, + configDirPath: process.env.ATOM_HOME, + env: process.env + }); + + return global.atom.startEditorWindow().then(function() { + // Workaround for focus getting cleared upon window creation + const windowFocused = function() { + window.removeEventListener('focus', windowFocused); + setTimeout(() => document.querySelector('atom-workspace').focus(), 0); + }; + window.addEventListener('focus', windowFocused); + + ipcRenderer.on('environment', (event, env) => updateProcessEnv(env)); + }); +}; diff --git a/src/initialize-benchmark-window.js b/src/initialize-benchmark-window.js new file mode 100644 index 00000000000..621ed722a53 --- /dev/null +++ b/src/initialize-benchmark-window.js @@ -0,0 +1,129 @@ +const { remote } = require('electron'); +const path = require('path'); +const ipcHelpers = require('./ipc-helpers'); +const util = require('util'); + +module.exports = async function() { + const getWindowLoadSettings = require('./get-window-load-settings'); + const { + test, + headless, + resourcePath, + benchmarkPaths + } = getWindowLoadSettings(); + try { + const Clipboard = require('../src/clipboard'); + const ApplicationDelegate = require('../src/application-delegate'); + const AtomEnvironment = require('../src/atom-environment'); + const TextEditor = require('../src/text-editor'); + require('./electron-shims'); + + const exportsPath = path.join(resourcePath, 'exports'); + require('module').globalPaths.push(exportsPath); // Add 'exports' to module search path. + process.env.NODE_PATH = exportsPath; // Set NODE_PATH env variable since tasks may need it. + + document.title = 'Benchmarks'; + // Allow `document.title` to be assigned in benchmarks without actually changing the window title. + let documentTitle = null; + Object.defineProperty(document, 'title', { + get() { + return documentTitle; + }, + set(title) { + documentTitle = title; + } + }); + + window.addEventListener( + 'keydown', + event => { + // Reload: cmd-r / ctrl-r + if ((event.metaKey || event.ctrlKey) && event.keyCode === 82) { + ipcHelpers.call('window-method', 'reload'); + } + + // Toggle Dev Tools: cmd-alt-i (Mac) / ctrl-shift-i (Linux/Windows) + if (event.keyCode === 73) { + const isDarwin = process.platform === 'darwin'; + if ( + (isDarwin && event.metaKey && event.altKey) || + (!isDarwin && event.ctrlKey && event.shiftKey) + ) { + ipcHelpers.call('window-method', 'toggleDevTools'); + } + } + + // Close: cmd-w / ctrl-w + if ((event.metaKey || event.ctrlKey) && event.keyCode === 87) { + ipcHelpers.call('window-method', 'close'); + } + + // Copy: cmd-c / ctrl-c + if ((event.metaKey || event.ctrlKey) && event.keyCode === 67) { + ipcHelpers.call('window-method', 'copy'); + } + }, + { capture: true } + ); + + const clipboard = new Clipboard(); + TextEditor.setClipboard(clipboard); + TextEditor.viewForItem = item => atom.views.getView(item); + + const applicationDelegate = new ApplicationDelegate(); + const environmentParams = { + applicationDelegate, + window, + document, + clipboard, + configDirPath: process.env.ATOM_HOME, + enablePersistence: false + }; + global.atom = new AtomEnvironment(environmentParams); + global.atom.initialize(environmentParams); + + // Prevent benchmarks from modifying application menus + global.atom.menu.sendToBrowserProcess = function() {}; + + if (headless) { + Object.defineProperties(process, { + stdout: { value: remote.process.stdout }, + stderr: { value: remote.process.stderr } + }); + + console.log = function(...args) { + const formatted = util.format(...args); + process.stdout.write(formatted + '\n'); + }; + console.warn = function(...args) { + const formatted = util.format(...args); + process.stderr.write(formatted + '\n'); + }; + console.error = function(...args) { + const formatted = util.format(...args); + process.stderr.write(formatted + '\n'); + }; + } else { + remote.getCurrentWindow().show(); + } + + const benchmarkRunner = require('../benchmarks/benchmark-runner'); + const statusCode = await benchmarkRunner({ test, benchmarkPaths }); + if (headless) { + exitWithStatusCode(statusCode); + } + } catch (error) { + if (headless) { + console.error(error.stack || error); + exitWithStatusCode(1); + } else { + ipcHelpers.call('window-method', 'openDevTools'); + throw error; + } + } +}; + +function exitWithStatusCode(statusCode) { + remote.app.emit('will-quit'); + remote.process.exit(statusCode); +} diff --git a/src/initialize-test-window.js b/src/initialize-test-window.js new file mode 100644 index 00000000000..1d5098b4d33 --- /dev/null +++ b/src/initialize-test-window.js @@ -0,0 +1,161 @@ +const ipcHelpers = require('./ipc-helpers'); +const { requireModule } = require('./module-utils'); + +function cloneObject(object) { + const clone = {}; + for (const key in object) { + clone[key] = object[key]; + } + return clone; +} + +module.exports = async function({ blobStore }) { + const { remote } = require('electron'); + const getWindowLoadSettings = require('./get-window-load-settings'); + + const exitWithStatusCode = function(status) { + remote.app.emit('will-quit'); + remote.process.exit(status); + }; + + try { + const path = require('path'); + const { ipcRenderer } = require('electron'); + const CompileCache = require('./compile-cache'); + const AtomEnvironment = require('../src/atom-environment'); + const ApplicationDelegate = require('../src/application-delegate'); + const Clipboard = require('../src/clipboard'); + const TextEditor = require('../src/text-editor'); + const { updateProcessEnv } = require('./update-process-env'); + require('./electron-shims'); + + ipcRenderer.on('environment', (event, env) => updateProcessEnv(env)); + + const { + testRunnerPath, + legacyTestRunnerPath, + headless, + logFile, + testPaths, + env + } = getWindowLoadSettings(); + + if (headless) { + // Install console functions that output to stdout and stderr. + const util = require('util'); + + Object.defineProperties(process, { + stdout: { value: remote.process.stdout }, + stderr: { value: remote.process.stderr } + }); + + console.log = (...args) => + process.stdout.write(`${util.format(...args)}\n`); + console.error = (...args) => + process.stderr.write(`${util.format(...args)}\n`); + } else { + // Show window synchronously so a focusout doesn't fire on input elements + // that are focused in the very first spec run. + remote.getCurrentWindow().show(); + } + + const handleKeydown = function(event) { + // Reload: cmd-r / ctrl-r + if ((event.metaKey || event.ctrlKey) && event.keyCode === 82) { + ipcHelpers.call('window-method', 'reload'); + } + + // Toggle Dev Tools: cmd-alt-i (Mac) / ctrl-shift-i (Linux/Windows) + if ( + event.keyCode === 73 && + ((process.platform === 'darwin' && event.metaKey && event.altKey) || + (process.platform !== 'darwin' && event.ctrlKey && event.shiftKey)) + ) { + ipcHelpers.call('window-method', 'toggleDevTools'); + } + + // Close: cmd-w / ctrl-w + if ((event.metaKey || event.ctrlKey) && event.keyCode === 87) { + ipcHelpers.call('window-method', 'close'); + } + + // Copy: cmd-c / ctrl-c + if ((event.metaKey || event.ctrlKey) && event.keyCode === 67) { + atom.clipboard.write(window.getSelection().toString()); + } + }; + + window.addEventListener('keydown', handleKeydown, { capture: true }); + + // Add 'exports' to module search path. + const exportsPath = path.join( + getWindowLoadSettings().resourcePath, + 'exports' + ); + require('module').globalPaths.push(exportsPath); + process.env.NODE_PATH = exportsPath; // Set NODE_PATH env variable since tasks may need it. + + updateProcessEnv(env); + + // Set up optional transpilation for packages under test if any + const FindParentDir = require('find-parent-dir'); + const packageRoot = FindParentDir.sync(testPaths[0], 'package.json'); + if (packageRoot) { + const packageMetadata = require(path.join(packageRoot, 'package.json')); + if (packageMetadata.atomTranspilers) { + CompileCache.addTranspilerConfigForPath( + packageRoot, + packageMetadata.name, + packageMetadata, + packageMetadata.atomTranspilers + ); + } + } + + document.title = 'Spec Suite'; + + const clipboard = new Clipboard(); + TextEditor.setClipboard(clipboard); + TextEditor.viewForItem = item => atom.views.getView(item); + + const testRunner = requireModule(testRunnerPath); + const legacyTestRunner = require(legacyTestRunnerPath); + const buildDefaultApplicationDelegate = () => new ApplicationDelegate(); + const buildAtomEnvironment = function(params) { + params = cloneObject(params); + if (!params.hasOwnProperty('clipboard')) { + params.clipboard = clipboard; + } + if (!params.hasOwnProperty('blobStore')) { + params.blobStore = blobStore; + } + if (!params.hasOwnProperty('onlyLoadBaseStyleSheets')) { + params.onlyLoadBaseStyleSheets = true; + } + const atomEnvironment = new AtomEnvironment(params); + atomEnvironment.initialize(params); + TextEditor.setScheduler(atomEnvironment.views); + return atomEnvironment; + }; + + const statusCode = await testRunner({ + logFile, + headless, + testPaths, + buildAtomEnvironment, + buildDefaultApplicationDelegate, + legacyTestRunner + }); + + if (getWindowLoadSettings().headless) { + exitWithStatusCode(statusCode); + } + } catch (error) { + if (getWindowLoadSettings().headless) { + console.error(error.stack || error); + exitWithStatusCode(1); + } else { + throw error; + } + } +}; diff --git a/src/input-component.coffee b/src/input-component.coffee deleted file mode 100644 index 88c1cf480e3..00000000000 --- a/src/input-component.coffee +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = -class InputComponent - constructor: -> - @domNode = document.createElement('input') - @domNode.classList.add('hidden-input') - @domNode.setAttribute('data-react-skip-selection-restoration', true) - @domNode.style['-webkit-transform'] = 'translateZ(0)' - @domNode.addEventListener 'paste', (event) -> event.preventDefault() - - getDomNode: -> - @domNode - - updateSync: (state) -> - @oldState ?= {} - newState = state.hiddenInput - - if newState.top isnt @oldState.top - @domNode.style.top = newState.top + 'px' - @oldState.top = newState.top - - if newState.left isnt @oldState.left - @domNode.style.left = newState.left + 'px' - @oldState.left = newState.left - - if newState.width isnt @oldState.width - @domNode.style.width = newState.width + 'px' - @oldState.width = newState.width - - if newState.height isnt @oldState.height - @domNode.style.height = newState.height + 'px' - @oldState.height = newState.height diff --git a/src/ipc-helpers.js b/src/ipc-helpers.js new file mode 100644 index 00000000000..3627dff43ca --- /dev/null +++ b/src/ipc-helpers.js @@ -0,0 +1,49 @@ +const Disposable = require('event-kit').Disposable; +let ipcRenderer = null; +let ipcMain = null; +let BrowserWindow = null; + +let nextResponseChannelId = 0; + +exports.on = function(emitter, eventName, callback) { + emitter.on(eventName, callback); + return new Disposable(() => emitter.removeListener(eventName, callback)); +}; + +exports.call = function(channel, ...args) { + if (!ipcRenderer) { + ipcRenderer = require('electron').ipcRenderer; + ipcRenderer.setMaxListeners(20); + } + + const responseChannel = `ipc-helpers-response-${nextResponseChannelId++}`; + + return new Promise(resolve => { + ipcRenderer.on(responseChannel, (event, result) => { + ipcRenderer.removeAllListeners(responseChannel); + resolve(result); + }); + + ipcRenderer.send(channel, responseChannel, ...args); + }); +}; + +exports.respondTo = function(channel, callback) { + if (!ipcMain) { + const electron = require('electron'); + ipcMain = electron.ipcMain; + BrowserWindow = electron.BrowserWindow; + } + + return exports.on( + ipcMain, + channel, + async (event, responseChannel, ...args) => { + const browserWindow = BrowserWindow.fromWebContents(event.sender); + const result = await callback(browserWindow, ...args); + if (!event.sender.isDestroyed()) { + event.sender.send(responseChannel, result); + } + } + ); +}; diff --git a/src/item-registry.coffee b/src/item-registry.coffee deleted file mode 100644 index 43af4cd1118..00000000000 --- a/src/item-registry.coffee +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = -class ItemRegistry - constructor: -> - @items = new WeakSet - - addItem: (item) -> - if @hasItem(item) - throw new Error("The workspace can only contain one instance of item #{item}") - @items.add(item) - - removeItem: (item) -> - @items.delete(item) - - hasItem: (item) -> - @items.has(item) diff --git a/src/item-registry.js b/src/item-registry.js new file mode 100644 index 00000000000..5d69a64054f --- /dev/null +++ b/src/item-registry.js @@ -0,0 +1,22 @@ +module.exports = class ItemRegistry { + constructor() { + this.items = new WeakSet(); + } + + addItem(item) { + if (this.hasItem(item)) { + throw new Error( + `The workspace can only contain one instance of item ${item}` + ); + } + return this.items.add(item); + } + + removeItem(item) { + return this.items.delete(item); + } + + hasItem(item) { + return this.items.has(item); + } +}; diff --git a/src/keymap-extensions.coffee b/src/keymap-extensions.coffee index 7ffd3d7eff4..dbba1b6b870 100644 --- a/src/keymap-extensions.coffee +++ b/src/keymap-extensions.coffee @@ -2,27 +2,32 @@ fs = require 'fs-plus' path = require 'path' KeymapManager = require 'atom-keymap' CSON = require 'season' -{jQuery} = require 'space-pen' -Grim = require 'grim' bundledKeymaps = require('../package.json')?._atomKeymaps KeymapManager::onDidLoadBundledKeymaps = (callback) -> @emitter.on 'did-load-bundled-keymaps', callback +KeymapManager::onDidLoadUserKeymap = (callback) -> + @emitter.on 'did-load-user-keymap', callback + +KeymapManager::canLoadBundledKeymapsFromMemory = -> + bundledKeymaps? + KeymapManager::loadBundledKeymaps = -> - keymapsPath = path.join(@resourcePath, 'keymaps') if bundledKeymaps? for keymapName, keymap of bundledKeymaps - keymapPath = path.join(keymapsPath, keymapName) - @add(keymapPath, keymap) + keymapPath = "core:#{keymapName}" + @add(keymapPath, keymap, 0, @devMode ? false) else + keymapsPath = path.join(@resourcePath, 'keymaps') @loadKeymap(keymapsPath) - @emit 'bundled-keymaps-loaded' if Grim.includeDeprecatedAPIs @emitter.emit 'did-load-bundled-keymaps' KeymapManager::getUserKeymapPath = -> + return "" unless @configDirPath? + if userKeymapPath = CSON.resolve(path.join(@configDirPath, 'keymap')) userKeymapPath else @@ -33,7 +38,7 @@ KeymapManager::loadUserKeymap = -> return unless fs.isFileSync(userKeymapPath) try - @loadKeymap(userKeymapPath, watch: true, suppressErrors: true) + @loadKeymap(userKeymapPath, watch: true, suppressErrors: true, priority: 100) catch error if error.message.indexOf('Unable to watch path') > -1 message = """ @@ -44,11 +49,14 @@ KeymapManager::loadUserKeymap = -> [this document][watches] for more info. [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path """ - atom.notifications.addError(message, {dismissable: true}) + @notificationManager.addError(message, {dismissable: true}) else detail = error.path stack = error.stack - atom.notifications.addFatalError(error.message, {detail, stack, dismissable: true}) + @notificationManager.addFatalError(error.message, {detail, stack, dismissable: true}) + + @emitter.emit 'did-load-user-keymap' + KeymapManager::subscribeToFileReadFailure = -> @onDidFailToReadFile (error) => @@ -60,11 +68,6 @@ KeymapManager::subscribeToFileReadFailure = -> else error.message - atom.notifications.addError(message, {detail, dismissable: true}) - -# This enables command handlers registered via jQuery to call -# `.abortKeyBinding()` on the `jQuery.Event` object passed to the handler. -jQuery.Event::abortKeyBinding = -> - @originalEvent?.abortKeyBinding?() + @notificationManager.addError(message, {detail, dismissable: true}) module.exports = KeymapManager diff --git a/src/language-mode.coffee b/src/language-mode.coffee deleted file mode 100644 index c9401550bdb..00000000000 --- a/src/language-mode.coffee +++ /dev/null @@ -1,337 +0,0 @@ -{Range} = require 'text-buffer' -_ = require 'underscore-plus' -{OnigRegExp} = require 'oniguruma' -ScopeDescriptor = require './scope-descriptor' - -module.exports = -class LanguageMode - # Sets up a `LanguageMode` for the given {TextEditor}. - # - # editor - The {TextEditor} to associate with - constructor: (@editor) -> - {@buffer} = @editor - - destroy: -> - - toggleLineCommentForBufferRow: (row) -> - @toggleLineCommentsForBufferRows(row, row) - - # Wraps the lines between two rows in comments. - # - # If the language doesn't have comment, nothing happens. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - toggleLineCommentsForBufferRows: (start, end) -> - scope = @editor.scopeDescriptorForBufferPosition([start, 0]) - {commentStartString, commentEndString} = @commentStartAndEndStringsForScope(scope) - return unless commentStartString? - - buffer = @editor.buffer - commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') - commentStartRegex = new OnigRegExp("^(\\s*)(#{commentStartRegexString})") - - if commentEndString - shouldUncomment = commentStartRegex.testSync(buffer.lineForRow(start)) - if shouldUncomment - commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?') - commentEndRegex = new OnigRegExp("(#{commentEndRegexString})(\\s*)$") - startMatch = commentStartRegex.searchSync(buffer.lineForRow(start)) - endMatch = commentEndRegex.searchSync(buffer.lineForRow(end)) - if startMatch and endMatch - buffer.transact -> - columnStart = startMatch[1].length - columnEnd = columnStart + startMatch[2].length - buffer.setTextInRange([[start, columnStart], [start, columnEnd]], "") - - endLength = buffer.lineLengthForRow(end) - endMatch[2].length - endColumn = endLength - endMatch[1].length - buffer.setTextInRange([[end, endColumn], [end, endLength]], "") - else - buffer.transact -> - indentLength = buffer.lineForRow(start).match(/^\s*/)?[0].length ? 0 - buffer.insert([start, indentLength], commentStartString) - buffer.insert([end, buffer.lineLengthForRow(end)], commentEndString) - else - allBlank = true - allBlankOrCommented = true - - for row in [start..end] - line = buffer.lineForRow(row) - blank = line?.match(/^\s*$/) - - allBlank = false unless blank - allBlankOrCommented = false unless blank or commentStartRegex.testSync(line) - - shouldUncomment = allBlankOrCommented and not allBlank - - if shouldUncomment - for row in [start..end] - if match = commentStartRegex.searchSync(buffer.lineForRow(row)) - columnStart = match[1].length - columnEnd = columnStart + match[2].length - buffer.setTextInRange([[row, columnStart], [row, columnEnd]], "") - else - if start is end - indent = @editor.indentationForBufferRow(start) - else - indent = @minIndentLevelForRowRange(start, end) - indentString = @editor.buildIndentString(indent) - tabLength = @editor.getTabLength() - indentRegex = new RegExp("(\t|[ ]{#{tabLength}}){#{Math.floor(indent)}}") - for row in [start..end] - line = buffer.lineForRow(row) - if indentLength = line.match(indentRegex)?[0].length - buffer.insert([row, indentLength], commentStartString) - else - buffer.setTextInRange([[row, 0], [row, indentString.length]], indentString + commentStartString) - return - - # Folds all the foldable lines in the buffer. - foldAll: -> - for currentRow in [0..@buffer.getLastRow()] - [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? - @editor.createFold(startRow, endRow) - return - - # Unfolds all the foldable lines in the buffer. - unfoldAll: -> - for row in [@buffer.getLastRow()..0] - fold.destroy() for fold in @editor.displayBuffer.foldsStartingAtBufferRow(row) - return - - # Fold all comment and code blocks at a given indentLevel - # - # indentLevel - A {Number} indicating indentLevel; 0 based. - foldAllAtIndentLevel: (indentLevel) -> - @unfoldAll() - for currentRow in [0..@buffer.getLastRow()] - [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? - - # assumption: startRow will always be the min indent level for the entire range - if @editor.indentationForBufferRow(startRow) is indentLevel - @editor.createFold(startRow, endRow) - return - - # Given a buffer row, creates a fold at it. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns the new {Fold}. - foldBufferRow: (bufferRow) -> - for currentRow in [bufferRow..0] - [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] - continue unless startRow? and startRow <= bufferRow <= endRow - fold = @editor.displayBuffer.largestFoldStartingAtBufferRow(startRow) - return @editor.createFold(startRow, endRow) unless fold - - # Find the row range for a fold at a given bufferRow. Will handle comments - # and code. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns an {Array} of the [startRow, endRow]. Returns null if no range. - rowRangeForFoldAtBufferRow: (bufferRow) -> - rowRange = @rowRangeForCommentAtBufferRow(bufferRow) - rowRange ?= @rowRangeForCodeFoldAtBufferRow(bufferRow) - rowRange - - rowRangeForCommentAtBufferRow: (bufferRow) -> - return unless @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow).isComment() - - startRow = bufferRow - endRow = bufferRow - - if bufferRow > 0 - for currentRow in [bufferRow-1..0] - break if @buffer.isRowBlank(currentRow) - break unless @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(currentRow).isComment() - startRow = currentRow - - if bufferRow < @buffer.getLastRow() - for currentRow in [bufferRow+1..@buffer.getLastRow()] - break if @buffer.isRowBlank(currentRow) - break unless @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(currentRow).isComment() - endRow = currentRow - - return [startRow, endRow] if startRow isnt endRow - - rowRangeForCodeFoldAtBufferRow: (bufferRow) -> - return null unless @isFoldableAtBufferRow(bufferRow) - - startIndentLevel = @editor.indentationForBufferRow(bufferRow) - scopeDescriptor = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - for row in [(bufferRow + 1)..@editor.getLastBufferRow()] - continue if @editor.isBufferRowBlank(row) - indentation = @editor.indentationForBufferRow(row) - if indentation <= startIndentLevel - includeRowInFold = indentation is startIndentLevel and @foldEndRegexForScopeDescriptor(scopeDescriptor)?.searchSync(@editor.lineTextForBufferRow(row)) - foldEndRow = row if includeRowInFold - break - - foldEndRow = row - - [bufferRow, foldEndRow] - - isFoldableAtBufferRow: (bufferRow) -> - @editor.displayBuffer.tokenizedBuffer.isFoldableAtRow(bufferRow) - - # Returns a {Boolean} indicating whether the line at the given buffer - # row is a comment. - isLineCommentedAtBufferRow: (bufferRow) -> - return false unless 0 <= bufferRow <= @editor.getLastBufferRow() - @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow).isComment() - - # Find a row range for a 'paragraph' around specified bufferRow. A paragraph - # is a block of text bounded by and empty line or a block of text that is not - # the same type (comments next to source code). - rowRangeForParagraphAtBufferRow: (bufferRow) -> - scope = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - {commentStartString, commentEndString} = @commentStartAndEndStringsForScope(scope) - commentStartRegex = null - if commentStartString? and not commentEndString? - commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') - commentStartRegex = new OnigRegExp("^(\\s*)(#{commentStartRegexString})") - - filterCommentStart = (line) -> - if commentStartRegex? - matches = commentStartRegex.searchSync(line) - line = line.substring(matches[0].end) if matches?.length - line - - return unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(bufferRow))) - - if @isLineCommentedAtBufferRow(bufferRow) - isOriginalRowComment = true - range = @rowRangeForCommentAtBufferRow(bufferRow) - [firstRow, lastRow] = range or [bufferRow, bufferRow] - else - isOriginalRowComment = false - [firstRow, lastRow] = [0, @editor.getLastBufferRow()-1] - - startRow = bufferRow - while startRow > firstRow - break if @isLineCommentedAtBufferRow(startRow - 1) isnt isOriginalRowComment - break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(startRow - 1))) - startRow-- - - endRow = bufferRow - lastRow = @editor.getLastBufferRow() - while endRow < lastRow - break if @isLineCommentedAtBufferRow(endRow + 1) isnt isOriginalRowComment - break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(endRow + 1))) - endRow++ - - new Range([startRow, 0], [endRow, @editor.lineTextForBufferRow(endRow).length]) - - # Given a buffer row, this returns a suggested indentation level. - # - # The indentation level provided is based on the current {LanguageMode}. - # - # bufferRow - A {Number} indicating the buffer row - # - # Returns a {Number}. - suggestedIndentForBufferRow: (bufferRow, options) -> - tokenizedLine = @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow) - @suggestedIndentForTokenizedLineAtBufferRow(bufferRow, tokenizedLine, options) - - suggestedIndentForLineAtBufferRow: (bufferRow, line, options) -> - tokenizedLine = @editor.displayBuffer.tokenizedBuffer.buildTokenizedLineForRowWithText(bufferRow, line) - @suggestedIndentForTokenizedLineAtBufferRow(bufferRow, tokenizedLine, options) - - suggestedIndentForTokenizedLineAtBufferRow: (bufferRow, tokenizedLine, options) -> - iterator = tokenizedLine.getTokenIterator() - iterator.next() - scopeDescriptor = new ScopeDescriptor(scopes: iterator.getScopes()) - - currentIndentLevel = @editor.indentationForBufferRow(bufferRow) - return currentIndentLevel unless increaseIndentRegex = @increaseIndentRegexForScopeDescriptor(scopeDescriptor) - - if options?.skipBlankLines ? true - precedingRow = @buffer.previousNonBlankRow(bufferRow) - return 0 unless precedingRow? - else - precedingRow = bufferRow - 1 - return currentIndentLevel if precedingRow < 0 - - precedingLine = @buffer.lineForRow(precedingRow) - desiredIndentLevel = @editor.indentationForBufferRow(precedingRow) - desiredIndentLevel += 1 if increaseIndentRegex.testSync(precedingLine) and not @editor.isBufferRowCommented(precedingRow) - - return desiredIndentLevel unless decreaseIndentRegex = @decreaseIndentRegexForScopeDescriptor(scopeDescriptor) - desiredIndentLevel -= 1 if decreaseIndentRegex.testSync(tokenizedLine.text) - - Math.max(desiredIndentLevel, 0) - - # Calculate a minimum indent level for a range of lines excluding empty lines. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - # - # Returns a {Number} of the indent level of the block of lines. - minIndentLevelForRowRange: (startRow, endRow) -> - indents = (@editor.indentationForBufferRow(row) for row in [startRow..endRow] when not @editor.isBufferRowBlank(row)) - indents = [0] unless indents.length - Math.min(indents...) - - # Indents all the rows between two buffer row numbers. - # - # startRow - The row {Number} to start at - # endRow - The row {Number} to end at - autoIndentBufferRows: (startRow, endRow) -> - @autoIndentBufferRow(row) for row in [startRow..endRow] - return - - # Given a buffer row, this indents it. - # - # bufferRow - The row {Number}. - # options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. - autoIndentBufferRow: (bufferRow, options) -> - indentLevel = @suggestedIndentForBufferRow(bufferRow, options) - @editor.setIndentationForBufferRow(bufferRow, indentLevel, options) - - # Given a buffer row, this decreases the indentation. - # - # bufferRow - The row {Number} - autoDecreaseIndentForBufferRow: (bufferRow) -> - scopeDescriptor = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - increaseIndentRegex = @increaseIndentRegexForScopeDescriptor(scopeDescriptor) - decreaseIndentRegex = @decreaseIndentRegexForScopeDescriptor(scopeDescriptor) - return unless increaseIndentRegex and decreaseIndentRegex - - line = @buffer.lineForRow(bufferRow) - return unless decreaseIndentRegex.testSync(line) - - currentIndentLevel = @editor.indentationForBufferRow(bufferRow) - return if currentIndentLevel is 0 - precedingRow = @buffer.previousNonBlankRow(bufferRow) - return unless precedingRow? - precedingLine = @buffer.lineForRow(precedingRow) - - desiredIndentLevel = @editor.indentationForBufferRow(precedingRow) - desiredIndentLevel -= 1 unless increaseIndentRegex.testSync(precedingLine) - if desiredIndentLevel >= 0 and desiredIndentLevel < currentIndentLevel - @editor.setIndentationForBufferRow(bufferRow, desiredIndentLevel) - - getRegexForProperty: (scopeDescriptor, property) -> - if pattern = atom.config.get(property, scope: scopeDescriptor) - new OnigRegExp(pattern) - - increaseIndentRegexForScopeDescriptor: (scopeDescriptor) -> - @getRegexForProperty(scopeDescriptor, 'editor.increaseIndentPattern') - - decreaseIndentRegexForScopeDescriptor: (scopeDescriptor) -> - @getRegexForProperty(scopeDescriptor, 'editor.decreaseIndentPattern') - - foldEndRegexForScopeDescriptor: (scopeDescriptor) -> - @getRegexForProperty(scopeDescriptor, 'editor.foldEndPattern') - - commentStartAndEndStringsForScope: (scope) -> - commentStartEntry = atom.config.getAll('editor.commentStart', {scope})[0] - commentEndEntry = _.find atom.config.getAll('editor.commentEnd', {scope}), (entry) -> - entry.scopeSelector is commentStartEntry.scopeSelector - commentStartString = commentStartEntry?.value - commentEndString = commentEndEntry?.value - {commentStartString, commentEndString} diff --git a/src/layer-decoration.coffee b/src/layer-decoration.coffee new file mode 100644 index 00000000000..e20921b4d65 --- /dev/null +++ b/src/layer-decoration.coffee @@ -0,0 +1,64 @@ +idCounter = 0 +nextId = -> idCounter++ + +# Essential: Represents a decoration that applies to every marker on a given +# layer. Created via {TextEditor::decorateMarkerLayer}. +module.exports = +class LayerDecoration + constructor: (@markerLayer, @decorationManager, @properties) -> + @id = nextId() + @destroyed = false + @markerLayerDestroyedDisposable = @markerLayer.onDidDestroy => @destroy() + @overridePropertiesByMarker = null + + # Essential: Destroys the decoration. + destroy: -> + return if @destroyed + @markerLayerDestroyedDisposable.dispose() + @markerLayerDestroyedDisposable = null + @destroyed = true + @decorationManager.didDestroyLayerDecoration(this) + + # Essential: Determine whether this decoration is destroyed. + # + # Returns a {Boolean}. + isDestroyed: -> @destroyed + + getId: -> @id + + getMarkerLayer: -> @markerLayer + + # Essential: Get this decoration's properties. + # + # Returns an {Object}. + getProperties: -> + @properties + + # Essential: Set this decoration's properties. + # + # * `newProperties` See {TextEditor::decorateMarker} for more information on + # the properties. The `type` of `gutter` and `overlay` are not supported on + # layer decorations. + setProperties: (newProperties) -> + return if @destroyed + @properties = newProperties + @decorationManager.emitDidUpdateDecorations() + + # Essential: Override the decoration properties for a specific marker. + # + # * `marker` The {DisplayMarker} or {Marker} for which to override + # properties. + # * `properties` An {Object} containing properties to apply to this marker. + # Pass `null` to clear the override. + setPropertiesForMarker: (marker, properties) -> + return if @destroyed + @overridePropertiesByMarker ?= new Map() + marker = @markerLayer.getMarker(marker.id) + if properties? + @overridePropertiesByMarker.set(marker, properties) + else + @overridePropertiesByMarker.delete(marker) + @decorationManager.emitDidUpdateDecorations() + + getPropertiesForMarker: (marker) -> + @overridePropertiesByMarker?.get(marker) diff --git a/src/less-compile-cache.coffee b/src/less-compile-cache.coffee index c70f312eed9..84fd92247d8 100644 --- a/src/less-compile-cache.coffee +++ b/src/less-compile-cache.coffee @@ -4,9 +4,9 @@ LessCache = require 'less-cache' # {LessCache} wrapper used by {ThemeManager} to read stylesheets. module.exports = class LessCompileCache - @cacheDir: path.join(process.env.ATOM_HOME, 'compile-cache', 'less') + constructor: ({resourcePath, importPaths, lessSourcesByRelativeFilePath, importedFilePathsByRelativeImportPath}) -> + cacheDir = path.join(process.env.ATOM_HOME, 'compile-cache', 'less') - constructor: ({resourcePath, importPaths}) -> @lessSearchPaths = [ path.join(resourcePath, 'static', 'variables') path.join(resourcePath, 'static') @@ -17,11 +17,14 @@ class LessCompileCache else importPaths = @lessSearchPaths - @cache = new LessCache - cacheDir: @constructor.cacheDir - importPaths: importPaths - resourcePath: resourcePath + @cache = new LessCache({ + importPaths, + resourcePath, + lessSourcesByRelativeFilePath, + importedFilePathsByRelativeImportPath, + cacheDir, fallbackDir: path.join(resourcePath, 'less-compile-cache') + }) setImportPaths: (importPaths=[]) -> @cache.setImportPaths(importPaths.concat(@lessSearchPaths)) @@ -29,5 +32,5 @@ class LessCompileCache read: (stylesheetPath) -> @cache.readFileSync(stylesheetPath) - cssForFile: (stylesheetPath, lessContent) -> - @cache.cssForFile(stylesheetPath, lessContent) + cssForFile: (stylesheetPath, lessContent, digest) -> + @cache.cssForFile(stylesheetPath, lessContent, digest) diff --git a/src/line-number-gutter-component.coffee b/src/line-number-gutter-component.coffee deleted file mode 100644 index 351275f6363..00000000000 --- a/src/line-number-gutter-component.coffee +++ /dev/null @@ -1,169 +0,0 @@ -_ = require 'underscore-plus' -{setDimensionsAndBackground} = require './gutter-component-helpers' - -WrapperDiv = document.createElement('div') - -module.exports = -class LineNumberGutterComponent - dummyLineNumberNode: null - - constructor: ({@onMouseDown, @editor, @gutter}) -> - @lineNumberNodesById = {} - @visible = true - - @domNode = atom.views.getView(@gutter) - @lineNumbersNode = @domNode.firstChild - @lineNumbersNode.innerHTML = '' - - @domNode.addEventListener 'click', @onClick - @domNode.addEventListener 'mousedown', @onMouseDown - - getDomNode: -> - @domNode - - hideNode: -> - if @visible - @domNode.style.display = 'none' - @visible = false - - showNode: -> - if not @visible - @domNode.style.removeProperty('display') - @visible = true - - # `state` is a subset of the TextEditorPresenter state that is specific - # to this line number gutter. - updateSync: (state) -> - @newState = state - @oldState ?= - lineNumbers: {} - styles: {} - - @appendDummyLineNumber() unless @dummyLineNumberNode? - - setDimensionsAndBackground(@oldState.styles, @newState.styles, @lineNumbersNode) - - if @newState.maxLineNumberDigits isnt @oldState.maxLineNumberDigits - @updateDummyLineNumber() - node.remove() for id, node of @lineNumberNodesById - @oldState = - maxLineNumberDigits: @newState.maxLineNumberDigits - lineNumbers: {} - styles: {} - @lineNumberNodesById = {} - - @updateLineNumbers() - - ### - Section: Private Methods - ### - - # This dummy line number element holds the gutter to the appropriate width, - # since the real line numbers are absolutely positioned for performance reasons. - appendDummyLineNumber: -> - WrapperDiv.innerHTML = @buildLineNumberHTML({bufferRow: -1}) - @dummyLineNumberNode = WrapperDiv.children[0] - @lineNumbersNode.appendChild(@dummyLineNumberNode) - - updateDummyLineNumber: -> - @dummyLineNumberNode.innerHTML = @buildLineNumberInnerHTML(0, false) - - updateLineNumbers: -> - newLineNumberIds = null - newLineNumbersHTML = null - - for id, lineNumberState of @newState.lineNumbers - if @oldState.lineNumbers.hasOwnProperty(id) - @updateLineNumberNode(id, lineNumberState) - else - newLineNumberIds ?= [] - newLineNumbersHTML ?= "" - newLineNumberIds.push(id) - newLineNumbersHTML += @buildLineNumberHTML(lineNumberState) - @oldState.lineNumbers[id] = _.clone(lineNumberState) - - if newLineNumberIds? - WrapperDiv.innerHTML = newLineNumbersHTML - newLineNumberNodes = _.toArray(WrapperDiv.children) - - node = @lineNumbersNode - for id, i in newLineNumberIds - lineNumberNode = newLineNumberNodes[i] - @lineNumberNodesById[id] = lineNumberNode - node.appendChild(lineNumberNode) - - for id, lineNumberState of @oldState.lineNumbers - unless @newState.lineNumbers.hasOwnProperty(id) - @lineNumberNodesById[id].remove() - delete @lineNumberNodesById[id] - delete @oldState.lineNumbers[id] - - return - - buildLineNumberHTML: (lineNumberState) -> - {screenRow, bufferRow, softWrapped, top, decorationClasses} = lineNumberState - if screenRow? - style = "position: absolute; top: #{top}px;" - else - style = "visibility: hidden;" - className = @buildLineNumberClassName(lineNumberState) - innerHTML = @buildLineNumberInnerHTML(bufferRow, softWrapped) - - "
#{innerHTML}
" - - buildLineNumberInnerHTML: (bufferRow, softWrapped) -> - {maxLineNumberDigits} = @newState - - if softWrapped - lineNumber = "•" - else - lineNumber = (bufferRow + 1).toString() - - padding = _.multiplyString(' ', maxLineNumberDigits - lineNumber.length) - iconHTML = '
' - padding + lineNumber + iconHTML - - updateLineNumberNode: (lineNumberId, newLineNumberState) -> - oldLineNumberState = @oldState.lineNumbers[lineNumberId] - node = @lineNumberNodesById[lineNumberId] - - unless oldLineNumberState.foldable is newLineNumberState.foldable and _.isEqual(oldLineNumberState.decorationClasses, newLineNumberState.decorationClasses) - node.className = @buildLineNumberClassName(newLineNumberState) - oldLineNumberState.foldable = newLineNumberState.foldable - oldLineNumberState.decorationClasses = _.clone(newLineNumberState.decorationClasses) - - unless oldLineNumberState.top is newLineNumberState.top - node.style.top = newLineNumberState.top + 'px' - node.dataset.screenRow = newLineNumberState.screenRow - oldLineNumberState.top = newLineNumberState.top - oldLineNumberState.screenRow = newLineNumberState.screenRow - - buildLineNumberClassName: ({bufferRow, foldable, decorationClasses, softWrapped}) -> - className = "line-number line-number-#{bufferRow}" - className += " " + decorationClasses.join(' ') if decorationClasses? - className += " foldable" if foldable and not softWrapped - className - - lineNumberNodeForScreenRow: (screenRow) -> - for id, lineNumberState of @oldState.lineNumbers - if lineNumberState.screenRow is screenRow - return @lineNumberNodesById[id] - null - - onMouseDown: (event) => - {target} = event - lineNumber = target.parentNode - - unless target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') - @onMouseDown(event) - - onClick: (event) => - {target} = event - lineNumber = target.parentNode - - if target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') - bufferRow = parseInt(lineNumber.getAttribute('data-buffer-row')) - if lineNumber.classList.contains('folded') - @editor.unfoldBufferRow(bufferRow) - else - @editor.foldBufferRow(bufferRow) diff --git a/src/lines-component.coffee b/src/lines-component.coffee deleted file mode 100644 index 17c904e9996..00000000000 --- a/src/lines-component.coffee +++ /dev/null @@ -1,401 +0,0 @@ -_ = require 'underscore-plus' -{toArray} = require 'underscore-plus' -{$$} = require 'space-pen' - -CursorsComponent = require './cursors-component' -HighlightsComponent = require './highlights-component' -TokenIterator = require './token-iterator' - -DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} -WrapperDiv = document.createElement('div') -TokenTextEscapeRegex = /[&"'<>]/g -MaxTokenLength = 20000 - -cloneObject = (object) -> - clone = {} - clone[key] = value for key, value of object - clone - -module.exports = -class LinesComponent - placeholderTextDiv: null - - constructor: ({@presenter, @hostElement, @useShadowDOM, visible}) -> - @tokenIterator = new TokenIterator - @measuredLines = new Set - @lineNodesByLineId = {} - @screenRowsByLineId = {} - @lineIdsByScreenRow = {} - @renderedDecorationsByLineId = {} - - @domNode = document.createElement('div') - @domNode.classList.add('lines') - - @cursorsComponent = new CursorsComponent(@presenter) - @domNode.appendChild(@cursorsComponent.getDomNode()) - - @highlightsComponent = new HighlightsComponent(@presenter) - @domNode.appendChild(@highlightsComponent.getDomNode()) - - if @useShadowDOM - insertionPoint = document.createElement('content') - insertionPoint.setAttribute('select', '.overlayer') - @domNode.appendChild(insertionPoint) - - getDomNode: -> - @domNode - - updateSync: (state) -> - @newState = state.content - @oldState ?= {lines: {}} - - if @newState.scrollHeight isnt @oldState.scrollHeight - @domNode.style.height = @newState.scrollHeight + 'px' - @oldState.scrollHeight = @newState.scrollHeight - - if @newState.scrollTop isnt @oldState.scrollTop or @newState.scrollLeft isnt @oldState.scrollLeft - @domNode.style['-webkit-transform'] = "translate3d(#{-@newState.scrollLeft}px, #{-@newState.scrollTop}px, 0px)" - @oldState.scrollTop = @newState.scrollTop - @oldState.scrollLeft = @newState.scrollLeft - - if @newState.backgroundColor isnt @oldState.backgroundColor - @domNode.style.backgroundColor = @newState.backgroundColor - @oldState.backgroundColor = @newState.backgroundColor - - if @newState.placeholderText isnt @oldState.placeholderText - @placeholderTextDiv?.remove() - if @newState.placeholderText? - @placeholderTextDiv = document.createElement('div') - @placeholderTextDiv.classList.add('placeholder-text') - @placeholderTextDiv.textContent = @newState.placeholderText - @domNode.appendChild(@placeholderTextDiv) - - @removeLineNodes() unless @oldState.indentGuidesVisible is @newState.indentGuidesVisible - @updateLineNodes() - - if @newState.scrollWidth isnt @oldState.scrollWidth - @domNode.style.width = @newState.scrollWidth + 'px' - @oldState.scrollWidth = @newState.scrollWidth - - @cursorsComponent.updateSync(state) - @highlightsComponent.updateSync(state) - - @oldState.indentGuidesVisible = @newState.indentGuidesVisible - @oldState.scrollWidth = @newState.scrollWidth - - removeLineNodes: -> - @removeLineNode(id) for id of @oldState.lines - return - - removeLineNode: (id) -> - @lineNodesByLineId[id].remove() - delete @lineNodesByLineId[id] - delete @lineIdsByScreenRow[@screenRowsByLineId[id]] - delete @screenRowsByLineId[id] - delete @oldState.lines[id] - - updateLineNodes: -> - for id of @oldState.lines - unless @newState.lines.hasOwnProperty(id) - @removeLineNode(id) - - newLineIds = null - newLinesHTML = null - - for id, lineState of @newState.lines - if @oldState.lines.hasOwnProperty(id) - @updateLineNode(id) - else - newLineIds ?= [] - newLinesHTML ?= "" - newLineIds.push(id) - newLinesHTML += @buildLineHTML(id) - @screenRowsByLineId[id] = lineState.screenRow - @lineIdsByScreenRow[lineState.screenRow] = id - @oldState.lines[id] = cloneObject(lineState) - - return unless newLineIds? - - WrapperDiv.innerHTML = newLinesHTML - newLineNodes = _.toArray(WrapperDiv.children) - for id, i in newLineIds - lineNode = newLineNodes[i] - @lineNodesByLineId[id] = lineNode - @domNode.appendChild(lineNode) - - return - - buildLineHTML: (id) -> - {scrollWidth} = @newState - {screenRow, tokens, text, top, lineEnding, fold, isSoftWrapped, indentLevel, decorationClasses} = @newState.lines[id] - - classes = '' - if decorationClasses? - for decorationClass in decorationClasses - classes += decorationClass + ' ' - classes += 'line' - - lineHTML = "
" - - if text is "" - lineHTML += @buildEmptyLineInnerHTML(id) - else - lineHTML += @buildLineInnerHTML(id) - - lineHTML += '' if fold - lineHTML += "
" - lineHTML - - buildEmptyLineInnerHTML: (id) -> - {indentGuidesVisible} = @newState - {indentLevel, tabLength, endOfLineInvisibles} = @newState.lines[id] - - if indentGuidesVisible and indentLevel > 0 - invisibleIndex = 0 - lineHTML = '' - for i in [0...indentLevel] - lineHTML += "" - for j in [0...tabLength] - if invisible = endOfLineInvisibles?[invisibleIndex++] - lineHTML += "#{invisible}" - else - lineHTML += ' ' - lineHTML += "" - - while invisibleIndex < endOfLineInvisibles?.length - lineHTML += "#{endOfLineInvisibles[invisibleIndex++]}" - - lineHTML - else - @buildEndOfLineHTML(id) or ' ' - - buildLineInnerHTML: (id) -> - lineState = @newState.lines[id] - {firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, invisibles} = lineState - lineIsWhitespaceOnly = firstTrailingWhitespaceIndex is 0 - - innerHTML = "" - @tokenIterator.reset(lineState) - - while @tokenIterator.next() - for scope in @tokenIterator.getScopeEnds() - innerHTML += "" - - for scope in @tokenIterator.getScopeStarts() - innerHTML += "" - - tokenStart = @tokenIterator.getScreenStart() - tokenEnd = @tokenIterator.getScreenEnd() - tokenText = @tokenIterator.getText() - isHardTab = @tokenIterator.isHardTab() - - if hasLeadingWhitespace = tokenStart < firstNonWhitespaceIndex - tokenFirstNonWhitespaceIndex = firstNonWhitespaceIndex - tokenStart - else - tokenFirstNonWhitespaceIndex = null - - if hasTrailingWhitespace = tokenEnd > firstTrailingWhitespaceIndex - tokenFirstTrailingWhitespaceIndex = Math.max(0, firstTrailingWhitespaceIndex - tokenStart) - else - tokenFirstTrailingWhitespaceIndex = null - - hasIndentGuide = - @newState.indentGuidesVisible and - (hasLeadingWhitespace or lineIsWhitespaceOnly) - - hasInvisibleCharacters = - (invisibles?.tab and isHardTab) or - (invisibles?.space and (hasLeadingWhitespace or hasTrailingWhitespace)) - - innerHTML += @buildTokenHTML(tokenText, isHardTab, tokenFirstNonWhitespaceIndex, tokenFirstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) - - for scope in @tokenIterator.getScopeEnds() - innerHTML += "" - - for scope in @tokenIterator.getScopes() - innerHTML += "" - - innerHTML += @buildEndOfLineHTML(id) - innerHTML - - buildTokenHTML: (tokenText, isHardTab, firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) -> - if isHardTab - classes = 'hard-tab' - classes += ' leading-whitespace' if firstNonWhitespaceIndex? - classes += ' trailing-whitespace' if firstTrailingWhitespaceIndex? - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if hasInvisibleCharacters - return "#{@escapeTokenText(tokenText)}" - else - startIndex = 0 - endIndex = tokenText.length - - leadingHtml = '' - trailingHtml = '' - - if firstNonWhitespaceIndex? - leadingWhitespace = tokenText.substring(0, firstNonWhitespaceIndex) - - classes = 'leading-whitespace' - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if hasInvisibleCharacters - - leadingHtml = "#{leadingWhitespace}" - startIndex = firstNonWhitespaceIndex - - if firstTrailingWhitespaceIndex? - tokenIsOnlyWhitespace = firstTrailingWhitespaceIndex is 0 - trailingWhitespace = tokenText.substring(firstTrailingWhitespaceIndex) - - classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not firstNonWhitespaceIndex? and tokenIsOnlyWhitespace - classes += ' invisible-character' if hasInvisibleCharacters - - trailingHtml = "#{trailingWhitespace}" - - endIndex = firstTrailingWhitespaceIndex - - html = leadingHtml - if tokenText.length > MaxTokenLength - while startIndex < endIndex - html += "" + @escapeTokenText(tokenText, startIndex, startIndex + MaxTokenLength) + "" - startIndex += MaxTokenLength - else - html += @escapeTokenText(tokenText, startIndex, endIndex) - - html += trailingHtml - html - - escapeTokenText: (tokenText, startIndex, endIndex) -> - if startIndex? and endIndex? and startIndex > 0 or endIndex < tokenText.length - tokenText = tokenText.slice(startIndex, endIndex) - tokenText.replace(TokenTextEscapeRegex, @escapeTokenTextReplace) - - escapeTokenTextReplace: (match) -> - switch match - when '&' then '&' - when '"' then '"' - when "'" then ''' - when '<' then '<' - when '>' then '>' - else match - - buildEndOfLineHTML: (id) -> - {endOfLineInvisibles} = @newState.lines[id] - - html = '' - if endOfLineInvisibles? - for invisible in endOfLineInvisibles - html += "#{invisible}" - html - - updateLineNode: (id) -> - oldLineState = @oldState.lines[id] - newLineState = @newState.lines[id] - - lineNode = @lineNodesByLineId[id] - - if @newState.scrollWidth isnt @oldState.scrollWidth - lineNode.style.width = @newState.scrollWidth + 'px' - - newDecorationClasses = newLineState.decorationClasses - oldDecorationClasses = oldLineState.decorationClasses - - if oldDecorationClasses? - for decorationClass in oldDecorationClasses - unless newDecorationClasses? and decorationClass in newDecorationClasses - lineNode.classList.remove(decorationClass) - - if newDecorationClasses? - for decorationClass in newDecorationClasses - unless oldDecorationClasses? and decorationClass in oldDecorationClasses - lineNode.classList.add(decorationClass) - - oldLineState.decorationClasses = newLineState.decorationClasses - - if newLineState.top isnt oldLineState.top - lineNode.style.top = newLineState.top + 'px' - oldLineState.top = newLineState.cop - - if newLineState.screenRow isnt oldLineState.screenRow - lineNode.dataset.screenRow = newLineState.screenRow - oldLineState.screenRow = newLineState.screenRow - @lineIdsByScreenRow[newLineState.screenRow] = id - - lineNodeForScreenRow: (screenRow) -> - @lineNodesByLineId[@lineIdsByScreenRow[screenRow]] - - measureLineHeightAndDefaultCharWidth: -> - @domNode.appendChild(DummyLineNode) - lineHeightInPixels = DummyLineNode.getBoundingClientRect().height - charWidth = DummyLineNode.firstChild.getBoundingClientRect().width - @domNode.removeChild(DummyLineNode) - - @presenter.setLineHeight(lineHeightInPixels) - @presenter.setBaseCharacterWidth(charWidth) - - remeasureCharacterWidths: -> - return unless @presenter.baseCharacterWidth - - @clearScopedCharWidths() - @measureCharactersInNewLines() - - measureCharactersInNewLines: -> - @presenter.batchCharacterMeasurement => - for id, lineState of @oldState.lines - unless @measuredLines.has(id) - lineNode = @lineNodesByLineId[id] - @measureCharactersInLine(id, lineState, lineNode) - return - - measureCharactersInLine: (lineId, tokenizedLine, lineNode) -> - rangeForMeasurement = null - iterator = null - charIndex = 0 - - @tokenIterator.reset(tokenizedLine) - while @tokenIterator.next() - scopes = @tokenIterator.getScopes() - text = @tokenIterator.getText() - charWidths = @presenter.getScopedCharacterWidths(scopes) - - textIndex = 0 - while textIndex < text.length - if @tokenIterator.isPairedCharacter() - char = text - charLength = 2 - textIndex += 2 - else - char = text[textIndex] - charLength = 1 - textIndex++ - - continue if char is '\0' - - unless charWidths[char]? - unless textNode? - rangeForMeasurement ?= document.createRange() - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - textNode = iterator.nextNode() - textNodeIndex = 0 - nextTextNodeIndex = textNode.textContent.length - - while nextTextNodeIndex <= charIndex - textNode = iterator.nextNode() - textNodeIndex = nextTextNodeIndex - nextTextNodeIndex = textNodeIndex + textNode.textContent.length - - i = charIndex - textNodeIndex - rangeForMeasurement.setStart(textNode, i) - rangeForMeasurement.setEnd(textNode, i + charLength) - charWidth = rangeForMeasurement.getBoundingClientRect().width - @presenter.setScopedCharacterWidth(scopes, char, charWidth) - - charIndex += charLength - - @measuredLines.add(lineId) - - clearScopedCharWidths: -> - @measuredLines.clear() - @presenter.clearScopedCharacterWidths() diff --git a/src/main-process/application-menu.js b/src/main-process/application-menu.js new file mode 100644 index 00000000000..7a8d6d9599d --- /dev/null +++ b/src/main-process/application-menu.js @@ -0,0 +1,254 @@ +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( + ({ id }) => id === 'Check for Update' + ); + const checkingForUpdateItem = items.find( + ({ id }) => id === 'Checking for Update' + ); + const downloadingUpdateItem = items.find( + ({ id }) => id === 'Downloading Update' + ); + const installUpdateItem = items.find( + ({ id }) => id === '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', + id: 'Atom', + submenu: [ + { + label: 'Check for Update', + id: 'Check for Update', + metadata: { autoUpdate: true } + }, + { + label: 'Reload', + id: 'Reload', + accelerator: 'Command+R', + click: () => { + const window = this.focusedWindow(); + if (window) window.reload(); + } + }, + { + label: 'Close Window', + id: 'Close Window', + accelerator: 'Command+Shift+W', + click: () => { + const window = this.focusedWindow(); + if (window) window.close(); + } + }, + { + label: 'Toggle Dev Tools', + id: 'Toggle Dev Tools', + accelerator: 'Command+Alt+I', + click: () => { + const window = this.focusedWindow(); + if (window) window.toggleDevTools(); + } + }, + { + label: 'Quit', + id: '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) { + const keystrokes = keystrokesByCommand[item.command]; + if (keystrokes && keystrokes.length > 0) { + const keystroke = keystrokes[0]; + // Electron does not support multi-keystroke accelerators. Therefore, + // when the command maps to a multi-stroke key binding, show the + // keystrokes next to the item's label. + if (keystroke.includes(' ')) { + item.label += ` [${_.humanizeKeystroke(keystroke)}]`; + } else { + item.accelerator = MenuHelpers.acceleratorForKeystroke(keystroke); + } + } + item.click = () => + global.atomApplication.sendCommand(item.command, item.commandDetail); + if (!/^application:/.test(item.command)) { + item.metadata.windowSpecific = true; + } + } + if (item.submenu) + this.translateTemplate(item.submenu, keystrokesByCommand); + }); + return template; + } +}; diff --git a/src/main-process/atom-application.js b/src/main-process/atom-application.js new file mode 100644 index 00000000000..f05d4924496 --- /dev/null +++ b/src/main-process/atom-application.js @@ -0,0 +1,2115 @@ +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 ConfigFile = require('../config-file'); +const FileRecoveryService = require('./file-recovery-service'); +const StartupTime = require('../startup-time'); +const ipcHelpers = require('../ipc-helpers'); +const { + BrowserWindow, + Menu, + app, + clipboard, + 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 { promisify } = require('util'); +const { EventEmitter } = require('events'); +const _ = require('underscore-plus'); +let FindParentDir = null; +let Resolve = null; +const ConfigSchema = require('../config-schema'); + +const LocationSuffixRegExp = /(:\d+)(:\d+)?$/; + +// Increment this when changing the serialization format of `${ATOM_HOME}/storage/application.json` used by +// AtomApplication::saveCurrentWindowOptions() and AtomApplication::loadPreviousWindowOptions() in a backward- +// incompatible way. +const APPLICATION_STATE_VERSION = '1'; + +const getDefaultPath = () => { + const editor = atom.workspace.getActiveTextEditor(); + if (!editor || !editor.getPath()) { + return; + } + const paths = atom.project.getPaths(); + if (paths) { + return paths[0]; + } +}; + +const getSocketSecretPath = atomVersion => { + const { username } = os.userInfo(); + const atomHome = path.resolve(process.env.ATOM_HOME); + + return path.join(atomHome, `.atom-socket-secret-${username}-${atomVersion}`); +}; + +const getSocketPath = socketSecret => { + if (!socketSecret) { + return null; + } + + // Hash the secret to create the socket name to not expose it. + const socketName = crypto + .createHmac('sha256', socketSecret) + .update('socketName') + .digest('hex') + .substr(0, 12); + + if (process.platform === 'win32') { + return `\\\\.\\pipe\\atom-${socketName}-sock`; + } else { + return path.join(os.tmpdir(), `atom-${socketName}.sock`); + } +}; + +const getExistingSocketSecret = atomVersion => { + const socketSecretPath = getSocketSecretPath(atomVersion); + + if (!fs.existsSync(socketSecretPath)) { + return null; + } + + return fs.readFileSync(socketSecretPath, 'utf8'); +}; + +const getRandomBytes = promisify(crypto.randomBytes); +const writeFile = promisify(fs.writeFile); + +const createSocketSecret = async atomVersion => { + const socketSecret = (await getRandomBytes(16)).toString('hex'); + + await writeFile(getSocketSecretPath(atomVersion), socketSecret, { + encoding: 'utf8', + mode: 0o600 + }); + + return socketSecret; +}; + +const encryptOptions = (options, secret) => { + const message = JSON.stringify(options); + const initVector = crypto.randomBytes(16); // AES uses 16 bytes for iV + const cipher = crypto.createCipheriv('aes-256-gcm', secret, initVector); + + let content = cipher.update(message, 'utf8', 'hex'); + content += cipher.final('hex'); + + const authTag = cipher.getAuthTag().toString('hex'); + + return JSON.stringify({ + authTag, + content, + initVector: initVector.toString('hex') + }); +}; + +const decryptOptions = (optionsMessage, secret) => { + const { authTag, content, initVector } = JSON.parse(optionsMessage); + + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + secret, + Buffer.from(initVector, 'hex') + ); + decipher.setAuthTag(Buffer.from(authTag, 'hex')); + + let message = decipher.update(content, 'hex', 'utf8'); + message += decipher.final('utf8'); + + return JSON.parse(message); +}; + +ipcMain.handle('isDefaultProtocolClient', (_, { protocol, path, args }) => { + return app.isDefaultProtocolClient(protocol, path, args); +}); + +ipcMain.handle('setAsDefaultProtocolClient', (_, { protocol, path, args }) => { + return app.setAsDefaultProtocolClient(protocol, path, args); +}); +// 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) { + StartupTime.addMarker('main-process:atom-application:open'); + + const socketSecret = getExistingSocketSecret(options.version); + const socketPath = getSocketPath(socketSecret); + const createApplication = + options.createApplication || + (async () => { + const app = new AtomApplication(options); + await app.initialize(options); + return app; + }); + + // 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 ( + !socketPath || + options.test || + options.benchmark || + options.benchmarkTest || + (process.platform !== 'win32' && !fs.existsSync(socketPath)) + ) { + return createApplication(options); + } + + return new Promise(resolve => { + const client = net.connect({ path: socketPath }, () => { + client.write(encryptOptions(options, socketSecret), () => { + client.end(); + app.quit(); + resolve(null); + }); + }); + + client.on('error', () => resolve(createApplication(options))); + }); + } + + exit(status) { + app.exit(status); + } + + constructor(options) { + StartupTime.addMarker('main-process:atom-application:constructor:start'); + + super(); + this.quitting = false; + this.quittingForUpdate = 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.logFile = options.logFile; + this.userDataDir = options.userDataDir; + this._killProcess = options.killProcess || process.kill.bind(process); + this.waitSessionsByWindow = new Map(); + this.windowStack = new WindowStack(); + + this.initializeAtomHome(process.env.ATOM_HOME); + + const configFilePath = fs.existsSync( + path.join(process.env.ATOM_HOME, 'config.json') + ) + ? path.join(process.env.ATOM_HOME, 'config.json') + : path.join(process.env.ATOM_HOME, 'config.cson'); + + this.configFile = ConfigFile.at(configFilePath); + this.config = new Config({ + saveCallback: settings => { + if (!this.quitting) { + return this.configFile.update(settings); + } + } + }); + this.config.setSchema(null, { + type: 'object', + properties: _.clone(ConfigSchema) + }); + + 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(); + + StartupTime.addMarker('main-process:atom-application:constructor:end'); + } + + // 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. + async initialize(options) { + StartupTime.addMarker('main-process:atom-application:initialize:start'); + + global.atomApplication = this; + + this.applicationMenu = new ApplicationMenu( + this.version, + this.autoUpdateManager + ); + this.atomProtocolHandler = new AtomProtocolHandler( + this.resourcePath, + this.safeMode + ); + + let socketServerPromise; + if (options.test || options.benchmark || options.benchmarkTest) { + socketServerPromise = Promise.resolve(); + } else { + socketServerPromise = this.listenForArgumentsFromNewProcess(); + } + + await socketServerPromise; + this.setupDockMenu(); + + const result = await this.launch(options); + this.autoUpdateManager.initialize(); + + StartupTime.addMarker('main-process:atom-application:initialize:end'); + + return result; + } + + async destroy() { + const windowsClosePromises = this.getAllWindows().map(window => { + window.close(); + return window.closedPromise; + }); + await Promise.all(windowsClosePromises); + this.disposable.dispose(); + } + + async launch(options) { + if (!this.configFilePromise) { + this.configFilePromise = this.configFile.watch().then(disposable => { + this.disposable.add(disposable); + this.config.onDidChange('core.titleBar', () => this.promptForRestart()); + this.config.onDidChange('core.colorProfile', () => + this.promptForRestart() + ); + }); + await this.configFilePromise; + } + + let optionsForWindowsToOpen = []; + let shouldReopenPreviousWindows = false; + + if (options.test || options.benchmark || options.benchmarkTest) { + optionsForWindowsToOpen.push(options); + } else if (options.newWindow) { + shouldReopenPreviousWindows = false; + } else if ( + (options.pathsToOpen && options.pathsToOpen.length > 0) || + (options.urlsToOpen && options.urlsToOpen.length > 0) + ) { + optionsForWindowsToOpen.push(options); + shouldReopenPreviousWindows = + this.config.get('core.restorePreviousWindowsOnStart') === 'always'; + } else { + shouldReopenPreviousWindows = + this.config.get('core.restorePreviousWindowsOnStart') !== 'no'; + } + + if (shouldReopenPreviousWindows) { + optionsForWindowsToOpen = [ + ...(await this.loadPreviousWindowOptions()), + ...optionsForWindowsToOpen + ]; + } + + if (optionsForWindowsToOpen.length === 0) { + optionsForWindowsToOpen.push(options); + } + + // Preserve window opening order + const windows = []; + for (const options of optionsForWindowsToOpen) { + windows.push(await this.openWithOptions(options)); + } + return windows; + } + + openWithOptions(options) { + const { + pathsToOpen, + executedFrom, + foldersToOpen, + urlsToOpen, + benchmark, + benchmarkTest, + test, + pidToKillWhenClosed, + devMode, + safeMode, + newWindow, + logFile, + profileStartup, + timeout, + clearWindowState, + addToLastWindow, + preserveFocus, + env + } = options; + + if (!preserveFocus) { + 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 && pathsToOpen.length > 0) || + (foldersToOpen && foldersToOpen.length > 0) + ) { + return this.openPaths({ + pathsToOpen, + foldersToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }); + } else if (urlsToOpen && urlsToOpen.length > 0) { + return Promise.all( + urlsToOpen.map(urlToOpen => + this.openUrl({ urlToOpen, devMode, safeMode, env }) + ) + ); + } else { + // Always open an editor window if this is the first instance of Atom. + return this.openPath({ + pathToOpen: null, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }); + } + } + + // Public: Create a new {AtomWindow} bound to this application. + createWindow(settings) { + return new AtomWindow(this, this.fileRecoveryService, settings); + } + + // Public: Removes the {AtomWindow} from the global window list. + removeWindow(window) { + this.windowStack.removeWindow(window); + if (this.getAllWindows().length === 0 && process.platform !== 'darwin') { + app.quit(); + return; + } + if (!window.isSpec) this.saveCurrentWindowOptions(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.saveCurrentWindowOptions(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); + this.saveCurrentWindowOptions(false); + } + } + + 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. + async listenForArgumentsFromNewProcess() { + this.socketSecretPromise = createSocketSecret(this.version); + this.socketSecret = await this.socketSecretPromise; + this.socketPath = getSocketPath(this.socketSecret); + + await this.deleteSocketFile(); + + const server = net.createServer(connection => { + let data = ''; + connection.on('data', chunk => { + data += chunk; + }); + connection.on('end', () => { + try { + const options = decryptOptions(data, this.socketSecret); + this.openWithOptions(options); + } catch (e) { + // Error while parsing/decrypting the options passed by the client. + // We cannot trust the client, aborting. + } + }); + }); + + return new Promise(resolve => { + server.listen(this.socketPath, resolve); + server.on('error', error => + console.error('Application server failed', error) + ); + }); + } + + async deleteSocketFile() { + if (process.platform === 'win32') return; + + if (!this.socketSecretPromise) { + return; + } + await this.socketSecretPromise; + + 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; + } + } + } + + async deleteSocketSecretFile() { + if (!this.socketSecretPromise) { + return; + } + await this.socketSecretPromise; + + const socketSecretPath = getSocketSecretPath(this.version); + + if (fs.existsSync(socketSecretPath)) { + try { + fs.unlinkSync(socketSecretPath); + } catch (error) { + // Ignore ENOENT errors in case the file was deleted between the exists + // check and the call to unlink sync. + if (error.code !== 'ENOENT') throw error; + } + } + } + + // Registers basic application commands, non-idempotent. + handleEvents() { + const createOpenSettings = ({ event, sameWindow }) => { + const targetWindow = event + ? this.atomWindowForEvent(event) + : this.focusedWindow(); + return { + devMode: targetWindow ? targetWindow.devMode : false, + safeMode: targetWindow ? targetWindow.safeMode : false, + window: sameWindow && targetWindow ? targetWindow : null + }; + }; + + this.on('application:quit', () => app.quit()); + this.on('application:new-window', () => + this.openPath(createOpenSettings({})) + ); + 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://github.com/atom/atom/discussions') + ); + 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.quittingForUpdate = true; + this.autoUpdateManager.install(); + }); + + this.on('application:check-for-update', () => + this.autoUpdateManager.check() + ); + + if (process.platform === 'darwin') { + this.on('application:reopen-project', ({ paths }) => { + const focusedWindow = this.focusedWindow(); + if (focusedWindow) { + const { safeMode, devMode } = focusedWindow; + this.openPaths({ pathsToOpen: paths, safeMode, devMode }); + return; + } + this.openPaths({ pathsToOpen: paths }); + }); + + this.on('application:open', () => { + this.promptForPathToOpen( + 'all', + createOpenSettings({ sameWindow: true }), + getDefaultPath() + ); + }); + this.on('application:open-file', () => { + this.promptForPathToOpen( + 'file', + createOpenSettings({ sameWindow: true }), + getDefaultPath() + ); + }); + this.on('application:open-folder', () => { + this.promptForPathToOpen( + 'folder', + createOpenSettings({ sameWindow: true }), + getDefaultPath() + ); + }); + + 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.configFile.onDidChange(settings => { + for (let window of this.getAllWindows()) { + window.didChangeUserSettings(settings); + } + this.config.resetUserSettings(settings); + }); + + this.configFile.onDidError(message => { + const window = this.focusedWindow() || this.getLastFocusedWindow(); + if (window) { + window.didFailToReadUserSettings(message); + } else { + console.error(message); + } + }); + + 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( + async window => { + const unloaded = await window.prepareToUnload(); + if (unloaded) { + window.close(); + await window.closedPromise; + } + return unloaded; + } + ); + const windowUnloadedResults = await Promise.all(windowUnloadPromises); + if (windowUnloadedResults.every(Boolean)) { + app.quit(); + } else { + this.quitting = false; + } + } + + resolveBeforeQuitPromise(); + }) + ); + + this.disposable.add( + ipcHelpers.on(app, 'will-quit', () => { + this.killAllProcesses(); + + return Promise.all([ + this.deleteSocketFile(), + this.deleteSocketSecretFile() + ]); + }) + ); + + // See: https://www.electronjs.org/docs/api/app#event-window-all-closed + this.disposable.add( + ipcHelpers.on(app, 'window-all-closed', () => { + if (this.applicationMenu != null) { + this.applicationMenu.enableWindowSpecificItems(false); + } + // Don't quit when the last window is closed on macOS. + if (process.platform !== 'darwin') { + app.quit(); + } + }) + ); + + // Triggered by the 'open-file' event from Electron: + // https://electronjs.org/docs/api/app#event-open-file-macos + // For example, this is fired when a file is dragged and dropped onto the Atom application icon in the dock. + 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', async (event, requestId, url) => { + const proxy = await event.sender.session.resolveProxy(url); + 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 set of paths using the standard window location logic. + // Used for application:reopen-project. + this.disposable.add( + ipcHelpers.on(ipcMain, 'open', (event, options) => { + if (options) { + if (typeof options.pathsToOpen === 'string') { + options.pathsToOpen = [options.pathsToOpen]; + } + + if (options.here) { + options.window = this.atomWindowForEvent(event); + } + + if (options.pathsToOpen && options.pathsToOpen.length > 0) { + this.openPaths(options); + } else { + this.addWindow(this.createWindow(options)); + } + } else { + this.promptForPathToOpen('all', {}); + } + }) + ); + + // Prompt for a file, folder, or either, then open the chosen paths. Files will be opened in the originating + // window; folders will be opened in a new window unless an existing window exactly contains all of them. + this.disposable.add( + ipcHelpers.on(ipcMain, 'open-chosen-any', (event, defaultPath) => { + this.promptForPathToOpen( + 'all', + createOpenSettings({ event, sameWindow: true }), + defaultPath + ); + }) + ); + this.disposable.add( + ipcHelpers.on(ipcMain, 'open-chosen-file', (event, defaultPath) => { + this.promptForPathToOpen( + 'file', + createOpenSettings({ event, sameWindow: true }), + defaultPath + ); + }) + ); + this.disposable.add( + ipcHelpers.on(ipcMain, 'open-chosen-folder', (event, defaultPath) => { + this.promptForPathToOpen( + 'folder', + createOpenSettings({ event }), + defaultPath + ); + }) + ); + + 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, options = {}) => { + this.runTests( + Object.assign( + { + resourcePath: this.devResourcePath, + pathsToOpen: [packageSpecPath], + headless: false + }, + options + ) + ); + } + ) + ); + + 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, 'window-command', (event, command, ...args) => { + const window = BrowserWindow.fromWebContents(event.sender); + return window && 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( + 'set-user-settings', + (window, settings, filePath) => { + if (!this.quitting) { + return ConfigFile.at(filePath || this.configFilePath).update( + JSON.parse(settings) + ); + } + } + ) + ); + + 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; + }) + ); + + 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.respondTo('will-save-path', (window, path) => + this.fileRecoveryService.willSavePath(window, path) + ) + ); + + this.disposable.add( + ipcHelpers.respondTo('did-save-path', (window, path) => + this.fileRecoveryService.didSavePath(window, path) + ) + ); + + 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') + } + ]) + ); + } + } + + initializeAtomHome(configDirPath) { + if (!fs.existsSync(configDirPath)) { + const templateConfigDirPath = fs.resolve(this.resourcePath, 'dot-atom'); + fs.copySync(templateConfigDirPath, configDirPath); + } + } + + // 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 locations. + windowForLocations(locationsToOpen, devMode, safeMode) { + return this.getLastFocusedWindow( + window => + !window.isSpec && + window.devMode === devMode && + window.safeMode === safeMode && + window.containsLocations(locationsToOpen) + ); + } + + // 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({ + pathToOpen, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + return this.openPaths({ + 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 + // :foldersToOpen - An array of additional paths to open that must be existing directories + // :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. + async openPaths({ + pathsToOpen, + foldersToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + windowDimensions, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + if (!env) env = process.env; + if (!pathsToOpen) pathsToOpen = []; + if (!foldersToOpen) foldersToOpen = []; + + devMode = Boolean(devMode); + safeMode = Boolean(safeMode); + clearWindowState = Boolean(clearWindowState); + + const locationsToOpen = await Promise.all( + pathsToOpen.map(pathToOpen => + this.parsePathToOpen(pathToOpen, executedFrom, { + hasWaitSession: pidToKillWhenClosed != null + }) + ) + ); + + for (const folderToOpen of foldersToOpen) { + locationsToOpen.push({ + pathToOpen: folderToOpen, + initialLine: null, + initialColumn: null, + exists: true, + isDirectory: true, + hasWaitSession: pidToKillWhenClosed != null + }); + } + + if (locationsToOpen.length === 0) { + return; + } + + const hasNonEmptyPath = locationsToOpen.some( + location => location.pathToOpen + ); + const createNewWindow = newWindow || !hasNonEmptyPath; + + let existingWindow; + + if (!createNewWindow) { + // An explicitly provided AtomWindow has precedence. + existingWindow = window; + + // If no window is specified and at least one path is provided, locate an existing window that contains all + // provided paths. + if (!existingWindow && hasNonEmptyPath) { + existingWindow = this.windowForLocations( + locationsToOpen, + devMode, + safeMode + ); + } + + // No window specified, no existing window found, and addition to the last window requested. Find the last + // focused window that matches the requested dev and safe modes. + if (!existingWindow && addToLastWindow) { + existingWindow = this.getLastFocusedWindow(win => { + return ( + !win.isSpec && win.devMode === devMode && win.safeMode === safeMode + ); + }); + } + + // Fall back to the last focused window that has no project roots. + if (!existingWindow) { + existingWindow = this.getLastFocusedWindow( + win => !win.isSpec && !win.hasProjectPaths() + ); + } + + // One last case: if *no* paths are directories, add to the last focused window. + if (!existingWindow) { + const noDirectories = locationsToOpen.every( + location => !location.isDirectory + ); + if (noDirectories) { + existingWindow = this.getLastFocusedWindow(win => { + return ( + !win.isSpec && + win.devMode === devMode && + win.safeMode === safeMode + ); + }); + } + } + } + + let openedWindow; + if (existingWindow) { + openedWindow = existingWindow; + StartupTime.addMarker('main-process:atom-application:open-in-existing'); + 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(); + + StartupTime.addMarker('main-process:atom-application:create-window'); + openedWindow = this.createWindow({ + 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( + locationsToOpen.map(location => location.pathToOpen).filter(Boolean) + ) + }); + } + + 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 + }` + ); + } + } + } + + async saveCurrentWindowOptions(allowEmpty = false) { + if (this.quitting) return; + + const windows = this.getAllWindows(); + const hasASpecWindow = windows.some(window => window.isSpec); + + if (windows.length === 1 && hasASpecWindow) return; + + const state = { + version: APPLICATION_STATE_VERSION, + windows: windows + .filter(window => !window.isSpec) + .map(window => ({ projectRoots: window.projectRoots })) + }; + state.windows.reverse(); + + if (state.windows.length > 0 || allowEmpty) { + await this.storageFolder.store('application.json', state); + this.emit('application:did-save-state'); + } + } + + async loadPreviousWindowOptions() { + const state = await this.storageFolder.load('application.json'); + if (!state) { + return []; + } + + if (state.version === APPLICATION_STATE_VERSION) { + // Atom >=1.36.1 + // Schema: {version: '1', windows: [{projectRoots: ['', ...]}, ...]} + return state.windows.map(each => ({ + foldersToOpen: each.projectRoots, + devMode: this.devMode, + safeMode: this.safeMode, + newWindow: true + })); + } else if (state.version === undefined) { + // Atom <= 1.36.0 + // Schema: [{initialPaths: ['', ...]}, ...] + return Promise.all( + state.map(async windowState => { + // Classify each window's initialPaths as directories or non-directories + const classifiedPaths = await Promise.all( + windowState.initialPaths.map( + initialPath => + new Promise(resolve => { + fs.isDirectory(initialPath, isDir => + resolve({ initialPath, isDir }) + ); + }) + ) + ); + + // Only accept initialPaths that are existing directories + return { + foldersToOpen: classifiedPaths + .filter(({ isDir }) => isDir) + .map(({ initialPath }) => initialPath), + devMode: this.devMode, + safeMode: this.safeMode, + newWindow: true + }; + }) + ); + } else { + // Unrecognized version (from a newer Atom?) + return []; + } + } + + // 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(); + return bestWindow; + } 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 = this.createWindow({ + 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 = this.createWindow({ + 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 = this.createWindow({ + windowInitializationScript, + resourcePath, + headless, + isSpec, + devMode, + testRunnerPath, + legacyTestRunnerPath, + testPaths, + logFile, + safeMode, + env + }); + this.addWindow(window); + if (env) window.replaceEnvironment(env); + 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 = this.createWindow({ + 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') + ); + } + } + + async parsePathToOpen(pathToOpen, executedFrom, extra) { + const result = Object.assign( + { + pathToOpen, + initialColumn: null, + initialLine: null, + exists: false, + isDirectory: false, + isFile: false + }, + extra + ); + + if (!pathToOpen) { + return result; + } + + result.pathToOpen = result.pathToOpen.replace(/[:\s]+$/, ''); + const match = result.pathToOpen.match(LocationSuffixRegExp); + + if (match != null) { + result.pathToOpen = result.pathToOpen.slice(0, -match[0].length); + if (match[1]) { + result.initialLine = Math.max(0, parseInt(match[1].slice(1), 10) - 1); + } + if (match[2]) { + result.initialColumn = Math.max(0, parseInt(match[2].slice(1), 10) - 1); + } + } + + const normalizedPath = path.normalize( + path.resolve(executedFrom, fs.normalize(result.pathToOpen)) + ); + if (!url.parse(pathToOpen).protocol) { + result.pathToOpen = normalizedPath; + } + + await new Promise((resolve, reject) => { + fs.stat(result.pathToOpen, (err, st) => { + if (err) { + if (err.code === 'ENOENT' || err.code === 'EACCES') { + result.exists = false; + resolve(); + } else { + reject(err); + } + return; + } + + result.exists = true; + result.isFile = st.isFile(); + result.isDirectory = st.isDirectory(); + resolve(); + }); + }); + + return result; + } + + // 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 selected file paths as long as + // all are files. + // :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, + async pathsToOpen => { + let targetWindow; + + // Open in :window as long as no chosen paths are folders. If any chosen path is a folder, open in a + // new window instead. + if (type === 'folder') { + targetWindow = null; + } else if (type === 'file') { + targetWindow = window; + } else if (type === 'all') { + const areDirectories = await Promise.all( + pathsToOpen.map( + pathToOpen => + new Promise(resolve => fs.isDirectory(pathToOpen, resolve)) + ) + ); + if (!areDirectories.some(Boolean)) { + targetWindow = window; + } + } + + return this.openPaths({ + pathsToOpen, + devMode, + safeMode, + window: targetWindow + }); + }, + 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 an 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; + dialog + .showOpenDialog(parentWindow, openOptions) + .then(({ filePaths, bookmarks }) => { + if (typeof callback === 'function') { + callback(filePaths, bookmarks); + } + }); + } + + async promptForRestart() { + const result = await 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 (result.response === 0) this.restart(); + } + + restart() { + const args = []; + if (this.safeMode) args.push('--safe'); + if (this.logFile != null) args.push(`--log-file=${this.logFile}`); + 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.js b/src/main-process/atom-protocol-handler.js new file mode 100644 index 00000000000..173733d5c12 --- /dev/null +++ b/src/main-process/atom-protocol-handler.js @@ -0,0 +1,54 @@ +const { protocol } = require('electron'); +const fs = require('fs-plus'); +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(resourcePath, '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.js b/src/main-process/atom-window.js new file mode 100644 index 00000000000..47f41e6cb53 --- /dev/null +++ b/src/main-process/atom-window.js @@ -0,0 +1,539 @@ +const { + BrowserWindow, + app, + dialog, + ipcMain, + nativeImage +} = require('electron'); +const getAppName = require('../get-app-name'); +const path = require('path'); +const url = require('url'); +const { EventEmitter } = require('events'); +const StartupTime = require('../startup-time'); + +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 = {}) { + StartupTime.addMarker('main-process:atom-window:start'); + + 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; + + const locationsToOpen = settings.locationsToOpen || []; + + this.loadedPromise = new Promise(resolve => { + this.resolveLoadedPromise = resolve; + }); + this.closedPromise = new Promise(resolve => { + this.resolveClosedPromise = resolve; + }); + + const options = { + show: false, + title: getAppName(), + 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', + nodeIntegration: true, + webviewTag: true, + + // TodoElectronIssue: remote module is deprecated https://www.electronjs.org/docs/breaking-changes#default-changed-enableremotemodule-defaults-to-false + enableRemoteModule: true, + // node support in threads + nodeIntegrationInWorker: true + }, + simpleFullscreen: this.getSimpleFullscreen() + }; + + // 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 = nativeImage.createFromPath(ICON_PATH); + if (this.shouldAddCustomTitleBar()) options.titleBarStyle = 'hidden'; + if (this.shouldAddCustomInsetTitleBar()) + options.titleBarStyle = 'hiddenInset'; + if (this.shouldHideTitleBar()) options.frame = false; + + const BrowserWindowConstructor = + settings.browserWindowConstructor || BrowserWindow; + this.browserWindow = new BrowserWindowConstructor(options); + + Object.defineProperty(this.browserWindow, 'loadSettingsJSON', { + get: () => + JSON.stringify( + Object.assign( + { + userSettings: !this.isSpec + ? this.atomApplication.configFile.get() + : null + }, + this.loadSettings + ) + ) + }); + + this.handleEvents(); + + this.loadSettings = Object.assign({}, settings); + this.loadSettings.appVersion = app.getVersion(); + this.loadSettings.appName = getAppName(); + 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; + + this.addLocationsToOpen(locationsToOpen); + + this.loadSettings.hasOpenFiles = locationsToOpen.some( + location => location.pathToOpen && !location.isDirectory + ); + this.loadSettings.initialProjectRoots = this.projectRoots; + + StartupTime.addMarker('main-process:atom-window:end'); + + // Expose the startup markers to the renderer process, so we can have unified + // measures about startup time between the main process and the renderer process. + Object.defineProperty(this.browserWindow, 'startupMarkers', { + get: () => { + // We only want to make the main process startup data available once, + // so if the window is refreshed or a new window is opened, the + // renderer process won't use it again. + const timingData = StartupTime.exportData(); + StartupTime.deleteData(); + + return timingData; + } + }); + + // 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; + } + } + + if (!this.loadSettings.env) this.env = this.loadSettings.env; + + 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); + } + + hasProjectPaths() { + return this.projectRoots.length > 0; + } + + setupContextMenu() { + const ContextMenu = require('./context-menu'); + + this.browserWindow.on('context-menu', menuTemplate => { + return new ContextMenu(menuTemplate, this); + }); + } + + containsLocations(locations) { + return locations.every(location => this.containsLocation(location)); + } + + containsLocation(location) { + if (!location.pathToOpen) return false; + + return this.projectRoots.some(projectPath => { + if (location.pathToOpen === projectPath) return true; + if (location.pathToOpen.startsWith(path.join(projectPath, path.sep))) { + if (!location.exists) return true; + if (!location.isDirectory) return true; + } + return false; + }); + } + + handleEvents() { + this.browserWindow.on('close', async event => { + if ( + (!this.atomApplication.quitting || + this.atomApplication.quittingForUpdate) && + !this.unloading + ) { + event.preventDefault(); + this.unloading = true; + this.atomApplication.saveCurrentWindowOptions(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', async () => { + if (this.isSpec) return; + const result = await dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Force Close', 'Keep Waiting'], + cancelId: 1, // Canceling should be the least destructive action + message: 'Editor is not responding', + detail: + 'The editor is not responding. Would you like to force close it or just keep waiting?' + }); + if (result.response === 0) this.browserWindow.destroy(); + }); + + this.browserWindow.webContents.on('render-process-gone', async () => { + if (this.headless) { + console.log('Renderer process crashed, exiting'); + this.atomApplication.exit(100); + return; + } + + await this.fileRecoveryService.didCrashWindow(this); + + const result = await dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Close Window', 'Reload', 'Keep It Open'], + cancelId: 2, // Canceling should be the least destructive action + message: 'The editor has crashed', + detail: 'Please report this issue to https://github.com/atom/atom' + }); + + switch (result.response) { + case 0: + this.browserWindow.destroy(); + break; + case 1: + this.browserWindow.reload(); + break; + } + }); + + 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) { + this.addLocationsToOpen(locationsToOpen); + await this.loadedPromise; + this.sendMessage('open-locations', locationsToOpen); + } + + didChangeUserSettings(settings) { + this.sendMessage('did-change-user-settings', settings); + } + + didFailToReadUserSettings(message) { + this.sendMessage('did-fail-to-read-user-settings', message); + } + + addLocationsToOpen(locationsToOpen) { + const roots = new Set(this.projectRoots || []); + for (const { pathToOpen, isDirectory } of locationsToOpen) { + if (isDirectory) { + roots.add(pathToOpen); + } + } + + this.projectRoots = Array.from(roots); + this.projectRoots.sort(); + } + + replaceEnvironment(env) { + const { + NODE_ENV, + NODE_PATH, + ATOM_HOME, + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT + } = env; + + this.browserWindow.webContents.send('environment', { + NODE_ENV, + NODE_PATH, + ATOM_HOME, + ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT + }); + } + + 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 }; + } + + getSimpleFullscreen() { + return this.atomApplication.config.get('core.simpleFullScreenWindows'); + } + + 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 && + 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.projectRoots[0] + }, + options + ); + + let promise = dialog.showSaveDialog(this.browserWindow, options); + if (typeof callback === 'function') { + promise = promise.then(({ filePath, bookmark }) => { + callback(filePath, bookmark); + }); + } + return promise; + } + + 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); + } + + setProjectRoots(projectRootPaths) { + this.projectRoots = projectRootPaths; + this.projectRoots.sort(); + this.loadSettings.initialProjectRoots = this.projectRoots; + return this.atomApplication.saveCurrentWindowOptions(); + } + + didClosePathWithWaitSession(path) { + this.atomApplication.windowDidClosePathWithWaitSession(this, path); + } + + copy() { + return this.browserWindow.copy(); + } + + disableZoom() { + return this.browserWindow.webContents.setVisualZoomLevelLimits(1, 1); + } + + getLoadedPromise() { + return this.loadedPromise; + } +}; diff --git a/src/main-process/auto-update-manager.js b/src/main-process/auto-update-manager.js new file mode 100644 index 00000000000..f33d718d282 --- /dev/null +++ b/src/main-process/auto-update-manager.js @@ -0,0 +1,200 @@ +const { EventEmitter } = require('events'); +const os = require('os'); +const path = require('path'); + +const IdleState = 'idle'; +const CheckingState = 'checking'; +const DownloadingState = 'downloading'; +const UpdateAvailableState = 'update-available'; +const NoUpdateAvailableState = 'no-update-available'; +const UnsupportedState = 'unsupported'; +const ErrorState = 'error'; + +let autoUpdater = null; + +module.exports = class AutoUpdateManager extends EventEmitter { + constructor(version, testMode, config) { + super(); + this.onUpdateNotAvailable = this.onUpdateNotAvailable.bind(this); + this.onUpdateError = this.onUpdateError.bind(this); + this.version = version; + this.testMode = testMode; + this.config = config; + this.state = IdleState; + this.iconPath = path.resolve( + __dirname, + '..', + '..', + 'resources', + 'atom.png' + ); + this.updateUrlPrefix = + process.env.ATOM_UPDATE_URL_PREFIX || 'https://atom.io'; + } + + initialize() { + if (process.platform === 'win32') { + const archSuffix = process.arch === 'ia32' ? '' : `-${process.arch}`; + this.feedUrl = + this.updateUrlPrefix + + `/api/updates${archSuffix}?version=${this.version}&os_version=${ + os.release + }`; + autoUpdater = require('./auto-updater-win32'); + } else { + this.feedUrl = + this.updateUrlPrefix + + `/api/updates?version=${this.version}&os_version=${os.release}`; + ({ autoUpdater } = require('electron')); + } + + autoUpdater.on('error', (event, message) => { + this.setState(ErrorState, message); + this.emitWindowEvent('update-error'); + console.error(`Error Downloading Update: ${message}`); + }); + + autoUpdater.setFeedURL(this.feedUrl); + + autoUpdater.on('checking-for-update', () => { + this.setState(CheckingState); + this.emitWindowEvent('checking-for-update'); + }); + + autoUpdater.on('update-not-available', () => { + this.setState(NoUpdateAvailableState); + this.emitWindowEvent('update-not-available'); + }); + + autoUpdater.on('update-available', () => { + this.setState(DownloadingState); + // We use sendMessage to send an event called 'update-available' in 'update-downloaded' + // once the update download is complete. This mismatch between the electron + // autoUpdater events is unfortunate but in the interest of not changing the + // one existing event handled by applicationDelegate + this.emitWindowEvent('did-begin-downloading-update'); + this.emit('did-begin-download'); + }); + + autoUpdater.on( + 'update-downloaded', + (event, releaseNotes, releaseVersion) => { + this.releaseVersion = releaseVersion; + this.setState(UpdateAvailableState); + this.emitUpdateAvailableEvent(); + } + ); + + this.config.onDidChange('core.automaticallyUpdate', ({ newValue }) => { + if (newValue) { + this.scheduleUpdateCheck(); + } else { + this.cancelScheduledUpdateCheck(); + } + }); + + if (this.config.get('core.automaticallyUpdate')) this.scheduleUpdateCheck(); + + switch (process.platform) { + case 'win32': + if (!autoUpdater.supportsUpdates()) { + this.setState(UnsupportedState); + } + break; + case 'linux': + this.setState(UnsupportedState); + } + } + + emitUpdateAvailableEvent() { + if (this.releaseVersion == null) return; + this.emitWindowEvent('update-available', { + releaseVersion: this.releaseVersion + }); + } + + emitWindowEvent(eventName, payload) { + for (let atomWindow of this.getWindows()) { + atomWindow.sendMessage(eventName, payload); + } + } + + setState(state, errorMessage) { + if (this.state === state) return; + this.state = state; + this.errorMessage = errorMessage; + this.emit('state-changed', this.state); + } + + getState() { + return this.state; + } + + getErrorMessage() { + return this.errorMessage; + } + + scheduleUpdateCheck() { + // Only schedule update check periodically if running in release version and + // and there is no existing scheduled update check. + if (!/-dev/.test(this.version) && !this.checkForUpdatesIntervalID) { + const checkForUpdates = () => this.check({ hidePopups: true }); + const fourHours = 1000 * 60 * 60 * 4; + this.checkForUpdatesIntervalID = setInterval(checkForUpdates, fourHours); + checkForUpdates(); + } + } + + cancelScheduledUpdateCheck() { + if (this.checkForUpdatesIntervalID) { + clearInterval(this.checkForUpdatesIntervalID); + this.checkForUpdatesIntervalID = null; + } + } + + check({ hidePopups } = {}) { + if (!hidePopups) { + autoUpdater.once('update-not-available', this.onUpdateNotAvailable); + autoUpdater.once('error', this.onUpdateError); + } + + if (!this.testMode) autoUpdater.checkForUpdates(); + } + + install() { + if (!this.testMode) autoUpdater.quitAndInstall(); + } + + onUpdateNotAvailable() { + autoUpdater.removeListener('error', this.onUpdateError); + const { dialog } = require('electron'); + dialog.showMessageBox({ + type: 'info', + buttons: ['OK'], + icon: this.iconPath, + message: 'No update available.', + title: 'No Update Available', + detail: `Version ${this.version} is the latest version.` + }); + } + + onUpdateError(event, message) { + autoUpdater.removeListener( + 'update-not-available', + this.onUpdateNotAvailable + ); + const { dialog } = require('electron'); + dialog.showMessageBox({ + type: 'warning', + buttons: ['OK'], + icon: this.iconPath, + message: 'There was an error checking for updates.', + title: 'Update Error', + detail: message + }); + } + + getWindows() { + return global.atomApplication.getAllWindows(); + } +}; diff --git a/src/main-process/auto-updater-win32.js b/src/main-process/auto-updater-win32.js new file mode 100644 index 00000000000..99015fd4c13 --- /dev/null +++ b/src/main-process/auto-updater-win32.js @@ -0,0 +1,94 @@ +const { EventEmitter } = require('events'); +const SquirrelUpdate = require('./squirrel-update'); + +class AutoUpdater extends EventEmitter { + setFeedURL(updateUrl) { + this.updateUrl = updateUrl; + } + + quitAndInstall() { + if (SquirrelUpdate.existsSync()) { + SquirrelUpdate.restartAtom(); + } else { + require('electron').autoUpdater.quitAndInstall(); + } + } + + downloadUpdate(callback) { + SquirrelUpdate.spawn(['--download', this.updateUrl], function( + error, + stdout + ) { + let update; + if (error != null) return callback(error); + + try { + // Last line of output is the JSON details about the releases + const json = stdout + .trim() + .split('\n') + .pop(); + const data = JSON.parse(json); + const releasesToApply = data && data.releasesToApply; + if (releasesToApply.pop) update = releasesToApply.pop(); + } catch (error) { + error.stdout = stdout; + return callback(error); + } + + callback(null, update); + }); + } + + installUpdate(callback) { + SquirrelUpdate.spawn(['--update', this.updateUrl], callback); + } + + supportsUpdates() { + return SquirrelUpdate.existsSync(); + } + + checkForUpdates() { + if (!this.updateUrl) throw new Error('Update URL is not set'); + + this.emit('checking-for-update'); + + if (!SquirrelUpdate.existsSync()) { + this.emit('update-not-available'); + return; + } + + this.downloadUpdate((error, update) => { + if (error != null) { + this.emit('update-not-available'); + return; + } + + if (update == null) { + this.emit('update-not-available'); + return; + } + + this.emit('update-available'); + + this.installUpdate(error => { + if (error != null) { + this.emit('update-not-available'); + return; + } + + this.emit( + 'update-downloaded', + {}, + update.releaseNotes, + update.version, + new Date(), + 'https://atom.io', + () => this.quitAndInstall() + ); + }); + }); + } +} + +module.exports = new AutoUpdater(); diff --git a/src/main-process/context-menu.js b/src/main-process/context-menu.js new file mode 100644 index 00000000000..28af6cf357f --- /dev/null +++ b/src/main-process/context-menu.js @@ -0,0 +1,31 @@ +const { Menu } = require('electron'); + +module.exports = class ContextMenu { + constructor(template, atomWindow) { + this.atomWindow = atomWindow; + this.createClickHandlers(template); + const menu = Menu.buildFromTemplate(template); + menu.popup(this.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 + // appropriately. + createClickHandlers(template) { + template.forEach(item => { + if (item.command) { + if (!item.commandDetail) item.commandDetail = {}; + item.commandDetail.contextCommand = true; + item.click = () => { + global.atomApplication.sendCommandToWindow( + item.command, + this.atomWindow, + item.commandDetail + ); + }; + } else if (item.submenu) { + this.createClickHandlers(item.submenu); + } + }); + } +}; diff --git a/src/main-process/file-recovery-service.js b/src/main-process/file-recovery-service.js new file mode 100644 index 00000000000..c0a36dfbec7 --- /dev/null +++ b/src/main-process/file-recovery-service.js @@ -0,0 +1,183 @@ +const { dialog } = require('electron'); +const crypto = require('crypto'); +const Path = require('path'); +const fs = require('fs-plus'); +const mkdirp = require('mkdirp'); + +module.exports = class FileRecoveryService { + constructor(recoveryDirectory) { + this.recoveryDirectory = recoveryDirectory; + this.recoveryFilesByFilePath = new Map(); + this.recoveryFilesByWindow = new WeakMap(); + this.windowsByRecoveryFile = new Map(); + } + + async willSavePath(window, path) { + const stats = await tryStatFile(path); + if (!stats) return; + + const recoveryPath = Path.join( + this.recoveryDirectory, + RecoveryFile.fileNameForPath(path) + ); + const recoveryFile = + this.recoveryFilesByFilePath.get(path) || + new RecoveryFile(path, stats.mode, recoveryPath); + + try { + await recoveryFile.retain(); + } catch (err) { + console.log( + `Couldn't retain ${recoveryFile.recoveryPath}. Code: ${ + err.code + }. Message: ${err.message}` + ); + return; + } + + if (!this.recoveryFilesByWindow.has(window)) { + this.recoveryFilesByWindow.set(window, new Set()); + } + if (!this.windowsByRecoveryFile.has(recoveryFile)) { + this.windowsByRecoveryFile.set(recoveryFile, new Set()); + } + + this.recoveryFilesByWindow.get(window).add(recoveryFile); + this.windowsByRecoveryFile.get(recoveryFile).add(window); + this.recoveryFilesByFilePath.set(path, recoveryFile); + } + + async didSavePath(window, path) { + const recoveryFile = this.recoveryFilesByFilePath.get(path); + if (recoveryFile != null) { + try { + await recoveryFile.release(); + } catch (err) { + console.log( + `Couldn't release ${recoveryFile.recoveryPath}. Code: ${ + err.code + }. Message: ${err.message}` + ); + } + if (recoveryFile.isReleased()) this.recoveryFilesByFilePath.delete(path); + this.recoveryFilesByWindow.get(window).delete(recoveryFile); + this.windowsByRecoveryFile.get(recoveryFile).delete(window); + } + } + + async didCrashWindow(window) { + if (!this.recoveryFilesByWindow.has(window)) return; + + const promises = []; + for (const recoveryFile of this.recoveryFilesByWindow.get(window)) { + promises.push( + recoveryFile + .recover() + .catch(error => { + const message = 'A file that Atom was saving could be corrupted'; + const detail = + `Error ${error.code}. There was a crash while saving "${ + recoveryFile.originalPath + }", so this file might be blank or corrupted.\n` + + `Atom couldn't recover it automatically, but a recovery file has been saved at: "${ + recoveryFile.recoveryPath + }".`; + console.log(detail); + dialog.showMessageBox(window, { + type: 'info', + buttons: ['OK'], + message, + detail + }); + }) + .then(() => { + for (let window of this.windowsByRecoveryFile.get(recoveryFile)) { + this.recoveryFilesByWindow.get(window).delete(recoveryFile); + } + this.windowsByRecoveryFile.delete(recoveryFile); + this.recoveryFilesByFilePath.delete(recoveryFile.originalPath); + }) + ); + } + + await Promise.all(promises); + } + + didCloseWindow(window) { + if (!this.recoveryFilesByWindow.has(window)) return; + + for (let recoveryFile of this.recoveryFilesByWindow.get(window)) { + this.windowsByRecoveryFile.get(recoveryFile).delete(window); + } + this.recoveryFilesByWindow.delete(window); + } +}; + +class RecoveryFile { + static fileNameForPath(path) { + const extension = Path.extname(path); + const basename = Path.basename(path, extension).substring(0, 34); + const randomSuffix = crypto.randomBytes(3).toString('hex'); + return `${basename}-${randomSuffix}${extension}`; + } + + constructor(originalPath, fileMode, recoveryPath) { + this.originalPath = originalPath; + this.fileMode = fileMode; + this.recoveryPath = recoveryPath; + this.refCount = 0; + } + + async store() { + await copyFile(this.originalPath, this.recoveryPath, this.fileMode); + } + + async recover() { + await copyFile(this.recoveryPath, this.originalPath, this.fileMode); + await this.remove(); + } + + async remove() { + return new Promise((resolve, reject) => + fs.unlink(this.recoveryPath, error => + error && error.code !== 'ENOENT' ? reject(error) : resolve() + ) + ); + } + + async retain() { + if (this.isReleased()) await this.store(); + this.refCount++; + } + + async release() { + this.refCount--; + if (this.isReleased()) await this.remove(); + } + + isReleased() { + return this.refCount === 0; + } +} + +async function tryStatFile(path) { + return new Promise((resolve, reject) => + fs.stat(path, (error, result) => resolve(error == null && result)) + ); +} + +async function copyFile(source, destination, mode) { + return new Promise((resolve, reject) => { + mkdirp(Path.dirname(destination), error => { + if (error) return reject(error); + const readStream = fs.createReadStream(source); + readStream.on('error', reject).once('open', () => { + const writeStream = fs.createWriteStream(destination, { mode }); + writeStream + .on('error', reject) + .on('open', () => readStream.pipe(writeStream)) + .once('close', () => resolve()); + }); + }); + }); +} diff --git a/src/main-process/main.js b/src/main-process/main.js new file mode 100644 index 00000000000..28b6f3f266f --- /dev/null +++ b/src/main-process/main.js @@ -0,0 +1,69 @@ +if (typeof snapshotResult !== 'undefined') { + snapshotResult.setGlobals(global, process, global, {}, console, require); +} + +const startTime = Date.now(); +const StartupTime = require('../startup-time'); +StartupTime.setStartTime(); + +const path = require('path'); +const fs = require('fs-plus'); +const CSON = require('season'); +const yargs = require('yargs'); +const { app } = require('electron'); + +const args = yargs(process.argv) + // Don't handle --help or --version here; they will be handled later. + .help(false) + .version(false) + .alias('d', 'dev') + .alias('t', 'test') + .alias('r', 'resource-path').argv; + +function isAtomRepoPath(repoPath) { + let packageJsonPath = path.join(repoPath, 'package.json'); + if (fs.statSyncNoException(packageJsonPath)) { + try { + let packageJson = CSON.readFileSync(packageJsonPath); + return packageJson.name === 'atom'; + } catch (e) { + return false; + } + } + + return false; +} + +let resourcePath; +let devResourcePath; + +if (args.resourcePath) { + resourcePath = args.resourcePath; + devResourcePath = resourcePath; +} else { + const stableResourcePath = path.dirname(path.dirname(__dirname)); + const defaultRepositoryPath = path.join( + app.getPath('home'), + 'github', + 'atom' + ); + + if (process.env.ATOM_DEV_RESOURCE_PATH) { + devResourcePath = process.env.ATOM_DEV_RESOURCE_PATH; + } else if (isAtomRepoPath(process.cwd())) { + devResourcePath = process.cwd(); + } else if (fs.statSyncNoException(defaultRepositoryPath)) { + devResourcePath = defaultRepositoryPath; + } else { + devResourcePath = stableResourcePath; + } + + if (args.dev || args.test || args.benchmark || args.benchmarkTest) { + resourcePath = devResourcePath; + } else { + resourcePath = stableResourcePath; + } +} + +const start = require(path.join(resourcePath, 'src', 'main-process', 'start')); +start(resourcePath, devResourcePath, startTime); diff --git a/src/main-process/parse-command-line.js b/src/main-process/parse-command-line.js new file mode 100644 index 00000000000..e152ee7e89d --- /dev/null +++ b/src/main-process/parse-command-line.js @@ -0,0 +1,239 @@ +'use strict'; + +const dedent = require('dedent'); +const yargs = require('yargs'); +const { app } = require('electron'); + +module.exports = function parseCommandLine(processArgs) { + // macOS Gatekeeper adds a flag ("-psn_0_[six or seven digits here]") when it intercepts Atom launches. + // (This happens for fresh downloads, new installs, or first launches after upgrading). + // We don't need this flag, and yargs interprets it as many short flags. So, we filter it out. + const filteredArgs = processArgs.filter(arg => !arg.startsWith('-psn_')); + + const options = yargs(filteredArgs).wrap(yargs.terminalWidth()); + const version = app.getVersion(); + options.usage( + dedent`Atom Editor v${version} + + Usage: + atom + atom [options] [path ...] + atom file[:line[:column]] + + One or more paths to files or folders may be specified. If there is an + existing Atom window that contains all of the given folders, the paths + will be opened in that window. Otherwise, they will be opened in a new + window. + + A file may be opened at the desired line (and optionally column) by + appending the numbers right after the file name, e.g. \`atom file:5:8\`. + + Paths that start with \`atom://\` will be interpreted as URLs. + + Environment Variables: + + ATOM_DEV_RESOURCE_PATH The path from which Atom loads source code in dev mode. + Defaults to \`~/github/atom\`. + + ATOM_HOME The root path for all configuration files and folders. + Defaults to \`~/.atom\`.` + ); + // Deprecated 1.0 API preview flag + options + .alias('1', 'one') + .boolean('1') + .describe('1', 'This option is no longer supported.'); + options + .boolean('include-deprecated-apis') + .describe( + 'include-deprecated-apis', + 'This option is not currently supported.' + ); + options + .alias('d', 'dev') + .boolean('d') + .describe('d', 'Run in development mode.'); + options + .alias('f', 'foreground') + .boolean('f') + .describe('f', 'Keep the main process in the foreground.'); + options.help('help', 'Print this usage message.').alias('h', 'help'); + options + .alias('l', 'log-file') + .string('l') + .describe('l', 'Log all output to file when running tests.'); + options + .alias('n', 'new-window') + .boolean('n') + .describe('n', 'Open a new window.'); + options + .boolean('profile-startup') + .describe( + 'profile-startup', + 'Create a profile of the startup execution time.' + ); + options + .alias('r', 'resource-path') + .string('r') + .describe( + 'r', + 'Set the path to the Atom source directory and enable dev-mode.' + ); + options + .boolean('safe') + .describe( + 'safe', + 'Do not load packages from ~/.atom/packages or ~/.atom/dev/packages.' + ); + options + .boolean('benchmark') + .describe( + 'benchmark', + 'Open a new window that runs the specified benchmarks.' + ); + options + .boolean('benchmark-test') + .describe( + 'benchmark-test', + 'Run a faster version of the benchmarks in headless mode.' + ); + options + .alias('t', 'test') + .boolean('t') + .describe( + 't', + 'Run the specified specs and exit with error code on failures.' + ); + options + .alias('m', 'main-process') + .boolean('m') + .describe('m', 'Run the specified specs in the main process.'); + options + .string('timeout') + .describe( + 'timeout', + 'When in test mode, waits until the specified time (in minutes) and kills the process (exit code: 130).' + ); + options + .alias('w', 'wait') + .boolean('w') + .describe('w', 'Wait for window to be closed before returning.'); + options + .alias('a', 'add') + .boolean('a') + .describe('add', 'Open path as a new project in last used window.'); + options.string('user-data-dir'); + options + .boolean('clear-window-state') + .describe('clear-window-state', 'Delete all Atom environment state.'); + options + .boolean('enable-electron-logging') + .describe( + 'enable-electron-logging', + 'Enable low-level logging messages from Electron.' + ); + options.boolean('uri-handler'); + options + .version( + dedent`Atom : ${version} + Electron: ${process.versions.electron} + Chrome : ${process.versions.chrome} + Node : ${process.versions.node}` + ) + .alias('v', 'version'); + + // NB: if --help or --version are given, this also displays the relevant message and exits + let args = options.argv; + + // If --uri-handler is set, then we parse NOTHING else + if (args.uriHandler) { + args = { + uriHandler: true, + 'uri-handler': true, + _: args._.filter(str => str.startsWith('atom://')).slice(0, 1) + }; + } + + const addToLastWindow = args['add']; + const safeMode = args['safe']; + const benchmark = args['benchmark']; + const benchmarkTest = args['benchmark-test']; + const test = args['test']; + const mainProcess = args['main-process']; + const timeout = args['timeout']; + const newWindow = args['new-window']; + let executedFrom = null; + if (args['executed-from'] && args['executed-from'].toString()) { + executedFrom = args['executed-from'].toString(); + } else { + executedFrom = process.cwd(); + } + + if (newWindow && addToLastWindow) { + process.stderr.write( + `Only one of the --add and --new-window options may be specified at the same time.\n\n${options.help()}` + ); + + // Exiting the main process with a nonzero exit code on MacOS causes the app open to fail with the mysterious + // message "LSOpenURLsWithRole() failed for the application /Applications/Atom Dev.app with error -10810." + process.exit(0); + } + + let pidToKillWhenClosed = null; + if (args['wait']) { + pidToKillWhenClosed = args['pid']; + } + + const logFile = args['log-file']; + const userDataDir = args['user-data-dir']; + const profileStartup = args['profile-startup']; + const clearWindowState = args['clear-window-state']; + let pathsToOpen = []; + let urlsToOpen = []; + let devMode = args['dev']; + + for (const path of args._) { + if (typeof path !== 'string') { + // Sometimes non-strings (such as numbers or boolean true) get into args._ + // In the next block, .startsWith() only works on strings. So, skip non-string arguments. + continue; + } + if (path.startsWith('atom://')) { + urlsToOpen.push(path); + } else { + pathsToOpen.push(path); + } + } + + if (args.resourcePath || test) { + devMode = true; + } + + if (args['path-environment']) { + // On Yosemite the $PATH is not inherited by the "open" command, so we have to + // explicitly pass it by command line, see http://git.io/YC8_Ew. + process.env.PATH = args['path-environment']; + } + + return { + pathsToOpen, + urlsToOpen, + executedFrom, + test, + version, + pidToKillWhenClosed, + devMode, + safeMode, + newWindow, + logFile, + userDataDir, + profileStartup, + timeout, + clearWindowState, + addToLastWindow, + mainProcess, + benchmark, + benchmarkTest, + env: process.env + }; +}; diff --git a/src/main-process/spawner.js b/src/main-process/spawner.js new file mode 100644 index 00000000000..21a673205ff --- /dev/null +++ b/src/main-process/spawner.js @@ -0,0 +1,47 @@ +const ChildProcess = require('child_process'); + +// Spawn a command and invoke the callback when it completes with an error +// and the output from standard out. +// +// * `command` The underlying OS command {String} to execute. +// * `args` (optional) The {Array} with arguments to be passed to command. +// * `callback` (optional) The {Function} to call after the command has run. It will be invoked with arguments: +// * `error` (optional) An {Error} object returned by the command, `null` if no error was thrown. +// * `code` Error code returned by the command. +// * `stdout` The {String} output text generated by the command. +// * `stdout` The {String} output text generated by the command. +exports.spawn = function(command, args, callback) { + let error; + let spawnedProcess; + let stdout = ''; + + try { + spawnedProcess = ChildProcess.spawn(command, args); + } catch (error) { + process.nextTick(() => callback && callback(error, stdout)); + return; + } + + spawnedProcess.stdout.on('data', data => { + stdout += data; + }); + spawnedProcess.on('error', processError => { + error = processError; + }); + spawnedProcess.on('close', (code, signal) => { + if (!error && code !== 0) { + error = new Error(`Command failed: ${signal != null ? signal : code}`); + } + + if (error) { + if (error.code == null) error.code = code; + if (error.stdout == null) error.stdout = stdout; + } + + callback && callback(error, stdout); + }); + + // This is necessary if using Powershell 2 on Windows 7 to get the events to raise + // http://stackoverflow.com/questions/9155289/calling-powershell-from-nodejs + return spawnedProcess.stdin.end(); +}; diff --git a/src/main-process/squirrel-update.js b/src/main-process/squirrel-update.js new file mode 100644 index 00000000000..86f4148245b --- /dev/null +++ b/src/main-process/squirrel-update.js @@ -0,0 +1,234 @@ +let setxPath; +const { app } = require('electron'); +const fs = require('fs-plus'); +const getAppName = require('../get-app-name'); +const path = require('path'); +const Spawner = require('./spawner'); +const WinShell = require('./win-shell'); +const WinPowerShell = require('./win-powershell'); + +const appFolder = path.resolve(process.execPath, '..'); +const rootAtomFolder = path.resolve(appFolder, '..'); +const binFolder = path.join(rootAtomFolder, 'bin'); +const updateDotExe = path.join(rootAtomFolder, 'Update.exe'); +const execName = path.basename(app.getPath('exe')); + +if (process.env.SystemRoot) { + const system32Path = path.join(process.env.SystemRoot, 'System32'); + setxPath = path.join(system32Path, 'setx.exe'); +} else { + setxPath = 'setx.exe'; +} + +// Spawn setx.exe and callback when it completes +const spawnSetx = (args, callback) => Spawner.spawn(setxPath, args, callback); + +// Spawn the Update.exe with the given arguments and invoke the callback when +// the command completes. +const spawnUpdate = (args, callback) => + Spawner.spawn(updateDotExe, args, callback); + +// Add atom and apm to the PATH +// +// This is done by adding .cmd shims to the root bin folder in the Atom +// install directory that point to the newly installed versions inside +// the versioned app directories. +const addCommandsToPath = callback => { + const atomCmdName = execName.replace('.exe', '.cmd'); + const apmCmdName = atomCmdName.replace('atom', 'apm'); + const atomShName = execName.replace('.exe', ''); + const apmShName = atomShName.replace('atom', 'apm'); + + const installCommands = callback => { + const atomCommandPath = path.join(binFolder, atomCmdName); + const relativeAtomPath = path.relative( + binFolder, + path.join(appFolder, 'resources', 'cli', 'atom.cmd') + ); + const atomCommand = `@echo off\r\n"%~dp0\\${relativeAtomPath}" %*`; + + const atomShCommandPath = path.join(binFolder, atomShName); + const relativeAtomShPath = path.relative( + binFolder, + path.join(appFolder, 'resources', 'cli', 'atom.sh') + ); + const atomShCommand = `#!/bin/sh\r\n"$(dirname "$0")/${relativeAtomShPath.replace( + /\\/g, + '/' + )}" "$@"\r\necho`; + + const apmCommandPath = path.join(binFolder, apmCmdName); + const relativeApmPath = path.relative( + binFolder, + path.join(process.resourcesPath, 'app', 'apm', 'bin', 'apm.cmd') + ); + const apmCommand = `@echo off\r\n"%~dp0\\${relativeApmPath}" %*`; + + const apmShCommandPath = path.join(binFolder, apmShName); + const relativeApmShPath = path.relative( + binFolder, + path.join(appFolder, 'resources', 'cli', 'apm.sh') + ); + const apmShCommand = `#!/bin/sh\r\n"$(dirname "$0")/${relativeApmShPath.replace( + /\\/g, + '/' + )}" "$@"`; + + fs.writeFile(atomCommandPath, atomCommand, () => + fs.writeFile(atomShCommandPath, atomShCommand, () => + fs.writeFile(apmCommandPath, apmCommand, () => + fs.writeFile(apmShCommandPath, apmShCommand, () => callback()) + ) + ) + ); + }; + + const addBinToPath = (pathSegments, callback) => { + pathSegments.push(binFolder); + const newPathEnv = pathSegments.join(';'); + spawnSetx(['Path', newPathEnv], callback); + }; + + installCommands(error => { + if (error) return callback(error); + + WinPowerShell.getPath((error, pathEnv) => { + if (error) return callback(error); + + const pathSegments = pathEnv + .split(/;+/) + .filter(pathSegment => pathSegment); + if (pathSegments.indexOf(binFolder) === -1) { + addBinToPath(pathSegments, callback); + } else { + callback(); + } + }); + }); +}; + +// Remove atom and apm from the PATH +const removeCommandsFromPath = callback => + WinPowerShell.getPath((error, pathEnv) => { + if (error != null) { + return callback(error); + } + + const pathSegments = pathEnv + .split(/;+/) + .filter(pathSegment => pathSegment && pathSegment !== binFolder); + const newPathEnv = pathSegments.join(';'); + + if (pathEnv !== newPathEnv) { + return spawnSetx(['Path', newPathEnv], callback); + } else { + return callback(); + } + }); + +// Create a desktop and start menu shortcut by using the command line API +// provided by Squirrel's Update.exe +const createShortcuts = (locations, callback) => + spawnUpdate( + ['--createShortcut', execName, '-l', locations.join(',')], + callback + ); + +// Update the desktop and start menu shortcuts by using the command line API +// provided by Squirrel's Update.exe +const updateShortcuts = callback => { + const homeDirectory = fs.getHomeDirectory(); + if (homeDirectory) { + const desktopShortcutPath = path.join( + homeDirectory, + 'Desktop', + `${getAppName()}.lnk` + ); + // Check if the desktop shortcut has been previously deleted and + // and keep it deleted if it was + fs.exists(desktopShortcutPath, desktopShortcutExists => { + const locations = ['StartMenu']; + if (desktopShortcutExists) { + locations.push('Desktop'); + } + + createShortcuts(locations, callback); + }); + } else { + createShortcuts(['Desktop', 'StartMenu'], callback); + } +}; + +// Remove the desktop and start menu shortcuts by using the command line API +// provided by Squirrel's Update.exe +const removeShortcuts = callback => + spawnUpdate(['--removeShortcut', execName], callback); + +exports.spawn = spawnUpdate; + +// Is the Update.exe installed with Atom? +exports.existsSync = () => fs.existsSync(updateDotExe); + +// Restart Atom using the version pointed to by the atom.cmd shim +exports.restartAtom = () => { + let args; + const atomCmdName = execName.replace('.exe', '.cmd'); + + if (global.atomApplication && global.atomApplication.lastFocusedWindow) { + const { projectPath } = global.atomApplication.lastFocusedWindow; + if (projectPath) args = [projectPath]; + } + Spawner.spawn(path.join(binFolder, atomCmdName), args); + app.quit(); +}; + +const updateContextMenus = callback => + WinShell.fileContextMenu.update(() => + WinShell.folderContextMenu.update(() => + WinShell.folderBackgroundContextMenu.update(() => callback()) + ) + ); + +// Handle squirrel events denoted by --squirrel-* command line arguments. +exports.handleStartupEvent = squirrelCommand => { + switch (squirrelCommand) { + case '--squirrel-install': + createShortcuts(['Desktop', 'StartMenu'], () => + addCommandsToPath(() => + WinShell.fileHandler.register(() => + updateContextMenus(() => app.quit()) + ) + ) + ); + return true; + case '--squirrel-updated': + updateShortcuts(() => + addCommandsToPath(() => + WinShell.fileHandler.update(() => + updateContextMenus(() => app.quit()) + ) + ) + ); + return true; + case '--squirrel-uninstall': + removeShortcuts(() => + removeCommandsFromPath(() => + WinShell.fileHandler.deregister(() => + WinShell.fileContextMenu.deregister(() => + WinShell.folderContextMenu.deregister(() => + WinShell.folderBackgroundContextMenu.deregister(() => + app.quit() + ) + ) + ) + ) + ) + ); + return true; + case '--squirrel-obsolete': + app.quit(); + return true; + default: + return false; + } +}; diff --git a/src/main-process/start.js b/src/main-process/start.js new file mode 100644 index 00000000000..122c332f67a --- /dev/null +++ b/src/main-process/start.js @@ -0,0 +1,171 @@ +const { app } = require('electron'); +const nslog = require('nslog'); +const path = require('path'); +const temp = require('temp'); +const parseCommandLine = require('./parse-command-line'); +const startCrashReporter = require('../crash-reporter-start'); +const getReleaseChannel = require('../get-release-channel'); +const atomPaths = require('../atom-paths'); +const fs = require('fs'); +const CSON = require('season'); +const Config = require('../config'); +const StartupTime = require('../startup-time'); + +StartupTime.setStartTime(); + +module.exports = function start(resourcePath, devResourcePath, startTime) { + global.shellStartTime = startTime; + StartupTime.addMarker('main-process:start'); + + process.on('uncaughtException', function(error = {}) { + if (error.message != null) { + console.log(error.message); + } + + if (error.stack != null) { + console.log(error.stack); + } + }); + + process.on('unhandledRejection', function(error = {}) { + if (error.message != null) { + console.log(error.message); + } + + if (error.stack != null) { + console.log(error.stack); + } + }); + + // TodoElectronIssue this should be set to true before Electron 12 - https://github.com/electron/electron/issues/18397 + app.allowRendererProcessReuse = false; + + app.commandLine.appendSwitch('enable-experimental-web-platform-features'); + + const args = parseCommandLine(process.argv.slice(1)); + + // This must happen after parseCommandLine() because yargs uses console.log + // to display the usage message. + const previousConsoleLog = console.log; + console.log = nslog; + + args.resourcePath = normalizeDriveLetterName(resourcePath); + args.devResourcePath = normalizeDriveLetterName(devResourcePath); + + atomPaths.setAtomHome(app.getPath('home')); + atomPaths.setUserData(app); + + const config = getConfig(); + const colorProfile = config.get('core.colorProfile'); + if (colorProfile && colorProfile !== 'default') { + app.commandLine.appendSwitch('force-color-profile', colorProfile); + } + + if (handleStartupEventWithSquirrel()) { + return; + } else if (args.test && args.mainProcess) { + app.setPath( + 'userData', + temp.mkdirSync('atom-user-data-dir-for-main-process-tests') + ); + console.log = previousConsoleLog; + app.on('ready', function() { + const testRunner = require(path.join( + args.resourcePath, + 'spec/main-process/mocha-test-runner' + )); + testRunner(args.pathsToOpen); + }); + return; + } + + const releaseChannel = getReleaseChannel(app.getVersion()); + let appUserModelId = 'com.squirrel.atom.' + process.arch; + + // If the release channel is not stable, we append it to the app user model id. + // This allows having the different release channels as separate items in the taskbar. + if (releaseChannel !== 'stable') { + appUserModelId += `-${releaseChannel}`; + } + + // NB: This prevents Win10 from showing dupe items in the taskbar. + app.setAppUserModelId(appUserModelId); + + function addPathToOpen(event, pathToOpen) { + event.preventDefault(); + args.pathsToOpen.push(pathToOpen); + } + + function addUrlToOpen(event, urlToOpen) { + event.preventDefault(); + args.urlsToOpen.push(urlToOpen); + } + + app.on('open-file', addPathToOpen); + app.on('open-url', addUrlToOpen); + app.on('will-finish-launching', () => + startCrashReporter({ + uploadToServer: config.get('core.telemetryConsent') === 'limited', + releaseChannel + }) + ); + + if (args.userDataDir != null) { + app.setPath('userData', args.userDataDir); + } else if (args.test || args.benchmark || args.benchmarkTest) { + app.setPath('userData', temp.mkdirSync('atom-test-data')); + } + + StartupTime.addMarker('main-process:electron-onready:start'); + app.on('ready', function() { + StartupTime.addMarker('main-process:electron-onready:end'); + app.removeListener('open-file', addPathToOpen); + app.removeListener('open-url', addUrlToOpen); + const AtomApplication = require(path.join( + args.resourcePath, + 'src', + 'main-process', + 'atom-application' + )); + AtomApplication.open(args); + }); +}; + +function handleStartupEventWithSquirrel() { + if (process.platform !== 'win32') { + return false; + } + + const SquirrelUpdate = require('./squirrel-update'); + const squirrelCommand = process.argv[1]; + return SquirrelUpdate.handleStartupEvent(squirrelCommand); +} + +function getConfig() { + const config = new Config(); + + let configFilePath; + if (fs.existsSync(path.join(process.env.ATOM_HOME, 'config.json'))) { + configFilePath = path.join(process.env.ATOM_HOME, 'config.json'); + } else if (fs.existsSync(path.join(process.env.ATOM_HOME, 'config.cson'))) { + configFilePath = path.join(process.env.ATOM_HOME, 'config.cson'); + } + + if (configFilePath) { + const configFileData = CSON.readFileSync(configFilePath); + config.resetUserSettings(configFileData); + } + + return config; +} + +function normalizeDriveLetterName(filePath) { + if (process.platform === 'win32' && filePath) { + return filePath.replace( + /^([a-z]):/, + ([driveLetter]) => driveLetter.toUpperCase() + ':' + ); + } else { + return filePath; + } +} diff --git a/src/main-process/win-powershell.js b/src/main-process/win-powershell.js new file mode 100644 index 00000000000..1c447c935e9 --- /dev/null +++ b/src/main-process/win-powershell.js @@ -0,0 +1,52 @@ +let powershellPath; +const path = require('path'); +const Spawner = require('./spawner'); + +if (process.env.SystemRoot) { + const system32Path = path.join(process.env.SystemRoot, 'System32'); + powershellPath = path.join( + system32Path, + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ); +} else { + powershellPath = 'powershell.exe'; +} + +// Spawn powershell.exe and callback when it completes +const spawnPowershell = function(args, callback) { + // Set encoding and execute the command, capture the output, and return it + // via .NET's console in order to have consistent UTF-8 encoding. + // See http://stackoverflow.com/questions/22349139/utf-8-output-from-powershell + // to address https://github.com/atom/atom/issues/5063 + args[0] = `\ +[Console]::OutputEncoding=[System.Text.Encoding]::UTF8 +$output=${args[0]} +[Console]::WriteLine($output)\ +`; + args.unshift('-command'); + args.unshift('RemoteSigned'); + args.unshift('-ExecutionPolicy'); + args.unshift('-noprofile'); + Spawner.spawn(powershellPath, args, callback); +}; + +// Get the user's PATH environment variable registry value. +// +// * `callback` The {Function} to call after registry operation is done. +// It will be invoked with the same arguments provided by {Spawner.spawn}. +// +// Returns the user's path {String}. +exports.getPath = callback => + spawnPowershell( + ["[environment]::GetEnvironmentVariable('Path','User')"], + function(error, stdout) { + if (error != null) { + return callback(error); + } + + const pathOutput = stdout.replace(/^\s+|\s+$/g, ''); + return callback(null, pathOutput); + } + ); diff --git a/src/main-process/win-shell.js b/src/main-process/win-shell.js new file mode 100644 index 00000000000..366eb513cd9 --- /dev/null +++ b/src/main-process/win-shell.js @@ -0,0 +1,104 @@ +const Registry = require('winreg'); +const Path = require('path'); +const getAppName = require('../get-app-name'); + +const appName = getAppName(); +const exeName = Path.basename(process.execPath); +const appPath = `"${process.execPath}"`; +const fileIconPath = `"${Path.join( + process.execPath, + '..', + 'resources', + 'cli', + 'file.ico' +)}"`; + +class ShellOption { + constructor(key, parts) { + this.isRegistered = this.isRegistered.bind(this); + this.register = this.register.bind(this); + this.deregister = this.deregister.bind(this); + this.update = this.update.bind(this); + this.key = key; + this.parts = parts; + } + + isRegistered(callback) { + new Registry({ + hive: 'HKCU', + key: `${this.key}\\${this.parts[0].key}` + }).get(this.parts[0].name, (err, val) => + callback(err == null && val != null && val.value === this.parts[0].value) + ); + } + + register(callback) { + let doneCount = this.parts.length; + this.parts.forEach(part => { + let reg = new Registry({ + hive: 'HKCU', + key: part.key != null ? `${this.key}\\${part.key}` : this.key + }); + return reg.create(() => + reg.set(part.name, Registry.REG_SZ, part.value, () => { + if (--doneCount === 0) return callback(); + }) + ); + }); + } + + deregister(callback) { + this.isRegistered(isRegistered => { + if (isRegistered) { + new Registry({ hive: 'HKCU', key: this.key }).destroy(() => + callback(null, true) + ); + } else { + callback(null, false); + } + }); + } + + update(callback) { + new Registry({ + hive: 'HKCU', + key: `${this.key}\\${this.parts[0].key}` + }).get(this.parts[0].name, (err, val) => { + if (err != null || val == null) { + callback(err); + } else { + this.register(callback); + } + }); + } +} + +exports.appName = appName; + +exports.fileHandler = new ShellOption( + `\\Software\\Classes\\Applications\\${exeName}`, + [ + { key: 'shell\\open\\command', name: '', value: `${appPath} "%1"` }, + { key: 'shell\\open', name: 'FriendlyAppName', value: `${appName}` }, + { key: 'DefaultIcon', name: '', value: `${fileIconPath}` } + ] +); + +let contextParts = [ + { key: 'command', name: '', value: `${appPath} "%1"` }, + { name: '', value: `Open with ${appName}` }, + { name: 'Icon', value: `${appPath}` } +]; + +exports.fileContextMenu = new ShellOption( + `\\Software\\Classes\\*\\shell\\${appName}`, + contextParts +); +exports.folderContextMenu = new ShellOption( + `\\Software\\Classes\\Directory\\shell\\${appName}`, + contextParts +); +exports.folderBackgroundContextMenu = new ShellOption( + `\\Software\\Classes\\Directory\\background\\shell\\${appName}`, + JSON.parse(JSON.stringify(contextParts).replace('%1', '%V')) +); diff --git a/src/marker.coffee b/src/marker.coffee deleted file mode 100644 index a0d8bda009e..00000000000 --- a/src/marker.coffee +++ /dev/null @@ -1,386 +0,0 @@ -_ = require 'underscore-plus' -{CompositeDisposable, Emitter} = require 'event-kit' -Grim = require 'grim' - -# Essential: Represents a buffer annotation that remains logically stationary -# even as the buffer changes. This is used to represent cursors, folds, snippet -# targets, misspelled words, and anything else that needs to track a logical -# location in the buffer over time. -# -# ### Marker Creation -# -# Use {TextEditor::markBufferRange} rather than creating Markers directly. -# -# ### Head and Tail -# -# Markers always have a *head* and sometimes have a *tail*. If you think of a -# marker as an editor selection, the tail is the part that's stationary and the -# head is the part that moves when the mouse is moved. A marker without a tail -# always reports an empty range at the head position. A marker with a head position -# greater than the tail is in a "normal" orientation. If the head precedes the -# tail the marker is in a "reversed" orientation. -# -# ### Validity -# -# Markers are considered *valid* when they are first created. Depending on the -# invalidation strategy you choose, certain changes to the buffer can cause a -# marker to become invalid, for example if the text surrounding the marker is -# deleted. The strategies, in order of descending fragility: -# -# * __never__: The marker is never marked as invalid. This is a good choice for -# markers representing selections in an editor. -# * __surround__: The marker is invalidated by changes that completely surround it. -# * __overlap__: The marker is invalidated by changes that surround the -# start or end of the marker. This is the default. -# * __inside__: The marker is invalidated by changes that extend into the -# inside of the marker. Changes that end at the marker's start or -# start at the marker's end do not invalidate the marker. -# * __touch__: The marker is invalidated by a change that touches the marked -# region in any way, including changes that end at the marker's -# start or start at the marker's end. This is the most fragile strategy. -# -# See {TextEditor::markBufferRange} for usage. -module.exports = -class Marker - bufferMarkerSubscription: null - oldHeadBufferPosition: null - oldHeadScreenPosition: null - oldTailBufferPosition: null - oldTailScreenPosition: null - wasValid: true - - ### - Section: Construction and Destruction - ### - - constructor: ({@bufferMarker, @displayBuffer}) -> - @emitter = new Emitter - @disposables = new CompositeDisposable - @id = @bufferMarker.id - @oldHeadBufferPosition = @getHeadBufferPosition() - @oldHeadScreenPosition = @getHeadScreenPosition() - @oldTailBufferPosition = @getTailBufferPosition() - @oldTailScreenPosition = @getTailScreenPosition() - @wasValid = @isValid() - - @disposables.add @bufferMarker.onDidDestroy => @destroyed() - @disposables.add @bufferMarker.onDidChange (event) => @notifyObservers(event) - - # Essential: Destroys the marker, causing it to emit the 'destroyed' event. Once - # destroyed, a marker cannot be restored by undo/redo operations. - destroy: -> - @bufferMarker.destroy() - @disposables.dispose() - - # Essential: Creates and returns a new {Marker} with the same properties as this - # marker. - # - # * `properties` {Object} - copy: (properties) -> - @displayBuffer.getMarker(@bufferMarker.copy(properties).id) - - ### - Section: Event Subscription - ### - - # Essential: Invoke the given callback when the state of the marker changes. - # - # * `callback` {Function} to be called when the marker changes. - # * `event` {Object} with the following keys: - # * `oldHeadPosition` {Point} representing the former head position - # * `newHeadPosition` {Point} representing the new head position - # * `oldTailPosition` {Point} representing the former tail position - # * `newTailPosition` {Point} representing the new tail position - # * `wasValid` {Boolean} indicating whether the marker was valid before the change - # * `isValid` {Boolean} indicating whether the marker is now valid - # * `hadTail` {Boolean} indicating whether the marker had a tail before the change - # * `hasTail` {Boolean} indicating whether the marker now has a tail - # * `oldProperties` {Object} containing the marker's custom properties before the change. - # * `newProperties` {Object} containing the marker's custom properties after the change. - # * `textChanged` {Boolean} indicating whether this change was caused by a textual change - # to the buffer or whether the marker was manipulated directly via its public API. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - # Essential: Invoke the given callback when the marker is destroyed. - # - # * `callback` {Function} to be called when the marker is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Marker Details - ### - - # Essential: Returns a {Boolean} indicating whether the marker is valid. Markers can be - # invalidated when a region surrounding them in the buffer is changed. - isValid: -> - @bufferMarker.isValid() - - # Essential: Returns a {Boolean} indicating whether the marker has been destroyed. A marker - # can be invalid without being destroyed, in which case undoing the invalidating - # operation would restore the marker. Once a marker is destroyed by calling - # {Marker::destroy}, no undo/redo operation can ever bring it back. - isDestroyed: -> - @bufferMarker.isDestroyed() - - # Essential: Returns a {Boolean} indicating whether the head precedes the tail. - isReversed: -> - @bufferMarker.isReversed() - - # Essential: Get the invalidation strategy for this marker. - # - # Valid values include: `never`, `surround`, `overlap`, `inside`, and `touch`. - # - # Returns a {String}. - getInvalidationStrategy: -> - @bufferMarker.getInvalidationStrategy() - - # Essential: Returns an {Object} containing any custom properties associated with - # the marker. - getProperties: -> - @bufferMarker.getProperties() - - # Essential: Merges an {Object} containing new properties into the marker's - # existing properties. - # - # * `properties` {Object} - setProperties: (properties) -> - @bufferMarker.setProperties(properties) - - matchesProperties: (attributes) -> - attributes = @displayBuffer.translateToBufferMarkerParams(attributes) - @bufferMarker.matchesParams(attributes) - - ### - Section: Comparing to other markers - ### - - # Essential: Returns a {Boolean} indicating whether this marker is equivalent to - # another marker, meaning they have the same range and options. - # - # * `other` {Marker} other marker - isEqual: (other) -> - return false unless other instanceof @constructor - @bufferMarker.isEqual(other.bufferMarker) - - # Essential: Compares this marker to another based on their ranges. - # - # * `other` {Marker} - # - # Returns a {Number} - compare: (other) -> - @bufferMarker.compare(other.bufferMarker) - - ### - Section: Managing the marker's range - ### - - # Essential: Gets the buffer range of the display marker. - # - # Returns a {Range}. - getBufferRange: -> - @bufferMarker.getRange() - - # Essential: Modifies the buffer range of the display marker. - # - # * `bufferRange` The new {Range} to use - # * `properties` (optional) {Object} properties to associate with the marker. - # * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. - setBufferRange: (bufferRange, properties) -> - @bufferMarker.setRange(bufferRange, properties) - - # Essential: Gets the screen range of the display marker. - # - # Returns a {Range}. - getScreenRange: -> - @displayBuffer.screenRangeForBufferRange(@getBufferRange(), wrapAtSoftNewlines: true) - - # Essential: Modifies the screen range of the display marker. - # - # * `screenRange` The new {Range} to use - # * `properties` (optional) {Object} properties to associate with the marker. - # * `reversed` {Boolean} If true, the marker will to be in a reversed orientation. - setScreenRange: (screenRange, options) -> - @setBufferRange(@displayBuffer.bufferRangeForScreenRange(screenRange), options) - - # Essential: Retrieves the buffer position of the marker's start. This will always be - # less than or equal to the result of {Marker::getEndBufferPosition}. - # - # Returns a {Point}. - getStartBufferPosition: -> - @bufferMarker.getStartPosition() - - # Essential: Retrieves the screen position of the marker's start. This will always be - # less than or equal to the result of {Marker::getEndScreenPosition}. - # - # Returns a {Point}. - getStartScreenPosition: -> - @displayBuffer.screenPositionForBufferPosition(@getStartBufferPosition(), wrapAtSoftNewlines: true) - - # Essential: Retrieves the buffer position of the marker's end. This will always be - # greater than or equal to the result of {Marker::getStartBufferPosition}. - # - # Returns a {Point}. - getEndBufferPosition: -> - @bufferMarker.getEndPosition() - - # Essential: Retrieves the screen position of the marker's end. This will always be - # greater than or equal to the result of {Marker::getStartScreenPosition}. - # - # Returns a {Point}. - getEndScreenPosition: -> - @displayBuffer.screenPositionForBufferPosition(@getEndBufferPosition(), wrapAtSoftNewlines: true) - - # Extended: Retrieves the buffer position of the marker's head. - # - # Returns a {Point}. - getHeadBufferPosition: -> - @bufferMarker.getHeadPosition() - - # Extended: Sets the buffer position of the marker's head. - # - # * `bufferPosition` The new {Point} to use - # * `properties` (optional) {Object} properties to associate with the marker. - setHeadBufferPosition: (bufferPosition, properties) -> - @bufferMarker.setHeadPosition(bufferPosition, properties) - - # Extended: Retrieves the screen position of the marker's head. - # - # Returns a {Point}. - getHeadScreenPosition: -> - @displayBuffer.screenPositionForBufferPosition(@getHeadBufferPosition(), wrapAtSoftNewlines: true) - - # Extended: Sets the screen position of the marker's head. - # - # * `screenPosition` The new {Point} to use - # * `properties` (optional) {Object} properties to associate with the marker. - setHeadScreenPosition: (screenPosition, properties) -> - @setHeadBufferPosition(@displayBuffer.bufferPositionForScreenPosition(screenPosition, properties)) - - # Extended: Retrieves the buffer position of the marker's tail. - # - # Returns a {Point}. - getTailBufferPosition: -> - @bufferMarker.getTailPosition() - - # Extended: Sets the buffer position of the marker's tail. - # - # * `bufferPosition` The new {Point} to use - # * `properties` (optional) {Object} properties to associate with the marker. - setTailBufferPosition: (bufferPosition) -> - @bufferMarker.setTailPosition(bufferPosition) - - # Extended: Retrieves the screen position of the marker's tail. - # - # Returns a {Point}. - getTailScreenPosition: -> - @displayBuffer.screenPositionForBufferPosition(@getTailBufferPosition(), wrapAtSoftNewlines: true) - - # Extended: Sets the screen position of the marker's tail. - # - # * `screenPosition` The new {Point} to use - # * `properties` (optional) {Object} properties to associate with the marker. - setTailScreenPosition: (screenPosition, options) -> - @setTailBufferPosition(@displayBuffer.bufferPositionForScreenPosition(screenPosition, options)) - - # Extended: Returns a {Boolean} indicating whether the marker has a tail. - hasTail: -> - @bufferMarker.hasTail() - - # Extended: Plants the marker's tail at the current head position. After calling - # the marker's tail position will be its head position at the time of the - # call, regardless of where the marker's head is moved. - # - # * `properties` (optional) {Object} properties to associate with the marker. - plantTail: -> - @bufferMarker.plantTail() - - # Extended: Removes the marker's tail. After calling the marker's head position - # will be reported as its current tail position until the tail is planted - # again. - # - # * `properties` (optional) {Object} properties to associate with the marker. - clearTail: (properties) -> - @bufferMarker.clearTail(properties) - - ### - Section: Private utility methods - ### - - # Returns a {String} representation of the marker - inspect: -> - "Marker(id: #{@id}, bufferRange: #{@getBufferRange()})" - - destroyed: -> - delete @displayBuffer.markers[@id] - @emit 'destroyed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-destroy' - @emitter.dispose() - - notifyObservers: ({textChanged}) -> - textChanged ?= false - - newHeadBufferPosition = @getHeadBufferPosition() - newHeadScreenPosition = @getHeadScreenPosition() - newTailBufferPosition = @getTailBufferPosition() - newTailScreenPosition = @getTailScreenPosition() - isValid = @isValid() - - return if isValid is @wasValid and - newHeadBufferPosition.isEqual(@oldHeadBufferPosition) and - newHeadScreenPosition.isEqual(@oldHeadScreenPosition) and - newTailBufferPosition.isEqual(@oldTailBufferPosition) and - newTailScreenPosition.isEqual(@oldTailScreenPosition) - - changeEvent = { - @oldHeadScreenPosition, newHeadScreenPosition, - @oldTailScreenPosition, newTailScreenPosition, - @oldHeadBufferPosition, newHeadBufferPosition, - @oldTailBufferPosition, newTailBufferPosition, - textChanged, - isValid - } - - @oldHeadBufferPosition = newHeadBufferPosition - @oldHeadScreenPosition = newHeadScreenPosition - @oldTailBufferPosition = newTailBufferPosition - @oldTailScreenPosition = newTailScreenPosition - @wasValid = isValid - - @emit 'changed', changeEvent if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change', changeEvent - - getPixelRange: -> - @displayBuffer.pixelRangeForScreenRange(@getScreenRange(), false) - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(Marker) - - Marker::on = (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use Marker::onDidChange instead") - when 'destroyed' - Grim.deprecate("Use Marker::onDidDestroy instead") - else - Grim.deprecate("Marker::on is deprecated. Use documented event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) - - Marker::getAttributes = -> - Grim.deprecate 'Use Marker::getProperties instead' - @getProperties() - - Marker::setAttributes = (properties) -> - Grim.deprecate 'Use Marker::setProperties instead' - @setProperties(properties) - - Marker::matchesAttributes = (attributes) -> - Grim.deprecate 'Use Marker::matchesProperties instead' - @matchesProperties(attributes) diff --git a/src/menu-helpers.coffee b/src/menu-helpers.coffee deleted file mode 100644 index aa346200c64..00000000000 --- a/src/menu-helpers.coffee +++ /dev/null @@ -1,54 +0,0 @@ -_ = require 'underscore-plus' - -ItemSpecificities = new WeakMap - -merge = (menu, item, itemSpecificity=Infinity) -> - item = cloneMenuItem(item) - ItemSpecificities.set(item, itemSpecificity) if itemSpecificity - matchingItemIndex = findMatchingItemIndex(menu, item) - matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1 - - if matchingItem? - if item.submenu? - merge(matchingItem.submenu, submenuItem, itemSpecificity) for submenuItem in item.submenu - else if itemSpecificity - unless itemSpecificity < ItemSpecificities.get(matchingItem) - menu[matchingItemIndex] = item - else unless item.type is 'separator' and _.last(menu)?.type is 'separator' - menu.push(item) - - return - -unmerge = (menu, item) -> - matchingItemIndex = findMatchingItemIndex(menu, item) - matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1 - - if matchingItem? - if item.submenu? - unmerge(matchingItem.submenu, submenuItem) for submenuItem in item.submenu - - unless matchingItem.submenu?.length > 0 - menu.splice(matchingItemIndex, 1) - -findMatchingItemIndex = (menu, {type, label, submenu}) -> - return -1 if type is 'separator' - for item, index in menu - if normalizeLabel(item.label) is normalizeLabel(label) and item.submenu? is submenu? - return index - -1 - -normalizeLabel = (label) -> - return undefined unless label? - - if process.platform is 'darwin' - label - else - label.replace(/\&/g, '') - -cloneMenuItem = (item) -> - item = _.pick(item, 'type', 'label', 'enabled', 'visible', 'command', 'submenu', 'commandDetail') - if item.submenu? - item.submenu = item.submenu.map (submenuItem) -> cloneMenuItem(submenuItem) - item - -module.exports = {merge, unmerge, normalizeLabel, cloneMenuItem} diff --git a/src/menu-helpers.js b/src/menu-helpers.js new file mode 100644 index 00000000000..d096f3f1c1e --- /dev/null +++ b/src/menu-helpers.js @@ -0,0 +1,138 @@ +const _ = require('underscore-plus'); + +const ItemSpecificities = new WeakMap(); + +// Add an item to a menu, ensuring separators are not duplicated. +function addItemToMenu(item, menu) { + const lastMenuItem = _.last(menu); + const lastMenuItemIsSpearator = + lastMenuItem && lastMenuItem.type === 'separator'; + if (!(item.type === 'separator' && lastMenuItemIsSpearator)) { + menu.push(item); + } +} + +function merge(menu, item, itemSpecificity = Infinity) { + item = cloneMenuItem(item); + ItemSpecificities.set(item, itemSpecificity); + const matchingItemIndex = findMatchingItemIndex(menu, item); + + if (matchingItemIndex === -1) { + addItemToMenu(item, menu); + return; + } + + const matchingItem = menu[matchingItemIndex]; + if (item.submenu != null) { + for (let submenuItem of item.submenu) { + merge(matchingItem.submenu, submenuItem, itemSpecificity); + } + } else if ( + itemSpecificity && + itemSpecificity >= ItemSpecificities.get(matchingItem) + ) { + menu[matchingItemIndex] = item; + } +} + +function unmerge(menu, item) { + item = cloneMenuItem(item); + const matchingItemIndex = findMatchingItemIndex(menu, item); + if (matchingItemIndex === -1) { + return; + } + + const matchingItem = menu[matchingItemIndex]; + if (item.submenu != null) { + for (let submenuItem of item.submenu) { + unmerge(matchingItem.submenu, submenuItem); + } + } + + if (matchingItem.submenu == null || matchingItem.submenu.length === 0) { + menu.splice(matchingItemIndex, 1); + } +} + +function findMatchingItemIndex(menu, { type, id, submenu }) { + if (type === 'separator') { + return -1; + } + for (let index = 0; index < menu.length; index++) { + const item = menu[index]; + if (item.id === id && (item.submenu != null) === (submenu != null)) { + return index; + } + } + return -1; +} + +function normalizeLabel(label) { + if (label == null) { + return; + } + return process.platform === 'darwin' ? label : label.replace(/&/g, ''); +} + +function cloneMenuItem(item) { + item = _.pick( + item, + 'type', + 'label', + 'id', + 'enabled', + 'visible', + 'command', + 'submenu', + 'commandDetail', + 'role', + 'accelerator', + 'before', + 'after', + 'beforeGroupContaining', + 'afterGroupContaining' + ); + if (item.id === null || item.id === undefined) { + item.id = normalizeLabel(item.label); + } + if (item.submenu != null) { + item.submenu = item.submenu.map(submenuItem => cloneMenuItem(submenuItem)); + } + return item; +} + +// Determine the Electron accelerator for a given Atom keystroke. +// +// keystroke - The keystroke. +// +// Returns a String containing the keystroke in a format that can be interpreted +// by Electron to provide nice icons where available. +function acceleratorForKeystroke(keystroke) { + if (!keystroke) { + return null; + } + let modifiers = keystroke.split(/-(?=.)/); + const key = modifiers + .pop() + .toUpperCase() + .replace('+', 'Plus'); + + modifiers = modifiers.map(modifier => + modifier + .replace(/shift/gi, 'Shift') + .replace(/cmd/gi, 'Command') + .replace(/ctrl/gi, 'Ctrl') + .replace(/alt/gi, 'Alt') + ); + + const keys = [...modifiers, key]; + return keys.join('+'); +} + +module.exports = { + merge, + unmerge, + normalizeLabel, + cloneMenuItem, + acceleratorForKeystroke +}; diff --git a/src/menu-manager.coffee b/src/menu-manager.coffee index b5c9a80e3f2..b6b4720a38c 100644 --- a/src/menu-manager.coffee +++ b/src/menu-manager.coffee @@ -1,7 +1,7 @@ path = require 'path' _ = require 'underscore-plus' -ipc = require 'ipc' +{ipcRenderer} = require 'electron' CSON = require 'season' fs = require 'fs-plus' {Disposable} = require 'event-kit' @@ -59,11 +59,17 @@ platformMenu = require('../package.json')?._atomMenu?.menu # See {::add} for more info about adding menu's directly. module.exports = class MenuManager - constructor: ({@resourcePath}) -> + constructor: ({@resourcePath, @keymapManager, @packageManager}) -> + @initialized = false @pendingUpdateOperation = null @template = [] - atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() - atom.packages.onDidActivateInitialPackages => @sortPackagesMenu() + @keymapManager.onDidLoadBundledKeymaps => @loadPlatformItems() + @packageManager.onDidActivateInitialPackages => @sortPackagesMenu() + + initialize: ({@resourcePath}) -> + @keymapManager.onDidReloadKeymap => @update() + @update() + @initialized = true # Public: Adds the given items to the application menu. # @@ -72,7 +78,7 @@ class MenuManager # atom.menu.add [ # { # label: 'Hello' - # submenu : [{label: 'World!', command: 'hello:world'}] + # submenu : [{label: 'World!', id: 'World!', command: 'hello:world'}] # } # ] # ``` @@ -83,11 +89,16 @@ class MenuManager # * `command` An optional {String} command to trigger when the item is # clicked. # + # * `id` (internal) A {String} containing the menu item's id. # Returns a {Disposable} on which `.dispose()` can be called to remove the # added menu items. add: (items) -> items = _.deepClone(items) - @merge(@template, item) for item in items + + for item in items + continue unless item.label? # TODO: Should we emit a warning here? + @merge(@template, item) + @update() new Disposable => @remove(items) @@ -95,6 +106,10 @@ class MenuManager @unmerge(@template, item) for item in items @update() + clear: -> + @template = [] + @update() + # Should the binding for the given selector be included in the menu # commands. # @@ -137,13 +152,27 @@ class MenuManager # Public: Refreshes the currently visible menu. update: -> - clearImmediate(@pendingUpdateOperation) if @pendingUpdateOperation? - @pendingUpdateOperation = setImmediate => + return unless @initialized + + clearTimeout(@pendingUpdateOperation) if @pendingUpdateOperation? + + @pendingUpdateOperation = setTimeout(=> + unsetKeystrokes = new Set + for binding in @keymapManager.getKeyBindings() + if binding.command is 'unset!' + unsetKeystrokes.add(binding.keystrokes) + keystrokesByCommand = {} - for binding in atom.keymaps.getKeyBindings() when @includeSelector(binding.selector) + for binding in @keymapManager.getKeyBindings() + continue unless @includeSelector(binding.selector) + continue if unsetKeystrokes.has(binding.keystrokes) + continue if process.platform is 'darwin' and /^alt-(shift-)?.$/.test(binding.keystrokes) + continue if process.platform is 'win32' and /^ctrl-alt-(shift-)?.$/.test(binding.keystrokes) keystrokesByCommand[binding.command] ?= [] keystrokesByCommand[binding.command].unshift binding.keystrokes + @sendToBrowserProcess(@template, keystrokesByCommand) + , 1) loadPlatformItems: -> if platformMenu? @@ -162,29 +191,18 @@ class MenuManager unmerge: (menu, item) -> MenuHelpers.unmerge(menu, item) - # OSX can't handle displaying accelerators for multiple keystrokes. - # If they are sent across, it will stop processing accelerators for the rest - # of the menu items. - filterMultipleKeystroke: (keystrokesByCommand) -> - filtered = {} - for key, bindings of keystrokesByCommand - for binding in bindings - continue if binding.indexOf(' ') isnt -1 - - filtered[key] ?= [] - filtered[key].push(binding) - filtered - sendToBrowserProcess: (template, keystrokesByCommand) -> - keystrokesByCommand = @filterMultipleKeystroke(keystrokesByCommand) - ipc.send 'update-application-menu', template, keystrokesByCommand + ipcRenderer.send 'update-application-menu', template, keystrokesByCommand # Get an {Array} of {String} classes for the given element. classesForElement: (element) -> - element?.classList.toString().split(' ') ? [] + if classList = element?.classList + Array::slice.apply(classList) + else + [] sortPackagesMenu: -> - packagesMenu = _.find @template, ({label}) -> MenuHelpers.normalizeLabel(label) is 'Packages' + packagesMenu = _.find @template, ({id}) -> MenuHelpers.normalizeLabel(id) is 'Packages' return unless packagesMenu?.submenu? packagesMenu.submenu.sort (item1, item2) -> diff --git a/src/menu-sort-helpers.js b/src/menu-sort-helpers.js new file mode 100644 index 00000000000..9f04d57a6c6 --- /dev/null +++ b/src/menu-sort-helpers.js @@ -0,0 +1,184 @@ +// UTILS + +function splitArray(arr, predicate) { + let lastArr = []; + const multiArr = [lastArr]; + arr.forEach(item => { + if (predicate(item)) { + if (lastArr.length > 0) { + lastArr = []; + multiArr.push(lastArr); + } + } else { + lastArr.push(item); + } + }); + return multiArr; +} + +function joinArrays(arrays, joiner) { + const joinedArr = []; + arrays.forEach((arr, i) => { + if (i > 0 && arr.length > 0) { + joinedArr.push(joiner); + } + joinedArr.push(...arr); + }); + return joinedArr; +} + +const pushOntoMultiMap = (map, key, value) => { + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(value); +}; + +function indexOfGroupContainingCommand(groups, command, ignoreGroup) { + return groups.findIndex( + candiateGroup => + candiateGroup !== ignoreGroup && + candiateGroup.some(candidateItem => candidateItem.command === command) + ); +} + +// Sort nodes topologically using a depth-first approach. Encountered cycles +// are broken. +function sortTopologically(originalOrder, edgesById) { + const sorted = []; + const marked = new Set(); + + function visit(id) { + if (marked.has(id)) { + // Either this node has already been placed, or we have encountered a + // cycle and need to exit. + return; + } + marked.add(id); + const edges = edgesById.get(id); + if (edges != null) { + edges.forEach(visit); + } + sorted.push(id); + } + + originalOrder.forEach(visit); + return sorted; +} + +function attemptToMergeAGroup(groups) { + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + for (const item of group) { + const toCommands = [...(item.before || []), ...(item.after || [])]; + for (const command of toCommands) { + const index = indexOfGroupContainingCommand(groups, command, group); + if (index === -1) { + // No valid edge for this command + continue; + } + const mergeTarget = groups[index]; + // Merge with group containing `command` + mergeTarget.push(...group); + groups.splice(i, 1); + return true; + } + } + } + return false; +} + +// Merge groups based on before/after positions +// Mutates both the array of groups, and the individual group arrays. +function mergeGroups(groups) { + let mergedAGroup = true; + while (mergedAGroup) { + mergedAGroup = attemptToMergeAGroup(groups); + } + return groups; +} + +function sortItemsInGroup(group) { + const originalOrder = group.map((node, i) => i); + const edges = new Map(); + const commandToIndex = new Map(group.map((item, i) => [item.command, i])); + + group.forEach((item, i) => { + if (item.before) { + item.before.forEach(toCommand => { + const to = commandToIndex.get(toCommand); + if (to != null) { + pushOntoMultiMap(edges, to, i); + } + }); + } + if (item.after) { + item.after.forEach(toCommand => { + const to = commandToIndex.get(toCommand); + if (to != null) { + pushOntoMultiMap(edges, i, to); + } + }); + } + }); + + const sortedNodes = sortTopologically(originalOrder, edges); + + return sortedNodes.map(i => group[i]); +} + +function findEdgesInGroup(groups, i, edges) { + const group = groups[i]; + for (const item of group) { + if (item.beforeGroupContaining) { + for (const command of item.beforeGroupContaining) { + const to = indexOfGroupContainingCommand(groups, command, group); + if (to !== -1) { + pushOntoMultiMap(edges, to, i); + return; + } + } + } + if (item.afterGroupContaining) { + for (const command of item.afterGroupContaining) { + const to = indexOfGroupContainingCommand(groups, command, group); + if (to !== -1) { + pushOntoMultiMap(edges, i, to); + return; + } + } + } + } +} + +function sortGroups(groups) { + const originalOrder = groups.map((item, i) => i); + const edges = new Map(); + + for (let i = 0; i < groups.length; i++) { + findEdgesInGroup(groups, i, edges); + } + + const sortedGroupIndexes = sortTopologically(originalOrder, edges); + return sortedGroupIndexes.map(i => groups[i]); +} + +function isSeparator(item) { + return item.type === 'separator'; +} + +function sortMenuItems(menuItems) { + // Split the items into their implicit groups based upon separators. + const groups = splitArray(menuItems, isSeparator); + // Merge groups that contain before/after references to eachother. + const mergedGroups = mergeGroups(groups); + // Sort each individual group internally. + const mergedGroupsWithSortedItems = mergedGroups.map(sortItemsInGroup); + // Sort the groups based upon their beforeGroupContaining/afterGroupContaining + // references. + const sortedGroups = sortGroups(mergedGroupsWithSortedItems); + // Join the groups back + return joinArrays(sortedGroups, { type: 'separator' }); +} + +module.exports = { sortMenuItems }; diff --git a/src/model.coffee b/src/model.coffee index 7b38c0eefea..94c06a76f67 100644 --- a/src/model.coffee +++ b/src/model.coffee @@ -1,16 +1,7 @@ -Grim = require 'grim' -if Grim.includeDeprecatedAPIs - module.exports = require('theorist').Model - return - -PropertyAccessors = require 'property-accessors' - nextInstanceId = 1 module.exports = class Model - PropertyAccessors.includeInto(this) - @resetNextInstanceId: -> nextInstanceId = 1 alive: true @@ -20,9 +11,7 @@ class Model assignId: (id) -> @id ?= id ? nextInstanceId++ - - @::advisedAccessor 'id', - set: (id) -> nextInstanceId = id + 1 if id >= nextInstanceId + nextInstanceId = id + 1 if id >= nextInstanceId destroy: -> return unless @isAlive() diff --git a/src/module-cache.coffee b/src/module-cache.coffee deleted file mode 100644 index e9245cf4027..00000000000 --- a/src/module-cache.coffee +++ /dev/null @@ -1,313 +0,0 @@ -Module = require 'module' -path = require 'path' -semver = require 'semver' - -# Extend semver.Range to memoize matched versions for speed -class Range extends semver.Range - constructor: -> - super - @matchedVersions = new Set() - @unmatchedVersions = new Set() - - test: (version) -> - return true if @matchedVersions.has(version) - return false if @unmatchedVersions.has(version) - - matches = super - if matches - @matchedVersions.add(version) - else - @unmatchedVersions.add(version) - matches - -nativeModules = process.binding('natives') - -cache = - builtins: {} - debug: false - dependencies: {} - extensions: {} - folders: {} - ranges: {} - registered: false - resourcePath: null - resourcePathWithTrailingSlash: null - -# isAbsolute is inlined from fs-plus so that fs-plus itself can be required -# from this cache. -if process.platform is 'win32' - isAbsolute = (pathToCheck) -> - pathToCheck and (pathToCheck[1] is ':' or (pathToCheck[0] is '\\' and pathToCheck[1] is '\\')) -else - isAbsolute = (pathToCheck) -> - pathToCheck and pathToCheck[0] is '/' - -isCorePath = (pathToCheck) -> - pathToCheck.startsWith(cache.resourcePathWithTrailingSlash) - -loadDependencies = (modulePath, rootPath, rootMetadata, moduleCache) -> - fs = require 'fs-plus' - - for childPath in fs.listSync(path.join(modulePath, 'node_modules')) - continue if path.basename(childPath) is '.bin' - continue if rootPath is modulePath and rootMetadata.packageDependencies?.hasOwnProperty(path.basename(childPath)) - - childMetadataPath = path.join(childPath, 'package.json') - continue unless fs.isFileSync(childMetadataPath) - - childMetadata = JSON.parse(fs.readFileSync(childMetadataPath)) - if childMetadata?.version - try - mainPath = require.resolve(childPath) - catch error - mainPath = null - - if mainPath - moduleCache.dependencies.push - name: childMetadata.name - version: childMetadata.version - path: path.relative(rootPath, mainPath) - - loadDependencies(childPath, rootPath, rootMetadata, moduleCache) - - return - -loadFolderCompatibility = (modulePath, rootPath, rootMetadata, moduleCache) -> - fs = require 'fs-plus' - - metadataPath = path.join(modulePath, 'package.json') - return unless fs.isFileSync(metadataPath) - - dependencies = JSON.parse(fs.readFileSync(metadataPath))?.dependencies ? {} - - for name, version of dependencies - try - new Range(version) - catch error - delete dependencies[name] - - onDirectory = (childPath) -> - path.basename(childPath) isnt 'node_modules' - - extensions = ['.js', '.coffee', '.json', '.node'] - paths = {} - onFile = (childPath) -> - if path.extname(childPath) in extensions - relativePath = path.relative(rootPath, path.dirname(childPath)) - paths[relativePath] = true - fs.traverseTreeSync(modulePath, onFile, onDirectory) - - paths = Object.keys(paths) - if paths.length > 0 and Object.keys(dependencies).length > 0 - moduleCache.folders.push({paths, dependencies}) - - for childPath in fs.listSync(path.join(modulePath, 'node_modules')) - continue if path.basename(childPath) is '.bin' - continue if rootPath is modulePath and rootMetadata.packageDependencies?.hasOwnProperty(path.basename(childPath)) - - loadFolderCompatibility(childPath, rootPath, rootMetadata, moduleCache) - - return - -loadExtensions = (modulePath, rootPath, rootMetadata, moduleCache) -> - fs = require 'fs-plus' - extensions = ['.js', '.coffee', '.json', '.node'] - nodeModulesPath = path.join(rootPath, 'node_modules') - - onFile = (filePath) -> - filePath = path.relative(rootPath, filePath) - segments = filePath.split(path.sep) - return if 'test' in segments - return if 'tests' in segments - return if 'spec' in segments - return if 'specs' in segments - return if segments.length > 1 and not (segments[0] in ['exports', 'lib', 'node_modules', 'src', 'static', 'vendor']) - - extension = path.extname(filePath) - if extension in extensions - moduleCache.extensions[extension] ?= [] - moduleCache.extensions[extension].push(filePath) - - onDirectory = (childPath) -> - # Don't include extensions from bundled packages - # These are generated and stored in the package's own metadata cache - if rootMetadata.name is 'atom' - parentPath = path.dirname(childPath) - if parentPath is nodeModulesPath - packageName = path.basename(childPath) - return false if rootMetadata.packageDependencies?.hasOwnProperty(packageName) - - true - - fs.traverseTreeSync(rootPath, onFile, onDirectory) - - return - -satisfies = (version, rawRange) -> - unless parsedRange = cache.ranges[rawRange] - parsedRange = new Range(rawRange) - cache.ranges[rawRange] = parsedRange - parsedRange.test(version) - -resolveFilePath = (relativePath, parentModule) -> - return unless relativePath - return unless parentModule?.filename - return unless relativePath[0] is '.' or isAbsolute(relativePath) - - resolvedPath = path.resolve(path.dirname(parentModule.filename), relativePath) - return unless isCorePath(resolvedPath) - - extension = path.extname(resolvedPath) - if extension - return resolvedPath if cache.extensions[extension]?.has(resolvedPath) - else - for extension, paths of cache.extensions - resolvedPathWithExtension = "#{resolvedPath}#{extension}" - return resolvedPathWithExtension if paths.has(resolvedPathWithExtension) - - return - -resolveModulePath = (relativePath, parentModule) -> - return unless relativePath - return unless parentModule?.filename - - return if nativeModules.hasOwnProperty(relativePath) - return if relativePath[0] is '.' - return if isAbsolute(relativePath) - - folderPath = path.dirname(parentModule.filename) - - range = cache.folders[folderPath]?[relativePath] - unless range? - if builtinPath = cache.builtins[relativePath] - return builtinPath - else - return - - candidates = cache.dependencies[relativePath] - return unless candidates? - - for version, resolvedPath of candidates - if Module._cache.hasOwnProperty(resolvedPath) or isCorePath(resolvedPath) - return resolvedPath if satisfies(version, range) - - return - -registerBuiltins = (devMode) -> - if devMode or not cache.resourcePath.startsWith("#{process.resourcesPath}#{path.sep}") - fs = require 'fs-plus' - atomCoffeePath = path.join(cache.resourcePath, 'exports', 'atom.coffee') - cache.builtins.atom = atomCoffeePath if fs.isFileSync(atomCoffeePath) - cache.builtins.atom ?= path.join(cache.resourcePath, 'exports', 'atom.js') - - atomShellRoot = path.join(process.resourcesPath, 'atom.asar') - - commonRoot = path.join(atomShellRoot, 'common', 'api', 'lib') - commonBuiltins = ['callbacks-registry', 'clipboard', 'crash-reporter', 'screen', 'shell'] - for builtin in commonBuiltins - cache.builtins[builtin] = path.join(commonRoot, "#{builtin}.js") - - rendererRoot = path.join(atomShellRoot, 'renderer', 'api', 'lib') - rendererBuiltins = ['ipc', 'remote'] - for builtin in rendererBuiltins - cache.builtins[builtin] = path.join(rendererRoot, "#{builtin}.js") - -if cache.debug - cache.findPathCount = 0 - cache.findPathTime = 0 - cache.loadCount = 0 - cache.requireTime = 0 - global.moduleCache = cache - - originalLoad = Module::load - Module::load = -> - cache.loadCount++ - originalLoad.apply(this, arguments) - - originalRequire = Module::require - Module::require = -> - startTime = Date.now() - exports = originalRequire.apply(this, arguments) - cache.requireTime += Date.now() - startTime - exports - - originalFindPath = Module._findPath - Module._findPath = (request, paths) -> - cacheKey = JSON.stringify({request, paths}) - cache.findPathCount++ unless Module._pathCache[cacheKey] - - startTime = Date.now() - foundPath = originalFindPath.apply(global, arguments) - cache.findPathTime += Date.now() - startTime - foundPath - -exports.create = (modulePath) -> - fs = require 'fs-plus' - - modulePath = fs.realpathSync(modulePath) - metadataPath = path.join(modulePath, 'package.json') - metadata = JSON.parse(fs.readFileSync(metadataPath)) - - moduleCache = - version: 1 - dependencies: [] - extensions: {} - folders: [] - - loadDependencies(modulePath, modulePath, metadata, moduleCache) - loadFolderCompatibility(modulePath, modulePath, metadata, moduleCache) - loadExtensions(modulePath, modulePath, metadata, moduleCache) - - metadata._atomModuleCache = moduleCache - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)) - - return - -exports.register = ({resourcePath, devMode}={}) -> - return if cache.registered - - originalResolveFilename = Module._resolveFilename - Module._resolveFilename = (relativePath, parentModule) -> - resolvedPath = resolveModulePath(relativePath, parentModule) - resolvedPath ?= resolveFilePath(relativePath, parentModule) - resolvedPath ? originalResolveFilename(relativePath, parentModule) - - cache.registered = true - cache.resourcePath = resourcePath - cache.resourcePathWithTrailingSlash = "#{resourcePath}#{path.sep}" - registerBuiltins(devMode) - - return - -exports.add = (directoryPath, metadata) -> - # path.join isn't used in this function for speed since path.join calls - # path.normalize and all the paths are already normalized here. - - unless metadata? - try - metadata = require("#{directoryPath}#{path.sep}package.json") - catch error - return - - cacheToAdd = metadata?._atomModuleCache - return unless cacheToAdd? - - for dependency in cacheToAdd.dependencies ? [] - cache.dependencies[dependency.name] ?= {} - cache.dependencies[dependency.name][dependency.version] ?= "#{directoryPath}#{path.sep}#{dependency.path}" - - for entry in cacheToAdd.folders ? [] - for folderPath in entry.paths - if folderPath - cache.folders["#{directoryPath}#{path.sep}#{folderPath}"] = entry.dependencies - else - cache.folders[directoryPath] = entry.dependencies - - for extension, paths of cacheToAdd.extensions - cache.extensions[extension] ?= new Set() - for filePath in paths - cache.extensions[extension].add("#{directoryPath}#{path.sep}#{filePath}") - - return - -exports.cache = cache diff --git a/src/module-cache.js b/src/module-cache.js new file mode 100644 index 00000000000..4caa115219a --- /dev/null +++ b/src/module-cache.js @@ -0,0 +1,376 @@ +const Module = require('module'); +const path = require('path'); +const semver = require('semver'); + +// Extend semver.Range to memoize matched versions for speed +class Range extends semver.Range { + constructor() { + super(...arguments); + this.matchedVersions = new Set(); + this.unmatchedVersions = new Set(); + } + + test(version) { + if (this.matchedVersions.has(version)) return true; + if (this.unmatchedVersions.has(version)) return false; + + const matches = super.test(...arguments); + if (matches) { + this.matchedVersions.add(version); + } else { + this.unmatchedVersions.add(version); + } + return matches; + } +} + +let nativeModules = null; + +const cache = { + builtins: {}, + debug: false, + dependencies: {}, + extensions: {}, + folders: {}, + ranges: {}, + registered: false, + resourcePath: null, + resourcePathWithTrailingSlash: null +}; + +// isAbsolute is inlined from fs-plus so that fs-plus itself can be required +// from this cache. +let isAbsolute; +if (process.platform === 'win32') { + isAbsolute = pathToCheck => + pathToCheck && + (pathToCheck[1] === ':' || + (pathToCheck[0] === '\\' && pathToCheck[1] === '\\')); +} else { + isAbsolute = pathToCheck => pathToCheck && pathToCheck[0] === '/'; +} + +const isCorePath = pathToCheck => + pathToCheck.startsWith(cache.resourcePathWithTrailingSlash); + +function loadDependencies(modulePath, rootPath, rootMetadata, moduleCache) { + const fs = require('fs-plus'); + + for (let childPath of fs.listSync(path.join(modulePath, 'node_modules'))) { + if (path.basename(childPath) === '.bin') continue; + if ( + rootPath === modulePath && + (rootMetadata.packageDependencies && + rootMetadata.packageDependencies.hasOwnProperty( + path.basename(childPath) + )) + ) { + continue; + } + + const childMetadataPath = path.join(childPath, 'package.json'); + if (!fs.isFileSync(childMetadataPath)) continue; + + const childMetadata = JSON.parse(fs.readFileSync(childMetadataPath)); + if (childMetadata && childMetadata.version) { + let mainPath; + try { + mainPath = require.resolve(childPath); + } catch (error) { + mainPath = null; + } + + if (mainPath) { + moduleCache.dependencies.push({ + name: childMetadata.name, + version: childMetadata.version, + path: path.relative(rootPath, mainPath) + }); + } + + loadDependencies(childPath, rootPath, rootMetadata, moduleCache); + } + } +} + +function loadFolderCompatibility( + modulePath, + rootPath, + rootMetadata, + moduleCache +) { + const fs = require('fs-plus'); + + const metadataPath = path.join(modulePath, 'package.json'); + if (!fs.isFileSync(metadataPath)) return; + + const metadata = JSON.parse(fs.readFileSync(metadataPath)); + const dependencies = metadata.dependencies || {}; + + for (let name in dependencies) { + if (!semver.validRange(dependencies[name])) { + delete dependencies[name]; + } + } + + const onDirectory = childPath => path.basename(childPath) !== 'node_modules'; + + const extensions = ['.js', '.coffee', '.json', '.node']; + let paths = {}; + function onFile(childPath) { + const needle = path.extname(childPath); + if (extensions.includes(needle)) { + const relativePath = path.relative(rootPath, path.dirname(childPath)); + paths[relativePath] = true; + } + } + fs.traverseTreeSync(modulePath, onFile, onDirectory); + + paths = Object.keys(paths); + if (paths.length > 0 && Object.keys(dependencies).length > 0) { + moduleCache.folders.push({ paths, dependencies }); + } + + for (let childPath of fs.listSync(path.join(modulePath, 'node_modules'))) { + if (path.basename(childPath) === '.bin') continue; + if ( + rootPath === modulePath && + (rootMetadata.packageDependencies && + rootMetadata.packageDependencies.hasOwnProperty( + path.basename(childPath) + )) + ) { + continue; + } + loadFolderCompatibility(childPath, rootPath, rootMetadata, moduleCache); + } +} + +function loadExtensions(modulePath, rootPath, rootMetadata, moduleCache) { + const fs = require('fs-plus'); + const extensions = ['.js', '.coffee', '.json', '.node']; + const nodeModulesPath = path.join(rootPath, 'node_modules'); + + function onFile(filePath) { + filePath = path.relative(rootPath, filePath); + const segments = filePath.split(path.sep); + if (segments.includes('test')) return; + if (segments.includes('tests')) return; + if (segments.includes('spec')) return; + if (segments.includes('specs')) return; + if ( + segments.length > 1 && + !['exports', 'lib', 'node_modules', 'src', 'static', 'vendor'].includes( + segments[0] + ) + ) + return; + + const extension = path.extname(filePath); + if (extensions.includes(extension)) { + if (moduleCache.extensions[extension] == null) { + moduleCache.extensions[extension] = []; + } + moduleCache.extensions[extension].push(filePath); + } + } + + function onDirectory(childPath) { + // Don't include extensions from bundled packages + // These are generated and stored in the package's own metadata cache + if (rootMetadata.name === 'atom') { + const parentPath = path.dirname(childPath); + if (parentPath === nodeModulesPath) { + const packageName = path.basename(childPath); + if ( + rootMetadata.packageDependencies && + rootMetadata.packageDependencies.hasOwnProperty(packageName) + ) + return false; + } + } + + return true; + } + + fs.traverseTreeSync(rootPath, onFile, onDirectory); +} + +function satisfies(version, rawRange) { + let parsedRange; + if (!(parsedRange = cache.ranges[rawRange])) { + parsedRange = new Range(rawRange); + cache.ranges[rawRange] = parsedRange; + } + return parsedRange.test(version); +} + +function resolveFilePath(relativePath, parentModule) { + if (!relativePath) return; + if (!(parentModule && parentModule.filename)) return; + if (relativePath[0] !== '.' && !isAbsolute(relativePath)) return; + + const resolvedPath = path.resolve( + path.dirname(parentModule.filename), + relativePath + ); + if (!isCorePath(resolvedPath)) return; + + let extension = path.extname(resolvedPath); + if (extension) { + if ( + cache.extensions[extension] && + cache.extensions[extension].has(resolvedPath) + ) + return resolvedPath; + } else { + for (extension in cache.extensions) { + const paths = cache.extensions[extension]; + const resolvedPathWithExtension = `${resolvedPath}${extension}`; + if (paths.has(resolvedPathWithExtension)) { + return resolvedPathWithExtension; + } + } + } +} + +function resolveModulePath(relativePath, parentModule) { + if (!relativePath) return; + if (!(parentModule && parentModule.filename)) return; + + if (!nativeModules) nativeModules = process.binding('natives'); + if (nativeModules.hasOwnProperty(relativePath)) return; + if (relativePath[0] === '.') return; + if (isAbsolute(relativePath)) return; + + const folderPath = path.dirname(parentModule.filename); + + const range = + cache.folders[folderPath] && cache.folders[folderPath][relativePath]; + if (!range) { + const builtinPath = cache.builtins[relativePath]; + if (builtinPath) { + return builtinPath; + } else { + return; + } + } + + const candidates = cache.dependencies[relativePath]; + if (candidates == null) return; + + for (let version in candidates) { + const resolvedPath = candidates[version]; + if (Module._cache[resolvedPath] || isCorePath(resolvedPath)) { + if (satisfies(version, range)) return resolvedPath; + } + } +} + +function registerBuiltins(devMode) { + if ( + devMode || + !cache.resourcePath.startsWith(`${process.resourcesPath}${path.sep}`) + ) { + const fs = require('fs-plus'); + const atomJsPath = path.join(cache.resourcePath, 'exports', 'atom.js'); + if (fs.isFileSync(atomJsPath)) { + cache.builtins.atom = atomJsPath; + } + } + if (cache.builtins.atom == null) { + cache.builtins.atom = path.join(cache.resourcePath, 'exports', 'atom.js'); + } +} + +exports.create = function(modulePath) { + const fs = require('fs-plus'); + + modulePath = fs.realpathSync(modulePath); + const metadataPath = path.join(modulePath, 'package.json'); + const metadata = JSON.parse(fs.readFileSync(metadataPath)); + + const moduleCache = { + version: 1, + dependencies: [], + extensions: {}, + folders: [] + }; + + loadDependencies(modulePath, modulePath, metadata, moduleCache); + loadFolderCompatibility(modulePath, modulePath, metadata, moduleCache); + loadExtensions(modulePath, modulePath, metadata, moduleCache); + + metadata._atomModuleCache = moduleCache; + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +}; + +exports.register = function({ resourcePath, devMode } = {}) { + if (cache.registered) return; + + const originalResolveFilename = Module._resolveFilename; + Module._resolveFilename = function(relativePath, parentModule) { + let resolvedPath = resolveModulePath(relativePath, parentModule); + if (!resolvedPath) { + resolvedPath = resolveFilePath(relativePath, parentModule); + } + return resolvedPath || originalResolveFilename(relativePath, parentModule); + }; + + cache.registered = true; + cache.resourcePath = resourcePath; + cache.resourcePathWithTrailingSlash = `${resourcePath}${path.sep}`; + registerBuiltins(devMode); +}; + +exports.add = function(directoryPath, metadata) { + // path.join isn't used in this function for speed since path.join calls + // path.normalize and all the paths are already normalized here. + + if (metadata == null) { + try { + metadata = require(`${directoryPath}${path.sep}package.json`); + } catch (error) { + return; + } + } + + const cacheToAdd = metadata && metadata._atomModuleCache; + if (!cacheToAdd) return; + + for (const dependency of cacheToAdd.dependencies || []) { + if (!cache.dependencies[dependency.name]) { + cache.dependencies[dependency.name] = {}; + } + if (!cache.dependencies[dependency.name][dependency.version]) { + cache.dependencies[dependency.name][ + dependency.version + ] = `${directoryPath}${path.sep}${dependency.path}`; + } + } + + for (const entry of cacheToAdd.folders || []) { + for (const folderPath of entry.paths) { + if (folderPath) { + cache.folders[`${directoryPath}${path.sep}${folderPath}`] = + entry.dependencies; + } else { + cache.folders[directoryPath] = entry.dependencies; + } + } + } + + for (const extension in cacheToAdd.extensions) { + const paths = cacheToAdd.extensions[extension]; + if (!cache.extensions[extension]) { + cache.extensions[extension] = new Set(); + } + for (let filePath of paths) { + cache.extensions[extension].add(`${directoryPath}${path.sep}${filePath}`); + } + } +}; + +exports.cache = cache; + +exports.Range = Range; diff --git a/src/module-utils.js b/src/module-utils.js new file mode 100644 index 00000000000..dc2173e6f3b --- /dev/null +++ b/src/module-utils.js @@ -0,0 +1,21 @@ +// a require function with both ES5 and ES6 default export support +function requireModule(path) { + const modul = require(path); + if (modul === null || modul === undefined) { + // if null do not bother + return modul; + } else { + if ( + modul.__esModule === true && + (modul.default !== undefined && modul.default !== null) + ) { + // __esModule flag is true and default is exported, which means that + // an object containing the main functions (e.g. activate, etc) is default exported + return modul.default; + } else { + return modul; + } + } +} + +exports.requireModule = requireModule; diff --git a/src/native-compile-cache.js b/src/native-compile-cache.js new file mode 100644 index 00000000000..3f82e108e84 --- /dev/null +++ b/src/native-compile-cache.js @@ -0,0 +1,129 @@ +const Module = require('module'); +const path = require('path'); +const crypto = require('crypto'); +const vm = require('vm'); + +function computeHash(contents) { + return crypto + .createHash('sha1') + .update(contents, 'utf8') + .digest('hex'); +} + +class NativeCompileCache { + constructor() { + this.cacheStore = null; + this.previousModuleCompile = null; + } + + setCacheStore(store) { + this.cacheStore = store; + } + + setV8Version(v8Version) { + this.v8Version = v8Version.toString(); + } + + install() { + this.savePreviousModuleCompile(); + this.overrideModuleCompile(); + } + + uninstall() { + this.restorePreviousModuleCompile(); + } + + savePreviousModuleCompile() { + this.previousModuleCompile = Module.prototype._compile; + } + + runInThisContext(code, filename) { + const script = new vm.Script(code, filename); + const cachedData = script.createCachedData(); + return { + result: script.runInThisContext(), + cacheBuffer: typeof cachedData !== 'undefined' ? cachedData : null + }; + } + + runInThisContextCached(code, filename, cachedData) { + const script = new vm.Script(code, { filename, cachedData }); + return { + result: script.runInThisContext(), + wasRejected: script.cachedDataRejected + }; + } + + overrideModuleCompile() { + let self = this; + // Here we override Node's module.js + // (https://github.com/atom/node/blob/atom/lib/module.js#L378), changing + // only the bits that affect compilation in order to use the cached one. + Module.prototype._compile = function(content, filename) { + let moduleSelf = this; + // remove shebang + content = content.replace(/^#!.*/, ''); + function require(path) { + return moduleSelf.require(path); + } + require.resolve = function(request) { + return Module._resolveFilename(request, moduleSelf); + }; + require.main = process.mainModule; + + // Enable support to add extra extension types + require.extensions = Module._extensions; + require.cache = Module._cache; + + let dirname = path.dirname(filename); + + // create wrapper function + let wrapper = Module.wrap(content); + + let cacheKey = computeHash(wrapper + self.v8Version); + let compiledWrapper = null; + if (self.cacheStore.has(cacheKey)) { + let buffer = self.cacheStore.get(cacheKey); + let compilationResult = self.runInThisContextCached( + wrapper, + filename, + buffer + ); + compiledWrapper = compilationResult.result; + if (compilationResult.wasRejected) { + self.cacheStore.delete(cacheKey); + } + } else { + let compilationResult; + try { + compilationResult = self.runInThisContext(wrapper, filename); + } catch (err) { + console.error(`Error running script ${filename}`); + throw err; + } + if (compilationResult.cacheBuffer) { + self.cacheStore.set(cacheKey, compilationResult.cacheBuffer); + } + compiledWrapper = compilationResult.result; + } + + let args = [ + moduleSelf.exports, + require, + moduleSelf, + filename, + dirname, + process, + global, + Buffer + ]; + return compiledWrapper.apply(moduleSelf.exports, args); + }; + } + + restorePreviousModuleCompile() { + Module.prototype._compile = this.previousModuleCompile; + } +} + +module.exports = new NativeCompileCache(); diff --git a/src/native-watcher-registry.js b/src/native-watcher-registry.js new file mode 100644 index 00000000000..54f2cdcd4b1 --- /dev/null +++ b/src/native-watcher-registry.js @@ -0,0 +1,447 @@ +const path = require('path'); + +// Private: re-join the segments split from an absolute path to form another absolute path. +function absolute(...parts) { + const candidate = path.join(...parts); + return path.isAbsolute(candidate) + ? candidate + : path.join(path.sep, candidate); +} + +// Private: Map userland filesystem watcher subscriptions efficiently to deliver filesystem change notifications to +// each watcher with the most efficient coverage of native watchers. +// +// * If two watchers subscribe to the same directory, use a single native watcher for each. +// * Re-use a native watcher watching a parent directory for a watcher on a child directory. If the parent directory +// watcher is removed, it will be split into child watchers. +// * If any child directories already being watched, stop and replace them with a watcher on the parent directory. +// +// Uses a trie whose structure mirrors the directory structure. +class RegistryTree { + // Private: Construct a tree with no native watchers. + // + // * `basePathSegments` the position of this tree's root relative to the filesystem's root as an {Array} of directory + // names. + // * `createNative` {Function} used to construct new native watchers. It should accept an absolute path as an argument + // and return a new {NativeWatcher}. + constructor(basePathSegments, createNative) { + this.basePathSegments = basePathSegments; + this.root = new RegistryNode(); + this.createNative = createNative; + } + + // Private: Identify the native watcher that should be used to produce events at a watched path, creating a new one + // if necessary. + // + // * `pathSegments` the path to watch represented as an {Array} of directory names relative to this {RegistryTree}'s + // root. + // * `attachToNative` {Function} invoked with the appropriate native watcher and the absolute path to its watch root. + add(pathSegments, attachToNative) { + const absolutePathSegments = this.basePathSegments.concat(pathSegments); + const absolutePath = absolute(...absolutePathSegments); + + const attachToNew = childPaths => { + const native = this.createNative(absolutePath); + const leaf = new RegistryWatcherNode( + native, + absolutePathSegments, + childPaths + ); + this.root = this.root.insert(pathSegments, leaf); + + const sub = native.onWillStop(() => { + sub.dispose(); + this.root = + this.root.remove(pathSegments, this.createNative) || + new RegistryNode(); + }); + + attachToNative(native, absolutePath); + return native; + }; + + this.root.lookup(pathSegments).when({ + parent: (parent, remaining) => { + // An existing NativeWatcher is watching the same directory or a parent directory of the requested path. + // Attach this Watcher to it as a filtering watcher and record it as a dependent child path. + const native = parent.getNativeWatcher(); + parent.addChildPath(remaining); + attachToNative(native, absolute(...parent.getAbsolutePathSegments())); + }, + children: children => { + // One or more NativeWatchers exist on child directories of the requested path. Create a new native watcher + // on the parent directory, note the subscribed child paths, and cleanly stop the child native watchers. + const newNative = attachToNew(children.map(child => child.path)); + + for (let i = 0; i < children.length; i++) { + const childNode = children[i].node; + const childNative = childNode.getNativeWatcher(); + childNative.reattachTo(newNative, absolutePath); + childNative.dispose(); + childNative.stop(); + } + }, + missing: () => attachToNew([]) + }); + } + + // Private: Access the root node of the tree. + getRoot() { + return this.root; + } + + // Private: Return a {String} representation of this tree's structure for diagnostics and testing. + print() { + return this.root.print(); + } +} + +// Private: Non-leaf node in a {RegistryTree} used by the {NativeWatcherRegistry} to cover the allocated {Watcher} +// instances with the most efficient set of {NativeWatcher} instances possible. Each {RegistryNode} maps to a directory +// in the filesystem tree. +class RegistryNode { + // Private: Construct a new, empty node representing a node with no watchers. + constructor() { + this.children = {}; + } + + // Private: Recursively discover any existing watchers corresponding to a path. + // + // * `pathSegments` filesystem path of a new {Watcher} already split into an Array of directory names. + // + // Returns: A {ParentResult} if the exact requested directory or a parent directory is being watched, a + // {ChildrenResult} if one or more child paths are being watched, or a {MissingResult} if no relevant watchers + // exist. + lookup(pathSegments) { + if (pathSegments.length === 0) { + return new ChildrenResult(this.leaves([])); + } + + const child = this.children[pathSegments[0]]; + if (child === undefined) { + return new MissingResult(this); + } + + return child.lookup(pathSegments.slice(1)); + } + + // Private: Insert a new {RegistryWatcherNode} into the tree, creating new intermediate {RegistryNode} instances as + // needed. Any existing children of the watched directory are removed. + // + // * `pathSegments` filesystem path of the new {Watcher}, already split into an Array of directory names. + // * `leaf` initialized {RegistryWatcherNode} to insert + // + // Returns: The root of a new tree with the {RegistryWatcherNode} inserted at the correct location. Callers should + // replace their node references with the returned value. + insert(pathSegments, leaf) { + if (pathSegments.length === 0) { + return leaf; + } + + const pathKey = pathSegments[0]; + let child = this.children[pathKey]; + if (child === undefined) { + child = new RegistryNode(); + } + this.children[pathKey] = child.insert(pathSegments.slice(1), leaf); + return this; + } + + // Private: Remove a {RegistryWatcherNode} by its exact watched directory. + // + // * `pathSegments` absolute pre-split filesystem path of the node to remove. + // * `createSplitNative` callback to be invoked with each child path segment {Array} if the {RegistryWatcherNode} + // is split into child watchers rather than removed outright. See {RegistryWatcherNode.remove}. + // + // Returns: The root of a new tree with the {RegistryWatcherNode} removed. Callers should replace their node + // references with the returned value. + remove(pathSegments, createSplitNative) { + if (pathSegments.length === 0) { + // Attempt to remove a path with child watchers. Do nothing. + return this; + } + + const pathKey = pathSegments[0]; + const child = this.children[pathKey]; + if (child === undefined) { + // Attempt to remove a path that isn't watched. Do nothing. + return this; + } + + // Recurse + const newChild = child.remove(pathSegments.slice(1), createSplitNative); + if (newChild === null) { + delete this.children[pathKey]; + } else { + this.children[pathKey] = newChild; + } + + // Remove this node if all of its children have been removed + return Object.keys(this.children).length === 0 ? null : this; + } + + // Private: Discover all {RegistryWatcherNode} instances beneath this tree node and the child paths + // that they are watching. + // + // * `prefix` {Array} of intermediate path segments to prepend to the resulting child paths. + // + // Returns: A possibly empty {Array} of `{node, path}` objects describing {RegistryWatcherNode} + // instances beneath this node. + leaves(prefix) { + const results = []; + for (const p of Object.keys(this.children)) { + results.push(...this.children[p].leaves(prefix.concat([p]))); + } + return results; + } + + // Private: Return a {String} representation of this subtree for diagnostics and testing. + print(indent = 0) { + let spaces = ''; + for (let i = 0; i < indent; i++) { + spaces += ' '; + } + + let result = ''; + for (const p of Object.keys(this.children)) { + result += `${spaces}${p}\n${this.children[p].print(indent + 2)}`; + } + return result; + } +} + +// Private: Leaf node within a {NativeWatcherRegistry} tree. Represents a directory that is covered by a +// {NativeWatcher}. +class RegistryWatcherNode { + // Private: Allocate a new node to track a {NativeWatcher}. + // + // * `nativeWatcher` An existing {NativeWatcher} instance. + // * `absolutePathSegments` The absolute path to this {NativeWatcher}'s directory as an {Array} of + // path segments. + // * `childPaths` {Array} of child directories that are currently the responsibility of this + // {NativeWatcher}, if any. Directories are represented as arrays of the path segments between this + // node's directory and the watched child path. + constructor(nativeWatcher, absolutePathSegments, childPaths) { + this.nativeWatcher = nativeWatcher; + this.absolutePathSegments = absolutePathSegments; + + // Store child paths as joined strings so they work as Set members. + this.childPaths = new Set(); + for (let i = 0; i < childPaths.length; i++) { + this.childPaths.add(path.join(...childPaths[i])); + } + } + + // Private: Assume responsibility for a new child path. If this node is removed, it will instead + // split into a subtree with a new {RegistryWatcherNode} for each child path. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the watched + // child directory. + addChildPath(childPathSegments) { + this.childPaths.add(path.join(...childPathSegments)); + } + + // Private: Stop assuming responsibility for a previously assigned child path. If this node is + // removed, the named child path will no longer be allocated a {RegistryWatcherNode}. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the no longer + // watched child directory. + removeChildPath(childPathSegments) { + this.childPaths.delete(path.join(...childPathSegments)); + } + + // Private: Accessor for the {NativeWatcher}. + getNativeWatcher() { + return this.nativeWatcher; + } + + // Private: Return the absolute path watched by this {NativeWatcher} as an {Array} of directory names. + getAbsolutePathSegments() { + return this.absolutePathSegments; + } + + // Private: Identify how this watcher relates to a request to watch a directory tree. + // + // * `pathSegments` filesystem path of a new {Watcher} already split into an Array of directory names. + // + // Returns: A {ParentResult} referencing this node. + lookup(pathSegments) { + return new ParentResult(this, pathSegments); + } + + // Private: Remove this leaf node if the watcher's exact path matches. If this node is covering additional + // {Watcher} instances on child paths, it will be split into a subtree. + // + // * `pathSegments` filesystem path of the node to remove. + // * `createSplitNative` callback invoked with each {Array} of absolute child path segments to create a native + // watcher on a subtree of this node. + // + // Returns: If `pathSegments` match this watcher's path exactly, returns `null` if this node has no `childPaths` + // or a new {RegistryNode} on a newly allocated subtree if it did. If `pathSegments` does not match the watcher's + // path, it's an attempt to remove a subnode that doesn't exist, so the remove call has no effect and returns + // `this` unaltered. + remove(pathSegments, createSplitNative) { + if (pathSegments.length !== 0) { + return this; + } else if (this.childPaths.size > 0) { + let newSubTree = new RegistryTree( + this.absolutePathSegments, + createSplitNative + ); + + for (const childPath of this.childPaths) { + const childPathSegments = childPath.split(path.sep); + newSubTree.add(childPathSegments, (native, attachmentPath) => { + this.nativeWatcher.reattachTo(native, attachmentPath); + }); + } + + return newSubTree.getRoot(); + } else { + return null; + } + } + + // Private: Discover this {RegistryWatcherNode} instance. + // + // * `prefix` {Array} of intermediate path segments to prepend to the resulting child paths. + // + // Returns: An {Array} containing a `{node, path}` object describing this node. + leaves(prefix) { + return [{ node: this, path: prefix }]; + } + + // Private: Return a {String} representation of this watcher for diagnostics and testing. Indicates the number of + // child paths that this node's {NativeWatcher} is responsible for. + print(indent = 0) { + let result = ''; + for (let i = 0; i < indent; i++) { + result += ' '; + } + result += '[watcher'; + if (this.childPaths.size > 0) { + result += ` +${this.childPaths.size}`; + } + result += ']\n'; + + return result; + } +} + +// Private: A {RegistryNode} traversal result that's returned when neither a directory, its children, nor its parents +// are present in the tree. +class MissingResult { + // Private: Instantiate a new {MissingResult}. + // + // * `lastParent` the final successfully traversed {RegistryNode}. + constructor(lastParent) { + this.lastParent = lastParent; + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `missing` key that maps to a callback to be invoked when no results were returned + // by {RegistryNode.lookup}. The callback will be called with the last parent node that was encountered during the + // traversal. + // + // Returns: the result of the `actions` callback. + when(actions) { + return actions.missing(this.lastParent); + } +} + +// Private: A {RegistryNode.lookup} traversal result that's returned when a parent or an exact match of the requested +// directory is being watched by an existing {RegistryWatcherNode}. +class ParentResult { + // Private: Instantiate a new {ParentResult}. + // + // * `parent` the {RegistryWatcherNode} that was discovered. + // * `remainingPathSegments` an {Array} of the directories that lie between the leaf node's watched directory and + // the requested directory. This will be empty for exact matches. + constructor(parent, remainingPathSegments) { + this.parent = parent; + this.remainingPathSegments = remainingPathSegments; + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `parent` key that maps to a callback to be invoked when a parent of a requested + // requested directory is returned by a {RegistryNode.lookup} call. The callback will be called with the + // {RegistryWatcherNode} instance and an {Array} of the {String} path segments that separate the parent node + // and the requested directory. + // + // Returns: the result of the `actions` callback. + when(actions) { + return actions.parent(this.parent, this.remainingPathSegments); + } +} + +// Private: A {RegistryNode.lookup} traversal result that's returned when one or more children of the requested +// directory are already being watched. +class ChildrenResult { + // Private: Instantiate a new {ChildrenResult}. + // + // * `children` {Array} of the {RegistryWatcherNode} instances that were discovered. + constructor(children) { + this.children = children; + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `children` key that maps to a callback to be invoked when a parent of a requested + // requested directory is returned by a {RegistryNode.lookup} call. The callback will be called with the + // {RegistryWatcherNode} instance. + // + // Returns: the result of the `actions` callback. + when(actions) { + return actions.children(this.children); + } +} + +// Private: Track the directories being monitored by native filesystem watchers. Minimize the number of native watchers +// allocated to receive events for a desired set of directories by: +// +// 1. Subscribing to the same underlying {NativeWatcher} when watching the same directory multiple times. +// 2. Subscribing to an existing {NativeWatcher} on a parent of a desired directory. +// 3. Replacing multiple {NativeWatcher} instances on child directories with a single new {NativeWatcher} on the +// parent. +class NativeWatcherRegistry { + // Private: Instantiate an empty registry. + // + // * `createNative` {Function} that will be called with a normalized filesystem path to create a new native + // filesystem watcher. + constructor(createNative) { + this.tree = new RegistryTree([], createNative); + } + + // Private: Attach a watcher to a directory, assigning it a {NativeWatcher}. If a suitable {NativeWatcher} already + // exists, it will be attached to the new {Watcher} with an appropriate subpath configuration. Otherwise, the + // `createWatcher` callback will be invoked to create a new {NativeWatcher}, which will be registered in the tree + // and attached to the watcher. + // + // If any pre-existing child watchers are removed as a result of this operation, {NativeWatcher.onWillReattach} will + // be broadcast on each with the new parent watcher as an event payload to give child watchers a chance to attach to + // the new watcher. + // + // * `watcher` an unattached {Watcher}. + async attach(watcher) { + const normalizedDirectory = await watcher.getNormalizedPathPromise(); + const pathSegments = normalizedDirectory + .split(path.sep) + .filter(segment => segment.length > 0); + + this.tree.add(pathSegments, (native, nativePath) => { + watcher.attachToNative(native, nativePath); + }); + } + + // Private: Generate a visual representation of the currently active watchers managed by this + // registry. + // + // Returns a {String} showing the tree structure. + print() { + return this.tree.print(); + } +} + +module.exports = { NativeWatcherRegistry }; diff --git a/src/notification-manager.coffee b/src/notification-manager.coffee deleted file mode 100644 index f4ebd97db48..00000000000 --- a/src/notification-manager.coffee +++ /dev/null @@ -1,91 +0,0 @@ -{Emitter, Disposable} = require 'event-kit' -Notification = require '../src/notification' - -# Public: A notification manager used to create {Notification}s to be shown -# to the user. -module.exports = -class NotificationManager - constructor: -> - @notifications = [] - @emitter = new Emitter - - ### - Section: Events - ### - - # Public: Invoke the given callback after a notification has been added. - # - # * `callback` {Function} to be called after the notification is added. - # * `notification` The {Notification} that was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddNotification: (callback) -> - @emitter.on 'did-add-notification', callback - - ### - Section: Adding Notifications - ### - - # Public: Add a success notification. - # - # * `message` A {String} message - # * `options` An options {Object} with optional keys such as: - # * `detail` A {String} with additional details about the notification - addSuccess: (message, options) -> - @addNotification(new Notification('success', message, options)) - - # Public: Add an informational notification. - # - # * `message` A {String} message - # * `options` An options {Object} with optional keys such as: - # * `detail` A {String} with additional details about the notification - addInfo: (message, options) -> - @addNotification(new Notification('info', message, options)) - - # Public: Add a warning notification. - # - # * `message` A {String} message - # * `options` An options {Object} with optional keys such as: - # * `detail` A {String} with additional details about the notification - addWarning: (message, options) -> - @addNotification(new Notification('warning', message, options)) - - # Public: Add an error notification. - # - # * `message` A {String} message - # * `options` An options {Object} with optional keys such as: - # * `detail` A {String} with additional details about the notification - addError: (message, options) -> - @addNotification(new Notification('error', message, options)) - - # Public: Add a fatal error notification. - # - # * `message` A {String} message - # * `options` An options {Object} with optional keys such as: - # * `detail` A {String} with additional details about the notification - addFatalError: (message, options) -> - @addNotification(new Notification('fatal', message, options)) - - add: (type, message, options) -> - @addNotification(new Notification(type, message, options)) - - addNotification: (notification) -> - @notifications.push(notification) - @emitter.emit('did-add-notification', notification) - notification - - ### - Section: Getting Notifications - ### - - # Public: Get all the notifications. - # - # Returns an {Array} of {Notifications}s. - getNotifications: -> @notifications.slice() - - ### - Section: Managing Notifications - ### - - clear: -> - @notifications = [] diff --git a/src/notification-manager.js b/src/notification-manager.js new file mode 100644 index 00000000000..482515304d9 --- /dev/null +++ b/src/notification-manager.js @@ -0,0 +1,218 @@ +const { Emitter } = require('event-kit'); +const Notification = require('../src/notification'); + +// Public: A notification manager used to create {Notification}s to be shown +// to the user. +// +// An instance of this class is always available as the `atom.notifications` +// global. +module.exports = class NotificationManager { + constructor() { + this.notifications = []; + this.emitter = new Emitter(); + } + + /* + Section: Events + */ + + // Public: Invoke the given callback after a notification has been added. + // + // * `callback` {Function} to be called after the notification is added. + // * `notification` The {Notification} that was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddNotification(callback) { + 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 + */ + + // Public: Add a success notification. + // + // * `message` A {String} message + // * `options` (optional) An options {Object} with the following keys: + // * `buttons` (optional) An {Array} of {Object} where each {Object} has + // the following options: + // * `className` (optional) {String} a class name to add to the button's + // default class name (`btn btn-success`). + // * `onDidClick` (optional) {Function} callback to call when the button + // has been clicked. The context will be set to the + // {NotificationElement} instance. + // * `text` {String} inner text for the button + // * `description` (optional) A Markdown {String} containing a longer + // description about the notification. By default, this **will not** + // preserve newlines and whitespace when it is rendered. + // * `detail` (optional) A plain-text {String} containing additional + // details about the notification. By default, this **will** preserve + // newlines and whitespace when it is rendered. + // * `dismissable` (optional) A {Boolean} indicating whether this + // notification can be dismissed by the user. Defaults to `false`. + // * `icon` (optional) A {String} name of an icon from Octicons to display + // in the notification header. Defaults to `'check'`. + // + // Returns the {Notification} that was added. + addSuccess(message, options) { + return this.addNotification(new Notification('success', message, options)); + } + + // Public: Add an informational notification. + // + // * `message` A {String} message + // * `options` (optional) An options {Object} with the following keys: + // * `buttons` (optional) An {Array} of {Object} where each {Object} has + // the following options: + // * `className` (optional) {String} a class name to add to the button's + // default class name (`btn btn-info`). + // * `onDidClick` (optional) {Function} callback to call when the button + // has been clicked. The context will be set to the + // {NotificationElement} instance. + // * `text` {String} inner text for the button + // * `description` (optional) A Markdown {String} containing a longer + // description about the notification. By default, this **will not** + // preserve newlines and whitespace when it is rendered. + // * `detail` (optional) A plain-text {String} containing additional + // details about the notification. By default, this **will** preserve + // newlines and whitespace when it is rendered. + // * `dismissable` (optional) A {Boolean} indicating whether this + // notification can be dismissed by the user. Defaults to `false`. + // * `icon` (optional) A {String} name of an icon from Octicons to display + // in the notification header. Defaults to `'info'`. + // + // Returns the {Notification} that was added. + addInfo(message, options) { + return this.addNotification(new Notification('info', message, options)); + } + + // Public: Add a warning notification. + // + // * `message` A {String} message + // * `options` (optional) An options {Object} with the following keys: + // * `buttons` (optional) An {Array} of {Object} where each {Object} has + // the following options: + // * `className` (optional) {String} a class name to add to the button's + // default class name (`btn btn-warning`). + // * `onDidClick` (optional) {Function} callback to call when the button + // has been clicked. The context will be set to the + // {NotificationElement} instance. + // * `text` {String} inner text for the button + // * `description` (optional) A Markdown {String} containing a longer + // description about the notification. By default, this **will not** + // preserve newlines and whitespace when it is rendered. + // * `detail` (optional) A plain-text {String} containing additional + // details about the notification. By default, this **will** preserve + // newlines and whitespace when it is rendered. + // * `dismissable` (optional) A {Boolean} indicating whether this + // notification can be dismissed by the user. Defaults to `false`. + // * `icon` (optional) A {String} name of an icon from Octicons to display + // in the notification header. Defaults to `'alert'`. + // + // Returns the {Notification} that was added. + addWarning(message, options) { + return this.addNotification(new Notification('warning', message, options)); + } + + // Public: Add an error notification. + // + // * `message` A {String} message + // * `options` (optional) An options {Object} with the following keys: + // * `buttons` (optional) An {Array} of {Object} where each {Object} has + // the following options: + // * `className` (optional) {String} a class name to add to the button's + // default class name (`btn btn-error`). + // * `onDidClick` (optional) {Function} callback to call when the button + // has been clicked. The context will be set to the + // {NotificationElement} instance. + // * `text` {String} inner text for the button + // * `description` (optional) A Markdown {String} containing a longer + // description about the notification. By default, this **will not** + // preserve newlines and whitespace when it is rendered. + // * `detail` (optional) A plain-text {String} containing additional + // details about the notification. By default, this **will** preserve + // newlines and whitespace when it is rendered. + // * `dismissable` (optional) A {Boolean} indicating whether this + // notification can be dismissed by the user. Defaults to `false`. + // * `icon` (optional) A {String} name of an icon from Octicons to display + // in the notification header. Defaults to `'flame'`. + // * `stack` (optional) A preformatted {String} with stack trace + // information describing the location of the error. + // Requires `detail` to be set. + // + // Returns the {Notification} that was added. + addError(message, options) { + return this.addNotification(new Notification('error', message, options)); + } + + // Public: Add a fatal error notification. + // + // * `message` A {String} message + // * `options` (optional) An options {Object} with the following keys: + // * `buttons` (optional) An {Array} of {Object} where each {Object} has + // the following options: + // * `className` (optional) {String} a class name to add to the button's + // default class name (`btn btn-error`). + // * `onDidClick` (optional) {Function} callback to call when the button + // has been clicked. The context will be set to the + // {NotificationElement} instance. + // * `text` {String} inner text for the button + // * `description` (optional) A Markdown {String} containing a longer + // description about the notification. By default, this **will not** + // preserve newlines and whitespace when it is rendered. + // * `detail` (optional) A plain-text {String} containing additional + // details about the notification. By default, this **will** preserve + // newlines and whitespace when it is rendered. + // * `dismissable` (optional) A {Boolean} indicating whether this + // notification can be dismissed by the user. Defaults to `false`. + // * `icon` (optional) A {String} name of an icon from Octicons to display + // in the notification header. Defaults to `'bug'`. + // * `stack` (optional) A preformatted {String} with stack trace + // information describing the location of the error. + // Requires `detail` to be set. + // + // Returns the {Notification} that was added. + addFatalError(message, options) { + return this.addNotification(new Notification('fatal', message, options)); + } + + add(type, message, options) { + return this.addNotification(new Notification(type, message, options)); + } + + addNotification(notification) { + this.notifications.push(notification); + this.emitter.emit('did-add-notification', notification); + return notification; + } + + /* + Section: Getting Notifications + */ + + // Public: Get all the notifications. + // + // Returns an {Array} of {Notification}s. + getNotifications() { + return this.notifications.slice(); + } + + /* + Section: Managing Notifications + */ + + // Public: Clear all the notifications. + clear() { + this.notifications = []; + this.emitter.emit('did-clear-notifications'); + } +}; diff --git a/src/notification.coffee b/src/notification.coffee deleted file mode 100644 index 9dfffc59ad6..00000000000 --- a/src/notification.coffee +++ /dev/null @@ -1,57 +0,0 @@ -{Emitter} = require 'event-kit' - -# Public: A notification to the user containing a message and type. -module.exports = -class Notification - constructor: (@type, @message, @options={}) -> - @emitter = new Emitter - @timestamp = new Date() - @dismissed = true - @dismissed = false if @isDismissable() - @displayed = false - - onDidDismiss: (callback) -> - @emitter.on 'did-dismiss', callback - - onDidDisplay: (callback) -> - @emitter.on 'did-display', callback - - getOptions: -> @options - - # Public: Retrieves the {String} type. - getType: -> @type - - # Public: Retrieves the {String} message. - getMessage: -> @message - - getTimestamp: -> @timestamp - - getDetail: -> @options.detail - - isEqual: (other) -> - @getMessage() is other.getMessage() \ - and @getType() is other.getType() \ - and @getDetail() is other.getDetail() - - dismiss: -> - return unless @isDismissable() and not @isDismissed() - @dismissed = true - @emitter.emit 'did-dismiss', this - - isDismissed: -> @dismissed - - isDismissable: -> !!@options.dismissable - - wasDisplayed: -> @displayed - - setDisplayed: (@displayed) -> - @emitter.emit 'did-display', this - - getIcon: -> - return @options.icon if @options.icon? - switch @type - when 'fatal' then 'bug' - when 'error' then 'flame' - when 'warning' then 'alert' - when 'info' then 'info' - when 'success' then 'check' diff --git a/src/notification.js b/src/notification.js new file mode 100644 index 00000000000..88e82d7ecd1 --- /dev/null +++ b/src/notification.js @@ -0,0 +1,128 @@ +const { Emitter } = require('event-kit'); +const _ = require('underscore-plus'); + +// Public: A notification to the user containing a message and type. +module.exports = class Notification { + constructor(type, message, options = {}) { + this.type = type; + this.message = message; + this.options = options; + this.emitter = new Emitter(); + this.timestamp = new Date(); + this.dismissed = true; + if (this.isDismissable()) this.dismissed = false; + this.displayed = false; + this.validate(); + } + + validate() { + if (typeof this.message !== 'string') { + throw new Error( + `Notification must be created with string message: ${this.message}` + ); + } + + if (!_.isObject(this.options) || Array.isArray(this.options)) { + throw new Error( + `Notification must be created with an options object: ${this.options}` + ); + } + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the notification is dismissed. + // + // * `callback` {Function} to be called when the notification is dismissed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDismiss(callback) { + return this.emitter.on('did-dismiss', callback); + } + + // Public: Invoke the given callback when the notification is displayed. + // + // * `callback` {Function} to be called when the notification is displayed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDisplay(callback) { + return this.emitter.on('did-display', callback); + } + + getOptions() { + return this.options; + } + + /* + Section: Methods + */ + + // Public: Returns the {String} type. + getType() { + return this.type; + } + + // Public: Returns the {String} message. + getMessage() { + return this.message; + } + + getTimestamp() { + return this.timestamp; + } + + getDetail() { + return this.options.detail; + } + + isEqual(other) { + return ( + this.getMessage() === other.getMessage() && + this.getType() === other.getType() && + this.getDetail() === other.getDetail() + ); + } + + // Extended: Dismisses the notification, removing it from the UI. Calling this + // programmatically will call all callbacks added via `onDidDismiss`. + dismiss() { + if (!this.isDismissable() || this.isDismissed()) return; + this.dismissed = true; + this.emitter.emit('did-dismiss', this); + } + + isDismissed() { + return this.dismissed; + } + + isDismissable() { + return !!this.options.dismissable; + } + + wasDisplayed() { + return this.displayed; + } + + setDisplayed(displayed) { + this.displayed = displayed; + this.emitter.emit('did-display', this); + } + + getIcon() { + if (this.options.icon != null) return this.options.icon; + switch (this.type) { + case 'fatal': + return 'bug'; + case 'error': + return 'flame'; + case 'warning': + return 'alert'; + case 'info': + return 'info'; + case 'success': + return 'check'; + } + } +}; diff --git a/src/null-grammar.js b/src/null-grammar.js new file mode 100644 index 00000000000..81a7cd68eee --- /dev/null +++ b/src/null-grammar.js @@ -0,0 +1,42 @@ +const { Disposable } = require('event-kit'); + +module.exports = { + name: 'Null Grammar', + scopeName: 'text.plain.null-grammar', + scopeForId(id) { + if (id === -1 || id === -2) { + return this.scopeName; + } else { + return null; + } + }, + startIdForScope(scopeName) { + if (scopeName === this.scopeName) { + return -1; + } else { + return null; + } + }, + endIdForScope(scopeName) { + if (scopeName === this.scopeName) { + return -2; + } else { + return null; + } + }, + tokenizeLine(text) { + return { + tags: [ + this.startIdForScope(this.scopeName), + text.length, + this.endIdForScope(this.scopeName) + ], + ruleStack: null + }; + }, + onDidUpdate(callback) { + return new Disposable(noop); + } +}; + +function noop() {} diff --git a/src/overlay-manager.coffee b/src/overlay-manager.coffee index 21a484fbe3f..c8c7f4b275c 100644 --- a/src/overlay-manager.coffee +++ b/src/overlay-manager.coffee @@ -1,6 +1,9 @@ +ElementResizeDetector = require('element-resize-detector') +elementResizeDetector = null + module.exports = class OverlayManager - constructor: (@presenter, @container) -> + constructor: (@presenter, @container, @views) -> @overlaysById = {} render: (state) -> @@ -12,6 +15,7 @@ class OverlayManager unless state.content.overlays.hasOwnProperty(id) delete @overlaysById[id] overlayNode.remove() + elementResizeDetector.uninstall(overlayNode) shouldUpdateOverlay: (decorationId, overlay) -> cachedOverlay = @overlaysById[decorationId] @@ -19,26 +23,30 @@ class OverlayManager cachedOverlay.pixelPosition?.top isnt overlay.pixelPosition?.top or cachedOverlay.pixelPosition?.left isnt overlay.pixelPosition?.left - measureOverlays: -> - for decorationId, {itemView} of @overlaysById - @measureOverlay(decorationId, itemView) - measureOverlay: (decorationId, itemView) -> contentMargin = parseInt(getComputedStyle(itemView)['margin-left']) ? 0 @presenter.setOverlayDimensions(decorationId, itemView.offsetWidth, itemView.offsetHeight, contentMargin) - renderOverlay: (state, decorationId, {item, pixelPosition}) -> - itemView = atom.views.getView(item) + renderOverlay: (state, decorationId, {item, pixelPosition, class: klass}) -> + itemView = @views.getView(item) cachedOverlay = @overlaysById[decorationId] unless overlayNode = cachedOverlay?.overlayNode overlayNode = document.createElement('atom-overlay') + overlayNode.classList.add(klass) if klass? + elementResizeDetector ?= ElementResizeDetector({strategy: 'scroll'}) + elementResizeDetector.listenTo(overlayNode, => + if overlayNode.parentElement? + @measureOverlay(decorationId, itemView) + ) @container.appendChild(overlayNode) @overlaysById[decorationId] = cachedOverlay = {overlayNode, itemView} # The same node may be used in more than one overlay. This steals the node # back if it has been displayed in another overlay. - overlayNode.appendChild(itemView) if overlayNode.childNodes.length is 0 + overlayNode.appendChild(itemView) unless overlayNode.contains(itemView) cachedOverlay.pixelPosition = pixelPosition overlayNode.style.top = pixelPosition.top + 'px' overlayNode.style.left = pixelPosition.left + 'px' + + @measureOverlay(decorationId, itemView) diff --git a/src/package-manager.coffee b/src/package-manager.coffee deleted file mode 100644 index 8d2f4d66358..00000000000 --- a/src/package-manager.coffee +++ /dev/null @@ -1,468 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -{Emitter} = require 'event-kit' -fs = require 'fs-plus' -Q = require 'q' -Grim = require 'grim' - -ServiceHub = require 'service-hub' -Package = require './package' -ThemePackage = require './theme-package' - -# Extended: Package manager for coordinating the lifecycle of Atom packages. -# -# An instance of this class is always available as the `atom.packages` global. -# -# Packages can be loaded, activated, and deactivated, and unloaded: -# * Loading a package reads and parses the package's metadata and resources -# such as keymaps, menus, stylesheets, etc. -# * Activating a package registers the loaded resources and calls `activate()` -# on the package's main module. -# * Deactivating a package unregisters the package's resources and calls -# `deactivate()` on the package's main module. -# * Unloading a package removes it completely from the package manager. -# -# Packages can be enabled/disabled via the `core.disabledPackages` config -# settings and also by calling `enablePackage()/disablePackage()`. -module.exports = -class PackageManager - constructor: ({configDirPath, @devMode, safeMode, @resourcePath}) -> - @emitter = new Emitter - @packageDirPaths = [] - unless safeMode - if @devMode - @packageDirPaths.push(path.join(configDirPath, "dev", "packages")) - @packageDirPaths.push(path.join(configDirPath, "packages")) - - @loadedPackages = {} - @activePackages = {} - @packageStates = {} - @serviceHub = new ServiceHub - - @packageActivators = [] - @registerPackageActivator(this, ['atom', 'textmate']) - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when all packages have been loaded. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidLoadInitialPackages: (callback) -> - @emitter.on 'did-load-initial-packages', callback - - # Public: Invoke the given callback when all packages have been activated. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidActivateInitialPackages: (callback) -> - @emitter.on 'did-activate-initial-packages', callback - - # Public: Invoke the given callback when a package is activated. - # - # * `callback` A {Function} to be invoked when a package is activated. - # * `package` The {Package} that was activated. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidActivatePackage: (callback) -> - @emitter.on 'did-activate-package', callback - - # Public: Invoke the given callback when a package is deactivated. - # - # * `callback` A {Function} to be invoked when a package is deactivated. - # * `package` The {Package} that was deactivated. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDeactivatePackage: (callback) -> - @emitter.on 'did-deactivate-package', callback - - # Public: Invoke the given callback when a package is loaded. - # - # * `callback` A {Function} to be invoked when a package is loaded. - # * `package` The {Package} that was loaded. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidLoadPackage: (callback) -> - @emitter.on 'did-load-package', callback - - # Public: Invoke the given callback when a package is unloaded. - # - # * `callback` A {Function} to be invoked when a package is unloaded. - # * `package` The {Package} that was unloaded. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidUnloadPackage: (callback) -> - @emitter.on 'did-unload-package', callback - - ### - Section: Package system data - ### - - # Public: Get the path to the apm command. - # - # Return a {String} file path to apm. - getApmPath: -> - return @apmPath if @apmPath? - - commandName = 'apm' - commandName += '.cmd' if process.platform is 'win32' - apmRoot = path.join(process.resourcesPath, 'app', 'apm') - @apmPath = path.join(apmRoot, 'bin', commandName) - unless fs.isFileSync(@apmPath) - @apmPath = path.join(apmRoot, 'node_modules', 'atom-package-manager', 'bin', commandName) - @apmPath - - # Public: Get the paths being used to look for packages. - # - # Returns an {Array} of {String} directory paths. - getPackageDirPaths: -> - _.clone(@packageDirPaths) - - ### - Section: General package data - ### - - # Public: Resolve the given package name to a path on disk. - # - # * `name` - The {String} package name. - # - # Return a {String} folder path or undefined if it could not be resolved. - resolvePackagePath: (name) -> - return name if fs.isDirectorySync(name) - - packagePath = fs.resolve(@packageDirPaths..., name) - return packagePath if fs.isDirectorySync(packagePath) - - packagePath = path.join(@resourcePath, 'node_modules', name) - return packagePath if @hasAtomEngine(packagePath) - - # Public: Is the package with the given name bundled with Atom? - # - # * `name` - The {String} package name. - # - # Returns a {Boolean}. - isBundledPackage: (name) -> - @getPackageDependencies().hasOwnProperty(name) - - ### - Section: Enabling and disabling packages - ### - - # Public: Enable the package with the given name. - # - # Returns the {Package} that was enabled or null if it isn't loaded. - enablePackage: (name) -> - pack = @loadPackage(name) - pack?.enable() - pack - - # Public: Disable the package with the given name. - # - # Returns the {Package} that was disabled or null if it isn't loaded. - disablePackage: (name) -> - pack = @loadPackage(name) - pack?.disable() - pack - - # Public: Is the package with the given name disabled? - # - # * `name` - The {String} package name. - # - # Returns a {Boolean}. - isPackageDisabled: (name) -> - _.include(atom.config.get('core.disabledPackages') ? [], name) - - ### - Section: Accessing active packages - ### - - # Public: Get an {Array} of all the active {Package}s. - getActivePackages: -> - _.values(@activePackages) - - # Public: Get the active {Package} with the given name. - # - # * `name` - The {String} package name. - # - # Returns a {Package} or undefined. - getActivePackage: (name) -> - @activePackages[name] - - # Public: Is the {Package} with the given name active? - # - # * `name` - The {String} package name. - # - # Returns a {Boolean}. - isPackageActive: (name) -> - @getActivePackage(name)? - - ### - Section: Accessing loaded packages - ### - - # Public: Get an {Array} of all the loaded {Package}s - getLoadedPackages: -> - _.values(@loadedPackages) - - # Get packages for a certain package type - # - # * `types` an {Array} of {String}s like ['atom', 'textmate']. - getLoadedPackagesForTypes: (types) -> - pack for pack in @getLoadedPackages() when pack.getType() in types - - # Public: Get the loaded {Package} with the given name. - # - # * `name` - The {String} package name. - # - # Returns a {Package} or undefined. - getLoadedPackage: (name) -> - @loadedPackages[name] - - # Public: Is the package with the given name loaded? - # - # * `name` - The {String} package name. - # - # Returns a {Boolean}. - isPackageLoaded: (name) -> - @getLoadedPackage(name)? - - ### - Section: Accessing available packages - ### - - # Public: Get an {Array} of {String}s of all the available package paths. - getAvailablePackagePaths: -> - packagePaths = [] - - for packageDirPath in @packageDirPaths - for packagePath in fs.listSync(packageDirPath) - packagePaths.push(packagePath) if fs.isDirectorySync(packagePath) - - packagesPath = path.join(@resourcePath, 'node_modules') - for packageName, packageVersion of @getPackageDependencies() - packagePath = path.join(packagesPath, packageName) - packagePaths.push(packagePath) if fs.isDirectorySync(packagePath) - - _.uniq(packagePaths) - - # Public: Get an {Array} of {String}s of all the available package names. - getAvailablePackageNames: -> - _.uniq _.map @getAvailablePackagePaths(), (packagePath) -> path.basename(packagePath) - - # Public: Get an {Array} of {String}s of all the available package metadata. - getAvailablePackageMetadata: -> - packages = [] - for packagePath in @getAvailablePackagePaths() - name = path.basename(packagePath) - metadata = @getLoadedPackage(name)?.metadata ? Package.loadMetadata(packagePath, true) - packages.push(metadata) - packages - - ### - Section: Private - ### - - getPackageState: (name) -> - @packageStates[name] - - setPackageState: (name, state) -> - @packageStates[name] = state - - getPackageDependencies: -> - unless @packageDependencies? - try - @packageDependencies = require('../package.json')?.packageDependencies - @packageDependencies ?= {} - - @packageDependencies - - hasAtomEngine: (packagePath) -> - metadata = Package.loadMetadata(packagePath, true) - metadata?.engines?.atom? - - unobserveDisabledPackages: -> - @disabledPackagesSubscription?.dispose() - @disabledPackagesSubscription = null - - observeDisabledPackages: -> - @disabledPackagesSubscription ?= atom.config.onDidChange 'core.disabledPackages', ({newValue, oldValue}) => - packagesToEnable = _.difference(oldValue, newValue) - packagesToDisable = _.difference(newValue, oldValue) - - @deactivatePackage(packageName) for packageName in packagesToDisable when @getActivePackage(packageName) - @activatePackage(packageName) for packageName in packagesToEnable - null - - loadPackages: -> - # Ensure atom exports is already in the require cache so the load time - # of the first package isn't skewed by being the first to require atom - require '../exports/atom' - - # TODO: remove after a few atom versions. - @uninstallAutocompletePlus() - - packagePaths = @getAvailablePackagePaths() - packagePaths = packagePaths.filter (packagePath) => not @isPackageDisabled(path.basename(packagePath)) - packagePaths = _.uniq packagePaths, (packagePath) -> path.basename(packagePath) - @loadPackage(packagePath) for packagePath in packagePaths - @emit 'loaded' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-load-initial-packages' - - loadPackage: (nameOrPath) -> - return pack if pack = @getLoadedPackage(nameOrPath) - - if packagePath = @resolvePackagePath(nameOrPath) - name = path.basename(nameOrPath) - return pack if pack = @getLoadedPackage(name) - - try - metadata = Package.loadMetadata(packagePath) ? {} - catch error - @handleMetadataError(error, packagePath) - return null - - if metadata.theme - pack = new ThemePackage(packagePath, metadata) - else - pack = new Package(packagePath, metadata) - pack.load() - @loadedPackages[pack.name] = pack - @emitter.emit 'did-load-package', pack - return pack - else - console.warn "Could not resolve '#{nameOrPath}' to a package path" - null - - unloadPackages: -> - @unloadPackage(name) for name in _.keys(@loadedPackages) - null - - unloadPackage: (name) -> - if @isPackageActive(name) - throw new Error("Tried to unload active package '#{name}'") - - if pack = @getLoadedPackage(name) - delete @loadedPackages[pack.name] - @emitter.emit 'did-unload-package', pack - else - throw new Error("No loaded package for name '#{name}'") - - # Activate all the packages that should be activated. - activate: -> - promises = [] - for [activator, types] in @packageActivators - packages = @getLoadedPackagesForTypes(types) - promises = promises.concat(activator.activatePackages(packages)) - Q.all(promises).then => - @emit 'activated' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-activate-initial-packages' - - # another type of package manager can handle other package types. - # See ThemeManager - registerPackageActivator: (activator, types) -> - @packageActivators.push([activator, types]) - - activatePackages: (packages) -> - promises = [] - atom.config.transact => - for pack in packages - promise = @activatePackage(pack.name) - promises.push(promise) unless pack.hasActivationCommands() - return - @observeDisabledPackages() - promises - - # Activate a single package by name - activatePackage: (name) -> - if pack = @getActivePackage(name) - Q(pack) - else if pack = @loadPackage(name) - pack.activate().then => - @activePackages[pack.name] = pack - @emitter.emit 'did-activate-package', pack - pack - else - Q.reject(new Error("Failed to load package '#{name}'")) - - # Deactivate all packages - deactivatePackages: -> - atom.config.transact => - @deactivatePackage(pack.name) for pack in @getLoadedPackages() - return - @unobserveDisabledPackages() - - # Deactivate the package with the given name - deactivatePackage: (name) -> - pack = @getLoadedPackage(name) - if @isPackageActive(name) - @setPackageState(pack.name, state) if state = pack.serialize?() - pack.deactivate() - delete @activePackages[pack.name] - @emitter.emit 'did-deactivate-package', pack - - handleMetadataError: (error, packagePath) -> - metadataPath = path.join(packagePath, 'package.json') - detail = "#{error.message} in #{metadataPath}" - stack = "#{error.stack}\n at #{metadataPath}:1:1" - message = "Failed to load the #{path.basename(packagePath)} package" - atom.notifications.addError(message, {stack, detail, dismissable: true}) - - # TODO: remove these autocomplete-plus specific helpers after a few versions. - uninstallAutocompletePlus: -> - packageDir = null - devDir = path.join("dev", "packages") - for packageDirPath in @packageDirPaths - if not packageDirPath.endsWith(devDir) - packageDir = packageDirPath - break - - if packageDir? - dirsToRemove = [ - path.join(packageDir, 'autocomplete-plus') - path.join(packageDir, 'autocomplete-atom-api') - path.join(packageDir, 'autocomplete-css') - path.join(packageDir, 'autocomplete-html') - path.join(packageDir, 'autocomplete-snippets') - ] - for dirToRemove in dirsToRemove - @uninstallDirectory(dirToRemove) - return - - uninstallDirectory: (directory) -> - symlinkPromise = new Promise (resolve) -> - fs.isSymbolicLink directory, (isSymLink) -> resolve(isSymLink) - - dirPromise = new Promise (resolve) -> - fs.isDirectory directory, (isDir) -> resolve(isDir) - - Promise.all([symlinkPromise, dirPromise]).then (values) -> - [isSymLink, isDir] = values - if not isSymLink and isDir - fs.remove directory, -> - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(PackageManager) - - PackageManager::on = (eventName) -> - switch eventName - when 'loaded' - Grim.deprecate 'Use PackageManager::onDidLoadInitialPackages instead' - when 'activated' - Grim.deprecate 'Use PackageManager::onDidActivateInitialPackages instead' - else - Grim.deprecate 'PackageManager::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - - PackageManager::onDidLoadAll = (callback) -> - Grim.deprecate("Use `::onDidLoadInitialPackages` instead.") - @onDidLoadInitialPackages(callback) - - PackageManager::onDidActivateAll = (callback) -> - Grim.deprecate("Use `::onDidActivateInitialPackages` instead.") - @onDidActivateInitialPackages(callback) diff --git a/src/package-manager.js b/src/package-manager.js new file mode 100644 index 00000000000..163e3ca9d5d --- /dev/null +++ b/src/package-manager.js @@ -0,0 +1,1034 @@ +const path = require('path'); +let normalizePackageData = null; + +const _ = require('underscore-plus'); +const { Emitter } = require('event-kit'); +const fs = require('fs-plus'); +const CSON = require('season'); + +const ServiceHub = require('service-hub'); +const Package = require('./package'); +const ThemePackage = require('./theme-package'); +const ModuleCache = require('./module-cache'); +const packageJSON = require('../package.json'); + +// Extended: Package manager for coordinating the lifecycle of Atom packages. +// +// An instance of this class is always available as the `atom.packages` global. +// +// Packages can be loaded, activated, and deactivated, and unloaded: +// * Loading a package reads and parses the package's metadata and resources +// such as keymaps, menus, stylesheets, etc. +// * Activating a package registers the loaded resources and calls `activate()` +// on the package's main module. +// * Deactivating a package unregisters the package's resources and calls +// `deactivate()` on the package's main module. +// * Unloading a package removes it completely from the package manager. +// +// Packages can be enabled/disabled via the `core.disabledPackages` config +// settings and also by calling `enablePackage()/disablePackage()`. +module.exports = class PackageManager { + constructor(params) { + ({ + config: this.config, + styleManager: this.styleManager, + notificationManager: this.notificationManager, + keymapManager: this.keymapManager, + commandRegistry: this.commandRegistry, + grammarRegistry: this.grammarRegistry, + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry, + uriHandlerRegistry: this.uriHandlerRegistry + } = params); + + this.emitter = new Emitter(); + this.activationHookEmitter = new Emitter(); + this.packageDirPaths = []; + this.deferredActivationHooks = []; + this.triggeredActivationHooks = new Set(); + this.packagesCache = + packageJSON._atomPackages != null ? packageJSON._atomPackages : {}; + this.packageDependencies = + packageJSON.packageDependencies != null + ? packageJSON.packageDependencies + : {}; + this.deprecatedPackages = packageJSON._deprecatedPackages || {}; + this.deprecatedPackageRanges = {}; + this.initialPackagesLoaded = false; + this.initialPackagesActivated = false; + this.preloadedPackages = {}; + this.loadedPackages = {}; + this.activePackages = {}; + this.activatingPackages = {}; + this.packageStates = {}; + this.serviceHub = new ServiceHub(); + + this.packageActivators = []; + this.registerPackageActivator(this, ['atom', 'textmate']); + } + + initialize(params) { + this.devMode = params.devMode; + this.resourcePath = params.resourcePath; + if (params.configDirPath != null && !params.safeMode) { + if (this.devMode) { + this.packageDirPaths.push( + path.join(params.configDirPath, 'dev', 'packages') + ); + this.packageDirPaths.push(path.join(this.resourcePath, 'packages')); + } + this.packageDirPaths.push(path.join(params.configDirPath, 'packages')); + } + } + + setContextMenuManager(contextMenuManager) { + this.contextMenuManager = contextMenuManager; + } + + setMenuManager(menuManager) { + this.menuManager = menuManager; + } + + setThemeManager(themeManager) { + this.themeManager = themeManager; + } + + async reset() { + this.serviceHub.clear(); + await this.deactivatePackages(); + this.loadedPackages = {}; + this.preloadedPackages = {}; + this.packageStates = {}; + this.packagesCache = + packageJSON._atomPackages != null ? packageJSON._atomPackages : {}; + this.packageDependencies = + packageJSON.packageDependencies != null + ? packageJSON.packageDependencies + : {}; + this.triggeredActivationHooks.clear(); + this.activatePromise = null; + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when all packages have been loaded. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidLoadInitialPackages(callback) { + return this.emitter.on('did-load-initial-packages', callback); + } + + // Public: Invoke the given callback when all packages have been activated. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidActivateInitialPackages(callback) { + return this.emitter.on('did-activate-initial-packages', callback); + } + + getActivatePromise() { + if (this.activatePromise) { + return this.activatePromise; + } else { + return Promise.resolve(); + } + } + + // Public: Invoke the given callback when a package is activated. + // + // * `callback` A {Function} to be invoked when a package is activated. + // * `package` The {Package} that was activated. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidActivatePackage(callback) { + return this.emitter.on('did-activate-package', callback); + } + + // Public: Invoke the given callback when a package is deactivated. + // + // * `callback` A {Function} to be invoked when a package is deactivated. + // * `package` The {Package} that was deactivated. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDeactivatePackage(callback) { + return this.emitter.on('did-deactivate-package', callback); + } + + // Public: Invoke the given callback when a package is loaded. + // + // * `callback` A {Function} to be invoked when a package is loaded. + // * `package` The {Package} that was loaded. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidLoadPackage(callback) { + return this.emitter.on('did-load-package', callback); + } + + // Public: Invoke the given callback when a package is unloaded. + // + // * `callback` A {Function} to be invoked when a package is unloaded. + // * `package` The {Package} that was unloaded. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidUnloadPackage(callback) { + return this.emitter.on('did-unload-package', callback); + } + + /* + Section: Package system data + */ + + // Public: Get the path to the apm command. + // + // Uses the value of the `core.apmPath` config setting if it exists. + // + // Return a {String} file path to apm. + getApmPath() { + const configPath = atom.config.get('core.apmPath'); + if (configPath || this.apmPath) { + return configPath || this.apmPath; + } + + const commandName = process.platform === 'win32' ? 'apm.cmd' : 'apm'; + const apmRoot = path.join(process.resourcesPath, 'app', 'apm'); + this.apmPath = path.join(apmRoot, 'bin', commandName); + if (!fs.isFileSync(this.apmPath)) { + this.apmPath = path.join( + apmRoot, + 'node_modules', + 'atom-package-manager', + 'bin', + commandName + ); + } + return this.apmPath; + } + + // Public: Get the paths being used to look for packages. + // + // Returns an {Array} of {String} directory paths. + getPackageDirPaths() { + return _.clone(this.packageDirPaths); + } + + /* + Section: General package data + */ + + // Public: Resolve the given package name to a path on disk. + // + // * `name` - The {String} package name. + // + // Return a {String} folder path or undefined if it could not be resolved. + resolvePackagePath(name) { + if (fs.isDirectorySync(name)) { + return name; + } + + let packagePath = fs.resolve(...this.packageDirPaths, name); + if (fs.isDirectorySync(packagePath)) { + return packagePath; + } + + packagePath = path.join(this.resourcePath, 'node_modules', name); + if (this.hasAtomEngine(packagePath)) { + return packagePath; + } + + return null; + } + + // Public: Is the package with the given name bundled with Atom? + // + // * `name` - The {String} package name. + // + // Returns a {Boolean}. + isBundledPackage(name) { + return this.getPackageDependencies().hasOwnProperty(name); + } + + isDeprecatedPackage(name, version) { + const metadata = this.deprecatedPackages[name]; + if (!metadata) return false; + if (!metadata.version) return true; + + let range = this.deprecatedPackageRanges[metadata.version]; + if (!range) { + try { + range = new ModuleCache.Range(metadata.version); + } catch (error) { + range = NullVersionRange; + } + this.deprecatedPackageRanges[metadata.version] = range; + } + return range.test(version); + } + + getDeprecatedPackageMetadata(name) { + const metadata = this.deprecatedPackages[name]; + if (metadata) Object.freeze(metadata); + return metadata; + } + + /* + Section: Enabling and disabling packages + */ + + // Public: Enable the package with the given name. + // + // * `name` - The {String} package name. + // + // Returns the {Package} that was enabled or null if it isn't loaded. + enablePackage(name) { + const pack = this.loadPackage(name); + if (pack != null) { + pack.enable(); + } + return pack; + } + + // Public: Disable the package with the given name. + // + // * `name` - The {String} package name. + // + // Returns the {Package} that was disabled or null if it isn't loaded. + disablePackage(name) { + const pack = this.loadPackage(name); + if (!this.isPackageDisabled(name) && pack != null) { + pack.disable(); + } + return pack; + } + + // Public: Is the package with the given name disabled? + // + // * `name` - The {String} package name. + // + // Returns a {Boolean}. + isPackageDisabled(name) { + return _.include(this.config.get('core.disabledPackages') || [], name); + } + + /* + Section: Accessing active packages + */ + + // Public: Get an {Array} of all the active {Package}s. + getActivePackages() { + return _.values(this.activePackages); + } + + // Public: Get the active {Package} with the given name. + // + // * `name` - The {String} package name. + // + // Returns a {Package} or undefined. + getActivePackage(name) { + return this.activePackages[name]; + } + + // Public: Is the {Package} with the given name active? + // + // * `name` - The {String} package name. + // + // Returns a {Boolean}. + isPackageActive(name) { + return this.getActivePackage(name) != null; + } + + // Public: Returns a {Boolean} indicating whether package activation has occurred. + hasActivatedInitialPackages() { + return this.initialPackagesActivated; + } + + /* + Section: Accessing loaded packages + */ + + // Public: Get an {Array} of all the loaded {Package}s + getLoadedPackages() { + return _.values(this.loadedPackages); + } + + // Get packages for a certain package type + // + // * `types` an {Array} of {String}s like ['atom', 'textmate']. + getLoadedPackagesForTypes(types) { + return this.getLoadedPackages().filter(p => types.includes(p.getType())); + } + + // Public: Get the loaded {Package} with the given name. + // + // * `name` - The {String} package name. + // + // Returns a {Package} or undefined. + getLoadedPackage(name) { + return this.loadedPackages[name]; + } + + // Public: Is the package with the given name loaded? + // + // * `name` - The {String} package name. + // + // Returns a {Boolean}. + isPackageLoaded(name) { + return this.getLoadedPackage(name) != null; + } + + // Public: Returns a {Boolean} indicating whether package loading has occurred. + hasLoadedInitialPackages() { + return this.initialPackagesLoaded; + } + + /* + Section: Accessing available packages + */ + + // Public: Returns an {Array} of {String}s of all the available package paths. + getAvailablePackagePaths() { + return this.getAvailablePackages().map(a => a.path); + } + + // Public: Returns an {Array} of {String}s of all the available package names. + getAvailablePackageNames() { + return this.getAvailablePackages().map(a => a.name); + } + + // Public: Returns an {Array} of {String}s of all the available package metadata. + getAvailablePackageMetadata() { + const packages = []; + for (const pack of this.getAvailablePackages()) { + const loadedPackage = this.getLoadedPackage(pack.name); + const metadata = + loadedPackage != null + ? loadedPackage.metadata + : this.loadPackageMetadata(pack, true); + packages.push(metadata); + } + return packages; + } + + getAvailablePackages() { + const packages = []; + const packagesByName = new Set(); + + for (const packageDirPath of this.packageDirPaths) { + if (fs.isDirectorySync(packageDirPath)) { + // checks for directories. + // dirent is faster, but for checking symbolic link we need stat. + const packageNames = fs + .readdirSync(packageDirPath, { withFileTypes: true }) + .filter( + dirent => + dirent.isDirectory() || + (dirent.isSymbolicLink() && + fs.isDirectorySync(path.join(packageDirPath, dirent.name))) + ) + .map(dirent => dirent.name); + + for (const packageName of packageNames) { + if ( + !packageName.startsWith('.') && + !packagesByName.has(packageName) + ) { + const packagePath = path.join(packageDirPath, packageName); + packages.push({ + name: packageName, + path: packagePath, + isBundled: false + }); + packagesByName.add(packageName); + } + } + } + } + + for (const packageName in this.packageDependencies) { + if (!packagesByName.has(packageName)) { + packages.push({ + name: packageName, + path: path.join(this.resourcePath, 'node_modules', packageName), + isBundled: true + }); + } + } + + return packages.sort((a, b) => a.name.localeCompare(b.name)); + } + + /* + Section: Private + */ + + getPackageState(name) { + return this.packageStates[name]; + } + + setPackageState(name, state) { + this.packageStates[name] = state; + } + + getPackageDependencies() { + return this.packageDependencies; + } + + hasAtomEngine(packagePath) { + const metadata = this.loadPackageMetadata(packagePath, true); + return ( + metadata != null && + metadata.engines != null && + metadata.engines.atom != null + ); + } + + unobserveDisabledPackages() { + if (this.disabledPackagesSubscription != null) { + this.disabledPackagesSubscription.dispose(); + } + this.disabledPackagesSubscription = null; + } + + observeDisabledPackages() { + if (this.disabledPackagesSubscription != null) { + return; + } + + this.disabledPackagesSubscription = this.config.onDidChange( + 'core.disabledPackages', + ({ newValue, oldValue }) => { + const packagesToEnable = _.difference(oldValue, newValue); + const packagesToDisable = _.difference(newValue, oldValue); + packagesToDisable.forEach(name => { + if (this.getActivePackage(name)) this.deactivatePackage(name); + }); + packagesToEnable.forEach(name => this.activatePackage(name)); + return null; + } + ); + } + + unobservePackagesWithKeymapsDisabled() { + if (this.packagesWithKeymapsDisabledSubscription != null) { + this.packagesWithKeymapsDisabledSubscription.dispose(); + } + this.packagesWithKeymapsDisabledSubscription = null; + } + + observePackagesWithKeymapsDisabled() { + if (this.packagesWithKeymapsDisabledSubscription != null) { + return; + } + + const performOnLoadedActivePackages = ( + packageNames, + disabledPackageNames, + action + ) => { + for (const packageName of packageNames) { + if (!disabledPackageNames.has(packageName)) { + const pack = this.getLoadedPackage(packageName); + if (pack != null) { + action(pack); + } + } + } + }; + + this.packagesWithKeymapsDisabledSubscription = this.config.onDidChange( + 'core.packagesWithKeymapsDisabled', + ({ newValue, oldValue }) => { + const keymapsToEnable = _.difference(oldValue, newValue); + const keymapsToDisable = _.difference(newValue, oldValue); + + const disabledPackageNames = new Set( + this.config.get('core.disabledPackages') + ); + performOnLoadedActivePackages( + keymapsToDisable, + disabledPackageNames, + p => p.deactivateKeymaps() + ); + performOnLoadedActivePackages( + keymapsToEnable, + disabledPackageNames, + p => p.activateKeymaps() + ); + return null; + } + ); + } + + preloadPackages() { + const result = []; + for (const packageName in this.packagesCache) { + result.push( + this.preloadPackage(packageName, this.packagesCache[packageName]) + ); + } + return result; + } + + preloadPackage(packageName, pack) { + const metadata = pack.metadata || {}; + if (typeof metadata.name !== 'string' || metadata.name.length < 1) { + metadata.name = packageName; + } + + if ( + metadata.repository != null && + metadata.repository.type === 'git' && + typeof metadata.repository.url === 'string' + ) { + metadata.repository.url = metadata.repository.url.replace( + /(^git\+)|(\.git$)/g, + '' + ); + } + + const options = { + path: pack.rootDirPath, + name: packageName, + preloadedPackage: true, + bundledPackage: true, + metadata, + packageManager: this, + config: this.config, + styleManager: this.styleManager, + commandRegistry: this.commandRegistry, + keymapManager: this.keymapManager, + notificationManager: this.notificationManager, + grammarRegistry: this.grammarRegistry, + themeManager: this.themeManager, + menuManager: this.menuManager, + contextMenuManager: this.contextMenuManager, + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry + }; + + pack = metadata.theme ? new ThemePackage(options) : new Package(options); + pack.preload(); + this.preloadedPackages[packageName] = pack; + return pack; + } + + loadPackages() { + // Ensure atom exports is already in the require cache so the load time + // of the first package isn't skewed by being the first to require atom + require('../exports/atom'); + + const disabledPackageNames = new Set( + this.config.get('core.disabledPackages') + ); + this.config.transact(() => { + for (const pack of this.getAvailablePackages()) { + this.loadAvailablePackage(pack, disabledPackageNames); + } + }); + this.initialPackagesLoaded = true; + this.emitter.emit('did-load-initial-packages'); + } + + loadPackage(nameOrPath) { + if (path.basename(nameOrPath)[0].match(/^\./)) { + // primarily to skip .git folder + return null; + } + + const pack = this.getLoadedPackage(nameOrPath); + if (pack) { + return pack; + } + + const packagePath = this.resolvePackagePath(nameOrPath); + if (packagePath) { + const name = path.basename(nameOrPath); + return this.loadAvailablePackage({ + name, + path: packagePath, + isBundled: this.isBundledPackagePath(packagePath) + }); + } + + console.warn(`Could not resolve '${nameOrPath}' to a package path`); + return null; + } + + loadAvailablePackage(availablePackage, disabledPackageNames) { + const preloadedPackage = this.preloadedPackages[availablePackage.name]; + + if ( + disabledPackageNames != null && + disabledPackageNames.has(availablePackage.name) + ) { + if (preloadedPackage != null) { + preloadedPackage.deactivate(); + delete preloadedPackage[availablePackage.name]; + } + return null; + } + + const loadedPackage = this.getLoadedPackage(availablePackage.name); + if (loadedPackage != null) { + return loadedPackage; + } + + if (preloadedPackage != null) { + if (availablePackage.isBundled) { + preloadedPackage.finishLoading(); + this.loadedPackages[availablePackage.name] = preloadedPackage; + return preloadedPackage; + } else { + preloadedPackage.deactivate(); + delete preloadedPackage[availablePackage.name]; + } + } + + let metadata; + try { + metadata = this.loadPackageMetadata(availablePackage) || {}; + } catch (error) { + this.handleMetadataError(error, availablePackage.path); + return null; + } + + if ( + !availablePackage.isBundled && + this.isDeprecatedPackage(metadata.name, metadata.version) + ) { + console.warn( + `Could not load ${metadata.name}@${ + metadata.version + } because it uses deprecated APIs that have been removed.` + ); + return null; + } + + const options = { + path: availablePackage.path, + name: availablePackage.name, + metadata, + bundledPackage: availablePackage.isBundled, + packageManager: this, + config: this.config, + styleManager: this.styleManager, + commandRegistry: this.commandRegistry, + keymapManager: this.keymapManager, + notificationManager: this.notificationManager, + grammarRegistry: this.grammarRegistry, + themeManager: this.themeManager, + menuManager: this.menuManager, + contextMenuManager: this.contextMenuManager, + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry + }; + + const pack = metadata.theme + ? new ThemePackage(options) + : new Package(options); + pack.load(); + this.loadedPackages[pack.name] = pack; + this.emitter.emit('did-load-package', pack); + return pack; + } + + unloadPackages() { + _.keys(this.loadedPackages).forEach(name => this.unloadPackage(name)); + } + + unloadPackage(name) { + if (this.isPackageActive(name)) { + throw new Error(`Tried to unload active package '${name}'`); + } + + const pack = this.getLoadedPackage(name); + if (pack) { + delete this.loadedPackages[pack.name]; + this.emitter.emit('did-unload-package', pack); + } else { + throw new Error(`No loaded package for name '${name}'`); + } + } + + // Activate all the packages that should be activated. + activate() { + let promises = []; + for (let [activator, types] of this.packageActivators) { + const packages = this.getLoadedPackagesForTypes(types); + promises = promises.concat(activator.activatePackages(packages)); + } + this.activatePromise = Promise.all(promises).then(() => { + this.triggerDeferredActivationHooks(); + this.initialPackagesActivated = true; + this.emitter.emit('did-activate-initial-packages'); + this.activatePromise = null; + }); + return this.activatePromise; + } + + registerURIHandlerForPackage(packageName, handler) { + return this.uriHandlerRegistry.registerHostHandler(packageName, handler); + } + + // another type of package manager can handle other package types. + // See ThemeManager + registerPackageActivator(activator, types) { + this.packageActivators.push([activator, types]); + } + + activatePackages(packages) { + const promises = []; + this.config.transactAsync(() => { + for (const pack of packages) { + const promise = this.activatePackage(pack.name); + if (!pack.activationShouldBeDeferred()) { + promises.push(promise); + } + } + return Promise.all(promises); + }); + this.observeDisabledPackages(); + this.observePackagesWithKeymapsDisabled(); + return promises; + } + + // Activate a single package by name + activatePackage(name) { + let pack = this.getActivePackage(name); + if (pack) { + return Promise.resolve(pack); + } + + pack = this.loadPackage(name); + if (!pack) { + return Promise.reject(new Error(`Failed to load package '${name}'`)); + } + + this.activatingPackages[pack.name] = pack; + const activationPromise = pack.activate().then(() => { + if (this.activatingPackages[pack.name] != null) { + delete this.activatingPackages[pack.name]; + this.activePackages[pack.name] = pack; + this.emitter.emit('did-activate-package', pack); + } + return pack; + }); + + if (this.deferredActivationHooks == null) { + this.triggeredActivationHooks.forEach(hook => + this.activationHookEmitter.emit(hook) + ); + } + + return activationPromise; + } + + triggerDeferredActivationHooks() { + if (this.deferredActivationHooks == null) { + return; + } + + for (const hook of this.deferredActivationHooks) { + this.activationHookEmitter.emit(hook); + } + + this.deferredActivationHooks = null; + } + + triggerActivationHook(hook) { + if (hook == null || !_.isString(hook) || hook.length <= 0) { + return new Error('Cannot trigger an empty activation hook'); + } + + this.triggeredActivationHooks.add(hook); + if (this.deferredActivationHooks != null) { + this.deferredActivationHooks.push(hook); + } else { + this.activationHookEmitter.emit(hook); + } + } + + onDidTriggerActivationHook(hook, callback) { + if (hook == null || !_.isString(hook) || hook.length <= 0) { + return; + } + return this.activationHookEmitter.on(hook, callback); + } + + serialize() { + for (const pack of this.getActivePackages()) { + this.serializePackage(pack); + } + return this.packageStates; + } + + serializePackage(pack) { + if (typeof pack.serialize === 'function') { + this.setPackageState(pack.name, pack.serialize()); + } + } + + // Deactivate all packages + async deactivatePackages() { + await this.config.transactAsync(() => + Promise.all( + this.getLoadedPackages().map(pack => + this.deactivatePackage(pack.name, true) + ) + ) + ); + this.unobserveDisabledPackages(); + this.unobservePackagesWithKeymapsDisabled(); + } + + // Deactivate the package with the given name + async deactivatePackage(name, suppressSerialization) { + const pack = this.getLoadedPackage(name); + if (pack == null) { + return; + } + + if (!suppressSerialization && this.isPackageActive(pack.name)) { + this.serializePackage(pack); + } + + const deactivationResult = pack.deactivate(); + if (deactivationResult && typeof deactivationResult.then === 'function') { + await deactivationResult; + } + + delete this.activePackages[pack.name]; + delete this.activatingPackages[pack.name]; + this.emitter.emit('did-deactivate-package', pack); + } + + handleMetadataError(error, packagePath) { + const metadataPath = path.join(packagePath, 'package.json'); + const detail = `${error.message} in ${metadataPath}`; + const stack = `${error.stack}\n at ${metadataPath}:1:1`; + const message = `Failed to load the ${path.basename(packagePath)} package`; + this.notificationManager.addError(message, { + stack, + detail, + packageName: path.basename(packagePath), + dismissable: true + }); + } + + uninstallDirectory(directory) { + const symlinkPromise = new Promise(resolve => + fs.isSymbolicLink(directory, isSymLink => resolve(isSymLink)) + ); + const dirPromise = new Promise(resolve => + fs.isDirectory(directory, isDir => resolve(isDir)) + ); + + return Promise.all([symlinkPromise, dirPromise]).then(values => { + const [isSymLink, isDir] = values; + if (!isSymLink && isDir) { + return fs.remove(directory, function() {}); + } + }); + } + + reloadActivePackageStyleSheets() { + for (const pack of this.getActivePackages()) { + if ( + pack.getType() !== 'theme' && + typeof pack.reloadStylesheets === 'function' + ) { + pack.reloadStylesheets(); + } + } + } + + isBundledPackagePath(packagePath) { + if ( + this.devMode && + !this.resourcePath.startsWith(`${process.resourcesPath}${path.sep}`) + ) { + return false; + } + + if (this.resourcePathWithTrailingSlash == null) { + this.resourcePathWithTrailingSlash = `${this.resourcePath}${path.sep}`; + } + + return ( + packagePath != null && + packagePath.startsWith(this.resourcePathWithTrailingSlash) + ); + } + + loadPackageMetadata(packagePathOrAvailablePackage, ignoreErrors = false) { + let isBundled, packageName, packagePath; + if (typeof packagePathOrAvailablePackage === 'object') { + const availablePackage = packagePathOrAvailablePackage; + packageName = availablePackage.name; + packagePath = availablePackage.path; + isBundled = availablePackage.isBundled; + } else { + packagePath = packagePathOrAvailablePackage; + packageName = path.basename(packagePath); + isBundled = this.isBundledPackagePath(packagePath); + } + + let metadata; + if (isBundled && this.packagesCache[packageName] != null) { + metadata = this.packagesCache[packageName].metadata; + } + + if (metadata == null) { + const metadataPath = CSON.resolve(path.join(packagePath, 'package')); + if (metadataPath) { + try { + metadata = CSON.readFileSync(metadataPath); + this.normalizePackageMetadata(metadata); + } catch (error) { + if (!ignoreErrors) { + throw error; + } + } + } + } + + if (metadata == null) { + metadata = {}; + } + + if (typeof metadata.name !== 'string' || metadata.name.length <= 0) { + metadata.name = packageName; + } + + if ( + metadata.repository && + metadata.repository.type === 'git' && + typeof metadata.repository.url === 'string' + ) { + metadata.repository.url = metadata.repository.url.replace( + /(^git\+)|(\.git$)/g, + '' + ); + } + + return metadata; + } + + normalizePackageMetadata(metadata) { + if (metadata != null) { + normalizePackageData = + normalizePackageData || require('normalize-package-data'); + normalizePackageData(metadata); + } + } +}; + +const NullVersionRange = { + test() { + return false; + } +}; diff --git a/src/package-transpilation-registry.js b/src/package-transpilation-registry.js new file mode 100644 index 00000000000..9cc189101cb --- /dev/null +++ b/src/package-transpilation-registry.js @@ -0,0 +1,212 @@ +'use strict'; +// This file is required by compile-cache, which is required directly from +// apm, so it can only use the subset of newer JavaScript features that apm's +// version of Node supports. Strict mode is required for block scoped declarations. + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const minimatch = require('minimatch'); + +let Resolve = null; + +class PackageTranspilationRegistry { + constructor() { + this.configByPackagePath = {}; + this.specByFilePath = {}; + this.transpilerPaths = {}; + } + + addTranspilerConfigForPath(packagePath, packageName, packageMeta, config) { + this.configByPackagePath[packagePath] = { + name: packageName, + meta: packageMeta, + path: packagePath, + specs: config.map(spec => Object.assign({}, spec)) + }; + } + + removeTranspilerConfigForPath(packagePath) { + delete this.configByPackagePath[packagePath]; + const packagePathWithSep = packagePath.endsWith(path.sep) + ? path.join(packagePath) + : path.join(packagePath) + path.sep; + Object.keys(this.specByFilePath).forEach(filePath => { + if (path.join(filePath).startsWith(packagePathWithSep)) { + delete this.specByFilePath[filePath]; + } + }); + } + + // Wraps the transpiler in an object with the same interface + // that falls back to the original transpiler implementation if and + // only if a package hasn't registered its desire to transpile its own source. + wrapTranspiler(transpiler) { + return { + getCachePath: (sourceCode, filePath) => { + const spec = this.getPackageTranspilerSpecForFilePath(filePath); + if (spec) { + return this.getCachePath(sourceCode, filePath, spec); + } + + return transpiler.getCachePath(sourceCode, filePath); + }, + + compile: (sourceCode, filePath) => { + const spec = this.getPackageTranspilerSpecForFilePath(filePath); + if (spec) { + return this.transpileWithPackageTranspiler( + sourceCode, + filePath, + spec + ); + } + + return transpiler.compile(sourceCode, filePath); + }, + + shouldCompile: (sourceCode, filePath) => { + if (this.transpilerPaths[filePath]) { + return false; + } + const spec = this.getPackageTranspilerSpecForFilePath(filePath); + if (spec) { + return true; + } + + return transpiler.shouldCompile(sourceCode, filePath); + } + }; + } + + getPackageTranspilerSpecForFilePath(filePath) { + if (this.specByFilePath[filePath] !== undefined) + return this.specByFilePath[filePath]; + + let thisPath = filePath; + let lastPath = null; + // Iterate parents from the file path to the root, checking at each level + // to see if a package manages transpilation for that directory. + // This means searching for a config for `/path/to/file/here.js` only + // only iterates four times, even if there are hundreds of configs registered. + while (thisPath !== lastPath) { + // until we reach the root + let config = this.configByPackagePath[thisPath]; + if (config) { + const relativePath = path.relative(thisPath, filePath); + if ( + relativePath.startsWith(`node_modules${path.sep}`) || + relativePath.indexOf(`${path.sep}node_modules${path.sep}`) > -1 + ) { + return false; + } + for (let i = 0; i < config.specs.length; i++) { + const spec = config.specs[i]; + if (minimatch(filePath, path.join(config.path, spec.glob))) { + spec._config = config; + this.specByFilePath[filePath] = spec; + return spec; + } + } + } + + lastPath = thisPath; + thisPath = path.join(thisPath, '..'); + } + + this.specByFilePath[filePath] = null; + return null; + } + + getCachePath(sourceCode, filePath, spec) { + const transpilerPath = this.getTranspilerPath(spec); + const transpilerSource = + spec._transpilerSource || fs.readFileSync(transpilerPath, 'utf8'); + spec._transpilerSource = transpilerSource; + const transpiler = this.getTranspiler(spec); + + let hash = crypto + .createHash('sha1') + .update(JSON.stringify(spec.options || {})) + .update(transpilerSource, 'utf8') + .update(sourceCode, 'utf8'); + + if (transpiler && transpiler.getCacheKeyData) { + const meta = this.getMetadata(spec); + const additionalCacheData = transpiler.getCacheKeyData( + sourceCode, + filePath, + spec.options || {}, + meta + ); + hash.update(additionalCacheData, 'utf8'); + } + + return path.join( + 'package-transpile', + spec._config.name, + hash.digest('hex') + ); + } + + transpileWithPackageTranspiler(sourceCode, filePath, spec) { + const transpiler = this.getTranspiler(spec); + + if (transpiler) { + const meta = this.getMetadata(spec); + const result = transpiler.transpile( + sourceCode, + filePath, + spec.options || {}, + meta + ); + if (result === undefined || (result && result.code === undefined)) { + return sourceCode; + } else if (result.code) { + return result.code.toString(); + } else { + throw new Error( + 'Could not find a property `.code` on the transpilation results of ' + + filePath + ); + } + } else { + const err = new Error( + "Could not resolve transpiler '" + + spec.transpiler + + "' from '" + + spec._config.path + + "'" + ); + throw err; + } + } + + getMetadata(spec) { + return { + name: spec._config.name, + path: spec._config.path, + meta: spec._config.meta + }; + } + + getTranspilerPath(spec) { + Resolve = Resolve || require('resolve'); + return Resolve.sync(spec.transpiler, { + basedir: spec._config.path, + extensions: Object.keys(require.extensions) + }); + } + + getTranspiler(spec) { + const transpilerPath = this.getTranspilerPath(spec); + if (transpilerPath) { + const transpiler = require(transpilerPath); + this.transpilerPaths[transpilerPath] = true; + return transpiler; + } + } +} + +module.exports = PackageTranspilationRegistry; diff --git a/src/package.coffee b/src/package.coffee deleted file mode 100644 index 65c7af8229d..00000000000 --- a/src/package.coffee +++ /dev/null @@ -1,621 +0,0 @@ -path = require 'path' -normalizePackageData = null - -_ = require 'underscore-plus' -async = require 'async' -CSON = require 'season' -fs = require 'fs-plus' -{Emitter, CompositeDisposable} = require 'event-kit' -Q = require 'q' -{includeDeprecatedAPIs, deprecate} = require 'grim' - -ModuleCache = require './module-cache' -ScopedProperties = require './scoped-properties' - -packagesCache = require('../package.json')?._atomPackages ? {} - -# Loads and activates a package's main module and resources such as -# stylesheets, keymaps, grammar, editor properties, and menus. -module.exports = -class Package - @isBundledPackagePath: (packagePath) -> - if atom.packages.devMode - return false unless atom.packages.resourcePath.startsWith("#{process.resourcesPath}#{path.sep}") - - @resourcePathWithTrailingSlash ?= "#{atom.packages.resourcePath}#{path.sep}" - packagePath?.startsWith(@resourcePathWithTrailingSlash) - - @normalizeMetadata: (metadata) -> - unless metadata?._id - normalizePackageData ?= require 'normalize-package-data' - normalizePackageData(metadata) - if metadata.repository?.type is 'git' and typeof metadata.repository.url is 'string' - metadata.repository.url = metadata.repository.url.replace(/^git\+/, '') - - @loadMetadata: (packagePath, ignoreErrors=false) -> - packageName = path.basename(packagePath) - if @isBundledPackagePath(packagePath) - metadata = packagesCache[packageName]?.metadata - unless metadata? - if metadataPath = CSON.resolve(path.join(packagePath, 'package')) - try - metadata = CSON.readFileSync(metadataPath) - @normalizeMetadata(metadata) - catch error - throw error unless ignoreErrors - - metadata ?= {} - metadata.name = packageName - - if includeDeprecatedAPIs and metadata.stylesheetMain? - deprecate("Use the `mainStyleSheet` key instead of `stylesheetMain` in the `package.json` of `#{packageName}`", {packageName}) - metadata.mainStyleSheet = metadata.stylesheetMain - - if includeDeprecatedAPIs and metadata.stylesheets? - deprecate("Use the `styleSheets` key instead of `stylesheets` in the `package.json` of `#{packageName}`", {packageName}) - metadata.styleSheets = metadata.stylesheets - - metadata - - keymaps: null - menus: null - stylesheets: null - stylesheetDisposables: null - grammars: null - settings: null - mainModulePath: null - resolvedMainModulePath: false - mainModule: null - - ### - Section: Construction - ### - - constructor: (@path, @metadata) -> - @emitter = new Emitter - @metadata ?= Package.loadMetadata(@path) - @bundledPackage = Package.isBundledPackagePath(@path) - @name = @metadata?.name ? path.basename(@path) - ModuleCache.add(@path, @metadata) - @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: -> - atom.config.removeAtKeyPath('core.disabledPackages', @name) - - disable: -> - atom.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 - - load: -> - @measure 'loadTime', => - try - @loadKeymaps() - @loadMenus() - @loadStylesheets() - @settingsPromise = @loadSettings() - @requireMainModule() unless @hasActivationCommands() - catch error - @handleError("Failed to load the #{@name} package", error) - this - - reset: -> - @stylesheets = [] - @keymaps = [] - @menus = [] - @grammars = [] - @settings = [] - - activate: -> - @grammarsPromise ?= @loadGrammars() - - unless @activationDeferred? - @activationDeferred = Q.defer() - @measure 'activateTime', => - try - @activateResources() - if @hasActivationCommands() - @subscribeToActivationCommands() - else - @activateNow() - catch error - @handleError("Failed to activate the #{@name} package", error) - - Q.all([@grammarsPromise, @settingsPromise, @activationDeferred.promise]) - - activateNow: -> - try - @activateConfig() - @activateStylesheets() - if @requireMainModule() - @mainModule.activate?(atom.packages.getPackageState(@name) ? {}) - @mainActivated = true - @activateServices() - catch error - @handleError("Failed to activate the #{@name} package", error) - - @activationDeferred?.resolve() - - activateConfig: -> - return if @configActivated - - @requireMainModule() - if @mainModule? - if @mainModule.config? and typeof @mainModule.config is 'object' - atom.config.setSchema @name, {type: 'object', properties: @mainModule.config} - else if includeDeprecatedAPIs and @mainModule.configDefaults? and typeof @mainModule.configDefaults is 'object' - deprecate("""Use a config schema instead. See the configuration section - of https://atom.io/docs/latest/hacking-atom-package-word-count and - https://atom.io/docs/api/latest/Config for more details""", {packageName: @name}) - atom.config.setDefaults(@name, @mainModule.configDefaults) - @mainModule.activateConfig?() - @configActivated = true - - 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(atom.styles.addStyleSheet(source, {sourcePath, priority, context})) - @stylesheetsActivated = true - - activateResources: -> - @activationDisposables = new CompositeDisposable - @activationDisposables.add(atom.keymaps.add(keymapPath, map)) for [keymapPath, map] in @keymaps - - for [menuPath, map] in @menus when map['context-menu']? - try - itemsBySelector = map['context-menu'] - - if includeDeprecatedAPIs - # Detect deprecated format for items object - for key, value of itemsBySelector - unless _.isArray(value) - deprecate(""" - The context menu CSON format has changed. Please see - https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format - for more info. - """, {packageName: @name}) - itemsBySelector = atom.contextMenu.convertLegacyItemsBySelector(itemsBySelector) - - @activationDisposables.add(atom.contextMenu.add(itemsBySelector)) - catch error - if error.code is 'EBADSELECTOR' - error.message += " in #{menuPath}" - error.stack += "\n at #{menuPath}:1:1" - throw error - - @activationDisposables.add(atom.menu.add(map['menu'])) for [menuPath, map] in @menus when map['menu']? - - unless @grammarsActivated - grammar.activate() for grammar in @grammars - @grammarsActivated = true - - settings.activate() for settings in @settings - @settingsActivated = 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 atom.packages.serviceHub.provide(name, servicesByVersion) - - for name, {versions} of @metadata.consumedServices - for version, methodName of versions - if typeof @mainModule[methodName] is 'function' - @activationDisposables.add atom.packages.serviceHub.consume(name, version, @mainModule[methodName].bind(@mainModule)) - return - - loadKeymaps: -> - if @bundledPackage and packagesCache[@name]? - @keymaps = (["#{atom.packages.resourcePath}#{path.sep}#{keymapPath}", keymapObject] for keymapPath, keymapObject of packagesCache[@name].keymaps) - else - @keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, CSON.readFileSync(keymapPath) ? {}] - return - - loadMenus: -> - if @bundledPackage and packagesCache[@name]? - @menus = (["#{atom.packages.resourcePath}#{path.sep}#{menuPath}", menuObject] for menuPath, menuObject of 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, atom.themes.loadStylesheet(stylesheetPath, true)] - - getStylesheetsPath: -> - if includeDeprecatedAPIs and fs.isDirectorySync(path.join(@path, 'stylesheets')) - deprecate("Store package style sheets in the `styles/` directory instead of `stylesheets/` in the `#{@name}` package", packageName: @name) - path.join(@path, 'stylesheets') - else - path.join(@path, 'styles') - - getStylesheetPaths: -> - 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 - - grammarsDirPath = path.join(@path, 'grammars') - grammarPaths = fs.listSync(grammarsDirPath, ['json', 'cson']) - for grammarPath in grammarPaths - try - grammar = atom.grammars.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 Q() if @grammarsLoaded - - loadGrammar = (grammarPath, callback) => - atom.grammars.readGrammar grammarPath, (error, grammar) => - if error? - detail = "#{error.message} in #{grammarPath}" - stack = "#{error.stack}\n at #{grammarPath}:1:1" - atom.notifications.addFatalError("Failed to load a #{@name} package grammar", {stack, detail, dismissable: true}) - else - grammar.packageName = @name - grammar.bundledPackage = @bundledPackage - @grammars.push(grammar) - grammar.activate() if @grammarsActivated - callback() - - deferred = Q.defer() - grammarsDirPath = path.join(@path, 'grammars') - fs.exists grammarsDirPath, (grammarsDirExists) -> - return deferred.resolve() unless grammarsDirExists - - fs.list grammarsDirPath, ['json', 'cson'], (error, grammarPaths=[]) -> - async.each grammarPaths, loadGrammar, -> deferred.resolve() - deferred.promise - - loadSettings: -> - @settings = [] - - loadSettingsFile = (settingsPath, callback) => - ScopedProperties.load settingsPath, (error, settings) => - if error? - detail = "#{error.message} in #{settingsPath}" - stack = "#{error.stack}\n at #{settingsPath}:1:1" - atom.notifications.addFatalError("Failed to load the #{@name} package settings", {stack, detail, dismissable: true}) - else - @settings.push(settings) - settings.activate() if @settingsActivated - callback() - - deferred = Q.defer() - - if includeDeprecatedAPIs and fs.isDirectorySync(path.join(@path, 'scoped-properties')) - settingsDirPath = path.join(@path, 'scoped-properties') - deprecate("Store package settings files in the `settings/` directory instead of `scoped-properties/`", packageName: @name) - else - settingsDirPath = path.join(@path, 'settings') - - fs.exists settingsDirPath, (settingsDirExists) -> - return deferred.resolve() unless settingsDirExists - - fs.list settingsDirPath, ['json', 'cson'], (error, settingsPaths=[]) -> - async.each settingsPaths, loadSettingsFile, -> deferred.resolve() - deferred.promise - - serialize: -> - if @mainActivated - try - @mainModule?.serialize?() - catch e - console.error "Error serializing package '#{@name}'", e.stack - - deactivate: -> - @activationDeferred?.reject() - @activationDeferred = null - @activationCommandSubscriptions?.dispose() - @deactivateResources() - @deactivateConfig() - if @mainActivated - try - @mainModule?.deactivate?() - catch e - console.error "Error deactivating package '#{@name}'", e.stack - @emit 'deactivated' if includeDeprecatedAPIs - @emitter.emit 'did-deactivate' - - deactivateConfig: -> - @mainModule?.deactivateConfig?() - @configActivated = false - - deactivateResources: -> - grammar.deactivate() for grammar in @grammars - settings.deactivate() for settings in @settings - @stylesheetDisposables?.dispose() - @activationDisposables?.dispose() - @stylesheetsActivated = false - @grammarsActivated = false - @settingsActivated = false - - reloadStylesheets: -> - oldSheets = _.clone(@stylesheets) - - try - @loadStylesheets() - catch error - @handleError("Failed to reload the #{@name} package stylesheets", error) - - @stylesheetDisposables?.dispose() - @stylesheetDisposables = new CompositeDisposable - @stylesheetsActivated = false - @activateStylesheets() - - requireMainModule: -> - return @mainModule if @mainModuleRequired - unless @isCompatible() - console.warn """ - Failed to require the main module of '#{@name}' because it requires an incompatible native module. - Run `apm rebuild` in the package directory to resolve. - """ - return - mainModulePath = @getMainModulePath() - if fs.isFileSync(mainModulePath) - @mainModuleRequired = true - @mainModule = require(mainModulePath) - - getMainModulePath: -> - return @mainModulePath if @resolvedMainModulePath - @resolvedMainModulePath = true - - if @bundledPackage and packagesCache[@name]? - if packagesCache[@name].main - @mainModulePath = "#{atom.packages.resourcePath}#{path.sep}#{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, ["", _.keys(require.extensions)...]) - - hasActivationCommands: -> - for selector, commands of @getActivationCommands() - return true if commands.length > 0 - false - - 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 atom.commands.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 atom.commands.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...) - - if includeDeprecatedAPIs and @metadata.activationEvents? - deprecate(""" - Use `activationCommands` instead of `activationEvents` in your package.json - Commands should be grouped by selector as follows: - ```json - "activationCommands": { - "atom-workspace": ["foo:bar", "foo:baz"], - "atom-text-editor": ["foo:quux"] - } - ``` - """, {packageName: @name}) - if _.isArray(@metadata.activationEvents) - for eventName in @metadata.activationEvents - @activationCommands['atom-workspace'] ?= [] - @activationCommands['atom-workspace'].push(eventName) - else if _.isString(@metadata.activationEvents) - eventName = @metadata.activationEvents - @activationCommands['atom-workspace'] ?= [] - @activationCommands['atom-workspace'].push(eventName) - else - for eventName, selector of @metadata.activationEvents - selector ?= 'atom-workspace' - @activationCommands[selector] ?= [] - @activationCommands[selector].push(eventName) - - @activationCommands - - # 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. - # This will recurse through all dependencies. - getNativeModuleDependencyPaths: -> - 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 - - # 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: -> - localStorageKey = "installed-packages:#{@name}:#{@metadata.version}" - unless atom.inDevMode() - try - {incompatibleNativeModules} = JSON.parse(global.localStorage.getItem(localStorageKey)) ? {} - return incompatibleNativeModules if incompatibleNativeModules? - - 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(localStorageKey, JSON.stringify({incompatibleNativeModules})) - incompatibleNativeModules - - # Public: Is this package compatible with this version of Atom? - # - # Incompatible packages cannot be activated. This will include packages - # installed to ~/.atom/packages that were built against node 0.11.10 but - # now need to be upgrade to node 0.11.13. - # - # Returns a {Boolean}, true if compatible, false if incompatible. - isCompatible: -> - return @compatible if @compatible? - - if @path.indexOf(path.join(atom.packages.resourcePath, 'node_modules') + path.sep) is 0 - # Bundled packages are always considered compatible - @compatible = true - else if @getMainModulePath() - @incompatibleModules = @getIncompatibleNativeModules() - @compatible = @incompatibleModules.length is 0 - else - @compatible = true - - handleError: (message, 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 - - atom.notifications.addFatalError(message, {stack, detail, dismissable: true}) - -if includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(Package) - - Package::on = (eventName) -> - switch eventName - when 'deactivated' - deprecate 'Use Package::onDidDeactivate instead' - else - deprecate 'Package::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) diff --git a/src/package.js b/src/package.js new file mode 100644 index 00000000000..2ef76cc7827 --- /dev/null +++ b/src/package.js @@ -0,0 +1,1410 @@ +const path = require('path'); +const asyncEach = require('async/each'); +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 BufferedProcess = require('./buffered-process'); +const { requireModule } = require('./module-utils'); + +// 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 = + (this.metadata && this.metadata.name) || + params.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 = window.performance.now(); + const value = fn(); + this[key] = Math.round(window.performance.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.config); + } + 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; + this.deserialized = 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(); + if (this.workspaceOpenerSubscriptions) + this.workspaceOpenerSubscriptions.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.config); + } + 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(); + if (atomEnvironment.packages.hasActivatedInitialPackages()) { + // Only explicitly activate the package if initial packages + // have finished activating. This is because deserialization + // generally occurs at Atom startup, which happens before the + // workspace element is added to the DOM and is inconsistent with + // with when initial package activation occurs. Triggering activation + // immediately may cause problems with packages that expect to + // always have access to the workspace element. + // Otherwise, we just set the deserialized flag and package-manager + // will activate this package as normal during initial package activation. + this.activateNow(); + } + this.deserialized = true; + 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 asyncEach(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(); + asyncEach(grammarPaths, loadGrammar, () => resolve()); + }); + }); + } + }); + } + + loadSettings() { + this.settings = []; + + const loadSettingsFile = (settingsPath, callback) => { + return SettingsFile.load(settingsPath, (error, settingsFile) => { + 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(settingsFile); + if (this.settingsActivated) settingsFile.activate(this.config); + } + return callback(); + }); + }; + + if (this.preloadedPackage && this.packageManager.packagesCache[this.name]) { + for (let settingsPath in this.packageManager.packagesCache[this.name] + .settings) { + const properties = this.packageManager.packagesCache[this.name] + .settings[settingsPath]; + const settingsFile = new SettingsFile( + `core:${settingsPath}`, + properties || {} + ); + this.settings.push(settingsFile); + if (this.settingsActivated) settingsFile.activate(this.config); + } + } else { + return new Promise(resolve => { + 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(); + asyncEach(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(this.config); + } + + 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 = requireModule( + 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 = requireModule(mainModulePath); + if ( + this.viewRegistry.getViewProviderCount() === + previousViewProviderCount && + this.deserializerManager.getDeserializerCount() === + previousDeserializerCount + ) { + localStorage.setItem( + this.getCanDeferMainModuleRequireStorageKey(), + 'true' + ); + } else { + localStorage.removeItem( + this.getCanDeferMainModuleRequireStorageKey() + ); + } + 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.deserialized && + (this.hasActivationCommands() || + this.hasActivationHooks() || + this.hasWorkspaceOpeners() || + this.hasDeferredURIHandler()) + ); + } + + hasActivationHooks() { + const hooks = this.getActivationHooks(); + return hooks && hooks.length > 0; + } + + hasWorkspaceOpeners() { + const openers = this.getWorkspaceOpeners(); + return openers && openers.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(); + this.subscribeToWorkspaceOpeners(); + } + + 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; + } + + subscribeToWorkspaceOpeners() { + this.workspaceOpenerSubscriptions = new CompositeDisposable(); + for (let opener of this.getWorkspaceOpeners()) { + this.workspaceOpenerSubscriptions.add( + atom.workspace.addOpener(filePath => { + if (filePath === opener) { + this.activateNow(); + this.workspaceOpenerSubscriptions.dispose(); + return atom.workspace.createItemForURI(opener); + } + }) + ); + } + } + + getWorkspaceOpeners() { + if (this.workspaceOpeners) return this.workspaceOpeners; + + if (this.metadata.workspaceOpeners) { + if (Array.isArray(this.metadata.workspaceOpeners)) { + this.workspaceOpeners = Array.from( + new Set(this.metadata.workspaceOpeners) + ); + } else if (typeof this.metadata.workspaceOpeners === 'string') { + this.workspaceOpeners = [this.metadata.workspaceOpeners]; + } else { + this.workspaceOpeners = []; + } + } else { + this.workspaceOpeners = []; + } + + return this.workspaceOpeners; + } + + getURIHandler() { + return this.metadata && this.metadata.uriHandler; + } + + // Does the given module path contain native code? + isNativeModule(modulePath) { + try { + return this.getModulePathNodeFiles(modulePath).length > 0; + } catch (error) { + return false; + } + } + + // get the list of `.node` files for the given module path + getModulePathNodeFiles(modulePath) { + try { + const modulePathNodeFiles = fs.listSync( + path.join(modulePath, 'build', 'Release'), + ['.node'] + ); + return modulePathNodeFiles; + } catch (error) { + return []; + } + } + + // Get a Map of all the native modules => the `.node` files 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. + getNativeModuleDependencyPathsMap() { + const nativeModulePaths = new Map(); + + if (this.metadata._atomModuleCache) { + const nodeFilePaths = []; + const relativeNativeModuleBindingPaths = + (this.metadata._atomModuleCache.extensions && + this.metadata._atomModuleCache.extensions['.node']) || + []; + for (let relativeNativeModuleBindingPath of relativeNativeModuleBindingPaths) { + const nodeFilePath = path.join( + this.path, + relativeNativeModuleBindingPath, + '..', + '..', + '..' + ); + nodeFilePaths.push(nodeFilePath); + } + nativeModulePaths.set(this.path, nodeFilePaths); + return nativeModulePaths; + } + + const traversePath = nodeModulesPath => { + try { + for (let modulePath of fs.listSync(nodeModulesPath)) { + const modulePathNodeFiles = this.getModulePathNodeFiles(modulePath); + if (modulePathNodeFiles) { + nativeModulePaths.set(modulePath, modulePathNodeFiles); + } + traversePath(path.join(modulePath, 'node_modules')); + } + } catch (error) {} + }; + + traversePath(path.join(this.path, 'node_modules')); + + return nativeModulePaths; + } + + // Get an array of all the native modules that this package depends on. + // See `getNativeModuleDependencyPathsMap` for more information + getNativeModuleDependencyPaths() { + return [...this.getNativeModuleDependencyPathsMap().keys()]; + } + + /* + 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 `.node` files. + // + // 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 = []; + const nativeModulePaths = this.getNativeModuleDependencyPathsMap(); + for (const [nativeModulePath, nodeFilesPaths] of nativeModulePaths) { + try { + // require each .node file + for (const nodeFilePath of nodeFilesPaths) { + require(nodeFilePath); + } + } 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 + }); + } +}; + +class SettingsFile { + static load(path, callback) { + CSON.readFile(path, (error, properties = {}) => { + if (error) { + callback(error); + } else { + callback(null, new SettingsFile(path, properties)); + } + }); + } + + constructor(path, properties) { + this.path = path; + this.properties = properties; + } + + activate(config) { + for (let selector in this.properties) { + config.set(null, this.properties[selector], { + scopeSelector: selector, + source: this.path + }); + } + } + + deactivate(config) { + for (let selector in this.properties) { + config.unset(null, { scopeSelector: selector, source: this.path }); + } + } +} diff --git a/src/pane-axis-element.coffee b/src/pane-axis-element.coffee deleted file mode 100644 index f9dd156975c..00000000000 --- a/src/pane-axis-element.coffee +++ /dev/null @@ -1,67 +0,0 @@ -{CompositeDisposable} = require 'event-kit' -{callAttachHooks} = require './space-pen-extensions' -PaneResizeHandleElement = require './pane-resize-handle-element' - -class PaneAxisElement extends HTMLElement - createdCallback: -> - @subscriptions = new CompositeDisposable - - detachedCallback: -> - @subscriptions.dispose() - - initialize: (@model) -> - @subscriptions.add @model.onDidAddChild(@childAdded.bind(this)) - @subscriptions.add @model.onDidRemoveChild(@childRemoved.bind(this)) - @subscriptions.add @model.onDidReplaceChild(@childReplaced.bind(this)) - @subscriptions.add @model.observeFlexScale(@flexScaleChanged.bind(this)) - - @childAdded({child, index}) for child, index in @model.getChildren() - - switch @model.getOrientation() - when 'horizontal' - @classList.add('horizontal', 'pane-row') - when 'vertical' - @classList.add('vertical', 'pane-column') - this - - isPaneResizeHandleElement: (element) -> - element?.nodeName.toLowerCase() is 'atom-pane-resize-handle' - - childAdded: ({child, index}) -> - view = atom.views.getView(child) - @insertBefore(view, @children[index * 2]) - - prevElement = view.previousSibling - # if previous element is not pane resize element, then insert new resize element - if prevElement? and not @isPaneResizeHandleElement(prevElement) - resizeHandle = document.createElement('atom-pane-resize-handle') - @insertBefore(resizeHandle, view) - - nextElement = view.nextSibling - # if next element isnot resize element, then insert new resize element - if nextElement? and not @isPaneResizeHandleElement(nextElement) - resizeHandle = document.createElement('atom-pane-resize-handle') - @insertBefore(resizeHandle, nextElement) - - callAttachHooks(view) # for backward compatibility with SpacePen views - - childRemoved: ({child}) -> - view = atom.views.getView(child) - siblingView = view.previousSibling - # make sure next sibling view is pane resize view - if siblingView? and @isPaneResizeHandleElement(siblingView) - siblingView.remove() - view.remove() - - childReplaced: ({index, oldChild, newChild}) -> - focusedElement = document.activeElement if @hasFocus() - @childRemoved({child: oldChild, index}) - @childAdded({child: newChild, index}) - focusedElement?.focus() if document.activeElement is document.body - - flexScaleChanged: (flexScale) -> @style.flexGrow = flexScale - - hasFocus: -> - this is document.activeElement or @contains(document.activeElement) - -module.exports = PaneAxisElement = document.registerElement 'atom-pane-axis', prototype: PaneAxisElement.prototype diff --git a/src/pane-axis-element.js b/src/pane-axis-element.js new file mode 100644 index 00000000000..1a620f9df0f --- /dev/null +++ b/src/pane-axis-element.js @@ -0,0 +1,126 @@ +const { CompositeDisposable } = require('event-kit'); +require('./pane-resize-handle-element'); + +class PaneAxisElement extends HTMLElement { + connectedCallback() { + if (this.subscriptions == null) { + this.subscriptions = this.subscribeToModel(); + } + this.model + .getChildren() + .map((child, index) => this.childAdded({ child, index })); + } + + disconnectedCallback() { + this.subscriptions.dispose(); + this.subscriptions = null; + this.model.getChildren().map(child => this.childRemoved({ child })); + } + + initialize(model, viewRegistry) { + this.model = model; + this.viewRegistry = viewRegistry; + if (this.subscriptions == null) { + this.subscriptions = this.subscribeToModel(); + } + const iterable = this.model.getChildren(); + for (let index = 0; index < iterable.length; index++) { + const child = iterable[index]; + this.childAdded({ child, index }); + } + + switch (this.model.getOrientation()) { + case 'horizontal': + this.classList.add('horizontal', 'pane-row'); + break; + case 'vertical': + this.classList.add('vertical', 'pane-column'); + break; + } + return this; + } + + subscribeToModel() { + const subscriptions = new CompositeDisposable(); + subscriptions.add(this.model.onDidAddChild(this.childAdded.bind(this))); + subscriptions.add( + this.model.onDidRemoveChild(this.childRemoved.bind(this)) + ); + subscriptions.add( + this.model.onDidReplaceChild(this.childReplaced.bind(this)) + ); + subscriptions.add( + this.model.observeFlexScale(this.flexScaleChanged.bind(this)) + ); + return subscriptions; + } + + isPaneResizeHandleElement(element) { + return ( + (element != null ? element.nodeName.toLowerCase() : undefined) === + 'atom-pane-resize-handle' + ); + } + + childAdded({ child, index }) { + let resizeHandle; + const view = this.viewRegistry.getView(child); + this.insertBefore(view, this.children[index * 2]); + + const prevElement = view.previousSibling; + // if previous element is not pane resize element, then insert new resize element + if (prevElement != null && !this.isPaneResizeHandleElement(prevElement)) { + resizeHandle = document.createElement('atom-pane-resize-handle'); + this.insertBefore(resizeHandle, view); + } + + const nextElement = view.nextSibling; + // if next element isnot resize element, then insert new resize element + if (nextElement != null && !this.isPaneResizeHandleElement(nextElement)) { + resizeHandle = document.createElement('atom-pane-resize-handle'); + return this.insertBefore(resizeHandle, nextElement); + } + } + + childRemoved({ child }) { + const view = this.viewRegistry.getView(child); + const siblingView = view.previousSibling; + // make sure next sibling view is pane resize view + if (siblingView != null && this.isPaneResizeHandleElement(siblingView)) { + siblingView.remove(); + } + return view.remove(); + } + + childReplaced({ index, oldChild, newChild }) { + let focusedElement; + if (this.hasFocus()) { + focusedElement = document.activeElement; + } + this.childRemoved({ child: oldChild, index }); + this.childAdded({ child: newChild, index }); + if (document.activeElement === document.body) { + return focusedElement != null ? focusedElement.focus() : undefined; + } + } + + flexScaleChanged(flexScale) { + this.style.flexGrow = flexScale; + } + + hasFocus() { + return ( + this === document.activeElement || this.contains(document.activeElement) + ); + } +} + +window.customElements.define('atom-pane-axis', PaneAxisElement); + +function createPaneAxisElement() { + return document.createElement('atom-pane-axis'); +} + +module.exports = { + createPaneAxisElement +}; diff --git a/src/pane-axis.coffee b/src/pane-axis.coffee deleted file mode 100644 index 1fba48d3788..00000000000 --- a/src/pane-axis.coffee +++ /dev/null @@ -1,145 +0,0 @@ -{Emitter, CompositeDisposable} = require 'event-kit' -{flatten} = require 'underscore-plus' -Serializable = require 'serializable' -Model = require './model' - -module.exports = -class PaneAxis extends Model - atom.deserializers.add(this) - Serializable.includeInto(this) - - parent: null - container: null - orientation: null - - constructor: ({@container, @orientation, children, flexScale}={}) -> - @emitter = new Emitter - @subscriptionsByChild = new WeakMap - @subscriptions = new CompositeDisposable - @children = [] - if children? - @addChild(child) for child in children - @flexScale = flexScale ? 1 - - deserializeParams: (params) -> - {container} = params - params.children = params.children.map (childState) -> atom.deserializers.deserialize(childState, {container}) - params - - serializeParams: -> - children: @children.map (child) -> child.serialize() - orientation: @orientation - flexScale: @flexScale - - getFlexScale: -> @flexScale - - setFlexScale: (@flexScale) -> - @emitter.emit 'did-change-flex-scale', @flexScale - @flexScale - - getParent: -> @parent - - setParent: (@parent) -> @parent - - getContainer: -> @container - - setContainer: (@container) -> @container - - getOrientation: -> @orientation - - getChildren: -> @children.slice() - - getPanes: -> - flatten(@children.map (child) -> child.getPanes()) - - getItems: -> - flatten(@children.map (child) -> child.getItems()) - - onDidAddChild: (fn) -> - @emitter.on 'did-add-child', fn - - onDidRemoveChild: (fn) -> - @emitter.on 'did-remove-child', fn - - onDidReplaceChild: (fn) -> - @emitter.on 'did-replace-child', fn - - onDidDestroy: (fn) -> - @emitter.on 'did-destroy', fn - - onDidChangeFlexScale: (fn) -> - @emitter.on 'did-change-flex-scale', fn - - observeFlexScale: (fn) -> - fn(@flexScale) - @onDidChangeFlexScale(fn) - - addChild: (child, index=@children.length) -> - child.setParent(this) - child.setContainer(@container) - - @subscribeToChild(child) - - @children.splice(index, 0, child) - @emitter.emit 'did-add-child', {child, index} - - adjustFlexScale: -> - # get current total flex scale of children - total = 0 - total += child.getFlexScale() for child in @children - - needTotal = @children.length - # set every child's flex scale by the ratio - for child in @children - child.setFlexScale(needTotal * child.getFlexScale() / total) - - removeChild: (child, replacing=false) -> - index = @children.indexOf(child) - throw new Error("Removing non-existent child") if index is -1 - - @unsubscribeFromChild(child) - - @children.splice(index, 1) - @adjustFlexScale() - @emitter.emit 'did-remove-child', {child, index} - @reparentLastChild() if not replacing and @children.length < 2 - - replaceChild: (oldChild, newChild) -> - @unsubscribeFromChild(oldChild) - @subscribeToChild(newChild) - - newChild.setParent(this) - newChild.setContainer(@container) - - index = @children.indexOf(oldChild) - @children.splice(index, 1, newChild) - @emitter.emit 'did-replace-child', {oldChild, newChild, index} - - insertChildBefore: (currentChild, newChild) -> - index = @children.indexOf(currentChild) - @addChild(newChild, index) - - insertChildAfter: (currentChild, newChild) -> - index = @children.indexOf(currentChild) - @addChild(newChild, index + 1) - - reparentLastChild: -> - lastChild = @children[0] - lastChild.setFlexScale(@flexScale) - @parent.replaceChild(this, lastChild) - @destroy() - - subscribeToChild: (child) -> - subscription = child.onDidDestroy => @removeChild(child) - @subscriptionsByChild.set(child, subscription) - @subscriptions.add(subscription) - - unsubscribeFromChild: (child) -> - subscription = @subscriptionsByChild.get(child) - @subscriptions.remove(subscription) - subscription.dispose() - - destroyed: -> - @subscriptions.dispose() - @emitter.emit 'did-destroy' - @emitter.dispose() diff --git a/src/pane-axis.js b/src/pane-axis.js new file mode 100644 index 00000000000..08afa2351f9 --- /dev/null +++ b/src/pane-axis.js @@ -0,0 +1,208 @@ +const { Emitter, CompositeDisposable } = require('event-kit'); +const { flatten } = require('underscore-plus'); +const Model = require('./model'); +const { createPaneAxisElement } = require('./pane-axis-element'); + +class PaneAxis extends Model { + static deserialize(state, { deserializers, views }) { + state.children = state.children.map(childState => + deserializers.deserialize(childState) + ); + return new PaneAxis(state, views); + } + + constructor({ orientation, children, flexScale }, viewRegistry) { + super(); + this.parent = null; + this.container = null; + this.orientation = orientation; + this.viewRegistry = viewRegistry; + this.emitter = new Emitter(); + this.subscriptionsByChild = new WeakMap(); + this.subscriptions = new CompositeDisposable(); + this.flexScale = flexScale != null ? flexScale : 1; + this.children = []; + if (children) { + for (let child of children) { + this.addChild(child); + } + } + } + + serialize() { + return { + deserializer: 'PaneAxis', + children: this.children.map(child => child.serialize()), + orientation: this.orientation, + flexScale: this.flexScale + }; + } + + getElement() { + if (!this.element) { + this.element = createPaneAxisElement().initialize( + this, + this.viewRegistry + ); + } + return this.element; + } + + getFlexScale() { + return this.flexScale; + } + + setFlexScale(flexScale) { + this.flexScale = flexScale; + this.emitter.emit('did-change-flex-scale', this.flexScale); + return this.flexScale; + } + + getParent() { + return this.parent; + } + + setParent(parent) { + this.parent = parent; + return this.parent; + } + + getContainer() { + return this.container; + } + + setContainer(container) { + if (container && container !== this.container) { + this.container = container; + this.children.forEach(child => child.setContainer(container)); + } + } + + getOrientation() { + return this.orientation; + } + + getChildren() { + return this.children.slice(); + } + + getPanes() { + return flatten(this.children.map(child => child.getPanes())); + } + + getItems() { + return flatten(this.children.map(child => child.getItems())); + } + + onDidAddChild(fn) { + return this.emitter.on('did-add-child', fn); + } + + onDidRemoveChild(fn) { + return this.emitter.on('did-remove-child', fn); + } + + onDidReplaceChild(fn) { + return this.emitter.on('did-replace-child', fn); + } + + onDidDestroy(fn) { + return this.emitter.once('did-destroy', fn); + } + + onDidChangeFlexScale(fn) { + return this.emitter.on('did-change-flex-scale', fn); + } + + observeFlexScale(fn) { + fn(this.flexScale); + return this.onDidChangeFlexScale(fn); + } + + addChild(child, index = this.children.length) { + this.children.splice(index, 0, child); + child.setParent(this); + child.setContainer(this.container); + this.subscribeToChild(child); + return this.emitter.emit('did-add-child', { child, index }); + } + + adjustFlexScale() { + // get current total flex scale of children + let total = 0; + for (var child of this.children) { + total += child.getFlexScale(); + } + + const needTotal = this.children.length; + // set every child's flex scale by the ratio + for (child of this.children) { + child.setFlexScale((needTotal * child.getFlexScale()) / total); + } + } + + removeChild(child, replacing = false) { + const index = this.children.indexOf(child); + if (index === -1) { + throw new Error('Removing non-existent child'); + } + + this.unsubscribeFromChild(child); + + this.children.splice(index, 1); + this.adjustFlexScale(); + this.emitter.emit('did-remove-child', { child, index }); + if (!replacing && this.children.length < 2) { + this.reparentLastChild(); + } + } + + replaceChild(oldChild, newChild) { + this.unsubscribeFromChild(oldChild); + this.subscribeToChild(newChild); + + newChild.setParent(this); + newChild.setContainer(this.container); + + const index = this.children.indexOf(oldChild); + this.children.splice(index, 1, newChild); + this.emitter.emit('did-replace-child', { oldChild, newChild, index }); + } + + insertChildBefore(currentChild, newChild) { + const index = this.children.indexOf(currentChild); + return this.addChild(newChild, index); + } + + insertChildAfter(currentChild, newChild) { + const index = this.children.indexOf(currentChild); + return this.addChild(newChild, index + 1); + } + + reparentLastChild() { + const lastChild = this.children[0]; + lastChild.setFlexScale(this.flexScale); + this.parent.replaceChild(this, lastChild); + this.destroy(); + } + + subscribeToChild(child) { + const subscription = child.onDidDestroy(() => this.removeChild(child)); + this.subscriptionsByChild.set(child, subscription); + this.subscriptions.add(subscription); + } + + unsubscribeFromChild(child) { + const subscription = this.subscriptionsByChild.get(child); + this.subscriptions.remove(subscription); + subscription.dispose(); + } + + destroyed() { + this.subscriptions.dispose(); + this.emitter.emit('did-destroy'); + this.emitter.dispose(); + } +} + +module.exports = PaneAxis; diff --git a/src/pane-container-element.coffee b/src/pane-container-element.coffee deleted file mode 100644 index 94b008255c8..00000000000 --- a/src/pane-container-element.coffee +++ /dev/null @@ -1,82 +0,0 @@ -{CompositeDisposable} = require 'event-kit' -Grim = require 'grim' -{callAttachHooks} = require './space-pen-extensions' -PaneContainerView = null -_ = require 'underscore-plus' - -module.exports = -class PaneContainerElement extends HTMLElement - createdCallback: -> - @subscriptions = new CompositeDisposable - @classList.add 'panes' - - if Grim.includeDeprecatedAPIs - PaneContainerView ?= require './pane-container-view' - @__spacePenView = new PaneContainerView(this) - - initialize: (@model) -> - @subscriptions.add @model.observeRoot(@rootChanged.bind(this)) - @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs - this - - rootChanged: (root) -> - focusedElement = document.activeElement if @hasFocus() - @firstChild?.remove() - if root? - view = atom.views.getView(root) - @appendChild(view) - callAttachHooks(view) - focusedElement?.focus() - - hasFocus: -> - this is document.activeElement or @contains(document.activeElement) - - focusPaneViewAbove: -> - @nearestPaneInDirection('above')?.focus() - - focusPaneViewBelow: -> - @nearestPaneInDirection('below')?.focus() - - focusPaneViewOnLeft: -> - @nearestPaneInDirection('left')?.focus() - - focusPaneViewOnRight: -> - @nearestPaneInDirection('right')?.focus() - - nearestPaneInDirection: (direction) -> - distance = (pointA, pointB) -> - x = pointB.x - pointA.x - y = pointB.y - pointA.y - Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) - - paneView = atom.views.getView(@model.getActivePane()) - box = @boundingBoxForPaneView(paneView) - - paneViews = _.toArray(@querySelectorAll('atom-pane')) - .filter (otherPaneView) => - otherBox = @boundingBoxForPaneView(otherPaneView) - switch direction - when 'left' then otherBox.right.x <= box.left.x - when 'right' then otherBox.left.x >= box.right.x - when 'above' then otherBox.bottom.y <= box.top.y - when 'below' then otherBox.top.y >= box.bottom.y - .sort (paneViewA, paneViewB) => - boxA = @boundingBoxForPaneView(paneViewA) - boxB = @boundingBoxForPaneView(paneViewB) - switch direction - when 'left' then distance(box.left, boxA.right) - distance(box.left, boxB.right) - when 'right' then distance(box.right, boxA.left) - distance(box.right, boxB.left) - when 'above' then distance(box.top, boxA.bottom) - distance(box.top, boxB.bottom) - when 'below' then distance(box.bottom, boxA.top) - distance(box.bottom, boxB.top) - - paneViews[0] - - boundingBoxForPaneView: (paneView) -> - boundingBox = paneView.getBoundingClientRect() - - left: {x: boundingBox.left, y: boundingBox.top} - right: {x: boundingBox.right, y: boundingBox.top} - top: {x: boundingBox.left, y: boundingBox.top} - bottom: {x: boundingBox.left, y: boundingBox.bottom} - -module.exports = PaneContainerElement = document.registerElement 'atom-pane-container', prototype: PaneContainerElement.prototype diff --git a/src/pane-container-element.js b/src/pane-container-element.js new file mode 100644 index 00000000000..f435ceef529 --- /dev/null +++ b/src/pane-container-element.js @@ -0,0 +1,54 @@ +const { CompositeDisposable } = require('event-kit'); + +class PaneContainerElement extends HTMLElement { + constructor() { + super(); + this.subscriptions = new CompositeDisposable(); + } + + initialize(model, { views }) { + this.model = model; + this.views = views; + if (this.views == null) { + throw new Error( + 'Must pass a views parameter when initializing PaneContainerElements' + ); + } + this.subscriptions.add(this.model.observeRoot(this.rootChanged.bind(this))); + return this; + } + + connectedCallback() { + this.classList.add('panes'); + } + + rootChanged(root) { + const focusedElement = this.hasFocus() ? document.activeElement : null; + if (this.firstChild != null) { + this.firstChild.remove(); + } + if (root != null) { + const view = this.views.getView(root); + this.appendChild(view); + if (focusedElement != null) { + focusedElement.focus(); + } + } + } + + hasFocus() { + return ( + this === document.activeElement || this.contains(document.activeElement) + ); + } +} + +window.customElements.define('atom-pane-container', PaneContainerElement); + +function createPaneContainerElement() { + return document.createElement('atom-pane-container'); +} + +module.exports = { + createPaneContainerElement +}; diff --git a/src/pane-container-view.coffee b/src/pane-container-view.coffee deleted file mode 100644 index d92e99299fb..00000000000 --- a/src/pane-container-view.coffee +++ /dev/null @@ -1,89 +0,0 @@ -{deprecate} = require 'grim' -Delegator = require 'delegato' -{CompositeDisposable} = require 'event-kit' -{$, View, callAttachHooks} = require './space-pen-extensions' -PaneView = require './pane-view' -PaneContainer = require './pane-container' - -# Manages the list of panes within a {WorkspaceView} -module.exports = -class PaneContainerView extends View - Delegator.includeInto(this) - - @delegatesMethod 'saveAll', toProperty: 'model' - - @content: -> - @div class: 'panes' - - constructor: (@element) -> - super - @subscriptions = new CompositeDisposable - - setModel: (@model) -> - @subscriptions.add @model.onDidChangeActivePaneItem(@onActivePaneItemChanged) - - getRoot: -> - view = atom.views.getView(@model.getRoot()) - view.__spacePenView ? view - - onActivePaneItemChanged: (activeItem) => - @trigger 'pane-container:active-pane-item-changed', [activeItem] - - confirmClose: -> - @model.confirmClose() - - getPaneViews: -> - @find('atom-pane').views() - - indexOfPane: (paneView) -> - @getPaneViews().indexOf(paneView.view()) - - paneAtIndex: (index) -> - @getPaneViews()[index] - - eachPaneView: (callback) -> - callback(paneView) for paneView in @getPaneViews() - paneViewAttached = (e) -> callback($(e.target).view()) - @on 'pane:attached', paneViewAttached - off: => @off 'pane:attached', paneViewAttached - - getFocusedPane: -> - @find('atom-pane:has(:focus)').view() - - getActivePane: -> - deprecate("Use PaneContainerView::getActivePaneView instead.") - @getActivePaneView() - - getActivePaneView: -> - atom.views.getView(@model.getActivePane()).__spacePenView - - getActivePaneItem: -> - @model.getActivePaneItem() - - getActiveView: -> - @getActivePaneView()?.activeView - - paneForUri: (uri) -> - atom.views.getView(@model.paneForURI(uri)).__spacePenView - - focusNextPaneView: -> - @model.activateNextPane() - - focusPreviousPaneView: -> - @model.activatePreviousPane() - - focusPaneViewAbove: -> - @element.focusPaneViewAbove() - - focusPaneViewBelow: -> - @element.focusPaneViewBelow() - - focusPaneViewOnLeft: -> - @element.focusPaneViewOnLeft() - - focusPaneViewOnRight: -> - @element.focusPaneViewOnRight() - - getPanes: -> - deprecate("Use PaneContainerView::getPaneViews() instead") - @getPaneViews() diff --git a/src/pane-container.coffee b/src/pane-container.coffee deleted file mode 100644 index 26ef22cacae..00000000000 --- a/src/pane-container.coffee +++ /dev/null @@ -1,246 +0,0 @@ -{find, flatten} = require 'underscore-plus' -Grim = require 'grim' -{Emitter, CompositeDisposable} = require 'event-kit' -Serializable = require 'serializable' -{createGutterView} = require './gutter-component-helpers' -Gutter = require './gutter' -Model = require './model' -Pane = require './pane' -PaneElement = require './pane-element' -PaneContainerElement = require './pane-container-element' -PaneAxisElement = require './pane-axis-element' -PaneAxis = require './pane-axis' -TextEditor = require './text-editor' -TextEditorElement = require './text-editor-element' -ItemRegistry = require './item-registry' - -module.exports = -class PaneContainer extends Model - atom.deserializers.add(this) - Serializable.includeInto(this) - - @version: 1 - - root: null - - constructor: (params) -> - super - - unless Grim.includeDeprecatedAPIs - @activePane = params?.activePane - - @emitter = new Emitter - @subscriptions = new CompositeDisposable - - @itemRegistry = new ItemRegistry - @registerViewProviders() - - @setRoot(params?.root ? new Pane) - @setActivePane(@getPanes()[0]) unless @getActivePane() - - @destroyEmptyPanes() if params?.destroyEmptyPanes - - @monitorActivePaneItem() - @monitorPaneItems() - - deserializeParams: (params) -> - params.root = atom.deserializers.deserialize(params.root, container: this) - params.destroyEmptyPanes = atom.config.get('core.destroyEmptyPanes') - params.activePane = find params.root.getPanes(), (pane) -> pane.id is params.activePaneId - params - - serializeParams: (params) -> - root: @root?.serialize() - activePaneId: @activePane.id - - registerViewProviders: -> - atom.views.addViewProvider PaneContainer, (model) -> - new PaneContainerElement().initialize(model) - atom.views.addViewProvider PaneAxis, (model) -> - new PaneAxisElement().initialize(model) - atom.views.addViewProvider Pane, (model) -> - new PaneElement().initialize(model) - atom.views.addViewProvider TextEditor, (model) -> - new TextEditorElement().initialize(model) - atom.views.addViewProvider(Gutter, createGutterView) - - onDidChangeRoot: (fn) -> - @emitter.on 'did-change-root', fn - - observeRoot: (fn) -> - fn(@getRoot()) - @onDidChangeRoot(fn) - - onDidAddPane: (fn) -> - @emitter.on 'did-add-pane', fn - - observePanes: (fn) -> - fn(pane) for pane in @getPanes() - @onDidAddPane ({pane}) -> fn(pane) - - onDidDestroyPane: (fn) -> - @emitter.on 'did-destroy-pane', fn - - onDidChangeActivePane: (fn) -> - @emitter.on 'did-change-active-pane', fn - - observeActivePane: (fn) -> - fn(@getActivePane()) - @onDidChangeActivePane(fn) - - onDidAddPaneItem: (fn) -> - @emitter.on 'did-add-pane-item', fn - - observePaneItems: (fn) -> - fn(item) for item in @getPaneItems() - @onDidAddPaneItem ({item}) -> fn(item) - - onDidChangeActivePaneItem: (fn) -> - @emitter.on 'did-change-active-pane-item', fn - - observeActivePaneItem: (fn) -> - fn(@getActivePaneItem()) - @onDidChangeActivePaneItem(fn) - - onWillDestroyPaneItem: (fn) -> - @emitter.on 'will-destroy-pane-item', fn - - onDidDestroyPaneItem: (fn) -> - @emitter.on 'did-destroy-pane-item', fn - - getRoot: -> @root - - setRoot: (@root) -> - @root.setParent(this) - @root.setContainer(this) - @emitter.emit 'did-change-root', @root - if not @getActivePane()? and @root instanceof Pane - @setActivePane(@root) - - replaceChild: (oldChild, newChild) -> - throw new Error("Replacing non-existent child") if oldChild isnt @root - @setRoot(newChild) - - getPanes: -> - @getRoot().getPanes() - - getPaneItems: -> - @getRoot().getItems() - - getActivePane: -> - @activePane - - setActivePane: (activePane) -> - if activePane isnt @activePane - unless activePane in @getPanes() - throw new Error("Setting active pane that is not present in pane container") - - @activePane = activePane - @emitter.emit 'did-change-active-pane', @activePane - @activePane - - getActivePaneItem: -> - @getActivePane().getActiveItem() - - paneForURI: (uri) -> - find @getPanes(), (pane) -> pane.itemForURI(uri)? - - paneForItem: (item) -> - find @getPanes(), (pane) -> item in pane.getItems() - - saveAll: -> - pane.saveItems() for pane in @getPanes() - return - - confirmClose: (options) -> - allSaved = true - - for pane in @getPanes() - for item in pane.getItems() - unless pane.promptToSaveItem(item, options) - allSaved = false - break - - allSaved - - activateNextPane: -> - panes = @getPanes() - if panes.length > 1 - currentIndex = panes.indexOf(@activePane) - nextIndex = (currentIndex + 1) % panes.length - panes[nextIndex].activate() - true - else - false - - activatePreviousPane: -> - panes = @getPanes() - if panes.length > 1 - currentIndex = panes.indexOf(@activePane) - previousIndex = currentIndex - 1 - previousIndex = panes.length - 1 if previousIndex < 0 - panes[previousIndex].activate() - true - else - false - - destroyEmptyPanes: -> - pane.destroy() for pane in @getPanes() when pane.items.length is 0 - return - - willDestroyPaneItem: (event) -> - @emitter.emit 'will-destroy-pane-item', event - - didDestroyPaneItem: (event) -> - @emitter.emit 'did-destroy-pane-item', event - - didAddPane: (event) -> - @emitter.emit 'did-add-pane', event - - didDestroyPane: (event) -> - @emitter.emit 'did-destroy-pane', event - - # Called by Model superclass when destroyed - destroyed: -> - pane.destroy() for pane in @getPanes() - @subscriptions.dispose() - @emitter.dispose() - - monitorActivePaneItem: -> - childSubscription = null - @subscriptions.add @observeActivePane (activePane) => - if childSubscription? - @subscriptions.remove(childSubscription) - childSubscription.dispose() - - childSubscription = activePane.observeActiveItem (activeItem) => - @emitter.emit 'did-change-active-pane-item', activeItem - - @subscriptions.add(childSubscription) - - monitorPaneItems: -> - @subscriptions.add @observePanes (pane) => - for item, index in pane.getItems() - @addedPaneItem(item, pane, index) - - pane.onDidAddItem ({item, index}) => - @addedPaneItem(item, pane, index) - - pane.onDidRemoveItem ({item}) => - @removedPaneItem(item) - - addedPaneItem: (item, pane, index) -> - @itemRegistry.addItem(item) - @emitter.emit 'did-add-pane-item', {item, pane, index} - - removedPaneItem: (item) -> - @itemRegistry.removeItem(item) - -if Grim.includeDeprecatedAPIs - PaneContainer.properties - activePane: null - - PaneContainer.behavior 'activePaneItem', -> - @$activePane - .switch((activePane) -> activePane?.$activeItem) - .distinctUntilChanged() diff --git a/src/pane-container.js b/src/pane-container.js new file mode 100644 index 00000000000..bc2f2a6f656 --- /dev/null +++ b/src/pane-container.js @@ -0,0 +1,351 @@ +const { find } = require('underscore-plus'); +const { Emitter, CompositeDisposable } = require('event-kit'); +const Pane = require('./pane'); +const ItemRegistry = require('./item-registry'); +const { createPaneContainerElement } = require('./pane-container-element'); + +const SERIALIZATION_VERSION = 1; +const STOPPED_CHANGING_ACTIVE_PANE_ITEM_DELAY = 100; + +module.exports = class PaneContainer { + constructor(params) { + let applicationDelegate, deserializerManager, notificationManager; + ({ + config: this.config, + applicationDelegate, + notificationManager, + deserializerManager, + viewRegistry: this.viewRegistry, + location: this.location + } = params); + this.emitter = new Emitter(); + this.subscriptions = new CompositeDisposable(); + this.itemRegistry = new ItemRegistry(); + this.alive = true; + this.stoppedChangingActivePaneItemTimeout = null; + + this.setRoot( + new Pane({ + container: this, + config: this.config, + applicationDelegate, + notificationManager, + deserializerManager, + viewRegistry: this.viewRegistry + }) + ); + this.didActivatePane(this.getRoot()); + } + + getLocation() { + return this.location; + } + + getElement() { + return this.element != null + ? this.element + : (this.element = createPaneContainerElement().initialize(this, { + views: this.viewRegistry + })); + } + + destroy() { + this.alive = false; + for (let pane of this.getRoot().getPanes()) { + pane.destroy(); + } + this.cancelStoppedChangingActivePaneItemTimeout(); + this.subscriptions.dispose(); + this.emitter.dispose(); + } + + isAlive() { + return this.alive; + } + + isDestroyed() { + return !this.isAlive(); + } + + serialize(params) { + return { + deserializer: 'PaneContainer', + version: SERIALIZATION_VERSION, + root: this.root ? this.root.serialize() : null, + activePaneId: this.activePane.id + }; + } + + deserialize(state, deserializerManager) { + if (state.version !== SERIALIZATION_VERSION) return; + this.itemRegistry = new ItemRegistry(); + this.setRoot(deserializerManager.deserialize(state.root)); + this.activePane = + find(this.getRoot().getPanes(), pane => pane.id === state.activePaneId) || + this.getPanes()[0]; + if (this.config.get('core.destroyEmptyPanes')) this.destroyEmptyPanes(); + } + + onDidChangeRoot(fn) { + return this.emitter.on('did-change-root', fn); + } + + observeRoot(fn) { + fn(this.getRoot()); + return this.onDidChangeRoot(fn); + } + + onDidAddPane(fn) { + return this.emitter.on('did-add-pane', fn); + } + + observePanes(fn) { + for (let pane of this.getPanes()) { + fn(pane); + } + return this.onDidAddPane(({ pane }) => fn(pane)); + } + + onDidDestroyPane(fn) { + return this.emitter.on('did-destroy-pane', fn); + } + + onWillDestroyPane(fn) { + return this.emitter.on('will-destroy-pane', fn); + } + + onDidChangeActivePane(fn) { + return this.emitter.on('did-change-active-pane', fn); + } + + onDidActivatePane(fn) { + return this.emitter.on('did-activate-pane', fn); + } + + observeActivePane(fn) { + fn(this.getActivePane()); + return this.onDidChangeActivePane(fn); + } + + onDidAddPaneItem(fn) { + return this.emitter.on('did-add-pane-item', fn); + } + + observePaneItems(fn) { + for (let item of this.getPaneItems()) { + fn(item); + } + return this.onDidAddPaneItem(({ item }) => fn(item)); + } + + onDidChangeActivePaneItem(fn) { + return this.emitter.on('did-change-active-pane-item', fn); + } + + onDidStopChangingActivePaneItem(fn) { + return this.emitter.on('did-stop-changing-active-pane-item', fn); + } + + observeActivePaneItem(fn) { + fn(this.getActivePaneItem()); + return this.onDidChangeActivePaneItem(fn); + } + + onWillDestroyPaneItem(fn) { + return this.emitter.on('will-destroy-pane-item', fn); + } + + onDidDestroyPaneItem(fn) { + return this.emitter.on('did-destroy-pane-item', fn); + } + + getRoot() { + return this.root; + } + + setRoot(root) { + this.root = root; + this.root.setParent(this); + this.root.setContainer(this); + this.emitter.emit('did-change-root', this.root); + if (this.getActivePane() == null && this.root instanceof Pane) { + this.didActivatePane(this.root); + } + } + + replaceChild(oldChild, newChild) { + if (oldChild !== this.root) { + throw new Error('Replacing non-existent child'); + } + this.setRoot(newChild); + } + + getPanes() { + if (this.alive) { + return this.getRoot().getPanes(); + } else { + return []; + } + } + + getPaneItems() { + return this.getRoot().getItems(); + } + + getActivePane() { + return this.activePane; + } + + getActivePaneItem() { + return this.getActivePane().getActiveItem(); + } + + paneForURI(uri) { + return find(this.getPanes(), pane => pane.itemForURI(uri) != null); + } + + paneForItem(item) { + return find(this.getPanes(), pane => pane.getItems().includes(item)); + } + + saveAll() { + for (let pane of this.getPanes()) { + pane.saveItems(); + } + } + + confirmClose(options) { + const promises = []; + for (const pane of this.getPanes()) { + for (const item of pane.getItems()) { + promises.push(pane.promptToSaveItem(item, options)); + } + } + return Promise.all(promises).then(results => !results.includes(false)); + } + + activateNextPane() { + const panes = this.getPanes(); + if (panes.length > 1) { + const currentIndex = panes.indexOf(this.activePane); + const nextIndex = (currentIndex + 1) % panes.length; + panes[nextIndex].activate(); + return true; + } else { + return false; + } + } + + activatePreviousPane() { + const panes = this.getPanes(); + if (panes.length > 1) { + const currentIndex = panes.indexOf(this.activePane); + let previousIndex = currentIndex - 1; + if (previousIndex < 0) { + previousIndex = panes.length - 1; + } + panes[previousIndex].activate(); + return true; + } else { + return false; + } + } + + moveActiveItemToPane(destPane) { + const item = this.activePane.getActiveItem(); + + if (!destPane.isItemAllowed(item)) { + return; + } + + this.activePane.moveItemToPane(item, destPane); + destPane.setActiveItem(item); + } + + copyActiveItemToPane(destPane) { + const item = this.activePane.copyActiveItem(); + + if (item && destPane.isItemAllowed(item)) { + destPane.activateItem(item); + } + } + + destroyEmptyPanes() { + for (let pane of this.getPanes()) { + if (pane.items.length === 0) { + pane.destroy(); + } + } + } + + didAddPane(event) { + this.emitter.emit('did-add-pane', event); + const items = event.pane.getItems(); + for (let i = 0, length = items.length; i < length; i++) { + const item = items[i]; + this.didAddPaneItem(item, event.pane, i); + } + } + + willDestroyPane(event) { + this.emitter.emit('will-destroy-pane', event); + } + + didDestroyPane(event) { + this.emitter.emit('did-destroy-pane', event); + } + + didActivatePane(activePane) { + if (activePane !== this.activePane) { + if (!this.getPanes().includes(activePane)) { + throw new Error( + 'Setting active pane that is not present in pane container' + ); + } + + this.activePane = activePane; + this.emitter.emit('did-change-active-pane', this.activePane); + this.didChangeActiveItemOnPane( + this.activePane, + this.activePane.getActiveItem() + ); + } + this.emitter.emit('did-activate-pane', this.activePane); + return this.activePane; + } + + didAddPaneItem(item, pane, index) { + this.itemRegistry.addItem(item); + this.emitter.emit('did-add-pane-item', { item, pane, index }); + } + + willDestroyPaneItem(event) { + return this.emitter.emitAsync('will-destroy-pane-item', event); + } + + didDestroyPaneItem(event) { + this.itemRegistry.removeItem(event.item); + this.emitter.emit('did-destroy-pane-item', event); + } + + didChangeActiveItemOnPane(pane, activeItem) { + if (pane === this.getActivePane()) { + this.emitter.emit('did-change-active-pane-item', activeItem); + + this.cancelStoppedChangingActivePaneItemTimeout(); + // `setTimeout()` isn't available during the snapshotting phase, but that's okay. + if (!global.isGeneratingSnapshot) { + this.stoppedChangingActivePaneItemTimeout = setTimeout(() => { + this.stoppedChangingActivePaneItemTimeout = null; + this.emitter.emit('did-stop-changing-active-pane-item', activeItem); + }, STOPPED_CHANGING_ACTIVE_PANE_ITEM_DELAY); + } + } + } + + cancelStoppedChangingActivePaneItemTimeout() { + if (this.stoppedChangingActivePaneItemTimeout != null) { + clearTimeout(this.stoppedChangingActivePaneItemTimeout); + } + } +}; diff --git a/src/pane-element.coffee b/src/pane-element.coffee deleted file mode 100644 index 15990bcc212..00000000000 --- a/src/pane-element.coffee +++ /dev/null @@ -1,160 +0,0 @@ -path = require 'path' -{CompositeDisposable} = require 'event-kit' -Grim = require 'grim' -{$, callAttachHooks, callRemoveHooks} = require './space-pen-extensions' -PaneView = null - -class PaneElement extends HTMLElement - attached: false - - createdCallback: -> - @attached = false - @subscriptions = new CompositeDisposable - @inlineDisplayStyles = new WeakMap - - @initializeContent() - @subscribeToDOMEvents() - @createSpacePenShim() if Grim.includeDeprecatedAPIs - - attachedCallback: -> - @attached = true - @focus() if @model.isFocused() - - detachedCallback: -> - @attached = false - - initializeContent: -> - @setAttribute 'class', 'pane' - @setAttribute 'tabindex', -1 - @appendChild @itemViews = document.createElement('div') - @itemViews.setAttribute 'class', 'item-views' - - subscribeToDOMEvents: -> - handleFocus = (event) => - @model.focus() - if event.target is this and view = @getActiveView() - view.focus() - event.stopPropagation() - - handleBlur = (event) => - @model.blur() unless @contains(event.relatedTarget) - - handleDragOver = (event) -> - event.preventDefault() - event.stopPropagation() - - handleDrop = (event) => - event.preventDefault() - event.stopPropagation() - @getModel().activate() - pathsToOpen = Array::map.call event.dataTransfer.files, (file) -> file.path - atom.open({pathsToOpen}) if pathsToOpen.length > 0 - - @addEventListener 'focus', handleFocus, true - @addEventListener 'blur', handleBlur, true - @addEventListener 'dragover', handleDragOver - @addEventListener 'drop', handleDrop - - createSpacePenShim: -> - PaneView ?= require './pane-view' - @__spacePenView = new PaneView(this) - - initialize: (@model) -> - @subscriptions.add @model.onDidActivate(@activated.bind(this)) - @subscriptions.add @model.observeActive(@activeStatusChanged.bind(this)) - @subscriptions.add @model.observeActiveItem(@activeItemChanged.bind(this)) - @subscriptions.add @model.onDidRemoveItem(@itemRemoved.bind(this)) - @subscriptions.add @model.onDidDestroy(@paneDestroyed.bind(this)) - @subscriptions.add @model.observeFlexScale(@flexScaleChanged.bind(this)) - - @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs - this - - getModel: -> @model - - activated: -> - @focus() - - activeStatusChanged: (active) -> - if active - @classList.add('active') - else - @classList.remove('active') - - activeItemChanged: (item) -> - delete @dataset.activeItemName - delete @dataset.activeItemPath - - return unless item? - - hasFocus = @hasFocus() - itemView = atom.views.getView(item) - - if itemPath = item.getPath?() - @dataset.activeItemName = path.basename(itemPath) - @dataset.activeItemPath = itemPath - - unless @itemViews.contains(itemView) - @itemViews.appendChild(itemView) - callAttachHooks(itemView) - - for child in @itemViews.children - if child is itemView - @showItemView(child) if @attached - else - @hideItemView(child) - - itemView.focus() if hasFocus - - showItemView: (itemView) -> - inlineDisplayStyle = @inlineDisplayStyles.get(itemView) - if inlineDisplayStyle? - itemView.style.display = inlineDisplayStyle - else - itemView.style.display = '' - - hideItemView: (itemView) -> - inlineDisplayStyle = itemView.style.display - unless inlineDisplayStyle is 'none' - @inlineDisplayStyles.set(itemView, inlineDisplayStyle) if inlineDisplayStyle? - itemView.style.display = 'none' - - itemRemoved: ({item, index, destroyed}) -> - if viewToRemove = atom.views.getView(item) - callRemoveHooks(viewToRemove) if destroyed - viewToRemove.remove() - - paneDestroyed: -> - @subscriptions.dispose() - - flexScaleChanged: (flexScale) -> - @style.flexGrow = flexScale - - getActiveView: -> atom.views.getView(@model.getActiveItem()) - - hasFocus: -> - this is document.activeElement or @contains(document.activeElement) - -atom.commands.add 'atom-pane', - 'pane:save-items': -> @getModel().saveItems() - 'pane:show-next-item': -> @getModel().activateNextItem() - 'pane:show-previous-item': -> @getModel().activatePreviousItem() - 'pane:show-item-1': -> @getModel().activateItemAtIndex(0) - 'pane:show-item-2': -> @getModel().activateItemAtIndex(1) - 'pane:show-item-3': -> @getModel().activateItemAtIndex(2) - 'pane:show-item-4': -> @getModel().activateItemAtIndex(3) - 'pane:show-item-5': -> @getModel().activateItemAtIndex(4) - 'pane:show-item-6': -> @getModel().activateItemAtIndex(5) - 'pane:show-item-7': -> @getModel().activateItemAtIndex(6) - 'pane:show-item-8': -> @getModel().activateItemAtIndex(7) - 'pane:show-item-9': -> @getModel().activateItemAtIndex(8) - 'pane:move-item-right': -> @getModel().moveItemRight() - 'pane:move-item-left': -> @getModel().moveItemLeft() - 'pane:split-left': -> @getModel().splitLeft(copyActiveItem: true) - 'pane:split-right': -> @getModel().splitRight(copyActiveItem: true) - 'pane:split-up': -> @getModel().splitUp(copyActiveItem: true) - 'pane:split-down': -> @getModel().splitDown(copyActiveItem: true) - 'pane:close': -> @getModel().close() - 'pane:close-other-items': -> @getModel().destroyInactiveItems() - -module.exports = PaneElement = document.registerElement 'atom-pane', prototype: PaneElement.prototype diff --git a/src/pane-element.js b/src/pane-element.js new file mode 100644 index 00000000000..60b8cf5d003 --- /dev/null +++ b/src/pane-element.js @@ -0,0 +1,226 @@ +const path = require('path'); +const { CompositeDisposable } = require('event-kit'); + +class PaneElement extends HTMLElement { + constructor() { + super(); + this.attached = false; + this.subscriptions = new CompositeDisposable(); + this.inlineDisplayStyles = new WeakMap(); + this.subscribeToDOMEvents(); + this.itemViews = document.createElement('div'); + } + + connectedCallback() { + this.initializeContent(); + this.attached = true; + if (this.model.isFocused()) { + this.focus(); + } + } + + detachedCallback() { + this.attached = false; + } + + initializeContent() { + this.setAttribute('class', 'pane'); + this.setAttribute('tabindex', -1); + this.appendChild(this.itemViews); + this.itemViews.setAttribute('class', 'item-views'); + } + + subscribeToDOMEvents() { + const handleFocus = event => { + if ( + !( + this.isActivating || + this.model.isDestroyed() || + this.contains(event.relatedTarget) + ) + ) { + this.model.focus(); + } + if (event.target !== this) return; + const view = this.getActiveView(); + if (view) { + view.focus(); + event.stopPropagation(); + } + }; + const handleBlur = event => { + if (!this.contains(event.relatedTarget)) { + this.model.blur(); + } + }; + const handleDragOver = event => { + event.preventDefault(); + event.stopPropagation(); + }; + const handleDrop = event => { + event.preventDefault(); + event.stopPropagation(); + this.getModel().activate(); + const pathsToOpen = [...event.dataTransfer.files].map(file => file.path); + if (pathsToOpen.length > 0) { + this.applicationDelegate.open({ pathsToOpen, here: true }); + } + }; + this.addEventListener('focus', handleFocus, { capture: true }); + this.addEventListener('blur', handleBlur, { capture: true }); + this.addEventListener('dragover', handleDragOver); + this.addEventListener('drop', handleDrop); + } + + initialize(model, { views, applicationDelegate }) { + this.model = model; + this.views = views; + this.applicationDelegate = applicationDelegate; + if (this.views == null) { + throw new Error( + 'Must pass a views parameter when initializing PaneElements' + ); + } + if (this.applicationDelegate == null) { + throw new Error( + 'Must pass an applicationDelegate parameter when initializing PaneElements' + ); + } + this.subscriptions.add(this.model.onDidActivate(this.activated.bind(this))); + this.subscriptions.add( + this.model.observeActive(this.activeStatusChanged.bind(this)) + ); + this.subscriptions.add( + this.model.observeActiveItem(this.activeItemChanged.bind(this)) + ); + this.subscriptions.add( + this.model.onDidRemoveItem(this.itemRemoved.bind(this)) + ); + this.subscriptions.add( + this.model.onDidDestroy(this.paneDestroyed.bind(this)) + ); + this.subscriptions.add( + this.model.observeFlexScale(this.flexScaleChanged.bind(this)) + ); + return this; + } + + getModel() { + return this.model; + } + + activated() { + this.isActivating = true; + if (!this.hasFocus()) { + // Don't steal focus from children. + this.focus(); + } + this.isActivating = false; + } + + activeStatusChanged(active) { + if (active) { + this.classList.add('active'); + } else { + this.classList.remove('active'); + } + } + + activeItemChanged(item) { + delete this.dataset.activeItemName; + delete this.dataset.activeItemPath; + if (this.changePathDisposable != null) { + this.changePathDisposable.dispose(); + } + if (item == null) { + return; + } + const hasFocus = this.hasFocus(); + const itemView = this.views.getView(item); + const itemPath = typeof item.getPath === 'function' ? item.getPath() : null; + if (itemPath) { + this.dataset.activeItemName = path.basename(itemPath); + this.dataset.activeItemPath = itemPath; + if (item.onDidChangePath != null) { + this.changePathDisposable = item.onDidChangePath(() => { + const itemPath = item.getPath(); + this.dataset.activeItemName = path.basename(itemPath); + this.dataset.activeItemPath = itemPath; + }); + } + } + + if (!this.itemViews.contains(itemView)) { + this.itemViews.appendChild(itemView); + } + for (const child of this.itemViews.children) { + if (child === itemView) { + if (this.attached) { + this.showItemView(child); + } + } else { + this.hideItemView(child); + } + } + if (hasFocus) { + itemView.focus(); + } + } + + showItemView(itemView) { + const inlineDisplayStyle = this.inlineDisplayStyles.get(itemView); + if (inlineDisplayStyle != null) { + itemView.style.display = inlineDisplayStyle; + } else { + itemView.style.display = ''; + } + } + + hideItemView(itemView) { + const inlineDisplayStyle = itemView.style.display; + if (inlineDisplayStyle !== 'none') { + if (inlineDisplayStyle != null) { + this.inlineDisplayStyles.set(itemView, inlineDisplayStyle); + } + itemView.style.display = 'none'; + } + } + + itemRemoved({ item, index, destroyed }) { + const viewToRemove = this.views.getView(item); + if (viewToRemove) { + viewToRemove.remove(); + } + } + + paneDestroyed() { + this.subscriptions.dispose(); + if (this.changePathDisposable != null) { + this.changePathDisposable.dispose(); + } + } + + flexScaleChanged(flexScale) { + this.style.flexGrow = flexScale; + } + + getActiveView() { + return this.views.getView(this.model.getActiveItem()); + } + + hasFocus() { + return ( + this === document.activeElement || this.contains(document.activeElement) + ); + } +} + +function createPaneElement() { + return document.createElement('atom-pane'); +} + +window.customElements.define('atom-pane', PaneElement); + +module.exports = { + createPaneElement +}; diff --git a/src/pane-resize-handle-element.coffee b/src/pane-resize-handle-element.coffee deleted file mode 100644 index 078bb44acc7..00000000000 --- a/src/pane-resize-handle-element.coffee +++ /dev/null @@ -1,68 +0,0 @@ -class PaneResizeHandleElement extends HTMLElement - createdCallback: -> - @resizePane = @resizePane.bind(this) - @resizeStopped = @resizeStopped.bind(this) - @subscribeToDOMEvents() - - subscribeToDOMEvents: -> - @addEventListener 'dblclick', @resizeToFitContent.bind(this) - @addEventListener 'mousedown', @resizeStarted.bind(this) - - attachedCallback: -> - @isHorizontal = @parentElement.classList.contains("horizontal") - @classList.add if @isHorizontal then 'horizontal' else 'vertical' - - detachedCallback: -> - @resizeStopped() - - resizeToFitContent: -> - # clear flex-grow css style of both pane - @previousSibling.model.setFlexScale(1) - @nextSibling.model.setFlexScale(1) - - resizeStarted: (e) -> - e.stopPropagation() - document.addEventListener 'mousemove', @resizePane - document.addEventListener 'mouseup', @resizeStopped - - resizeStopped: -> - document.removeEventListener 'mousemove', @resizePane - document.removeEventListener 'mouseup', @resizeStopped - - calcRatio: (ratio1, ratio2, total) -> - allRatio = ratio1 + ratio2 - [total * ratio1 / allRatio, total * ratio2 / allRatio] - - setFlexGrow: (prevSize, nextSize) -> - @prevModel = @previousSibling.model - @nextModel = @nextSibling.model - totalScale = @prevModel.getFlexScale() + @nextModel.getFlexScale() - flexGrows = @calcRatio(prevSize, nextSize, totalScale) - @prevModel.setFlexScale flexGrows[0] - @nextModel.setFlexScale flexGrows[1] - - fixInRange: (val, minValue, maxValue) -> - Math.min(Math.max(val, minValue), maxValue) - - resizePane: ({clientX, clientY, which}) -> - return @resizeStopped() unless which is 1 - return @resizeStopped() unless @previousSibling? and @nextSibling? - - if @isHorizontal - totalWidth = @previousSibling.clientWidth + @nextSibling.clientWidth - #get the left and right width after move the resize view - leftWidth = clientX - @previousSibling.getBoundingClientRect().left - leftWidth = @fixInRange(leftWidth, 0, totalWidth) - rightWidth = totalWidth - leftWidth - # set the flex grow by the ratio of left width and right width - # to change pane width - @setFlexGrow(leftWidth, rightWidth) - else - totalHeight = @previousSibling.clientHeight + @nextSibling.clientHeight - topHeight = clientY - @previousSibling.getBoundingClientRect().top - topHeight = @fixInRange(topHeight, 0, totalHeight) - bottomHeight = totalHeight - topHeight - @setFlexGrow(topHeight, bottomHeight) - -module.exports = PaneResizeHandleElement = -document.registerElement 'atom-pane-resize-handle', prototype: PaneResizeHandleElement.prototype diff --git a/src/pane-resize-handle-element.js b/src/pane-resize-handle-element.js new file mode 100644 index 00000000000..ede61042f8b --- /dev/null +++ b/src/pane-resize-handle-element.js @@ -0,0 +1,120 @@ +class PaneResizeHandleElement extends HTMLElement { + constructor() { + super(); + this.resizePane = this.resizePane.bind(this); + this.resizeStopped = this.resizeStopped.bind(this); + this.subscribeToDOMEvents(); + } + + subscribeToDOMEvents() { + this.addEventListener('dblclick', this.resizeToFitContent.bind(this)); + this.addEventListener('mousedown', this.resizeStarted.bind(this)); + } + + connectedCallback() { + // 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 (this.parentElement) { + this.isHorizontal = this.parentElement.classList.contains('horizontal'); + this.classList.add(this.isHorizontal ? 'horizontal' : 'vertical'); + } + } + + disconnectedCallback() { + this.resizeStopped(); + } + + resizeToFitContent() { + // clear flex-grow css style of both pane + if (this.previousSibling != null) { + this.previousSibling.model.setFlexScale(1); + } + return this.nextSibling != null + ? this.nextSibling.model.setFlexScale(1) + : undefined; + } + + resizeStarted(e) { + e.stopPropagation(); + if (!this.overlay) { + this.overlay = document.createElement('div'); + this.overlay.classList.add('atom-pane-cursor-overlay'); + this.overlay.classList.add(this.isHorizontal ? 'horizontal' : 'vertical'); + this.appendChild(this.overlay); + } + document.addEventListener('mousemove', this.resizePane); + document.addEventListener('mouseup', this.resizeStopped); + } + + resizeStopped() { + document.removeEventListener('mousemove', this.resizePane); + document.removeEventListener('mouseup', this.resizeStopped); + if (this.overlay) { + this.removeChild(this.overlay); + this.overlay = undefined; + } + } + + calcRatio(ratio1, ratio2, total) { + const allRatio = ratio1 + ratio2; + return [(total * ratio1) / allRatio, (total * ratio2) / allRatio]; + } + + setFlexGrow(prevSize, nextSize) { + this.prevModel = this.previousSibling.model; + this.nextModel = this.nextSibling.model; + const totalScale = + this.prevModel.getFlexScale() + this.nextModel.getFlexScale(); + const flexGrows = this.calcRatio(prevSize, nextSize, totalScale); + this.prevModel.setFlexScale(flexGrows[0]); + this.nextModel.setFlexScale(flexGrows[1]); + } + + fixInRange(val, minValue, maxValue) { + return Math.min(Math.max(val, minValue), maxValue); + } + + resizePane({ clientX, clientY, which }) { + if (which !== 1) { + return this.resizeStopped(); + } + if (this.previousSibling == null || this.nextSibling == null) { + return this.resizeStopped(); + } + + if (this.isHorizontal) { + const totalWidth = + this.previousSibling.clientWidth + this.nextSibling.clientWidth; + // get the left and right width after move the resize view + let leftWidth = + clientX - this.previousSibling.getBoundingClientRect().left; + leftWidth = this.fixInRange(leftWidth, 0, totalWidth); + const rightWidth = totalWidth - leftWidth; + // set the flex grow by the ratio of left width and right width + // to change pane width + this.setFlexGrow(leftWidth, rightWidth); + } else { + const totalHeight = + this.previousSibling.clientHeight + this.nextSibling.clientHeight; + let topHeight = + clientY - this.previousSibling.getBoundingClientRect().top; + topHeight = this.fixInRange(topHeight, 0, totalHeight); + const bottomHeight = totalHeight - topHeight; + this.setFlexGrow(topHeight, bottomHeight); + } + } +} + +window.customElements.define( + 'atom-pane-resize-handle', + PaneResizeHandleElement +); + +function createPaneResizeHandleElement() { + return document.createElement('atom-pane-resize-handle'); +} + +module.exports = { + createPaneResizeHandleElement +}; diff --git a/src/pane-view.coffee b/src/pane-view.coffee deleted file mode 100644 index 775514ca297..00000000000 --- a/src/pane-view.coffee +++ /dev/null @@ -1,167 +0,0 @@ -{$, View} = require './space-pen-extensions' -Delegator = require 'delegato' -{deprecate} = require 'grim' -{CompositeDisposable} = require 'event-kit' -PropertyAccessors = require 'property-accessors' - -Pane = require './pane' - -# A container which can contains multiple items to be switched between. -# -# Items can be almost anything however most commonly they're {TextEditorView}s. -# -# Most packages won't need to use this class, unless you're interested in -# building a package that deals with switching between panes or items. -module.exports = -class PaneView extends View - Delegator.includeInto(this) - PropertyAccessors.includeInto(this) - - @delegatesProperties 'items', 'activeItem', toProperty: 'model' - @delegatesMethods 'getItems', 'activateNextItem', 'activatePreviousItem', 'getActiveItemIndex', - 'activateItemAtIndex', 'activateItem', 'addItem', 'itemAtIndex', 'moveItem', 'moveItemToPane', - 'destroyItem', 'destroyItems', 'destroyActiveItem', 'destroyInactiveItems', - 'saveActiveItem', 'saveActiveItemAs', 'saveItem', 'saveItemAs', 'saveItems', - 'itemForUri', 'activateItemForUri', 'promptToSaveItem', 'copyActiveItem', 'isActive', - 'activate', 'getActiveItem', toProperty: 'model' - - previousActiveItem: null - attached: false - - constructor: (@element) -> - @itemViews = $(element.itemViews) - super - - setModel: (@model) -> - @subscriptions = new CompositeDisposable - @subscriptions.add @model.observeActiveItem(@onActiveItemChanged) - @subscriptions.add @model.onDidAddItem(@onItemAdded) - @subscriptions.add @model.onDidRemoveItem(@onItemRemoved) - @subscriptions.add @model.onDidMoveItem(@onItemMoved) - @subscriptions.add @model.onWillDestroyItem(@onBeforeItemDestroyed) - @subscriptions.add @model.observeActive(@onActiveStatusChanged) - @subscriptions.add @model.onDidDestroy(@onPaneDestroyed) - - afterAttach: -> - @container ?= @closest('atom-pane-container').view() - @trigger('pane:attached', [this]) unless @attached - @attached = true - - onPaneDestroyed: => - @container?.trigger 'pane:removed', [this] - @subscriptions.dispose() - - remove: -> - @model.destroy() unless @model.isDestroyed() - - # Essential: Returns the {Pane} model underlying this pane view - getModel: -> @model - - # Deprecated: Use ::destroyItem - removeItem: (item) -> - deprecate("Use PaneView::destroyItem instead") - @destroyItem(item) - - # Deprecated: Use ::activateItem - showItem: (item) -> - deprecate("Use PaneView::activateItem instead") - @activateItem(item) - - # Deprecated: Use ::activateItemForUri - showItemForUri: (item) -> - deprecate("Use PaneView::activateItemForUri instead") - @activateItemForUri(item) - - # Deprecated: Use ::activateItemAtIndex - showItemAtIndex: (index) -> - deprecate("Use PaneView::activateItemAtIndex instead") - @activateItemAtIndex(index) - - # Deprecated: Use ::activateNextItem - showNextItem: -> - deprecate("Use PaneView::activateNextItem instead") - @activateNextItem() - - # Deprecated: Use ::activatePreviousItem - showPreviousItem: -> - deprecate("Use PaneView::activatePreviousItem instead") - @activatePreviousItem() - - onActiveStatusChanged: (active) => - if active - @trigger 'pane:became-active' - else - @trigger 'pane:became-inactive' - - # Public: Returns the next pane, ordered by creation. - getNextPane: -> - panes = @container?.getPaneViews() - return unless panes.length > 1 - nextIndex = (panes.indexOf(this) + 1) % panes.length - panes[nextIndex] - - getActivePaneItem: -> - @activeItem - - onActiveItemChanged: (item) => - @activeItemDisposables.dispose() if @activeItemDisposables? - @activeItemDisposables = new CompositeDisposable() - - if @previousActiveItem?.off? - @previousActiveItem.off 'title-changed', @activeItemTitleChanged - @previousActiveItem.off 'modified-status-changed', @activeItemModifiedChanged - @previousActiveItem = item - - return unless item? - - if item.onDidChangeTitle? - disposable = item.onDidChangeTitle(@activeItemTitleChanged) - @activeItemDisposables.add(disposable) if disposable?.dispose? - else if item.on? - disposable = item.on('title-changed', @activeItemTitleChanged) - @activeItemDisposables.add(disposable) if disposable?.dispose? - - if item.onDidChangeModified? - disposable = item.onDidChangeModified(@activeItemModifiedChanged) - @activeItemDisposables.add(disposable) if disposable?.dispose? - else if item.on? - item.on('modified-status-changed', @activeItemModifiedChanged) - @activeItemDisposables.add(disposable) if disposable?.dispose? - - @trigger 'pane:active-item-changed', [item] - - onItemAdded: ({item, index}) => - @trigger 'pane:item-added', [item, index] - - onItemRemoved: ({item, index, destroyed}) => - @trigger 'pane:item-removed', [item, index] - - onItemMoved: ({item, newIndex}) => - @trigger 'pane:item-moved', [item, newIndex] - - onBeforeItemDestroyed: ({item}) => - @unsubscribe(item) if typeof item.off is 'function' - @trigger 'pane:before-item-destroyed', [item] - - activeItemTitleChanged: => - @trigger 'pane:active-item-title-changed' - - activeItemModifiedChanged: => - @trigger 'pane:active-item-modified-status-changed' - - @::accessor 'activeView', -> - element = atom.views.getView(@activeItem) - $(element).view() ? element - - splitLeft: (items...) -> atom.views.getView(@model.splitLeft({items})).__spacePenView - - splitRight: (items...) -> atom.views.getView(@model.splitRight({items})).__spacePenView - - splitUp: (items...) -> atom.views.getView(@model.splitUp({items})).__spacePenView - - splitDown: (items...) -> atom.views.getView(@model.splitDown({items})).__spacePenView - - getContainer: -> @closest('atom-pane-container').view() - - focus: -> - @element.focus() diff --git a/src/pane.coffee b/src/pane.coffee deleted file mode 100644 index 38c8d9201c2..00000000000 --- a/src/pane.coffee +++ /dev/null @@ -1,739 +0,0 @@ -{find, compact, extend, last} = require 'underscore-plus' -{Emitter} = require 'event-kit' -Serializable = require 'serializable' -Grim = require 'grim' -Model = require './model' -PaneAxis = require './pane-axis' -TextEditor = require './text-editor' - -# Extended: A container for presenting content in the center of the workspace. -# Panes can contain multiple items, one of which is *active* at a given time. -# The view corresponding to the active item is displayed in the interface. In -# the default configuration, tabs are also displayed for each item. -module.exports = -class Pane extends Model - atom.deserializers.add(this) - Serializable.includeInto(this) - - constructor: (params) -> - super - - unless Grim.includeDeprecatedAPIs - @container = params?.container - @activeItem = params?.activeItem - - @emitter = new Emitter - @itemSubscriptions = new WeakMap - @items = [] - - @addItems(compact(params?.items ? [])) - @setActiveItem(@items[0]) unless @getActiveItem()? - @setFlexScale(params?.flexScale ? 1) - - # Called by the Serializable mixin during serialization. - serializeParams: -> - if typeof @activeItem?.getURI is 'function' - activeItemURI = @activeItem.getURI() - else if Grim.includeDeprecatedAPIs and typeof @activeItem?.getUri is 'function' - activeItemURI = @activeItem.getUri() - - id: @id - items: compact(@items.map((item) -> item.serialize?())) - activeItemURI: activeItemURI - focused: @focused - flexScale: @flexScale - - # Called by the Serializable mixin during deserialization. - deserializeParams: (params) -> - {items, activeItemURI, activeItemUri} = params - activeItemURI ?= activeItemUri - params.items = compact(items.map (itemState) -> atom.deserializers.deserialize(itemState)) - params.activeItem = find params.items, (item) -> - if typeof item.getURI is 'function' - itemURI = item.getURI() - else if Grim.includeDeprecatedAPIs and typeof item.getUri is 'function' - itemURI = item.getUri() - - itemURI is activeItemURI - params - - getParent: -> @parent - - setParent: (@parent) -> @parent - - getContainer: -> @container - - setContainer: (container) -> - unless container is @container - @container = container - container.didAddPane({pane: this}) - - setFlexScale: (@flexScale) -> - @emitter.emit 'did-change-flex-scale', @flexScale - @flexScale - - getFlexScale: -> @flexScale - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when the pane resize - # - # the callback will be invoked when pane's flexScale property changes - # - # * `callback` {Function} to be called when the pane is resized - # - # Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. - onDidChangeFlexScale: (callback) -> - @emitter.on 'did-change-flex-scale', callback - - # Public: Invoke the given callback with all current and future items. - # - # * `callback` {Function} to be called with current and future items. - # * `item` An item that is present in {::getItems} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeFlexScale: (callback) -> - callback(@flexScale) - @onDidChangeFlexScale(callback) - - # Public: Invoke the given callback when the pane is activated. - # - # The given callback will be invoked whenever {::activate} is called on the - # pane, even if it is already active at the time. - # - # * `callback` {Function} to be called when the pane is activated. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidActivate: (callback) -> - @emitter.on 'did-activate', callback - - # Public: Invoke the given callback when the pane is destroyed. - # - # * `callback` {Function} to be called when the pane is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - # Public: Invoke the given callback when the value of the {::isActive} - # property changes. - # - # * `callback` {Function} to be called when the value of the {::isActive} - # property changes. - # * `active` {Boolean} indicating whether the pane is active. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActive: (callback) -> - @container.onDidChangeActivePane (activePane) => - callback(this is activePane) - - # Public: Invoke the given callback with the current and future values of the - # {::isActive} property. - # - # * `callback` {Function} to be called with the current and future values of - # the {::isActive} property. - # * `active` {Boolean} indicating whether the pane is active. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActive: (callback) -> - callback(@isActive()) - @onDidChangeActive(callback) - - # Public: Invoke the given callback when an item is added to the pane. - # - # * `callback` {Function} to be called with when items are added. - # * `event` {Object} with the following keys: - # * `item` The added pane item. - # * `index` {Number} indicating where the item is located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddItem: (callback) -> - @emitter.on 'did-add-item', callback - - # Public: Invoke the given callback when an item is removed from the pane. - # - # * `callback` {Function} to be called with when items are removed. - # * `event` {Object} with the following keys: - # * `item` The removed pane item. - # * `index` {Number} indicating where the item was located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveItem: (callback) -> - @emitter.on 'did-remove-item', callback - - # Public: Invoke the given callback when an item is moved within the pane. - # - # * `callback` {Function} to be called with when items are moved. - # * `event` {Object} with the following keys: - # * `item` The removed pane item. - # * `oldIndex` {Number} indicating where the item was located. - # * `newIndex` {Number} indicating where the item is now located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidMoveItem: (callback) -> - @emitter.on 'did-move-item', callback - - # Public: Invoke the given callback with all current and future items. - # - # * `callback` {Function} to be called with current and future items. - # * `item` An item that is present in {::getItems} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeItems: (callback) -> - callback(item) for item in @getItems() - @onDidAddItem ({item}) -> callback(item) - - # Public: Invoke the given callback when the value of {::getActiveItem} - # changes. - # - # * `callback` {Function} to be called with when the active item changes. - # * `activeItem` The current active item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActiveItem: (callback) -> - @emitter.on 'did-change-active-item', callback - - # Public: Invoke the given callback with the current and future values of - # {::getActiveItem}. - # - # * `callback` {Function} to be called with the current and future active - # items. - # * `activeItem` The current active item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActiveItem: (callback) -> - callback(@getActiveItem()) - @onDidChangeActiveItem(callback) - - # Public: Invoke the given callback before items are destroyed. - # - # * `callback` {Function} to be called before items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The item that will be destroyed. - # * `index` The location of the item. - # - # Returns a {Disposable} on which `.dispose()` can be called to - # unsubscribe. - onWillDestroyItem: (callback) -> - @emitter.on 'will-destroy-item', callback - - # Called by the view layer to indicate that the pane has gained focus. - focus: -> - @focused = true - @activate() unless @isActive() - - # Called by the view layer to indicate that the pane has lost focus. - blur: -> - @focused = false - true # if this is called from an event handler, don't cancel it - - isFocused: -> @focused - - getPanes: -> [this] - - unsubscribeFromItem: (item) -> - @itemSubscriptions.get(item)?.dispose() - @itemSubscriptions.delete(item) - - ### - Section: Items - ### - - # Public: Get the items in this pane. - # - # Returns an {Array} of items. - getItems: -> - @items.slice() - - # Public: Get the active pane item in this pane. - # - # Returns a pane item. - getActiveItem: -> @activeItem - - setActiveItem: (activeItem) -> - unless activeItem is @activeItem - @activeItem = activeItem - @emitter.emit 'did-change-active-item', @activeItem - @activeItem - - # Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. - getActiveEditor: -> - @activeItem if @activeItem instanceof TextEditor - - # Public: Return the item at the given index. - # - # * `index` {Number} - # - # Returns an item or `null` if no item exists at the given index. - itemAtIndex: (index) -> - @items[index] - - # Public: Makes the next item active. - activateNextItem: -> - index = @getActiveItemIndex() - if index < @items.length - 1 - @activateItemAtIndex(index + 1) - else - @activateItemAtIndex(0) - - # Public: Makes the previous item active. - activatePreviousItem: -> - index = @getActiveItemIndex() - if index > 0 - @activateItemAtIndex(index - 1) - else - @activateItemAtIndex(@items.length - 1) - - # Public: Move the active tab to the right. - moveItemRight: -> - index = @getActiveItemIndex() - rightItemIndex = index + 1 - @moveItem(@getActiveItem(), rightItemIndex) unless rightItemIndex > @items.length - 1 - - # Public: Move the active tab to the left - moveItemLeft: -> - index = @getActiveItemIndex() - leftItemIndex = index - 1 - @moveItem(@getActiveItem(), leftItemIndex) unless leftItemIndex < 0 - - # Public: Get the index of the active item. - # - # Returns a {Number}. - getActiveItemIndex: -> - @items.indexOf(@activeItem) - - # Public: Activate the item at the given index. - # - # * `index` {Number} - activateItemAtIndex: (index) -> - @activateItem(@itemAtIndex(index)) - - # Public: Make the given item *active*, causing it to be displayed by - # the pane's view. - activateItem: (item) -> - if item? - @addItem(item) - @setActiveItem(item) - - # Public: Add the given item to the pane. - # - # * `item` The item to add. It can be a model with an associated view or a - # view. - # * `index` (optional) {Number} indicating the index at which to add the item. - # If omitted, the item is added after the current active item. - # - # Returns the added item. - addItem: (item, index=@getActiveItemIndex() + 1) -> - return if item in @items - - if typeof item.onDidDestroy is 'function' - @itemSubscriptions.set item, item.onDidDestroy => @removeItem(item, true) - else if Grim.includeDeprecatedAPIs and typeof item.on is 'function' - @subscribe item, 'destroyed', => @removeItem(item, true) - - @items.splice(index, 0, item) - @emit 'item-added', item, index if Grim.includeDeprecatedAPIs - @emitter.emit 'did-add-item', {item, index} - @setActiveItem(item) unless @getActiveItem()? - item - - # Public: Add the given items to the pane. - # - # * `items` An {Array} of items to add. Items can be views or models with - # associated views. Any objects that are already present in the pane's - # current items will not be added again. - # * `index` (optional) {Number} index at which to add the items. If omitted, - # the item is # added after the current active item. - # - # Returns an {Array} of added items. - addItems: (items, index=@getActiveItemIndex() + 1) -> - items = items.filter (item) => not (item in @items) - @addItem(item, index + i) for item, i in items - items - - removeItem: (item, destroyed=false) -> - index = @items.indexOf(item) - return if index is -1 - - if Grim.includeDeprecatedAPIs and typeof item.on is 'function' - @unsubscribe item - @unsubscribeFromItem(item) - - if item is @activeItem - if @items.length is 1 - @setActiveItem(undefined) - else if index is 0 - @activateNextItem() - else - @activatePreviousItem() - @items.splice(index, 1) - @emit 'item-removed', item, index, destroyed if Grim.includeDeprecatedAPIs - @emitter.emit 'did-remove-item', {item, index, destroyed} - @container?.didDestroyPaneItem({item, index, pane: this}) if destroyed - @destroy() if @items.length is 0 and atom.config.get('core.destroyEmptyPanes') - - # Public: Move the given item to the given index. - # - # * `item` The item to move. - # * `index` {Number} indicating the index to which to move the item. - moveItem: (item, newIndex) -> - oldIndex = @items.indexOf(item) - @items.splice(oldIndex, 1) - @items.splice(newIndex, 0, item) - @emit 'item-moved', item, newIndex if Grim.includeDeprecatedAPIs - @emitter.emit 'did-move-item', {item, oldIndex, newIndex} - - # Public: Move the given item to the given index on another pane. - # - # * `item` The item to move. - # * `pane` {Pane} to which to move the item. - # * `index` {Number} indicating the index to which to move the item in the - # given pane. - moveItemToPane: (item, pane, index) -> - @removeItem(item) - pane.addItem(item, index) - - # Public: Destroy the active item and activate the next item. - destroyActiveItem: -> - @destroyItem(@activeItem) - false - - # Public: Destroy the given item. - # - # If the item is active, the next item will be activated. If the item is the - # last item, the pane will be destroyed if the `core.destroyEmptyPanes` config - # setting is `true`. - # - # * `item` Item to destroy - destroyItem: (item) -> - index = @items.indexOf(item) - if index isnt -1 - @emit 'before-item-destroyed', item if Grim.includeDeprecatedAPIs - @emitter.emit 'will-destroy-item', {item, index} - @container?.willDestroyPaneItem({item, index, pane: this}) - if @promptToSaveItem(item) - @removeItem(item, true) - item.destroy?() - true - else - false - - # Public: Destroy all items. - destroyItems: -> - @destroyItem(item) for item in @getItems() - return - - # Public: Destroy all items except for the active item. - destroyInactiveItems: -> - @destroyItem(item) for item in @getItems() when item isnt @activeItem - return - - promptToSaveItem: (item, options={}) -> - return true unless item.shouldPromptToSave?(options) - - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - else - return true - - chosen = atom.confirm - message: "'#{item.getTitle?() ? uri}' has changes, do you want to save them?" - detailedMessage: "Your changes will be lost if you close this item without saving." - buttons: ["Save", "Cancel", "Don't Save"] - - switch chosen - when 0 then @saveItem(item, -> true) - when 1 then false - when 2 then true - - # Public: Save the active item. - saveActiveItem: (nextAction) -> - @saveItem(@getActiveItem(), nextAction) - - # Public: Prompt the user for a location and save the active item with the - # path they select. - # - # * `nextAction` (optional) {Function} which will be called after the item is - # successfully saved. - saveActiveItemAs: (nextAction) -> - @saveItemAs(@getActiveItem(), nextAction) - - # Public: Save the given item. - # - # * `item` The item to save. - # * `nextAction` (optional) {Function} which will be called after the item is - # successfully saved. - saveItem: (item, nextAction) -> - if typeof item?.getURI is 'function' - itemURI = item.getURI() - else if typeof item?.getUri is 'function' - itemURI = item.getUri() - - if itemURI? - try - item.save?() - catch error - @handleSaveError(error) - nextAction?() - else - @saveItemAs(item, nextAction) - - # Public: Prompt the user for a location and save the active item with the - # path they select. - # - # * `item` The item to save. - # * `nextAction` (optional) {Function} which will be called after the item is - # successfully saved. - saveItemAs: (item, nextAction) -> - return unless item?.saveAs? - - itemPath = item.getPath?() - newItemPath = atom.showSaveDialogSync(itemPath) - if newItemPath - try - item.saveAs(newItemPath) - catch error - @handleSaveError(error) - nextAction?() - - # Public: Save all items. - saveItems: -> - @saveItem(item) for item in @getItems() - return - - # Public: Return the first item that matches the given URI or undefined if - # none exists. - # - # * `uri` {String} containing a URI. - itemForURI: (uri) -> - find @items, (item) -> - if typeof item.getURI is 'function' - itemUri = item.getURI() - else if typeof item.getUri is 'function' - itemUri = item.getUri() - - itemUri is uri - - # Public: Activate the first item that matches the given URI. - # - # Returns a {Boolean} indicating whether an item matching the URI was found. - activateItemForURI: (uri) -> - if item = @itemForURI(uri) - @activateItem(item) - true - else - false - - copyActiveItem: -> - if @activeItem? - @activeItem.copy?() ? atom.deserializers.deserialize(@activeItem.serialize()) - - ### - Section: Lifecycle - ### - - # Public: Determine whether the pane is active. - # - # Returns a {Boolean}. - isActive: -> - @container?.getActivePane() is this - - # Public: Makes this pane the *active* pane, causing it to gain focus. - activate: -> - throw new Error("Pane has been destroyed") if @isDestroyed() - - @container?.setActivePane(this) - @emit 'activated' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-activate' - - # Public: Close the pane and destroy all its items. - # - # If this is the last pane, all the items will be destroyed but the pane - # itself will not be destroyed. - destroy: -> - if @container?.isAlive() and @container.getPanes().length is 1 - @destroyItems() - else - super - - # Called by model superclass. - destroyed: -> - @container.activateNextPane() if @isActive() - @emitter.emit 'did-destroy' - @emitter.dispose() - item.destroy?() for item in @items.slice() - @container?.didDestroyPane(pane: this) - - ### - Section: Splitting - ### - - # Public: Create a new pane to the left of this pane. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitLeft: (params) -> - @split('horizontal', 'before', params) - - # Public: Create a new pane to the right of this pane. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitRight: (params) -> - @split('horizontal', 'after', params) - - # Public: Creates a new pane above the receiver. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitUp: (params) -> - @split('vertical', 'before', params) - - # Public: Creates a new pane below the receiver. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitDown: (params) -> - @split('vertical', 'after', params) - - split: (orientation, side, params) -> - if params?.copyActiveItem - params.items ?= [] - params.items.push(@copyActiveItem()) - - if @parent.orientation isnt orientation - @parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this], @flexScale})) - @setFlexScale(1) - - newPane = new @constructor(params) - switch side - when 'before' then @parent.insertChildBefore(this, newPane) - when 'after' then @parent.insertChildAfter(this, newPane) - - newPane.activate() - newPane - - # If the parent is a horizontal axis, returns its first child if it is a pane; - # otherwise returns this pane. - findLeftmostSibling: -> - if @parent.orientation is 'horizontal' - [leftmostSibling] = @parent.children - if leftmostSibling instanceof PaneAxis - this - else - leftmostSibling - else - this - - # If the parent is a horizontal axis, returns its last child if it is a pane; - # otherwise returns a new pane created by splitting this pane rightward. - findOrCreateRightmostSibling: -> - if @parent.orientation is 'horizontal' - rightmostSibling = last(@parent.children) - if rightmostSibling instanceof PaneAxis - @splitRight() - else - rightmostSibling - else - @splitRight() - - close: -> - @destroy() if @confirmClose() - - confirmClose: -> - for item in @getItems() - return false unless @promptToSaveItem(item) - true - - handleSaveError: (error) -> - if error.code is 'EISDIR' or error.message.endsWith('is a directory') - atom.notifications.addWarning("Unable to save file: #{error.message}") - else if error.code is 'EACCES' and error.path? - atom.notifications.addWarning("Unable to save file: Permission denied '#{error.path}'") - else if error.code in ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST'] and error.path? - atom.notifications.addWarning("Unable to save file '#{error.path}'", detail: error.message) - else if error.code is 'EROFS' and error.path? - atom.notifications.addWarning("Unable to save file: Read-only file system '#{error.path}'") - else if error.code is 'ENOSPC' and error.path? - atom.notifications.addWarning("Unable to save file: No space left on device '#{error.path}'") - else if error.code is 'ENXIO' and error.path? - atom.notifications.addWarning("Unable to save file: No such device or address '#{error.path}'") - else if errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) - fileName = errorMatch[1] - atom.notifications.addWarning("Unable to save file: A directory in the path '#{fileName}' could not be written to") - else - throw error - -if Grim.includeDeprecatedAPIs - Pane.properties - container: undefined - activeItem: undefined - focused: false - - Pane.behavior 'active', -> - @$container - .switch((container) -> container?.$activePane) - .map((activePane) => activePane is this) - .distinctUntilChanged() - - Pane::on = (eventName) -> - switch eventName - when 'activated' - Grim.deprecate("Use Pane::onDidActivate instead") - when 'destroyed' - Grim.deprecate("Use Pane::onDidDestroy instead") - when 'item-added' - Grim.deprecate("Use Pane::onDidAddItem instead") - when 'item-removed' - Grim.deprecate("Use Pane::onDidRemoveItem instead") - when 'item-moved' - Grim.deprecate("Use Pane::onDidMoveItem instead") - when 'before-item-destroyed' - Grim.deprecate("Use Pane::onWillDestroyItem instead") - else - Grim.deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") - super - - Pane::behavior = (behaviorName) -> - switch behaviorName - when 'active' - Grim.deprecate("The $active behavior property is deprecated. Use ::observeActive or ::onDidChangeActive instead.") - when 'container' - Grim.deprecate("The $container behavior property is deprecated.") - when 'activeItem' - Grim.deprecate("The $activeItem behavior property is deprecated. Use ::observeActiveItem or ::onDidChangeActiveItem instead.") - when 'focused' - Grim.deprecate("The $focused behavior property is deprecated.") - else - Grim.deprecate("Pane::behavior is deprecated. Use event subscription methods instead.") - - super - - Pane::itemForUri = (uri) -> - Grim.deprecate("Use `::itemForURI` instead.") - @itemForURI(uri) - - Pane::activateItemForUri = (uri) -> - Grim.deprecate("Use `::activateItemForURI` instead.") - @activateItemForURI(uri) -else - Pane::container = undefined - Pane::activeItem = undefined - Pane::focused = undefined diff --git a/src/pane.js b/src/pane.js new file mode 100644 index 00000000000..a55575ae049 --- /dev/null +++ b/src/pane.js @@ -0,0 +1,1430 @@ +const Grim = require('grim'); +const { CompositeDisposable, Emitter } = require('event-kit'); +const PaneAxis = require('./pane-axis'); +const TextEditor = require('./text-editor'); +const { createPaneElement } = require('./pane-element'); + +let nextInstanceId = 1; + +class SaveCancelledError extends Error {} + +// Extended: A container for presenting content in the center of the workspace. +// Panes can contain multiple items, one of which is *active* at a given time. +// The view corresponding to the active item is displayed in the interface. In +// the default configuration, tabs are also displayed for each item. +// +// Each pane may also contain one *pending* item. When a pending item is added +// to a pane, it will replace the currently pending item, if any, instead of +// simply being added. In the default configuration, the text in the tab for +// pending items is shown in italics. +module.exports = class Pane { + inspect() { + return `Pane ${this.id}`; + } + + static deserialize( + state, + { deserializers, applicationDelegate, config, notifications, views } + ) { + const { activeItemIndex } = state; + const activeItemURI = state.activeItemURI || state.activeItemUri; + + const items = []; + for (const itemState of state.items) { + const item = deserializers.deserialize(itemState); + if (item) items.push(item); + } + state.items = items; + + state.activeItem = items[activeItemIndex]; + if (!state.activeItem && activeItemURI) { + state.activeItem = state.items.find( + item => + typeof item.getURI === 'function' && item.getURI() === activeItemURI + ); + } + + return new Pane( + Object.assign( + { + deserializerManager: deserializers, + notificationManager: notifications, + viewRegistry: views, + config, + applicationDelegate + }, + state + ) + ); + } + + constructor(params = {}) { + this.setPendingItem = this.setPendingItem.bind(this); + this.getPendingItem = this.getPendingItem.bind(this); + this.clearPendingItem = this.clearPendingItem.bind(this); + this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind( + this + ); + this.saveItem = this.saveItem.bind(this); + this.saveItemAs = this.saveItemAs.bind(this); + + this.id = params.id; + if (this.id != null) { + nextInstanceId = Math.max(nextInstanceId, this.id + 1); + } else { + this.id = nextInstanceId++; + } + + this.activeItem = params.activeItem; + this.focused = params.focused != null ? params.focused : false; + this.applicationDelegate = params.applicationDelegate; + this.notificationManager = params.notificationManager; + this.config = params.config; + this.deserializerManager = params.deserializerManager; + this.viewRegistry = params.viewRegistry; + + this.emitter = new Emitter(); + this.alive = true; + this.subscriptionsPerItem = new WeakMap(); + this.items = []; + this.itemStack = []; + this.container = null; + + this.addItems((params.items || []).filter(item => item)); + if (!this.getActiveItem()) this.setActiveItem(this.items[0]); + this.addItemsToStack(params.itemStackIndices || []); + this.setFlexScale(params.flexScale || 1); + } + + getElement() { + if (!this.element) { + this.element = createPaneElement().initialize(this, { + views: this.viewRegistry, + applicationDelegate: this.applicationDelegate + }); + } + return this.element; + } + + serialize() { + const itemsToBeSerialized = this.items.filter( + item => item && typeof item.serialize === 'function' + ); + + const itemStackIndices = []; + for (const item of this.itemStack) { + if (typeof item.serialize === 'function') { + itemStackIndices.push(itemsToBeSerialized.indexOf(item)); + } + } + + const activeItemIndex = itemsToBeSerialized.indexOf(this.activeItem); + + return { + deserializer: 'Pane', + id: this.id, + items: itemsToBeSerialized.map(item => item.serialize()), + itemStackIndices, + activeItemIndex, + focused: this.focused, + flexScale: this.flexScale + }; + } + + getParent() { + return this.parent; + } + + setParent(parent) { + this.parent = parent; + } + + getContainer() { + return this.container; + } + + setContainer(container) { + if (container && container !== this.container) { + this.container = container; + container.didAddPane({ pane: this }); + } + } + + // Private: Determine whether the given item is allowed to exist in this pane. + // + // * `item` the Item + // + // Returns a {Boolean}. + isItemAllowed(item) { + if (typeof item.getAllowedLocations !== 'function') { + return true; + } else { + return item + .getAllowedLocations() + .includes(this.getContainer().getLocation()); + } + } + + setFlexScale(flexScale) { + this.flexScale = flexScale; + this.emitter.emit('did-change-flex-scale', this.flexScale); + return this.flexScale; + } + + getFlexScale() { + return this.flexScale; + } + + increaseSize() { + if (this.getContainer().getPanes().length > 1) { + this.setFlexScale(this.getFlexScale() * 1.1); + } + } + + decreaseSize() { + if (this.getContainer().getPanes().length > 1) { + this.setFlexScale(this.getFlexScale() / 1.1); + } + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the pane resizes + // + // The callback will be invoked when pane's flexScale property changes. + // Use {::getFlexScale} to get the current value. + // + // * `callback` {Function} to be called when the pane is resized + // * `flexScale` {Number} representing the panes `flex-grow`; ability for a + // flex item to grow if necessary. + // + // Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. + onDidChangeFlexScale(callback) { + return this.emitter.on('did-change-flex-scale', callback); + } + + // Public: Invoke the given callback with the current and future values of + // {::getFlexScale}. + // + // * `callback` {Function} to be called with the current and future values of + // the {::getFlexScale} property. + // * `flexScale` {Number} representing the panes `flex-grow`; ability for a + // flex item to grow if necessary. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeFlexScale(callback) { + callback(this.flexScale); + return this.onDidChangeFlexScale(callback); + } + + // Public: Invoke the given callback when the pane is activated. + // + // The given callback will be invoked whenever {::activate} is called on the + // pane, even if it is already active at the time. + // + // * `callback` {Function} to be called when the pane is activated. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidActivate(callback) { + return this.emitter.on('did-activate', callback); + } + + // Public: Invoke the given callback before the pane is destroyed. + // + // * `callback` {Function} to be called before the pane is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroy(callback) { + return this.emitter.on('will-destroy', callback); + } + + // Public: Invoke the given callback when the pane is destroyed. + // + // * `callback` {Function} to be called when the pane is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + // Public: Invoke the given callback when the value of the {::isActive} + // property changes. + // + // * `callback` {Function} to be called when the value of the {::isActive} + // property changes. + // * `active` {Boolean} indicating whether the pane is active. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActive(callback) { + return this.container.onDidChangeActivePane(activePane => { + const isActive = this === activePane; + callback(isActive); + }); + } + + // Public: Invoke the given callback with the current and future values of the + // {::isActive} property. + // + // * `callback` {Function} to be called with the current and future values of + // the {::isActive} property. + // * `active` {Boolean} indicating whether the pane is active. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActive(callback) { + callback(this.isActive()); + return this.onDidChangeActive(callback); + } + + // Public: Invoke the given callback when an item is added to the pane. + // + // * `callback` {Function} to be called with when items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `index` {Number} indicating where the item is located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddItem(callback) { + return this.emitter.on('did-add-item', callback); + } + + // Public: Invoke the given callback when an item is removed from the pane. + // + // * `callback` {Function} to be called with when items are removed. + // * `event` {Object} with the following keys: + // * `item` The removed pane item. + // * `index` {Number} indicating where the item was located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveItem(callback) { + return this.emitter.on('did-remove-item', callback); + } + + // Public: Invoke the given callback before an item is removed from the pane. + // + // * `callback` {Function} to be called with when items are removed. + // * `event` {Object} with the following keys: + // * `item` The pane item to be removed. + // * `index` {Number} indicating where the item is located. + onWillRemoveItem(callback) { + return this.emitter.on('will-remove-item', callback); + } + + // Public: Invoke the given callback when an item is moved within the pane. + // + // * `callback` {Function} to be called with when items are moved. + // * `event` {Object} with the following keys: + // * `item` The removed pane item. + // * `oldIndex` {Number} indicating where the item was located. + // * `newIndex` {Number} indicating where the item is now located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidMoveItem(callback) { + return this.emitter.on('did-move-item', callback); + } + + // Public: Invoke the given callback with all current and future items. + // + // * `callback` {Function} to be called with current and future items. + // * `item` An item that is present in {::getItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeItems(callback) { + for (let item of this.getItems()) { + callback(item); + } + return this.onDidAddItem(({ item }) => callback(item)); + } + + // Public: Invoke the given callback when the value of {::getActiveItem} + // changes. + // + // * `callback` {Function} to be called with when the active item changes. + // * `activeItem` The current active item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActiveItem(callback) { + return this.emitter.on('did-change-active-item', callback); + } + + // Public: Invoke the given callback when {::activateNextRecentlyUsedItem} + // has been called, either initiating or continuing a forward MRU traversal of + // pane items. + // + // * `callback` {Function} to be called with when the active item changes. + // * `nextRecentlyUsedItem` The next MRU item, now being set active + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onChooseNextMRUItem(callback) { + return this.emitter.on('choose-next-mru-item', callback); + } + + // Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem} + // has been called, either initiating or continuing a reverse MRU traversal of + // pane items. + // + // * `callback` {Function} to be called with when the active item changes. + // * `previousRecentlyUsedItem` The previous MRU item, now being set active + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onChooseLastMRUItem(callback) { + return this.emitter.on('choose-last-mru-item', callback); + } + + // Public: Invoke the given callback when {::moveActiveItemToTopOfStack} + // has been called, terminating an MRU traversal of pane items and moving the + // current active item to the top of the stack. Typically bound to a modifier + // (e.g. CTRL) key up event. + // + // * `callback` {Function} to be called with when the MRU traversal is done. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDoneChoosingMRUItem(callback) { + return this.emitter.on('done-choosing-mru-item', callback); + } + + // Public: Invoke the given callback with the current and future values of + // {::getActiveItem}. + // + // * `callback` {Function} to be called with the current and future active + // items. + // * `activeItem` The current active item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActiveItem(callback) { + callback(this.getActiveItem()); + return this.onDidChangeActiveItem(callback); + } + + // Public: Invoke the given callback before items are destroyed. + // + // * `callback` {Function} to be called before items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The item that will be destroyed. + // * `index` The location of the item. + // + // Returns a {Disposable} on which `.dispose()` can be called to + // unsubscribe. + onWillDestroyItem(callback) { + return this.emitter.on('will-destroy-item', callback); + } + + // Called by the view layer to indicate that the pane has gained focus. + focus() { + return this.activate(); + } + + // Called by the view layer to indicate that the pane has lost focus. + blur() { + this.focused = false; + return true; // if this is called from an event handler, don't cancel it + } + + isFocused() { + return this.focused; + } + + getPanes() { + return [this]; + } + + unsubscribeFromItem(item) { + const subscription = this.subscriptionsPerItem.get(item); + if (subscription) { + subscription.dispose(); + this.subscriptionsPerItem.delete(item); + } + } + + /* + Section: Items + */ + + // Public: Get the items in this pane. + // + // Returns an {Array} of items. + getItems() { + return this.items.slice(); + } + + // Public: Get the active pane item in this pane. + // + // Returns a pane item. + getActiveItem() { + return this.activeItem; + } + + setActiveItem(activeItem, options) { + const modifyStack = options && options.modifyStack; + if (activeItem !== this.activeItem) { + if (modifyStack !== false) this.addItemToStack(activeItem); + this.activeItem = activeItem; + this.emitter.emit('did-change-active-item', this.activeItem); + if (this.container) + this.container.didChangeActiveItemOnPane(this, this.activeItem); + } + return this.activeItem; + } + + // Build the itemStack after deserializing + addItemsToStack(itemStackIndices) { + if (this.items.length > 0) { + if ( + itemStackIndices.length !== this.items.length || + itemStackIndices.includes(-1) + ) { + itemStackIndices = this.items.map((item, i) => i); + } + + for (let itemIndex of itemStackIndices) { + this.addItemToStack(this.items[itemIndex]); + } + } + } + + // Add item (or move item) to the end of the itemStack + addItemToStack(newItem) { + if (newItem == null) { + return; + } + const index = this.itemStack.indexOf(newItem); + if (index !== -1) this.itemStack.splice(index, 1); + return this.itemStack.push(newItem); + } + + // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. + getActiveEditor() { + if (this.activeItem instanceof TextEditor) return this.activeItem; + } + + // Public: Return the item at the given index. + // + // * `index` {Number} + // + // Returns an item or `null` if no item exists at the given index. + itemAtIndex(index) { + return this.items[index]; + } + + // Makes the next item in the itemStack active. + activateNextRecentlyUsedItem() { + if (this.items.length > 1) { + if (this.itemStackIndex == null) + this.itemStackIndex = this.itemStack.length - 1; + if (this.itemStackIndex === 0) + this.itemStackIndex = this.itemStack.length; + this.itemStackIndex--; + const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex]; + this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem); + this.setActiveItem(nextRecentlyUsedItem, { modifyStack: false }); + } + } + + // Makes the previous item in the itemStack active. + activatePreviousRecentlyUsedItem() { + if (this.items.length > 1) { + if ( + this.itemStackIndex + 1 === this.itemStack.length || + this.itemStackIndex == null + ) { + this.itemStackIndex = -1; + } + this.itemStackIndex++; + const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex]; + this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem); + this.setActiveItem(previousRecentlyUsedItem, { modifyStack: false }); + } + } + + // Moves the active item to the end of the itemStack once the ctrl key is lifted + moveActiveItemToTopOfStack() { + delete this.itemStackIndex; + this.addItemToStack(this.activeItem); + this.emitter.emit('done-choosing-mru-item'); + } + + // Public: Makes the next item active. + activateNextItem() { + const index = this.getActiveItemIndex(); + if (index < this.items.length - 1) { + this.activateItemAtIndex(index + 1); + } else { + this.activateItemAtIndex(0); + } + } + + // Public: Makes the previous item active. + activatePreviousItem() { + const index = this.getActiveItemIndex(); + if (index > 0) { + this.activateItemAtIndex(index - 1); + } else { + this.activateItemAtIndex(this.items.length - 1); + } + } + + activateLastItem() { + this.activateItemAtIndex(this.items.length - 1); + } + + // Public: Move the active tab to the right. + moveItemRight() { + const index = this.getActiveItemIndex(); + const rightItemIndex = index + 1; + if (rightItemIndex <= this.items.length - 1) + this.moveItem(this.getActiveItem(), rightItemIndex); + } + + // Public: Move the active tab to the left + moveItemLeft() { + const index = this.getActiveItemIndex(); + const leftItemIndex = index - 1; + if (leftItemIndex >= 0) + return this.moveItem(this.getActiveItem(), leftItemIndex); + } + + // Public: Get the index of the active item. + // + // Returns a {Number}. + getActiveItemIndex() { + return this.items.indexOf(this.activeItem); + } + + // Public: Activate the item at the given index. + // + // * `index` {Number} + activateItemAtIndex(index) { + const item = this.itemAtIndex(index) || this.getActiveItem(); + return this.setActiveItem(item); + } + + // Public: Make the given item *active*, causing it to be displayed by + // the pane's view. + // + // * `item` The item to activate + // * `options` (optional) {Object} + // * `pending` (optional) {Boolean} indicating that the item should be added + // in a pending state if it does not yet exist in the pane. Existing pending + // items in a pane are replaced with new pending items when they are opened. + activateItem(item, options = {}) { + if (item) { + const index = + this.getPendingItem() === this.activeItem + ? this.getActiveItemIndex() + : this.getActiveItemIndex() + 1; + this.addItem(item, Object.assign({}, options, { index })); + this.setActiveItem(item); + } + } + + // Public: Add the given item to the pane. + // + // * `item` The item to add. It can be a model with an associated view or a + // view. + // * `options` (optional) {Object} + // * `index` (optional) {Number} indicating the index at which to add the item. + // If omitted, the item is added after the current active item. + // * `pending` (optional) {Boolean} indicating that the item should be + // added in a pending state. Existing pending items in a pane are replaced with + // new pending items when they are opened. + // + // Returns the added item. + addItem(item, options = {}) { + // Backward compat with old API: + // addItem(item, index=@getActiveItemIndex() + 1) + if (typeof options === 'number') { + Grim.deprecate( + `Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})` + ); + options = { index: options }; + } + + const index = + options.index != null ? options.index : this.getActiveItemIndex() + 1; + const moved = options.moved != null ? options.moved : false; + const pending = options.pending != null ? options.pending : false; + + if (!item || typeof item !== 'object') { + throw new Error( + `Pane items must be objects. Attempted to add item ${item}.` + ); + } + + if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { + throw new Error( + `Adding a pane item with URI '${typeof item.getURI === 'function' && + item.getURI()}' that has already been destroyed` + ); + } + + if (this.items.includes(item)) return; + + const itemSubscriptions = new CompositeDisposable(); + this.subscriptionsPerItem.set(item, itemSubscriptions); + if (typeof item.onDidDestroy === 'function') { + itemSubscriptions.add( + item.onDidDestroy(() => this.removeItem(item, false)) + ); + } + if (typeof item.onDidTerminatePendingState === 'function') { + itemSubscriptions.add( + item.onDidTerminatePendingState(() => { + if (this.getPendingItem() === item) this.clearPendingItem(); + }) + ); + } + + this.items.splice(index, 0, item); + const lastPendingItem = this.getPendingItem(); + const replacingPendingItem = lastPendingItem != null && !moved; + if (replacingPendingItem) this.pendingItem = null; + if (pending) this.setPendingItem(item); + + this.emitter.emit('did-add-item', { item, index, moved }); + if (!moved) { + if (this.container) this.container.didAddPaneItem(item, this, index); + } + + if (replacingPendingItem) this.destroyItem(lastPendingItem); + if (!this.getActiveItem()) this.setActiveItem(item); + return item; + } + + setPendingItem(item) { + if (this.pendingItem !== item) { + const mostRecentPendingItem = this.pendingItem; + this.pendingItem = item; + if (mostRecentPendingItem) { + this.emitter.emit( + 'item-did-terminate-pending-state', + mostRecentPendingItem + ); + } + } + } + + getPendingItem() { + return this.pendingItem || null; + } + + clearPendingItem() { + this.setPendingItem(null); + } + + onItemDidTerminatePendingState(callback) { + return this.emitter.on('item-did-terminate-pending-state', callback); + } + + // Public: Add the given items to the pane. + // + // * `items` An {Array} of items to add. Items can be views or models with + // associated views. Any objects that are already present in the pane's + // current items will not be added again. + // * `index` (optional) {Number} index at which to add the items. If omitted, + // the item is # added after the current active item. + // + // Returns an {Array} of added items. + addItems(items, index = this.getActiveItemIndex() + 1) { + items = items.filter(item => !this.items.includes(item)); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + this.addItem(item, { index: index + i }); + } + return items; + } + + removeItem(item, moved) { + const index = this.items.indexOf(item); + if (index === -1) return; + if (this.getPendingItem() === item) this.pendingItem = null; + this.removeItemFromStack(item); + this.emitter.emit('will-remove-item', { + item, + index, + destroyed: !moved, + moved + }); + this.unsubscribeFromItem(item); + + if (item === this.activeItem) { + if (this.items.length === 1) { + this.setActiveItem(undefined); + } else if (index === 0) { + this.activateNextItem(); + } else { + this.activatePreviousItem(); + } + } + this.items.splice(index, 1); + this.emitter.emit('did-remove-item', { + item, + index, + destroyed: !moved, + moved + }); + if (!moved && this.container) + this.container.didDestroyPaneItem({ item, index, pane: this }); + if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) + this.destroy(); + } + + // Remove the given item from the itemStack. + // + // * `item` The item to remove. + // * `index` {Number} indicating the index to which to remove the item from the itemStack. + removeItemFromStack(item) { + const index = this.itemStack.indexOf(item); + if (index !== -1) this.itemStack.splice(index, 1); + } + + // Public: Move the given item to the given index. + // + // * `item` The item to move. + // * `index` {Number} indicating the index to which to move the item. + moveItem(item, newIndex) { + const oldIndex = this.items.indexOf(item); + this.items.splice(oldIndex, 1); + this.items.splice(newIndex, 0, item); + this.emitter.emit('did-move-item', { item, oldIndex, newIndex }); + } + + // Public: Move the given item to the given index on another pane. + // + // * `item` The item to move. + // * `pane` {Pane} to which to move the item. + // * `index` {Number} indicating the index to which to move the item in the + // given pane. + moveItemToPane(item, pane, index) { + this.removeItem(item, true); + return pane.addItem(item, { index, moved: true }); + } + + // Public: Destroy the active item and activate the next item. + // + // Returns a {Promise} that resolves when the item is destroyed. + destroyActiveItem() { + return this.destroyItem(this.activeItem); + } + + // Public: Destroy the given item. + // + // If the item is active, the next item will be activated. If the item is the + // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config + // setting is `true`. + // + // This action can be prevented by onWillDestroyPaneItem callbacks in which + // case nothing happens. + // + // * `item` Item to destroy + // * `force` (optional) {Boolean} Destroy the item without prompting to save + // it, even if the item's `isPermanentDockItem` method returns true. + // + // Returns a {Promise} that resolves with a {Boolean} indicating whether or not + // the item was destroyed. + async destroyItem(item, force) { + const index = this.items.indexOf(item); + if (index === -1) return false; + + if ( + !force && + typeof item.isPermanentDockItem === 'function' && + item.isPermanentDockItem() && + (!this.container || this.container.getLocation() !== 'center') + ) { + return false; + } + + // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior + // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. + if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { + await this.emitter.emitAsync('will-destroy-item', { item, index }); + } + if ( + this.container && + this.container.emitter.listenerCountForEventName( + 'will-destroy-pane-item' + ) > 0 + ) { + let preventClosing = false; + await this.container.willDestroyPaneItem({ + item, + index, + pane: this, + prevent: () => { + preventClosing = true; + } + }); + if (preventClosing) return false; + } + + if ( + !force && + typeof item.shouldPromptToSave === 'function' && + item.shouldPromptToSave() + ) { + if (!(await this.promptToSaveItem(item))) return false; + } + this.removeItem(item, false); + if (typeof item.destroy === 'function') item.destroy(); + return true; + } + + // Public: Destroy all items. + destroyItems() { + return Promise.all(this.getItems().map(item => this.destroyItem(item))); + } + + // Public: Destroy all items except for the active item. + destroyInactiveItems() { + return Promise.all( + this.getItems() + .filter(item => item !== this.activeItem) + .map(item => this.destroyItem(item)) + ); + } + + promptToSaveItem(item, options = {}) { + return new Promise((resolve, reject) => { + if ( + typeof item.shouldPromptToSave !== 'function' || + !item.shouldPromptToSave(options) + ) { + return resolve(true); + } + + 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. + saveActiveItem(nextAction) { + return this.saveItem(this.getActiveItem(), nextAction); + } + + // Public: Prompt the user for a location and save the active item with the + // path they select. + // + // * `nextAction` (optional) {Function} which will be called after the item is + // successfully saved. + // + // Returns a {Promise} that resolves when the save is complete + saveActiveItemAs(nextAction) { + return this.saveItemAs(this.getActiveItem(), nextAction); + } + + // Public: Save the given item. + // + // * `item` The item to save. + // * `nextAction` (optional) {Function} which will be called with no argument + // 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 + // + // Returns a {Promise} that resolves when the save is complete + saveItem(item, nextAction) { + if (!item) return Promise.resolve(); + + let itemURI; + if (typeof item.getURI === 'function') { + itemURI = item.getURI(); + } else if (typeof item.getUri === 'function') { + itemURI = item.getUri(); + } + + if (itemURI != null) { + if (typeof item.save === 'function') { + return promisify(() => item.save()) + .then(() => { + if (nextAction) nextAction(); + }) + .catch(error => { + if (nextAction) { + nextAction(error); + } else { + this.handleSaveError(error, item); + } + }); + } else if (nextAction) { + nextAction(); + return Promise.resolve(); + } + } else { + return this.saveItemAs(item, nextAction); + } + } + + // Public: Prompt the user for a location and save the active item with the + // path they select. + // + // * `item` The item to save. + // * `nextAction` (optional) {Function} which will be called with no argument + // 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 + async saveItemAs(item, nextAction) { + if (!item) return; + if (typeof item.saveAs !== 'function') return; + + const saveOptions = + typeof item.getSaveDialogOptions === 'function' + ? item.getSaveDialogOptions() + : {}; + + const itemPath = item.getPath(); + if (itemPath && !saveOptions.defaultPath) + saveOptions.defaultPath = itemPath; + + 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 saveDialogPromise; + } + + // Public: Save all items. + saveItems() { + for (let item of this.getItems()) { + if (typeof item.isModified === 'function' && item.isModified()) { + this.saveItem(item); + } + } + } + + // Public: Return the first item that matches the given URI or undefined if + // none exists. + // + // * `uri` {String} containing a URI. + itemForURI(uri) { + return this.items.find(item => { + if (typeof item.getURI === 'function') { + return item.getURI() === uri; + } else if (typeof item.getUri === 'function') { + return item.getUri() === uri; + } + }); + } + + // Public: Activate the first item that matches the given URI. + // + // * `uri` {String} containing a URI. + // + // Returns a {Boolean} indicating whether an item matching the URI was found. + activateItemForURI(uri) { + const item = this.itemForURI(uri); + if (item) { + this.activateItem(item); + return true; + } else { + return false; + } + } + + copyActiveItem() { + if (this.activeItem && typeof this.activeItem.copy === 'function') { + return this.activeItem.copy(); + } + } + + /* + Section: Lifecycle + */ + + // Public: Determine whether the pane is active. + // + // Returns a {Boolean}. + isActive() { + return this.container && this.container.getActivePane() === this; + } + + // Public: Makes this pane the *active* pane, causing it to gain focus. + activate() { + if (this.isDestroyed()) throw new Error('Pane has been destroyed'); + this.focused = true; + + if (this.container) this.container.didActivatePane(this); + this.emitter.emit('did-activate'); + } + + // Public: Close the pane and destroy all its items. + // + // If this is the last pane, all the items will be destroyed but the pane + // itself will not be destroyed. + destroy() { + if ( + this.container && + this.container.isAlive() && + this.container.getPanes().length === 1 + ) { + return this.destroyItems(); + } + + this.emitter.emit('will-destroy'); + this.alive = false; + if (this.container) { + this.container.willDestroyPane({ pane: this }); + if (this.isActive()) this.container.activateNextPane(); + } + this.emitter.emit('did-destroy'); + this.emitter.dispose(); + for (let item of this.items.slice()) { + if (typeof item.destroy === 'function') item.destroy(); + } + if (this.container) this.container.didDestroyPane({ pane: this }); + } + + isAlive() { + return this.alive; + } + + // Public: Determine whether this pane has been destroyed. + // + // Returns a {Boolean}. + isDestroyed() { + return !this.isAlive(); + } + + /* + Section: Splitting + */ + + // Public: Create a new pane to the left of this pane. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitLeft(params) { + return this.split('horizontal', 'before', params); + } + + // Public: Create a new pane to the right of this pane. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitRight(params) { + return this.split('horizontal', 'after', params); + } + + // Public: Creates a new pane above the receiver. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitUp(params) { + return this.split('vertical', 'before', params); + } + + // Public: Creates a new pane below the receiver. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitDown(params) { + return this.split('vertical', 'after', params); + } + + split(orientation, side, params) { + if (params && params.copyActiveItem) { + if (!params.items) params.items = []; + params.items.push(this.copyActiveItem()); + } + + if (this.parent.orientation !== orientation) { + this.parent.replaceChild( + this, + new PaneAxis( + { + container: this.container, + orientation, + children: [this], + flexScale: this.flexScale + }, + this.viewRegistry + ) + ); + this.setFlexScale(1); + } + + const newPane = new Pane( + Object.assign( + { + applicationDelegate: this.applicationDelegate, + notificationManager: this.notificationManager, + deserializerManager: this.deserializerManager, + config: this.config, + viewRegistry: this.viewRegistry + }, + params + ) + ); + + switch (side) { + case 'before': + this.parent.insertChildBefore(this, newPane); + break; + case 'after': + this.parent.insertChildAfter(this, newPane); + break; + } + + if (params && params.moveActiveItem && this.activeItem) + this.moveItemToPane(this.activeItem, newPane); + + newPane.activate(); + return newPane; + } + + // If the parent is a horizontal axis, returns its first child if it is a pane; + // otherwise returns this pane. + findLeftmostSibling() { + if (this.parent.orientation === 'horizontal') { + const [leftmostSibling] = this.parent.children; + if (leftmostSibling instanceof PaneAxis) { + return this; + } else { + return leftmostSibling; + } + } else { + return this; + } + } + + findRightmostSibling() { + if (this.parent.orientation === 'horizontal') { + const rightmostSibling = this.parent.children[ + this.parent.children.length - 1 + ]; + if (rightmostSibling instanceof PaneAxis) { + return this; + } else { + return rightmostSibling; + } + } else { + return this; + } + } + + // If the parent is a horizontal axis, returns its last child if it is a pane; + // otherwise returns a new pane created by splitting this pane rightward. + findOrCreateRightmostSibling() { + const rightmostSibling = this.findRightmostSibling(); + if (rightmostSibling === this) { + return this.splitRight(); + } else { + return rightmostSibling; + } + } + + // If the parent is a vertical axis, returns its first child if it is a pane; + // otherwise returns this pane. + findTopmostSibling() { + if (this.parent.orientation === 'vertical') { + const [topmostSibling] = this.parent.children; + if (topmostSibling instanceof PaneAxis) { + return this; + } else { + return topmostSibling; + } + } else { + return this; + } + } + + findBottommostSibling() { + if (this.parent.orientation === 'vertical') { + const bottommostSibling = this.parent.children[ + this.parent.children.length - 1 + ]; + if (bottommostSibling instanceof PaneAxis) { + return this; + } else { + return bottommostSibling; + } + } else { + return this; + } + } + + // If the parent is a vertical axis, returns its last child if it is a pane; + // otherwise returns a new pane created by splitting this pane bottomward. + findOrCreateBottommostSibling() { + const bottommostSibling = this.findBottommostSibling(); + if (bottommostSibling === this) { + return this.splitDown(); + } else { + return bottommostSibling; + } + } + + // Private: Close the pane unless the user cancels the action via a dialog. + // + // Returns a {Promise} that resolves once the pane is either closed, or the + // closing has been cancelled. + close() { + return Promise.all( + this.getItems().map(item => this.promptToSaveItem(item)) + ).then(results => { + if (!results.includes(false)) return this.destroy(); + }); + } + + handleSaveError(error, item) { + const itemPath = + error.path || (typeof item.getPath === 'function' && item.getPath()); + const addWarningWithPath = (message, options) => { + if (itemPath) message = `${message} '${itemPath}'`; + this.notificationManager.addWarning(message, options); + }; + + const customMessage = this.getMessageForErrorCode(error.code); + if (customMessage != null) { + addWarningWithPath(`Unable to save file: ${customMessage}`); + } else if ( + error.code === 'EISDIR' || + (error.message && error.message.endsWith('is a directory')) + ) { + return this.notificationManager.addWarning( + `Unable to save file: ${error.message}` + ); + } else if ( + ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes( + error.code + ) + ) { + addWarningWithPath('Unable to save file', { detail: error.message }); + } else { + const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec( + error.message + ); + if (errorMatch) { + const fileName = errorMatch[1]; + this.notificationManager.addWarning( + `Unable to save file: A directory in the path '${fileName}' could not be written to` + ); + } else { + throw error; + } + } + } + + getMessageForErrorCode(errorCode) { + switch (errorCode) { + case 'EACCES': + return 'Permission denied'; + case 'ECONNRESET': + return 'Connection reset'; + case 'EINTR': + return 'Interrupted system call'; + case 'EIO': + return 'I/O error writing file'; + case 'ENOSPC': + return 'No space left on device'; + case 'ENOTSUP': + return 'Operation not supported on socket'; + case 'ENXIO': + return 'No such device or address'; + case 'EROFS': + return 'Read-only file system'; + case 'ESPIPE': + return 'Invalid seek'; + case 'ETIMEDOUT': + return 'Connection timed out'; + } + } +}; + +function promisify(callback) { + try { + return Promise.resolve(callback()); + } catch (error) { + return Promise.reject(error); + } +} diff --git a/src/panel-container-element.coffee b/src/panel-container-element.coffee deleted file mode 100644 index e1459e6d63c..00000000000 --- a/src/panel-container-element.coffee +++ /dev/null @@ -1,47 +0,0 @@ -{CompositeDisposable} = require 'event-kit' - -class PanelContainerElement extends HTMLElement - createdCallback: -> - @subscriptions = new CompositeDisposable - - initialize: (@model) -> - @subscriptions.add @model.onDidAddPanel(@panelAdded.bind(this)) - @subscriptions.add @model.onDidRemovePanel(@panelRemoved.bind(this)) - @subscriptions.add @model.onDidDestroy(@destroyed.bind(this)) - @classList.add(@model.getLocation()) - this - - getModel: -> @model - - panelAdded: ({panel, index}) -> - panelElement = atom.views.getView(panel) - panelElement.classList.add(@model.getLocation()) - if @model.isModal() - panelElement.classList.add("overlay", "from-top") - else - panelElement.classList.add("tool-panel", "panel-#{@model.getLocation()}") - - if index >= @childNodes.length - @appendChild(panelElement) - else - referenceItem = @childNodes[index] - @insertBefore(panelElement, referenceItem) - - if @model.isModal() - @hideAllPanelsExcept(panel) - @subscriptions.add panel.onDidChangeVisible (visible) => - @hideAllPanelsExcept(panel) if visible - - panelRemoved: ({panel, index}) -> - @removeChild(atom.views.getView(panel)) - - destroyed: -> - @subscriptions.dispose() - @parentNode?.removeChild(this) - - hideAllPanelsExcept: (excludedPanel) -> - for panel in @model.getPanels() - panel.hide() unless panel is excludedPanel - return - -module.exports = PanelContainerElement = document.registerElement 'atom-panel-container', prototype: PanelContainerElement.prototype diff --git a/src/panel-container-element.js b/src/panel-container-element.js new file mode 100644 index 00000000000..4e7f6024f38 --- /dev/null +++ b/src/panel-container-element.js @@ -0,0 +1,123 @@ +'use strict'; + +const { createFocusTrap } = require('focus-trap'); +const { CompositeDisposable } = require('event-kit'); + +class PanelContainerElement extends HTMLElement { + constructor() { + super(); + this.subscriptions = new CompositeDisposable(); + } + + connectedCallback() { + if (this.model.dock) { + this.model.dock.elementAttached(); + } + } + + initialize(model, viewRegistry) { + this.model = model; + this.viewRegistry = viewRegistry; + + this.subscriptions.add( + this.model.onDidAddPanel(this.panelAdded.bind(this)) + ); + this.subscriptions.add(this.model.onDidDestroy(this.destroyed.bind(this))); + this.classList.add(this.model.getLocation()); + + // Add the dock. + if (this.model.dock != null) { + this.appendChild(this.model.dock.getElement()); + } + + return this; + } + + getModel() { + return this.model; + } + + panelAdded({ panel, index }) { + const panelElement = panel.getElement(); + panelElement.classList.add(this.model.getLocation()); + if (this.model.isModal()) { + panelElement.classList.add('overlay', 'from-top'); + } else { + panelElement.classList.add( + 'tool-panel', + `panel-${this.model.getLocation()}` + ); + } + + if (index >= this.childNodes.length) { + this.appendChild(panelElement); + } else { + const referenceItem = this.childNodes[index]; + this.insertBefore(panelElement, referenceItem); + } + + if (this.model.isModal()) { + this.hideAllPanelsExcept(panel); + this.subscriptions.add( + panel.onDidChangeVisible(visible => { + if (visible) { + this.hideAllPanelsExcept(panel); + } + }) + ); + + if (panel.autoFocus) { + const focusOptions = { + // focus-trap will attempt to give focus to the first tabbable element + // on activation. If there aren't any tabbable elements, + // give focus to the panel element itself + fallbackFocus: panelElement, + // closing is handled by core Atom commands and this already deactivates + // on visibility changes + escapeDeactivates: false, + delayInitialFocus: false + }; + + if (panel.autoFocus !== true) { + focusOptions.initialFocus = panel.autoFocus; + } + const modalFocusTrap = createFocusTrap(panelElement, focusOptions); + + this.subscriptions.add( + panel.onDidChangeVisible(visible => { + if (visible) { + modalFocusTrap.activate(); + } else { + modalFocusTrap.deactivate(); + } + }) + ); + } + } + } + + destroyed() { + this.subscriptions.dispose(); + if (this.parentNode != null) { + this.parentNode.removeChild(this); + } + } + + hideAllPanelsExcept(excludedPanel) { + for (let panel of this.model.getPanels()) { + if (panel !== excludedPanel) { + panel.hide(); + } + } + } +} + +window.customElements.define('atom-panel-container', PanelContainerElement); + +function createPanelContainerElement() { + return document.createElement('atom-panel-container'); +} + +module.exports = { + createPanelContainerElement +}; diff --git a/src/panel-container.coffee b/src/panel-container.coffee deleted file mode 100644 index 322773f6998..00000000000 --- a/src/panel-container.coffee +++ /dev/null @@ -1,71 +0,0 @@ -{Emitter, CompositeDisposable} = require 'event-kit' - -module.exports = -class PanelContainer - constructor: ({@location}={}) -> - @emitter = new Emitter - @subscriptions = new CompositeDisposable - @panels = [] - - destroy: -> - panel.destroy() for panel in @getPanels() - @subscriptions.dispose() - @emitter.emit 'did-destroy', this - @emitter.dispose() - - ### - Section: Event Subscription - ### - - onDidAddPanel: (callback) -> - @emitter.on 'did-add-panel', callback - - onDidRemovePanel: (callback) -> - @emitter.on 'did-remove-panel', callback - - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Panels - ### - - getLocation: -> @location - - isModal: -> @location is 'modal' - - getPanels: -> @panels - - addPanel: (panel) -> - @subscriptions.add panel.onDidDestroy(@panelDestroyed.bind(this)) - - index = @getPanelIndex(panel) - if index is @panels.length - @panels.push(panel) - else - @panels.splice(index, 0, panel) - - @emitter.emit 'did-add-panel', {panel, index} - panel - - panelForItem: (item) -> - for panel in @panels - return panel if panel.getItem() is item - null - - panelDestroyed: (panel) -> - index = @panels.indexOf(panel) - if index > -1 - @panels.splice(index, 1) - @emitter.emit 'did-remove-panel', {panel, index} - - getPanelIndex: (panel) -> - priority = panel.getPriority() - if @location in ['bottom', 'right'] - for p, i in @panels by -1 - return i + 1 if priority < p.getPriority() - 0 - else - for p, i in @panels - return i if priority < p.getPriority() - @panels.length diff --git a/src/panel-container.js b/src/panel-container.js new file mode 100644 index 00000000000..5a003f3f83c --- /dev/null +++ b/src/panel-container.js @@ -0,0 +1,118 @@ +'use strict'; + +const { Emitter, CompositeDisposable } = require('event-kit'); +const { createPanelContainerElement } = require('./panel-container-element'); + +module.exports = class PanelContainer { + constructor({ location, dock, viewRegistry } = {}) { + this.location = location; + this.emitter = new Emitter(); + this.subscriptions = new CompositeDisposable(); + this.panels = []; + this.dock = dock; + this.viewRegistry = viewRegistry; + } + + destroy() { + for (let panel of this.getPanels()) { + panel.destroy(); + } + this.subscriptions.dispose(); + this.emitter.emit('did-destroy', this); + this.emitter.dispose(); + } + + getElement() { + if (!this.element) { + this.element = createPanelContainerElement().initialize( + this, + this.viewRegistry + ); + } + return this.element; + } + + /* + Section: Event Subscription + */ + + onDidAddPanel(callback) { + return this.emitter.on('did-add-panel', callback); + } + + onDidRemovePanel(callback) { + return this.emitter.on('did-remove-panel', callback); + } + + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Panels + */ + + getLocation() { + return this.location; + } + + isModal() { + return this.location === 'modal'; + } + + getPanels() { + return this.panels.slice(); + } + + addPanel(panel) { + this.subscriptions.add(panel.onDidDestroy(this.panelDestroyed.bind(this))); + + const index = this.getPanelIndex(panel); + if (index === this.panels.length) { + this.panels.push(panel); + } else { + this.panels.splice(index, 0, panel); + } + + this.emitter.emit('did-add-panel', { panel, index }); + return panel; + } + + panelForItem(item) { + for (let panel of this.panels) { + if (panel.getItem() === item) { + return panel; + } + } + return null; + } + + panelDestroyed(panel) { + const index = this.panels.indexOf(panel); + if (index > -1) { + this.panels.splice(index, 1); + this.emitter.emit('did-remove-panel', { panel, index }); + } + } + + getPanelIndex(panel) { + const priority = panel.getPriority(); + if (['bottom', 'right'].includes(this.location)) { + for (let i = this.panels.length - 1; i >= 0; i--) { + const p = this.panels[i]; + if (priority < p.getPriority()) { + return i + 1; + } + } + return 0; + } else { + for (let i = 0; i < this.panels.length; i++) { + const p = this.panels[i]; + if (priority < p.getPriority()) { + return i; + } + } + return this.panels.length; + } + } +}; diff --git a/src/panel-element.coffee b/src/panel-element.coffee deleted file mode 100644 index 69b836aeb10..00000000000 --- a/src/panel-element.coffee +++ /dev/null @@ -1,37 +0,0 @@ -{CompositeDisposable} = require 'event-kit' -{callAttachHooks} = require './space-pen-extensions' -Panel = require './panel' - -class PanelElement extends HTMLElement - createdCallback: -> - @subscriptions = new CompositeDisposable - - initialize: (@model) -> - @appendChild(@getItemView()) - - @classList.add(@model.getClassName().split(' ')...) if @model.getClassName()? - @subscriptions.add @model.onDidChangeVisible(@visibleChanged.bind(this)) - @subscriptions.add @model.onDidDestroy(@destroyed.bind(this)) - this - - getModel: -> - @model ?= new Panel - - getItemView: -> - atom.views.getView(@getModel().getItem()) - - attachedCallback: -> - callAttachHooks(@getItemView()) # for backward compatibility with SpacePen views - @visibleChanged(@getModel().isVisible()) - - visibleChanged: (visible) -> - if visible - @style.display = null - else - @style.display = 'none' - - destroyed: -> - @subscriptions.dispose() - @parentNode?.removeChild(this) - -module.exports = PanelElement = document.registerElement 'atom-panel', prototype: PanelElement.prototype diff --git a/src/panel.coffee b/src/panel.coffee deleted file mode 100644 index 6a1e2be0929..00000000000 --- a/src/panel.coffee +++ /dev/null @@ -1,75 +0,0 @@ -{Emitter} = require 'event-kit' - -# Extended: A container representing a panel on the edges of the editor window. -# You should not create a `Panel` directly, instead use {Workspace::addTopPanel} -# and friends to add panels. -# -# Examples: [tree-view](https://github.com/atom/tree-view), -# [status-bar](https://github.com/atom/status-bar), -# and [find-and-replace](https://github.com/atom/find-and-replace) all use -# panels. -module.exports = -class Panel - ### - Section: Construction and Destruction - ### - - constructor: ({@item, @visible, @priority, @className}={}) -> - @emitter = new Emitter - @visible ?= true - @priority ?= 100 - - # Public: Destroy and remove this panel from the UI. - destroy: -> - @hide() - @emitter.emit 'did-destroy', this - @emitter.dispose() - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when the pane hidden or shown. - # - # * `callback` {Function} to be called when the pane is destroyed. - # * `visible` {Boolean} true when the panel has been shown - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeVisible: (callback) -> - @emitter.on 'did-change-visible', callback - - # Public: Invoke the given callback when the pane is destroyed. - # - # * `callback` {Function} to be called when the pane is destroyed. - # * `panel` {Panel} this panel - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Panel Details - ### - - # Public: Returns the panel's item. - getItem: -> @item - - # Public: Returns a {Number} indicating this panel's priority. - getPriority: -> @priority - - getClassName: -> @className - - # Public: Returns a {Boolean} true when the panel is visible. - isVisible: -> @visible - - # Public: Hide this panel - hide: -> - wasVisible = @visible - @visible = false - @emitter.emit 'did-change-visible', @visible if wasVisible - - # Public: Show this panel - show: -> - wasVisible = @visible - @visible = true - @emitter.emit 'did-change-visible', @visible unless wasVisible diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 00000000000..accc419cc82 --- /dev/null +++ b/src/panel.js @@ -0,0 +1,109 @@ +const { Emitter } = require('event-kit'); + +// Extended: A container representing a panel on the edges of the editor window. +// You should not create a `Panel` directly, instead use {Workspace::addTopPanel} +// and friends to add panels. +// +// Examples: [status-bar](https://github.com/atom/status-bar) +// and [find-and-replace](https://github.com/atom/find-and-replace) both use +// panels. +module.exports = class Panel { + /* + Section: Construction and Destruction + */ + + constructor({ item, autoFocus, visible, priority, className }, viewRegistry) { + this.destroyed = false; + this.item = item; + this.autoFocus = autoFocus == null ? false : autoFocus; + this.visible = visible == null ? true : visible; + this.priority = priority == null ? 100 : priority; + this.className = className; + this.viewRegistry = viewRegistry; + this.emitter = new Emitter(); + } + + // Public: Destroy and remove this panel from the UI. + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.hide(); + if (this.element) this.element.remove(); + this.emitter.emit('did-destroy', this); + return this.emitter.dispose(); + } + + getElement() { + if (!this.element) { + this.element = document.createElement('atom-panel'); + if (!this.visible) this.element.style.display = 'none'; + if (this.className) + this.element.classList.add(...this.className.split(' ')); + this.element.appendChild(this.viewRegistry.getView(this.item)); + } + return this.element; + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the pane hidden or shown. + // + // * `callback` {Function} to be called when the pane is destroyed. + // * `visible` {Boolean} true when the panel has been shown + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeVisible(callback) { + return this.emitter.on('did-change-visible', callback); + } + + // Public: Invoke the given callback when the pane is destroyed. + // + // * `callback` {Function} to be called when the pane is destroyed. + // * `panel` {Panel} this panel + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Panel Details + */ + + // Public: Returns the panel's item. + getItem() { + return this.item; + } + + // Public: Returns a {Number} indicating this panel's priority. + getPriority() { + return this.priority; + } + + getClassName() { + return this.className; + } + + // Public: Returns a {Boolean} true when the panel is visible. + isVisible() { + return this.visible; + } + + // Public: Hide this panel + hide() { + let wasVisible = this.visible; + this.visible = false; + if (this.element) this.element.style.display = 'none'; + if (wasVisible) this.emitter.emit('did-change-visible', this.visible); + } + + // Public: Show this panel + show() { + let wasVisible = this.visible; + this.visible = true; + if (this.element) this.element.style.display = null; + if (!wasVisible) this.emitter.emit('did-change-visible', this.visible); + } +}; diff --git a/src/path-watcher.js b/src/path-watcher.js new file mode 100644 index 00000000000..11064eaf28a --- /dev/null +++ b/src/path-watcher.js @@ -0,0 +1,822 @@ +const fs = require('fs'); +const path = require('path'); + +const { Emitter, Disposable, CompositeDisposable } = require('event-kit'); +const nsfw = require('@atom/nsfw'); +const watcher = require('@atom/watcher'); +const { NativeWatcherRegistry } = require('./native-watcher-registry'); + +// Private: Associate native watcher action flags with descriptive String equivalents. +const ACTION_MAP = new Map([ + [nsfw.actions.MODIFIED, 'modified'], + [nsfw.actions.CREATED, 'created'], + [nsfw.actions.DELETED, 'deleted'], + [nsfw.actions.RENAMED, 'renamed'] +]); + +// Private: Possible states of a {NativeWatcher}. +const WATCHER_STATE = { + STOPPED: Symbol('stopped'), + STARTING: Symbol('starting'), + RUNNING: Symbol('running'), + STOPPING: Symbol('stopping') +}; + +// Private: Interface with and normalize events from a filesystem watcher implementation. +class NativeWatcher { + // Private: Initialize a native watcher on a path. + // + // Events will not be produced until {start()} is called. + constructor(normalizedPath) { + this.normalizedPath = normalizedPath; + this.emitter = new Emitter(); + this.subs = new CompositeDisposable(); + + this.state = WATCHER_STATE.STOPPED; + + this.onEvents = this.onEvents.bind(this); + this.onError = this.onError.bind(this); + } + + // Private: Begin watching for filesystem events. + // + // Has no effect if the watcher has already been started. + async start() { + if (this.state !== WATCHER_STATE.STOPPED) { + return; + } + this.state = WATCHER_STATE.STARTING; + + await this.doStart(); + + this.state = WATCHER_STATE.RUNNING; + this.emitter.emit('did-start'); + } + + doStart() { + return Promise.reject(new Error('doStart() not overridden')); + } + + // Private: Return true if the underlying watcher is actively listening for filesystem events. + isRunning() { + return this.state === WATCHER_STATE.RUNNING; + } + + // Private: Register a callback to be invoked when the filesystem watcher has been initialized. + // + // Returns: A {Disposable} to revoke the subscription. + onDidStart(callback) { + return this.emitter.on('did-start', callback); + } + + // Private: Register a callback to be invoked with normalized filesystem events as they arrive. Starts the watcher + // automatically if it is not already running. The watcher will be stopped automatically when all subscribers + // dispose their subscriptions. + // + // Returns: A {Disposable} to revoke the subscription. + onDidChange(callback) { + this.start(); + + const sub = this.emitter.on('did-change', callback); + return new Disposable(() => { + sub.dispose(); + if (this.emitter.listenerCountForEventName('did-change') === 0) { + this.stop(); + } + }); + } + + // Private: Register a callback to be invoked when a {Watcher} should attach to a different {NativeWatcher}. + // + // Returns: A {Disposable} to revoke the subscription. + onShouldDetach(callback) { + return this.emitter.on('should-detach', callback); + } + + // Private: Register a callback to be invoked when a {NativeWatcher} is about to be stopped. + // + // Returns: A {Disposable} to revoke the subscription. + onWillStop(callback) { + return this.emitter.on('will-stop', callback); + } + + // Private: Register a callback to be invoked when the filesystem watcher has been stopped. + // + // Returns: A {Disposable} to revoke the subscription. + onDidStop(callback) { + return this.emitter.on('did-stop', callback); + } + + // Private: Register a callback to be invoked with any errors reported from the watcher. + // + // Returns: A {Disposable} to revoke the subscription. + onDidError(callback) { + return this.emitter.on('did-error', callback); + } + + // Private: Broadcast an `onShouldDetach` event to prompt any {Watcher} instances bound here to attach to a new + // {NativeWatcher} instead. + // + // * `replacement` the new {NativeWatcher} instance that a live {Watcher} instance should reattach to instead. + // * `watchedPath` absolute path watched by the new {NativeWatcher}. + reattachTo(replacement, watchedPath, options) { + this.emitter.emit('should-detach', { replacement, watchedPath, options }); + } + + // Private: Stop the native watcher and release any operating system resources associated with it. + // + // Has no effect if the watcher is not running. + async stop() { + if (this.state !== WATCHER_STATE.RUNNING) { + return; + } + this.state = WATCHER_STATE.STOPPING; + this.emitter.emit('will-stop'); + + await this.doStop(); + + this.state = WATCHER_STATE.STOPPED; + + this.emitter.emit('did-stop'); + } + + doStop() { + return Promise.resolve(); + } + + // Private: Detach any event subscribers. + dispose() { + this.emitter.dispose(); + } + + // Private: Callback function invoked by the native watcher when a debounced group of filesystem events arrive. + // Normalize and re-broadcast them to any subscribers. + // + // * `events` An Array of filesystem events. + onEvents(events) { + this.emitter.emit('did-change', events); + } + + // Private: Callback function invoked by the native watcher when an error occurs. + // + // * `err` The native filesystem error. + onError(err) { + this.emitter.emit('did-error', err); + } +} + +// Private: Emulate a "filesystem watcher" by subscribing to Atom events like buffers being saved. This will miss +// any changes made to files outside of Atom, but it also has no overhead. +class AtomNativeWatcher extends NativeWatcher { + async doStart() { + const getRealPath = givenPath => { + if (!givenPath) { + return Promise.resolve(null); + } + + return new Promise(resolve => { + fs.realpath(givenPath, (err, resolvedPath) => { + err ? resolve(null) : resolve(resolvedPath); + }); + }); + }; + + this.subs.add( + atom.workspace.observeTextEditors(async editor => { + let realPath = await getRealPath(editor.getPath()); + if (!realPath || !realPath.startsWith(this.normalizedPath)) { + return; + } + + const announce = (action, oldPath) => { + const payload = { action, path: realPath }; + if (oldPath) payload.oldPath = oldPath; + this.onEvents([payload]); + }; + + const buffer = editor.getBuffer(); + + this.subs.add(buffer.onDidConflict(() => announce('modified'))); + this.subs.add(buffer.onDidReload(() => announce('modified'))); + this.subs.add( + buffer.onDidSave(event => { + if (event.path === realPath) { + announce('modified'); + } else { + const oldPath = realPath; + realPath = event.path; + announce('renamed', oldPath); + } + }) + ); + + this.subs.add(buffer.onDidDelete(() => announce('deleted'))); + + this.subs.add( + buffer.onDidChangePath(newPath => { + if (newPath !== this.normalizedPath) { + const oldPath = this.normalizedPath; + this.normalizedPath = newPath; + announce('renamed', oldPath); + } + }) + ); + }) + ); + + // Giant-ass brittle hack to hook files (and eventually directories) created from the TreeView. + const treeViewPackage = await atom.packages.getLoadedPackage('tree-view'); + if (!treeViewPackage) return; + await treeViewPackage.activationPromise; + const treeViewModule = treeViewPackage.mainModule; + if (!treeViewModule) return; + const treeView = treeViewModule.getTreeViewInstance(); + + const isOpenInEditor = async eventPath => { + const openPaths = await Promise.all( + atom.workspace + .getTextEditors() + .map(editor => getRealPath(editor.getPath())) + ); + return openPaths.includes(eventPath); + }; + + this.subs.add( + treeView.onFileCreated(async event => { + const realPath = await getRealPath(event.path); + if (!realPath) return; + + this.onEvents([{ action: 'added', path: realPath }]); + }) + ); + + this.subs.add( + treeView.onEntryDeleted(async event => { + const realPath = await getRealPath(event.path); + if (!realPath || (await isOpenInEditor(realPath))) return; + + this.onEvents([{ action: 'deleted', path: realPath }]); + }) + ); + + this.subs.add( + treeView.onEntryMoved(async event => { + const [realNewPath, realOldPath] = await Promise.all([ + getRealPath(event.newPath), + getRealPath(event.initialPath) + ]); + if ( + !realNewPath || + !realOldPath || + (await isOpenInEditor(realNewPath)) || + (await isOpenInEditor(realOldPath)) + ) + return; + + this.onEvents([ + { action: 'renamed', path: realNewPath, oldPath: realOldPath } + ]); + }) + ); + } +} + +// Private: Implement a native watcher by translating events from an NSFW watcher. +class NSFWNativeWatcher extends NativeWatcher { + async doStart(rootPath, eventCallback, errorCallback) { + const handler = events => { + this.onEvents( + events.map(event => { + const action = + ACTION_MAP.get(event.action) || `unexpected (${event.action})`; + const payload = { action }; + + if (event.file) { + payload.path = path.join(event.directory, event.file); + } else { + payload.oldPath = path.join( + event.directory, + typeof event.oldFile === 'undefined' ? '' : event.oldFile + ); + payload.path = path.join( + event.directory, + typeof event.newFile === 'undefined' ? '' : event.newFile + ); + } + + return payload; + }) + ); + }; + + this.watcher = await nsfw(this.normalizedPath, handler, { + debounceMS: 100, + errorCallback: this.onError + }); + + await this.watcher.start(); + } + + doStop() { + return this.watcher.stop(); + } +} + +// Extended: Manage a subscription to filesystem events that occur beneath a root directory. Construct these by +// calling `watchPath`. To watch for events within active project directories, use {Project::onDidChangeFiles} +// instead. +// +// Multiple PathWatchers may be backed by a single native watcher to conserve operation system resources. +// +// Call {::dispose} to stop receiving events and, if possible, release underlying resources. A PathWatcher may be +// added to a {CompositeDisposable} to manage its lifetime along with other {Disposable} resources like event +// subscriptions. +// +// ```js +// const {watchPath} = require('atom') +// +// const disposable = await watchPath('/var/log', {}, events => { +// console.log(`Received batch of ${events.length} events.`) +// for (const event of events) { +// // "created", "modified", "deleted", "renamed" +// console.log(`Event action: ${event.action}`) +// +// // absolute path to the filesystem entry that was touched +// console.log(`Event path: ${event.path}`) +// +// if (event.action === 'renamed') { +// console.log(`.. renamed from: ${event.oldPath}`) +// } +// } +// }) +// +// // Immediately stop receiving filesystem events. If this is the last +// // watcher, asynchronously release any OS resources required to +// // subscribe to these events. +// disposable.dispose() +// ``` +// +// `watchPath` accepts the following arguments: +// +// `rootPath` {String} specifies the absolute path to the root of the filesystem content to watch. +// +// `options` Control the watcher's behavior. Currently a placeholder. +// +// `eventCallback` {Function} to be called each time a batch of filesystem events is observed. Each event object has +// the keys: `action`, a {String} describing the filesystem action that occurred, one of `"created"`, `"modified"`, +// `"deleted"`, or `"renamed"`; `path`, a {String} containing the absolute path to the filesystem entry that was acted +// upon; for rename events only, `oldPath`, a {String} containing the filesystem entry's former absolute path. +class PathWatcher { + // Private: Instantiate a new PathWatcher. Call {watchPath} instead. + // + // * `nativeWatcherRegistry` {NativeWatcherRegistry} used to find and consolidate redundant watchers. + // * `watchedPath` {String} containing the absolute path to the root of the watched filesystem tree. + // * `options` See {watchPath} for options. + // + constructor(nativeWatcherRegistry, watchedPath, options) { + this.watchedPath = watchedPath; + this.nativeWatcherRegistry = nativeWatcherRegistry; + + this.normalizedPath = null; + this.native = null; + this.changeCallbacks = new Map(); + + this.attachedPromise = new Promise(resolve => { + this.resolveAttachedPromise = resolve; + }); + + this.startPromise = new Promise((resolve, reject) => { + this.resolveStartPromise = resolve; + this.rejectStartPromise = reject; + }); + + this.normalizedPathPromise = new Promise((resolve, reject) => { + fs.realpath(watchedPath, (err, real) => { + if (err) { + reject(err); + return; + } + + this.normalizedPath = real; + resolve(real); + }); + }); + this.normalizedPathPromise.catch(err => this.rejectStartPromise(err)); + + this.emitter = new Emitter(); + this.subs = new CompositeDisposable(); + } + + // Private: Return a {Promise} that will resolve with the normalized root path. + getNormalizedPathPromise() { + return this.normalizedPathPromise; + } + + // Private: Return a {Promise} that will resolve the first time that this watcher is attached to a native watcher. + getAttachedPromise() { + return this.attachedPromise; + } + + // 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 its events. + // + // PathWatchers acquired through `watchPath` are already started. + // + // ```js + // const {watchPath} = require('atom') + // const ROOT = path.join(__dirname, 'fixtures') + // const FILE = path.join(ROOT, 'filename.txt') + // + // describe('something', function () { + // it("doesn't miss events", async function () { + // const watcher = watchPath(ROOT, {}, events => {}) + // await watcher.getStartPromise() + // fs.writeFile(FILE, 'contents\n', err => { + // // The watcher is listening and the event should be + // // received asynchronously + // } + // }) + // }) + // ``` + getStartPromise() { + return this.startPromise; + } + + // Private: Attach another {Function} to be called with each batch of filesystem events. See {watchPath} for the + // spec of the callback's argument. + // + // * `callback` {Function} to be called with each batch of filesystem events. + // + // Returns a {Disposable} that will stop the underlying watcher when all callbacks mapped to it have been disposed. + onDidChange(callback) { + if (this.native) { + const sub = this.native.onDidChange(events => + this.onNativeEvents(events, callback) + ); + this.changeCallbacks.set(callback, sub); + + this.native.start(); + } else { + // Attach to a new native listener and retry + this.nativeWatcherRegistry.attach(this).then(() => { + this.onDidChange(callback); + }); + } + + return new Disposable(() => { + const sub = this.changeCallbacks.get(callback); + this.changeCallbacks.delete(callback); + sub.dispose(); + }); + } + + // Extended: Invoke a {Function} when any errors related to this watcher are reported. + // + // * `callback` {Function} to be called when an error occurs. + // * `err` An {Error} describing the failure condition. + // + // Returns a {Disposable}. + onDidError(callback) { + return this.emitter.on('did-error', callback); + } + + // Private: Wire this watcher to an operating system-level native watcher implementation. + attachToNative(native) { + this.subs.dispose(); + this.native = native; + + if (native.isRunning()) { + this.resolveStartPromise(); + } else { + this.subs.add( + native.onDidStart(() => { + this.resolveStartPromise(); + }) + ); + } + + // Transfer any native event subscriptions to the new NativeWatcher. + for (const [callback, formerSub] of this.changeCallbacks) { + const newSub = native.onDidChange(events => + this.onNativeEvents(events, callback) + ); + this.changeCallbacks.set(callback, newSub); + formerSub.dispose(); + } + + this.subs.add( + native.onDidError(err => { + this.emitter.emit('did-error', err); + }) + ); + + this.subs.add( + native.onShouldDetach(({ replacement, watchedPath }) => { + if ( + this.native === native && + replacement !== native && + this.normalizedPath.startsWith(watchedPath) + ) { + this.attachToNative(replacement); + } + }) + ); + + this.subs.add( + native.onWillStop(() => { + if (this.native === native) { + this.subs.dispose(); + this.native = null; + } + }) + ); + + this.resolveAttachedPromise(); + } + + // Private: Invoked when the attached native watcher creates a batch of native filesystem events. The native watcher's + // events may include events for paths above this watcher's root path, so filter them to only include the relevant + // ones, then re-broadcast them to our subscribers. + onNativeEvents(events, callback) { + const isWatchedPath = eventPath => + eventPath.startsWith(this.normalizedPath); + + const filtered = []; + for (let i = 0; i < events.length; i++) { + const event = events[i]; + + if (event.action === 'renamed') { + const srcWatched = isWatchedPath(event.oldPath); + const destWatched = isWatchedPath(event.path); + + if (srcWatched && destWatched) { + filtered.push(event); + } else if (srcWatched && !destWatched) { + filtered.push({ + action: 'deleted', + kind: event.kind, + path: event.oldPath + }); + } else if (!srcWatched && destWatched) { + filtered.push({ + action: 'created', + kind: event.kind, + path: event.path + }); + } + } else { + if (isWatchedPath(event.path)) { + filtered.push(event); + } + } + } + + if (filtered.length > 0) { + callback(filtered); + } + } + + // 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()) { + sub.dispose(); + } + + this.emitter.dispose(); + this.subs.dispose(); + } +} + +// Private: Globally tracked state used to de-duplicate related [PathWatchers]{PathWatcher} backed by emulated Atom +// events or NSFW. +class PathWatcherManager { + // Private: Access the currently active manager instance, creating one if necessary. + static active() { + if (!this.activeManager) { + this.activeManager = new PathWatcherManager( + atom.config.get('core.fileSystemWatcher') + ); + this.sub = atom.config.onDidChange( + 'core.fileSystemWatcher', + ({ newValue }) => { + this.transitionTo(newValue); + } + ); + } + return this.activeManager; + } + + // Private: Replace the active {PathWatcherManager} with a new one that creates [NativeWatchers]{NativeWatcher} + // based on the value of `setting`. + static async transitionTo(setting) { + const current = this.active(); + + if (this.transitionPromise) { + await this.transitionPromise; + } + + if (current.setting === setting) { + return; + } + current.isShuttingDown = true; + + let resolveTransitionPromise = () => {}; + this.transitionPromise = new Promise(resolve => { + resolveTransitionPromise = resolve; + }); + + const replacement = new PathWatcherManager(setting); + this.activeManager = replacement; + + await Promise.all( + Array.from(current.live, async ([root, native]) => { + const w = await replacement.createWatcher(root, {}, () => {}); + native.reattachTo(w.native, root, w.native.options || {}); + }) + ); + + current.stopAllWatchers(); + + resolveTransitionPromise(); + this.transitionPromise = null; + } + + // Private: Initialize global {PathWatcher} state. + constructor(setting) { + this.setting = setting; + this.live = new Map(); + + const initLocal = NativeConstructor => { + this.nativeRegistry = new NativeWatcherRegistry(normalizedPath => { + const nativeWatcher = new NativeConstructor(normalizedPath); + + this.live.set(normalizedPath, nativeWatcher); + const sub = nativeWatcher.onWillStop(() => { + this.live.delete(normalizedPath); + sub.dispose(); + }); + + return nativeWatcher; + }); + }; + + if (setting === 'atom') { + initLocal(AtomNativeWatcher); + } else if (setting === 'experimental') { + // + } else if (setting === 'poll') { + // + } else { + initLocal(NSFWNativeWatcher); + } + + this.isShuttingDown = false; + } + + useExperimentalWatcher() { + return this.setting === 'experimental' || this.setting === 'poll'; + } + + // Private: Create a {PathWatcher} tied to this global state. See {watchPath} for detailed arguments. + async createWatcher(rootPath, options, eventCallback) { + if (this.isShuttingDown) { + await this.constructor.transitionPromise; + return PathWatcherManager.active().createWatcher( + rootPath, + options, + eventCallback + ); + } + + if (this.useExperimentalWatcher()) { + if (this.setting === 'poll') { + options.poll = true; + } + + const w = await watcher.watchPath(rootPath, options, eventCallback); + this.live.set(rootPath, w.native); + return w; + } + + const w = new PathWatcher(this.nativeRegistry, rootPath, options); + w.onDidChange(eventCallback); + await w.getStartPromise(); + return w; + } + + // Private: Directly access the {NativeWatcherRegistry}. + getRegistry() { + if (this.useExperimentalWatcher()) { + return watcher.getRegistry(); + } + + return this.nativeRegistry; + } + + // Private: Sample watcher usage statistics. Only available for experimental watchers. + status() { + if (this.useExperimentalWatcher()) { + return watcher.status(); + } + + return {}; + } + + // Private: Return a {String} depicting the currently active native watchers. + print() { + if (this.useExperimentalWatcher()) { + return watcher.printWatchers(); + } + + return this.nativeRegistry.print(); + } + + // Private: Stop all living watchers. + // + // Returns a {Promise} that resolves when all native watcher resources are disposed. + stopAllWatchers() { + if (this.useExperimentalWatcher()) { + return watcher.stopAllWatchers(); + } + + return Promise.all(Array.from(this.live, ([, w]) => w.stop())); + } +} + +// Extended: Invoke a callback with each filesystem event that occurs beneath a specified path. If you only need to +// watch events within the project's root paths, use {Project::onDidChangeFiles} instead. +// +// watchPath handles the efficient re-use of operating system resources across living watchers. Watching the same path +// more than once, or the child of a watched path, will re-use the existing native watcher. +// +// * `rootPath` {String} specifies the absolute path to the root of the filesystem content to watch. +// * `options` Control the watcher's behavior. +// * `eventCallback` {Function} or other callable to be called each time a batch of filesystem events is observed. +// * `events` {Array} of objects that describe the events that have occurred. +// * `action` {String} describing the filesystem action that occurred. One of `"created"`, `"modified"`, +// `"deleted"`, or `"renamed"`. +// * `path` {String} containing the absolute path to the filesystem entry that was acted upon. +// * `oldPath` For rename events, {String} containing the filesystem entry's former absolute path. +// +// Returns a {Promise} that will resolve to a {PathWatcher} once it has started. Note that every {PathWatcher} +// is a {Disposable}, so they can be managed by a {CompositeDisposable} if desired. +// +// ```js +// const {watchPath} = require('atom') +// +// const disposable = await watchPath('/var/log', {}, events => { +// console.log(`Received batch of ${events.length} events.`) +// for (const event of events) { +// // "created", "modified", "deleted", "renamed" +// console.log(`Event action: ${event.action}`) +// // absolute path to the filesystem entry that was touched +// console.log(`Event path: ${event.path}`) +// if (event.action === 'renamed') { +// console.log(`.. renamed from: ${event.oldPath}`) +// } +// } +// }) +// +// // Immediately stop receiving filesystem events. If this is the last watcher, asynchronously release any OS +// // resources required to subscribe to these events. +// disposable.dispose() +// ``` +// +function watchPath(rootPath, options, eventCallback) { + return PathWatcherManager.active().createWatcher( + rootPath, + options, + eventCallback + ); +} + +// Private: Return a Promise that resolves when all {NativeWatcher} instances associated with a FileSystemManager +// have stopped listening. This is useful for `afterEach()` blocks in unit tests. +function stopAllWatchers() { + return PathWatcherManager.active().stopAllWatchers(); +} + +// Private: Show the currently active native watchers in a formatted {String}. +watchPath.printWatchers = function() { + return PathWatcherManager.active().print(); +}; + +// Private: Access the active {NativeWatcherRegistry}. +watchPath.getRegistry = function() { + return PathWatcherManager.active().getRegistry(); +}; + +// Private: Sample usage statistics for the active watcher. +watchPath.status = function() { + return PathWatcherManager.active().status(); +}; + +// Private: Configure @atom/watcher ("experimental") directly. +watchPath.configure = function(...args) { + return watcher.configure(...args); +}; + +module.exports = { watchPath, stopAllWatchers }; diff --git a/src/project.coffee b/src/project.coffee deleted file mode 100644 index 75cdb714fc8..00000000000 --- a/src/project.coffee +++ /dev/null @@ -1,498 +0,0 @@ -path = require 'path' -url = require 'url' - -_ = require 'underscore-plus' -fs = require 'fs-plus' -Q = require 'q' -{includeDeprecatedAPIs, deprecate} = require 'grim' -{Emitter} = require 'event-kit' -Serializable = require 'serializable' -TextBuffer = require 'text-buffer' -Grim = require 'grim' - -DefaultDirectoryProvider = require './default-directory-provider' -Model = require './model' -TextEditor = require './text-editor' -Task = require './task' -GitRepositoryProvider = require './git-repository-provider' - -# Extended: Represents a project that's opened in Atom. -# -# An instance of this class is always available as the `atom.project` global. -module.exports = -class Project extends Model - atom.deserializers.add(this) - Serializable.includeInto(this) - - ### - Section: Construction and Destruction - ### - - constructor: ({path, paths, @buffers}={}) -> - @emitter = new Emitter - @buffers ?= [] - @rootDirectories = [] - @repositories = [] - - @directoryProviders = [new DefaultDirectoryProvider()] - atom.packages.serviceHub.consume( - 'atom.directory-provider', - '^0.1.0', - # New providers are added to the front of @directoryProviders because - # DefaultDirectoryProvider is a catch-all that will always provide a Directory. - (provider) => @directoryProviders.unshift(provider)) - - # Mapping from the real path of a {Directory} to a {Promise} that resolves - # to either a {Repository} or null. Ideally, the {Directory} would be used - # as the key; however, there can be multiple {Directory} objects created for - # the same real path, so it is not a good key. - @repositoryPromisesByPath = new Map() - - # Note that the GitRepositoryProvider is registered synchronously so that - # it is available immediately on startup. - @repositoryProviders = [new GitRepositoryProvider(this)] - atom.packages.serviceHub.consume( - 'atom.repository-provider', - '^0.1.0', - (provider) => - @repositoryProviders.push(provider) - - # If a path in getPaths() does not have a corresponding Repository, try - # to assign one by running through setPaths() again now that - # @repositoryProviders has been updated. - if null in @repositories - @setPaths(@getPaths()) - ) - - @subscribeToBuffer(buffer) for buffer in @buffers - - if Grim.includeDeprecatedAPIs and path? - Grim.deprecate("Pass 'paths' array instead of 'path' to project constructor") - - paths ?= _.compact([path]) - @setPaths(paths) - - destroyed: -> - buffer.destroy() for buffer in @getBuffers() - @setPaths([]) - - destroyUnretainedBuffers: -> - buffer.destroy() for buffer in @getBuffers() when not buffer.isRetained() - return - - ### - Section: Serialization - ### - - serializeParams: -> - paths: @getPaths() - buffers: _.compact(@buffers.map (buffer) -> buffer.serialize() if buffer.isRetained()) - - deserializeParams: (params) -> - params.buffers = _.compact params.buffers.map (bufferState) -> - # Check that buffer's file path is accessible - return if fs.isDirectorySync(bufferState.filePath) - if bufferState.filePath - try - fs.closeSync(fs.openSync(bufferState.filePath, 'r')) - catch error - return unless error.code is 'ENOENT' - - atom.deserializers.deserialize(bufferState) - params - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when the project paths change. - # - # * `callback` {Function} to be called after the project paths change. - # * `projectPaths` An {Array} of {String} project paths. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePaths: (callback) -> - @emitter.on 'did-change-paths', callback - - onDidAddBuffer: (callback) -> - @emitter.on 'did-add-buffer', callback - - ### - Section: Accessing the git repository - ### - - # Public: Get an {Array} of {GitRepository}s associated with the project's - # directories. - # - # 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: - # ``` - # Promise.all(project.getDirectories().map( - # project.repositoryForDirectory.bind(project))) - # ``` - getRepositories: -> @repositories - - # Public: Get the repository for a given directory asynchronously. - # - # * `directory` {Directory} for which to get a {Repository}. - # - # Returns a {Promise} that resolves with either: - # * {Repository} if a repository can be created for the given directory - # * `null` if no repository can be created for the given directory. - repositoryForDirectory: (directory) -> - pathForDirectory = directory.getRealPathSync() - promise = @repositoryPromisesByPath.get(pathForDirectory) - unless promise - promises = @repositoryProviders.map (provider) -> - provider.repositoryForDirectory(directory) - promise = Promise.all(promises).then (repositories) => - repo = _.find(repositories, (repo) -> repo?) ? null - - # If no repository is found, remove the entry in for the directory in - # @repositoryPromisesByPath in case some other RepositoryProvider is - # registered in the future that could supply a Repository for the - # directory. - @repositoryPromisesByPath.delete(pathForDirectory) unless repo? - repo - @repositoryPromisesByPath.set(pathForDirectory, promise) - promise - - ### - Section: Managing Paths - ### - - # Public: Get an {Array} of {String}s containing the paths of the project's - # directories. - getPaths: -> rootDirectory.getPath() for rootDirectory in @rootDirectories - - # Public: Set the paths of the project's directories. - # - # * `projectPaths` {Array} of {String} paths. - setPaths: (projectPaths) -> - if includeDeprecatedAPIs - rootDirectory.off() for rootDirectory in @rootDirectories - - repository?.destroy() for repository in @repositories - @rootDirectories = [] - @repositories = [] - - @addPath(projectPath, emitEvent: false) for projectPath in projectPaths - - @emit "path-changed" if includeDeprecatedAPIs - @emitter.emit 'did-change-paths', projectPaths - - # Public: Add a path to the project's list of root paths - # - # * `projectPath` {String} The path to the directory to add. - addPath: (projectPath, options) -> - for directory in @getDirectories() - # Apparently a Directory does not believe it can contain itself, so we - # must also check whether the paths match. - return if directory.contains(projectPath) or directory.getPath() is projectPath - - directory = null - for provider in @directoryProviders - break if directory = provider.directoryForURISync?(projectPath) - if directory is null - # This should never happen because DefaultDirectoryProvider should always - # return a Directory. - throw new Error(projectPath + ' could not be resolved to a directory') - @rootDirectories.push(directory) - - repo = null - for provider in @repositoryProviders - break if repo = provider.repositoryForDirectorySync?(directory) - @repositories.push(repo ? null) - - unless options?.emitEvent is false - @emit "path-changed" if includeDeprecatedAPIs - @emitter.emit 'did-change-paths', @getPaths() - - # Public: remove a path from the project's list of root paths. - # - # * `projectPath` {String} The path to remove. - removePath: (projectPath) -> - # The projectPath may be a URI, in which case it should not be normalized. - unless projectPath in @getPaths() - projectPath = path.normalize(projectPath) - - indexToRemove = null - for directory, i in @rootDirectories - if directory.getPath() is projectPath - indexToRemove = i - break - - if indexToRemove? - [removedDirectory] = @rootDirectories.splice(indexToRemove, 1) - [removedRepository] = @repositories.splice(indexToRemove, 1) - removedDirectory.off() if includeDeprecatedAPIs - removedRepository?.destroy() unless removedRepository in @repositories - @emit "path-changed" if includeDeprecatedAPIs - @emitter.emit "did-change-paths", @getPaths() - true - else - false - - # Public: Get an {Array} of {Directory}s associated with this project. - getDirectories: -> - @rootDirectories - - resolvePath: (uri) -> - return unless uri - - if uri?.match(/[A-Za-z0-9+-.]+:\/\//) # leave path alone if it has a scheme - uri - else - if fs.isAbsolute(uri) - path.normalize(fs.absolute(uri)) - - # TODO: what should we do here when there are multiple directories? - else if projectPath = @getPaths()[0] - path.normalize(fs.absolute(path.join(projectPath, uri))) - else - undefined - - relativize: (fullPath) -> - @relativizePath(fullPath)[1] - - # Public: Get the path to the project directory that contains the given path, - # and the relative path from that project directory to the given path. - # - # * `fullPath` {String} An absolute path. - # - # Returns an {Array} with two elements: - # * `projectPath` The {String} path to the project directory that contains the - # given path, or `null` if none is found. - # * `relativePath` {String} The relative path from the project directory to - # the given path. - relativizePath: (fullPath) -> - for rootDirectory in @rootDirectories - relativePath = rootDirectory.relativize(fullPath) - return [rootDirectory.getPath(), relativePath] unless relativePath is fullPath - [null, fullPath] - - # Public: Determines whether the given path (real or symbolic) is inside the - # project's directory. - # - # This method does not actually check if the path exists, it just checks their - # locations relative to each other. - # - # ## Examples - # - # Basic operation - # - # ```coffee - # # Project's root directory is /foo/bar - # project.contains('/foo/bar/baz') # => true - # project.contains('/usr/lib/baz') # => false - # ``` - # - # Existence of the path is not required - # - # ```coffee - # # Project's root directory is /foo/bar - # fs.existsSync('/foo/bar/baz') # => false - # project.contains('/foo/bar/baz') # => true - # ``` - # - # * `pathToCheck` {String} path - # - # Returns whether the path is inside the project's root directory. - contains: (pathToCheck) -> - @rootDirectories.some (dir) -> dir.contains(pathToCheck) - - ### - Section: Private - ### - - # Given a path to a file, this constructs and associates a new - # {TextEditor}, showing the file. - # - # * `filePath` The {String} path of the file to associate with. - # * `options` Options that you can pass to the {TextEditor} constructor. - # - # Returns a promise that resolves to an {TextEditor}. - open: (filePath, options={}) -> - filePath = @resolvePath(filePath) - - if filePath? - try - fs.closeSync(fs.openSync(filePath, 'r')) - catch error - # allow ENOENT errors to create an editor for paths that dont exist - throw error unless error.code is 'ENOENT' - - @bufferForPath(filePath).then (buffer) => - @buildEditorForBuffer(buffer, options) - - # Retrieves all the {TextBuffer}s in the project; that is, the - # buffers for all open files. - # - # Returns an {Array} of {TextBuffer}s. - getBuffers: -> - @buffers.slice() - - # Is the buffer for the given path modified? - isPathModified: (filePath) -> - @findBufferForPath(@resolvePath(filePath))?.isModified() - - findBufferForPath: (filePath) -> - _.find @buffers, (buffer) -> buffer.getPath() is filePath - - # Only to be used in specs - bufferForPathSync: (filePath) -> - absoluteFilePath = @resolvePath(filePath) - existingBuffer = @findBufferForPath(absoluteFilePath) if filePath - existingBuffer ? @buildBufferSync(absoluteFilePath) - - # Given a file path, this retrieves or creates a new {TextBuffer}. - # - # If the `filePath` already has a `buffer`, that value is used instead. Otherwise, - # `text` is used as the contents of the new buffer. - # - # * `filePath` A {String} representing a path. If `null`, an "Untitled" buffer is created. - # - # Returns a promise that resolves to the {TextBuffer}. - bufferForPath: (filePath) -> - absoluteFilePath = @resolvePath(filePath) - existingBuffer = @findBufferForPath(absoluteFilePath) if absoluteFilePath - Q(existingBuffer ? @buildBuffer(absoluteFilePath)) - - bufferForId: (id) -> - _.find @buffers, (buffer) -> buffer.id is id - - # Still needed when deserializing a tokenized buffer - buildBufferSync: (absoluteFilePath) -> - buffer = new TextBuffer({filePath: absoluteFilePath}) - @addBuffer(buffer) - buffer.loadSync() - buffer - - # Given a file path, this sets its {TextBuffer}. - # - # * `absoluteFilePath` A {String} representing a path. - # * `text` The {String} text to use as a buffer. - # - # Returns a promise that resolves to the {TextBuffer}. - buildBuffer: (absoluteFilePath) -> - if fs.getSizeSync(absoluteFilePath) >= 2 * 1048576 # 2MB - error = new Error("Atom can only handle files < 2MB for now.") - error.code = 'EFILETOOLARGE' - throw error - - buffer = new TextBuffer({filePath: absoluteFilePath}) - @addBuffer(buffer) - buffer.load() - .then((buffer) -> buffer) - .catch(=> @removeBuffer(buffer)) - - addBuffer: (buffer, options={}) -> - @addBufferAtIndex(buffer, @buffers.length, options) - @subscribeToBuffer(buffer) - - addBufferAtIndex: (buffer, index, options={}) -> - @buffers.splice(index, 0, buffer) - @subscribeToBuffer(buffer) - @emit 'buffer-created', buffer if includeDeprecatedAPIs - @emitter.emit 'did-add-buffer', buffer - buffer - - # Removes a {TextBuffer} association from the project. - # - # Returns the removed {TextBuffer}. - removeBuffer: (buffer) -> - index = @buffers.indexOf(buffer) - @removeBufferAtIndex(index) unless index is -1 - - removeBufferAtIndex: (index, options={}) -> - [buffer] = @buffers.splice(index, 1) - buffer?.destroy() - - buildEditorForBuffer: (buffer, editorOptions) -> - editor = new TextEditor(_.extend({buffer, registerEditor: true}, editorOptions)) - editor - - eachBuffer: (args...) -> - subscriber = args.shift() if args.length > 1 - callback = args.shift() - - callback(buffer) for buffer in @getBuffers() - if subscriber - subscriber.subscribe this, 'buffer-created', (buffer) -> callback(buffer) - else - @on 'buffer-created', (buffer) -> callback(buffer) - - subscribeToBuffer: (buffer) -> - buffer.onDidDestroy => @removeBuffer(buffer) - buffer.onWillThrowWatchError ({error, handle}) -> - handle() - atom.notifications.addWarning """ - Unable to read file after file `#{error.eventType}` event. - Make sure you have permission to access `#{buffer.getPath()}`. - """, - detail: error.message - dismissable: true - -if includeDeprecatedAPIs - Project.pathForRepositoryUrl = (repoUrl) -> - deprecate '::pathForRepositoryUrl will be removed. Please remove from your code.' - [repoName] = url.parse(repoUrl).path.split('/')[-1..] - repoName = repoName.replace(/\.git$/, '') - path.join(atom.config.get('core.projectHome'), repoName) - - Project::registerOpener = (opener) -> - deprecate("Use Workspace::addOpener instead") - atom.workspace.addOpener(opener) - - Project::unregisterOpener = (opener) -> - deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") - atom.workspace.unregisterOpener(opener) - - Project::eachEditor = (callback) -> - deprecate("Use Workspace::observeTextEditors instead") - atom.workspace.observeTextEditors(callback) - - Project::getEditors = -> - deprecate("Use Workspace::getTextEditors instead") - atom.workspace.getTextEditors() - - Project::on = (eventName) -> - if eventName is 'path-changed' - Grim.deprecate("Use Project::onDidChangePaths instead") - else - Grim.deprecate("Project::on is deprecated. Use documented event subscription methods instead.") - super - - Project::getRepo = -> - Grim.deprecate("Use ::getRepositories instead") - @getRepositories()[0] - - Project::getPath = -> - Grim.deprecate("Use ::getPaths instead") - @getPaths()[0] - - Project::setPath = (path) -> - Grim.deprecate("Use ::setPaths instead") - @setPaths([path]) - - Project::getRootDirectory = -> - Grim.deprecate("Use ::getDirectories instead") - @getDirectories()[0] - - Project::resolve = (uri) -> - Grim.deprecate("Use `Project::getDirectories()[0]?.resolve()` instead") - @resolvePath(uri) - - Project::scan = (regex, options={}, iterator) -> - Grim.deprecate("Use atom.workspace.scan instead of atom.project.scan") - atom.workspace.scan(regex, options, iterator) - - Project::replace = (regex, replacementText, filePaths, iterator) -> - Grim.deprecate("Use atom.workspace.replace instead of atom.project.replace") - atom.workspace.replace(regex, replacementText, filePaths, iterator) - - Project::openSync = (filePath, options={}) -> - deprecate("Use Project::open instead") - filePath = @resolvePath(filePath) - @buildEditorForBuffer(@bufferForPathSync(filePath), options) diff --git a/src/project.js b/src/project.js new file mode 100644 index 00000000000..d917c6fe812 --- /dev/null +++ b/src/project.js @@ -0,0 +1,876 @@ +const path = require('path'); + +const _ = require('underscore-plus'); +const fs = require('fs-plus'); +const { Emitter, Disposable, CompositeDisposable } = require('event-kit'); +const TextBuffer = require('text-buffer'); +const { watchPath } = require('./path-watcher'); + +const DefaultDirectoryProvider = require('./default-directory-provider'); +const Model = require('./model'); +const GitRepositoryProvider = require('./git-repository-provider'); + +// Extended: Represents a project that's opened in Atom. +// +// An instance of this class is always available as the `atom.project` global. +module.exports = class Project extends Model { + /* + Section: Construction and Destruction + */ + + constructor({ + notificationManager, + packageManager, + config, + applicationDelegate, + grammarRegistry + }) { + super(); + this.notificationManager = notificationManager; + this.applicationDelegate = applicationDelegate; + this.grammarRegistry = grammarRegistry; + + this.emitter = new Emitter(); + this.buffers = []; + this.rootDirectories = []; + this.repositories = []; + this.directoryProviders = []; + this.defaultDirectoryProvider = new DefaultDirectoryProvider(); + this.repositoryPromisesByPath = new Map(); + this.repositoryProviders = [new GitRepositoryProvider(this, config)]; + this.loadPromisesByPath = {}; + this.watcherPromisesByPath = {}; + this.retiredBufferIDs = new Set(); + this.retiredBufferPaths = new Set(); + this.subscriptions = new CompositeDisposable(); + this.consumeServices(packageManager); + } + + destroyed() { + for (let buffer of this.buffers.slice()) { + buffer.destroy(); + } + for (let repository of this.repositories.slice()) { + if (repository != null) repository.destroy(); + } + for (let path in this.watcherPromisesByPath) { + this.watcherPromisesByPath[path].then(watcher => { + watcher.dispose(); + }); + } + this.rootDirectories = []; + this.repositories = []; + } + + reset(packageManager) { + 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(); + } + this.buffers = []; + this.setPaths([]); + this.loadPromisesByPath = {}; + this.retiredBufferIDs = new Set(); + this.retiredBufferPaths = new Set(); + this.consumeServices(packageManager); + } + + destroyUnretainedBuffers() { + for (let buffer of this.getBuffers()) { + if (!buffer.isRetained()) buffer.destroy(); + } + } + + // Layers the contents of a project's file's config + // on top of the current global config. + replace(projectSpecification) { + if (projectSpecification == null) { + atom.config.clearProjectSettings(); + this.setPaths([]); + } else { + if (projectSpecification.originPath == null) { + return; + } + + // If no path is specified, set to directory of originPath. + if (!Array.isArray(projectSpecification.paths)) { + projectSpecification.paths = [ + path.dirname(projectSpecification.originPath) + ]; + } + atom.config.resetProjectSettings( + projectSpecification.config, + projectSpecification.originPath + ); + this.setPaths(projectSpecification.paths); + } + this.emitter.emit('did-replace', projectSpecification); + } + + onDidReplace(callback) { + return this.emitter.on('did-replace', callback); + } + + /* + Section: Serialization + */ + + deserialize(state) { + this.retiredBufferIDs = new Set(); + this.retiredBufferPaths = new Set(); + + const handleBufferState = bufferState => { + if (bufferState.shouldDestroyOnFileDelete == null) { + bufferState.shouldDestroyOnFileDelete = () => + atom.config.get('core.closeDeletedFileTabs'); + } + + // Use a little guilty knowledge of the way TextBuffers are serialized. + // This allows TextBuffers that have never been saved (but have filePaths) to be deserialized, but prevents + // TextBuffers backed by files that have been deleted from being saved. + bufferState.mustExist = bufferState.digestWhenLastPersisted !== false; + + return TextBuffer.deserialize(bufferState).catch(_ => { + this.retiredBufferIDs.add(bufferState.id); + this.retiredBufferPaths.add(bufferState.filePath); + return null; + }); + }; + + const bufferPromises = []; + for (let bufferState of state.buffers) { + bufferPromises.push(handleBufferState(bufferState)); + } + + 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 }); + }); + } + + serialize(options = {}) { + return { + deserializer: 'Project', + paths: this.getPaths(), + buffers: _.compact( + this.buffers.map(function(buffer) { + if (buffer.isRetained()) { + const isUnloading = options.isUnloading === true; + return buffer.serialize({ + markerLayers: isUnloading, + history: isUnloading + }); + } + }) + ) + }; + } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the project paths change. + // + // * `callback` {Function} to be called after the project paths change. + // * `projectPaths` An {Array} of {String} project paths. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePaths(callback) { + return this.emitter.on('did-change-paths', callback); + } + + // Public: Invoke the given callback when a text buffer is added to the + // project. + // + // * `callback` {Function} to be called when a text buffer is added. + // * `buffer` A {TextBuffer} item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddBuffer(callback) { + return this.emitter.on('did-add-buffer', callback); + } + + // Public: Invoke the given callback with all current and future text + // buffers in the project. + // + // * `callback` {Function} to be called with current and future text buffers. + // * `buffer` A {TextBuffer} item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeBuffers(callback) { + for (let buffer of this.getBuffers()) { + callback(buffer); + } + return this.onDidAddBuffer(callback); + } + + // Extended: Invoke a callback when a filesystem change occurs within any open + // project path. + // + // ```js + // const disposable = atom.project.onDidChangeFiles(events => { + // for (const event of events) { + // // "created", "modified", "deleted", or "renamed" + // console.log(`Event action: ${event.action}`) + // + // // absolute path to the filesystem entry that was touched + // console.log(`Event path: ${event.path}`) + // + // if (event.action === 'renamed') { + // console.log(`.. renamed from: ${event.oldPath}`) + // } + // } + // }) + // + // disposable.dispose() + // ``` + // + // To watch paths outside of open projects, use the `watchPaths` function instead; see {PathWatcher}. + // + // When writing tests against functionality that uses this method, be sure to wait for the + // {Promise} returned by {::getWatcherPromise} before manipulating the filesystem to ensure that + // the watcher is receiving events. + // + // * `callback` {Function} to be called with batches of filesystem events reported by + // the operating system. + // * `events` An {Array} of objects that describe a batch of filesystem events. + // * `action` {String} describing the filesystem action that occurred. One of `"created"`, + // `"modified"`, `"deleted"`, or `"renamed"`. + // * `path` {String} containing the absolute path to the filesystem entry + // that was acted upon. + // * `oldPath` For rename events, {String} containing the filesystem entry's + // former absolute path. + // + // Returns a {Disposable} to manage this event subscription. + onDidChangeFiles(callback) { + return this.emitter.on('did-change-files', callback); + } + + // Public: Invoke the given callback with all current and future + // repositories in the project. + // + // * `callback` {Function} to be called with current and future + // repositories. + // * `repository` A {GitRepository} that is present at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to + // unsubscribe. + observeRepositories(callback) { + for (const repo of this.repositories) { + if (repo != null) { + callback(repo); + } + } + + return this.onDidAddRepository(callback); + } + + // Public: Invoke the given callback when a repository is added to the + // project. + // + // * `callback` {Function} to be called when a repository is added. + // * `repository` A {GitRepository}. + // + // Returns a {Disposable} on which `.dispose()` can be called to + // unsubscribe. + onDidAddRepository(callback) { + return this.emitter.on('did-add-repository', callback); + } + + /* + Section: Accessing the git repository + */ + + // Public: Get an {Array} of {GitRepository}s associated with the project's + // directories. + // + // 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 {GitRepository} objects: + // ``` + // Promise.all(atom.project.getDirectories().map( + // atom.project.repositoryForDirectory.bind(atom.project))) + // ``` + getRepositories() { + return this.repositories; + } + + // Public: Get the repository for a given directory asynchronously. + // + // * `directory` {Directory} for which to get a {GitRepository}. + // + // Returns a {Promise} that resolves with either: + // * {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(); + let promise = this.repositoryPromisesByPath.get(pathForDirectory); + if (!promise) { + const promises = this.repositoryProviders.map(provider => + provider.repositoryForDirectory(directory) + ); + promise = Promise.all(promises).then(repositories => { + const repo = repositories.find(repo => repo != null) || null; + + // If no repository is found, remove the entry for the directory in + // @repositoryPromisesByPath in case some other RepositoryProvider is + // registered in the future that could supply a Repository for the + // directory. + if (repo == null) + this.repositoryPromisesByPath.delete(pathForDirectory); + + if (repo && repo.onDidDestroy) { + repo.onDidDestroy(() => + this.repositoryPromisesByPath.delete(pathForDirectory) + ); + } + + return repo; + }); + this.repositoryPromisesByPath.set(pathForDirectory, promise); + } + return promise; + } + + /* + Section: Managing Paths + */ + + // Public: Get an {Array} of {String}s containing the paths of the project's + // directories. + getPaths() { + try { + return this.rootDirectories.map(rootDirectory => rootDirectory.getPath()); + } catch (e) { + atom.notifications.addError( + "Please clear Atom's window state with: atom --clear-window-state" + ); + } + } + + // Public: Set the paths of the project's directories. + // + // * `projectPaths` {Array} of {String} paths. + // * `options` An optional {Object} that may contain the following keys: + // * `mustExist` If `true`, throw an Error if any `projectPaths` do not exist. Any remaining `projectPaths` that + // do exist will still be added to the project. Default: `false`. + // * `exact` If `true`, only add a `projectPath` if it names an existing directory. If `false` and any `projectPath` + // is a file or does not exist, its parent directory will be added instead. Default: `false`. + setPaths(projectPaths, options = {}) { + for (let repository of this.repositories) { + if (repository != null) repository.destroy(); + } + this.rootDirectories = []; + this.repositories = []; + + for (let path in this.watcherPromisesByPath) { + this.watcherPromisesByPath[path].then(watcher => { + watcher.dispose(); + }); + } + this.watcherPromisesByPath = {}; + + const missingProjectPaths = []; + for (let projectPath of projectPaths) { + try { + this.addPath(projectPath, { + emitEvent: false, + mustExist: true, + exact: options.exact === true + }); + } catch (e) { + if (e.missingProjectPaths != null) { + missingProjectPaths.push(...e.missingProjectPaths); + } else { + throw e; + } + } + } + + this.emitter.emit('did-change-paths', projectPaths); + + if (options.mustExist === true && missingProjectPaths.length > 0) { + const err = new Error('One or more project directories do not exist'); + err.missingProjectPaths = missingProjectPaths; + throw err; + } + } + + // Public: Add a path to the project's list of root paths + // + // * `projectPath` {String} The path to the directory to add. + // * `options` An optional {Object} that may contain the following keys: + // * `mustExist` If `true`, throw an Error if the `projectPath` does not exist. If `false`, a `projectPath` that does + // not exist is ignored. Default: `false`. + // * `exact` If `true`, only add `projectPath` if it names an existing directory. If `false`, if `projectPath` is a + // a file or does not exist, its parent directory will be added instead. + addPath(projectPath, options = {}) { + const directory = this.getDirectoryForProjectPath(projectPath); + let ok = true; + if (options.exact === true) { + ok = directory.getPath() === projectPath; + } + ok = ok && directory.existsSync(); + + if (!ok) { + if (options.mustExist === true) { + const err = new Error(`Project directory ${directory} does not exist`); + err.missingProjectPaths = [projectPath]; + throw err; + } else { + return; + } + } + + for (let existingDirectory of this.getDirectories()) { + if (existingDirectory.getPath() === directory.getPath()) { + return; + } + } + + this.rootDirectories.push(directory); + + const didChangeCallback = events => { + // Stop event delivery immediately on removal of a rootDirectory, even if its watcher + // promise has yet to resolve at the time of removal + if (this.rootDirectories.includes(directory)) { + this.emitter.emit('did-change-files', events); + } + }; + + // We'll use the directory's custom onDidChangeFiles callback, if available. + // CustomDirectory::onDidChangeFiles should match the signature of + // Project::onDidChangeFiles below (although it may resolve asynchronously) + this.watcherPromisesByPath[directory.getPath()] = + directory.onDidChangeFiles != null + ? Promise.resolve(directory.onDidChangeFiles(didChangeCallback)) + : watchPath(directory.getPath(), {}, didChangeCallback); + + for (let watchedPath in this.watcherPromisesByPath) { + if (!this.rootDirectories.find(dir => dir.getPath() === watchedPath)) { + this.watcherPromisesByPath[watchedPath].then(watcher => { + watcher.dispose(); + }); + } + } + + let repo = null; + for (let provider of this.repositoryProviders) { + if (provider.repositoryForDirectorySync) { + repo = provider.repositoryForDirectorySync(directory); + } + if (repo) { + break; + } + } + this.repositories.push(repo != null ? repo : null); + if (repo != null) { + this.emitter.emit('did-add-repository', repo); + } + + if (options.emitEvent !== false) { + this.emitter.emit('did-change-paths', this.getPaths()); + } + } + + getProvidedDirectoryForProjectPath(projectPath) { + for (let provider of this.directoryProviders) { + if (typeof provider.directoryForURISync === 'function') { + const directory = provider.directoryForURISync(projectPath); + if (directory) { + return directory; + } + } + } + return null; + } + + getDirectoryForProjectPath(projectPath) { + let directory = this.getProvidedDirectoryForProjectPath(projectPath); + if (directory == null) { + directory = this.defaultDirectoryProvider.directoryForURISync( + projectPath + ); + } + return directory; + } + + // Extended: Access a {Promise} that resolves when the filesystem watcher associated with a project + // root directory is ready to begin receiving events. + // + // This is especially useful in test cases, where it's important to know that the watcher is + // ready before manipulating the filesystem to produce events. + // + // * `projectPath` {String} One of the project's root directories. + // + // Returns a {Promise} that resolves with the {PathWatcher} associated with this project root + // once it has initialized and is ready to start sending events. The Promise will reject with + // an error instead if `projectPath` is not currently a root directory. + getWatcherPromise(projectPath) { + return ( + this.watcherPromisesByPath[projectPath] || + Promise.reject(new Error(`${projectPath} is not a project root`)) + ); + } + + // Public: remove a path from the project's list of root paths. + // + // * `projectPath` {String} The path to remove. + removePath(projectPath) { + // The projectPath may be a URI, in which case it should not be normalized. + if (!this.getPaths().includes(projectPath)) { + projectPath = this.defaultDirectoryProvider.normalizePath(projectPath); + } + + let indexToRemove = null; + for (let i = 0; i < this.rootDirectories.length; i++) { + const directory = this.rootDirectories[i]; + if (directory.getPath() === projectPath) { + indexToRemove = i; + break; + } + } + + if (indexToRemove != null) { + this.rootDirectories.splice(indexToRemove, 1); + const [removedRepository] = this.repositories.splice(indexToRemove, 1); + if (!this.repositories.includes(removedRepository)) { + if (removedRepository) removedRepository.destroy(); + } + if (this.watcherPromisesByPath[projectPath] != null) { + this.watcherPromisesByPath[projectPath].then(w => w.dispose()); + } + delete this.watcherPromisesByPath[projectPath]; + this.emitter.emit('did-change-paths', this.getPaths()); + return true; + } else { + return false; + } + } + + // Public: Get an {Array} of {Directory}s associated with this project. + getDirectories() { + return this.rootDirectories; + } + + resolvePath(uri) { + if (!uri) { + return; + } + + if (uri.match(/[A-Za-z0-9+-.]+:\/\//)) { + // leave path alone if it has a scheme + return uri; + } else { + let projectPath; + if (fs.isAbsolute(uri)) { + return this.defaultDirectoryProvider.normalizePath(fs.resolveHome(uri)); + // TODO: what should we do here when there are multiple directories? + } else if ((projectPath = this.getPaths()[0])) { + return this.defaultDirectoryProvider.normalizePath( + fs.resolveHome(path.join(projectPath, uri)) + ); + } else { + return undefined; + } + } + } + + relativize(fullPath) { + return this.relativizePath(fullPath)[1]; + } + + // Public: Get the path to the project directory that contains the given path, + // and the relative path from that project directory to the given path. + // + // * `fullPath` {String} An absolute path. + // + // Returns an {Array} with two elements: + // * `projectPath` The {String} path to the project directory that contains the + // given path, or `null` if none is found. + // * `relativePath` {String} The relative path from the project directory to + // the given path. + relativizePath(fullPath) { + let result = [null, fullPath]; + if (fullPath != null) { + for (let rootDirectory of this.rootDirectories) { + const relativePath = rootDirectory.relativize(fullPath); + if (relativePath != null && relativePath.length < result[1].length) { + result = [rootDirectory.getPath(), relativePath]; + } + } + } + return result; + } + + // Public: Determines whether the given path (real or symbolic) is inside the + // project's directory. + // + // This method does not actually check if the path exists, it just checks their + // locations relative to each other. + // + // ## Examples + // + // Basic operation + // + // ```coffee + // # Project's root directory is /foo/bar + // project.contains('/foo/bar/baz') # => true + // project.contains('/usr/lib/baz') # => false + // ``` + // + // Existence of the path is not required + // + // ```coffee + // # Project's root directory is /foo/bar + // fs.existsSync('/foo/bar/baz') # => false + // project.contains('/foo/bar/baz') # => true + // ``` + // + // * `pathToCheck` {String} path + // + // Returns whether the path is inside the project's root directory. + contains(pathToCheck) { + return this.rootDirectories.some(dir => dir.contains(pathToCheck)); + } + + /* + Section: Private + */ + + consumeServices({ serviceHub }) { + serviceHub.consume('atom.directory-provider', '^0.1.0', provider => { + this.directoryProviders.unshift(provider); + return new Disposable(() => { + return this.directoryProviders.splice( + this.directoryProviders.indexOf(provider), + 1 + ); + }); + }); + + return serviceHub.consume( + 'atom.repository-provider', + '^0.1.0', + provider => { + this.repositoryProviders.unshift(provider); + if (this.repositories.includes(null)) { + this.setPaths(this.getPaths()); + } + return new Disposable(() => { + return this.repositoryProviders.splice( + this.repositoryProviders.indexOf(provider), + 1 + ); + }); + } + ); + } + + // Retrieves all the {TextBuffer}s in the project; that is, the + // buffers for all open files. + // + // Returns an {Array} of {TextBuffer}s. + getBuffers() { + return this.buffers.slice(); + } + + // Is the buffer for the given path modified? + isPathModified(filePath) { + const bufferForPath = this.findBufferForPath(this.resolvePath(filePath)); + return bufferForPath && bufferForPath.isModified(); + } + + findBufferForPath(filePath) { + return _.find(this.buffers, buffer => buffer.getPath() === filePath); + } + + findBufferForId(id) { + return _.find(this.buffers, buffer => buffer.getId() === id); + } + + // Only to be used in specs + bufferForPathSync(filePath) { + const absoluteFilePath = this.resolvePath(filePath); + if (this.retiredBufferPaths.has(absoluteFilePath)) { + return null; + } + + let existingBuffer; + if (filePath) { + existingBuffer = this.findBufferForPath(absoluteFilePath); + } + return existingBuffer != null + ? existingBuffer + : this.buildBufferSync(absoluteFilePath); + } + + // Only to be used when deserializing + bufferForIdSync(id) { + if (this.retiredBufferIDs.has(id)) { + return null; + } + + let existingBuffer; + if (id) { + existingBuffer = this.findBufferForId(id); + } + return existingBuffer != null ? existingBuffer : this.buildBufferSync(); + } + + // Given a file path, this retrieves or creates a new {TextBuffer}. + // + // If the `filePath` already has a `buffer`, that value is used instead. Otherwise, + // `text` is used as the contents of the new buffer. + // + // * `filePath` A {String} representing a path. If `null`, an "Untitled" buffer is created. + // + // Returns a {Promise} that resolves to the {TextBuffer}. + bufferForPath(absoluteFilePath) { + let existingBuffer; + if (absoluteFilePath != null) { + existingBuffer = this.findBufferForPath(absoluteFilePath); + } + if (existingBuffer) { + return Promise.resolve(existingBuffer); + } else { + return this.buildBuffer(absoluteFilePath); + } + } + + shouldDestroyBufferOnFileDelete() { + return atom.config.get('core.closeDeletedFileTabs'); + } + + // Still needed when deserializing a tokenized buffer + buildBufferSync(absoluteFilePath) { + const params = { + shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete + }; + + let buffer; + if (absoluteFilePath != null) { + buffer = TextBuffer.loadSync(absoluteFilePath, params); + } else { + buffer = new TextBuffer(params); + } + this.addBuffer(buffer); + return buffer; + } + + // Given a file path, this sets its {TextBuffer}. + // + // * `absoluteFilePath` A {String} representing a path. + // * `text` The {String} text to use as a buffer. + // + // Returns a {Promise} that resolves to the {TextBuffer}. + async buildBuffer(absoluteFilePath) { + const params = { + shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete + }; + + let buffer; + if (absoluteFilePath != null) { + if (this.loadPromisesByPath[absoluteFilePath] == null) { + this.loadPromisesByPath[absoluteFilePath] = TextBuffer.load( + absoluteFilePath, + params + ) + .then(result => { + delete this.loadPromisesByPath[absoluteFilePath]; + return result; + }) + .catch(error => { + delete this.loadPromisesByPath[absoluteFilePath]; + throw error; + }); + } + buffer = await this.loadPromisesByPath[absoluteFilePath]; + } else { + buffer = new TextBuffer(params); + } + + this.grammarRegistry.autoAssignLanguageMode(buffer); + + this.addBuffer(buffer); + return buffer; + } + + addBuffer(buffer, options = {}) { + this.buffers.push(buffer); + this.subscriptions.add(this.grammarRegistry.maintainLanguageMode(buffer)); + this.subscribeToBuffer(buffer); + this.emitter.emit('did-add-buffer', buffer); + return buffer; + } + + // Removes a {TextBuffer} association from the project. + // + // Returns the removed {TextBuffer}. + removeBuffer(buffer) { + const index = this.buffers.indexOf(buffer); + if (index !== -1) { + return this.removeBufferAtIndex(index); + } + } + + removeBufferAtIndex(index, options = {}) { + const [buffer] = this.buffers.splice(index, 1); + return buffer != null ? buffer.destroy() : undefined; + } + + eachBuffer(...args) { + let subscriber; + if (args.length > 1) { + subscriber = args.shift(); + } + const callback = args.shift(); + + for (let buffer of this.getBuffers()) { + callback(buffer); + } + if (subscriber) { + return subscriber.subscribe(this, 'buffer-created', buffer => + callback(buffer) + ); + } else { + return this.on('buffer-created', buffer => callback(buffer)); + } + } + + subscribeToBuffer(buffer) { + buffer.onWillSave(async ({ path }) => + this.applicationDelegate.emitWillSavePath(path) + ); + buffer.onDidSave(({ path }) => + this.applicationDelegate.emitDidSavePath(path) + ); + buffer.onDidDestroy(() => this.removeBuffer(buffer)); + buffer.onDidChangePath(() => { + if (!(this.getPaths().length > 0)) { + this.setPaths([path.dirname(buffer.getPath())]); + } + }); + buffer.onWillThrowWatchError(({ error, handle }) => { + handle(); + const message = + `Unable to read file after file \`${error.eventType}\` event.` + + `Make sure you have permission to access \`${buffer.getPath()}\`.`; + this.notificationManager.addWarning(message, { + detail: error.message, + dismissable: true + }); + }); + } +}; diff --git a/src/protocol-handler-installer.js b/src/protocol-handler-installer.js new file mode 100644 index 00000000000..6b904fc7a49 --- /dev/null +++ b/src/protocol-handler-installer.js @@ -0,0 +1,117 @@ +const { ipcRenderer } = require('electron'); + +const SETTING = 'core.uriHandlerRegistration'; +const PROMPT = 'prompt'; +const ALWAYS = 'always'; +const NEVER = 'never'; + +module.exports = class ProtocolHandlerInstaller { + isSupported() { + return ['win32', 'darwin'].includes(process.platform); + } + + async isDefaultProtocolClient() { + return ipcRenderer.invoke('isDefaultProtocolClient', { + protocol: 'atom', + path: process.execPath, + args: ['--uri-handler', '--'] + }); + } + + async 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() && + ipcRenderer.invoke('setAsDefaultProtocolClient', { + protocol: 'atom', + path: process.execPath, + args: ['--uri-handler', '--'] + }) + ); + } + + async initialize(config, notifications) { + if (!this.isSupported()) { + return; + } + + const behaviorWhenNotProtocolClient = config.get(SETTING); + switch (behaviorWhenNotProtocolClient) { + case PROMPT: + if (await !this.isDefaultProtocolClient()) { + this.promptToBecomeProtocolClient(config, notifications); + } + break; + case ALWAYS: + if (await !this.isDefaultProtocolClient()) { + this.setAsDefaultProtocolClient(); + } + 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 + } + } + + promptToBecomeProtocolClient(config, notifications) { + let notification; + + const withSetting = (value, fn) => { + return function() { + config.set(SETTING, value); + fn(); + }; + }; + + const accept = () => { + notification.dismiss(); + this.setAsDefaultProtocolClient(); + }; + const decline = () => { + notification.dismiss(); + }; + + notification = notifications.addInfo( + 'Register as default atom:// URI handler?', + { + dismissable: true, + icon: 'link', + description: + 'Atom is not currently set as the default handler for atom:// URIs. Would you like Atom to handle ' + + 'atom:// URIs?', + buttons: [ + { + text: 'Yes', + className: 'btn btn-info btn-primary', + onDidClick: accept + }, + { + text: 'Yes, Always', + className: 'btn btn-info', + onDidClick: withSetting(ALWAYS, accept) + }, + { + text: 'No', + className: 'btn btn-info', + onDidClick: decline + }, + { + text: 'No, Never', + className: 'btn btn-info', + onDidClick: withSetting(NEVER, decline) + } + ] + } + ); + } +}; diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee new file mode 100644 index 00000000000..35b566fe7a2 --- /dev/null +++ b/src/register-default-commands.coffee @@ -0,0 +1,348 @@ +{ipcRenderer} = require 'electron' +Grim = require 'grim' + +module.exports = ({commandRegistry, commandInstaller, config, notificationManager, project, clipboard}) -> + commandRegistry.add( + 'atom-workspace', + { + 'pane:show-next-recently-used-item': -> @getModel().getActivePane().activateNextRecentlyUsedItem() + 'pane:show-previous-recently-used-item': -> @getModel().getActivePane().activatePreviousRecentlyUsedItem() + 'pane:move-active-item-to-top-of-stack': -> @getModel().getActivePane().moveActiveItemToTopOfStack() + 'pane:show-next-item': -> @getModel().getActivePane().activateNextItem() + 'pane:show-previous-item': -> @getModel().getActivePane().activatePreviousItem() + 'pane:show-item-1': -> @getModel().getActivePane().activateItemAtIndex(0) + 'pane:show-item-2': -> @getModel().getActivePane().activateItemAtIndex(1) + 'pane:show-item-3': -> @getModel().getActivePane().activateItemAtIndex(2) + 'pane:show-item-4': -> @getModel().getActivePane().activateItemAtIndex(3) + 'pane:show-item-5': -> @getModel().getActivePane().activateItemAtIndex(4) + 'pane:show-item-6': -> @getModel().getActivePane().activateItemAtIndex(5) + 'pane:show-item-7': -> @getModel().getActivePane().activateItemAtIndex(6) + 'pane:show-item-8': -> @getModel().getActivePane().activateItemAtIndex(7) + 'pane:show-item-9': -> @getModel().getActivePane().activateLastItem() + 'pane:move-item-right': -> @getModel().getActivePane().moveItemRight() + 'pane:move-item-left': -> @getModel().getActivePane().moveItemLeft() + 'window:increase-font-size': -> @getModel().increaseFontSize() + 'window:decrease-font-size': -> @getModel().decreaseFontSize() + 'window:reset-font-size': -> @getModel().resetFontSize() + 'application:about': -> ipcRenderer.send('command', 'application:about') + 'application:show-preferences': -> ipcRenderer.send('command', 'application:show-settings') + 'application:show-settings': -> ipcRenderer.send('command', 'application:show-settings') + 'application:quit': -> ipcRenderer.send('command', 'application:quit') + 'application:hide': -> ipcRenderer.send('command', 'application:hide') + 'application:hide-other-applications': -> ipcRenderer.send('command', 'application:hide-other-applications') + 'application:install-update': -> ipcRenderer.send('command', 'application:install-update') + 'application:unhide-all-applications': -> ipcRenderer.send('command', 'application:unhide-all-applications') + 'application:new-window': -> ipcRenderer.send('command', 'application:new-window') + 'application:new-file': -> ipcRenderer.send('command', 'application:new-file') + 'application:open': -> + defaultPath = atom.workspace.getActiveTextEditor()?.getPath() ? atom.project.getPaths()?[0] + ipcRenderer.send('open-chosen-any', defaultPath) + 'application:open-file': -> + defaultPath = atom.workspace.getActiveTextEditor()?.getPath() ? atom.project.getPaths()?[0] + ipcRenderer.send('open-chosen-file', defaultPath) + 'application:open-folder': -> + defaultPath = atom.workspace.getActiveTextEditor()?.getPath() ? atom.project.getPaths()?[0] + ipcRenderer.send('open-chosen-folder', defaultPath) + 'application:open-dev': -> ipcRenderer.send('command', 'application:open-dev') + 'application:open-safe': -> ipcRenderer.send('command', 'application:open-safe') + 'application:add-project-folder': -> atom.addProjectFolder() + 'application:minimize': -> ipcRenderer.send('command', 'application:minimize') + 'application:zoom': -> ipcRenderer.send('command', 'application:zoom') + 'application:bring-all-windows-to-front': -> ipcRenderer.send('command', 'application:bring-all-windows-to-front') + 'application:open-your-config': -> ipcRenderer.send('command', 'application:open-your-config') + 'application:open-your-init-script': -> ipcRenderer.send('command', 'application:open-your-init-script') + 'application:open-your-keymap': -> ipcRenderer.send('command', 'application:open-your-keymap') + 'application:open-your-snippets': -> ipcRenderer.send('command', 'application:open-your-snippets') + 'application:open-your-stylesheet': -> ipcRenderer.send('command', 'application:open-your-stylesheet') + 'application:open-license': -> @getModel().openLicense() + 'window:run-package-specs': -> @runPackageSpecs() + 'window:run-benchmarks': -> @runBenchmarks() + 'window:toggle-left-dock': -> @getModel().getLeftDock().toggle() + 'window:toggle-right-dock': -> @getModel().getRightDock().toggle() + 'window:toggle-bottom-dock': -> @getModel().getBottomDock().toggle() + 'window:focus-next-pane': -> @getModel().activateNextPane() + 'window:focus-previous-pane': -> @getModel().activatePreviousPane() + 'window:focus-pane-above': -> @focusPaneViewAbove() + 'window:focus-pane-below': -> @focusPaneViewBelow() + 'window:focus-pane-on-left': -> @focusPaneViewOnLeft() + 'window:focus-pane-on-right': -> @focusPaneViewOnRight() + 'window:move-active-item-to-pane-above': -> @moveActiveItemToPaneAbove() + 'window:move-active-item-to-pane-below': -> @moveActiveItemToPaneBelow() + 'window:move-active-item-to-pane-on-left': -> @moveActiveItemToPaneOnLeft() + 'window:move-active-item-to-pane-on-right': -> @moveActiveItemToPaneOnRight() + 'window:copy-active-item-to-pane-above': -> @moveActiveItemToPaneAbove(keepOriginal: true) + 'window:copy-active-item-to-pane-below': -> @moveActiveItemToPaneBelow(keepOriginal: true) + 'window:copy-active-item-to-pane-on-left': -> @moveActiveItemToPaneOnLeft(keepOriginal: true) + 'window:copy-active-item-to-pane-on-right': -> @moveActiveItemToPaneOnRight(keepOriginal: true) + 'window:save-all': -> @getModel().saveAll() + 'window:toggle-invisibles': -> config.set("editor.showInvisibles", not config.get("editor.showInvisibles")) + 'window:log-deprecation-warnings': -> Grim.logDeprecations() + 'window:toggle-auto-indent': -> config.set("editor.autoIndent", not config.get("editor.autoIndent")) + 'pane:reopen-closed-item': -> @getModel().reopenItem() + 'core:close': -> @getModel().closeActivePaneItemOrEmptyPaneOrWindow() + 'core:save': -> @getModel().saveActivePaneItem() + 'core:save-as': -> @getModel().saveActivePaneItemAs() + }, + false + ) + + + if process.platform is 'darwin' + commandRegistry.add( + 'atom-workspace', + 'window:install-shell-commands', + (-> commandInstaller.installShellCommandsInteractively()), + false + ) + + commandRegistry.add( + 'atom-pane', + { + 'pane:save-items': -> @getModel().saveItems() + 'pane:split-left': -> @getModel().splitLeft() + 'pane:split-right': -> @getModel().splitRight() + 'pane:split-up': -> @getModel().splitUp() + 'pane:split-down': -> @getModel().splitDown() + 'pane:split-left-and-copy-active-item': -> @getModel().splitLeft(copyActiveItem: true) + 'pane:split-right-and-copy-active-item': -> @getModel().splitRight(copyActiveItem: true) + 'pane:split-up-and-copy-active-item': -> @getModel().splitUp(copyActiveItem: true) + 'pane:split-down-and-copy-active-item': -> @getModel().splitDown(copyActiveItem: true) + 'pane:split-left-and-move-active-item': -> @getModel().splitLeft(moveActiveItem: true) + 'pane:split-right-and-move-active-item': -> @getModel().splitRight(moveActiveItem: true) + 'pane:split-up-and-move-active-item': -> @getModel().splitUp(moveActiveItem: true) + 'pane:split-down-and-move-active-item': -> @getModel().splitDown(moveActiveItem: true) + 'pane:close': -> @getModel().close() + 'pane:close-other-items': -> @getModel().destroyInactiveItems() + 'pane:increase-size': -> @getModel().increaseSize() + 'pane:decrease-size': -> @getModel().decreaseSize() + }, + false + ) + + commandRegistry.add( + 'atom-text-editor', + stopEventPropagation({ + 'core:move-left': -> @moveLeft() + 'core:move-right': -> @moveRight() + 'core:select-left': -> @selectLeft() + 'core:select-right': -> @selectRight() + 'core:select-up': -> @selectUp() + 'core:select-down': -> @selectDown() + 'core:select-all': -> @selectAll() + 'editor:select-word': -> @selectWordsContainingCursors() + 'editor:consolidate-selections': (event) -> event.abortKeyBinding() unless @consolidateSelections() + 'editor:move-to-beginning-of-next-paragraph': -> @moveToBeginningOfNextParagraph() + 'editor:move-to-beginning-of-previous-paragraph': -> @moveToBeginningOfPreviousParagraph() + 'editor:move-to-beginning-of-screen-line': -> @moveToBeginningOfScreenLine() + 'editor:move-to-beginning-of-line': -> @moveToBeginningOfLine() + 'editor:move-to-end-of-screen-line': -> @moveToEndOfScreenLine() + 'editor:move-to-end-of-line': -> @moveToEndOfLine() + 'editor:move-to-first-character-of-line': -> @moveToFirstCharacterOfLine() + 'editor:move-to-beginning-of-word': -> @moveToBeginningOfWord() + 'editor:move-to-end-of-word': -> @moveToEndOfWord() + 'editor:move-to-beginning-of-next-word': -> @moveToBeginningOfNextWord() + 'editor:move-to-previous-word-boundary': -> @moveToPreviousWordBoundary() + 'editor:move-to-next-word-boundary': -> @moveToNextWordBoundary() + 'editor:move-to-previous-subword-boundary': -> @moveToPreviousSubwordBoundary() + 'editor:move-to-next-subword-boundary': -> @moveToNextSubwordBoundary() + 'editor:select-to-beginning-of-next-paragraph': -> @selectToBeginningOfNextParagraph() + 'editor:select-to-beginning-of-previous-paragraph': -> @selectToBeginningOfPreviousParagraph() + 'editor:select-to-end-of-line': -> @selectToEndOfLine() + 'editor:select-to-beginning-of-line': -> @selectToBeginningOfLine() + 'editor:select-to-end-of-word': -> @selectToEndOfWord() + 'editor:select-to-beginning-of-word': -> @selectToBeginningOfWord() + 'editor:select-to-beginning-of-next-word': -> @selectToBeginningOfNextWord() + 'editor:select-to-next-word-boundary': -> @selectToNextWordBoundary() + 'editor:select-to-previous-word-boundary': -> @selectToPreviousWordBoundary() + 'editor:select-to-next-subword-boundary': -> @selectToNextSubwordBoundary() + '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 + ) + + commandRegistry.add( + 'atom-text-editor:not([readonly])', + stopEventPropagation({ + 'core:undo': -> @undo() + 'core:redo': -> @redo() + }), + false + ) + + commandRegistry.add( + 'atom-text-editor', + stopEventPropagationAndGroupUndo( + config, + { + 'core:copy': -> @copySelectedText() + 'editor:copy-selection': -> @copyOnlySelectedText() + } + ), + false + ) + + commandRegistry.add( + 'atom-text-editor:not([readonly])', + stopEventPropagationAndGroupUndo( + config, + { + 'core:backspace': -> @backspace() + 'core:delete': -> @delete() + 'core:cut': -> @cutSelectedText() + 'core:paste': -> @pasteText() + 'editor:paste-without-reformatting': -> @pasteText({ + normalizeLineEndings: false, + autoIndent: false, + preserveTrailingLineIndentation: true + }) + 'editor:delete-to-previous-word-boundary': -> @deleteToPreviousWordBoundary() + 'editor:delete-to-next-word-boundary': -> @deleteToNextWordBoundary() + 'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord() + 'editor:delete-to-beginning-of-line': -> @deleteToBeginningOfLine() + 'editor:delete-to-end-of-line': -> @deleteToEndOfLine() + 'editor:delete-to-end-of-word': -> @deleteToEndOfWord() + 'editor:delete-to-beginning-of-subword': -> @deleteToBeginningOfSubword() + 'editor:delete-to-end-of-subword': -> @deleteToEndOfSubword() + 'editor:delete-line': -> @deleteLine() + 'editor:cut-to-end-of-line': -> @cutToEndOfLine() + 'editor:cut-to-end-of-buffer-line': -> @cutToEndOfBufferLine() + 'editor:transpose': -> @transpose() + 'editor:upper-case': -> @upperCase() + 'editor:lower-case': -> @lowerCase() + } + ), + false + ) + + commandRegistry.add( + 'atom-text-editor:not([mini])', + stopEventPropagation({ + 'core:move-up': -> @moveUp() + 'core:move-down': -> @moveDown() + 'core:move-to-top': -> @moveToTop() + 'core:move-to-bottom': -> @moveToBottom() + 'core:page-up': -> @pageUp() + 'core:page-down': -> @pageDown() + 'core:select-to-top': -> @selectToTop() + 'core:select-to-bottom': -> @selectToBottom() + 'core:select-page-up': -> @selectPageUp() + 'core:select-page-down': -> @selectPageDown() + 'editor:add-selection-below': -> @addSelectionBelow() + 'editor:add-selection-above': -> @addSelectionAbove() + 'editor:split-selections-into-lines': -> @splitSelectionsIntoLines() + 'editor:toggle-soft-tabs': -> @toggleSoftTabs() + 'editor:toggle-soft-wrap': -> @toggleSoftWrapped() + 'editor:fold-all': -> @foldAll() + 'editor:unfold-all': -> @unfoldAll() + 'editor:fold-current-row': -> + @foldCurrentRow() + @scrollToCursorPosition() + 'editor:unfold-current-row': -> + @unfoldCurrentRow() + @scrollToCursorPosition() + 'editor:fold-selection': -> @foldSelectedLines() + '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:log-cursor-syntax-tree-scope': -> showSyntaxTree(@getCursorSyntaxTreeScope(), notificationManager) + 'editor:copy-path': -> copyPathToClipboard(this, project, clipboard, false) + 'editor:copy-project-path': -> copyPathToClipboard(this, project, clipboard, true) + 'editor:toggle-indent-guide': -> config.set('editor.showIndentGuide', not config.get('editor.showIndentGuide')) + 'editor:toggle-line-numbers': -> config.set('editor.showLineNumbers', not config.get('editor.showLineNumbers')) + 'editor:scroll-to-cursor': -> @scrollToCursorPosition() + }), + false + ) + + commandRegistry.add( + 'atom-text-editor:not([mini]):not([readonly])', + stopEventPropagationAndGroupUndo( + config, + { + 'editor:indent': -> @indent() + 'editor:auto-indent': -> @autoIndentSelectedRows() + 'editor:indent-selected-rows': -> @indentSelectedRows() + 'editor:outdent-selected-rows': -> @outdentSelectedRows() + 'editor:newline': -> @insertNewline() + 'editor:newline-below': -> @insertNewlineBelow() + 'editor:newline-above': -> @insertNewlineAbove() + 'editor:toggle-line-comments': -> @toggleLineCommentsInSelection() + 'editor:checkout-head-revision': -> atom.workspace.checkoutHeadRevision(this) + 'editor:move-line-up': -> @moveLineUp() + 'editor:move-line-down': -> @moveLineDown() + 'editor:move-selection-left': -> @moveSelectionLeft() + 'editor:move-selection-right': -> @moveSelectionRight() + 'editor:duplicate-lines': -> @duplicateLines() + 'editor:join-lines': -> @joinLines() + } + ), + false + ) + +stopEventPropagation = (commandListeners) -> + newCommandListeners = {} + for commandName, commandListener of commandListeners + do (commandListener) -> + newCommandListeners[commandName] = (event) -> + event.stopPropagation() + commandListener.call(@getModel(), event) + newCommandListeners + +stopEventPropagationAndGroupUndo = (config, commandListeners) -> + newCommandListeners = {} + for commandName, commandListener of commandListeners + do (commandListener) -> + newCommandListeners[commandName] = (event) -> + event.stopPropagation() + model = @getModel() + model.transact model.getUndoGroupingInterval(), -> + commandListener.call(model, event) + newCommandListeners + +showCursorScope = (descriptor, notificationManager) -> + list = descriptor.scopes.toString().split(',') + list = list.map (item) -> "* #{item}" + content = "Scopes at Cursor\n#{list.join('\n')}" + + notificationManager.addInfo(content, dismissable: true) + +showSyntaxTree = (descriptor, notificationManager) -> + list = descriptor.scopes.toString().split(',') + list = list.map (item) -> "* #{item}" + content = "Syntax tree at Cursor\n#{list.join('\n')}" + + notificationManager.addInfo(content, dismissable: true) + +copyPathToClipboard = (editor, project, clipboard, relative) -> + if filePath = editor.getPath() + filePath = project.relativize(filePath) if relative + clipboard.write(filePath) diff --git a/src/reopen-project-list-view.js b/src/reopen-project-list-view.js new file mode 100644 index 00000000000..2ae7577e763 --- /dev/null +++ b/src/reopen-project-list-view.js @@ -0,0 +1,77 @@ +const SelectListView = require('atom-select-list'); + +module.exports = class ReopenProjectListView { + constructor(callback) { + this.callback = callback; + this.selectListView = new SelectListView({ + emptyMessage: 'No projects in history.', + itemsClassList: ['mark-active'], + items: [], + filterKeyForItem: project => project.name, + elementForItem: project => { + let element = document.createElement('li'); + if (project.name === this.currentProjectName) { + element.classList.add('active'); + } + element.textContent = project.name; + return element; + }, + didConfirmSelection: project => { + this.cancel(); + this.callback(project.value); + }, + didCancelSelection: () => { + this.cancel(); + } + }); + this.selectListView.element.classList.add('reopen-project'); + } + + get element() { + return this.selectListView.element; + } + + dispose() { + this.cancel(); + return this.selectListView.destroy(); + } + + cancel() { + if (this.panel != null) { + this.panel.destroy(); + } + this.panel = null; + this.currentProjectName = null; + if (this.previouslyFocusedElement) { + this.previouslyFocusedElement.focus(); + this.previouslyFocusedElement = null; + } + } + + attach() { + this.previouslyFocusedElement = document.activeElement; + if (this.panel == null) { + this.panel = atom.workspace.addModalPanel({ item: this }); + } + this.selectListView.focus(); + this.selectListView.reset(); + } + + async toggle() { + if (this.panel != null) { + this.cancel(); + } else { + this.currentProjectName = + atom.project != null ? this.makeName(atom.project.getPaths()) : null; + const projects = atom.history + .getProjects() + .map(p => ({ name: this.makeName(p.paths), value: p.paths })); + await this.selectListView.update({ items: projects }); + this.attach(); + } + } + + makeName(paths) { + return paths.join(', '); + } +}; diff --git a/src/reopen-project-menu-manager.js b/src/reopen-project-menu-manager.js new file mode 100644 index 00000000000..d1d55395780 --- /dev/null +++ b/src/reopen-project-menu-manager.js @@ -0,0 +1,175 @@ +const { CompositeDisposable } = require('event-kit'); +const path = require('path'); + +module.exports = class ReopenProjectMenuManager { + constructor({ menu, commands, history, config, open }) { + this.menuManager = menu; + this.historyManager = history; + this.config = config; + this.open = open; + this.projects = []; + + this.subscriptions = new CompositeDisposable(); + this.subscriptions.add( + history.onDidChangeProjects(this.update.bind(this)), + config.onDidChange( + 'core.reopenProjectMenuCount', + ({ oldValue, newValue }) => { + this.update(); + } + ), + commands.add('atom-workspace', { + 'application:reopen-project': this.reopenProjectCommand.bind(this) + }) + ); + + this.applyWindowsJumpListRemovals(); + } + + reopenProjectCommand(e) { + if (e.detail != null && e.detail.index != null) { + this.open(this.projects[e.detail.index].paths); + } else { + this.createReopenProjectListView(); + } + } + + createReopenProjectListView() { + if (this.reopenProjectListView == null) { + const ReopenProjectListView = require('./reopen-project-list-view'); + this.reopenProjectListView = new ReopenProjectListView(paths => { + if (paths != null) { + this.open(paths); + } + }); + } + this.reopenProjectListView.toggle(); + } + + update() { + this.disposeProjectMenu(); + this.projects = this.historyManager + .getProjects() + .slice(0, this.config.get('core.reopenProjectMenuCount')); + const newMenu = ReopenProjectMenuManager.createProjectsMenu(this.projects); + this.lastProjectMenu = this.menuManager.add([newMenu]); + this.updateWindowsJumpList(); + } + + static taskDescription(paths) { + return paths + .map(path => `${ReopenProjectMenuManager.betterBaseName(path)} (${path})`) + .join(' '); + } + + // Windows users can right-click Atom taskbar and remove project from the jump list. + // We have to honor that or the group stops working. As we only get a partial list + // each time we remove them from history entirely. + async applyWindowsJumpListRemovals() { + if (process.platform !== 'win32') return; + if (this.app === undefined) { + this.app = require('electron').remote.app; + } + + const removed = this.app + .getJumpListSettings() + .removedItems.map(i => i.description); + if (removed.length === 0) return; + for (let project of this.historyManager.getProjects()) { + if ( + removed.includes( + ReopenProjectMenuManager.taskDescription(project.paths) + ) + ) { + await this.historyManager.removeProject(project.paths); + } + } + } + + updateWindowsJumpList() { + if (process.platform !== 'win32') return; + if (this.app === undefined) { + this.app = require('electron').remote.app; + } + + this.app.setJumpList([ + { + type: 'custom', + name: 'Recent Projects', + items: this.projects.map(project => ({ + type: 'task', + title: project.paths + .map(ReopenProjectMenuManager.betterBaseName) + .join(', '), + description: ReopenProjectMenuManager.taskDescription(project.paths), + program: process.execPath, + args: project.paths.map(path => `"${path}"`).join(' '), + iconPath: path.join( + path.dirname(process.execPath), + 'resources', + 'cli', + 'folder.ico' + ), + iconIndex: 0 + })) + }, + { type: 'recent' }, + { + items: [ + { + type: 'task', + title: 'New Window', + program: process.execPath, + args: '--new-window', + description: 'Opens a new Atom window' + } + ] + } + ]); + } + + dispose() { + this.subscriptions.dispose(); + this.disposeProjectMenu(); + if (this.reopenProjectListView != null) { + this.reopenProjectListView.dispose(); + } + } + + disposeProjectMenu() { + if (this.lastProjectMenu) { + this.lastProjectMenu.dispose(); + this.lastProjectMenu = null; + } + } + + static createProjectsMenu(projects) { + return { + label: 'File', + id: 'File', + submenu: [ + { + label: 'Reopen Project', + id: 'Reopen Project', + submenu: projects.map((project, index) => ({ + label: this.createLabel(project), + command: 'application:reopen-project', + commandDetail: { index: index, paths: project.paths } + })) + } + ] + }; + } + + static createLabel(project) { + return project.paths.length === 1 + ? project.paths[0] + : project.paths.map(this.betterBaseName).join(', '); + } + + static betterBaseName(directory) { + // Handles Windows roots better than path.basename which returns '' for 'd:' and 'd:\' + const match = directory.match(/^([a-z]:)[\\]?$/i); + return match ? match[1] + '\\' : path.basename(directory); + } +}; diff --git a/src/repository-status-handler.coffee b/src/repository-status-handler.coffee deleted file mode 100644 index d0763fd5af3..00000000000 --- a/src/repository-status-handler.coffee +++ /dev/null @@ -1,35 +0,0 @@ -Git = require 'git-utils' -path = require 'path' - -module.exports = (repoPath) -> - repo = Git.open(repoPath) - - upstream = {} - statuses = {} - submodules = {} - branch = null - - if repo? - # Statuses in main repo - workingDirectoryPath = repo.getWorkingDirectory() - for filePath, status of repo.getStatus() - statuses[filePath] = status - - # Statuses in submodules - for submodulePath, submoduleRepo of repo.submodules - submodules[submodulePath] = - branch: submoduleRepo.getHead() - upstream: submoduleRepo.getAheadBehindCount() - - workingDirectoryPath = submoduleRepo.getWorkingDirectory() - for filePath, status of submoduleRepo.getStatus() - absolutePath = path.join(workingDirectoryPath, filePath) - # Make path relative to parent repository - relativePath = repo.relativize(absolutePath) - statuses[relativePath] = status - - upstream = repo.getAheadBehindCount() - branch = repo.getHead() - repo.release() - - {statuses, upstream, branch, submodules} diff --git a/src/ripgrep-directory-searcher.js b/src/ripgrep-directory-searcher.js new file mode 100644 index 00000000000..55bfe4e1d04 --- /dev/null +++ b/src/ripgrep-directory-searcher.js @@ -0,0 +1,426 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +// `ripgrep` and `scandal` have a different way of handling the trailing and leading +// context lines: +// * `scandal` returns all the context lines that are requested, even if they include +// previous or future results. +// * `ripgrep` is a bit smarter and only returns the context lines that do not correspond +// to any result (in a similar way that is shown in the find and replace UI). +// +// For example, if we have the following file and we request to leading context lines: +// +// line 1 +// line 2 +// result 1 +// result 2 +// line 3 +// line 4 +// +// `scandal` will return two results: +// * First result with `['line 1', line 2']` as leading context. +// * Second result with `['line 2', result 1']` as leading context. +// `ripgrep` on the other hand will return a JS object that is more similar to the way that +// the results are shown: +// [ +// {type: 'begin', ...}, +// {type: 'context', ...}, // context for line 1 +// {type: 'context', ...}, // context for line 2 +// {type: 'match', ...}, // result 1 +// {type: 'match', ...}, // result 2 +// {type: 'end', ...}, +// ] +// +// In order to keep backwards compatibility, and avoid doing changes to the find and replace logic, +// for `ripgrep` we need to keep some state with the context lines (and matches) to be able to build +// a data structure that has the same behaviour as the `scandal` one. +// +// We use the `pendingLeadingContext` array to generate the leading context. This array gets mutated +// to always contain the leading `n` lines and is cloned every time a match is found. It's currently +// implemented as a standard array but we can easily change it to use a linked list if we find that +// the shift operations are slow. +// +// We use the `pendingTrailingContexts` Set to generate the trailing context. Since the trailing +// context needs to be generated after receiving a match, we keep all trailing context arrays that +// haven't been fulfilled in this Set, and mutate them adding new lines until they are fulfilled. + +function updateLeadingContext(message, pendingLeadingContext, options) { + if (message.type !== 'match' && message.type !== 'context') { + return; + } + + if (options.leadingContextLineCount) { + pendingLeadingContext.push(cleanResultLine(message.data.lines)); + + if (pendingLeadingContext.length > options.leadingContextLineCount) { + pendingLeadingContext.shift(); + } + } +} + +function updateTrailingContexts(message, pendingTrailingContexts, options) { + if (message.type !== 'match' && message.type !== 'context') { + return; + } + + if (options.trailingContextLineCount) { + for (const trailingContextLines of pendingTrailingContexts) { + trailingContextLines.push(cleanResultLine(message.data.lines)); + + if (trailingContextLines.length === options.trailingContextLineCount) { + pendingTrailingContexts.delete(trailingContextLines); + } + } + } +} + +function cleanResultLine(resultLine) { + resultLine = getText(resultLine); + + return resultLine[resultLine.length - 1] === '\n' + ? resultLine.slice(0, -1) + : resultLine; +} + +function getPositionFromColumn(lines, column) { + let currentLength = 0; + let currentLine = 0; + let previousLength = 0; + + while (column >= currentLength) { + previousLength = currentLength; + currentLength += lines[currentLine].length + 1; + currentLine++; + } + + return [currentLine - 1, column - previousLength]; +} + +function processUnicodeMatch(match) { + const text = getText(match.lines); + + if (text.length === Buffer.byteLength(text)) { + // fast codepath for lines that only contain characters of 1 byte length. + return; + } + + let remainingBuffer = Buffer.from(text); + let currentLength = 0; + let previousPosition = 0; + + function convertPosition(position) { + const currentBuffer = remainingBuffer.slice(0, position - previousPosition); + currentLength = currentBuffer.toString().length + currentLength; + remainingBuffer = remainingBuffer.slice(position); + + previousPosition = position; + + return currentLength; + } + + // Iterate over all the submatches to find the convert the start and end values + // (which come as bytes from ripgrep) to character positions. + // We can do this because submatches come ordered by position. + for (const submatch of match.submatches) { + submatch.start = convertPosition(submatch.start); + submatch.end = convertPosition(submatch.end); + } +} + +// This function processes a ripgrep submatch to create the correct +// range. This is mostly needed for multi-line results, since the range +// will have different start and end rows and we need to calculate these +// based on the lines that ripgrep returns. +function processSubmatch(submatch, lineText, offsetRow) { + const lineParts = lineText.split('\n'); + + const start = getPositionFromColumn(lineParts, submatch.start); + const end = getPositionFromColumn(lineParts, submatch.end); + + // Make sure that the lineText string only contains lines that are + // relevant to this submatch. This means getting rid of lines above + // the start row and below the end row. + for (let i = start[0]; i > 0; i--) { + lineParts.shift(); + } + while (end[0] < lineParts.length - 1) { + lineParts.pop(); + } + + start[0] += offsetRow; + end[0] += offsetRow; + + return { + range: [start, end], + lineText: cleanResultLine({ text: lineParts.join('\n') }) + }; +} + +function getText(input) { + return 'text' in input + ? input.text + : Buffer.from(input.bytes, 'base64').toString(); +} + +module.exports = class RipgrepDirectorySearcher { + canSearchDirectory() { + return true; + } + + // Performs a text search for files in the specified `Directory`s, subject to the + // specified parameters. + // + // Results are streamed back to the caller by invoking methods on the specified `options`, + // such as `didMatch` and `didError`. + // + // * `directories` {Array} of {Directory} objects to search, all of which have been accepted by + // this searcher's `canSearchDirectory()` predicate. + // * `regex` {RegExp} to search with. + // * `options` {Object} with the following properties: + // * `didMatch` {Function} call with a search result structured as follows: + // * `searchResult` {Object} with the following keys: + // * `filePath` {String} absolute path to the matching file. + // * `matches` {Array} with object elements with the following keys: + // * `lineText` {String} The full text of the matching line (without a line terminator character). + // * `lineTextOffset` {Number} Always 0, present for backwards compatibility + // * `matchText` {String} The text that matched the `regex` used for the search. + // * `range` {Range} Identifies the matching region in the file. (Likely as an array of numeric arrays.) + // * `didError` {Function} call with an Error if there is a problem during the search. + // * `didSearchPaths` {Function} periodically call with the number of paths searched that contain results thus far. + // * `inclusions` {Array} of glob patterns (as strings) to search within. Note that this + // array may be empty, indicating that all files should be searched. + // + // Each item in the array is a file/directory pattern, e.g., `src` to search in the "src" + // directory or `*.js` to search all JavaScript files. In practice, this often comes from the + // comma-delimited list of patterns in the bottom text input of the ProjectFindView dialog. + // * `includeHidden` {boolean} whether to ignore hidden files. + // * `excludeVcsIgnores` {boolean} whether to exclude VCS ignored paths. + // * `exclusions` {Array} similar to inclusions + // * `follow` {boolean} whether symlinks should be followed. + // + // Returns a *thenable* `DirectorySearch` that includes a `cancel()` method. If `cancel()` is + // invoked before the `DirectorySearch` is determined, it will resolve the `DirectorySearch`. + search(directories, regexp, options) { + const numPathsFound = { num: 0 }; + + const allPromises = directories.map(directory => + this.searchInDirectory(directory, regexp, options, numPathsFound) + ); + + const promise = Promise.all(allPromises); + + promise.cancel = () => { + for (const promise of allPromises) { + promise.cancel(); + } + }; + + return promise; + } + + searchInDirectory(directory, regexp, options, numPathsFound) { + // Delay the require of vscode-ripgrep to not mess with the snapshot creation. + if (!this.rgPath) { + this.rgPath = require('vscode-ripgrep').rgPath.replace( + /\bapp\.asar\b/, + 'app.asar.unpacked' + ); + } + + const directoryPath = directory.getPath(); + const regexpStr = this.prepareRegexp(regexp.source); + + const args = ['--json', '--regexp', regexpStr]; + if (options.leadingContextLineCount) { + args.push('--before-context', options.leadingContextLineCount); + } + if (options.trailingContextLineCount) { + args.push('--after-context', options.trailingContextLineCount); + } + if (regexp.ignoreCase) { + args.push('--ignore-case'); + } + for (const inclusion of this.prepareGlobs( + options.inclusions, + directoryPath + )) { + args.push('--glob', inclusion); + } + for (const exclusion of this.prepareGlobs( + options.exclusions, + directoryPath + )) { + args.push('--glob', '!' + exclusion); + } + + if (this.isMultilineRegexp(regexpStr)) { + args.push('--multiline'); + } + + if (options.includeHidden) { + args.push('--hidden'); + } + + if (options.follow) { + args.push('--follow'); + } + + if (!options.excludeVcsIgnores) { + args.push('--no-ignore-vcs'); + } + + if (options.PCRE2) { + args.push('--pcre2'); + } + + args.push('.'); + + const child = spawn(this.rgPath, args, { + cwd: directoryPath, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const didMatch = options.didMatch || (() => {}); + let cancelled = false; + + const returnedPromise = new Promise((resolve, reject) => { + let buffer = ''; + let bufferError = ''; + let pendingEvent; + let pendingLeadingContext; + let pendingTrailingContexts; + + child.on('close', (code, signal) => { + // code 1 is used when no results are found. + if (code !== null && code > 1) { + reject(new Error(bufferError)); + } else { + resolve(); + } + }); + + child.stderr.on('data', chunk => { + bufferError += chunk; + }); + + child.stdout.on('data', chunk => { + if (cancelled) { + return; + } + + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + const message = JSON.parse(line); + updateTrailingContexts(message, pendingTrailingContexts, options); + + if (message.type === 'begin') { + pendingEvent = { + filePath: path.join(directoryPath, getText(message.data.path)), + matches: [] + }; + pendingLeadingContext = []; + pendingTrailingContexts = new Set(); + } else if (message.type === 'match') { + const trailingContextLines = []; + pendingTrailingContexts.add(trailingContextLines); + + processUnicodeMatch(message.data); + + for (const submatch of message.data.submatches) { + const { lineText, range } = processSubmatch( + submatch, + getText(message.data.lines), + message.data.line_number - 1 + ); + + pendingEvent.matches.push({ + matchText: getText(submatch.match), + lineText, + lineTextOffset: 0, + range, + leadingContextLines: [...pendingLeadingContext], + trailingContextLines + }); + } + } else if (message.type === 'end') { + options.didSearchPaths(++numPathsFound.num); + didMatch(pendingEvent); + pendingEvent = null; + } + + updateLeadingContext(message, pendingLeadingContext, options); + } + }); + }); + + returnedPromise.cancel = () => { + child.kill(); + cancelled = true; + }; + + return returnedPromise; + } + + // We need to prepare the "globs" that we receive from the user to make their behaviour more + // user-friendly (e.g when adding `src/` the user probably means `src/**/*`). + // This helper function takes care of that. + prepareGlobs(globs, projectRootPath) { + const output = []; + + for (let pattern of globs) { + // we need to replace path separators by slashes since globs should + // always use always slashes as path separators. + pattern = pattern.replace(new RegExp(`\\${path.sep}`, 'g'), '/'); + + if (pattern.length === 0) { + continue; + } + + const projectName = path.basename(projectRootPath); + + // The user can just search inside one of the opened projects. When we detect + // this scenario we just consider the glob to include every file. + if (pattern === projectName) { + output.push('**/*'); + continue; + } + + if (pattern.startsWith(projectName + '/')) { + pattern = pattern.slice(projectName.length + 1); + } + + if (pattern.endsWith('/')) { + pattern = pattern.slice(0, -1); + } + + output.push(pattern); + output.push(pattern.endsWith('/**') ? pattern : `${pattern}/**`); + } + + return output; + } + + prepareRegexp(regexpStr) { + // ripgrep handles `--` as the arguments separator, so we need to escape it if the + // user searches for that exact same string. + if (regexpStr === '--') { + return '\\-\\-'; + } + + // ripgrep is quite picky about unnecessarily escaped sequences, so we need to unescape + // them: https://github.com/BurntSushi/ripgrep/issues/434. + regexpStr = regexpStr.replace(/\\\//g, '/'); + + return regexpStr; + } + + isMultilineRegexp(regexpStr) { + if (regexpStr.includes('\\n')) { + return true; + } + + return false; + } +}; diff --git a/src/row-map.coffee b/src/row-map.coffee deleted file mode 100644 index 5510c142114..00000000000 --- a/src/row-map.coffee +++ /dev/null @@ -1,120 +0,0 @@ -{spliceWithArray} = require 'underscore-plus' - -# Used by the display buffer to map screen rows to buffer rows and vice-versa. -# This mapping may not be 1:1 due to folds and soft-wraps. This object maintains -# an array of regions, which contain `bufferRows` and `screenRows` fields. -# -# Rectangular Regions: -# If a region has the same number of buffer rows and screen rows, it is referred -# to as "rectangular", and represents one or more non-soft-wrapped, non-folded -# lines. -# -# Trapezoidal Regions: -# If a region has one buffer row and more than one screen row, it represents a -# soft-wrapped line. If a region has one screen row and more than one buffer -# row, it represents folded lines -module.exports = -class RowMap - constructor: -> - @regions = [] - - # Public: Returns a copy of all the regions in the map - getRegions: -> - @regions.slice() - - # Public: Returns an end-row-exclusive range of screen rows corresponding to - # the given buffer row. If the buffer row is soft-wrapped, the range may span - # multiple screen rows. Otherwise it will span a single screen row. - screenRowRangeForBufferRow: (targetBufferRow) -> - {region, bufferRows, screenRows} = @traverseToBufferRow(targetBufferRow) - - if region? and region.bufferRows isnt region.screenRows - [screenRows, screenRows + region.screenRows] - else - screenRows += targetBufferRow - bufferRows - [screenRows, screenRows + 1] - - # Public: Returns an end-row-exclusive range of buffer rows corresponding to - # the given screen row. If the screen row is the first line of a folded range - # of buffer rows, the range may span multiple buffer rows. Otherwise it will - # span a single buffer row. - bufferRowRangeForScreenRow: (targetScreenRow) -> - {region, screenRows, bufferRows} = @traverseToScreenRow(targetScreenRow) - if region? and region.bufferRows isnt region.screenRows - [bufferRows, bufferRows + region.bufferRows] - else - bufferRows += targetScreenRow - screenRows - [bufferRows, bufferRows + 1] - - # Public: If the given buffer row is part of a folded row range, returns that - # row range. Otherwise returns a range spanning only the given buffer row. - bufferRowRangeForBufferRow: (targetBufferRow) -> - {region, bufferRows} = @traverseToBufferRow(targetBufferRow) - if region? and region.bufferRows isnt region.screenRows - [bufferRows, bufferRows + region.bufferRows] - else - [targetBufferRow, targetBufferRow + 1] - - # Public: Given a starting buffer row, the number of buffer rows to replace, - # and an array of regions of shape {bufferRows: n, screenRows: m}, splices - # the regions at the appropriate location in the map. This method is used by - # display buffer to keep the map updated when the underlying buffer changes. - spliceRegions: (startBufferRow, bufferRowCount, regions) -> - endBufferRow = startBufferRow + bufferRowCount - {index, bufferRows} = @traverseToBufferRow(startBufferRow) - precedingRows = startBufferRow - bufferRows - - count = 0 - while region = @regions[index + count] - count++ - bufferRows += region.bufferRows - if bufferRows >= endBufferRow - followingRows = bufferRows - endBufferRow - break - - if precedingRows > 0 - regions.unshift({bufferRows: precedingRows, screenRows: precedingRows}) - - if followingRows > 0 - regions.push({bufferRows: followingRows, screenRows: followingRows}) - - spliceWithArray(@regions, index, count, regions) - @mergeAdjacentRectangularRegions(index - 1, index + regions.length) - - traverseToBufferRow: (targetBufferRow) -> - bufferRows = 0 - screenRows = 0 - for region, index in @regions - if (bufferRows + region.bufferRows) > targetBufferRow - return {region, index, screenRows, bufferRows} - bufferRows += region.bufferRows - screenRows += region.screenRows - {index, screenRows, bufferRows} - - traverseToScreenRow: (targetScreenRow) -> - bufferRows = 0 - screenRows = 0 - for region, index in @regions - if (screenRows + region.screenRows) > targetScreenRow - return {region, index, screenRows, bufferRows} - bufferRows += region.bufferRows - screenRows += region.screenRows - {index, screenRows, bufferRows} - - mergeAdjacentRectangularRegions: (startIndex, endIndex) -> - for index in [endIndex..startIndex] - if 0 < index < @regions.length - leftRegion = @regions[index - 1] - rightRegion = @regions[index] - leftIsRectangular = leftRegion.bufferRows is leftRegion.screenRows - rightIsRectangular = rightRegion.bufferRows is rightRegion.screenRows - if leftIsRectangular and rightIsRectangular - @regions.splice index - 1, 2, - bufferRows: leftRegion.bufferRows + rightRegion.bufferRows - screenRows: leftRegion.screenRows + rightRegion.screenRows - return - - # Public: Returns an array of strings describing the map's regions. - inspect: -> - for {bufferRows, screenRows} in @regions - "#{bufferRows}:#{screenRows}" diff --git a/src/safe-clipboard.coffee b/src/safe-clipboard.coffee deleted file mode 100644 index 8301f9d54e2..00000000000 --- a/src/safe-clipboard.coffee +++ /dev/null @@ -1,6 +0,0 @@ -# Using clipboard in renderer process is not safe on Linux. -module.exports = - if process.platform is 'linux' and process.type is 'renderer' - require('remote').require('clipboard') - else - require('clipboard') diff --git a/src/scan-handler.coffee b/src/scan-handler.coffee index 74e15d930a2..db2e8299b35 100644 --- a/src/scan-handler.coffee +++ b/src/scan-handler.coffee @@ -1,17 +1,14 @@ -_ = require "underscore-plus" path = require "path" async = require "async" {PathSearcher, PathScanner, search} = require 'scandal' -module.exports = (rootPaths, regexSource, options) -> +module.exports = (rootPaths, regexSource, options, searchOptions={}) -> callback = @async() - rootPath = rootPaths[0] - PATHS_COUNTER_SEARCHED_CHUNK = 50 pathsSearched = 0 - searcher = new PathSearcher() + searcher = new PathSearcher(searchOptions) searcher.on 'file-error', ({code, path, message}) -> emit('scan:file-error', {code, path, message}) @@ -26,9 +23,9 @@ module.exports = (rootPaths, regexSource, options) -> async.each( rootPaths, (rootPath, next) -> - options2 = _.extend {}, options, + options2 = Object.assign {}, options, inclusions: processPaths(rootPath, options.inclusions) - exclusions: processPaths(rootPath, options.exclusions) + globalExclusions: processPaths(rootPath, options.globalExclusions) scanner = new PathScanner(rootPath, options2) diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee deleted file mode 100644 index 7940cc630ac..00000000000 --- a/src/scope-descriptor.coffee +++ /dev/null @@ -1,49 +0,0 @@ -# Extended: Wraps an {Array} of `String`s. The Array describes a path from the -# 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} -# scope names e.g. `['.source.js']`. -# -# You can use `ScopeDescriptor`s to get language-specific config settings via -# {Config::get}. -# -# You should not need to create a `ScopeDescriptor` directly. -# -# * {Editor::getRootScopeDescriptor} to get the language's descriptor. -# * {Editor::scopeDescriptorForBufferPosition} to get the descriptor at a -# specific position in the buffer. -# * {Cursor::getScopeDescriptor} to get a cursor's descriptor based on position. -# -# See the [scopes and scope descriptor guide](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) -# for more information. -module.exports = -class ScopeDescriptor - @fromObject: (scopes) -> - if scopes instanceof ScopeDescriptor - scopes - else - new ScopeDescriptor({scopes}) - - ### - Section: Construction and Destruction - ### - - # Public: Create a {ScopeDescriptor} object. - # - # * `object` {Object} - # * `scopes` {Array} of {String}s - constructor: ({@scopes}) -> - - # Public: Returns an {Array} of {String}s - getScopesArray: -> @scopes - - getScopeChain: -> - @scopes - .map (scope) -> - scope = ".#{scope}" unless scope[0] is '.' - scope - .join(' ') - - toString: -> - @getScopeChain() diff --git a/src/scope-descriptor.js b/src/scope-descriptor.js new file mode 100644 index 00000000000..111ce567643 --- /dev/null +++ b/src/scope-descriptor.js @@ -0,0 +1,83 @@ +// Extended: Wraps an {Array} of `String`s. The Array describes a path from the +// 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 {String} +// scope names e.g. `['.source.js']`. +// +// You can use `ScopeDescriptor`s to get language-specific config settings via +// {Config::get}. +// +// You should not need to create a `ScopeDescriptor` directly. +// +// * {TextEditor::getRootScopeDescriptor} to get the language's descriptor. +// * {TextEditor::scopeDescriptorForBufferPosition} to get the descriptor at a +// specific position in the buffer. +// * {Cursor::getScopeDescriptor} to get a cursor's descriptor based on position. +// +// See the [scopes and scope descriptor guide](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/) +// for more information. +module.exports = class ScopeDescriptor { + static fromObject(scopes) { + if (scopes instanceof ScopeDescriptor) { + return scopes; + } else { + return new ScopeDescriptor({ scopes }); + } + } + + /* + Section: Construction and Destruction + */ + + // Public: Create a {ScopeDescriptor} object. + // + // * `object` {Object} + // * `scopes` {Array} of {String}s + constructor({ scopes }) { + this.scopes = scopes; + } + + // Public: Returns an {Array} of {String}s + getScopesArray() { + return this.scopes; + } + + getScopeChain() { + // For backward compatibility, prefix TextMate-style scope names with + // leading dots (e.g. 'source.js' -> '.source.js'). + if (this.scopes[0] != null && this.scopes[0].includes('.')) { + let result = ''; + for (let i = 0; i < this.scopes.length; i++) { + const scope = this.scopes[i]; + if (i > 0) { + result += ' '; + } + if (scope[0] !== '.') { + result += '.'; + } + result += scope; + } + return result; + } else { + return this.scopes.join(' '); + } + } + + toString() { + return this.getScopeChain(); + } + + isEqual(other) { + if (this.scopes.length !== other.scopes.length) { + return false; + } + for (let i = 0; i < this.scopes.length; i++) { + const scope = this.scopes[i]; + if (scope !== other.scopes[i]) { + return false; + } + } + return true; + } +}; diff --git a/src/scoped-properties.coffee b/src/scoped-properties.coffee deleted file mode 100644 index 8bf4c5ed371..00000000000 --- a/src/scoped-properties.coffee +++ /dev/null @@ -1,23 +0,0 @@ -CSON = require 'season' -{CompositeDisposable} = require 'event-kit' - -module.exports = -class ScopedProperties - @load: (scopedPropertiesPath, callback) -> - CSON.readFile scopedPropertiesPath, (error, scopedProperties={}) -> - if error? - callback(error) - else - callback(null, new ScopedProperties(scopedPropertiesPath, scopedProperties)) - - constructor: (@path, @scopedProperties) -> - - activate: -> - for selector, properties of @scopedProperties - atom.config.set(null, properties, scopeSelector: selector, source: @path) - return - - deactivate: -> - for selector of @scopedProperties - atom.config.unset(null, scopeSelector: selector, source: @path) - return diff --git a/src/scroll-view.coffee b/src/scroll-view.coffee deleted file mode 100644 index 86743be4c3a..00000000000 --- a/src/scroll-view.coffee +++ /dev/null @@ -1,38 +0,0 @@ -{View} = require './space-pen-extensions' - -# Deprecated: Represents a view that scrolls. -# -# Handles several core events to update scroll position: -# -# * `core:move-up` Scrolls the view up -# * `core:move-down` Scrolls the view down -# * `core:page-up` Scrolls the view up by the height of the page -# * `core:page-down` Scrolls the view down by the height of the page -# * `core:move-to-top` Scrolls the editor to the top -# * `core:move-to-bottom` Scroll the editor to the bottom -# -# Subclasses must call `super` if overriding the `initialize` method. -# -# ## Examples -# -# ```coffee -# {ScrollView} = require 'atom' -# -# class MyView extends ScrollView -# @content: -> -# @div() -# -# initialize: -> -# super -# @text('super long content that will scroll') -# ``` -# -module.exports = -class ScrollView extends View - initialize: -> - @on 'core:move-up', => @scrollUp() - @on 'core:move-down', => @scrollDown() - @on 'core:page-up', => @pageUp() - @on 'core:page-down', => @pageDown() - @on 'core:move-to-top', => @scrollToTop() - @on 'core:move-to-bottom', => @scrollToBottom() diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee deleted file mode 100644 index 45cfbd24031..00000000000 --- a/src/scrollbar-component.coffee +++ /dev/null @@ -1,75 +0,0 @@ -module.exports = -class ScrollbarComponent - constructor: ({@orientation, @onScroll}) -> - @domNode = document.createElement('div') - @domNode.classList.add "#{@orientation}-scrollbar" - @domNode.style['-webkit-transform'] = 'translateZ(0)' # See atom/atom#3559 - @domNode.style.left = 0 if @orientation is 'horizontal' - - @contentNode = document.createElement('div') - @contentNode.classList.add "scrollbar-content" - @domNode.appendChild(@contentNode) - - @domNode.addEventListener 'scroll', @onScrollCallback - - getDomNode: -> - @domNode - - updateSync: (state) -> - @oldState ?= {} - switch @orientation - when 'vertical' - @newState = state.verticalScrollbar - @updateVertical() - when 'horizontal' - @newState = state.horizontalScrollbar - @updateHorizontal() - - if @newState.visible isnt @oldState.visible - if @newState.visible - @domNode.style.display = '' - else - @domNode.style.display = 'none' - @oldState.visible = @newState.visible - - updateVertical: -> - if @newState.width isnt @oldState.width - @domNode.style.width = @newState.width + 'px' - @oldState.width = @newState.width - - if @newState.bottom isnt @oldState.bottom - @domNode.style.bottom = @newState.bottom + 'px' - @oldState.bottom = @newState.bottom - - if @newState.scrollHeight isnt @oldState.scrollHeight - @contentNode.style.height = @newState.scrollHeight + 'px' - @oldState.scrollHeight = @newState.scrollHeight - - if @newState.scrollTop isnt @oldState.scrollTop - @domNode.scrollTop = @newState.scrollTop - @oldState.scrollTop = @newState.scrollTop - - updateHorizontal: -> - if @newState.height isnt @oldState.height - @domNode.style.height = @newState.height + 'px' - @oldState.height = @newState.height - - if @newState.right isnt @oldState.right - @domNode.style.right = @newState.right + 'px' - @oldState.right = @newState.right - - if @newState.scrollWidth isnt @oldState.scrollWidth - @contentNode.style.width = @newState.scrollWidth + 'px' - @oldState.scrollWidth = @newState.scrollWidth - - if @newState.scrollLeft isnt @oldState.scrollLeft - @domNode.scrollLeft = @newState.scrollLeft - @oldState.scrollLeft = @newState.scrollLeft - - - onScrollCallback: => - switch @orientation - when 'vertical' - @onScroll(@domNode.scrollTop) - when 'horizontal' - @onScroll(@domNode.scrollLeft) diff --git a/src/scrollbar-corner-component.coffee b/src/scrollbar-corner-component.coffee deleted file mode 100644 index bc059f12cd7..00000000000 --- a/src/scrollbar-corner-component.coffee +++ /dev/null @@ -1,38 +0,0 @@ -module.exports = -class ScrollbarCornerComponent - constructor: -> - @domNode = document.createElement('div') - @domNode.classList.add('scrollbar-corner') - - @contentNode = document.createElement('div') - @domNode.appendChild(@contentNode) - - getDomNode: -> - @domNode - - updateSync: (state) -> - @oldState ?= {} - @newState ?= {} - - newHorizontalState = state.horizontalScrollbar - newVerticalState = state.verticalScrollbar - @newState.visible = newHorizontalState.visible and newVerticalState.visible - @newState.height = newHorizontalState.height - @newState.width = newVerticalState.width - - if @newState.visible isnt @oldState.visible - if @newState.visible - @domNode.style.display = '' - else - @domNode.style.display = 'none' - @oldState.visible = @newState.visible - - if @newState.height isnt @oldState.height - @domNode.style.height = @newState.height + 'px' - @contentNode.style.height = @newState.height + 1 + 'px' - @oldState.height = @newState.height - - if @newState.width isnt @oldState.width - @domNode.style.width = @newState.width + 'px' - @contentNode.style.width = @newState.width + 1 + 'px' - @oldState.width = @newState.width diff --git a/src/select-list-view.coffee b/src/select-list-view.coffee deleted file mode 100644 index 046be1a3d51..00000000000 --- a/src/select-list-view.coffee +++ /dev/null @@ -1,312 +0,0 @@ -{$, View} = require './space-pen-extensions' -TextEditorView = require './text-editor-view' -fuzzyFilter = require('fuzzaldrin').filter - -# Deprecated: Provides a view that renders a list of items with an editor that -# filters the items. Used by many packages such as the fuzzy-finder, -# command-palette, symbols-view and autocomplete. -# -# Subclasses must implement the following methods: -# -# * {::viewForItem} -# * {::confirmed} -# -# ## Requiring in packages -# -# ```coffee -# {SelectListView} = require 'atom' -# -# class MySelectListView extends SelectListView -# initialize: -> -# super -# @addClass('overlay from-top') -# @setItems(['Hello', 'World']) -# atom.workspaceView.append(this) -# @focusFilterEditor() -# -# viewForItem: (item) -> -# "
  • #{item}
  • " -# -# confirmed: (item) -> -# console.log("#{item} was selected") -# ``` -module.exports = -class SelectListView extends View - @content: -> - @div class: 'select-list', => - @subview 'filterEditorView', new TextEditorView(mini: true) - @div class: 'error-message', outlet: 'error' - @div class: 'loading', outlet: 'loadingArea', => - @span class: 'loading-message', outlet: 'loading' - @span class: 'badge', outlet: 'loadingBadge' - @ol class: 'list-group', outlet: 'list' - - maxItems: Infinity - scheduleTimeout: null - inputThrottle: 50 - cancelling: false - - ### - Section: Construction - ### - - # Essential: Initialize the select list view. - # - # This method can be overridden by subclasses but `super` should always - # be called. - initialize: -> - @filterEditorView.getEditor().getBuffer().onDidChange => - @schedulePopulateList() - @filterEditorView.on 'blur', => - @cancel() unless @cancelling - - # This prevents the focusout event from firing on the filter editor view - # when the list is scrolled by clicking the scrollbar and dragging. - @list.on 'mousedown', ({target}) => - false if target is @list[0] - - @on 'core:move-up', => - @selectPreviousItemView() - @on 'core:move-down', => - @selectNextItemView() - @on 'core:move-to-top', => - @selectItemView(@list.find('li:first')) - @list.scrollToTop() - false - @on 'core:move-to-bottom', => - @selectItemView(@list.find('li:last')) - @list.scrollToBottom() - false - - @on 'core:confirm', => @confirmSelection() - @on 'core:cancel', => @cancel() - - @list.on 'mousedown', 'li', (e) => - @selectItemView($(e.target).closest('li')) - e.preventDefault() - - @list.on 'mouseup', 'li', (e) => - @confirmSelection() if $(e.target).closest('li').hasClass('selected') - e.preventDefault() - - ### - Section: Methods that must be overridden - ### - - # Essential: Create a view for the given model item. - # - # This method must be overridden by subclasses. - # - # This is called when the item is about to appended to the list view. - # - # * `item` The model item being rendered. This will always be one of the items - # previously passed to {::setItems}. - # - # Returns a String of HTML, DOM element, jQuery object, or View. - viewForItem: (item) -> - throw new Error("Subclass must implement a viewForItem(item) method") - - # Essential: Callback function for when an item is selected. - # - # This method must be overridden by subclasses. - # - # * `item` The selected model item. This will always be one of the items - # previously passed to {::setItems}. - # - # Returns a DOM element, jQuery object, or {View}. - confirmed: (item) -> - throw new Error("Subclass must implement a confirmed(item) method") - - ### - Section: Managing the list of items - ### - - # Essential: Set the array of items to display in the list. - # - # This should be model items not actual views. {::viewForItem} will be - # called to render the item when it is being appended to the list view. - # - # * `items` The {Array} of model items to display in the list (default: []). - setItems: (@items=[]) -> - @populateList() - @setLoading() - - # Essential: Get the model item that is currently selected in the list view. - # - # Returns a model item. - getSelectedItem: -> - @getSelectedItemView().data('select-list-item') - - # Extended: Get the property name to use when filtering items. - # - # This method may be overridden by classes to allow fuzzy filtering based - # on a specific property of the item objects. - # - # For example if the objects you pass to {::setItems} are of the type - # `{"id": 3, "name": "Atom"}` then you would return `"name"` from this method - # to fuzzy filter by that property when text is entered into this view's - # editor. - # - # Returns the property name to fuzzy filter by. - getFilterKey: -> - - # Extended: Get the filter query to use when fuzzy filtering the visible - # elements. - # - # By default this method returns the text in the mini editor but it can be - # overridden by subclasses if needed. - # - # Returns a {String} to use when fuzzy filtering the elements to display. - getFilterQuery: -> - @filterEditorView.getEditor().getText() - - # Extended: Set the maximum numbers of items to display in the list. - # - # * `maxItems` The maximum {Number} of items to display. - setMaxItems: (@maxItems) -> - - # Extended: Populate the list view with the model items previously set by - # calling {::setItems}. - # - # Subclasses may override this method but should always call `super`. - populateList: -> - return unless @items? - - filterQuery = @getFilterQuery() - if filterQuery.length - filteredItems = fuzzyFilter(@items, filterQuery, key: @getFilterKey()) - else - filteredItems = @items - - @list.empty() - if filteredItems.length - @setError(null) - - for i in [0...Math.min(filteredItems.length, @maxItems)] - item = filteredItems[i] - itemView = $(@viewForItem(item)) - itemView.data('select-list-item', item) - @list.append(itemView) - - @selectItemView(@list.find('li:first')) - else - @setError(@getEmptyMessage(@items.length, filteredItems.length)) - - ### - Section: Messages to the user - ### - - # Essential: Set the error message to display. - # - # * `message` The {String} error message (default: ''). - setError: (message='') -> - if message.length is 0 - @error.text('').hide() - else - @setLoading() - @error.text(message).show() - - # Essential: Set the loading message to display. - # - # * `message` The {String} loading message (default: ''). - setLoading: (message='') -> - if message.length is 0 - @loading.text("") - @loadingBadge.text("") - @loadingArea.hide() - else - @setError() - @loading.text(message) - @loadingArea.show() - - # Extended: Get the message to display when there are no items. - # - # Subclasses may override this method to customize the message. - # - # * `itemCount` The {Number} of items in the array specified to {::setItems} - # * `filteredItemCount` The {Number} of items that pass the fuzzy filter test. - # - # Returns a {String} message (default: 'No matches found'). - getEmptyMessage: (itemCount, filteredItemCount) -> 'No matches found' - - ### - Section: View Actions - ### - - # Essential: Cancel and close this select list view. - # - # This restores focus to the previously focused element if - # {::storeFocusedElement} was called prior to this view being attached. - cancel: -> - @list.empty() - @cancelling = true - filterEditorViewFocused = @filterEditorView.isFocused - @cancelled() - @detach() - @restoreFocus() if filterEditorViewFocused - @cancelling = false - clearTimeout(@scheduleTimeout) - - # Extended: Focus the fuzzy filter editor view. - focusFilterEditor: -> - @filterEditorView.focus() - - # Extended: Store the currently focused element. This element will be given - # back focus when {::cancel} is called. - storeFocusedElement: -> - @previouslyFocusedElement = $(document.activeElement) - - ### - Section: Private - ### - - selectPreviousItemView: -> - view = @getSelectedItemView().prev() - view = @list.find('li:last') unless view.length - @selectItemView(view) - - selectNextItemView: -> - view = @getSelectedItemView().next() - view = @list.find('li:first') unless view.length - @selectItemView(view) - - selectItemView: (view) -> - return unless view.length - @list.find('.selected').removeClass('selected') - view.addClass('selected') - @scrollToItemView(view) - - scrollToItemView: (view) -> - scrollTop = @list.scrollTop() - desiredTop = view.position().top + scrollTop - desiredBottom = desiredTop + view.outerHeight() - - if desiredTop < scrollTop - @list.scrollTop(desiredTop) - else if desiredBottom > @list.scrollBottom() - @list.scrollBottom(desiredBottom) - - restoreFocus: -> - if @previouslyFocusedElement?.isOnDom() - @previouslyFocusedElement.focus() - else - atom.workspaceView.focus() - - cancelled: -> - @filterEditorView.getEditor().setText('') - - getSelectedItemView: -> - @list.find('li.selected') - - confirmSelection: -> - item = @getSelectedItem() - if item? - @confirmed(item) - else - @cancel() - - schedulePopulateList: -> - clearTimeout(@scheduleTimeout) - populateCallback = => - @populateList() if @isOnDom() - @scheduleTimeout = setTimeout(populateCallback, @inputThrottle) diff --git a/src/selection.coffee b/src/selection.coffee deleted file mode 100644 index 6ec874203d5..00000000000 --- a/src/selection.coffee +++ /dev/null @@ -1,805 +0,0 @@ -{Point, Range} = require 'text-buffer' -{pick} = _ = require 'underscore-plus' -{Emitter} = require 'event-kit' -Grim = require 'grim' -Model = require './model' - -NonWhitespaceRegExp = /\S/ - -# Extended: Represents a selection in the {TextEditor}. -module.exports = -class Selection extends Model - cursor: null - marker: null - editor: null - initialScreenRange: null - wordwise: false - - constructor: ({@cursor, @marker, @editor, id}) -> - @emitter = new Emitter - - @assignId(id) - @cursor.selection = this - @decoration = @editor.decorateMarker(@marker, type: 'highlight', class: 'selection') - - @marker.onDidChange (e) => @screenRangeChanged(e) - @marker.onDidDestroy => - unless @editor.isDestroyed() - @destroyed = true - @editor.removeSelection(this) - @emit 'destroyed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-destroy' - @emitter.dispose() - - destroy: -> - @marker.destroy() - - isLastSelection: -> - this is @editor.getLastSelection() - - ### - Section: Event Subscription - ### - - # Extended: Calls your `callback` when the selection was moved. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferRange` {Range} - # * `oldScreenRange` {Range} - # * `newBufferRange` {Range} - # * `newScreenRange` {Range} - # * `selection` {Selection} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeRange: (callback) -> - @emitter.on 'did-change-range', callback - - # Extended: Calls your `callback` when the selection was destroyed - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - ### - Section: Managing the selection range - ### - - # Public: Returns the screen {Range} for the selection. - getScreenRange: -> - @marker.getScreenRange() - - # Public: Modifies the screen range for the selection. - # - # * `screenRange` The new {Range} to use. - # * `options` (optional) {Object} options matching those found in {::setBufferRange}. - setScreenRange: (screenRange, options) -> - @setBufferRange(@editor.bufferRangeForScreenRange(screenRange), options) - - # Public: Returns the buffer {Range} for the selection. - getBufferRange: -> - @marker.getBufferRange() - - # Public: Modifies the buffer {Range} for the selection. - # - # * `screenRange` The new {Range} to select. - # * `options` (optional) {Object} with the keys: - # * `preserveFolds` if `true`, the fold settings are preserved after the - # selection moves. - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # range. Defaults to `true` if this is the most recently added selection, - # `false` otherwise. - setBufferRange: (bufferRange, options={}) -> - bufferRange = Range.fromObject(bufferRange) - options.reversed ?= @isReversed() - @editor.destroyFoldsContainingBufferRange(bufferRange) unless options.preserveFolds - @modifySelection => - needsFlash = options.flash - delete options.flash if options.flash? - @marker.setBufferRange(bufferRange, options) - @autoscroll() if options?.autoscroll ? @isLastSelection() - @decoration.flash('flash', @editor.selectionFlashDuration) if needsFlash - - # Public: Returns the starting and ending buffer rows the selection is - # highlighting. - # - # Returns an {Array} of two {Number}s: the starting row, and the ending row. - getBufferRowRange: -> - range = @getBufferRange() - start = range.start.row - end = range.end.row - end = Math.max(start, end - 1) if range.end.column is 0 - [start, end] - - getTailScreenPosition: -> - @marker.getTailScreenPosition() - - getTailBufferPosition: -> - @marker.getTailBufferPosition() - - getHeadScreenPosition: -> - @marker.getHeadScreenPosition() - - getHeadBufferPosition: -> - @marker.getHeadBufferPosition() - - ### - Section: Info about the selection - ### - - # Public: Determines if the selection contains anything. - isEmpty: -> - @getBufferRange().isEmpty() - - # Public: Determines if the ending position of a marker is greater than the - # starting position. - # - # This can happen when, for example, you highlight text "up" in a {TextBuffer}. - isReversed: -> - @marker.isReversed() - - # Public: Returns whether the selection is a single line or not. - isSingleScreenLine: -> - @getScreenRange().isSingleLine() - - # Public: Returns the text in the selection. - getText: -> - @editor.buffer.getTextInRange(@getBufferRange()) - - # Public: Identifies if a selection intersects with a given buffer range. - # - # * `bufferRange` A {Range} to check against. - # - # Returns a {Boolean} - intersectsBufferRange: (bufferRange) -> - @getBufferRange().intersectsWith(bufferRange) - - intersectsScreenRowRange: (startRow, endRow) -> - @getScreenRange().intersectsRowRange(startRow, endRow) - - intersectsScreenRow: (screenRow) -> - @getScreenRange().intersectsRow(screenRow) - - # Public: Identifies if a selection intersects with another selection. - # - # * `otherSelection` A {Selection} to check against. - # - # Returns a {Boolean} - intersectsWith: (otherSelection, exclusive) -> - @getBufferRange().intersectsWith(otherSelection.getBufferRange(), exclusive) - - ### - Section: Modifying the selected range - ### - - # Public: Clears the selection, moving the marker to the head. - # - # * `options` (optional) {Object} with the following keys: - # * `autoscroll` {Boolean} indicating whether to autoscroll to the new - # range. Defaults to `true` if this is the most recently added selection, - # `false` otherwise. - clear: (options) -> - @marker.setProperties(goalScreenRange: null) - @marker.clearTail() unless @retainSelection - @autoscroll() if options?.autoscroll ? @isLastSelection() - @finalize() - - # Public: Selects the text from the current cursor position to a given screen - # position. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToScreenPosition: (position) -> - position = Point.fromObject(position) - - @modifySelection => - if @initialScreenRange - if position.isLessThan(@initialScreenRange.start) - @marker.setScreenRange([position, @initialScreenRange.end], reversed: true) - else - @marker.setScreenRange([@initialScreenRange.start, position]) - else - @cursor.setScreenPosition(position) - - if @linewise - @expandOverLine() - else if @wordwise - @expandOverWord() - - # Public: Selects the text from the current cursor position to a given buffer - # position. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToBufferPosition: (position) -> - @modifySelection => @cursor.setBufferPosition(position) - - # Public: Selects the text one position right of the cursor. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - selectRight: (columnCount) -> - @modifySelection => @cursor.moveRight(columnCount) - - # Public: Selects the text one position left of the cursor. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - selectLeft: (columnCount) -> - @modifySelection => @cursor.moveLeft(columnCount) - - # Public: Selects all the text one position above the cursor. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - selectUp: (rowCount) -> - @modifySelection => @cursor.moveUp(rowCount) - - # Public: Selects all the text one position below the cursor. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - selectDown: (rowCount) -> - @modifySelection => @cursor.moveDown(rowCount) - - # Public: Selects all the text from the current cursor position to the top of - # the buffer. - selectToTop: -> - @modifySelection => @cursor.moveToTop() - - # Public: Selects all the text from the current cursor position to the bottom - # of the buffer. - selectToBottom: -> - @modifySelection => @cursor.moveToBottom() - - # Public: Selects all the text in the buffer. - selectAll: -> - @setBufferRange(@editor.buffer.getRange(), autoscroll: false) - - # Public: Selects all the text from the current cursor position to the - # beginning of the line. - selectToBeginningOfLine: -> - @modifySelection => @cursor.moveToBeginningOfLine() - - # Public: Selects all the text from the current cursor position to the first - # character of the line. - selectToFirstCharacterOfLine: -> - @modifySelection => @cursor.moveToFirstCharacterOfLine() - - # Public: Selects all the text from the current cursor position to the end of - # the line. - selectToEndOfLine: -> - @modifySelection => @cursor.moveToEndOfScreenLine() - - # Public: Selects all the text from the current cursor position to the - # beginning of the word. - selectToBeginningOfWord: -> - @modifySelection => @cursor.moveToBeginningOfWord() - - # Public: Selects all the text from the current cursor position to the end of - # the word. - selectToEndOfWord: -> - @modifySelection => @cursor.moveToEndOfWord() - - # Public: Selects all the text from the current cursor position to the - # beginning of the next word. - selectToBeginningOfNextWord: -> - @modifySelection => @cursor.moveToBeginningOfNextWord() - - # Public: Selects text to the previous word boundary. - selectToPreviousWordBoundary: -> - @modifySelection => @cursor.moveToPreviousWordBoundary() - - # Public: Selects text to the next word boundary. - selectToNextWordBoundary: -> - @modifySelection => @cursor.moveToNextWordBoundary() - - # Public: Selects all the text from the current cursor position to the - # beginning of the next paragraph. - selectToBeginningOfNextParagraph: -> - @modifySelection => @cursor.moveToBeginningOfNextParagraph() - - # Public: Selects all the text from the current cursor position to the - # beginning of the previous paragraph. - selectToBeginningOfPreviousParagraph: -> - @modifySelection => @cursor.moveToBeginningOfPreviousParagraph() - - # Public: Modifies the selection to encompass the current word. - # - # Returns a {Range}. - selectWord: -> - options = {} - options.wordRegex = /[\t ]*/ if @cursor.isSurroundedByWhitespace() - if @cursor.isBetweenWordAndNonWord() - options.includeNonWordCharacters = false - - @setBufferRange(@cursor.getCurrentWordBufferRange(options)) - @wordwise = true - @initialScreenRange = @getScreenRange() - - # Public: Expands the newest selection to include the entire word on which - # the cursors rests. - expandOverWord: -> - @setBufferRange(@getBufferRange().union(@cursor.getCurrentWordBufferRange())) - - # Public: Selects an entire line in the buffer. - # - # * `row` The line {Number} to select (default: the row of the cursor). - selectLine: (row=@cursor.getBufferPosition().row) -> - range = @editor.bufferRangeForBufferRow(row, includeNewline: true) - @setBufferRange(@getBufferRange().union(range), autoscroll: true) - @linewise = true - @wordwise = false - @initialScreenRange = @getScreenRange() - - # Public: Expands the newest selection to include the entire line on which - # the cursor currently rests. - # - # It also includes the newline character. - expandOverLine: -> - range = @getBufferRange().union(@cursor.getCurrentLineBufferRange(includeNewline: true)) - @setBufferRange(range) - - ### - Section: Modifying the selected text - ### - - # Public: Replaces text at the current selection. - # - # * `text` A {String} representing the text to add - # * `options` (optional) {Object} with keys: - # * `select` if `true`, selects the newly added text. - # * `autoIndent` if `true`, indents all inserted text appropriately. - # * `autoIndentNewline` if `true`, indent newline appropriately. - # * `autoDecreaseIndent` if `true`, decreases indent level appropriately - # (for example, when a closing bracket is inserted). - # * `normalizeLineEndings` (optional) {Boolean} (default: true) - # * `undo` if `skip`, skips the undo stack for this operation. - insertText: (text, options={}) -> - oldBufferRange = @getBufferRange() - @editor.unfoldBufferRow(oldBufferRange.end.row) - wasReversed = @isReversed() - @clear() - - autoIndentFirstLine = false - precedingText = @editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start]) - remainingLines = text.split('\n') - firstInsertedLine = remainingLines.shift() - - if options.indentBasis? - indentAdjustment = @editor.indentLevelForLine(precedingText) - options.indentBasis - @adjustIndent(remainingLines, indentAdjustment) - - if options.autoIndent and not NonWhitespaceRegExp.test(precedingText) - autoIndentFirstLine = true - firstLine = precedingText + firstInsertedLine - desiredIndentLevel = @editor.languageMode.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) - indentAdjustment = desiredIndentLevel - @editor.indentLevelForLine(firstLine) - @adjustIndent(remainingLines, indentAdjustment) - - text = firstInsertedLine - text += '\n' + remainingLines.join('\n') if remainingLines.length > 0 - - newBufferRange = @editor.buffer.setTextInRange(oldBufferRange, text, pick(options, 'undo', 'normalizeLineEndings')) - - if options.select - @setBufferRange(newBufferRange, reversed: wasReversed) - else - @cursor.setBufferPosition(newBufferRange.end, clip: 'forward') if wasReversed - - if autoIndentFirstLine - @editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel) - - if options.autoIndentNewline and text is '\n' - currentIndentation = @editor.indentationForBufferRow(newBufferRange.start.row) - @editor.autoIndentBufferRow(newBufferRange.end.row, preserveLeadingWhitespace: true, skipBlankLines: false) - if @editor.indentationForBufferRow(newBufferRange.end.row) < currentIndentation - @editor.setIndentationForBufferRow(newBufferRange.end.row, currentIndentation) - else if options.autoDecreaseIndent and NonWhitespaceRegExp.test(text) - @editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row) - - @autoscroll() if @isLastSelection() - - newBufferRange - - # Public: Removes the first character before the selection if the selection - # is empty otherwise it deletes the selection. - backspace: -> - @selectLeft() if @isEmpty() and not @editor.isFoldedAtScreenRow(@cursor.getScreenRow()) - @deleteSelectedText() - - # Public: Removes the selection or, if nothing is selected, then all - # characters from the start of the selection back to the previous word - # boundary. - deleteToPreviousWordBoundary: -> - @selectToPreviousWordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or, if nothing is selected, then all - # characters from the start of the selection up to the next word - # boundary. - deleteToNextWordBoundary: -> - @selectToNextWordBoundary() if @isEmpty() - @deleteSelectedText() - - # Public: Removes from the start of the selection to the beginning of the - # current word if the selection is empty otherwise it deletes the selection. - deleteToBeginningOfWord: -> - @selectToBeginningOfWord() if @isEmpty() - @deleteSelectedText() - - # Public: Removes from the beginning of the line which the selection begins on - # all the way through to the end of the selection. - deleteToBeginningOfLine: -> - if @isEmpty() and @cursor.isAtBeginningOfLine() - @selectLeft() - else - @selectToBeginningOfLine() - @deleteSelectedText() - - # Public: Removes the selection or the next character after the start of the - # selection if the selection is empty. - delete: -> - if @isEmpty() - if @cursor.isAtEndOfLine() and fold = @editor.largestFoldStartingAtScreenRow(@cursor.getScreenRow() + 1) - @selectToBufferPosition(fold.getBufferRange().end) - else - @selectRight() - @deleteSelectedText() - - # Public: If the selection is empty, removes all text from the cursor to the - # end of the line. If the cursor is already at the end of the line, it - # removes the following newline. If the selection isn't empty, only deletes - # the contents of the selection. - deleteToEndOfLine: -> - return @delete() if @isEmpty() and @cursor.isAtEndOfLine() - @selectToEndOfLine() if @isEmpty() - @deleteSelectedText() - - # Public: Removes the selection or all characters from the start of the - # selection to the end of the current word if nothing is selected. - deleteToEndOfWord: -> - @selectToEndOfWord() if @isEmpty() - @deleteSelectedText() - - # Public: Removes only the selected text. - deleteSelectedText: -> - bufferRange = @getBufferRange() - if bufferRange.isEmpty() and fold = @editor.largestFoldContainingBufferRow(bufferRange.start.row) - bufferRange = bufferRange.union(fold.getBufferRange(includeNewline: true)) - @editor.buffer.delete(bufferRange) unless bufferRange.isEmpty() - @cursor?.setBufferPosition(bufferRange.start) - - # Public: Removes the line at the beginning of the selection if the selection - # is empty unless the selection spans multiple lines in which case all lines - # are removed. - deleteLine: -> - if @isEmpty() - start = @cursor.getScreenRow() - range = @editor.bufferRowsForScreenRows(start, start + 1) - if range[1] > range[0] - @editor.buffer.deleteRows(range[0], range[1] - 1) - else - @editor.buffer.deleteRow(range[0]) - else - range = @getBufferRange() - start = range.start.row - end = range.end.row - if end isnt @editor.buffer.getLastRow() and range.end.column is 0 - end-- - @editor.buffer.deleteRows(start, end) - - # Public: Joins the current line with the one below it. Lines will - # be separated by a single space. - # - # If there selection spans more than one line, all the lines are joined together. - joinLines: -> - selectedRange = @getBufferRange() - if selectedRange.isEmpty() - return if selectedRange.start.row is @editor.buffer.getLastRow() - else - joinMarker = @editor.markBufferRange(selectedRange, invalidationStrategy: 'never') - - rowCount = Math.max(1, selectedRange.getRowCount() - 1) - for row in [0...rowCount] - @cursor.setBufferPosition([selectedRange.start.row]) - @cursor.moveToEndOfLine() - - # Remove trailing whitespace from the current line - scanRange = @cursor.getCurrentLineBufferRange() - trailingWhitespaceRange = null - @editor.scanInBufferRange /[ \t]+$/, scanRange, ({range}) -> - trailingWhitespaceRange = range - if trailingWhitespaceRange? - @setBufferRange(trailingWhitespaceRange) - @deleteSelectedText() - - currentRow = selectedRange.start.row - nextRow = currentRow + 1 - insertSpace = nextRow <= @editor.buffer.getLastRow() and - @editor.buffer.lineLengthForRow(nextRow) > 0 and - @editor.buffer.lineLengthForRow(currentRow) > 0 - @insertText(' ') if insertSpace - - @cursor.moveToEndOfLine() - - # Remove leading whitespace from the line below - @modifySelection => - @cursor.moveRight() - @cursor.moveToFirstCharacterOfLine() - @deleteSelectedText() - - @cursor.moveLeft() if insertSpace - - if joinMarker? - newSelectedRange = joinMarker.getBufferRange() - @setBufferRange(newSelectedRange) - joinMarker.destroy() - - # Public: Removes one level of indent from the currently selected rows. - outdentSelectedRows: -> - [start, end] = @getBufferRowRange() - buffer = @editor.buffer - leadingTabRegex = new RegExp("^( {1,#{@editor.getTabLength()}}|\t)") - for row in [start..end] - if matchLength = buffer.lineForRow(row).match(leadingTabRegex)?[0].length - buffer.delete [[row, 0], [row, matchLength]] - return - - # Public: Sets the indentation level of all selected rows to values suggested - # by the relevant grammars. - autoIndentSelectedRows: -> - [start, end] = @getBufferRowRange() - @editor.autoIndentBufferRows(start, end) - - # Public: Wraps the selected lines in comments if they aren't currently part - # of a comment. - # - # Removes the comment if they are currently wrapped in a comment. - toggleLineComments: -> - @editor.toggleLineCommentsForBufferRows(@getBufferRowRange()...) - - # Public: Cuts the selection until the end of the line. - cutToEndOfLine: (maintainClipboard) -> - @selectToEndOfLine() if @isEmpty() - @cut(maintainClipboard) - - # Public: Copies the selection to the clipboard and then deletes it. - # - # * `maintainClipboard` {Boolean} (default: false) See {::copy} - # * `fullLine` {Boolean} (default: false) See {::copy} - cut: (maintainClipboard=false, fullLine=false) -> - @copy(maintainClipboard, fullLine) - @delete() - - # Public: Copies the current selection to the clipboard. - # - # * `maintainClipboard` {Boolean} if `true`, a specific metadata property - # is created to store each content copied to the clipboard. The clipboard - # `text` still contains the concatenation of the clipboard with the - # current selection. (default: false) - # * `fullLine` {Boolean} if `true`, the copied text will always be pasted - # at the beginning of the line containing the cursor, regardless of the - # cursor's horizontal position. (default: false) - copy: (maintainClipboard=false, fullLine=false) -> - return if @isEmpty() - {start, end} = @getBufferRange() - selectionText = @editor.getTextInRange([start, end]) - precedingText = @editor.getTextInRange([[start.row, 0], start]) - startLevel = @editor.indentLevelForLine(precedingText) - - if maintainClipboard - {text: clipboardText, metadata} = atom.clipboard.readWithMetadata() - metadata ?= {} - unless metadata.selections? - metadata.selections = [{ - text: clipboardText, - indentBasis: metadata.indentBasis, - fullLine: metadata.fullLine, - }] - metadata.selections.push({ - text: selectionText, - indentBasis: startLevel, - fullLine: fullLine - }) - atom.clipboard.write([clipboardText, selectionText].join("\n"), metadata) - else - atom.clipboard.write(selectionText, { - indentBasis: startLevel, - fullLine: fullLine - }) - - # Public: Creates a fold containing the current selection. - fold: -> - range = @getBufferRange() - @editor.createFold(range.start.row, range.end.row) - @cursor.setBufferPosition([range.end.row + 1, 0]) - - # Private: Increase the indentation level of the given text by given number - # of levels. Leaves the first line unchanged. - adjustIndent: (lines, indentAdjustment) -> - for line, i in lines - if indentAdjustment is 0 or line is '' - continue - else if indentAdjustment > 0 - lines[i] = @editor.buildIndentString(indentAdjustment) + line - else - currentIndentLevel = @editor.indentLevelForLine(lines[i]) - indentLevel = Math.max(0, currentIndentLevel + indentAdjustment) - lines[i] = line.replace(/^[\t ]+/, @editor.buildIndentString(indentLevel)) - return - - # Indent the current line(s). - # - # If the selection is empty, indents the current line if the cursor precedes - # non-whitespace characters, and otherwise inserts a tab. If the selection is - # non empty, calls {::indentSelectedRows}. - # - # * `options` (optional) {Object} with the keys: - # * `autoIndent` If `true`, the line is indented to an automatically-inferred - # level. Otherwise, {TextEditor::getTabText} is inserted. - indent: ({autoIndent}={}) -> - {row, column} = @cursor.getBufferPosition() - - if @isEmpty() - @cursor.skipLeadingWhitespace() - desiredIndent = @editor.suggestedIndentForBufferRow(row) - delta = desiredIndent - @cursor.getIndentLevel() - - if autoIndent and delta > 0 - delta = Math.max(delta, 1) unless @editor.getSoftTabs() - @insertText(@editor.buildIndentString(delta)) - else - @insertText(@editor.buildIndentString(1, @cursor.getBufferColumn())) - else - @indentSelectedRows() - - # Public: If the selection spans multiple rows, indent all of them. - indentSelectedRows: -> - [start, end] = @getBufferRowRange() - for row in [start..end] - @editor.buffer.insert([row, 0], @editor.getTabText()) unless @editor.buffer.lineLengthForRow(row) is 0 - return - - ### - Section: Managing multiple selections - ### - - # Public: Moves the selection down one row. - addSelectionBelow: -> - range = (@getGoalScreenRange() ? @getScreenRange()).copy() - nextRow = range.end.row + 1 - - for row in [nextRow..@editor.getLastScreenRow()] - range.start.row = row - range.end.row = row - clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) - - if range.isEmpty() - continue if range.end.column > 0 and clippedRange.end.column is 0 - else - continue if clippedRange.isEmpty() - - @editor.addSelectionForScreenRange(clippedRange, goalScreenRange: range) - break - - return - - # Public: Moves the selection up one row. - addSelectionAbove: -> - range = (@getGoalScreenRange() ? @getScreenRange()).copy() - previousRow = range.end.row - 1 - - for row in [previousRow..0] - range.start.row = row - range.end.row = row - clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) - - if range.isEmpty() - continue if range.end.column > 0 and clippedRange.end.column is 0 - else - continue if clippedRange.isEmpty() - - @editor.addSelectionForScreenRange(clippedRange, goalScreenRange: range) - break - - return - - # Public: Combines the given selection into this selection and then destroys - # the given selection. - # - # * `otherSelection` A {Selection} to merge with. - # * `options` (optional) {Object} options matching those found in {::setBufferRange}. - merge: (otherSelection, options) -> - myGoalScreenRange = @getGoalScreenRange() - otherGoalScreenRange = otherSelection.getGoalScreenRange() - - if myGoalScreenRange? and otherGoalScreenRange? - options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange) - else - options.goalScreenRange = myGoalScreenRange ? otherGoalScreenRange - - @setBufferRange(@getBufferRange().union(otherSelection.getBufferRange()), _.extend(autoscroll: false, options)) - otherSelection.destroy() - - ### - Section: Comparing to other selections - ### - - # Public: Compare this selection's buffer range to another selection's buffer - # range. - # - # See {Range::compare} for more details. - # - # * `otherSelection` A {Selection} to compare against - compare: (otherSelection) -> - @getBufferRange().compare(otherSelection.getBufferRange()) - - ### - Section: Private Utilities - ### - - screenRangeChanged: (e) -> - {oldHeadBufferPosition, oldTailBufferPosition} = e - {oldHeadScreenPosition, oldTailScreenPosition} = e - - eventObject = - oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition) - oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition) - newBufferRange: @getBufferRange() - newScreenRange: @getScreenRange() - selection: this - - @emit 'screen-range-changed', @getScreenRange() if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-range' - @editor.selectionRangeChanged(eventObject) - - finalize: -> - @initialScreenRange = null unless @initialScreenRange?.isEqual(@getScreenRange()) - if @isEmpty() - @wordwise = false - @linewise = false - - autoscroll: -> - if @marker.hasTail() - @editor.scrollToScreenRange(@getScreenRange(), reversed: @isReversed()) - else - @cursor.autoscroll() - - clearAutoscroll: -> - - modifySelection: (fn) -> - @retainSelection = true - @plantTail() - fn() - @retainSelection = false - - # Sets the marker's tail to the same position as the marker's head. - # - # This only works if there isn't already a tail position. - # - # Returns a {Point} representing the new tail position. - plantTail: -> - @marker.plantTail() - - getGoalScreenRange: -> - if goalScreenRange = @marker.getProperties().goalScreenRange - Range.fromObject(goalScreenRange) - -if Grim.includeDeprecatedAPIs - Selection::on = (eventName) -> - switch eventName - when 'screen-range-changed' - Grim.deprecate("Use Selection::onDidChangeRange instead. Call ::getScreenRange() yourself in your callback if you need the range.") - when 'destroyed' - Grim.deprecate("Use Selection::onDidDestroy instead.") - else - Grim.deprecate("Selection::on is deprecated. Use documented event subscription methods instead.") - - super - - # Deprecated: Use {::deleteToBeginningOfWord} instead. - Selection::backspaceToBeginningOfWord = -> - deprecate("Use Selection::deleteToBeginningOfWord() instead") - @deleteToBeginningOfWord() - - # Deprecated: Use {::deleteToBeginningOfLine} instead. - Selection::backspaceToBeginningOfLine = -> - deprecate("Use Selection::deleteToBeginningOfLine() instead") - @deleteToBeginningOfLine() diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 00000000000..29381a0fd6f --- /dev/null +++ b/src/selection.js @@ -0,0 +1,1234 @@ +const { Point, Range } = require('text-buffer'); +const { pick } = require('underscore-plus'); +const { Emitter } = require('event-kit'); + +const NonWhitespaceRegExp = /\S/; +let nextId = 0; + +// Extended: Represents a selection in the {TextEditor}. +module.exports = class Selection { + constructor({ cursor, marker, editor, id }) { + this.id = id != null ? id : nextId++; + this.cursor = cursor; + this.marker = marker; + this.editor = editor; + this.emitter = new Emitter(); + this.initialScreenRange = null; + this.wordwise = false; + this.cursor.selection = this; + this.decoration = this.editor.decorateMarker(this.marker, { + type: 'highlight', + class: 'selection' + }); + this.marker.onDidChange(e => this.markerDidChange(e)); + this.marker.onDidDestroy(() => this.markerDidDestroy()); + } + + destroy() { + this.marker.destroy(); + } + + isLastSelection() { + return this === this.editor.getLastSelection(); + } + + /* + Section: Event Subscription + */ + + // Extended: Calls your `callback` when the selection was moved. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferRange` {Range} + // * `oldScreenRange` {Range} + // * `newBufferRange` {Range} + // * `newScreenRange` {Range} + // * `selection` {Selection} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeRange(callback) { + return this.emitter.on('did-change-range', callback); + } + + // Extended: Calls your `callback` when the selection was destroyed + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + /* + Section: Managing the selection range + */ + + // Public: Returns the screen {Range} for the selection. + getScreenRange() { + return this.marker.getScreenRange(); + } + + // Public: Modifies the screen range for the selection. + // + // * `screenRange` The new {Range} to use. + // * `options` (optional) {Object} options matching those found in {::setBufferRange}. + setScreenRange(screenRange, options) { + return this.setBufferRange( + this.editor.bufferRangeForScreenRange(screenRange), + options + ); + } + + // Public: Returns the buffer {Range} for the selection. + getBufferRange() { + return this.marker.getBufferRange(); + } + + // Public: Modifies the buffer {Range} for the selection. + // + // * `bufferRange` The new {Range} to select. + // * `options` (optional) {Object} with the keys: + // * `reversed` {Boolean} indicating whether to set the selection in a + // reversed orientation. + // * `preserveFolds` if `true`, the fold settings are preserved after the + // selection moves. + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // range. Defaults to `true` if this is the most recently added selection, + // `false` otherwise. + setBufferRange(bufferRange, options = {}) { + bufferRange = Range.fromObject(bufferRange); + if (options.reversed == null) options.reversed = this.isReversed(); + if (!options.preserveFolds) + this.editor.destroyFoldsContainingBufferPositions( + [bufferRange.start, bufferRange.end], + true + ); + this.modifySelection(() => { + const needsFlash = options.flash; + options.flash = null; + this.marker.setBufferRange(bufferRange, options); + const autoscroll = + options.autoscroll != null + ? options.autoscroll + : this.isLastSelection(); + if (autoscroll) this.autoscroll(); + if (needsFlash) + this.decoration.flash('flash', this.editor.selectionFlashDuration); + }); + } + + // Public: Returns the starting and ending buffer rows the selection is + // highlighting. + // + // Returns an {Array} of two {Number}s: the starting row, and the ending row. + getBufferRowRange() { + const range = this.getBufferRange(); + const start = range.start.row; + let end = range.end.row; + if (range.end.column === 0) end = Math.max(start, end - 1); + return [start, end]; + } + + getTailScreenPosition() { + return this.marker.getTailScreenPosition(); + } + + getTailBufferPosition() { + return this.marker.getTailBufferPosition(); + } + + getHeadScreenPosition() { + return this.marker.getHeadScreenPosition(); + } + + getHeadBufferPosition() { + return this.marker.getHeadBufferPosition(); + } + + /* + Section: Info about the selection + */ + + // Public: Determines if the selection contains anything. + isEmpty() { + return this.getBufferRange().isEmpty(); + } + + // Public: Determines if the ending position of a marker is greater than the + // starting position. + // + // This can happen when, for example, you highlight text "up" in a {TextBuffer}. + isReversed() { + return this.marker.isReversed(); + } + + // Public: Returns whether the selection is a single line or not. + isSingleScreenLine() { + return this.getScreenRange().isSingleLine(); + } + + // Public: Returns the text in the selection. + getText() { + return this.editor.buffer.getTextInRange(this.getBufferRange()); + } + + // Public: Identifies if a selection intersects with a given buffer range. + // + // * `bufferRange` A {Range} to check against. + // + // Returns a {Boolean} + intersectsBufferRange(bufferRange) { + return this.getBufferRange().intersectsWith(bufferRange); + } + + intersectsScreenRowRange(startRow, endRow) { + return this.getScreenRange().intersectsRowRange(startRow, endRow); + } + + intersectsScreenRow(screenRow) { + return this.getScreenRange().intersectsRow(screenRow); + } + + // Public: Identifies if a selection intersects with another selection. + // + // * `otherSelection` A {Selection} to check against. + // + // Returns a {Boolean} + intersectsWith(otherSelection, exclusive) { + return this.getBufferRange().intersectsWith( + otherSelection.getBufferRange(), + exclusive + ); + } + + /* + Section: Modifying the selected range + */ + + // Public: Clears the selection, moving the marker to the head. + // + // * `options` (optional) {Object} with the following keys: + // * `autoscroll` {Boolean} indicating whether to autoscroll to the new + // range. Defaults to `true` if this is the most recently added selection, + // `false` otherwise. + clear(options) { + this.goalScreenRange = null; + if (!this.retainSelection) this.marker.clearTail(); + const autoscroll = + options && options.autoscroll != null + ? options.autoscroll + : this.isLastSelection(); + if (autoscroll) this.autoscroll(); + this.finalize(); + } + + // Public: Selects the text from the current cursor position to a given screen + // position. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToScreenPosition(position, options) { + position = Point.fromObject(position); + + this.modifySelection(() => { + if (this.initialScreenRange) { + if (position.isLessThan(this.initialScreenRange.start)) { + this.marker.setScreenRange([position, this.initialScreenRange.end], { + reversed: true + }); + } else { + this.marker.setScreenRange( + [this.initialScreenRange.start, position], + { reversed: false } + ); + } + } else { + this.cursor.setScreenPosition(position, options); + } + + if (this.linewise) { + this.expandOverLine(options); + } else if (this.wordwise) { + this.expandOverWord(options); + } + }); + } + + // Public: Selects the text from the current cursor position to a given buffer + // position. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToBufferPosition(position) { + this.modifySelection(() => this.cursor.setBufferPosition(position)); + } + + // Public: Selects the text one position right of the cursor. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + selectRight(columnCount) { + this.modifySelection(() => this.cursor.moveRight(columnCount)); + } + + // Public: Selects the text one position left of the cursor. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + selectLeft(columnCount) { + this.modifySelection(() => this.cursor.moveLeft(columnCount)); + } + + // Public: Selects all the text one position above the cursor. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + selectUp(rowCount) { + this.modifySelection(() => this.cursor.moveUp(rowCount)); + } + + // Public: Selects all the text one position below the cursor. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + selectDown(rowCount) { + this.modifySelection(() => this.cursor.moveDown(rowCount)); + } + + // Public: Selects all the text from the current cursor position to the top of + // the buffer. + selectToTop() { + this.modifySelection(() => this.cursor.moveToTop()); + } + + // Public: Selects all the text from the current cursor position to the bottom + // of the buffer. + selectToBottom() { + this.modifySelection(() => this.cursor.moveToBottom()); + } + + // Public: Selects all the text in the buffer. + selectAll() { + this.setBufferRange(this.editor.buffer.getRange(), { autoscroll: false }); + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the line. + selectToBeginningOfLine() { + this.modifySelection(() => this.cursor.moveToBeginningOfLine()); + } + + // Public: Selects all the text from the current cursor position to the first + // character of the line. + selectToFirstCharacterOfLine() { + this.modifySelection(() => this.cursor.moveToFirstCharacterOfLine()); + } + + // Public: Selects all the text from the current cursor position to the end of + // the screen line. + selectToEndOfLine() { + this.modifySelection(() => this.cursor.moveToEndOfScreenLine()); + } + + // Public: Selects all the text from the current cursor position to the end of + // the buffer line. + selectToEndOfBufferLine() { + this.modifySelection(() => this.cursor.moveToEndOfLine()); + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the word. + selectToBeginningOfWord() { + this.modifySelection(() => this.cursor.moveToBeginningOfWord()); + } + + // Public: Selects all the text from the current cursor position to the end of + // the word. + selectToEndOfWord() { + this.modifySelection(() => this.cursor.moveToEndOfWord()); + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the next word. + selectToBeginningOfNextWord() { + this.modifySelection(() => this.cursor.moveToBeginningOfNextWord()); + } + + // Public: Selects text to the previous word boundary. + selectToPreviousWordBoundary() { + this.modifySelection(() => this.cursor.moveToPreviousWordBoundary()); + } + + // Public: Selects text to the next word boundary. + selectToNextWordBoundary() { + this.modifySelection(() => this.cursor.moveToNextWordBoundary()); + } + + // Public: Selects text to the previous subword boundary. + selectToPreviousSubwordBoundary() { + this.modifySelection(() => this.cursor.moveToPreviousSubwordBoundary()); + } + + // Public: Selects text to the next subword boundary. + selectToNextSubwordBoundary() { + this.modifySelection(() => this.cursor.moveToNextSubwordBoundary()); + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the next paragraph. + selectToBeginningOfNextParagraph() { + this.modifySelection(() => this.cursor.moveToBeginningOfNextParagraph()); + } + + // Public: Selects all the text from the current cursor position to the + // beginning of the previous paragraph. + selectToBeginningOfPreviousParagraph() { + this.modifySelection(() => + this.cursor.moveToBeginningOfPreviousParagraph() + ); + } + + // Public: Modifies the selection to encompass the current word. + // + // Returns a {Range}. + selectWord(options = {}) { + if (this.cursor.isSurroundedByWhitespace()) options.wordRegex = /[\t ]*/; + if (this.cursor.isBetweenWordAndNonWord()) { + options.includeNonWordCharacters = false; + } + + this.setBufferRange( + this.cursor.getCurrentWordBufferRange(options), + options + ); + this.wordwise = true; + this.initialScreenRange = this.getScreenRange(); + } + + // Public: Expands the newest selection to include the entire word on which + // the cursors rests. + expandOverWord(options) { + this.setBufferRange( + this.getBufferRange().union(this.cursor.getCurrentWordBufferRange()), + { autoscroll: false } + ); + const autoscroll = + options && options.autoscroll != null + ? options.autoscroll + : this.isLastSelection(); + if (autoscroll) this.cursor.autoscroll(); + } + + // Public: Selects an entire line in the buffer. + // + // * `row` The line {Number} to select (default: the row of the cursor). + selectLine(row, options) { + if (row != null) { + this.setBufferRange( + this.editor.bufferRangeForBufferRow(row, { includeNewline: true }), + options + ); + } else { + const startRange = this.editor.bufferRangeForBufferRow( + this.marker.getStartBufferPosition().row + ); + const endRange = this.editor.bufferRangeForBufferRow( + this.marker.getEndBufferPosition().row, + { includeNewline: true } + ); + this.setBufferRange(startRange.union(endRange), options); + } + + this.linewise = true; + this.wordwise = false; + this.initialScreenRange = this.getScreenRange(); + } + + // Public: Expands the newest selection to include the entire line on which + // the cursor currently rests. + // + // It also includes the newline character. + expandOverLine(options) { + const range = this.getBufferRange().union( + this.cursor.getCurrentLineBufferRange({ includeNewline: true }) + ); + this.setBufferRange(range, { autoscroll: false }); + const autoscroll = + options && options.autoscroll != null + ? options.autoscroll + : this.isLastSelection(); + if (autoscroll) this.cursor.autoscroll(); + } + + // Private: Ensure that the {TextEditor} is not marked read-only before allowing a buffer modification to occur. if + // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error. + ensureWritable(methodName, opts) { + if (!opts.bypassReadOnly && this.editor.isReadOnly()) { + if (atom.inDevMode() || atom.inSpecMode()) { + const e = new Error( + 'Attempt to mutate a read-only TextEditor through a Selection' + ); + e.detail = + `Your package is attempting to call ${methodName} on a selection within an editor that has been marked ` + + ' read-only. Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before ' + + ' attempting modifications.'; + throw e; + } + + return false; + } + + return true; + } + + /* + Section: Modifying the selected text + */ + + // Public: Replaces text at the current selection. + // + // * `text` A {String} representing the text to add + // * `options` (optional) {Object} with keys: + // * `select` If `true`, selects the newly added text. + // * `autoIndent` If `true`, indents all inserted text appropriately. + // * `autoIndentNewline` If `true`, indent newline appropriately. + // * `autoDecreaseIndent` If `true`, decreases indent level appropriately + // (for example, when a closing bracket is inserted). + // * `preserveTrailingLineIndentation` By default, when pasting multiple + // lines, Atom attempts to preserve the relative indent level between the + // first line and trailing lines, even if the indent level of the first + // line has changed from the copied text. If this option is `true`, this + // behavior is suppressed. + // level between the first lines and the trailing lines. + // * `normalizeLineEndings` (optional) {Boolean} (default: true) + // * `undo` *Deprecated* If `skip`, skips the undo stack for this operation. This property is deprecated. Call groupLastChanges() on the {TextBuffer} afterward instead. + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertText(text, options = {}) { + if (!this.ensureWritable('insertText', options)) return; + + let desiredIndentLevel, indentAdjustment; + const oldBufferRange = this.getBufferRange(); + const wasReversed = this.isReversed(); + this.clear(options); + + let autoIndentFirstLine = false; + const precedingText = this.editor.getTextInRange([ + [oldBufferRange.start.row, 0], + oldBufferRange.start + ]); + const remainingLines = text.split('\n'); + const firstInsertedLine = remainingLines.shift(); + + if ( + options.indentBasis != null && + !options.preserveTrailingLineIndentation + ) { + indentAdjustment = + this.editor.indentLevelForLine(precedingText) - options.indentBasis; + this.adjustIndent(remainingLines, indentAdjustment); + } + + const textIsAutoIndentable = + text === '\n' || text === '\r\n' || NonWhitespaceRegExp.test(text); + if ( + options.autoIndent && + textIsAutoIndentable && + !NonWhitespaceRegExp.test(precedingText) && + remainingLines.length > 0 + ) { + autoIndentFirstLine = true; + const firstLine = precedingText + firstInsertedLine; + 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; + if (remainingLines.length > 0) text += `\n${remainingLines.join('\n')}`; + + const newBufferRange = this.editor.buffer.setTextInRange( + oldBufferRange, + text, + pick(options, 'undo', 'normalizeLineEndings') + ); + + if (options.select) { + this.setBufferRange(newBufferRange, { reversed: wasReversed }); + } else { + if (wasReversed) this.cursor.setBufferPosition(newBufferRange.end); + } + + if (autoIndentFirstLine) { + this.editor.setIndentationForBufferRow( + oldBufferRange.start.row, + desiredIndentLevel + ); + } + + if (options.autoIndentNewline && text === '\n') { + this.editor.autoIndentBufferRow(newBufferRange.end.row, { + preserveLeadingWhitespace: true, + skipBlankLines: false + }); + } else if (options.autoDecreaseIndent && NonWhitespaceRegExp.test(text)) { + this.editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row); + } + + const autoscroll = + options.autoscroll != null ? options.autoscroll : this.isLastSelection(); + if (autoscroll) this.autoscroll(); + + return newBufferRange; + } + + // Public: Removes the first character before the selection if the selection + // is empty otherwise it deletes the selection. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + backspace(options = {}) { + if (!this.ensureWritable('backspace', options)) return; + if (this.isEmpty()) this.selectLeft(); + this.deleteSelectedText(options); + } + + // Public: Removes the selection or, if nothing is selected, then all + // characters from the start of the selection back to the previous word + // boundary. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToPreviousWordBoundary(options = {}) { + if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return; + if (this.isEmpty()) this.selectToPreviousWordBoundary(); + this.deleteSelectedText(options); + } + + // Public: Removes the selection or, if nothing is selected, then all + // characters from the start of the selection up to the next word + // boundary. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToNextWordBoundary(options = {}) { + if (!this.ensureWritable('deleteToNextWordBoundary', options)) return; + if (this.isEmpty()) this.selectToNextWordBoundary(); + this.deleteSelectedText(options); + } + + // Public: Removes from the start of the selection to the beginning of the + // current word if the selection is empty otherwise it deletes the selection. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfWord(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfWord', options)) return; + if (this.isEmpty()) this.selectToBeginningOfWord(); + this.deleteSelectedText(options); + } + + // Public: Removes from the beginning of the line which the selection begins on + // all the way through to the end of the selection. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfLine(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfLine', options)) return; + if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) { + this.selectLeft(); + } else { + this.selectToBeginningOfLine(); + } + this.deleteSelectedText(options); + } + + // Public: Removes the selection or the next character after the start of the + // selection if the selection is empty. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + delete(options = {}) { + if (!this.ensureWritable('delete', options)) return; + if (this.isEmpty()) this.selectRight(); + this.deleteSelectedText(options); + } + + // Public: If the selection is empty, removes all text from the cursor to the + // end of the line. If the cursor is already at the end of the line, it + // removes the following newline. If the selection isn't empty, only deletes + // the contents of the selection. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfLine(options = {}) { + if (!this.ensureWritable('deleteToEndOfLine', options)) return; + if (this.isEmpty()) { + if (this.cursor.isAtEndOfLine()) { + this.delete(options); + return; + } + this.selectToEndOfLine(); + } + this.deleteSelectedText(options); + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfWord(options = {}) { + if (!this.ensureWritable('deleteToEndOfWord', options)) return; + if (this.isEmpty()) this.selectToEndOfWord(); + this.deleteSelectedText(options); + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfSubword(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return; + if (this.isEmpty()) this.selectToPreviousSubwordBoundary(); + this.deleteSelectedText(options); + } + + // Public: Removes the selection or all characters from the start of the + // selection to the end of the current word if nothing is selected. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfSubword(options = {}) { + if (!this.ensureWritable('deleteToEndOfSubword', options)) return; + if (this.isEmpty()) this.selectToNextSubwordBoundary(); + this.deleteSelectedText(options); + } + + // Public: Removes only the selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteSelectedText(options = {}) { + if (!this.ensureWritable('deleteSelectedText', options)) return; + const bufferRange = this.getBufferRange(); + if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange); + if (this.cursor) this.cursor.setBufferPosition(bufferRange.start); + } + + // Public: Removes the line at the beginning of the selection if the selection + // is empty unless the selection spans multiple lines in which case all lines + // are removed. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteLine(options = {}) { + if (!this.ensureWritable('deleteLine', options)) return; + 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]) { + this.editor.buffer.deleteRows(range[0], range[1] - 1); + } else { + this.editor.buffer.deleteRow(range[0]); + } + } else { + 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 + // be separated by a single space. + // + // If there selection spans more than one line, all the lines are joined together. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + joinLines(options = {}) { + if (!this.ensureWritable('joinLines', options)) return; + let joinMarker; + const selectedRange = this.getBufferRange(); + if (selectedRange.isEmpty()) { + if (selectedRange.start.row === this.editor.buffer.getLastRow()) return; + } else { + joinMarker = this.editor.markBufferRange(selectedRange, { + invalidate: 'never' + }); + } + + const rowCount = Math.max(1, selectedRange.getRowCount() - 1); + for (let i = 0; i < rowCount; i++) { + this.cursor.setBufferPosition([selectedRange.start.row]); + this.cursor.moveToEndOfLine(); + + // Remove trailing whitespace from the current line + const scanRange = this.cursor.getCurrentLineBufferRange(); + let trailingWhitespaceRange = null; + this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({ range }) => { + trailingWhitespaceRange = range; + }); + if (trailingWhitespaceRange) { + this.setBufferRange(trailingWhitespaceRange); + this.deleteSelectedText(options); + } + + const currentRow = selectedRange.start.row; + const nextRow = currentRow + 1; + const insertSpace = + nextRow <= this.editor.buffer.getLastRow() && + this.editor.buffer.lineLengthForRow(nextRow) > 0 && + this.editor.buffer.lineLengthForRow(currentRow) > 0; + if (insertSpace) this.insertText(' ', options); + + this.cursor.moveToEndOfLine(); + + // Remove leading whitespace from the line below + this.modifySelection(() => { + this.cursor.moveRight(); + this.cursor.moveToFirstCharacterOfLine(); + }); + this.deleteSelectedText(options); + + if (insertSpace) this.cursor.moveLeft(); + } + + if (joinMarker) { + const newSelectedRange = joinMarker.getBufferRange(); + this.setBufferRange(newSelectedRange); + joinMarker.destroy(); + } + } + + // Public: Removes one level of indent from the currently selected rows. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + outdentSelectedRows(options = {}) { + if (!this.ensureWritable('outdentSelectedRows', options)) return; + const [start, end] = this.getBufferRowRange(); + const { buffer } = this.editor; + const leadingTabRegex = new RegExp( + `^( {1,${this.editor.getTabLength()}}|\t)` + ); + for (let row = start; row <= end; row++) { + const match = buffer.lineForRow(row).match(leadingTabRegex); + if (match && match[0].length > 0) { + buffer.delete([[row, 0], [row, match[0].length]]); + } + } + } + + // Public: Sets the indentation level of all selected rows to values suggested + // by the relevant grammars. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + autoIndentSelectedRows(options = {}) { + if (!this.ensureWritable('autoIndentSelectedRows', options)) return; + const [start, end] = this.getBufferRowRange(); + return this.editor.autoIndentBufferRows(start, end); + } + + // Public: Wraps the selected lines in comments if they aren't currently part + // of a comment. + // + // Removes the comment if they are currently wrapped in a comment. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + toggleLineComments(options = {}) { + if (!this.ensureWritable('toggleLineComments', options)) return; + let bufferRowRange = this.getBufferRowRange() || [null, null]; + this.editor.toggleLineCommentsForBufferRows(...bufferRowRange, { + correctSelection: true, + selection: this + }); + } + + // Public: Cuts the selection until the end of the screen line. + // + // * `maintainClipboard` {Boolean} + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + cutToEndOfLine(maintainClipboard, options = {}) { + if (!this.ensureWritable('cutToEndOfLine', options)) return; + if (this.isEmpty()) this.selectToEndOfLine(); + return this.cut(maintainClipboard, false, options.bypassReadOnly); + } + + // Public: Cuts the selection until the end of the buffer line. + // + // * `maintainClipboard` {Boolean} + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + cutToEndOfBufferLine(maintainClipboard, options = {}) { + if (!this.ensureWritable('cutToEndOfBufferLine', options)) return; + if (this.isEmpty()) this.selectToEndOfBufferLine(); + this.cut(maintainClipboard, false, options.bypassReadOnly); + } + + // Public: Copies the selection to the clipboard and then deletes it. + // + // * `maintainClipboard` {Boolean} (default: false) See {::copy} + // * `fullLine` {Boolean} (default: false) See {::copy} + // * `bypassReadOnly` {Boolean} (default: false) Must be `true` to modify text within a read-only editor. + cut(maintainClipboard = false, fullLine = false, bypassReadOnly = false) { + if (!this.ensureWritable('cut', { bypassReadOnly })) return; + this.copy(maintainClipboard, fullLine); + this.delete({ bypassReadOnly }); + } + + // Public: Copies the current selection to the clipboard. + // + // * `maintainClipboard` {Boolean} if `true`, a specific metadata property + // is created to store each content copied to the clipboard. The clipboard + // `text` still contains the concatenation of the clipboard with the + // current selection. (default: false) + // * `fullLine` {Boolean} if `true`, the copied text will always be pasted + // at the beginning of the line containing the cursor, regardless of the + // cursor's horizontal position. (default: false) + copy(maintainClipboard = false, fullLine = false) { + if (this.isEmpty()) return; + const { start, end } = this.getBufferRange(); + const selectionText = this.editor.getTextInRange([start, end]); + const precedingText = this.editor.getTextInRange([[start.row, 0], start]); + const startLevel = this.editor.indentLevelForLine(precedingText); + + if (maintainClipboard) { + let { + text: clipboardText, + metadata + } = this.editor.constructor.clipboard.readWithMetadata(); + if (!metadata) metadata = {}; + if (!metadata.selections) { + metadata.selections = [ + { + text: clipboardText, + indentBasis: metadata.indentBasis, + fullLine: metadata.fullLine + } + ]; + } + metadata.selections.push({ + text: selectionText, + indentBasis: startLevel, + fullLine + }); + this.editor.constructor.clipboard.write( + [clipboardText, selectionText].join('\n'), + metadata + ); + } else { + this.editor.constructor.clipboard.write(selectionText, { + indentBasis: startLevel, + fullLine + }); + } + } + + // Public: Creates a fold containing the current selection. + fold() { + const range = this.getBufferRange(); + if (!range.isEmpty()) { + this.editor.foldBufferRange(range); + this.cursor.setBufferPosition(range.end); + } + } + + // Private: Increase the indentation level of the given text by given number + // of levels. Leaves the first line unchanged. + adjustIndent(lines, indentAdjustment) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (indentAdjustment === 0 || line === '') { + continue; + } else if (indentAdjustment > 0) { + lines[i] = this.editor.buildIndentString(indentAdjustment) + line; + } else { + const currentIndentLevel = this.editor.indentLevelForLine(lines[i]); + const indentLevel = Math.max(0, currentIndentLevel + indentAdjustment); + lines[i] = line.replace( + /^[\t ]+/, + this.editor.buildIndentString(indentLevel) + ); + } + } + } + + // Indent the current line(s). + // + // If the selection is empty, indents the current line if the cursor precedes + // non-whitespace characters, and otherwise inserts a tab. If the selection is + // non empty, calls {::indentSelectedRows}. + // + // * `options` (optional) {Object} with the keys: + // * `autoIndent` If `true`, the line is indented to an automatically-inferred + // level. Otherwise, {TextEditor::getTabText} is inserted. + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + indent({ autoIndent, bypassReadOnly } = {}) { + if (!this.ensureWritable('indent', { bypassReadOnly })) return; + const { row } = this.cursor.getBufferPosition(); + + if (this.isEmpty()) { + this.cursor.skipLeadingWhitespace(); + const desiredIndent = this.editor.suggestedIndentForBufferRow(row); + let delta = desiredIndent - this.cursor.getIndentLevel(); + + if (autoIndent && delta > 0) { + if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1); + this.insertText(this.editor.buildIndentString(delta), { + bypassReadOnly + }); + } else { + this.insertText( + this.editor.buildIndentString(1, this.cursor.getBufferColumn()), + { bypassReadOnly } + ); + } + } else { + this.indentSelectedRows({ bypassReadOnly }); + } + } + + // Public: If the selection spans multiple rows, indent all of them. + // + // * `options` (optional) {Object} with the keys: + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + indentSelectedRows(options = {}) { + if (!this.ensureWritable('indentSelectedRows', options)) return; + const [start, end] = this.getBufferRowRange(); + for (let row = start; row <= end; row++) { + if (this.editor.buffer.lineLengthForRow(row) !== 0) { + this.editor.buffer.insert([row, 0], this.editor.getTabText()); + } + } + } + + /* + Section: Managing multiple selections + */ + + // Public: Moves the selection down one row. + addSelectionBelow() { + const range = this.getGoalScreenRange().copy(); + const nextRow = range.end.row + 1; + + for ( + let row = nextRow, end = this.editor.getLastScreenRow(); + row <= end; + row++ + ) { + range.start.row = row; + range.end.row = row; + const clippedRange = this.editor.clipScreenRange(range, { + skipSoftWrapIndentation: true + }); + + if (range.isEmpty()) { + if (range.end.column > 0 && clippedRange.end.column === 0) continue; + } else { + if (clippedRange.isEmpty()) continue; + } + + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers( + { containsScreenRange: clippedRange } + ); + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange); + selection.setGoalScreenRange(range); + } + + break; + } + } + + // Public: Moves the selection up one row. + addSelectionAbove() { + const range = this.getGoalScreenRange().copy(); + const previousRow = range.end.row - 1; + + for (let row = previousRow; row >= 0; row--) { + range.start.row = row; + range.end.row = row; + const clippedRange = this.editor.clipScreenRange(range, { + skipSoftWrapIndentation: true + }); + + if (range.isEmpty()) { + if (range.end.column > 0 && clippedRange.end.column === 0) continue; + } else { + if (clippedRange.isEmpty()) continue; + } + + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers( + { containsScreenRange: clippedRange } + ); + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange); + selection.setGoalScreenRange(range); + } + + break; + } + } + + // Public: Combines the given selection into this selection and then destroys + // the given selection. + // + // * `otherSelection` A {Selection} to merge with. + // * `options` (optional) {Object} options matching those found in {::setBufferRange}. + merge(otherSelection, options = {}) { + const myGoalScreenRange = this.getGoalScreenRange(); + const otherGoalScreenRange = otherSelection.getGoalScreenRange(); + + if (myGoalScreenRange && otherGoalScreenRange) { + options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange); + } else { + options.goalScreenRange = myGoalScreenRange || otherGoalScreenRange; + } + + const bufferRange = this.getBufferRange().union( + otherSelection.getBufferRange() + ); + this.setBufferRange( + bufferRange, + Object.assign({ autoscroll: false }, options) + ); + otherSelection.destroy(); + } + + /* + Section: Comparing to other selections + */ + + // Public: Compare this selection's buffer range to another selection's buffer + // range. + // + // See {Range::compare} for more details. + // + // * `otherSelection` A {Selection} to compare against + compare(otherSelection) { + return this.marker.compare(otherSelection.marker); + } + + /* + Section: Private Utilities + */ + + setGoalScreenRange(range) { + this.goalScreenRange = Range.fromObject(range); + } + + getGoalScreenRange() { + return this.goalScreenRange || this.getScreenRange(); + } + + markerDidChange(e) { + const { + oldHeadBufferPosition, + oldTailBufferPosition, + newHeadBufferPosition + } = e; + const { + oldHeadScreenPosition, + oldTailScreenPosition, + newHeadScreenPosition + } = e; + const { textChanged } = e; + + if (!oldHeadScreenPosition.isEqual(newHeadScreenPosition)) { + this.cursor.goalColumn = null; + const cursorMovedEvent = { + oldBufferPosition: oldHeadBufferPosition, + oldScreenPosition: oldHeadScreenPosition, + newBufferPosition: newHeadBufferPosition, + newScreenPosition: newHeadScreenPosition, + textChanged, + cursor: this.cursor + }; + this.cursor.emitter.emit('did-change-position', cursorMovedEvent); + this.editor.cursorMoved(cursorMovedEvent); + } + + const rangeChangedEvent = { + oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition), + oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition), + newBufferRange: this.getBufferRange(), + newScreenRange: this.getScreenRange(), + selection: this + }; + this.emitter.emit('did-change-range', rangeChangedEvent); + this.editor.selectionRangeChanged(rangeChangedEvent); + } + + markerDidDestroy() { + if (this.editor.isDestroyed()) return; + + this.destroyed = true; + this.cursor.destroyed = true; + + this.editor.removeSelection(this); + + this.cursor.emitter.emit('did-destroy'); + this.emitter.emit('did-destroy'); + + this.cursor.emitter.dispose(); + this.emitter.dispose(); + } + + finalize() { + if ( + !this.initialScreenRange || + !this.initialScreenRange.isEqual(this.getScreenRange()) + ) { + this.initialScreenRange = null; + } + if (this.isEmpty()) { + this.wordwise = false; + this.linewise = false; + } + } + + autoscroll(options) { + if (this.marker.hasTail()) { + this.editor.scrollToScreenRange( + this.getScreenRange(), + Object.assign({ reversed: this.isReversed() }, options) + ); + } else { + this.cursor.autoscroll(options); + } + } + + clearAutoscroll() {} + + modifySelection(fn) { + this.retainSelection = true; + this.plantTail(); + fn(); + this.retainSelection = false; + } + + // Sets the marker's tail to the same position as the marker's head. + // + // This only works if there isn't already a tail position. + // + // Returns a {Point} representing the new tail position. + plantTail() { + this.marker.plantTail(); + } +}; diff --git a/src/selectors.js b/src/selectors.js new file mode 100644 index 00000000000..5ee2d681ebe --- /dev/null +++ b/src/selectors.js @@ -0,0 +1,36 @@ +module.exports = { selectorMatchesAnyScope, matcherForSelector }; + +const { isSubset } = require('underscore-plus'); + +// Private: Parse a selector into parts. +// If already parsed, returns the selector unmodified. +// +// * `selector` a {String|Array} specifying what to match +// Returns selector parts, an {Array}. +function parse(selector) { + return typeof selector === 'string' + ? selector.replace(/^\./, '').split('.') + : selector; +} + +const always = scope => true; + +// Essential: Return a matcher function for a selector. +// +// * selector, a {String} selector +// Returns {(scope: String) -> Boolean}, a matcher function returning +// true iff the scope matches the selector. +function matcherForSelector(selector) { + const parts = parse(selector); + if (typeof parts === 'function') return parts; + return selector ? scope => isSubset(parts, parse(scope)) : always; +} + +// Essential: Return true iff the selector matches any provided scope. +// +// * {String} selector +// * {Array} scopes +// Returns {Boolean} true if any scope matches the selector. +function selectorMatchesAnyScope(selector, scopes) { + return !selector || scopes.some(matcherForSelector(selector)); +} diff --git a/src/space-pen-extensions.coffee b/src/space-pen-extensions.coffee deleted file mode 100644 index 128ddb8b1c4..00000000000 --- a/src/space-pen-extensions.coffee +++ /dev/null @@ -1,157 +0,0 @@ -_ = require 'underscore-plus' -SpacePen = require 'space-pen' -{Subscriber} = require 'emissary' - -Subscriber.includeInto(SpacePen.View) - -jQuery = SpacePen.jQuery -JQueryCleanData = jQuery.cleanData -jQuery.cleanData = (elements) -> - jQuery(element).view()?.unsubscribe?() for element in elements - JQueryCleanData(elements) - -SpacePenCallRemoveHooks = SpacePen.callRemoveHooks -SpacePen.callRemoveHooks = (element) -> - view.unsubscribe?() for view in SpacePen.viewsForElement(element) - SpacePenCallRemoveHooks(element) - -NativeEventNames = new Set -NativeEventNames.add(nativeEvent) for nativeEvent in ["blur", "focus", "focusin", -"focusout", "load", "resize", "scroll", "unload", "click", "dblclick", "mousedown", -"mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave", "change", -"select", "submit", "keydown", "keypress", "keyup", "error", "contextmenu", "textInput", -"textinput", "beforeunload"] - -JQueryTrigger = jQuery.fn.trigger -jQuery.fn.trigger = (eventName, data) -> - if NativeEventNames.has(eventName) or typeof eventName is 'object' - JQueryTrigger.call(this, eventName, data) - else - data ?= {} - data.jQueryTrigger = true - - for element in this - atom.commands.dispatch(element, eventName, data) - this - -HandlersByOriginalHandler = new WeakMap -CommandDisposablesByElement = new WeakMap - -AddEventListener = (element, type, listener) -> - if NativeEventNames.has(type) - element.addEventListener(type, listener) - else - disposable = atom.commands.add(element, type, listener) - - unless disposablesByType = CommandDisposablesByElement.get(element) - disposablesByType = {} - CommandDisposablesByElement.set(element, disposablesByType) - - unless disposablesByListener = disposablesByType[type] - disposablesByListener = new WeakMap - disposablesByType[type] = disposablesByListener - - disposablesByListener.set(listener, disposable) - -RemoveEventListener = (element, type, listener) -> - if NativeEventNames.has(type) - element.removeEventListener(type, listener) - else - CommandDisposablesByElement.get(element)?[type]?.get(listener)?.dispose() - -JQueryEventAdd = jQuery.event.add -jQuery.event.add = (elem, types, originalHandler, data, selector) -> - handler = (event) -> - if arguments.length is 1 and event.originalEvent?.detail? - {detail} = event.originalEvent - if Array.isArray(detail) - originalHandler.apply(this, [event].concat(detail)) - else - originalHandler.call(this, event, detail) - else - originalHandler.apply(this, arguments) - - HandlersByOriginalHandler.set(originalHandler, handler) - - JQueryEventAdd.call(this, elem, types, handler, data, selector, AddEventListener if atom?.commands?) - -JQueryEventRemove = jQuery.event.remove -jQuery.event.remove = (elem, types, originalHandler, selector, mappedTypes) -> - if originalHandler? - handler = HandlersByOriginalHandler.get(originalHandler) ? originalHandler - JQueryEventRemove(elem, types, handler, selector, mappedTypes, RemoveEventListener if atom?.commands?) - -JQueryContains = jQuery.contains - -jQuery.contains = (a, b) -> - shadowRoot = null - currentNode = b - while currentNode - if currentNode instanceof ShadowRoot and a.contains(currentNode.host) - return true - currentNode = currentNode.parentNode - - JQueryContains.call(this, a, b) - -tooltipDefaults = - delay: - show: 1000 - hide: 100 - container: 'body' - html: true - placement: 'auto top' - viewportPadding: 2 - -humanizeKeystrokes = (keystroke) -> - keystrokes = keystroke.split(' ') - keystrokes = (_.humanizeKeystroke(stroke) for stroke in keystrokes) - keystrokes.join(' ') - -getKeystroke = (bindings) -> - if bindings?.length - "#{humanizeKeystrokes(bindings[0].keystrokes)}" - else - '' - -requireBootstrapTooltip = _.once -> - atom.requireWithGlobals('bootstrap/js/tooltip', {jQuery}) - -# options from http://getbootstrap.com/javascript/#tooltips -jQuery.fn.setTooltip = (tooltipOptions, {command, commandElement}={}) -> - requireBootstrapTooltip() - - tooltipOptions = {title: tooltipOptions} if _.isString(tooltipOptions) - - if commandElement - bindings = atom.keymaps.findKeyBindings(command: command, target: commandElement[0]) - else if command - bindings = atom.keymaps.findKeyBindings(command: command) - - tooltipOptions.title = "#{tooltipOptions.title} #{getKeystroke(bindings)}" - - @tooltip(jQuery.extend({}, tooltipDefaults, tooltipOptions)) - -jQuery.fn.hideTooltip = -> - tip = @data('bs.tooltip') - if tip - tip.leave(currentTarget: this) - tip.hide() - -jQuery.fn.destroyTooltip = -> - @hideTooltip() - requireBootstrapTooltip() - @tooltip('destroy') - -# Hide tooltips when window is resized -jQuery(document.body).on 'show.bs.tooltip', ({target}) -> - windowHandler = -> jQuery(target).hideTooltip() - jQuery(window).one('resize', windowHandler) - jQuery(target).one 'hide.bs.tooltip', -> - jQuery(window).off('resize', windowHandler) - -jQuery.fn.setTooltip.getKeystroke = getKeystroke -jQuery.fn.setTooltip.humanizeKeystrokes = humanizeKeystrokes - -Object.defineProperty jQuery.fn, 'element', get: -> @[0] - -module.exports = SpacePen diff --git a/src/startup-time.js b/src/startup-time.js new file mode 100644 index 00000000000..314415336ce --- /dev/null +++ b/src/startup-time.js @@ -0,0 +1,33 @@ +let startTime; +let markers = []; + +module.exports = { + setStartTime() { + if (!startTime) { + startTime = Date.now(); + } + }, + addMarker(label, dateTime) { + if (!startTime) { + return; + } + + dateTime = dateTime || Date.now(); + markers.push({ label, time: dateTime - startTime }); + }, + importData(data) { + startTime = data.startTime; + markers = data.markers; + }, + exportData() { + if (!startTime) { + return undefined; + } + + return { startTime, markers }; + }, + deleteData() { + startTime = undefined; + markers = []; + } +}; diff --git a/src/state-store.js b/src/state-store.js new file mode 100644 index 00000000000..840ec18319b --- /dev/null +++ b/src/state-store.js @@ -0,0 +1,141 @@ +'use strict'; + +module.exports = class StateStore { + constructor(databaseName, version) { + this.connected = false; + this.databaseName = databaseName; + this.version = version; + } + + get dbPromise() { + if (!this._dbPromise) { + this._dbPromise = new Promise(resolve => { + const dbOpenRequest = indexedDB.open(this.databaseName, this.version); + dbOpenRequest.onupgradeneeded = event => { + let db = event.target.result; + db.onerror = error => { + atom.notifications.addFatalError('Error loading database', { + stack: new Error('Error loading database').stack, + dismissable: true + }); + console.error('Error loading database', error); + }; + db.createObjectStore('states'); + }; + dbOpenRequest.onsuccess = () => { + this.connected = true; + resolve(dbOpenRequest.result); + }; + dbOpenRequest.onerror = error => { + atom.notifications.addFatalError('Could not connect to indexedDB', { + stack: new Error('Could not connect to indexedDB').stack, + dismissable: true + }); + console.error('Could not connect to indexedDB', error); + this.connected = false; + resolve(null); + }; + }); + } + + return this._dbPromise; + } + + isConnected() { + return this.connected; + } + + connect() { + return this.dbPromise.then(db => !!db); + } + + save(key, value) { + return new Promise((resolve, reject) => { + this.dbPromise.then(db => { + if (db == null) return resolve(); + + const request = db + .transaction(['states'], 'readwrite') + .objectStore('states') + .put({ value: value, storedAt: new Date().toString() }, key); + + request.onsuccess = resolve; + request.onerror = reject; + }); + }); + } + + load(key) { + return this.dbPromise.then(db => { + if (!db) return; + + return new Promise((resolve, reject) => { + const request = db + .transaction(['states']) + .objectStore('states') + .get(key); + + request.onsuccess = event => { + let result = event.target.result; + if (result && !result.isJSON) { + resolve(result.value); + } else { + resolve(null); + } + }; + + request.onerror = event => reject(event); + }); + }); + } + + delete(key) { + return new Promise((resolve, reject) => { + this.dbPromise.then(db => { + if (db == null) return resolve(); + + const request = db + .transaction(['states'], 'readwrite') + .objectStore('states') + .delete(key); + + request.onsuccess = resolve; + request.onerror = reject; + }); + }); + } + + clear() { + return this.dbPromise.then(db => { + if (!db) return; + + return new Promise((resolve, reject) => { + const request = db + .transaction(['states'], 'readwrite') + .objectStore('states') + .clear(); + + request.onsuccess = resolve; + request.onerror = reject; + }); + }); + } + + count() { + return this.dbPromise.then(db => { + if (!db) return; + + return new Promise((resolve, reject) => { + const request = db + .transaction(['states']) + .objectStore('states') + .count(); + + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = reject; + }); + }); + } +}; diff --git a/src/storage-folder.coffee b/src/storage-folder.coffee deleted file mode 100644 index bf969dee246..00000000000 --- a/src/storage-folder.coffee +++ /dev/null @@ -1,27 +0,0 @@ -path = require "path" -fs = require "fs-plus" - -module.exports = -class StorageFolder - constructor: (containingPath) -> - @path = path.join(containingPath, "storage") - - store: (name, object) -> - fs.writeFileSync(@pathForKey(name), JSON.stringify(object), 'utf8') - - load: (name) -> - statePath = @pathForKey(name) - try - stateString = fs.readFileSync(statePath, 'utf8') - catch error - unless error.code is 'ENOENT' - console.warn "Error reading state file: #{statePath}", error.stack, error - return undefined - - try - JSON.parse(stateString) - catch error - console.warn "Error parsing state file: #{statePath}", error.stack, error - - pathForKey: (name) -> path.join(@getPath(), name) - getPath: -> @path diff --git a/src/storage-folder.js b/src/storage-folder.js new file mode 100644 index 00000000000..da78f973384 --- /dev/null +++ b/src/storage-folder.js @@ -0,0 +1,59 @@ +const path = require('path'); +const fs = require('fs-plus'); + +module.exports = class StorageFolder { + constructor(containingPath) { + if (containingPath) { + this.path = path.join(containingPath, 'storage'); + } + } + + store(name, object) { + return new Promise((resolve, reject) => { + if (!this.path) return resolve(); + fs.writeFile( + this.pathForKey(name), + JSON.stringify(object), + 'utf8', + error => (error ? reject(error) : resolve()) + ); + }); + } + + load(name) { + return new Promise(resolve => { + if (!this.path) return resolve(null); + const statePath = this.pathForKey(name); + fs.readFile(statePath, 'utf8', (error, stateString) => { + if (error && error.code !== 'ENOENT') { + console.warn( + `Error reading state file: ${statePath}`, + error.stack, + error + ); + } + + if (!stateString) return resolve(null); + + try { + resolve(JSON.parse(stateString)); + } catch (error) { + console.warn( + `Error parsing state file: ${statePath}`, + error.stack, + error + ); + resolve(null); + } + }); + }); + } + + pathForKey(name) { + return path.join(this.getPath(), name); + } + + getPath() { + return this.path; + } +}; diff --git a/src/style-manager.coffee b/src/style-manager.coffee deleted file mode 100644 index cfe86b3fe79..00000000000 --- a/src/style-manager.coffee +++ /dev/null @@ -1,169 +0,0 @@ -fs = require 'fs-plus' -path = require 'path' -{Emitter, Disposable} = require 'event-kit' - -# Extended: A singleton instance of this class available via `atom.styles`, -# which you can use to globally query and observe the set of active style -# sheets. The `StyleManager` doesn't add any style elements to the DOM on its -# own, but is instead subscribed to by individual `` elements, -# which clone and attach style elements in different contexts. -module.exports = -class StyleManager - constructor: -> - @emitter = new Emitter - @styleElements = [] - @styleElementsBySourcePath = {} - - ### - Section: Event Subscription - ### - - # Extended: Invoke `callback` for all current and future style elements. - # - # * `callback` {Function} that is called with style elements. - # * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property - # will be null because this element isn't attached to the DOM. If you want - # to attach this element to the DOM, be sure to clone it first by calling - # `.cloneNode(true)` on it. The style element will also have the following - # non-standard properties: - # * `sourcePath` A {String} containing the path from which the style - # element was loaded. - # * `context` A {String} indicating the target context of the style - # element. - # - # Returns a {Disposable} on which `.dispose()` can be called to cancel the - # subscription. - observeStyleElements: (callback) -> - callback(styleElement) for styleElement in @getStyleElements() - @onDidAddStyleElement(callback) - - # Extended: Invoke `callback` when a style element is added. - # - # * `callback` {Function} that is called with style elements. - # * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property - # will be null because this element isn't attached to the DOM. If you want - # to attach this element to the DOM, be sure to clone it first by calling - # `.cloneNode(true)` on it. The style element will also have the following - # non-standard properties: - # * `sourcePath` A {String} containing the path from which the style - # element was loaded. - # * `context` A {String} indicating the target context of the style - # element. - # - # Returns a {Disposable} on which `.dispose()` can be called to cancel the - # subscription. - onDidAddStyleElement: (callback) -> - @emitter.on 'did-add-style-element', callback - - # Extended: Invoke `callback` when a style element is removed. - # - # * `callback` {Function} that is called with style elements. - # * `styleElement` An `HTMLStyleElement` instance. - # - # Returns a {Disposable} on which `.dispose()` can be called to cancel the - # subscription. - onDidRemoveStyleElement: (callback) -> - @emitter.on 'did-remove-style-element', callback - - # Extended: Invoke `callback` when an existing style element is updated. - # - # * `callback` {Function} that is called with style elements. - # * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property - # will be null because this element isn't attached to the DOM. The style - # element will also have the following non-standard properties: - # * `sourcePath` A {String} containing the path from which the style - # element was loaded. - # * `context` A {String} indicating the target context of the style - # element. - # - # Returns a {Disposable} on which `.dispose()` can be called to cancel the - # subscription. - onDidUpdateStyleElement: (callback) -> - @emitter.on 'did-update-style-element', callback - - ### - Section: Reading Style Elements - ### - - # Extended: Get all loaded style elements. - getStyleElements: -> - @styleElements.slice() - - addStyleSheet: (source, params) -> - sourcePath = params?.sourcePath - context = params?.context - priority = params?.priority - - if sourcePath? and styleElement = @styleElementsBySourcePath[sourcePath] - updated = true - else - styleElement = document.createElement('style') - if sourcePath? - styleElement.sourcePath = sourcePath - styleElement.setAttribute('source-path', sourcePath) - - if context? - styleElement.context = context - styleElement.setAttribute('context', context) - - if priority? - styleElement.priority = priority - styleElement.setAttribute('priority', priority) - - styleElement.textContent = source - - if updated - @emitter.emit 'did-update-style-element', styleElement - else - @addStyleElement(styleElement) - - new Disposable => @removeStyleElement(styleElement) - - addStyleElement: (styleElement) -> - {sourcePath, priority} = styleElement - - if priority? - for existingElement, index in @styleElements - if existingElement.priority > priority - insertIndex = index - break - - insertIndex ?= @styleElements.length - - @styleElements.splice(insertIndex, 0, styleElement) - @styleElementsBySourcePath[sourcePath] ?= styleElement if sourcePath? - @emitter.emit 'did-add-style-element', styleElement - - removeStyleElement: (styleElement) -> - index = @styleElements.indexOf(styleElement) - unless index is -1 - @styleElements.splice(index, 1) - delete @styleElementsBySourcePath[styleElement.sourcePath] if styleElement.sourcePath? - @emitter.emit 'did-remove-style-element', styleElement - - getSnapshot: -> - @styleElements.slice() - - restoreSnapshot: (styleElementsToRestore) -> - for styleElement in @getStyleElements() - @removeStyleElement(styleElement) unless styleElement in styleElementsToRestore - - existingStyleElements = @getStyleElements() - for styleElement in styleElementsToRestore - @addStyleElement(styleElement) unless styleElement in existingStyleElements - - return - - ### - Section: Paths - ### - - # Extended: Get the path of the user style sheet in `~/.atom`. - # - # Returns a {String}. - getUserStyleSheetPath: -> - stylesheetPath = fs.resolve(path.join(atom.getConfigDirPath(), 'styles'), ['css', 'less']) - if fs.isFileSync(stylesheetPath) - stylesheetPath - else - path.join(atom.getConfigDirPath(), 'styles.less') diff --git a/src/style-manager.js b/src/style-manager.js new file mode 100644 index 00000000000..fa4d3798126 --- /dev/null +++ b/src/style-manager.js @@ -0,0 +1,378 @@ +const { Emitter, Disposable } = require('event-kit'); +const crypto = require('crypto'); +const fs = require('fs-plus'); +const path = require('path'); +const postcss = require('postcss'); +const selectorParser = require('postcss-selector-parser'); +const { createStylesElement } = require('./styles-element'); +const DEPRECATED_SYNTAX_SELECTORS = require('./deprecated-syntax-selectors'); + +// Extended: A singleton instance of this class available via `atom.styles`, +// which you can use to globally query and observe the set of active style +// sheets. The `StyleManager` doesn't add any style elements to the DOM on its +// own, but is instead subscribed to by individual `` elements, +// which clone and attach style elements in different contexts. +module.exports = class StyleManager { + constructor() { + this.emitter = new Emitter(); + this.styleElements = []; + this.styleElementsBySourcePath = {}; + this.deprecationsBySourcePath = {}; + } + + initialize({ configDirPath }) { + this.configDirPath = configDirPath; + if (this.configDirPath != null) { + this.cacheDirPath = path.join( + this.configDirPath, + 'compile-cache', + 'style-manager' + ); + } + } + + /* + Section: Event Subscription + */ + + // Extended: Invoke `callback` for all current and future style elements. + // + // * `callback` {Function} that is called with style elements. + // * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property + // will be null because this element isn't attached to the DOM. If you want + // to attach this element to the DOM, be sure to clone it first by calling + // `.cloneNode(true)` on it. The style element will also have the following + // non-standard properties: + // * `sourcePath` A {String} containing the path from which the style + // element was loaded. + // * `context` A {String} indicating the target context of the style + // element. + // + // Returns a {Disposable} on which `.dispose()` can be called to cancel the + // subscription. + observeStyleElements(callback) { + for (let styleElement of this.getStyleElements()) { + callback(styleElement); + } + + return this.onDidAddStyleElement(callback); + } + + // Extended: Invoke `callback` when a style element is added. + // + // * `callback` {Function} that is called with style elements. + // * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property + // will be null because this element isn't attached to the DOM. If you want + // to attach this element to the DOM, be sure to clone it first by calling + // `.cloneNode(true)` on it. The style element will also have the following + // non-standard properties: + // * `sourcePath` A {String} containing the path from which the style + // element was loaded. + // * `context` A {String} indicating the target context of the style + // element. + // + // Returns a {Disposable} on which `.dispose()` can be called to cancel the + // subscription. + onDidAddStyleElement(callback) { + return this.emitter.on('did-add-style-element', callback); + } + + // Extended: Invoke `callback` when a style element is removed. + // + // * `callback` {Function} that is called with style elements. + // * `styleElement` An `HTMLStyleElement` instance. + // + // Returns a {Disposable} on which `.dispose()` can be called to cancel the + // subscription. + onDidRemoveStyleElement(callback) { + return this.emitter.on('did-remove-style-element', callback); + } + + // Extended: Invoke `callback` when an existing style element is updated. + // + // * `callback` {Function} that is called with style elements. + // * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property + // will be null because this element isn't attached to the DOM. The style + // element will also have the following non-standard properties: + // * `sourcePath` A {String} containing the path from which the style + // element was loaded. + // * `context` A {String} indicating the target context of the style + // element. + // + // Returns a {Disposable} on which `.dispose()` can be called to cancel the + // subscription. + onDidUpdateStyleElement(callback) { + return this.emitter.on('did-update-style-element', callback); + } + + onDidUpdateDeprecations(callback) { + return this.emitter.on('did-update-deprecations', callback); + } + + /* + Section: Reading Style Elements + */ + + // Extended: Get all loaded style elements. + getStyleElements() { + return this.styleElements.slice(); + } + + addStyleSheet(source, params = {}) { + let styleElement; + let updated; + if ( + params.sourcePath != null && + this.styleElementsBySourcePath[params.sourcePath] != null + ) { + updated = true; + styleElement = this.styleElementsBySourcePath[params.sourcePath]; + } else { + updated = false; + styleElement = document.createElement('style'); + if (params.sourcePath != null) { + styleElement.sourcePath = params.sourcePath; + styleElement.setAttribute('source-path', params.sourcePath); + } + if (params.context != null) { + styleElement.context = params.context; + styleElement.setAttribute('context', params.context); + } + if (params.priority != null) { + styleElement.priority = params.priority; + styleElement.setAttribute('priority', params.priority); + } + } + + if (params.skipDeprecatedSelectorsTransformation) { + styleElement.textContent = source; + } else { + const transformed = this.upgradeDeprecatedSelectorsForStyleSheet( + source, + params.context + ); + styleElement.textContent = transformed.source; + if (transformed.deprecationMessage) { + this.deprecationsBySourcePath[params.sourcePath] = { + message: transformed.deprecationMessage + }; + this.emitter.emit('did-update-deprecations'); + } + } + + if (updated) { + this.emitter.emit('did-update-style-element', styleElement); + } else { + this.addStyleElement(styleElement); + } + return new Disposable(() => { + this.removeStyleElement(styleElement); + }); + } + + addStyleElement(styleElement) { + let insertIndex = this.styleElements.length; + if (styleElement.priority != null) { + for (let i = 0; i < this.styleElements.length; i++) { + const existingElement = this.styleElements[i]; + if (existingElement.priority > styleElement.priority) { + insertIndex = i; + break; + } + } + } + + this.styleElements.splice(insertIndex, 0, styleElement); + if ( + styleElement.sourcePath != null && + this.styleElementsBySourcePath[styleElement.sourcePath] == null + ) { + this.styleElementsBySourcePath[styleElement.sourcePath] = styleElement; + } + this.emitter.emit('did-add-style-element', styleElement); + } + + removeStyleElement(styleElement) { + const index = this.styleElements.indexOf(styleElement); + if (index !== -1) { + this.styleElements.splice(index, 1); + if (styleElement.sourcePath != null) { + delete this.styleElementsBySourcePath[styleElement.sourcePath]; + } + this.emitter.emit('did-remove-style-element', styleElement); + } + } + + upgradeDeprecatedSelectorsForStyleSheet(styleSheet, context) { + if (this.cacheDirPath != null) { + const hash = crypto.createHash('sha1'); + if (context != null) { + hash.update(context); + } + hash.update(styleSheet); + const cacheFilePath = path.join(this.cacheDirPath, hash.digest('hex')); + try { + return JSON.parse(fs.readFileSync(cacheFilePath)); + } catch (e) { + const transformed = transformDeprecatedShadowDOMSelectors( + styleSheet, + context + ); + fs.writeFileSync(cacheFilePath, JSON.stringify(transformed)); + return transformed; + } + } else { + return transformDeprecatedShadowDOMSelectors(styleSheet, context); + } + } + + getDeprecations() { + return this.deprecationsBySourcePath; + } + + clearDeprecations() { + this.deprecationsBySourcePath = {}; + } + + getSnapshot() { + return this.styleElements.slice(); + } + + restoreSnapshot(styleElementsToRestore) { + for (let styleElement of this.getStyleElements()) { + if (!styleElementsToRestore.includes(styleElement)) { + this.removeStyleElement(styleElement); + } + } + + const existingStyleElements = this.getStyleElements(); + for (let styleElement of styleElementsToRestore) { + if (!existingStyleElements.includes(styleElement)) { + this.addStyleElement(styleElement); + } + } + } + + buildStylesElement() { + const stylesElement = createStylesElement(); + stylesElement.initialize(this); + return stylesElement; + } + + /* + Section: Paths + */ + + // Extended: Get the path of the user style sheet in `~/.atom`. + // + // Returns a {String}. + getUserStyleSheetPath() { + if (this.configDirPath == null) { + return ''; + } else { + const stylesheetPath = fs.resolve( + path.join(this.configDirPath, 'styles'), + ['css', 'less'] + ); + if (fs.isFileSync(stylesheetPath)) { + return stylesheetPath; + } else { + return path.join(this.configDirPath, 'styles.less'); + } + } + } +}; + +function transformDeprecatedShadowDOMSelectors(css, context) { + const transformedSelectors = []; + let transformedSource; + try { + transformedSource = postcss.parse(css); + } catch (e) { + transformedSource = null; + } + + if (transformedSource) { + transformedSource.walkRules(rule => { + const transformedSelector = selectorParser(selectors => { + selectors.each(selector => { + const firstNode = selector.nodes[0]; + if ( + context === 'atom-text-editor' && + firstNode.type === 'pseudo' && + firstNode.value === ':host' + ) { + const atomTextEditorElementNode = selectorParser.tag({ + value: 'atom-text-editor' + }); + firstNode.replaceWith(atomTextEditorElementNode); + } + + let previousNodeIsAtomTextEditor = false; + let targetsAtomTextEditorShadow = context === 'atom-text-editor'; + let previousNode; + selector.each(node => { + if (targetsAtomTextEditorShadow && node.type === 'class') { + if (DEPRECATED_SYNTAX_SELECTORS.has(node.value)) { + node.value = `syntax--${node.value}`; + } + } else { + if ( + previousNodeIsAtomTextEditor && + node.type === 'pseudo' && + node.value === '::shadow' + ) { + node.type = 'className'; + node.value = '.editor'; + targetsAtomTextEditorShadow = true; + } + } + previousNode = node; + if (node.type === 'combinator') { + previousNodeIsAtomTextEditor = false; + } else if ( + previousNode.type === 'tag' && + previousNode.value === 'atom-text-editor' + ) { + previousNodeIsAtomTextEditor = true; + } + }); + }); + }).processSync(rule.selector, { lossless: true }); + if (transformedSelector !== rule.selector) { + transformedSelectors.push({ + before: rule.selector, + after: transformedSelector + }); + rule.selector = transformedSelector; + } + }); + + let deprecationMessage; + if (transformedSelectors.length > 0) { + deprecationMessage = + 'Starting from Atom v1.13.0, the contents of `atom-text-editor` elements '; + deprecationMessage += + 'are no longer encapsulated within a shadow DOM boundary. '; + deprecationMessage += + 'This means you should stop using `:host` and `::shadow` '; + deprecationMessage += + 'pseudo-selectors, and prepend all your syntax selectors with `syntax--`. '; + deprecationMessage += + 'To prevent breakage with existing style sheets, Atom will automatically '; + deprecationMessage += 'upgrade the following selectors:\n\n'; + deprecationMessage += + transformedSelectors + .map(selector => `* \`${selector.before}\` => \`${selector.after}\``) + .join('\n\n') + '\n\n'; + deprecationMessage += + 'Automatic translation of selectors will be removed in a few release cycles to minimize startup time. '; + deprecationMessage += + 'Please, make sure to upgrade the above selectors as soon as possible.'; + } + return { source: transformedSource.toString(), deprecationMessage }; + } else { + // CSS was malformed so we don't transform it. + return { source: css }; + } +} diff --git a/src/styles-element.coffee b/src/styles-element.coffee deleted file mode 100644 index 74ebd23baa3..00000000000 --- a/src/styles-element.coffee +++ /dev/null @@ -1,119 +0,0 @@ -{Emitter, CompositeDisposable} = require 'event-kit' -{includeDeprecatedAPIs} = require 'grim' - -class StylesElement extends HTMLElement - subscriptions: null - context: null - - onDidAddStyleElement: (callback) -> - @emitter.on 'did-add-style-element', callback - - onDidRemoveStyleElement: (callback) -> - @emitter.on 'did-remove-style-element', callback - - onDidUpdateStyleElement: (callback) -> - @emitter.on 'did-update-style-element', callback - - createdCallback: -> - @emitter = new Emitter - @styleElementClonesByOriginalElement = new WeakMap - - attachedCallback: -> - if includeDeprecatedAPIs and @context is 'atom-text-editor' - for styleElement in @children - @upgradeDeprecatedSelectors(styleElement) - @initialize() - - detachedCallback: -> - @subscriptions.dispose() - @subscriptions = null - - attributeChangedCallback: (attrName, oldVal, newVal) -> - @contextChanged() if attrName is 'context' - - initialize: -> - return if @subscriptions? - - @subscriptions = new CompositeDisposable - @context = @getAttribute('context') ? undefined - - @subscriptions.add atom.styles.observeStyleElements(@styleElementAdded.bind(this)) - @subscriptions.add atom.styles.onDidRemoveStyleElement(@styleElementRemoved.bind(this)) - @subscriptions.add atom.styles.onDidUpdateStyleElement(@styleElementUpdated.bind(this)) - - contextChanged: -> - return unless @subscriptions? - - @styleElementRemoved(child) for child in Array::slice.call(@children) - @context = @getAttribute('context') - @styleElementAdded(styleElement) for styleElement in atom.styles.getStyleElements() - return - - styleElementAdded: (styleElement) -> - return unless @styleElementMatchesContext(styleElement) - - styleElementClone = styleElement.cloneNode(true) - styleElementClone.sourcePath = styleElement.sourcePath - styleElementClone.context = styleElement.context - styleElementClone.priority = styleElement.priority - @styleElementClonesByOriginalElement.set(styleElement, styleElementClone) - - priority = styleElement.priority - if priority? - for child in @children - if child.priority > priority - insertBefore = child - break - - @insertBefore(styleElementClone, insertBefore) - - if includeDeprecatedAPIs and @context is 'atom-text-editor' - @upgradeDeprecatedSelectors(styleElementClone) - - @emitter.emit 'did-add-style-element', styleElementClone - - styleElementRemoved: (styleElement) -> - return unless @styleElementMatchesContext(styleElement) - - styleElementClone = @styleElementClonesByOriginalElement.get(styleElement) ? styleElement - styleElementClone.remove() - @emitter.emit 'did-remove-style-element', styleElementClone - - styleElementUpdated: (styleElement) -> - return unless @styleElementMatchesContext(styleElement) - - styleElementClone = @styleElementClonesByOriginalElement.get(styleElement) - styleElementClone.textContent = styleElement.textContent - @emitter.emit 'did-update-style-element', styleElementClone - - styleElementMatchesContext: (styleElement) -> - not @context? or styleElement.context is @context - - upgradeDeprecatedSelectors: (styleElement) -> - return unless styleElement.sheet? - - upgradedSelectors = [] - - for rule in styleElement.sheet.cssRules - continue unless rule.selectorText? - continue if /\:host/.test(rule.selectorText) - - inputSelector = rule.selectorText - outputSelector = rule.selectorText - .replace(/\.editor-colors($|[ >])/g, ':host$1') - .replace(/\.editor([:.][^ ,>]+)/g, ':host($1)') - .replace(/\.editor($|[ ,>])/g, ':host$1') - - unless inputSelector is outputSelector - rule.selectorText = outputSelector - upgradedSelectors.push({inputSelector, outputSelector}) - - if upgradedSelectors.length > 0 - warning = "Upgraded the following syntax theme selectors in `#{styleElement.sourcePath}` for shadow DOM compatibility:\n\n" - for {inputSelector, outputSelector} in upgradedSelectors - warning += "`#{inputSelector}` => `#{outputSelector}`\n" - - warning += "\nSee the upgrade guide for information on removing this warning." - console.warn(warning) - -module.exports = StylesElement = document.registerElement 'atom-styles', prototype: StylesElement.prototype diff --git a/src/styles-element.js b/src/styles-element.js new file mode 100644 index 00000000000..94c287dc036 --- /dev/null +++ b/src/styles-element.js @@ -0,0 +1,151 @@ +const { Emitter, CompositeDisposable } = require('event-kit'); + +class StylesElement extends HTMLElement { + constructor() { + super(); + this.subscriptions = new CompositeDisposable(); + this.emitter = new Emitter(); + this.styleElementClonesByOriginalElement = new WeakMap(); + this.context = null; + } + + onDidAddStyleElement(callback) { + this.emitter.on('did-add-style-element', callback); + } + + onDidRemoveStyleElement(callback) { + this.emitter.on('did-remove-style-element', callback); + } + + onDidUpdateStyleElement(callback) { + this.emitter.on('did-update-style-element', callback); + } + + connectedCallback() { + let left; + this.context = + (left = this.getAttribute('context')) != null ? left : undefined; + } + + disconnectedCallback() { + this.subscriptions.dispose(); + this.subscriptions = new CompositeDisposable(); + } + + static get observedAttributes() { + return ['context']; + } + + attributeChangedCallback(attrName) { + if (attrName === 'context') { + return this.contextChanged(); + } + } + + initialize(styleManager) { + this.styleManager = styleManager; + if (this.styleManager == null) { + throw new Error( + 'Must pass a styleManager parameter when initializing a StylesElement' + ); + } + + this.subscriptions.add( + this.styleManager.observeStyleElements(this.styleElementAdded.bind(this)) + ); + this.subscriptions.add( + this.styleManager.onDidRemoveStyleElement( + this.styleElementRemoved.bind(this) + ) + ); + this.subscriptions.add( + this.styleManager.onDidUpdateStyleElement( + this.styleElementUpdated.bind(this) + ) + ); + } + + contextChanged() { + if (this.subscriptions == null) { + return; + } + + for (let child of Array.from(Array.prototype.slice.call(this.children))) { + this.styleElementRemoved(child); + } + this.context = this.getAttribute('context'); + for (let styleElement of Array.from(this.styleManager.getStyleElements())) { + this.styleElementAdded(styleElement); + } + } + + styleElementAdded(styleElement) { + let insertBefore; + if (!this.styleElementMatchesContext(styleElement)) { + return; + } + + const styleElementClone = styleElement.cloneNode(true); + styleElementClone.sourcePath = styleElement.sourcePath; + styleElementClone.context = styleElement.context; + styleElementClone.priority = styleElement.priority; + this.styleElementClonesByOriginalElement.set( + styleElement, + styleElementClone + ); + + const { priority } = styleElement; + if (priority != null) { + for (let child of this.children) { + if (child.priority > priority) { + insertBefore = child; + break; + } + } + } + + this.insertBefore(styleElementClone, insertBefore); + this.emitter.emit('did-add-style-element', styleElementClone); + } + + styleElementRemoved(styleElement) { + let left; + if (!this.styleElementMatchesContext(styleElement)) { + return; + } + + const styleElementClone = + (left = this.styleElementClonesByOriginalElement.get(styleElement)) != + null + ? left + : styleElement; + styleElementClone.remove(); + this.emitter.emit('did-remove-style-element', styleElementClone); + } + + styleElementUpdated(styleElement) { + if (!this.styleElementMatchesContext(styleElement)) { + return; + } + + const styleElementClone = this.styleElementClonesByOriginalElement.get( + styleElement + ); + styleElementClone.textContent = styleElement.textContent; + this.emitter.emit('did-update-style-element', styleElementClone); + } + + styleElementMatchesContext(styleElement) { + return this.context == null || styleElement.context === this.context; + } +} + +window.customElements.define('atom-styles', StylesElement); + +function createStylesElement() { + return document.createElement('atom-styles'); +} + +module.exports = { + createStylesElement +}; diff --git a/src/subscriber-mixin.coffee b/src/subscriber-mixin.coffee deleted file mode 100644 index b6817ce5369..00000000000 --- a/src/subscriber-mixin.coffee +++ /dev/null @@ -1,4 +0,0 @@ -{Subscriber} = require 'emissary' -SubscriberMixin = componentDidUnmount: -> @unsubscribe() -Subscriber.extend(SubscriberMixin) -module.exports = SubscriberMixin diff --git a/src/syntax-scope-map.js b/src/syntax-scope-map.js new file mode 100644 index 00000000000..fe3f07dd71a --- /dev/null +++ b/src/syntax-scope-map.js @@ -0,0 +1,182 @@ +const parser = require('postcss-selector-parser'); + +module.exports = class SyntaxScopeMap { + constructor(resultsBySelector) { + this.namedScopeTable = {}; + this.anonymousScopeTable = {}; + for (let selector in resultsBySelector) { + this.addSelector(selector, resultsBySelector[selector]); + } + setTableDefaults(this.namedScopeTable, true); + setTableDefaults(this.anonymousScopeTable, false); + } + + addSelector(selector, result) { + 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).replace(/\\"/g, '"'); + 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.result = result; + } + }).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.result != null) { + result = currentTable.result; + } + + if (i === 0) break; + i--; + currentTable = + currentTable.parents && + (currentTable.parents[nodeTypes[i]] || currentTable.parents['*']); + } + + return result; + } +}; + +function setTableDefaults(table, allowWildcardSelector) { + const defaultTypeTable = allowWildcardSelector ? table['*'] : null; + + for (let type in table) { + let typeTable = table[type]; + if (typeTable === defaultTypeTable) continue; + + if (defaultTypeTable) { + mergeTable(typeTable, defaultTypeTable); + } + + if (typeTable.parents) { + setTableDefaults(typeTable.parents, true); + } + + for (let key in typeTable.indices) { + const indexTable = typeTable.indices[key]; + mergeTable(indexTable, typeTable, false); + if (indexTable.parents) { + setTableDefaults(indexTable.parents, true); + } + } + } +} + +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.result != null && table.result == null) { + table.result = defaultTable.result; + } +} + +function rejectSelector(selector) { + throw new TypeError(`Unsupported selector '${selector}'`); +} diff --git a/src/task-bootstrap.coffee b/src/task-bootstrap.coffee deleted file mode 100644 index ebb5cdc2b03..00000000000 --- a/src/task-bootstrap.coffee +++ /dev/null @@ -1,54 +0,0 @@ -{userAgent, taskPath} = process.env -handler = null - -setupGlobals = -> - global.attachEvent = -> - console = - warn: -> emit 'task:warn', arguments... - log: -> emit 'task:log', arguments... - error: -> emit 'task:error', arguments... - trace: -> - global.__defineGetter__ 'console', -> console - - global.document = - createElement: -> - setAttribute: -> - getElementsByTagName: -> [] - appendChild: -> - documentElement: - insertBefore: -> - removeChild: -> - getElementById: -> {} - createComment: -> {} - createDocumentFragment: -> {} - - global.emit = (event, args...) -> - process.send({event, args}) - global.navigator = {userAgent} - global.window = global - -handleEvents = -> - process.on 'uncaughtException', (error) -> - console.error(error.message, error.stack) - process.on 'message', ({event, args}={}) -> - return unless event is 'start' - - isAsync = false - async = -> - isAsync = true - (result) -> - emit('task:completed', result) - result = handler.bind({async})(args...) - emit('task:completed', result) unless isAsync - -setupDeprecations = -> - Grim = require 'grim' - Grim.on 'updated', -> - deprecations = Grim.getDeprecations().map (deprecation) -> deprecation.serialize() - Grim.clearDeprecations() - emit('task:deprecations', deprecations) - -setupGlobals() -handleEvents() -setupDeprecations() -handler = require(taskPath) diff --git a/src/task-bootstrap.js b/src/task-bootstrap.js new file mode 100644 index 00000000000..1edbce8faa6 --- /dev/null +++ b/src/task-bootstrap.js @@ -0,0 +1,90 @@ +const { userAgent } = process.env; +const [compileCachePath, taskPath] = process.argv.slice(2); + +const CompileCache = require('./compile-cache'); +CompileCache.setCacheDirectory(compileCachePath); +CompileCache.install(`${process.resourcesPath}`, require); + +const setupGlobals = function() { + global.attachEvent = function() {}; + const console = { + warn() { + return global.emit('task:warn', ...arguments); + }, + log() { + return global.emit('task:log', ...arguments); + }, + error() { + return global.emit('task:error', ...arguments); + }, + trace() {} + }; + global.__defineGetter__('console', () => console); + + global.document = { + createElement() { + return { + setAttribute() {}, + getElementsByTagName() { + return []; + }, + appendChild() {} + }; + }, + documentElement: { + insertBefore() {}, + removeChild() {} + }, + getElementById() { + return {}; + }, + createComment() { + return {}; + }, + createDocumentFragment() { + return {}; + } + }; + + global.emit = (event, ...args) => process.send({ event, args }); + global.navigator = { userAgent }; + return (global.window = global); +}; + +const handleEvents = function() { + process.on('uncaughtException', error => + console.error(error.message, error.stack) + ); + + return process.on('message', function({ event, args } = {}) { + if (event !== 'start') { + return; + } + + let isAsync = false; + const async = function() { + isAsync = true; + return result => global.emit('task:completed', result); + }; + const result = handler.bind({ async })(...args); + if (!isAsync) { + return global.emit('task:completed', result); + } + }); +}; + +const setupDeprecations = function() { + const Grim = require('grim'); + return Grim.on('updated', function() { + const deprecations = Grim.getDeprecations().map(deprecation => + deprecation.serialize() + ); + Grim.clearDeprecations(); + return global.emit('task:deprecations', deprecations); + }); +}; + +setupGlobals(); +handleEvents(); +setupDeprecations(); +const handler = require(taskPath); diff --git a/src/task.coffee b/src/task.coffee index d752ea11dde..fa09c69f103 100644 --- a/src/task.coffee +++ b/src/task.coffee @@ -1,6 +1,6 @@ _ = require 'underscore-plus' -{fork} = require 'child_process' -{Emitter} = require 'emissary' +ChildProcess = require 'child_process' +{Emitter} = require 'event-kit' Grim = require 'grim' # Extended: Run a node script in a separate process. @@ -38,8 +38,6 @@ Grim = require 'grim' # ``` module.exports = class Task - Emitter.includeInto(this) - # Public: A helper method to easily launch and run a task once. # # * `taskPath` The {String} path to the CoffeeScript/JavaScript file which @@ -66,24 +64,13 @@ class Task # * `taskPath` The {String} path to the CoffeeScript/JavaScript file that # exports a single {Function} to execute. constructor: (taskPath) -> - coffeeCacheRequire = "require('#{require.resolve('coffee-cash')}')" - coffeeCachePath = require('coffee-cash').getCacheDirectory() - coffeeStackRequire = "require('#{require.resolve('coffeestack')}')" - stackCachePath = require('coffeestack').getCacheDirectory() - taskBootstrapRequire = "require('#{require.resolve('./task-bootstrap')}');" - bootstrap = """ - #{coffeeCacheRequire}.setCacheDirectory('#{coffeeCachePath}'); - #{coffeeCacheRequire}.register(); - #{coffeeStackRequire}.setCacheDirectory('#{stackCachePath}'); - #{taskBootstrapRequire} - """ - bootstrap = bootstrap.replace(/\\/g, "\\\\") + @emitter = new Emitter + compileCachePath = require('./compile-cache').getCacheDirectory() taskPath = require.resolve(taskPath) - taskPath = taskPath.replace(/\\/g, "\\\\") - env = _.extend({}, process.env, {taskPath, userAgent: navigator.userAgent}) - @childProcess = fork '--eval', [bootstrap], {env, silent: true} + env = Object.assign({}, process.env, {userAgent: navigator.userAgent}) + @childProcess = ChildProcess.fork require.resolve('./task-bootstrap'), [compileCachePath, taskPath], {env, silent: true} @on "task:log", -> console.log(arguments...) @on "task:warn", -> console.warn(arguments...) @@ -99,12 +86,16 @@ class Task handleEvents: -> @childProcess.removeAllListeners() @childProcess.on 'message', ({event, args}) => - @emit(event, args...) if @childProcess? + @emitter.emit(event, args) if @childProcess? + # Catch the errors that happened before task-bootstrap. - @childProcess.stdout.on 'data', (data) -> - console.log data.toString() - @childProcess.stderr.on 'data', (data) -> - console.error data.toString() + if @childProcess.stdout? + @childProcess.stdout.removeAllListeners() + @childProcess.stdout.on 'data', (data) -> console.log data.toString() + + if @childProcess.stderr? + @childProcess.stderr.removeAllListeners() + @childProcess.stderr.on 'data', (data) -> console.error data.toString() # Public: Starts the task. # @@ -143,16 +134,32 @@ class Task # * `callback` The {Function} to call when the event is emitted. # # Returns a {Disposable} that can be used to stop listening for the event. - on: (eventName, callback) -> Emitter::on.call(this, eventName, callback) + on: (eventName, callback) -> @emitter.on eventName, (args) -> callback(args...) + + once: (eventName, callback) -> + disposable = @on eventName, (args...) -> + disposable.dispose() + callback(args...) # Public: Forcefully stop the running task. # # No more events are emitted once this method is called. terminate: -> - return unless @childProcess? + return false unless @childProcess? @childProcess.removeAllListeners() + @childProcess.stdout?.removeAllListeners() + @childProcess.stderr?.removeAllListeners() @childProcess.kill() @childProcess = null - undefined + true + + # Public: Cancel the running task and emit an event if it was canceled. + # + # Returns a {Boolean} indicating whether the task was terminated. + cancel: -> + didForcefullyTerminate = @terminate() + if didForcefullyTerminate + @emitter.emit('task:cancelled') + didForcefullyTerminate diff --git a/src/test.ejs b/src/test.ejs new file mode 100644 index 00000000000..7b93c31b320 --- /dev/null +++ b/src/test.ejs @@ -0,0 +1,9 @@ + + +<% if something() { %> +
    + <%= html `ok how about this` %> +
    +<% } %> + + diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee deleted file mode 100644 index eb01e0f2391..00000000000 --- a/src/text-editor-component.coffee +++ /dev/null @@ -1,800 +0,0 @@ -_ = require 'underscore-plus' -scrollbarStyle = require 'scrollbar-style' -{Range, Point} = require 'text-buffer' -grim = require 'grim' -{CompositeDisposable} = require 'event-kit' -ipc = require 'ipc' - -TextEditorPresenter = require './text-editor-presenter' -GutterContainerComponent = require './gutter-container-component' -InputComponent = require './input-component' -LinesComponent = require './lines-component' -ScrollbarComponent = require './scrollbar-component' -ScrollbarCornerComponent = require './scrollbar-corner-component' -OverlayManager = require './overlay-manager' - -module.exports = -class TextEditorComponent - scrollSensitivity: 0.4 - cursorBlinkPeriod: 800 - cursorBlinkResumeDelay: 100 - lineOverdrawMargin: 15 - - pendingScrollTop: null - pendingScrollLeft: null - updateRequested: false - updatesPaused: false - updateRequestedWhilePaused: false - heightAndWidthMeasurementRequested: false - cursorMoved: false - selectionChanged: false - inputEnabled: true - measureScrollbarsWhenShown: true - measureLineHeightAndDefaultCharWidthWhenShown: true - remeasureCharacterWidthsWhenShown: false - stylingChangeAnimationFrameRequested: false - gutterComponent: null - mounted: true - - constructor: ({@editor, @hostElement, @rootElement, @stylesElement, @useShadowDOM, lineOverdrawMargin}) -> - @lineOverdrawMargin = lineOverdrawMargin if lineOverdrawMargin? - @disposables = new CompositeDisposable - - @observeConfig() - @setScrollSensitivity(atom.config.get('editor.scrollSensitivity')) - - @presenter = new TextEditorPresenter - model: @editor - scrollTop: @editor.getScrollTop() - scrollLeft: @editor.getScrollLeft() - lineOverdrawMargin: lineOverdrawMargin - cursorBlinkPeriod: @cursorBlinkPeriod - cursorBlinkResumeDelay: @cursorBlinkResumeDelay - stoppedScrollingDelay: 200 - - @presenter.onDidUpdateState(@requestUpdate) - - @domNode = document.createElement('div') - if @useShadowDOM - @domNode.classList.add('editor-contents--private') - - insertionPoint = document.createElement('content') - insertionPoint.setAttribute('select', 'atom-overlay') - @domNode.appendChild(insertionPoint) - @overlayManager = new OverlayManager(@presenter, @hostElement) - else - @domNode.classList.add('editor-contents') - @overlayManager = new OverlayManager(@presenter, @domNode) - - @scrollViewNode = document.createElement('div') - @scrollViewNode.classList.add('scroll-view') - @domNode.appendChild(@scrollViewNode) - - @mountGutterContainerComponent() if @presenter.getState().gutters.length - - @hiddenInputComponent = new InputComponent - @scrollViewNode.appendChild(@hiddenInputComponent.getDomNode()) - - @linesComponent = new LinesComponent({@presenter, @hostElement, @useShadowDOM}) - @scrollViewNode.appendChild(@linesComponent.getDomNode()) - - @horizontalScrollbarComponent = new ScrollbarComponent({orientation: 'horizontal', onScroll: @onHorizontalScroll}) - @scrollViewNode.appendChild(@horizontalScrollbarComponent.getDomNode()) - - @verticalScrollbarComponent = new ScrollbarComponent({orientation: 'vertical', onScroll: @onVerticalScroll}) - @domNode.appendChild(@verticalScrollbarComponent.getDomNode()) - - @scrollbarCornerComponent = new ScrollbarCornerComponent - @domNode.appendChild(@scrollbarCornerComponent.getDomNode()) - - @observeEditor() - @listenForDOMEvents() - - @disposables.add @stylesElement.onDidAddStyleElement @onStylesheetsChanged - @disposables.add @stylesElement.onDidUpdateStyleElement @onStylesheetsChanged - @disposables.add @stylesElement.onDidRemoveStyleElement @onStylesheetsChanged - unless atom.themes.isInitialLoadComplete() - @disposables.add atom.themes.onDidChangeActiveThemes @onAllThemesLoaded - @disposables.add scrollbarStyle.onDidChangePreferredScrollbarStyle @refreshScrollbars - - @disposables.add atom.views.pollDocument(@pollDOM) - - @updateSync() - @checkForVisibilityChange() - - destroy: -> - @mounted = false - @disposables.dispose() - @presenter.destroy() - window.removeEventListener 'resize', @requestHeightAndWidthMeasurement - - getDomNode: -> - @domNode - - updateSync: -> - @oldState ?= {} - @newState = @presenter.getState() - - cursorMoved = @cursorMoved - selectionChanged = @selectionChanged - @cursorMoved = false - @selectionChanged = false - - if @editor.getLastSelection()? and not @editor.getLastSelection().isEmpty() - @domNode.classList.add('has-selection') - else - @domNode.classList.remove('has-selection') - - if @newState.focused isnt @oldState.focused - @domNode.classList.toggle('is-focused', @newState.focused) - - @performedInitialMeasurement = false if @editor.isDestroyed() - - if @performedInitialMeasurement - if @newState.height isnt @oldState.height - if @newState.height? - @domNode.style.height = @newState.height + 'px' - else - @domNode.style.height = '' - - if @newState.gutters.length - @mountGutterContainerComponent() unless @gutterContainerComponent? - @gutterContainerComponent.updateSync(@newState) - else - @gutterContainerComponent?.getDomNode()?.remove() - @gutterContainerComponent = null - - @hiddenInputComponent.updateSync(@newState) - @linesComponent.updateSync(@newState) - @horizontalScrollbarComponent.updateSync(@newState) - @verticalScrollbarComponent.updateSync(@newState) - @scrollbarCornerComponent.updateSync(@newState) - - @overlayManager?.render(@newState) - - if @editor.isAlive() - @updateParentViewFocusedClassIfNeeded() - @updateParentViewMiniClass() - if grim.includeDeprecatedAPIs - @hostElement.__spacePenView.trigger 'cursor:moved' if cursorMoved - @hostElement.__spacePenView.trigger 'selection:changed' if selectionChanged - @hostElement.__spacePenView.trigger 'editor:display-updated' - - readAfterUpdateSync: => - @linesComponent.measureCharactersInNewLines() if @isVisible() and not @newState.content.scrollingVertically - @overlayManager?.measureOverlays() - - mountGutterContainerComponent: -> - @gutterContainerComponent = new GutterContainerComponent({@editor, @onLineNumberGutterMouseDown}) - @domNode.insertBefore(@gutterContainerComponent.getDomNode(), @domNode.firstChild) - - becameVisible: -> - @updatesPaused = true - @measureScrollbars() if @measureScrollbarsWhenShown - @sampleFontStyling() - @sampleBackgroundColors() - @measureWindowSize() - @measureDimensions() - @measureLineHeightAndDefaultCharWidth() if @measureLineHeightAndDefaultCharWidthWhenShown - @remeasureCharacterWidths() if @remeasureCharacterWidthsWhenShown - @editor.setVisible(true) - @performedInitialMeasurement = true - @updatesPaused = false - @updateSync() if @canUpdate() - - requestUpdate: => - return unless @canUpdate() - - if @updatesPaused - @updateRequestedWhilePaused = true - return - - if @hostElement.isUpdatedSynchronously() - @updateSync() - else unless @updateRequested - @updateRequested = true - atom.views.updateDocument => - @updateRequested = false - @updateSync() if @editor.isAlive() - atom.views.readDocument(@readAfterUpdateSync) - - canUpdate: -> - @mounted and @editor.isAlive() - - requestAnimationFrame: (fn) -> - @updatesPaused = true - requestAnimationFrame => - fn() - @updatesPaused = false - if @updateRequestedWhilePaused and @canUpdate() - @updateRequestedWhilePaused = false - @updateSync() - - getTopmostDOMNode: -> - @hostElement - - observeEditor: -> - @disposables.add @editor.observeGrammar(@onGrammarChanged) - @disposables.add @editor.observeCursors(@onCursorAdded) - @disposables.add @editor.observeSelections(@onSelectionAdded) - - listenForDOMEvents: -> - @domNode.addEventListener 'mousewheel', @onMouseWheel - @domNode.addEventListener 'textInput', @onTextInput - @scrollViewNode.addEventListener 'mousedown', @onMouseDown - @scrollViewNode.addEventListener 'scroll', @onScrollViewScroll - window.addEventListener 'resize', @requestHeightAndWidthMeasurement - - @listenForIMEEvents() - @trackSelectionClipboard() if process.platform is 'linux' - - listenForIMEEvents: -> - # The IME composition events work like this: - # - # User types 's', chromium pops up the completion helper - # 1. compositionstart fired - # 2. compositionupdate fired; event.data == 's' - # User hits arrow keys to move around in completion helper - # 3. compositionupdate fired; event.data == 's' for each arry key press - # User escape to cancel - # 4. compositionend fired - # OR User chooses a completion - # 4. compositionend fired - # 5. textInput fired; event.data == the completion string - - selectedText = null - @domNode.addEventListener 'compositionstart', => - selectedText = @editor.getSelectedText() - @domNode.addEventListener 'compositionupdate', (event) => - @editor.insertText(event.data, select: true, undo: 'skip') - @domNode.addEventListener 'compositionend', (event) => - @editor.insertText(selectedText, select: true, undo: 'skip') - event.target.value = '' - - # Listen for selection changes and store the currently selected text - # in the selection clipboard. This is only applicable on Linux. - trackSelectionClipboard: -> - timeoutId = null - writeSelectedTextToSelectionClipboard = => - return if @editor.isDestroyed() - if selectedText = @editor.getSelectedText() - # This uses ipc.send instead of clipboard.writeText because - # clipboard.writeText is a sync ipc call on Linux and that - # will slow down selections. - ipc.send('write-text-to-selection-clipboard', selectedText) - @disposables.add @editor.onDidChangeSelectionRange -> - clearTimeout(timeoutId) - timeoutId = setTimeout(writeSelectedTextToSelectionClipboard) - - observeConfig: -> - @disposables.add atom.config.onDidChange 'editor.fontSize', @sampleFontStyling - @disposables.add atom.config.onDidChange 'editor.fontFamily', @sampleFontStyling - @disposables.add atom.config.onDidChange 'editor.lineHeight', @sampleFontStyling - - onGrammarChanged: => - if @scopedConfigDisposables? - @scopedConfigDisposables.dispose() - @disposables.remove(@scopedConfigDisposables) - - @scopedConfigDisposables = new CompositeDisposable - @disposables.add(@scopedConfigDisposables) - - scope = @editor.getRootScopeDescriptor() - @scopedConfigDisposables.add atom.config.observe 'editor.scrollSensitivity', {scope}, @setScrollSensitivity - - focused: -> - if @mounted - @presenter.setFocused(true) - @hiddenInputComponent.getDomNode().focus() - - blurred: -> - if @mounted - @presenter.setFocused(false) - - onTextInput: (event) => - event.stopPropagation() - - # If we prevent the insertion of a space character, then the browser - # interprets the spacebar keypress as a page-down command. - event.preventDefault() unless event.data is ' ' - - return unless @isInputEnabled() - - inputNode = event.target - - # Work around of the accented character suggestion feature in OS X. - # Text input fires before a character is inserted, and if the browser is - # replacing the previous un-accented character with an accented variant, it - # will select backward over it. - selectedLength = inputNode.selectionEnd - inputNode.selectionStart - @editor.selectLeft() if selectedLength is 1 - - insertedRange = @editor.transact atom.config.get('editor.undoGroupingInterval'), => - @editor.insertText(event.data) - inputNode.value = event.data if insertedRange - - onVerticalScroll: (scrollTop) => - return if @updateRequested or scrollTop is @editor.getScrollTop() - - animationFramePending = @pendingScrollTop? - @pendingScrollTop = scrollTop - unless animationFramePending - @requestAnimationFrame => - pendingScrollTop = @pendingScrollTop - @pendingScrollTop = null - @presenter.setScrollTop(pendingScrollTop) - - onHorizontalScroll: (scrollLeft) => - return if @updateRequested or scrollLeft is @editor.getScrollLeft() - - animationFramePending = @pendingScrollLeft? - @pendingScrollLeft = scrollLeft - unless animationFramePending - @requestAnimationFrame => - @presenter.setScrollLeft(@pendingScrollLeft) - @pendingScrollLeft = null - - onMouseWheel: (event) => - # Only scroll in one direction at a time - {wheelDeltaX, wheelDeltaY} = event - - # Ctrl+MouseWheel adjusts font size. - if event.ctrlKey and atom.config.get('editor.zoomFontWhenCtrlScrolling') - if wheelDeltaY > 0 - atom.workspace.increaseFontSize() - else if wheelDeltaY < 0 - atom.workspace.decreaseFontSize() - event.preventDefault() - return - - if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) - # Scrolling horizontally - previousScrollLeft = @presenter.getScrollLeft() - @presenter.setScrollLeft(previousScrollLeft - Math.round(wheelDeltaX * @scrollSensitivity)) - event.preventDefault() unless previousScrollLeft is @presenter.getScrollLeft() - else - # Scrolling vertically - @presenter.setMouseWheelScreenRow(@screenRowForNode(event.target)) - previousScrollTop = @presenter.getScrollTop() - @presenter.setScrollTop(previousScrollTop - Math.round(wheelDeltaY * @scrollSensitivity)) - event.preventDefault() unless previousScrollTop is @presenter.getScrollTop() - - onScrollViewScroll: => - if @mounted - console.warn "TextEditorScrollView scrolled when it shouldn't have." - @scrollViewNode.scrollTop = 0 - @scrollViewNode.scrollLeft = 0 - - onMouseDown: (event) => - unless event.button is 0 or (event.button is 1 and process.platform is 'linux') - # Only handle mouse down events for left mouse button on all platforms - # and middle mouse button on Linux since it pastes the selection clipboard - return - - return if event.target?.classList.contains('horizontal-scrollbar') - - {detail, shiftKey, metaKey, ctrlKey} = event - - # CTRL+click brings up the context menu on OSX, so don't handle those either - return if ctrlKey and process.platform is 'darwin' - - # Prevent focusout event on hidden input if editor is already focused - event.preventDefault() if @oldState.focused - - screenPosition = @screenPositionForMouseEvent(event) - - if event.target?.classList.contains('fold-marker') - bufferRow = @editor.bufferRowForScreenRow(screenPosition.row) - @editor.unfoldBufferRow(bufferRow) - return - - switch detail - when 1 - if shiftKey - @editor.selectToScreenPosition(screenPosition) - else if metaKey or (ctrlKey and process.platform isnt 'darwin') - @editor.addCursorAtScreenPosition(screenPosition) - else - @editor.setCursorScreenPosition(screenPosition) - when 2 - @editor.getLastSelection().selectWord() - when 3 - @editor.getLastSelection().selectLine() - - @handleDragUntilMouseUp event, (screenPosition) => - @editor.selectToScreenPosition(screenPosition) - - onLineNumberGutterMouseDown: (event) => - return unless event.button is 0 # only handle the left mouse button - - {shiftKey, metaKey, ctrlKey} = event - - if shiftKey - @onGutterShiftClick(event) - else if metaKey or (ctrlKey and process.platform isnt 'darwin') - @onGutterMetaClick(event) - else - @onGutterClick(event) - - onGutterClick: (event) => - clickedRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) - - @editor.setSelectedBufferRange([[clickedBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) - - @handleDragUntilMouseUp event, (screenPosition) => - dragRow = screenPosition.row - dragBufferRow = @editor.bufferRowForScreenRow(dragRow) - if dragBufferRow < clickedBufferRow # dragging up - @editor.setSelectedBufferRange([[dragBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) - else - @editor.setSelectedBufferRange([[clickedBufferRow, 0], [dragBufferRow + 1, 0]], preserveFolds: true) - - onGutterMetaClick: (event) => - clickedRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) - - bufferRange = new Range([clickedBufferRow, 0], [clickedBufferRow + 1, 0]) - rowSelection = @editor.addSelectionForBufferRange(bufferRange, preserveFolds: true) - - @handleDragUntilMouseUp event, (screenPosition) => - dragRow = screenPosition.row - dragBufferRow = @editor.bufferRowForScreenRow(dragRow) - - if dragBufferRow < clickedBufferRow # dragging up - rowSelection.setBufferRange([[dragBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) - else - rowSelection.setBufferRange([[clickedBufferRow, 0], [dragBufferRow + 1, 0]], preserveFolds: true) - - # After updating the selected screen range, merge overlapping selections - @editor.mergeIntersectingSelections(preserveFolds: true) - - # The merge process will possibly destroy the current selection because - # it will be merged into another one. Therefore, we need to obtain a - # reference to the new selection that contains the originally selected row - rowSelection = _.find @editor.getSelections(), (selection) -> - selection.intersectsBufferRange(bufferRange) - - onGutterShiftClick: (event) => - clickedRow = @screenPositionForMouseEvent(event).row - clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) - tailPosition = @editor.getLastSelection().getTailScreenPosition() - tailBufferPosition = @editor.bufferPositionForScreenPosition(tailPosition) - - if clickedRow < tailPosition.row - @editor.selectToBufferPosition([clickedBufferRow, 0]) - else - @editor.selectToBufferPosition([clickedBufferRow + 1, 0]) - - @handleDragUntilMouseUp event, (screenPosition) => - dragRow = screenPosition.row - dragBufferRow = @editor.bufferRowForScreenRow(dragRow) - if dragRow < tailPosition.row # dragging up - @editor.setSelectedBufferRange([[dragBufferRow, 0], tailBufferPosition], preserveFolds: true) - else - @editor.setSelectedBufferRange([tailBufferPosition, [dragBufferRow + 1, 0]], preserveFolds: true) - - - onStylesheetsChanged: (styleElement) => - return unless @performedInitialMeasurement - return unless atom.themes.isInitialLoadComplete() - - # This delay prevents the styling from going haywire when stylesheets are - # reloaded in dev mode. It seems like a workaround for a browser bug, but - # not totally sure. - - unless @stylingChangeAnimationFrameRequested - @stylingChangeAnimationFrameRequested = true - requestAnimationFrame => - @stylingChangeAnimationFrameRequested = false - if @mounted - @refreshScrollbars() if not styleElement.sheet? or @containsScrollbarSelector(styleElement.sheet) - @handleStylingChange() - - onAllThemesLoaded: => - @refreshScrollbars() - @handleStylingChange() - - handleStylingChange: => - @sampleFontStyling() - @sampleBackgroundColors() - @remeasureCharacterWidths() - - onSelectionAdded: (selection) => - selectionDisposables = new CompositeDisposable - selectionDisposables.add selection.onDidChangeRange => @onSelectionChanged(selection) - selectionDisposables.add selection.onDidDestroy => - @onSelectionChanged(selection) - selectionDisposables.dispose() - @disposables.remove(selectionDisposables) - - @disposables.add(selectionDisposables) - - if @editor.selectionIntersectsVisibleRowRange(selection) - @selectionChanged = true - - onSelectionChanged: (selection) => - if @editor.selectionIntersectsVisibleRowRange(selection) - @selectionChanged = true - - onCursorAdded: (cursor) => - @disposables.add cursor.onDidChangePosition @onCursorMoved - - onCursorMoved: => - @cursorMoved = true - - handleDragUntilMouseUp: (event, dragHandler) => - dragging = false - lastMousePosition = {} - animationLoop = => - @requestAnimationFrame => - if dragging and @mounted - screenPosition = @screenPositionForMouseEvent(lastMousePosition) - dragHandler(screenPosition) - animationLoop() - else if not @mounted - stopDragging() - - onMouseMove = (event) -> - lastMousePosition.clientX = event.clientX - lastMousePosition.clientY = event.clientY - - # Start the animation loop when the mouse moves prior to a mouseup event - unless dragging - dragging = true - animationLoop() - - # Stop dragging when cursor enters dev tools because we can't detect mouseup - onMouseUp() if event.which is 0 - - onMouseUp = (event) => - stopDragging() - @editor.finalizeSelections() - pasteSelectionClipboard(event) - - stopDragging = -> - dragging = false - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - - pasteSelectionClipboard = (event) => - if event?.which is 2 and process.platform is 'linux' - if selection = require('./safe-clipboard').readText('selection') - @editor.insertText(selection) - - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - - isVisible: -> - @domNode.offsetHeight > 0 or @domNode.offsetWidth > 0 - - pollDOM: => - unless @checkForVisibilityChange() - @sampleBackgroundColors() - @measureDimensions() - @sampleFontStyling() - @overlayManager?.measureOverlays() - - checkForVisibilityChange: -> - if @isVisible() - if @wasVisible - false - else - @becameVisible() - @wasVisible = true - else - @wasVisible = false - - requestHeightAndWidthMeasurement: => - return if @heightAndWidthMeasurementRequested - - @heightAndWidthMeasurementRequested = true - requestAnimationFrame => - @heightAndWidthMeasurementRequested = false - @measureDimensions() - @measureWindowSize() - - # Measure explicitly-styled height and width and relay them to the model. If - # these values aren't explicitly styled, we assume the editor is unconstrained - # and use the scrollHeight / scrollWidth as its height and width in - # calculations. - measureDimensions: -> - return unless @mounted - - {position} = getComputedStyle(@hostElement) - {height} = @hostElement.style - - if position is 'absolute' or height - @presenter.setAutoHeight(false) - height = @hostElement.offsetHeight - if height > 0 - @presenter.setExplicitHeight(height) - else - @presenter.setAutoHeight(true) - @presenter.setExplicitHeight(null) - - clientWidth = @scrollViewNode.clientWidth - paddingLeft = parseInt(getComputedStyle(@scrollViewNode).paddingLeft) - clientWidth -= paddingLeft - if clientWidth > 0 - @presenter.setContentFrameWidth(clientWidth) - - @presenter.setGutterWidth(@gutterContainerComponent?.getDomNode().offsetWidth ? 0) - @presenter.setBoundingClientRect(@hostElement.getBoundingClientRect()) - - measureWindowSize: -> - return unless @mounted - - # FIXME: on Ubuntu (via xvfb) `window.innerWidth` reports an incorrect value - # when window gets resized through `atom.setWindowDimensions({width: - # windowWidth, height: windowHeight})`. - @presenter.setWindowSize(window.innerWidth, window.innerHeight) - - sampleFontStyling: => - oldFontSize = @fontSize - oldFontFamily = @fontFamily - oldLineHeight = @lineHeight - - {@fontSize, @fontFamily, @lineHeight} = getComputedStyle(@getTopmostDOMNode()) - - if @fontSize isnt oldFontSize or @fontFamily isnt oldFontFamily or @lineHeight isnt oldLineHeight - @measureLineHeightAndDefaultCharWidth() - - if (@fontSize isnt oldFontSize or @fontFamily isnt oldFontFamily) and @performedInitialMeasurement - @remeasureCharacterWidths() - - sampleBackgroundColors: (suppressUpdate) -> - {backgroundColor} = getComputedStyle(@hostElement) - - @presenter.setBackgroundColor(backgroundColor) - - lineNumberGutter = @gutterContainerComponent?.getLineNumberGutterComponent() - if lineNumberGutter - gutterBackgroundColor = getComputedStyle(lineNumberGutter.getDomNode()).backgroundColor - @presenter.setGutterBackgroundColor(gutterBackgroundColor) - - measureLineHeightAndDefaultCharWidth: -> - if @isVisible() - @measureLineHeightAndDefaultCharWidthWhenShown = false - @linesComponent.measureLineHeightAndDefaultCharWidth() - else - @measureLineHeightAndDefaultCharWidthWhenShown = true - - remeasureCharacterWidths: -> - if @isVisible() - @remeasureCharacterWidthsWhenShown = false - @linesComponent.remeasureCharacterWidths() - else - @remeasureCharacterWidthsWhenShown = true - - measureScrollbars: -> - @measureScrollbarsWhenShown = false - - cornerNode = @scrollbarCornerComponent.getDomNode() - originalDisplayValue = cornerNode.style.display - - cornerNode.style.display = 'block' - - width = (cornerNode.offsetWidth - cornerNode.clientWidth) or 15 - height = (cornerNode.offsetHeight - cornerNode.clientHeight) or 15 - - @presenter.setVerticalScrollbarWidth(width) - @presenter.setHorizontalScrollbarHeight(height) - - cornerNode.style.display = originalDisplayValue - - containsScrollbarSelector: (stylesheet) -> - for rule in stylesheet.cssRules - if rule.selectorText?.indexOf('scrollbar') > -1 - return true - false - - refreshScrollbars: => - if @isVisible() - @measureScrollbarsWhenShown = false - else - @measureScrollbarsWhenShown = true - return - - verticalNode = @verticalScrollbarComponent.getDomNode() - horizontalNode = @horizontalScrollbarComponent.getDomNode() - cornerNode = @scrollbarCornerComponent.getDomNode() - - originalVerticalDisplayValue = verticalNode.style.display - originalHorizontalDisplayValue = horizontalNode.style.display - originalCornerDisplayValue = cornerNode.style.display - - # First, hide all scrollbars in case they are visible so they take on new - # styles when they are shown again. - verticalNode.style.display = 'none' - horizontalNode.style.display = 'none' - cornerNode.style.display = 'none' - - # Force a reflow - cornerNode.offsetWidth - - # Now measure the new scrollbar dimensions - @measureScrollbars() - - # Now restore the display value for all scrollbars, since they were - # previously hidden - verticalNode.style.display = originalVerticalDisplayValue - horizontalNode.style.display = originalHorizontalDisplayValue - cornerNode.style.display = originalCornerDisplayValue - - consolidateSelections: (e) -> - e.abortKeyBinding() unless @editor.consolidateSelections() - - lineNodeForScreenRow: (screenRow) -> @linesComponent.lineNodeForScreenRow(screenRow) - - lineNumberNodeForScreenRow: (screenRow) -> @gutterContainerComponent.getLineNumberGutterComponent().lineNumberNodeForScreenRow(screenRow) - - screenRowForNode: (node) -> - while node? - if screenRow = node.dataset.screenRow - return parseInt(screenRow) - node = node.parentElement - null - - getFontSize: -> - parseInt(getComputedStyle(@getTopmostDOMNode()).fontSize) - - setFontSize: (fontSize) -> - @getTopmostDOMNode().style.fontSize = fontSize + 'px' - @sampleFontStyling() - - getFontFamily: -> - getComputedStyle(@getTopmostDOMNode()).fontFamily - - setFontFamily: (fontFamily) -> - @getTopmostDOMNode().style.fontFamily = fontFamily - @sampleFontStyling() - - setLineHeight: (lineHeight) -> - @getTopmostDOMNode().style.lineHeight = lineHeight - @sampleFontStyling() - - setShowIndentGuide: (showIndentGuide) -> - atom.config.set("editor.showIndentGuide", showIndentGuide) - - setScrollSensitivity: (scrollSensitivity) => - if scrollSensitivity = parseInt(scrollSensitivity) - @scrollSensitivity = Math.abs(scrollSensitivity) / 100 - - screenPositionForMouseEvent: (event) -> - pixelPosition = @pixelPositionForMouseEvent(event) - @editor.screenPositionForPixelPosition(pixelPosition) - - pixelPositionForMouseEvent: (event) -> - {clientX, clientY} = event - - linesClientRect = @linesComponent.getDomNode().getBoundingClientRect() - top = clientY - linesClientRect.top - left = clientX - linesClientRect.left - {top, left} - - getModel: -> - @editor - - isInputEnabled: -> @inputEnabled - - setInputEnabled: (@inputEnabled) -> @inputEnabled - - updateParentViewFocusedClassIfNeeded: -> - if @oldState.focused isnt @newState.focused - @hostElement.classList.toggle('is-focused', @newState.focused) - @rootElement.classList.toggle('is-focused', @newState.focused) - @oldState.focused = @newState.focused - - updateParentViewMiniClass: -> - @hostElement.classList.toggle('mini', @editor.isMini()) - @rootElement.classList.toggle('mini', @editor.isMini()) - -if grim.includeDeprecatedAPIs - TextEditorComponent::setInvisibles = (invisibles={}) -> - grim.deprecate "Use config.set('editor.invisibles', invisibles) instead" - atom.config.set('editor.invisibles', invisibles) - - TextEditorComponent::setShowInvisibles = (showInvisibles) -> - grim.deprecate "Use config.set('editor.showInvisibles', showInvisibles) instead" - atom.config.set('editor.showInvisibles', showInvisibles) diff --git a/src/text-editor-component.js b/src/text-editor-component.js new file mode 100644 index 00000000000..b00e16afa29 --- /dev/null +++ b/src/text-editor-component.js @@ -0,0 +1,5211 @@ +/* global ResizeObserver */ + +const etch = require('etch'); +const { Point, Range } = require('text-buffer'); +const LineTopIndex = require('line-top-index'); +const TextEditor = require('./text-editor'); +const { isPairedCharacter } = require('./text-utils'); +const electron = require('electron'); +const clipboard = electron.clipboard; +const $ = etch.dom; + +let TextEditorElement; + +const DEFAULT_ROWS_PER_TILE = 6; +const NORMAL_WIDTH_CHARACTER = 'x'; +const DOUBLE_WIDTH_CHARACTER = '我'; +const HALF_WIDTH_CHARACTER = 'ハ'; +const KOREAN_CHARACTER = '세'; +const NBSP_CHARACTER = '\u00a0'; +const ZERO_WIDTH_NBSP_CHARACTER = '\ufeff'; +const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40; +const CURSOR_BLINK_RESUME_DELAY = 300; +const CURSOR_BLINK_PERIOD = 800; + +function scaleMouseDragAutoscrollDelta(delta) { + return Math.pow(delta / 3, 3) / 280; +} + +module.exports = class TextEditorComponent { + static setScheduler(scheduler) { + etch.setScheduler(scheduler); + } + + static getScheduler() { + return etch.getScheduler(); + } + + static didUpdateStyles() { + if (this.attachedComponents) { + this.attachedComponents.forEach(component => { + component.didUpdateStyles(); + }); + } + } + + static didUpdateScrollbarStyles() { + if (this.attachedComponents) { + this.attachedComponents.forEach(component => { + component.didUpdateScrollbarStyles(); + }); + } + } + + constructor(props) { + this.props = props; + + if (!props.model) { + props.model = new TextEditor({ + mini: props.mini, + readOnly: props.readOnly + }); + } + this.props.model.component = this; + + if (props.element) { + this.element = props.element; + } else { + if (!TextEditorElement) + TextEditorElement = require('./text-editor-element'); + this.element = TextEditorElement.createTextEditorElement(); + } + this.element.initialize(this); + this.virtualNode = $('atom-text-editor'); + this.virtualNode.domNode = this.element; + this.refs = {}; + + this.updateSync = this.updateSync.bind(this); + this.didBlurHiddenInput = this.didBlurHiddenInput.bind(this); + this.didFocusHiddenInput = this.didFocusHiddenInput.bind(this); + this.didPaste = this.didPaste.bind(this); + this.didTextInput = this.didTextInput.bind(this); + this.didKeydown = this.didKeydown.bind(this); + this.didKeyup = this.didKeyup.bind(this); + this.didKeypress = this.didKeypress.bind(this); + this.didCompositionStart = this.didCompositionStart.bind(this); + this.didCompositionUpdate = this.didCompositionUpdate.bind(this); + this.didCompositionEnd = this.didCompositionEnd.bind(this); + + this.updatedSynchronously = this.props.updatedSynchronously; + this.didScrollDummyScrollbar = this.didScrollDummyScrollbar.bind(this); + this.didMouseDownOnContent = this.didMouseDownOnContent.bind(this); + this.debouncedResumeCursorBlinking = debounce( + this.resumeCursorBlinking.bind(this), + this.props.cursorBlinkResumeDelay || CURSOR_BLINK_RESUME_DELAY + ); + this.lineTopIndex = new LineTopIndex(); + this.lineNodesPool = new NodePool(); + this.updateScheduled = false; + this.suppressUpdates = false; + this.hasInitialMeasurements = false; + this.measurements = { + lineHeight: 0, + baseCharacterWidth: 0, + doubleWidthCharacterWidth: 0, + halfWidthCharacterWidth: 0, + koreanCharacterWidth: 0, + gutterContainerWidth: 0, + lineNumberGutterWidth: 0, + clientContainerHeight: 0, + clientContainerWidth: 0, + verticalScrollbarWidth: 0, + horizontalScrollbarHeight: 0, + longestLineWidth: 0 + }; + this.derivedDimensionsCache = {}; + this.visible = false; + this.cursorsBlinking = false; + this.cursorsBlinkedOff = false; + this.nextUpdateOnlyBlinksCursors = null; + this.linesToMeasure = new Map(); + this.extraRenderedScreenLines = new Map(); + this.horizontalPositionsToMeasure = new Map(); // Keys are rows with positions we want to measure, values are arrays of columns to measure + this.horizontalPixelPositionsByScreenLineId = new Map(); // Values are maps from column to horizontal pixel positions + this.blockDecorationsToMeasure = new Set(); + this.blockDecorationsByElement = new WeakMap(); + this.blockDecorationSentinel = document.createElement('div'); + this.blockDecorationSentinel.style.height = '1px'; + this.heightsByBlockDecoration = new WeakMap(); + this.blockDecorationResizeObserver = new ResizeObserver( + this.didResizeBlockDecorations.bind(this) + ); + this.lineComponentsByScreenLineId = new Map(); + this.overlayComponents = new Set(); + this.shouldRenderDummyScrollbars = true; + this.remeasureScrollbars = false; + this.pendingAutoscroll = null; + this.scrollTopPending = false; + this.scrollLeftPending = false; + this.scrollTop = 0; + this.scrollLeft = 0; + this.previousScrollWidth = 0; + this.previousScrollHeight = 0; + this.lastKeydown = null; + this.lastKeydownBeforeKeypress = null; + this.accentedCharacterMenuIsOpen = false; + this.remeasureGutterDimensions = false; + this.guttersToRender = [this.props.model.getLineNumberGutter()]; + this.guttersVisibility = [this.guttersToRender[0].visible]; + this.idsByTileStartRow = new Map(); + this.nextTileId = 0; + this.renderedTileStartRows = []; + this.showLineNumbers = this.props.model.doesShowLineNumbers(); + this.lineNumbersToRender = { + maxDigits: 2, + bufferRows: [], + screenRows: [], + keys: [], + softWrappedFlags: [], + foldableFlags: [] + }; + this.decorationsToRender = { + lineNumbers: new Map(), + lines: null, + highlights: [], + cursors: [], + overlays: [], + customGutter: new Map(), + blocks: new Map(), + text: [] + }; + this.decorationsToMeasure = { + highlights: [], + cursors: new Map() + }; + this.textDecorationsByMarker = new Map(); + 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(); + this.queryMaxLineNumberDigits(); + this.observeBlockDecorations(); + this.updateClassList(); + etch.updateSync(this); + } + + update(props) { + if (props.model !== this.props.model) { + this.props.model.component = null; + props.model.component = this; + } + this.props = props; + this.scheduleUpdate(); + } + + pixelPositionForScreenPosition({ row, column }) { + const top = this.pixelPositionAfterBlocksForRow(row); + let left = column === 0 ? 0 : this.pixelLeftForRowAndColumn(row, column); + if (left == null) { + this.requestHorizontalMeasurement(row, column); + this.updateSync(); + left = this.pixelLeftForRowAndColumn(row, column); + } + return { top, left }; + } + + scheduleUpdate(nextUpdateOnlyBlinksCursors = false) { + if (!this.visible) return; + if (this.suppressUpdates) return; + + this.nextUpdateOnlyBlinksCursors = + this.nextUpdateOnlyBlinksCursors !== false && + nextUpdateOnlyBlinksCursors === true; + + if (this.updatedSynchronously) { + this.updateSync(); + } else if (!this.updateScheduled) { + this.updateScheduled = true; + etch.getScheduler().updateDocument(() => { + if (this.updateScheduled) this.updateSync(true); + }); + } + } + + updateSync(useScheduler = false) { + // Don't proceed if we know we are not visible + if (!this.visible) { + this.updateScheduled = false; + return; + } + + // Don't proceed if we have to pay for a measurement anyway and detect + // that we are no longer visible. + if ( + (this.remeasureCharacterDimensions || + this.remeasureAllBlockDecorations) && + !this.isVisible() + ) { + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise(); + this.updateScheduled = false; + return; + } + + const onlyBlinkingCursors = this.nextUpdateOnlyBlinksCursors; + this.nextUpdateOnlyBlinksCursors = null; + if (useScheduler && onlyBlinkingCursors) { + this.refs.cursorsAndInput.updateCursorBlinkSync(this.cursorsBlinkedOff); + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise(); + this.updateScheduled = false; + return; + } + + if (this.remeasureCharacterDimensions) { + const originalLineHeight = this.getLineHeight(); + const originalBaseCharacterWidth = this.getBaseCharacterWidth(); + const scrollTopRow = this.getScrollTopRow(); + const scrollLeftColumn = this.getScrollLeftColumn(); + + this.measureCharacterDimensions(); + this.measureGutterDimensions(); + this.queryLongestLine(); + + if (this.getLineHeight() !== originalLineHeight) { + this.setScrollTopRow(scrollTopRow); + } + if (this.getBaseCharacterWidth() !== originalBaseCharacterWidth) { + this.setScrollLeftColumn(scrollLeftColumn); + } + this.remeasureCharacterDimensions = false; + } + + this.measureBlockDecorations(); + + this.updateSyncBeforeMeasuringContent(); + if (useScheduler === true) { + const scheduler = etch.getScheduler(); + scheduler.readDocument(() => { + const restartFrame = this.measureContentDuringUpdateSync(); + scheduler.updateDocument(() => { + if (restartFrame) { + this.updateSync(true); + } else { + this.updateSyncAfterMeasuringContent(); + } + }); + }); + } else { + const restartFrame = this.measureContentDuringUpdateSync(); + if (restartFrame) { + this.updateSync(false); + } else { + this.updateSyncAfterMeasuringContent(); + } + } + + this.updateScheduled = false; + } + + measureBlockDecorations() { + if (this.remeasureAllBlockDecorations) { + this.remeasureAllBlockDecorations = false; + + const decorations = this.props.model.getDecorations(); + for (let i = 0; i < decorations.length; i++) { + const decoration = decorations[i]; + const marker = decoration.getMarker(); + if (marker.isValid() && decoration.getProperties().type === 'block') { + this.blockDecorationsToMeasure.add(decoration); + } + } + + // Update the width of the line tiles to ensure block decorations are + // measured with the most recent width. + if (this.blockDecorationsToMeasure.size > 0) { + this.updateSyncBeforeMeasuringContent(); + } + } + + if (this.blockDecorationsToMeasure.size > 0) { + const { blockDecorationMeasurementArea } = this.refs; + const sentinelElements = new Set(); + + blockDecorationMeasurementArea.appendChild(document.createElement('div')); + this.blockDecorationsToMeasure.forEach(decoration => { + const { item } = decoration.getProperties(); + const decorationElement = TextEditor.viewForItem(item); + if (document.contains(decorationElement)) { + const parentElement = decorationElement.parentElement; + + if (!decorationElement.previousSibling) { + const sentinelElement = this.blockDecorationSentinel.cloneNode(); + parentElement.insertBefore(sentinelElement, decorationElement); + sentinelElements.add(sentinelElement); + } + + if (!decorationElement.nextSibling) { + const sentinelElement = this.blockDecorationSentinel.cloneNode(); + parentElement.appendChild(sentinelElement); + sentinelElements.add(sentinelElement); + } + + this.didMeasureVisibleBlockDecoration = true; + } else { + blockDecorationMeasurementArea.appendChild( + this.blockDecorationSentinel.cloneNode() + ); + blockDecorationMeasurementArea.appendChild(decorationElement); + blockDecorationMeasurementArea.appendChild( + this.blockDecorationSentinel.cloneNode() + ); + } + }); + + if (this.resizeBlockDecorationMeasurementsArea) { + this.resizeBlockDecorationMeasurementsArea = false; + this.refs.blockDecorationMeasurementArea.style.width = + this.getScrollWidth() + 'px'; + } + + this.blockDecorationsToMeasure.forEach(decoration => { + const { item } = decoration.getProperties(); + const decorationElement = TextEditor.viewForItem(item); + const { previousSibling, nextSibling } = decorationElement; + const height = + nextSibling.getBoundingClientRect().top - + previousSibling.getBoundingClientRect().bottom; + this.heightsByBlockDecoration.set(decoration, height); + this.lineTopIndex.resizeBlock(decoration, height); + }); + + sentinelElements.forEach(sentinelElement => sentinelElement.remove()); + while (blockDecorationMeasurementArea.firstChild) { + blockDecorationMeasurementArea.firstChild.remove(); + } + this.blockDecorationsToMeasure.clear(); + } + } + + updateSyncBeforeMeasuringContent() { + this.measuredContent = false; + this.derivedDimensionsCache = {}; + this.updateModelSoftWrapColumn(); + if (this.pendingAutoscroll) { + let { screenRange, options } = this.pendingAutoscroll; + this.autoscrollVertically(screenRange, options); + this.requestHorizontalMeasurement( + screenRange.start.row, + screenRange.start.column + ); + this.requestHorizontalMeasurement( + screenRange.end.row, + screenRange.end.column + ); + } + this.populateVisibleRowRange(this.getRenderedStartRow()); + this.populateVisibleTiles(); + this.queryScreenLinesToRender(); + this.queryLongestLine(); + this.queryLineNumbersToRender(); + this.queryGuttersToRender(); + this.queryDecorationsToRender(); + this.queryExtraScreenLinesToRender(); + this.shouldRenderDummyScrollbars = !this.remeasureScrollbars; + etch.updateSync(this); + this.updateClassList(); + this.shouldRenderDummyScrollbars = true; + this.didMeasureVisibleBlockDecoration = false; + } + + measureContentDuringUpdateSync() { + let gutterDimensionsChanged = false; + if (this.remeasureGutterDimensions) { + gutterDimensionsChanged = this.measureGutterDimensions(); + this.remeasureGutterDimensions = false; + } + const wasHorizontalScrollbarVisible = + this.canScrollHorizontally() && this.getHorizontalScrollbarHeight() > 0; + + this.measureLongestLineWidth(); + this.measureHorizontalPositions(); + this.updateAbsolutePositionedDecorations(); + + const isHorizontalScrollbarVisible = + this.canScrollHorizontally() && this.getHorizontalScrollbarHeight() > 0; + + if (this.pendingAutoscroll) { + this.derivedDimensionsCache = {}; + const { screenRange, options } = this.pendingAutoscroll; + this.autoscrollHorizontally(screenRange, options); + + if (!wasHorizontalScrollbarVisible && isHorizontalScrollbarVisible) { + this.autoscrollVertically(screenRange, options); + } + this.pendingAutoscroll = null; + } + + this.linesToMeasure.clear(); + this.measuredContent = true; + + return ( + gutterDimensionsChanged || + wasHorizontalScrollbarVisible !== isHorizontalScrollbarVisible + ); + } + + updateSyncAfterMeasuringContent() { + this.derivedDimensionsCache = {}; + etch.updateSync(this); + + this.currentFrameLineNumberGutterProps = null; + this.scrollTopPending = false; + this.scrollLeftPending = false; + if (this.remeasureScrollbars) { + // Flush stored scroll positions to the vertical and the horizontal + // scrollbars. This is because they have just been destroyed and recreated + // as a result of their remeasurement, but we could not assign the scroll + // top while they were initialized because they were not attached to the + // DOM yet. + this.refs.verticalScrollbar.flushScrollPosition(); + this.refs.horizontalScrollbar.flushScrollPosition(); + + this.measureScrollbarDimensions(); + this.remeasureScrollbars = false; + etch.updateSync(this); + } + + this.derivedDimensionsCache = {}; + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise(); + } + + render() { + const { model } = this.props; + const style = {}; + + if (!model.getAutoHeight() && !model.getAutoWidth()) { + style.contain = 'size'; + } + + let clientContainerHeight = '100%'; + let clientContainerWidth = '100%'; + if (this.hasInitialMeasurements) { + if (model.getAutoHeight()) { + clientContainerHeight = + this.getContentHeight() + this.getHorizontalScrollbarHeight() + 'px'; + } + if (model.getAutoWidth()) { + style.width = 'min-content'; + clientContainerWidth = + this.getGutterContainerWidth() + + this.getContentWidth() + + this.getVerticalScrollbarWidth() + + 'px'; + } else { + style.width = this.element.style.width; + } + } + + let attributes = {}; + if (model.isMini()) { + attributes.mini = ''; + } + + if (model.isReadOnly()) { + attributes.readonly = ''; + } + + const dataset = { encoding: model.getEncoding() }; + const grammar = model.getGrammar(); + if (grammar && grammar.scopeName) { + dataset.grammar = grammar.scopeName.replace(/\./g, ' '); + } + + return $( + 'atom-text-editor', + { + // See this.updateClassList() for construction of the class name + style, + attributes, + dataset, + tabIndex: -1, + on: { mousewheel: this.didMouseWheel } + }, + $.div( + { + ref: 'clientContainer', + style: { + position: 'relative', + contain: 'strict', + overflow: 'hidden', + backgroundColor: 'inherit', + height: clientContainerHeight, + width: clientContainerWidth + } + }, + this.renderGutterContainer(), + this.renderScrollContainer() + ), + this.renderOverlayDecorations() + ); + } + + renderGutterContainer() { + if (this.props.model.isMini()) { + return null; + } else { + return $(GutterContainerComponent, { + ref: 'gutterContainer', + key: 'gutterContainer', + rootComponent: this, + hasInitialMeasurements: this.hasInitialMeasurements, + measuredContent: this.measuredContent, + scrollTop: this.getScrollTop(), + scrollHeight: this.getScrollHeight(), + lineNumberGutterWidth: this.getLineNumberGutterWidth(), + lineHeight: this.getLineHeight(), + renderedStartRow: this.getRenderedStartRow(), + renderedEndRow: this.getRenderedEndRow(), + rowsPerTile: this.getRowsPerTile(), + guttersToRender: this.guttersToRender, + decorationsToRender: this.decorationsToRender, + isLineNumberGutterVisible: this.props.model.isLineNumberGutterVisible(), + showLineNumbers: this.showLineNumbers, + lineNumbersToRender: this.lineNumbersToRender, + didMeasureVisibleBlockDecoration: this.didMeasureVisibleBlockDecoration + }); + } + } + + renderScrollContainer() { + const style = { + position: 'absolute', + contain: 'strict', + overflow: 'hidden', + top: 0, + bottom: 0, + backgroundColor: 'inherit' + }; + + if (this.hasInitialMeasurements) { + style.left = this.getGutterContainerWidth() + 'px'; + style.width = this.getScrollContainerWidth() + 'px'; + } + + return $.div( + { + ref: 'scrollContainer', + key: 'scrollContainer', + className: 'scroll-view', + style + }, + this.renderContent(), + this.renderDummyScrollbars() + ); + } + + renderContent() { + let style = { + contain: 'strict', + overflow: 'hidden', + backgroundColor: 'inherit' + }; + if (this.hasInitialMeasurements) { + style.width = ceilToPhysicalPixelBoundary(this.getScrollWidth()) + 'px'; + style.height = ceilToPhysicalPixelBoundary(this.getScrollHeight()) + 'px'; + style.willChange = 'transform'; + style.transform = `translate(${-roundToPhysicalPixelBoundary( + this.getScrollLeft() + )}px, ${-roundToPhysicalPixelBoundary(this.getScrollTop())}px)`; + } + + return $.div( + { + ref: 'content', + on: { mousedown: this.didMouseDownOnContent }, + style + }, + this.renderLineTiles(), + this.renderBlockDecorationMeasurementArea(), + this.renderCharacterMeasurementLine() + ); + } + + renderHighlightDecorations() { + return $(HighlightsComponent, { + hasInitialMeasurements: this.hasInitialMeasurements, + highlightDecorations: this.decorationsToRender.highlights.slice(), + width: this.getScrollWidth(), + height: this.getScrollHeight(), + lineHeight: this.getLineHeight() + }); + } + + renderLineTiles() { + const style = { + position: 'absolute', + contain: 'strict', + overflow: 'hidden' + }; + + const children = []; + children.push(this.renderHighlightDecorations()); + + if (this.hasInitialMeasurements) { + const { lineComponentsByScreenLineId } = this; + + const startRow = this.getRenderedStartRow(); + const endRow = this.getRenderedEndRow(); + const rowsPerTile = this.getRowsPerTile(); + const tileWidth = this.getScrollWidth(); + + for (let i = 0; i < this.renderedTileStartRows.length; i++) { + const tileStartRow = this.renderedTileStartRows[i]; + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile); + const tileHeight = + this.pixelPositionBeforeBlocksForRow(tileEndRow) - + this.pixelPositionBeforeBlocksForRow(tileStartRow); + + children.push( + $(LinesTileComponent, { + key: this.idsByTileStartRow.get(tileStartRow), + measuredContent: this.measuredContent, + height: tileHeight, + width: tileWidth, + top: this.pixelPositionBeforeBlocksForRow(tileStartRow), + lineHeight: this.getLineHeight(), + renderedStartRow: startRow, + tileStartRow, + tileEndRow, + screenLines: this.renderedScreenLines.slice( + tileStartRow - startRow, + tileEndRow - startRow + ), + lineDecorations: this.decorationsToRender.lines.slice( + tileStartRow - startRow, + tileEndRow - startRow + ), + textDecorations: this.decorationsToRender.text.slice( + tileStartRow - startRow, + tileEndRow - startRow + ), + blockDecorations: this.decorationsToRender.blocks.get(tileStartRow), + displayLayer: this.props.model.displayLayer, + nodePool: this.lineNodesPool, + lineComponentsByScreenLineId + }) + ); + } + + this.extraRenderedScreenLines.forEach((screenLine, screenRow) => { + if (screenRow < startRow || screenRow >= endRow) { + children.push( + $(LineComponent, { + key: 'extra-' + screenLine.id, + offScreen: true, + screenLine, + screenRow, + displayLayer: this.props.model.displayLayer, + nodePool: this.lineNodesPool, + lineComponentsByScreenLineId + }) + ); + } + }); + + style.width = this.getScrollWidth() + 'px'; + style.height = this.getScrollHeight() + 'px'; + } + + children.push(this.renderPlaceholderText()); + children.push(this.renderCursorsAndInput()); + + return $.div( + { key: 'lineTiles', ref: 'lineTiles', className: 'lines', style }, + children + ); + } + + renderCursorsAndInput() { + return $(CursorsAndInputComponent, { + ref: 'cursorsAndInput', + key: 'cursorsAndInput', + didBlurHiddenInput: this.didBlurHiddenInput, + didFocusHiddenInput: this.didFocusHiddenInput, + didTextInput: this.didTextInput, + didPaste: this.didPaste, + didKeydown: this.didKeydown, + didKeyup: this.didKeyup, + didKeypress: this.didKeypress, + didCompositionStart: this.didCompositionStart, + didCompositionUpdate: this.didCompositionUpdate, + didCompositionEnd: this.didCompositionEnd, + measuredContent: this.measuredContent, + lineHeight: this.getLineHeight(), + scrollHeight: this.getScrollHeight(), + scrollWidth: this.getScrollWidth(), + decorationsToRender: this.decorationsToRender, + cursorsBlinkedOff: this.cursorsBlinkedOff, + hiddenInputPosition: this.hiddenInputPosition, + tabIndex: this.tabIndex + }); + } + + renderPlaceholderText() { + const { model } = this.props; + if (model.isEmpty()) { + const placeholderText = model.getPlaceholderText(); + if (placeholderText != null) { + return $.div({ className: 'placeholder-text' }, placeholderText); + } + } + return null; + } + + renderCharacterMeasurementLine() { + return $.div( + { + key: 'characterMeasurementLine', + ref: 'characterMeasurementLine', + className: 'line dummy', + style: { position: 'absolute', visibility: 'hidden' } + }, + $.span({ ref: 'normalWidthCharacterSpan' }, NORMAL_WIDTH_CHARACTER), + $.span({ ref: 'doubleWidthCharacterSpan' }, DOUBLE_WIDTH_CHARACTER), + $.span({ ref: 'halfWidthCharacterSpan' }, HALF_WIDTH_CHARACTER), + $.span({ ref: 'koreanCharacterSpan' }, KOREAN_CHARACTER) + ); + } + + renderBlockDecorationMeasurementArea() { + return $.div({ + ref: 'blockDecorationMeasurementArea', + key: 'blockDecorationMeasurementArea', + style: { + contain: 'strict', + position: 'absolute', + visibility: 'hidden', + width: this.getScrollWidth() + 'px' + } + }); + } + + renderDummyScrollbars() { + if (this.shouldRenderDummyScrollbars && !this.props.model.isMini()) { + let scrollHeight, scrollTop, horizontalScrollbarHeight; + let scrollWidth, + scrollLeft, + verticalScrollbarWidth, + forceScrollbarVisible; + let canScrollHorizontally, canScrollVertically; + + if (this.hasInitialMeasurements) { + scrollHeight = this.getScrollHeight(); + scrollWidth = this.getScrollWidth(); + scrollTop = this.getScrollTop(); + scrollLeft = this.getScrollLeft(); + canScrollHorizontally = this.canScrollHorizontally(); + canScrollVertically = this.canScrollVertically(); + horizontalScrollbarHeight = this.getHorizontalScrollbarHeight(); + verticalScrollbarWidth = this.getVerticalScrollbarWidth(); + forceScrollbarVisible = this.remeasureScrollbars; + } else { + forceScrollbarVisible = true; + } + + return [ + $(DummyScrollbarComponent, { + ref: 'verticalScrollbar', + orientation: 'vertical', + didScroll: this.didScrollDummyScrollbar, + didMouseDown: this.didMouseDownOnContent, + canScroll: canScrollVertically, + scrollHeight, + scrollTop, + horizontalScrollbarHeight, + forceScrollbarVisible + }), + $(DummyScrollbarComponent, { + ref: 'horizontalScrollbar', + orientation: 'horizontal', + didScroll: this.didScrollDummyScrollbar, + didMouseDown: this.didMouseDownOnContent, + canScroll: canScrollHorizontally, + scrollWidth, + scrollLeft, + verticalScrollbarWidth, + forceScrollbarVisible + }), + + // Force a "corner" to render where the two scrollbars meet at the lower right + $.div({ + ref: 'scrollbarCorner', + className: 'scrollbar-corner', + style: { + position: 'absolute', + height: '20px', + width: '20px', + bottom: 0, + right: 0, + overflow: 'scroll' + } + }) + ]; + } else { + return null; + } + } + + renderOverlayDecorations() { + return this.decorationsToRender.overlays.map(overlayProps => + $( + OverlayComponent, + Object.assign( + { + key: overlayProps.element, + overlayComponents: this.overlayComponents, + didResize: overlayComponent => { + this.updateOverlayToRender(overlayProps); + overlayComponent.update(overlayProps); + } + }, + overlayProps + ) + ) + ); + } + + // Imperatively manipulate the class list of the root element to avoid + // clearing classes assigned by package authors. + updateClassList() { + const { model } = this.props; + + const oldClassList = this.classList; + const newClassList = ['editor']; + if (this.focused) newClassList.push('is-focused'); + if (model.isMini()) newClassList.push('mini'); + for (var i = 0; i < model.selections.length; i++) { + if (!model.selections[i].isEmpty()) { + newClassList.push('has-selection'); + break; + } + } + + if (oldClassList) { + for (let i = 0; i < oldClassList.length; i++) { + const className = oldClassList[i]; + if (!newClassList.includes(className)) { + this.element.classList.remove(className); + } + } + } + + for (let i = 0; i < newClassList.length; i++) { + this.element.classList.add(newClassList[i]); + } + + this.classList = newClassList; + } + + queryScreenLinesToRender() { + const { model } = this.props; + + this.renderedScreenLines = model.displayLayer.getScreenLines( + this.getRenderedStartRow(), + this.getRenderedEndRow() + ); + } + + queryLongestLine() { + const { model } = this.props; + + const longestLineRow = model.getApproximateLongestScreenRow(); + const longestLine = model.screenLineForScreenRow(longestLineRow); + if ( + longestLine !== this.previousLongestLine || + this.remeasureCharacterDimensions + ) { + this.requestLineToMeasure(longestLineRow, longestLine); + this.longestLineToMeasure = longestLine; + this.previousLongestLine = longestLine; + } + } + + queryExtraScreenLinesToRender() { + this.extraRenderedScreenLines.clear(); + this.linesToMeasure.forEach((screenLine, row) => { + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { + this.extraRenderedScreenLines.set(row, screenLine); + } + }); + } + + queryLineNumbersToRender() { + const { model } = this.props; + if (!model.anyLineNumberGutterVisible()) return; + if (this.showLineNumbers !== model.doesShowLineNumbers()) { + this.remeasureGutterDimensions = true; + this.showLineNumbers = model.doesShowLineNumbers(); + } + + this.queryMaxLineNumberDigits(); + + const startRow = this.getRenderedStartRow(); + const endRow = this.getRenderedEndRow(); + const renderedRowCount = this.getRenderedRowCount(); + + const bufferRows = model.bufferRowsForScreenRows(startRow, endRow); + const screenRows = new Array(renderedRowCount); + const keys = new Array(renderedRowCount); + const foldableFlags = new Array(renderedRowCount); + const softWrappedFlags = new Array(renderedRowCount); + + let previousBufferRow = + startRow > 0 ? model.bufferRowForScreenRow(startRow - 1) : -1; + let softWrapCount = 0; + for (let row = startRow; row < endRow; row++) { + const i = row - startRow; + const bufferRow = bufferRows[i]; + if (bufferRow === previousBufferRow) { + softWrapCount++; + softWrappedFlags[i] = true; + keys[i] = bufferRow + '-' + softWrapCount; + } else { + softWrapCount = 0; + softWrappedFlags[i] = false; + keys[i] = bufferRow; + } + + const nextBufferRow = bufferRows[i + 1]; + if (bufferRow !== nextBufferRow) { + foldableFlags[i] = model.isFoldableAtBufferRow(bufferRow); + } else { + foldableFlags[i] = false; + } + + screenRows[i] = row; + previousBufferRow = bufferRow; + } + + // Delete extra buffer row at the end because it's not currently on screen. + bufferRows.pop(); + + this.lineNumbersToRender.bufferRows = bufferRows; + this.lineNumbersToRender.screenRows = screenRows; + this.lineNumbersToRender.keys = keys; + this.lineNumbersToRender.foldableFlags = foldableFlags; + this.lineNumbersToRender.softWrappedFlags = softWrappedFlags; + } + + queryMaxLineNumberDigits() { + const { model } = this.props; + if (model.anyLineNumberGutterVisible()) { + const maxDigits = Math.max(2, model.getLineCount().toString().length); + if (maxDigits !== this.lineNumbersToRender.maxDigits) { + this.remeasureGutterDimensions = true; + this.lineNumbersToRender.maxDigits = maxDigits; + } + } + } + + renderedScreenLineForRow(row) { + return ( + this.renderedScreenLines[row - this.getRenderedStartRow()] || + this.extraRenderedScreenLines.get(row) + ); + } + + queryGuttersToRender() { + const oldGuttersToRender = this.guttersToRender; + const oldGuttersVisibility = this.guttersVisibility; + this.guttersToRender = this.props.model.getGutters(); + this.guttersVisibility = this.guttersToRender.map(g => g.visible); + + if ( + !oldGuttersToRender || + oldGuttersToRender.length !== this.guttersToRender.length + ) { + this.remeasureGutterDimensions = true; + } else { + for (let i = 0, length = this.guttersToRender.length; i < length; i++) { + if ( + this.guttersToRender[i] !== oldGuttersToRender[i] || + this.guttersVisibility[i] !== oldGuttersVisibility[i] + ) { + this.remeasureGutterDimensions = true; + break; + } + } + } + } + + queryDecorationsToRender() { + this.decorationsToRender.lineNumbers.clear(); + this.decorationsToRender.lines = []; + this.decorationsToRender.overlays.length = 0; + this.decorationsToRender.customGutter.clear(); + this.decorationsToRender.blocks = new Map(); + this.decorationsToRender.text = []; + this.decorationsToMeasure.highlights.length = 0; + this.decorationsToMeasure.cursors.clear(); + this.textDecorationsByMarker.clear(); + this.textDecorationBoundaries.length = 0; + + const decorationsByMarker = this.props.model.decorationManager.decorationPropertiesByMarkerForScreenRowRange( + this.getRenderedStartRow(), + this.getRenderedEndRow() + ); + + decorationsByMarker.forEach((decorations, marker) => { + const screenRange = marker.getScreenRange(); + const reversed = marker.isReversed(); + for (let i = 0; i < decorations.length; i++) { + const decoration = decorations[i]; + this.addDecorationToRender( + decoration.type, + decoration, + marker, + screenRange, + reversed + ); + } + }); + + this.populateTextDecorationsToRender(); + } + + addDecorationToRender(type, decoration, marker, screenRange, reversed) { + if (Array.isArray(type)) { + for (let i = 0, length = type.length; i < length; i++) { + this.addDecorationToRender( + type[i], + decoration, + marker, + screenRange, + reversed + ); + } + } else { + switch (type) { + case 'line': + case 'line-number': + this.addLineDecorationToRender( + type, + decoration, + screenRange, + reversed + ); + break; + case 'highlight': + this.addHighlightDecorationToMeasure( + decoration, + screenRange, + marker.id + ); + break; + case 'cursor': + this.addCursorDecorationToMeasure( + decoration, + marker, + screenRange, + reversed + ); + break; + case 'overlay': + this.addOverlayDecorationToRender(decoration, marker); + break; + case 'gutter': + this.addCustomGutterDecorationToRender(decoration, screenRange); + break; + case 'block': + this.addBlockDecorationToRender(decoration, screenRange, reversed); + break; + case 'text': + this.addTextDecorationToRender(decoration, screenRange, marker); + break; + } + } + } + + addLineDecorationToRender(type, decoration, screenRange, reversed) { + let decorationsToRender; + if (type === 'line') { + decorationsToRender = this.decorationsToRender.lines; + } else { + const gutterName = decoration.gutterName || 'line-number'; + decorationsToRender = this.decorationsToRender.lineNumbers.get( + gutterName + ); + if (!decorationsToRender) { + decorationsToRender = []; + this.decorationsToRender.lineNumbers.set( + gutterName, + decorationsToRender + ); + } + } + + let omitLastRow = false; + if (screenRange.isEmpty()) { + if (decoration.onlyNonEmpty) return; + } else { + if (decoration.onlyEmpty) return; + if (decoration.omitEmptyLastRow !== false) { + omitLastRow = screenRange.end.column === 0; + } + } + + const renderedStartRow = this.getRenderedStartRow(); + let rangeStartRow = screenRange.start.row; + let rangeEndRow = screenRange.end.row; + + if (decoration.onlyHead) { + if (reversed) { + rangeEndRow = rangeStartRow; + } else { + rangeStartRow = rangeEndRow; + } + } + + rangeStartRow = Math.max(rangeStartRow, this.getRenderedStartRow()); + rangeEndRow = Math.min(rangeEndRow, this.getRenderedEndRow() - 1); + + for (let row = rangeStartRow; row <= rangeEndRow; row++) { + if (omitLastRow && row === screenRange.end.row) break; + const currentClassName = decorationsToRender[row - renderedStartRow]; + const newClassName = currentClassName + ? currentClassName + ' ' + decoration.class + : decoration.class; + decorationsToRender[row - renderedStartRow] = newClassName; + } + } + + addHighlightDecorationToMeasure(decoration, screenRange, key) { + screenRange = constrainRangeToRows( + screenRange, + this.getRenderedStartRow(), + this.getRenderedEndRow() + ); + if (screenRange.isEmpty()) return; + + const { + class: className, + flashRequested, + flashClass, + flashDuration + } = decoration; + decoration.flashRequested = false; + this.decorationsToMeasure.highlights.push({ + screenRange, + key, + className, + flashRequested, + flashClass, + flashDuration + }); + this.requestHorizontalMeasurement( + screenRange.start.row, + screenRange.start.column + ); + this.requestHorizontalMeasurement( + screenRange.end.row, + screenRange.end.column + ); + } + + addCursorDecorationToMeasure(decoration, marker, screenRange, reversed) { + const { model } = this.props; + if (!model.getShowCursorOnSelection() && !screenRange.isEmpty()) return; + + let decorationToMeasure = this.decorationsToMeasure.cursors.get(marker); + if (!decorationToMeasure) { + const isLastCursor = model.getLastCursor().getMarker() === marker; + const screenPosition = reversed ? screenRange.start : screenRange.end; + const { row, column } = screenPosition; + + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) + return; + + this.requestHorizontalMeasurement(row, column); + let columnWidth = 0; + if (model.lineLengthForScreenRow(row) > column) { + columnWidth = 1; + this.requestHorizontalMeasurement(row, column + 1); + } + decorationToMeasure = { screenPosition, columnWidth, isLastCursor }; + this.decorationsToMeasure.cursors.set(marker, decorationToMeasure); + } + + if (decoration.class) { + if (decorationToMeasure.className) { + decorationToMeasure.className += ' ' + decoration.class; + } else { + decorationToMeasure.className = decoration.class; + } + } + + if (decoration.style) { + if (decorationToMeasure.style) { + Object.assign(decorationToMeasure.style, decoration.style); + } else { + decorationToMeasure.style = Object.assign({}, decoration.style); + } + } + } + + addOverlayDecorationToRender(decoration, marker) { + const { class: className, item, position, avoidOverflow } = decoration; + const element = TextEditor.viewForItem(item); + const screenPosition = + position === 'tail' + ? marker.getTailScreenPosition() + : marker.getHeadScreenPosition(); + + this.requestHorizontalMeasurement( + screenPosition.row, + screenPosition.column + ); + this.decorationsToRender.overlays.push({ + className, + element, + avoidOverflow, + screenPosition + }); + } + + addCustomGutterDecorationToRender(decoration, screenRange) { + let decorations = this.decorationsToRender.customGutter.get( + decoration.gutterName + ); + if (!decorations) { + decorations = []; + this.decorationsToRender.customGutter.set( + decoration.gutterName, + decorations + ); + } + const top = this.pixelPositionAfterBlocksForRow(screenRange.start.row); + const height = + this.pixelPositionBeforeBlocksForRow(screenRange.end.row + 1) - top; + + decorations.push({ + className: + 'decoration' + (decoration.class ? ' ' + decoration.class : ''), + element: TextEditor.viewForItem(decoration.item), + top, + height + }); + } + + addBlockDecorationToRender(decoration, screenRange, reversed) { + const { row } = reversed ? screenRange.start : screenRange.end; + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) + return; + + const tileStartRow = this.tileStartRowForRow(row); + const screenLine = this.renderedScreenLines[ + row - this.getRenderedStartRow() + ]; + + let decorationsByScreenLine = this.decorationsToRender.blocks.get( + tileStartRow + ); + if (!decorationsByScreenLine) { + decorationsByScreenLine = new Map(); + this.decorationsToRender.blocks.set( + tileStartRow, + decorationsByScreenLine + ); + } + + let decorations = decorationsByScreenLine.get(screenLine.id); + if (!decorations) { + decorations = []; + decorationsByScreenLine.set(screenLine.id, decorations); + } + decorations.push(decoration); + + // Order block decorations by increasing values of their "order" property. Break ties with "id", which mirrors + // their creation sequence. + decorations.sort((a, b) => + a.order !== b.order ? a.order - b.order : a.id - b.id + ); + } + + addTextDecorationToRender(decoration, screenRange, marker) { + if (screenRange.isEmpty()) return; + + let decorationsForMarker = this.textDecorationsByMarker.get(marker); + if (!decorationsForMarker) { + decorationsForMarker = []; + this.textDecorationsByMarker.set(marker, decorationsForMarker); + this.textDecorationBoundaries.push({ + position: screenRange.start, + starting: [marker] + }); + this.textDecorationBoundaries.push({ + position: screenRange.end, + ending: [marker] + }); + } + decorationsForMarker.push(decoration); + } + + populateTextDecorationsToRender() { + // Sort all boundaries in ascending order of position + this.textDecorationBoundaries.sort((a, b) => + a.position.compare(b.position) + ); + + // Combine adjacent boundaries with the same position + for (let i = 0; i < this.textDecorationBoundaries.length; ) { + const boundary = this.textDecorationBoundaries[i]; + const nextBoundary = this.textDecorationBoundaries[i + 1]; + if (nextBoundary && nextBoundary.position.isEqual(boundary.position)) { + if (nextBoundary.starting) { + if (boundary.starting) { + boundary.starting.push(...nextBoundary.starting); + } else { + boundary.starting = nextBoundary.starting; + } + } + + if (nextBoundary.ending) { + if (boundary.ending) { + boundary.ending.push(...nextBoundary.ending); + } else { + boundary.ending = nextBoundary.ending; + } + } + + this.textDecorationBoundaries.splice(i + 1, 1); + } else { + i++; + } + } + + const renderedStartRow = this.getRenderedStartRow(); + const renderedEndRow = this.getRenderedEndRow(); + const containingMarkers = []; + + // Iterate over boundaries to build up text decorations. + for (let i = 0; i < this.textDecorationBoundaries.length; i++) { + const boundary = this.textDecorationBoundaries[i]; + + // If multiple markers start here, sort them by order of nesting (markers ending later come first) + if (boundary.starting && boundary.starting.length > 1) { + boundary.starting.sort((a, b) => a.compare(b)); + } + + // If multiple markers start here, sort them by order of nesting (markers starting earlier come first) + if (boundary.ending && boundary.ending.length > 1) { + boundary.ending.sort((a, b) => b.compare(a)); + } + + // Remove markers ending here from containing markers array + if (boundary.ending) { + for (let j = boundary.ending.length - 1; j >= 0; j--) { + containingMarkers.splice( + containingMarkers.lastIndexOf(boundary.ending[j]), + 1 + ); + } + } + // Add markers starting here to containing markers array + if (boundary.starting) containingMarkers.push(...boundary.starting); + + // Determine desired className and style based on containing markers + let className, style; + for (let j = 0; j < containingMarkers.length; j++) { + const marker = containingMarkers[j]; + const decorations = this.textDecorationsByMarker.get(marker); + for (let k = 0; k < decorations.length; k++) { + const decoration = decorations[k]; + if (decoration.class) { + if (className) { + className += ' ' + decoration.class; + } else { + className = decoration.class; + } + } + if (decoration.style) { + if (style) { + Object.assign(style, decoration.style); + } else { + style = Object.assign({}, decoration.style); + } + } + } + } + + // Add decoration start with className/style for current position's column, + // and also for the start of every row up until the next decoration boundary + if (boundary.position.row >= renderedStartRow) { + this.addTextDecorationStart( + boundary.position.row, + boundary.position.column, + className, + style + ); + } + const nextBoundary = this.textDecorationBoundaries[i + 1]; + if (nextBoundary) { + let row = Math.max(boundary.position.row + 1, renderedStartRow); + const endRow = Math.min(nextBoundary.position.row, renderedEndRow); + for (; row < endRow; row++) { + this.addTextDecorationStart(row, 0, className, style); + } + + if ( + row === nextBoundary.position.row && + nextBoundary.position.column !== 0 + ) { + this.addTextDecorationStart(row, 0, className, style); + } + } + } + } + + addTextDecorationStart(row, column, className, style) { + const renderedStartRow = this.getRenderedStartRow(); + let decorationStarts = this.decorationsToRender.text[ + row - renderedStartRow + ]; + if (!decorationStarts) { + decorationStarts = []; + this.decorationsToRender.text[row - renderedStartRow] = decorationStarts; + } + decorationStarts.push({ column, className, style }); + } + + updateAbsolutePositionedDecorations() { + this.updateHighlightsToRender(); + this.updateCursorsToRender(); + this.updateOverlaysToRender(); + } + + updateHighlightsToRender() { + this.decorationsToRender.highlights.length = 0; + for (let i = 0; i < this.decorationsToMeasure.highlights.length; i++) { + const highlight = this.decorationsToMeasure.highlights[i]; + const { start, end } = highlight.screenRange; + highlight.startPixelTop = this.pixelPositionAfterBlocksForRow(start.row); + highlight.startPixelLeft = this.pixelLeftForRowAndColumn( + start.row, + start.column + ); + highlight.endPixelTop = + this.pixelPositionAfterBlocksForRow(end.row) + this.getLineHeight(); + highlight.endPixelLeft = this.pixelLeftForRowAndColumn( + end.row, + end.column + ); + this.decorationsToRender.highlights.push(highlight); + } + } + + updateCursorsToRender() { + this.decorationsToRender.cursors.length = 0; + + this.decorationsToMeasure.cursors.forEach(cursor => { + const { screenPosition, className, style } = cursor; + const { row, column } = screenPosition; + + const pixelTop = this.pixelPositionAfterBlocksForRow(row); + const pixelLeft = this.pixelLeftForRowAndColumn(row, column); + let pixelWidth; + if (cursor.columnWidth === 0) { + pixelWidth = this.getBaseCharacterWidth(); + } else { + pixelWidth = this.pixelLeftForRowAndColumn(row, column + 1) - pixelLeft; + } + + const cursorPosition = { + pixelTop, + pixelLeft, + pixelWidth, + className, + style + }; + this.decorationsToRender.cursors.push(cursorPosition); + if (cursor.isLastCursor) this.hiddenInputPosition = cursorPosition; + }); + } + + updateOverlayToRender(decoration) { + const windowInnerHeight = this.getWindowInnerHeight(); + const windowInnerWidth = this.getWindowInnerWidth(); + const contentClientRect = this.refs.content.getBoundingClientRect(); + + const { element, screenPosition, avoidOverflow } = decoration; + const { row, column } = screenPosition; + let wrapperTop = + contentClientRect.top + + this.pixelPositionAfterBlocksForRow(row) + + this.getLineHeight(); + let wrapperLeft = + contentClientRect.left + this.pixelLeftForRowAndColumn(row, column); + const clientRect = element.getBoundingClientRect(); + + if (avoidOverflow !== false) { + const computedStyle = window.getComputedStyle(element); + const elementTop = wrapperTop + parseInt(computedStyle.marginTop); + const elementBottom = elementTop + clientRect.height; + const flippedElementTop = + wrapperTop - + this.getLineHeight() - + clientRect.height - + parseInt(computedStyle.marginBottom); + const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft); + const elementRight = elementLeft + clientRect.width; + + if (elementBottom > windowInnerHeight && flippedElementTop >= 0) { + wrapperTop -= elementTop - flippedElementTop; + } + if (elementLeft < 0) { + wrapperLeft -= elementLeft; + } else if (elementRight > windowInnerWidth) { + wrapperLeft -= elementRight - windowInnerWidth; + } + } + + decoration.pixelTop = Math.round(wrapperTop); + decoration.pixelLeft = Math.round(wrapperLeft); + } + + updateOverlaysToRender() { + const overlayCount = this.decorationsToRender.overlays.length; + if (overlayCount === 0) return null; + + for (let i = 0; i < overlayCount; i++) { + const decoration = this.decorationsToRender.overlays[i]; + this.updateOverlayToRender(decoration); + } + } + + didAttach() { + if (!this.attached) { + this.attached = true; + this.intersectionObserver = new IntersectionObserver(entries => { + const { intersectionRect } = entries[entries.length - 1]; + if (intersectionRect.width > 0 || intersectionRect.height > 0) { + this.didShow(); + } else { + this.didHide(); + } + }); + this.intersectionObserver.observe(this.element); + + this.resizeObserver = new ResizeObserver(this.didResize.bind(this)); + this.resizeObserver.observe(this.element); + + if (this.refs.gutterContainer) { + this.gutterContainerResizeObserver = new ResizeObserver( + this.didResizeGutterContainer.bind(this) + ); + this.gutterContainerResizeObserver.observe( + this.refs.gutterContainer.element + ); + } + + this.overlayComponents.forEach(component => component.didAttach()); + + if (this.isVisible()) { + this.didShow(); + + if (this.refs.verticalScrollbar) + this.refs.verticalScrollbar.flushScrollPosition(); + if (this.refs.horizontalScrollbar) + this.refs.horizontalScrollbar.flushScrollPosition(); + } else { + this.didHide(); + } + if (!this.constructor.attachedComponents) { + this.constructor.attachedComponents = new Set(); + } + this.constructor.attachedComponents.add(this); + } + } + + didDetach() { + if (this.attached) { + this.intersectionObserver.disconnect(); + this.resizeObserver.disconnect(); + if (this.gutterContainerResizeObserver) + this.gutterContainerResizeObserver.disconnect(); + this.overlayComponents.forEach(component => component.didDetach()); + + this.didHide(); + this.attached = false; + this.constructor.attachedComponents.delete(this); + } + } + + didShow() { + if (!this.visible && this.isVisible()) { + if (!this.hasInitialMeasurements) this.measureDimensions(); + this.visible = true; + this.props.model.setVisible(true); + this.resizeBlockDecorationMeasurementsArea = true; + this.updateSync(); + this.flushPendingLogicalScrollPosition(); + } + } + + didHide() { + if (this.visible) { + this.visible = false; + this.props.model.setVisible(false); + } + } + + // Called by TextEditorElement so that focus events can be handled before + // the element is attached to the DOM. + didFocus() { + if (!this.visible) this.didShow(); + + if (!this.focused) { + this.focused = true; + this.startCursorBlinking(); + this.scheduleUpdate(); + } + + this.getHiddenInput().focus({ preventScroll: true }); + } + + // Called by TextEditorElement so that this function is always the first + // listener to be fired, even if other listeners are bound before creating + // the component. + didBlur(event) { + if (event.relatedTarget === this.getHiddenInput()) { + event.stopImmediatePropagation(); + } + } + + didBlurHiddenInput(event) { + if ( + this.element !== event.relatedTarget && + !this.element.contains(event.relatedTarget) + ) { + this.focused = false; + this.stopCursorBlinking(); + this.scheduleUpdate(); + this.element.dispatchEvent(new FocusEvent(event.type, event)); + } + } + + didFocusHiddenInput() { + if (!this.focused) { + this.focused = true; + this.startCursorBlinking(); + this.scheduleUpdate(); + } + } + + didMouseWheel(event) { + const scrollSensitivity = this.props.model.getScrollSensitivity() / 100; + + let { wheelDeltaX, wheelDeltaY } = event; + + if (Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY)) { + wheelDeltaX = wheelDeltaX * scrollSensitivity; + wheelDeltaY = 0; + } else { + wheelDeltaX = 0; + wheelDeltaY = wheelDeltaY * scrollSensitivity; + } + + if (this.getPlatform() !== 'darwin' && event.shiftKey) { + let temp = wheelDeltaX; + wheelDeltaX = wheelDeltaY; + wheelDeltaY = temp; + } + + const scrollLeftChanged = + wheelDeltaX !== 0 && + this.setScrollLeft(this.getScrollLeft() - wheelDeltaX); + const scrollTopChanged = + wheelDeltaY !== 0 && this.setScrollTop(this.getScrollTop() - wheelDeltaY); + + if (scrollLeftChanged || scrollTopChanged) { + event.preventDefault(); + this.updateSync(); + } + } + + didResize() { + // Prevent the component from measuring the client container dimensions when + // getting spurious resize events. + if (this.isVisible()) { + const clientContainerWidthChanged = this.measureClientContainerWidth(); + const clientContainerHeightChanged = this.measureClientContainerHeight(); + if (clientContainerWidthChanged || clientContainerHeightChanged) { + if (clientContainerWidthChanged) { + this.remeasureAllBlockDecorations = true; + } + + this.resizeObserver.disconnect(); + this.scheduleUpdate(); + process.nextTick(() => { + this.resizeObserver.observe(this.element); + }); + } + } + } + + didResizeGutterContainer() { + // Prevent the component from measuring the gutter dimensions when getting + // spurious resize events. + if (this.isVisible() && this.measureGutterDimensions()) { + this.gutterContainerResizeObserver.disconnect(); + this.scheduleUpdate(); + process.nextTick(() => { + this.gutterContainerResizeObserver.observe( + this.refs.gutterContainer.element + ); + }); + } + } + + didScrollDummyScrollbar() { + let scrollTopChanged = false; + let scrollLeftChanged = false; + if (!this.scrollTopPending) { + scrollTopChanged = this.setScrollTop( + this.refs.verticalScrollbar.element.scrollTop + ); + } + if (!this.scrollLeftPending) { + scrollLeftChanged = this.setScrollLeft( + this.refs.horizontalScrollbar.element.scrollLeft + ); + } + if (scrollTopChanged || scrollLeftChanged) this.updateSync(); + } + + didUpdateStyles() { + this.remeasureCharacterDimensions = true; + this.horizontalPixelPositionsByScreenLineId.clear(); + this.scheduleUpdate(); + } + + didUpdateScrollbarStyles() { + if (!this.props.model.isMini()) { + this.remeasureScrollbars = true; + this.scheduleUpdate(); + } + } + + didPaste(event) { + // On Linux, Chromium translates a middle-button mouse click into a + // mousedown event *and* a paste event. Since Atom supports the middle mouse + // click as a way of closing a tab, we only want the mousedown event, not + // the paste event. And since we don't use the `paste` event for any + // behavior in Atom, we can no-op the event to eliminate this issue. + // See https://github.com/atom/atom/pull/15183#issue-248432413. + if (this.getPlatform() === 'linux') event.preventDefault(); + } + + didTextInput(event) { + if (this.compositionCheckpoint) { + this.props.model.revertToCheckpoint(this.compositionCheckpoint); + this.compositionCheckpoint = null; + } + + if (this.isInputEnabled()) { + event.stopPropagation(); + + // WARNING: If we call preventDefault on the input of a space + // character, then the browser interprets the spacebar keypress as a + // page-down command, causing spaces to scroll elements containing + // editors. This means typing space will actually change the contents + // of the hidden input, which will cause the browser to autoscroll the + // scroll container to reveal the input if it is off screen (See + // https://github.com/atom/atom/issues/16046). To correct for this + // situation, we automatically reset the scroll position to 0,0 after + // typing a space. None of this can really be tested. + if (event.data === ' ') { + window.setImmediate(() => { + this.refs.scrollContainer.scrollTop = 0; + this.refs.scrollContainer.scrollLeft = 0; + }); + } else { + event.preventDefault(); + } + + // If the input event is fired while the accented character menu is open it + // means that the user has chosen one of the accented alternatives. Thus, we + // will replace the original non accented character with the selected + // alternative. + if (this.accentedCharacterMenuIsOpen) { + this.props.model.selectLeft(); + } + + this.props.model.insertText(event.data, { groupUndo: true }); + } + } + + // We need to get clever to detect when the accented character menu is + // opened on macOS. Usually, every keydown event that could cause input is + // followed by a corresponding keypress. However, pressing and holding + // long enough to open the accented character menu causes additional keydown + // events to fire that aren't followed by their own keypress and textInput + // events. + // + // Therefore, we assume the accented character menu has been deployed if, + // before observing any keyup event, we observe events in the following + // sequence: + // + // keydown(code: X), keypress, keydown(code: X) + // + // The code X must be the same in the keydown events that bracket the + // keypress, meaning we're *holding* the _same_ key we initially pressed. + // Got that? + didKeydown(event) { + // Stop dragging when user interacts with the keyboard. This prevents + // unwanted selections in the case edits are performed while selecting text + // at the same time. Modifier keys are exempt to preserve the ability to + // add selections, shift-scroll horizontally while selecting. + if ( + this.stopDragging && + event.key !== 'Control' && + event.key !== 'Alt' && + event.key !== 'Meta' && + event.key !== 'Shift' + ) { + this.stopDragging(); + } + + if (this.lastKeydownBeforeKeypress != null) { + if (this.lastKeydownBeforeKeypress.code === event.code) { + this.accentedCharacterMenuIsOpen = true; + } + + this.lastKeydownBeforeKeypress = null; + } + + this.lastKeydown = event; + } + + didKeypress(event) { + this.lastKeydownBeforeKeypress = this.lastKeydown; + + // This cancels the accented character behavior if we type a key normally + // with the menu open. + this.accentedCharacterMenuIsOpen = false; + } + + didKeyup(event) { + if ( + this.lastKeydownBeforeKeypress && + this.lastKeydownBeforeKeypress.code === event.code + ) { + this.lastKeydownBeforeKeypress = null; + } + } + + // The IME composition events work like this: + // + // User types 's', chromium pops up the completion helper + // 1. compositionstart fired + // 2. compositionupdate fired; event.data == 's' + // User hits arrow keys to move around in completion helper + // 3. compositionupdate fired; event.data == 's' for each arry key press + // User escape to cancel OR User chooses a completion + // 4. compositionend fired + // 5. textInput fired; event.data == the completion string + didCompositionStart() { + // Workaround for Chromium not preventing composition events when + // preventDefault is called on the keydown event that precipitated them. + if (this.lastKeydown && this.lastKeydown.defaultPrevented) { + this.getHiddenInput().disabled = true; + process.nextTick(() => { + // Disabling the hidden input makes it lose focus as well, so we have to + // re-enable and re-focus it. + this.getHiddenInput().disabled = false; + this.getHiddenInput().focus({ preventScroll: true }); + }); + return; + } + + this.compositionCheckpoint = this.props.model.createCheckpoint(); + if (this.accentedCharacterMenuIsOpen) { + this.props.model.selectLeft(); + } + } + + didCompositionUpdate(event) { + this.props.model.insertText(event.data, { select: true }); + } + + didCompositionEnd(event) { + event.target.value = ''; + } + + didMouseDownOnContent(event) { + const { model } = this.props; + const { target, button, detail, ctrlKey, shiftKey, metaKey } = event; + const platform = this.getPlatform(); + + // Ignore clicks on block decorations. + if (target) { + let element = target; + while (element && element !== this.element) { + if (this.blockDecorationsByElement.has(element)) { + return; + } + + element = element.parentElement; + } + } + + const screenPosition = this.screenPositionForMouseEvent(event); + + if (button === 1) { + 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 editor.selectionClipboard is set to true. + if ( + platform === 'linux' && + this.isInputEnabled() && + atom.config.get('editor.selectionClipboard') + ) + model.insertText(clipboard.readText('selection')); + return; + } + + if (button !== 0) return; + + // Ctrl-click brings up the context menu on macOS + if (platform === 'darwin' && ctrlKey) return; + + if (target && target.matches('.fold-marker')) { + const bufferPosition = model.bufferPositionForScreenPosition( + screenPosition + ); + model.destroyFoldsContainingBufferPositions([bufferPosition], false); + return; + } + + const allowMultiCursor = atom.config.get('editor.multiCursorOnClick'); + const addOrRemoveSelection = + allowMultiCursor && (metaKey || (ctrlKey && platform !== 'darwin')); + + switch (detail) { + case 1: + if (addOrRemoveSelection) { + const existingSelection = model.getSelectionAtScreenPosition( + screenPosition + ); + if (existingSelection) { + if (model.hasMultipleCursors()) existingSelection.destroy(); + } else { + model.addCursorAtScreenPosition(screenPosition, { + autoscroll: false + }); + } + } else { + if (shiftKey) { + model.selectToScreenPosition(screenPosition, { autoscroll: false }); + } else { + model.setCursorScreenPosition(screenPosition, { + autoscroll: false + }); + } + } + break; + case 2: + if (addOrRemoveSelection) + model.addCursorAtScreenPosition(screenPosition, { + autoscroll: false + }); + model.getLastSelection().selectWord({ autoscroll: false }); + break; + case 3: + if (addOrRemoveSelection) + model.addCursorAtScreenPosition(screenPosition, { + autoscroll: false + }); + model.getLastSelection().selectLine(null, { autoscroll: false }); + break; + } + + this.handleMouseDragUntilMouseUp({ + didDrag: event => { + this.autoscrollOnMouseDrag(event); + const screenPosition = this.screenPositionForMouseEvent(event); + model.selectToScreenPosition(screenPosition, { + suppressSelectionMerge: true, + autoscroll: false + }); + this.updateSync(); + }, + didStopDragging: () => { + model.finalizeSelections(); + model.mergeIntersectingSelections(); + this.updateSync(); + } + }); + } + + didMouseDownOnLineNumberGutter(event) { + const { model } = this.props; + const { target, button, ctrlKey, shiftKey, metaKey } = event; + + // Only handle mousedown events for left mouse button + if (button !== 0) return; + + const clickedScreenRow = this.screenPositionForMouseEvent(event).row; + const startBufferRow = model.bufferPositionForScreenPosition([ + clickedScreenRow, + 0 + ]).row; + + if ( + target && + (target.matches('.foldable .icon-right') || + target.matches('.folded .icon-right')) + ) { + model.toggleFoldAtBufferRow(startBufferRow); + return; + } + + const addOrRemoveSelection = + metaKey || (ctrlKey && this.getPlatform() !== 'darwin'); + const endBufferRow = model.bufferPositionForScreenPosition([ + clickedScreenRow, + Infinity + ]).row; + const clickedLineBufferRange = Range( + Point(startBufferRow, 0), + Point(endBufferRow + 1, 0) + ); + + let initialBufferRange; + if (shiftKey) { + const lastSelection = model.getLastSelection(); + initialBufferRange = lastSelection.getBufferRange(); + lastSelection.setBufferRange( + initialBufferRange.union(clickedLineBufferRange), + { + reversed: clickedScreenRow < lastSelection.getScreenRange().start.row, + autoscroll: false, + preserveFolds: true, + suppressSelectionMerge: true + } + ); + } else { + initialBufferRange = clickedLineBufferRange; + if (addOrRemoveSelection) { + model.addSelectionForBufferRange(clickedLineBufferRange, { + autoscroll: false, + preserveFolds: true + }); + } else { + model.setSelectedBufferRange(clickedLineBufferRange, { + autoscroll: false, + preserveFolds: true + }); + } + } + + const initialScreenRange = model.screenRangeForBufferRange( + initialBufferRange + ); + this.handleMouseDragUntilMouseUp({ + didDrag: event => { + this.autoscrollOnMouseDrag(event, true); + const dragRow = this.screenPositionForMouseEvent(event).row; + const draggedLineScreenRange = Range( + Point(dragRow, 0), + Point(dragRow + 1, 0) + ); + model + .getLastSelection() + .setScreenRange(draggedLineScreenRange.union(initialScreenRange), { + reversed: dragRow < initialScreenRange.start.row, + autoscroll: false, + preserveFolds: true + }); + this.updateSync(); + }, + didStopDragging: () => { + model.mergeIntersectingSelections(); + this.updateSync(); + } + }); + } + + handleMouseDragUntilMouseUp({ didDrag, didStopDragging }) { + let dragging = false; + let lastMousemoveEvent; + + const animationFrameLoop = () => { + window.requestAnimationFrame(() => { + if (dragging && this.visible) { + didDrag(lastMousemoveEvent); + animationFrameLoop(); + } + }); + }; + + function didMouseMove(event) { + lastMousemoveEvent = event; + if (!dragging) { + dragging = true; + animationFrameLoop(); + } + } + + function didMouseUp() { + this.stopDragging = null; + window.removeEventListener('mousemove', didMouseMove); + window.removeEventListener('mouseup', didMouseUp, { capture: true }); + if (dragging) { + dragging = false; + didStopDragging(); + } + } + + window.addEventListener('mousemove', didMouseMove); + window.addEventListener('mouseup', didMouseUp, { capture: true }); + this.stopDragging = didMouseUp; + } + + autoscrollOnMouseDrag({ clientX, clientY }, verticalOnly = false) { + let { + top, + bottom, + left, + right + } = this.refs.scrollContainer.getBoundingClientRect(); // Using var to avoid deopt on += assignments below + top += MOUSE_DRAG_AUTOSCROLL_MARGIN; + bottom -= MOUSE_DRAG_AUTOSCROLL_MARGIN; + left += MOUSE_DRAG_AUTOSCROLL_MARGIN; + right -= MOUSE_DRAG_AUTOSCROLL_MARGIN; + + let yDelta, yDirection; + if (clientY < top) { + yDelta = top - clientY; + yDirection = -1; + } else if (clientY > bottom) { + yDelta = clientY - bottom; + yDirection = 1; + } + + let xDelta, xDirection; + if (clientX < left) { + xDelta = left - clientX; + xDirection = -1; + } else if (clientX > right) { + xDelta = clientX - right; + xDirection = 1; + } + + let scrolled = false; + if (yDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection; + scrolled = this.setScrollTop(this.getScrollTop() + scaledDelta); + } + + if (!verticalOnly && xDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection; + scrolled = this.setScrollLeft(this.getScrollLeft() + scaledDelta); + } + + if (scrolled) this.updateSync(); + } + + screenPositionForMouseEvent(event) { + return this.screenPositionForPixelPosition( + this.pixelPositionForMouseEvent(event) + ); + } + + pixelPositionForMouseEvent({ clientX, clientY }) { + const scrollContainerRect = this.refs.scrollContainer.getBoundingClientRect(); + clientX = Math.min( + scrollContainerRect.right, + Math.max(scrollContainerRect.left, clientX) + ); + clientY = Math.min( + scrollContainerRect.bottom, + Math.max(scrollContainerRect.top, clientY) + ); + const linesRect = this.refs.lineTiles.getBoundingClientRect(); + return { + top: clientY - linesRect.top, + left: clientX - linesRect.left + }; + } + + didUpdateSelections() { + this.pauseCursorBlinking(); + this.scheduleUpdate(); + } + + pauseCursorBlinking() { + this.stopCursorBlinking(); + this.debouncedResumeCursorBlinking(); + } + + resumeCursorBlinking() { + this.cursorsBlinkedOff = true; + this.startCursorBlinking(); + } + + stopCursorBlinking() { + if (this.cursorsBlinking) { + this.cursorsBlinkedOff = false; + this.cursorsBlinking = false; + window.clearInterval(this.cursorBlinkIntervalHandle); + this.cursorBlinkIntervalHandle = null; + this.scheduleUpdate(); + } + } + + startCursorBlinking() { + if (!this.cursorsBlinking) { + this.cursorBlinkIntervalHandle = window.setInterval(() => { + this.cursorsBlinkedOff = !this.cursorsBlinkedOff; + this.scheduleUpdate(true); + }, (this.props.cursorBlinkPeriod || CURSOR_BLINK_PERIOD) / 2); + this.cursorsBlinking = true; + this.scheduleUpdate(true); + } + } + + didRequestAutoscroll(autoscroll) { + this.pendingAutoscroll = autoscroll; + this.scheduleUpdate(); + } + + flushPendingLogicalScrollPosition() { + let changedScrollTop = false; + if (this.pendingScrollTopRow > 0) { + changedScrollTop = this.setScrollTopRow(this.pendingScrollTopRow, false); + this.pendingScrollTopRow = null; + } + + let changedScrollLeft = false; + if (this.pendingScrollLeftColumn > 0) { + changedScrollLeft = this.setScrollLeftColumn( + this.pendingScrollLeftColumn, + false + ); + this.pendingScrollLeftColumn = null; + } + + if (changedScrollTop || changedScrollLeft) { + this.updateSync(); + } + } + + autoscrollVertically(screenRange, options) { + const screenRangeTop = this.pixelPositionAfterBlocksForRow( + screenRange.start.row + ); + const screenRangeBottom = + this.pixelPositionAfterBlocksForRow(screenRange.end.row) + + this.getLineHeight(); + const verticalScrollMargin = this.getVerticalAutoscrollMargin(); + + let desiredScrollTop, desiredScrollBottom; + if (options && options.center) { + const desiredScrollCenter = (screenRangeTop + screenRangeBottom) / 2; + desiredScrollTop = + desiredScrollCenter - this.getScrollContainerClientHeight() / 2; + desiredScrollBottom = + desiredScrollCenter + this.getScrollContainerClientHeight() / 2; + } else { + desiredScrollTop = screenRangeTop - verticalScrollMargin; + desiredScrollBottom = screenRangeBottom + verticalScrollMargin; + } + + if (!options || options.reversed !== false) { + if (desiredScrollBottom > this.getScrollBottom()) { + this.setScrollBottom(desiredScrollBottom); + } + if (desiredScrollTop < this.getScrollTop()) { + this.setScrollTop(desiredScrollTop); + } + } else { + if (desiredScrollTop < this.getScrollTop()) { + this.setScrollTop(desiredScrollTop); + } + if (desiredScrollBottom > this.getScrollBottom()) { + this.setScrollBottom(desiredScrollBottom); + } + } + + return false; + } + + autoscrollHorizontally(screenRange, options) { + const horizontalScrollMargin = this.getHorizontalAutoscrollMargin(); + + const gutterContainerWidth = this.getGutterContainerWidth(); + let left = + this.pixelLeftForRowAndColumn( + screenRange.start.row, + screenRange.start.column + ) + gutterContainerWidth; + let right = + this.pixelLeftForRowAndColumn( + screenRange.end.row, + screenRange.end.column + ) + gutterContainerWidth; + const desiredScrollLeft = Math.max( + 0, + left - horizontalScrollMargin - gutterContainerWidth + ); + const desiredScrollRight = Math.min( + this.getScrollWidth(), + right + horizontalScrollMargin + ); + + if (!options || options.reversed !== false) { + if (desiredScrollRight > this.getScrollRight()) { + this.setScrollRight(desiredScrollRight); + } + if (desiredScrollLeft < this.getScrollLeft()) { + this.setScrollLeft(desiredScrollLeft); + } + } else { + if (desiredScrollLeft < this.getScrollLeft()) { + this.setScrollLeft(desiredScrollLeft); + } + if (desiredScrollRight > this.getScrollRight()) { + this.setScrollRight(desiredScrollRight); + } + } + } + + getVerticalAutoscrollMargin() { + const maxMarginInLines = Math.floor( + (this.getScrollContainerClientHeight() / this.getLineHeight() - 1) / 2 + ); + const marginInLines = Math.min( + this.props.model.verticalScrollMargin, + maxMarginInLines + ); + return marginInLines * this.getLineHeight(); + } + + getHorizontalAutoscrollMargin() { + const maxMarginInBaseCharacters = Math.floor( + (this.getScrollContainerClientWidth() / this.getBaseCharacterWidth() - + 1) / + 2 + ); + const marginInBaseCharacters = Math.min( + this.props.model.horizontalScrollMargin, + maxMarginInBaseCharacters + ); + return marginInBaseCharacters * this.getBaseCharacterWidth(); + } + + // This method is called at the beginning of a frame render to relay any + // potential changes in the editor's width into the model before proceeding. + updateModelSoftWrapColumn() { + const { model } = this.props; + const newEditorWidthInChars = this.getScrollContainerClientWidthInBaseCharacters(); + if (newEditorWidthInChars !== model.getEditorWidthInChars()) { + this.suppressUpdates = true; + + const renderedStartRow = this.getRenderedStartRow(); + this.props.model.setEditorWidthInChars(newEditorWidthInChars); + + // Relaying a change in to the editor's client width may cause the + // vertical scrollbar to appear or disappear, which causes the editor's + // client width to change *again*. Make sure the display layer is fully + // populated for the visible area before recalculating the editor's + // width in characters. Then update the display layer *again* just in + // case a change in scrollbar visibility causes lines to wrap + // differently. We capture the renderedStartRow before resetting the + // display layer because once it has been reset, we can't compute the + // rendered start row accurately. 😥 + this.populateVisibleRowRange(renderedStartRow); + this.props.model.setEditorWidthInChars( + this.getScrollContainerClientWidthInBaseCharacters() + ); + this.derivedDimensionsCache = {}; + + this.suppressUpdates = false; + } + } + + // This method exists because it existed in the previous implementation and some + // package tests relied on it + measureDimensions() { + this.measureCharacterDimensions(); + this.measureGutterDimensions(); + this.measureClientContainerHeight(); + this.measureClientContainerWidth(); + this.measureScrollbarDimensions(); + this.hasInitialMeasurements = true; + } + + measureCharacterDimensions() { + this.measurements.lineHeight = Math.max( + 1, + this.refs.characterMeasurementLine.getBoundingClientRect().height + ); + this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width; + this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width; + this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width; + this.measurements.koreanCharacterWidth = this.refs.koreanCharacterSpan.getBoundingClientRect().width; + + this.props.model.setLineHeightInPixels(this.measurements.lineHeight); + this.props.model.setDefaultCharWidth( + this.measurements.baseCharacterWidth, + this.measurements.doubleWidthCharacterWidth, + this.measurements.halfWidthCharacterWidth, + this.measurements.koreanCharacterWidth + ); + this.lineTopIndex.setDefaultLineHeight(this.measurements.lineHeight); + } + + measureGutterDimensions() { + let dimensionsChanged = false; + + if (this.refs.gutterContainer) { + const gutterContainerWidth = this.refs.gutterContainer.element + .offsetWidth; + if (gutterContainerWidth !== this.measurements.gutterContainerWidth) { + dimensionsChanged = true; + this.measurements.gutterContainerWidth = gutterContainerWidth; + } + } else { + this.measurements.gutterContainerWidth = 0; + } + + if ( + this.refs.gutterContainer && + this.refs.gutterContainer.refs.lineNumberGutter + ) { + const lineNumberGutterWidth = this.refs.gutterContainer.refs + .lineNumberGutter.element.offsetWidth; + if (lineNumberGutterWidth !== this.measurements.lineNumberGutterWidth) { + dimensionsChanged = true; + this.measurements.lineNumberGutterWidth = lineNumberGutterWidth; + } + } else { + this.measurements.lineNumberGutterWidth = 0; + } + + return dimensionsChanged; + } + + measureClientContainerHeight() { + const clientContainerHeight = this.refs.clientContainer.offsetHeight; + if (clientContainerHeight !== this.measurements.clientContainerHeight) { + this.measurements.clientContainerHeight = clientContainerHeight; + return true; + } else { + return false; + } + } + + measureClientContainerWidth() { + const clientContainerWidth = this.refs.clientContainer.offsetWidth; + if (clientContainerWidth !== this.measurements.clientContainerWidth) { + this.measurements.clientContainerWidth = clientContainerWidth; + return true; + } else { + return false; + } + } + + measureScrollbarDimensions() { + if (this.props.model.isMini()) { + this.measurements.verticalScrollbarWidth = 0; + this.measurements.horizontalScrollbarHeight = 0; + } else { + this.measurements.verticalScrollbarWidth = this.refs.verticalScrollbar.getRealScrollbarWidth(); + this.measurements.horizontalScrollbarHeight = this.refs.horizontalScrollbar.getRealScrollbarHeight(); + } + } + + measureLongestLineWidth() { + if (this.longestLineToMeasure) { + const lineComponent = this.lineComponentsByScreenLineId.get( + this.longestLineToMeasure.id + ); + this.measurements.longestLineWidth = + lineComponent.element.firstChild.offsetWidth; + this.longestLineToMeasure = null; + } + } + + requestLineToMeasure(row, screenLine) { + this.linesToMeasure.set(row, screenLine); + } + + requestHorizontalMeasurement(row, column) { + if (column === 0) return; + + const screenLine = this.props.model.screenLineForScreenRow(row); + if (screenLine) { + this.requestLineToMeasure(row, screenLine); + + let columns = this.horizontalPositionsToMeasure.get(row); + if (columns == null) { + columns = []; + this.horizontalPositionsToMeasure.set(row, columns); + } + columns.push(column); + } + } + + measureHorizontalPositions() { + this.horizontalPositionsToMeasure.forEach((columnsToMeasure, row) => { + columnsToMeasure.sort((a, b) => a - b); + + const screenLine = this.renderedScreenLineForRow(row); + const lineComponent = this.lineComponentsByScreenLineId.get( + screenLine.id + ); + + if (!lineComponent) { + const error = new Error( + 'Requested measurement of a line component that is not currently rendered' + ); + error.metadata = { + row, + columnsToMeasure, + renderedScreenLineIds: this.renderedScreenLines.map(line => line.id), + extraRenderedScreenLineIds: Array.from( + this.extraRenderedScreenLines.keys() + ), + lineComponentScreenLineIds: Array.from( + this.lineComponentsByScreenLineId.keys() + ), + renderedStartRow: this.getRenderedStartRow(), + renderedEndRow: this.getRenderedEndRow(), + requestedScreenLineId: screenLine.id + }; + throw error; + } + + const lineNode = lineComponent.element; + const textNodes = lineComponent.textNodes; + let positionsForLine = this.horizontalPixelPositionsByScreenLineId.get( + screenLine.id + ); + if (positionsForLine == null) { + positionsForLine = new Map(); + this.horizontalPixelPositionsByScreenLineId.set( + screenLine.id, + positionsForLine + ); + } + + this.measureHorizontalPositionsOnLine( + lineNode, + textNodes, + columnsToMeasure, + positionsForLine + ); + }); + this.horizontalPositionsToMeasure.clear(); + } + + measureHorizontalPositionsOnLine( + lineNode, + textNodes, + columnsToMeasure, + positions + ) { + let lineNodeClientLeft = -1; + let textNodeStartColumn = 0; + let textNodesIndex = 0; + let lastTextNodeRight = null; + + // eslint-disable-next-line no-labels + columnLoop: for ( + let columnsIndex = 0; + columnsIndex < columnsToMeasure.length; + columnsIndex++ + ) { + const nextColumnToMeasure = columnsToMeasure[columnsIndex]; + while (textNodesIndex < textNodes.length) { + if (nextColumnToMeasure === 0) { + positions.set(0, 0); + continue columnLoop; // eslint-disable-line no-labels + } + + if (positions.has(nextColumnToMeasure)) continue columnLoop; // eslint-disable-line no-labels + const textNode = textNodes[textNodesIndex]; + const textNodeEndColumn = + textNodeStartColumn + textNode.textContent.length; + + if (nextColumnToMeasure < textNodeEndColumn) { + let clientPixelPosition; + if (nextColumnToMeasure === textNodeStartColumn) { + clientPixelPosition = clientRectForRange(textNode, 0, 1).left; + } else { + clientPixelPosition = clientRectForRange( + textNode, + 0, + nextColumnToMeasure - textNodeStartColumn + ).right; + } + + if (lineNodeClientLeft === -1) { + lineNodeClientLeft = lineNode.getBoundingClientRect().left; + } + + positions.set( + nextColumnToMeasure, + Math.round(clientPixelPosition - lineNodeClientLeft) + ); + continue columnLoop; // eslint-disable-line no-labels + } else { + textNodesIndex++; + textNodeStartColumn = textNodeEndColumn; + } + } + + if (lastTextNodeRight == null) { + const lastTextNode = textNodes[textNodes.length - 1]; + lastTextNodeRight = clientRectForRange( + lastTextNode, + 0, + lastTextNode.textContent.length + ).right; + } + + if (lineNodeClientLeft === -1) { + lineNodeClientLeft = lineNode.getBoundingClientRect().left; + } + + positions.set( + nextColumnToMeasure, + Math.round(lastTextNodeRight - lineNodeClientLeft) + ); + } + } + + rowForPixelPosition(pixelPosition) { + return Math.max(0, this.lineTopIndex.rowForPixelPosition(pixelPosition)); + } + + heightForBlockDecorationsBeforeRow(row) { + return ( + this.pixelPositionAfterBlocksForRow(row) - + this.pixelPositionBeforeBlocksForRow(row) + ); + } + + heightForBlockDecorationsAfterRow(row) { + const currentRowBottom = + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight(); + const nextRowTop = this.pixelPositionBeforeBlocksForRow(row + 1); + return nextRowTop - currentRowBottom; + } + + pixelPositionBeforeBlocksForRow(row) { + return this.lineTopIndex.pixelPositionBeforeBlocksForRow(row); + } + + pixelPositionAfterBlocksForRow(row) { + return this.lineTopIndex.pixelPositionAfterBlocksForRow(row); + } + + pixelLeftForRowAndColumn(row, column) { + if (column === 0) return 0; + const screenLine = this.renderedScreenLineForRow(row); + if (screenLine) { + const horizontalPositionsByColumn = this.horizontalPixelPositionsByScreenLineId.get( + screenLine.id + ); + if (horizontalPositionsByColumn) { + return horizontalPositionsByColumn.get(column); + } + } + } + + screenPositionForPixelPosition({ top, left }) { + const { model } = this.props; + + const row = Math.min( + this.rowForPixelPosition(top), + model.getApproximateScreenLineCount() - 1 + ); + + let screenLine = this.renderedScreenLineForRow(row); + if (!screenLine) { + this.requestLineToMeasure(row, model.screenLineForScreenRow(row)); + this.updateSyncBeforeMeasuringContent(); + this.measureContentDuringUpdateSync(); + screenLine = this.renderedScreenLineForRow(row); + } + + const linesClientLeft = this.refs.lineTiles.getBoundingClientRect().left; + const targetClientLeft = linesClientLeft + Math.max(0, left); + const { textNodes } = this.lineComponentsByScreenLineId.get(screenLine.id); + + let containingTextNodeIndex; + { + let low = 0; + let high = textNodes.length - 1; + while (low <= high) { + const mid = low + ((high - low) >> 1); + const textNode = textNodes[mid]; + const textNodeRect = clientRectForRange(textNode, 0, textNode.length); + + if (targetClientLeft < textNodeRect.left) { + high = mid - 1; + containingTextNodeIndex = Math.max(0, mid - 1); + } else if (targetClientLeft > textNodeRect.right) { + low = mid + 1; + containingTextNodeIndex = Math.min(textNodes.length - 1, mid + 1); + } else { + containingTextNodeIndex = mid; + break; + } + } + } + const containingTextNode = textNodes[containingTextNodeIndex]; + let characterIndex = 0; + { + let low = 0; + let high = containingTextNode.length - 1; + while (low <= high) { + const charIndex = low + ((high - low) >> 1); + const nextCharIndex = isPairedCharacter( + containingTextNode.textContent, + charIndex + ) + ? charIndex + 2 + : charIndex + 1; + + const rangeRect = clientRectForRange( + containingTextNode, + charIndex, + nextCharIndex + ); + if (targetClientLeft < rangeRect.left) { + high = charIndex - 1; + characterIndex = Math.max(0, charIndex - 1); + } else if (targetClientLeft > rangeRect.right) { + low = nextCharIndex; + characterIndex = Math.min( + containingTextNode.textContent.length, + nextCharIndex + ); + } else { + if (targetClientLeft <= (rangeRect.left + rangeRect.right) / 2) { + characterIndex = charIndex; + } else { + characterIndex = nextCharIndex; + } + break; + } + } + } + + let textNodeStartColumn = 0; + for (let i = 0; i < containingTextNodeIndex; i++) { + textNodeStartColumn = textNodeStartColumn + textNodes[i].length; + } + const column = textNodeStartColumn + characterIndex; + + return Point(row, column); + } + + didResetDisplayLayer() { + this.spliceLineTopIndex(0, Infinity, Infinity); + this.scheduleUpdate(); + } + + didChangeDisplayLayer(changes) { + for (let i = 0; i < changes.length; i++) { + const { oldRange, newRange } = changes[i]; + this.spliceLineTopIndex( + newRange.start.row, + oldRange.end.row - oldRange.start.row, + newRange.end.row - newRange.start.row + ); + } + + this.scheduleUpdate(); + } + + didChangeSelectionRange() { + const { model } = this.props; + + if (this.getPlatform() === 'linux') { + if (this.selectionClipboardImmediateId) { + clearImmediate(this.selectionClipboardImmediateId); + } + + this.selectionClipboardImmediateId = setImmediate(() => { + this.selectionClipboardImmediateId = null; + + if (model.isDestroyed()) return; + + const selectedText = model.getSelectedText(); + if (selectedText) { + // This uses ipcRenderer.send instead of clipboard.writeText because + // clipboard.writeText is a sync ipcRenderer call on Linux and that + // will slow down selections. + electron.ipcRenderer.send( + 'write-text-to-selection-clipboard', + selectedText + ); + } + }); + } + } + + observeBlockDecorations() { + const { model } = this.props; + const decorations = model.getDecorations({ type: 'block' }); + for (let i = 0; i < decorations.length; i++) { + this.addBlockDecoration(decorations[i]); + } + } + + addBlockDecoration(decoration, subscribeToChanges = true) { + const marker = decoration.getMarker(); + const { item, position } = decoration.getProperties(); + const element = TextEditor.viewForItem(item); + + if (marker.isValid()) { + const row = marker.getHeadScreenPosition().row; + this.lineTopIndex.insertBlock(decoration, row, 0, position === 'after'); + this.blockDecorationsToMeasure.add(decoration); + this.blockDecorationsByElement.set(element, decoration); + this.blockDecorationResizeObserver.observe(element); + + this.scheduleUpdate(); + } + + if (subscribeToChanges) { + let wasValid = marker.isValid(); + + const didUpdateDisposable = marker.bufferMarker.onDidChange( + ({ textChanged }) => { + const isValid = marker.isValid(); + if (wasValid && !isValid) { + wasValid = false; + this.blockDecorationsToMeasure.delete(decoration); + this.heightsByBlockDecoration.delete(decoration); + this.blockDecorationsByElement.delete(element); + this.blockDecorationResizeObserver.unobserve(element); + this.lineTopIndex.removeBlock(decoration); + this.scheduleUpdate(); + } else if (!wasValid && isValid) { + wasValid = true; + this.addBlockDecoration(decoration, false); + } else if (isValid && !textChanged) { + this.lineTopIndex.moveBlock( + decoration, + marker.getHeadScreenPosition().row + ); + this.scheduleUpdate(); + } + } + ); + + const didDestroyDisposable = decoration.onDidDestroy(() => { + didUpdateDisposable.dispose(); + didDestroyDisposable.dispose(); + + if (wasValid) { + wasValid = false; + this.blockDecorationsToMeasure.delete(decoration); + this.heightsByBlockDecoration.delete(decoration); + this.blockDecorationsByElement.delete(element); + this.blockDecorationResizeObserver.unobserve(element); + this.lineTopIndex.removeBlock(decoration); + this.scheduleUpdate(); + } + }); + } + } + + didResizeBlockDecorations(entries) { + if (!this.visible) return; + + for (let i = 0; i < entries.length; i++) { + const { target, contentRect } = entries[i]; + const decoration = this.blockDecorationsByElement.get(target); + const previousHeight = this.heightsByBlockDecoration.get(decoration); + if ( + this.element.contains(target) && + contentRect.height !== previousHeight + ) { + this.invalidateBlockDecorationDimensions(decoration); + } + } + } + + invalidateBlockDecorationDimensions(decoration) { + this.blockDecorationsToMeasure.add(decoration); + this.scheduleUpdate(); + } + + spliceLineTopIndex(startRow, oldExtent, newExtent) { + const invalidatedBlockDecorations = this.lineTopIndex.splice( + startRow, + oldExtent, + newExtent + ); + invalidatedBlockDecorations.forEach(decoration => { + const newPosition = decoration.getMarker().getHeadScreenPosition(); + this.lineTopIndex.moveBlock(decoration, newPosition.row); + }); + } + + isVisible() { + return this.element.offsetWidth > 0 || this.element.offsetHeight > 0; + } + + getWindowInnerHeight() { + return window.innerHeight; + } + + getWindowInnerWidth() { + return window.innerWidth; + } + + getLineHeight() { + return this.measurements.lineHeight; + } + + getBaseCharacterWidth() { + return this.measurements.baseCharacterWidth; + } + + getLongestLineWidth() { + return this.measurements.longestLineWidth; + } + + getClientContainerHeight() { + return this.measurements.clientContainerHeight; + } + + getClientContainerWidth() { + return this.measurements.clientContainerWidth; + } + + getScrollContainerWidth() { + if (this.props.model.getAutoWidth()) { + return this.getScrollWidth(); + } else { + return this.getClientContainerWidth() - this.getGutterContainerWidth(); + } + } + + getScrollContainerHeight() { + if (this.props.model.getAutoHeight()) { + return this.getScrollHeight() + this.getHorizontalScrollbarHeight(); + } else { + return this.getClientContainerHeight(); + } + } + + getScrollContainerClientWidth() { + return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth(); + } + + getScrollContainerClientHeight() { + return ( + this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight() + ); + } + + canScrollVertically() { + const { model } = this.props; + if (model.isMini()) return false; + if (model.getAutoHeight()) return false; + return this.getContentHeight() > this.getScrollContainerClientHeight(); + } + + canScrollHorizontally() { + const { model } = this.props; + if (model.isMini()) return false; + if (model.getAutoWidth()) return false; + if (model.isSoftWrapped()) return false; + return this.getContentWidth() > this.getScrollContainerClientWidth(); + } + + getScrollHeight() { + if (this.props.model.getScrollPastEnd()) { + return ( + this.getContentHeight() + + Math.max( + 3 * this.getLineHeight(), + this.getScrollContainerClientHeight() - 3 * this.getLineHeight() + ) + ); + } else if (this.props.model.getAutoHeight()) { + return this.getContentHeight(); + } else { + return Math.max( + this.getContentHeight(), + this.getScrollContainerClientHeight() + ); + } + } + + getScrollWidth() { + const { model } = this.props; + + if (model.isSoftWrapped()) { + return this.getScrollContainerClientWidth(); + } else if (model.getAutoWidth()) { + return this.getContentWidth(); + } else { + return Math.max( + this.getContentWidth(), + this.getScrollContainerClientWidth() + ); + } + } + + getContentHeight() { + return this.pixelPositionAfterBlocksForRow( + this.props.model.getApproximateScreenLineCount() + ); + } + + getContentWidth() { + return Math.ceil(this.getLongestLineWidth() + this.getBaseCharacterWidth()); + } + + getScrollContainerClientWidthInBaseCharacters() { + return Math.floor( + this.getScrollContainerClientWidth() / this.getBaseCharacterWidth() + ); + } + + getGutterContainerWidth() { + return this.measurements.gutterContainerWidth; + } + + getLineNumberGutterWidth() { + return this.measurements.lineNumberGutterWidth; + } + + getVerticalScrollbarWidth() { + return this.measurements.verticalScrollbarWidth; + } + + getHorizontalScrollbarHeight() { + return this.measurements.horizontalScrollbarHeight; + } + + getRowsPerTile() { + return this.props.rowsPerTile || DEFAULT_ROWS_PER_TILE; + } + + tileStartRowForRow(row) { + return row - (row % this.getRowsPerTile()); + } + + getRenderedStartRow() { + if (this.derivedDimensionsCache.renderedStartRow == null) { + this.derivedDimensionsCache.renderedStartRow = this.tileStartRowForRow( + this.getFirstVisibleRow() + ); + } + + return this.derivedDimensionsCache.renderedStartRow; + } + + getRenderedEndRow() { + if (this.derivedDimensionsCache.renderedEndRow == null) { + this.derivedDimensionsCache.renderedEndRow = Math.min( + this.props.model.getApproximateScreenLineCount(), + this.getRenderedStartRow() + + this.getVisibleTileCount() * this.getRowsPerTile() + ); + } + + return this.derivedDimensionsCache.renderedEndRow; + } + + getRenderedRowCount() { + if (this.derivedDimensionsCache.renderedRowCount == null) { + this.derivedDimensionsCache.renderedRowCount = Math.max( + 0, + this.getRenderedEndRow() - this.getRenderedStartRow() + ); + } + + return this.derivedDimensionsCache.renderedRowCount; + } + + getRenderedTileCount() { + if (this.derivedDimensionsCache.renderedTileCount == null) { + this.derivedDimensionsCache.renderedTileCount = Math.ceil( + this.getRenderedRowCount() / this.getRowsPerTile() + ); + } + + return this.derivedDimensionsCache.renderedTileCount; + } + + getFirstVisibleRow() { + if (this.derivedDimensionsCache.firstVisibleRow == null) { + this.derivedDimensionsCache.firstVisibleRow = this.rowForPixelPosition( + this.getScrollTop() + ); + } + + return this.derivedDimensionsCache.firstVisibleRow; + } + + getLastVisibleRow() { + if (this.derivedDimensionsCache.lastVisibleRow == null) { + this.derivedDimensionsCache.lastVisibleRow = Math.min( + this.props.model.getApproximateScreenLineCount() - 1, + this.rowForPixelPosition(this.getScrollBottom()) + ); + } + + return this.derivedDimensionsCache.lastVisibleRow; + } + + // We may render more tiles than needed if some contain block decorations, + // but keeping this calculation simple ensures the number of tiles remains + // fixed for a given editor height, which eliminates situations where a + // tile is repeatedly added and removed during scrolling in certain + // combinations of editor height and line height. + getVisibleTileCount() { + if (this.derivedDimensionsCache.visibleTileCount == null) { + const editorHeightInTiles = + this.getScrollContainerHeight() / + this.getLineHeight() / + this.getRowsPerTile(); + this.derivedDimensionsCache.visibleTileCount = + Math.ceil(editorHeightInTiles) + 1; + } + return this.derivedDimensionsCache.visibleTileCount; + } + + getFirstVisibleColumn() { + return Math.floor(this.getScrollLeft() / this.getBaseCharacterWidth()); + } + + getScrollTop() { + this.scrollTop = Math.min(this.getMaxScrollTop(), this.scrollTop); + return this.scrollTop; + } + + setScrollTop(scrollTop) { + if (Number.isNaN(scrollTop) || scrollTop == null) return false; + + scrollTop = roundToPhysicalPixelBoundary( + Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop)) + ); + if (scrollTop !== this.scrollTop) { + this.derivedDimensionsCache = {}; + this.scrollTopPending = true; + this.scrollTop = scrollTop; + this.element.emitter.emit('did-change-scroll-top', scrollTop); + return true; + } else { + return false; + } + } + + getMaxScrollTop() { + return Math.round( + Math.max( + 0, + this.getScrollHeight() - this.getScrollContainerClientHeight() + ) + ); + } + + getScrollBottom() { + return this.getScrollTop() + this.getScrollContainerClientHeight(); + } + + setScrollBottom(scrollBottom) { + return this.setScrollTop( + scrollBottom - this.getScrollContainerClientHeight() + ); + } + + getScrollLeft() { + return this.scrollLeft; + } + + setScrollLeft(scrollLeft) { + if (Number.isNaN(scrollLeft) || scrollLeft == null) return false; + + scrollLeft = roundToPhysicalPixelBoundary( + Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft)) + ); + if (scrollLeft !== this.scrollLeft) { + this.scrollLeftPending = true; + this.scrollLeft = scrollLeft; + this.element.emitter.emit('did-change-scroll-left', scrollLeft); + return true; + } else { + return false; + } + } + + getMaxScrollLeft() { + return Math.round( + Math.max(0, this.getScrollWidth() - this.getScrollContainerClientWidth()) + ); + } + + getScrollRight() { + return this.getScrollLeft() + this.getScrollContainerClientWidth(); + } + + setScrollRight(scrollRight) { + return this.setScrollLeft( + scrollRight - this.getScrollContainerClientWidth() + ); + } + + setScrollTopRow(scrollTopRow, scheduleUpdate = true) { + if (this.hasInitialMeasurements) { + const didScroll = this.setScrollTop( + this.pixelPositionBeforeBlocksForRow(scrollTopRow) + ); + if (didScroll && scheduleUpdate) { + this.scheduleUpdate(); + } + return didScroll; + } else { + this.pendingScrollTopRow = scrollTopRow; + return false; + } + } + + getScrollTopRow() { + if (this.hasInitialMeasurements) { + return this.rowForPixelPosition(this.getScrollTop()); + } else { + return this.pendingScrollTopRow || 0; + } + } + + setScrollLeftColumn(scrollLeftColumn, scheduleUpdate = true) { + if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { + const didScroll = this.setScrollLeft( + scrollLeftColumn * this.getBaseCharacterWidth() + ); + if (didScroll && scheduleUpdate) { + this.scheduleUpdate(); + } + return didScroll; + } else { + this.pendingScrollLeftColumn = scrollLeftColumn; + return false; + } + } + + getScrollLeftColumn() { + if (this.hasInitialMeasurements && this.getLongestLineWidth() != null) { + return Math.round(this.getScrollLeft() / this.getBaseCharacterWidth()); + } else { + return this.pendingScrollLeftColumn || 0; + } + } + + // Ensure the spatial index is populated with rows that are currently visible + populateVisibleRowRange(renderedStartRow) { + const { model } = this.props; + const previousScreenLineCount = model.getApproximateScreenLineCount(); + + const renderedEndRow = + renderedStartRow + this.getVisibleTileCount() * this.getRowsPerTile(); + this.props.model.displayLayer.populateSpatialIndexIfNeeded( + Infinity, + renderedEndRow + ); + + // If the approximate screen line count changes, previously-cached derived + // dimensions could now be out of date. + if (model.getApproximateScreenLineCount() !== previousScreenLineCount) { + this.derivedDimensionsCache = {}; + } + } + + populateVisibleTiles() { + const startRow = this.getRenderedStartRow(); + const endRow = this.getRenderedEndRow(); + const freeTileIds = []; + for (let i = 0; i < this.renderedTileStartRows.length; i++) { + const tileStartRow = this.renderedTileStartRows[i]; + if (tileStartRow < startRow || tileStartRow >= endRow) { + const tileId = this.idsByTileStartRow.get(tileStartRow); + freeTileIds.push(tileId); + this.idsByTileStartRow.delete(tileStartRow); + } + } + + const rowsPerTile = this.getRowsPerTile(); + this.renderedTileStartRows.length = this.getRenderedTileCount(); + for ( + let tileStartRow = startRow, i = 0; + tileStartRow < endRow; + tileStartRow = tileStartRow + rowsPerTile, i++ + ) { + this.renderedTileStartRows[i] = tileStartRow; + if (!this.idsByTileStartRow.has(tileStartRow)) { + if (freeTileIds.length > 0) { + this.idsByTileStartRow.set(tileStartRow, freeTileIds.shift()); + } else { + this.idsByTileStartRow.set(tileStartRow, this.nextTileId++); + } + } + } + + this.renderedTileStartRows.sort( + (a, b) => this.idsByTileStartRow.get(a) - this.idsByTileStartRow.get(b) + ); + } + + getNextUpdatePromise() { + if (!this.nextUpdatePromise) { + this.nextUpdatePromise = new Promise(resolve => { + this.resolveNextUpdatePromise = () => { + this.nextUpdatePromise = null; + this.resolveNextUpdatePromise = null; + resolve(); + }; + }); + } + return this.nextUpdatePromise; + } + + setInputEnabled(inputEnabled) { + this.props.model.update({ keyboardInputEnabled: inputEnabled }); + } + + isInputEnabled() { + return ( + !this.props.model.isReadOnly() && + this.props.model.isKeyboardInputEnabled() + ); + } + + getHiddenInput() { + return this.refs.cursorsAndInput.refs.hiddenInput; + } + + getPlatform() { + return this.props.platform || process.platform; + } + + getChromeVersion() { + return this.props.chromeVersion || parseInt(process.versions.chrome); + } +}; + +class DummyScrollbarComponent { + constructor(props) { + this.props = props; + etch.initialize(this); + } + + update(newProps) { + const oldProps = this.props; + this.props = newProps; + etch.updateSync(this); + + const shouldFlushScrollPosition = + newProps.scrollTop !== oldProps.scrollTop || + newProps.scrollLeft !== oldProps.scrollLeft; + if (shouldFlushScrollPosition) this.flushScrollPosition(); + } + + flushScrollPosition() { + if (this.props.orientation === 'horizontal') { + this.element.scrollLeft = this.props.scrollLeft; + } else { + this.element.scrollTop = this.props.scrollTop; + } + } + + render() { + const { + orientation, + scrollWidth, + scrollHeight, + verticalScrollbarWidth, + horizontalScrollbarHeight, + canScroll, + forceScrollbarVisible, + didScroll + } = this.props; + + const outerStyle = { + position: 'absolute', + contain: 'content', + zIndex: 1, + willChange: 'transform' + }; + if (!canScroll) outerStyle.visibility = 'hidden'; + + const innerStyle = {}; + if (orientation === 'horizontal') { + let right = verticalScrollbarWidth || 0; + outerStyle.bottom = 0; + outerStyle.left = 0; + outerStyle.right = right + 'px'; + outerStyle.height = '15px'; + outerStyle.overflowY = 'hidden'; + outerStyle.overflowX = forceScrollbarVisible ? 'scroll' : 'auto'; + outerStyle.cursor = 'default'; + innerStyle.height = '15px'; + innerStyle.width = (scrollWidth || 0) + 'px'; + } else { + let bottom = horizontalScrollbarHeight || 0; + outerStyle.right = 0; + outerStyle.top = 0; + outerStyle.bottom = bottom + 'px'; + outerStyle.width = '15px'; + outerStyle.overflowX = 'hidden'; + outerStyle.overflowY = forceScrollbarVisible ? 'scroll' : 'auto'; + outerStyle.cursor = 'default'; + innerStyle.width = '15px'; + innerStyle.height = (scrollHeight || 0) + 'px'; + } + + return $.div( + { + className: `${orientation}-scrollbar`, + style: outerStyle, + on: { + scroll: didScroll, + mousedown: this.didMouseDown + } + }, + $.div({ style: innerStyle }) + ); + } + + didMouseDown(event) { + let { bottom, right } = this.element.getBoundingClientRect(); + const clickedOnScrollbar = + this.props.orientation === 'horizontal' + ? event.clientY >= bottom - this.getRealScrollbarHeight() + : event.clientX >= right - this.getRealScrollbarWidth(); + if (!clickedOnScrollbar) this.props.didMouseDown(event); + } + + getRealScrollbarWidth() { + return this.element.offsetWidth - this.element.clientWidth; + } + + getRealScrollbarHeight() { + return this.element.offsetHeight - this.element.clientHeight; + } +} + +class GutterContainerComponent { + constructor(props) { + this.props = props; + etch.initialize(this); + } + + update(props) { + if (this.shouldUpdate(props)) { + this.props = props; + etch.updateSync(this); + } + } + + shouldUpdate(props) { + return ( + !props.measuredContent || + props.lineNumberGutterWidth !== this.props.lineNumberGutterWidth + ); + } + + render() { + const { + hasInitialMeasurements, + scrollTop, + scrollHeight, + guttersToRender, + decorationsToRender + } = this.props; + + const innerStyle = { + willChange: 'transform', + display: 'flex' + }; + + if (hasInitialMeasurements) { + innerStyle.transform = `translateY(${-roundToPhysicalPixelBoundary( + scrollTop + )}px)`; + } + + return $.div( + { + ref: 'gutterContainer', + key: 'gutterContainer', + className: 'gutter-container', + style: { + position: 'relative', + zIndex: 1, + backgroundColor: 'inherit' + } + }, + $.div( + { style: innerStyle }, + guttersToRender.map(gutter => { + if (gutter.type === 'line-number') { + return this.renderLineNumberGutter(gutter); + } else { + return $(CustomGutterComponent, { + key: gutter, + element: gutter.getElement(), + name: gutter.name, + visible: gutter.isVisible(), + height: scrollHeight, + decorations: decorationsToRender.customGutter.get(gutter.name) + }); + } + }) + ) + ); + } + + renderLineNumberGutter(gutter) { + const { + rootComponent, + showLineNumbers, + hasInitialMeasurements, + lineNumbersToRender, + renderedStartRow, + renderedEndRow, + rowsPerTile, + decorationsToRender, + didMeasureVisibleBlockDecoration, + scrollHeight, + lineNumberGutterWidth, + lineHeight + } = this.props; + + if (!gutter.isVisible()) { + return null; + } + + const oneTrueLineNumberGutter = gutter.name === 'line-number'; + const ref = oneTrueLineNumberGutter ? 'lineNumberGutter' : undefined; + const width = oneTrueLineNumberGutter ? lineNumberGutterWidth : undefined; + + if (hasInitialMeasurements) { + const { + maxDigits, + keys, + bufferRows, + screenRows, + softWrappedFlags, + foldableFlags + } = lineNumbersToRender; + return $(LineNumberGutterComponent, { + ref, + element: gutter.getElement(), + name: gutter.name, + className: gutter.className, + labelFn: gutter.labelFn, + onMouseDown: gutter.onMouseDown, + onMouseMove: gutter.onMouseMove, + rootComponent: rootComponent, + startRow: renderedStartRow, + endRow: renderedEndRow, + rowsPerTile: rowsPerTile, + maxDigits: maxDigits, + keys: keys, + bufferRows: bufferRows, + screenRows: screenRows, + softWrappedFlags: softWrappedFlags, + foldableFlags: foldableFlags, + decorations: decorationsToRender.lineNumbers.get(gutter.name) || [], + blockDecorations: decorationsToRender.blocks, + didMeasureVisibleBlockDecoration: didMeasureVisibleBlockDecoration, + height: scrollHeight, + width, + lineHeight: lineHeight, + showLineNumbers + }); + } else { + return $(LineNumberGutterComponent, { + ref, + element: gutter.getElement(), + name: gutter.name, + className: gutter.className, + onMouseDown: gutter.onMouseDown, + onMouseMove: gutter.onMouseMove, + maxDigits: lineNumbersToRender.maxDigits, + showLineNumbers + }); + } + } +} + +class LineNumberGutterComponent { + constructor(props) { + this.props = props; + this.element = this.props.element; + this.virtualNode = $.div(null); + this.virtualNode.domNode = this.element; + this.nodePool = new NodePool(); + etch.updateSync(this); + } + + update(newProps) { + if (this.shouldUpdate(newProps)) { + this.props = newProps; + etch.updateSync(this); + } + } + + render() { + const { + rootComponent, + showLineNumbers, + height, + width, + startRow, + endRow, + rowsPerTile, + maxDigits, + keys, + bufferRows, + screenRows, + softWrappedFlags, + foldableFlags, + decorations, + className + } = this.props; + + let children = null; + + if (bufferRows) { + children = new Array(rootComponent.renderedTileStartRows.length); + for (let i = 0; i < rootComponent.renderedTileStartRows.length; i++) { + const tileStartRow = rootComponent.renderedTileStartRows[i]; + const tileEndRow = Math.min(endRow, tileStartRow + rowsPerTile); + const tileChildren = new Array(tileEndRow - tileStartRow); + for (let row = tileStartRow; row < tileEndRow; row++) { + const indexInTile = row - tileStartRow; + const j = row - startRow; + const key = keys[j]; + const softWrapped = softWrappedFlags[j]; + const foldable = foldableFlags[j]; + const bufferRow = bufferRows[j]; + const screenRow = screenRows[j]; + + let className = 'line-number'; + if (foldable) className = className + ' foldable'; + + const decorationsForRow = decorations[row - startRow]; + if (decorationsForRow) + className = className + ' ' + decorationsForRow; + + let number = null; + if (showLineNumbers) { + if (this.props.labelFn == null) { + number = softWrapped ? '•' : bufferRow + 1; + number = + NBSP_CHARACTER.repeat(maxDigits - number.length) + number; + } else { + number = this.props.labelFn({ + bufferRow, + screenRow, + foldable, + softWrapped, + maxDigits + }); + } + } + + // We need to adjust the line number position to account for block + // decorations preceding the current row and following the preceding + // row. Note that we ignore the latter when the line number starts at + // the beginning of the tile, because the tile will already be + // positioned to take into account block decorations added after the + // last row of the previous tile. + let marginTop = rootComponent.heightForBlockDecorationsBeforeRow(row); + if (indexInTile > 0) + marginTop += rootComponent.heightForBlockDecorationsAfterRow( + row - 1 + ); + + tileChildren[row - tileStartRow] = $(LineNumberComponent, { + key, + className, + width, + bufferRow, + screenRow, + number, + marginTop, + nodePool: this.nodePool + }); + } + + const tileTop = rootComponent.pixelPositionBeforeBlocksForRow( + tileStartRow + ); + const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow( + tileEndRow + ); + const tileHeight = tileBottom - tileTop; + const tileWidth = width != null && width > 0 ? width + 'px' : ''; + + children[i] = $.div( + { + key: rootComponent.idsByTileStartRow.get(tileStartRow), + style: { + contain: 'layout style', + position: 'absolute', + top: 0, + height: tileHeight + 'px', + width: tileWidth, + transform: `translateY(${tileTop}px)` + } + }, + ...tileChildren + ); + } + } + + let rootClassName = 'gutter line-numbers'; + if (className) { + rootClassName += ' ' + className; + } + + return $.div( + { + className: rootClassName, + attributes: { 'gutter-name': this.props.name }, + style: { + position: 'relative', + height: ceilToPhysicalPixelBoundary(height) + 'px' + }, + on: { + mousedown: this.didMouseDown, + mousemove: this.didMouseMove + } + }, + $.div( + { + key: 'placeholder', + className: 'line-number dummy', + style: { visibility: 'hidden' } + }, + showLineNumbers ? '0'.repeat(maxDigits) : null, + $.div({ className: 'icon-right' }) + ), + children + ); + } + + shouldUpdate(newProps) { + const oldProps = this.props; + + if (oldProps.showLineNumbers !== newProps.showLineNumbers) return true; + if (oldProps.height !== newProps.height) return true; + if (oldProps.width !== newProps.width) return true; + if (oldProps.lineHeight !== newProps.lineHeight) return true; + if (oldProps.startRow !== newProps.startRow) return true; + if (oldProps.endRow !== newProps.endRow) return true; + if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true; + if (oldProps.maxDigits !== newProps.maxDigits) return true; + if (oldProps.labelFn !== newProps.labelFn) return true; + if (oldProps.className !== newProps.className) return true; + if (newProps.didMeasureVisibleBlockDecoration) return true; + if (!arraysEqual(oldProps.keys, newProps.keys)) return true; + if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true; + if (!arraysEqual(oldProps.foldableFlags, newProps.foldableFlags)) + return true; + if (!arraysEqual(oldProps.decorations, newProps.decorations)) return true; + + let oldTileStartRow = oldProps.startRow; + let newTileStartRow = newProps.startRow; + while ( + oldTileStartRow < oldProps.endRow || + newTileStartRow < newProps.endRow + ) { + let oldTileBlockDecorations = oldProps.blockDecorations.get( + oldTileStartRow + ); + let newTileBlockDecorations = newProps.blockDecorations.get( + newTileStartRow + ); + + if (oldTileBlockDecorations && newTileBlockDecorations) { + if (oldTileBlockDecorations.size !== newTileBlockDecorations.size) + return true; + + let blockDecorationsChanged = false; + + oldTileBlockDecorations.forEach((oldDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const newDecorations = newTileBlockDecorations.get(screenLineId); + blockDecorationsChanged = + newDecorations == null || + !arraysEqual(oldDecorations, newDecorations); + } + }); + if (blockDecorationsChanged) return true; + + newTileBlockDecorations.forEach((newDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldTileBlockDecorations.get(screenLineId); + blockDecorationsChanged = oldDecorations == null; + } + }); + if (blockDecorationsChanged) return true; + } else if (oldTileBlockDecorations) { + return true; + } else if (newTileBlockDecorations) { + return true; + } + + oldTileStartRow += oldProps.rowsPerTile; + newTileStartRow += newProps.rowsPerTile; + } + + return false; + } + + didMouseDown(event) { + if (this.props.onMouseDown == null) { + this.props.rootComponent.didMouseDownOnLineNumberGutter(event); + } else { + const { bufferRow, screenRow } = event.target.dataset; + this.props.onMouseDown({ + bufferRow: parseInt(bufferRow, 10), + screenRow: parseInt(screenRow, 10), + domEvent: event + }); + } + } + + didMouseMove(event) { + if (this.props.onMouseMove != null) { + const { bufferRow, screenRow } = event.target.dataset; + this.props.onMouseMove({ + bufferRow: parseInt(bufferRow, 10), + screenRow: parseInt(screenRow, 10), + domEvent: event + }); + } + } +} + +class LineNumberComponent { + constructor(props) { + const { + className, + width, + marginTop, + bufferRow, + screenRow, + number, + nodePool + } = props; + this.props = props; + const style = {}; + if (width != null && width > 0) style.width = width + 'px'; + if (marginTop != null && marginTop > 0) style.marginTop = marginTop + 'px'; + this.element = nodePool.getElement('DIV', className, style); + this.element.dataset.bufferRow = bufferRow; + this.element.dataset.screenRow = screenRow; + if (number) this.element.appendChild(nodePool.getTextNode(number)); + this.element.appendChild(nodePool.getElement('DIV', 'icon-right', null)); + } + + destroy() { + this.element.remove(); + this.props.nodePool.release(this.element); + } + + update(props) { + const { + nodePool, + className, + width, + marginTop, + bufferRow, + screenRow, + number + } = props; + + if (this.props.bufferRow !== bufferRow) + this.element.dataset.bufferRow = bufferRow; + if (this.props.screenRow !== screenRow) + this.element.dataset.screenRow = screenRow; + if (this.props.className !== className) this.element.className = className; + if (this.props.width !== width) { + if (width != null && width > 0) { + this.element.style.width = width + 'px'; + } else { + this.element.style.width = ''; + } + } + if (this.props.marginTop !== marginTop) { + if (marginTop != null && marginTop > 0) { + this.element.style.marginTop = marginTop + 'px'; + } else { + this.element.style.marginTop = ''; + } + } + + if (this.props.number !== number) { + if (this.props.number != null) { + const numberNode = this.element.firstChild; + numberNode.remove(); + nodePool.release(numberNode); + } + + if (number != null) { + this.element.insertBefore( + nodePool.getTextNode(number), + this.element.firstChild + ); + } + } + + this.props = props; + } +} + +class CustomGutterComponent { + constructor(props) { + this.props = props; + this.element = this.props.element; + this.virtualNode = $.div(null); + this.virtualNode.domNode = this.element; + etch.updateSync(this); + } + + update(props) { + this.props = props; + etch.updateSync(this); + } + + destroy() { + etch.destroy(this); + } + + render() { + let className = 'gutter'; + if (this.props.className) { + className += ' ' + this.props.className; + } + return $.div( + { + className, + attributes: { 'gutter-name': this.props.name }, + style: { + display: this.props.visible ? '' : 'none' + } + }, + $.div( + { + className: 'custom-decorations', + style: { height: this.props.height + 'px' } + }, + this.renderDecorations() + ) + ); + } + + renderDecorations() { + if (!this.props.decorations) return null; + + return this.props.decorations.map(({ className, element, top, height }) => { + return $(CustomGutterDecorationComponent, { + className, + element, + top, + height + }); + }); + } +} + +class CustomGutterDecorationComponent { + constructor(props) { + this.props = props; + this.element = document.createElement('div'); + const { top, height, className, element } = this.props; + + this.element.style.position = 'absolute'; + this.element.style.top = top + 'px'; + this.element.style.height = height + 'px'; + if (className != null) this.element.className = className; + if (element != null) { + this.element.appendChild(element); + element.style.height = height + 'px'; + } + } + + update(newProps) { + const oldProps = this.props; + this.props = newProps; + + if (newProps.top !== oldProps.top) + this.element.style.top = newProps.top + 'px'; + if (newProps.height !== oldProps.height) { + this.element.style.height = newProps.height + 'px'; + if (newProps.element) + newProps.element.style.height = newProps.height + 'px'; + } + if (newProps.className !== oldProps.className) + this.element.className = newProps.className || ''; + if (newProps.element !== oldProps.element) { + if (this.element.firstChild) this.element.firstChild.remove(); + if (newProps.element != null) { + this.element.appendChild(newProps.element); + newProps.element.style.height = newProps.height + 'px'; + } + } + } +} + +class CursorsAndInputComponent { + constructor(props) { + this.props = props; + etch.initialize(this); + } + + update(props) { + if (props.measuredContent) { + this.props = props; + etch.updateSync(this); + } + } + + updateCursorBlinkSync(cursorsBlinkedOff) { + this.props.cursorsBlinkedOff = cursorsBlinkedOff; + const className = this.getCursorsClassName(); + this.refs.cursors.className = className; + this.virtualNode.props.className = className; + } + + render() { + const { + lineHeight, + decorationsToRender, + scrollHeight, + scrollWidth + } = this.props; + + const className = this.getCursorsClassName(); + const cursorHeight = lineHeight + 'px'; + + const children = [this.renderHiddenInput()]; + for (let i = 0; i < decorationsToRender.cursors.length; i++) { + const { + pixelLeft, + pixelTop, + pixelWidth, + className: extraCursorClassName, + style: extraCursorStyle + } = decorationsToRender.cursors[i]; + let cursorClassName = 'cursor'; + if (extraCursorClassName) cursorClassName += ' ' + extraCursorClassName; + + const cursorStyle = { + height: cursorHeight, + width: Math.min(pixelWidth, scrollWidth - pixelLeft) + 'px', + transform: `translate(${pixelLeft}px, ${pixelTop}px)` + }; + if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle); + + children.push( + $.div({ + className: cursorClassName, + style: cursorStyle + }) + ); + } + + return $.div( + { + key: 'cursors', + ref: 'cursors', + className, + style: { + position: 'absolute', + contain: 'strict', + zIndex: 1, + width: scrollWidth + 'px', + height: scrollHeight + 'px', + pointerEvents: 'none', + userSelect: 'none' + } + }, + children + ); + } + + getCursorsClassName() { + return this.props.cursorsBlinkedOff ? 'cursors blink-off' : 'cursors'; + } + + renderHiddenInput() { + const { + lineHeight, + hiddenInputPosition, + didBlurHiddenInput, + didFocusHiddenInput, + didPaste, + didTextInput, + didKeydown, + didKeyup, + didKeypress, + didCompositionStart, + didCompositionUpdate, + didCompositionEnd, + tabIndex + } = this.props; + + let top, left; + if (hiddenInputPosition) { + top = hiddenInputPosition.pixelTop; + left = hiddenInputPosition.pixelLeft; + } else { + top = 0; + left = 0; + } + + return $.input({ + ref: 'hiddenInput', + key: 'hiddenInput', + className: 'hidden-input', + on: { + blur: didBlurHiddenInput, + focus: didFocusHiddenInput, + paste: didPaste, + textInput: didTextInput, + keydown: didKeydown, + keyup: didKeyup, + keypress: didKeypress, + compositionstart: didCompositionStart, + compositionupdate: didCompositionUpdate, + compositionend: didCompositionEnd + }, + tabIndex: tabIndex, + style: { + position: 'absolute', + width: '1px', + height: lineHeight + 'px', + top: top + 'px', + left: left + 'px', + opacity: 0, + padding: 0, + border: 0 + } + }); + } +} + +class LinesTileComponent { + constructor(props) { + this.props = props; + etch.initialize(this); + this.createLines(); + this.updateBlockDecorations({}, props); + } + + update(newProps) { + if (this.shouldUpdate(newProps)) { + const oldProps = this.props; + this.props = newProps; + etch.updateSync(this); + if (!newProps.measuredContent) { + this.updateLines(oldProps, newProps); + this.updateBlockDecorations(oldProps, newProps); + } + } + } + + destroy() { + for (let i = 0; i < this.lineComponents.length; i++) { + this.lineComponents[i].destroy(); + } + this.lineComponents.length = 0; + + return etch.destroy(this); + } + + render() { + const { height, width, top } = this.props; + + return $.div( + { + style: { + contain: 'layout style', + position: 'absolute', + height: height + 'px', + width: width + 'px', + transform: `translateY(${top}px)` + } + } + // Lines and block decorations will be manually inserted here for efficiency + ); + } + + createLines() { + const { + tileStartRow, + screenLines, + lineDecorations, + textDecorations, + nodePool, + displayLayer, + lineComponentsByScreenLineId + } = this.props; + + this.lineComponents = []; + for (let i = 0, length = screenLines.length; i < length; i++) { + const component = new LineComponent({ + screenLine: screenLines[i], + screenRow: tileStartRow + i, + lineDecoration: lineDecorations[i], + textDecorations: textDecorations[i], + displayLayer, + nodePool, + lineComponentsByScreenLineId + }); + this.element.appendChild(component.element); + this.lineComponents.push(component); + } + } + + updateLines(oldProps, newProps) { + const { + screenLines, + tileStartRow, + lineDecorations, + textDecorations, + nodePool, + displayLayer, + lineComponentsByScreenLineId + } = newProps; + + const oldScreenLines = oldProps.screenLines; + const newScreenLines = screenLines; + const oldScreenLinesEndIndex = oldScreenLines.length; + const newScreenLinesEndIndex = newScreenLines.length; + let oldScreenLineIndex = 0; + let newScreenLineIndex = 0; + let lineComponentIndex = 0; + + while ( + oldScreenLineIndex < oldScreenLinesEndIndex || + newScreenLineIndex < newScreenLinesEndIndex + ) { + const oldScreenLine = oldScreenLines[oldScreenLineIndex]; + const newScreenLine = newScreenLines[newScreenLineIndex]; + + if (oldScreenLineIndex >= oldScreenLinesEndIndex) { + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLine, + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], + displayLayer, + nodePool, + lineComponentsByScreenLineId + }); + this.element.appendChild(newScreenLineComponent.element); + this.lineComponents.push(newScreenLineComponent); + + newScreenLineIndex++; + lineComponentIndex++; + } else if (newScreenLineIndex >= newScreenLinesEndIndex) { + this.lineComponents[lineComponentIndex].destroy(); + this.lineComponents.splice(lineComponentIndex, 1); + + oldScreenLineIndex++; + } else if (oldScreenLine === newScreenLine) { + const lineComponent = this.lineComponents[lineComponentIndex]; + lineComponent.update({ + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex] + }); + + oldScreenLineIndex++; + newScreenLineIndex++; + lineComponentIndex++; + } else { + const oldScreenLineIndexInNewScreenLines = newScreenLines.indexOf( + oldScreenLine + ); + const newScreenLineIndexInOldScreenLines = oldScreenLines.indexOf( + newScreenLine + ); + if ( + newScreenLineIndex < oldScreenLineIndexInNewScreenLines && + oldScreenLineIndexInNewScreenLines < newScreenLinesEndIndex + ) { + const newScreenLineComponents = []; + while (newScreenLineIndex < oldScreenLineIndexInNewScreenLines) { + // eslint-disable-next-line no-redeclare + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], + displayLayer, + nodePool, + lineComponentsByScreenLineId + }); + this.element.insertBefore( + newScreenLineComponent.element, + this.getFirstElementForScreenLine(oldProps, oldScreenLine) + ); + newScreenLineComponents.push(newScreenLineComponent); + + newScreenLineIndex++; + } + + this.lineComponents.splice( + lineComponentIndex, + 0, + ...newScreenLineComponents + ); + lineComponentIndex = + lineComponentIndex + newScreenLineComponents.length; + } else if ( + oldScreenLineIndex < newScreenLineIndexInOldScreenLines && + newScreenLineIndexInOldScreenLines < oldScreenLinesEndIndex + ) { + while (oldScreenLineIndex < newScreenLineIndexInOldScreenLines) { + this.lineComponents[lineComponentIndex].destroy(); + this.lineComponents.splice(lineComponentIndex, 1); + + oldScreenLineIndex++; + } + } else { + const oldScreenLineComponent = this.lineComponents[ + lineComponentIndex + ]; + // eslint-disable-next-line no-redeclare + var newScreenLineComponent = new LineComponent({ + screenLine: newScreenLines[newScreenLineIndex], + screenRow: tileStartRow + newScreenLineIndex, + lineDecoration: lineDecorations[newScreenLineIndex], + textDecorations: textDecorations[newScreenLineIndex], + displayLayer, + nodePool, + lineComponentsByScreenLineId + }); + this.element.insertBefore( + newScreenLineComponent.element, + oldScreenLineComponent.element + ); + oldScreenLineComponent.destroy(); + this.lineComponents[lineComponentIndex] = newScreenLineComponent; + + oldScreenLineIndex++; + newScreenLineIndex++; + lineComponentIndex++; + } + } + } + } + + getFirstElementForScreenLine(oldProps, screenLine) { + const blockDecorations = oldProps.blockDecorations + ? oldProps.blockDecorations.get(screenLine.id) + : null; + if (blockDecorations) { + const blockDecorationElementsBeforeOldScreenLine = []; + for (let i = 0; i < blockDecorations.length; i++) { + const decoration = blockDecorations[i]; + if (decoration.position !== 'after') { + blockDecorationElementsBeforeOldScreenLine.push( + TextEditor.viewForItem(decoration.item) + ); + } + } + + for ( + let i = 0; + i < blockDecorationElementsBeforeOldScreenLine.length; + i++ + ) { + const blockDecorationElement = + blockDecorationElementsBeforeOldScreenLine[i]; + if ( + !blockDecorationElementsBeforeOldScreenLine.includes( + blockDecorationElement.previousSibling + ) + ) { + return blockDecorationElement; + } + } + } + + return oldProps.lineComponentsByScreenLineId.get(screenLine.id).element; + } + + updateBlockDecorations(oldProps, newProps) { + const { blockDecorations, lineComponentsByScreenLineId } = newProps; + + if (oldProps.blockDecorations) { + oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { + const newDecorations = newProps.blockDecorations + ? newProps.blockDecorations.get(screenLineId) + : null; + for (let i = 0; i < oldDecorations.length; i++) { + const oldDecoration = oldDecorations[i]; + if (newDecorations && newDecorations.includes(oldDecoration)) + continue; + + const element = TextEditor.viewForItem(oldDecoration.item); + if (element.parentElement !== this.element) continue; + + element.remove(); + } + }); + } + + if (blockDecorations) { + blockDecorations.forEach((newDecorations, screenLineId) => { + const oldDecorations = oldProps.blockDecorations + ? oldProps.blockDecorations.get(screenLineId) + : null; + const lineNode = lineComponentsByScreenLineId.get(screenLineId).element; + let lastAfter = lineNode; + + for (let i = 0; i < newDecorations.length; i++) { + const newDecoration = newDecorations[i]; + const element = TextEditor.viewForItem(newDecoration.item); + + if (oldDecorations && oldDecorations.includes(newDecoration)) { + if (newDecoration.position === 'after') { + lastAfter = element; + } + continue; + } + + if (newDecoration.position === 'after') { + this.element.insertBefore(element, lastAfter.nextSibling); + lastAfter = element; + } else { + this.element.insertBefore(element, lineNode); + } + } + }); + } + } + + shouldUpdate(newProps) { + const oldProps = this.props; + if (oldProps.top !== newProps.top) return true; + if (oldProps.height !== newProps.height) return true; + if (oldProps.width !== newProps.width) return true; + if (oldProps.lineHeight !== newProps.lineHeight) return true; + if (oldProps.tileStartRow !== newProps.tileStartRow) return true; + if (oldProps.tileEndRow !== newProps.tileEndRow) return true; + if (!arraysEqual(oldProps.screenLines, newProps.screenLines)) return true; + if (!arraysEqual(oldProps.lineDecorations, newProps.lineDecorations)) + return true; + + if (oldProps.blockDecorations && newProps.blockDecorations) { + if (oldProps.blockDecorations.size !== newProps.blockDecorations.size) + return true; + + let blockDecorationsChanged = false; + + oldProps.blockDecorations.forEach((oldDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const newDecorations = newProps.blockDecorations.get(screenLineId); + blockDecorationsChanged = + newDecorations == null || + !arraysEqual(oldDecorations, newDecorations); + } + }); + if (blockDecorationsChanged) return true; + + newProps.blockDecorations.forEach((newDecorations, screenLineId) => { + if (!blockDecorationsChanged) { + const oldDecorations = oldProps.blockDecorations.get(screenLineId); + blockDecorationsChanged = oldDecorations == null; + } + }); + if (blockDecorationsChanged) return true; + } else if (oldProps.blockDecorations) { + return true; + } else if (newProps.blockDecorations) { + return true; + } + + if (oldProps.textDecorations.length !== newProps.textDecorations.length) + return true; + for (let i = 0; i < oldProps.textDecorations.length; i++) { + if ( + !textDecorationsEqual( + oldProps.textDecorations[i], + newProps.textDecorations[i] + ) + ) + return true; + } + + return false; + } +} + +class LineComponent { + constructor(props) { + const { + nodePool, + screenRow, + screenLine, + lineComponentsByScreenLineId, + offScreen + } = props; + this.props = props; + this.element = nodePool.getElement('DIV', this.buildClassName(), null); + this.element.dataset.screenRow = screenRow; + this.textNodes = []; + + if (offScreen) { + this.element.style.position = 'absolute'; + this.element.style.visibility = 'hidden'; + this.element.dataset.offScreen = true; + } + + this.appendContents(); + lineComponentsByScreenLineId.set(screenLine.id, this); + } + + update(newProps) { + if (this.props.lineDecoration !== newProps.lineDecoration) { + this.props.lineDecoration = newProps.lineDecoration; + this.element.className = this.buildClassName(); + } + + if (this.props.screenRow !== newProps.screenRow) { + this.props.screenRow = newProps.screenRow; + this.element.dataset.screenRow = newProps.screenRow; + } + + if ( + !textDecorationsEqual( + this.props.textDecorations, + newProps.textDecorations + ) + ) { + this.props.textDecorations = newProps.textDecorations; + this.element.firstChild.remove(); + this.appendContents(); + } + } + + destroy() { + const { nodePool, lineComponentsByScreenLineId, screenLine } = this.props; + + if (lineComponentsByScreenLineId.get(screenLine.id) === this) { + lineComponentsByScreenLineId.delete(screenLine.id); + } + + this.element.remove(); + nodePool.release(this.element); + } + + appendContents() { + const { displayLayer, nodePool, screenLine, textDecorations } = this.props; + + this.textNodes.length = 0; + + const { lineText, tags } = screenLine; + let openScopeNode = nodePool.getElement('SPAN', null, null); + this.element.appendChild(openScopeNode); + + let decorationIndex = 0; + let column = 0; + let activeClassName = null; + let activeStyle = null; + let nextDecoration = textDecorations + ? textDecorations[decorationIndex] + : null; + if (nextDecoration && nextDecoration.column === 0) { + column = nextDecoration.column; + activeClassName = nextDecoration.className; + activeStyle = nextDecoration.style; + nextDecoration = textDecorations[++decorationIndex]; + } + + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + if (tag !== 0) { + if (displayLayer.isCloseTag(tag)) { + openScopeNode = openScopeNode.parentElement; + } else if (displayLayer.isOpenTag(tag)) { + const newScopeNode = nodePool.getElement( + 'SPAN', + displayLayer.classNameForTag(tag), + null + ); + openScopeNode.appendChild(newScopeNode); + openScopeNode = newScopeNode; + } else { + const nextTokenColumn = column + tag; + while (nextDecoration && nextDecoration.column <= nextTokenColumn) { + const text = lineText.substring(column, nextDecoration.column); + this.appendTextNode( + openScopeNode, + text, + activeClassName, + activeStyle + ); + column = nextDecoration.column; + activeClassName = nextDecoration.className; + activeStyle = nextDecoration.style; + nextDecoration = textDecorations[++decorationIndex]; + } + + if (column < nextTokenColumn) { + const text = lineText.substring(column, nextTokenColumn); + this.appendTextNode( + openScopeNode, + text, + activeClassName, + activeStyle + ); + column = nextTokenColumn; + } + } + } + } + + if (column === 0) { + const textNode = nodePool.getTextNode(' '); + this.element.appendChild(textNode); + this.textNodes.push(textNode); + } + + if (lineText.endsWith(displayLayer.foldCharacter)) { + // Insert a zero-width non-breaking whitespace, so that LinesYardstick can + // take the fold-marker::after pseudo-element into account during + // measurements when such marker is the last character on the line. + const textNode = nodePool.getTextNode(ZERO_WIDTH_NBSP_CHARACTER); + this.element.appendChild(textNode); + this.textNodes.push(textNode); + } + } + + appendTextNode(openScopeNode, text, activeClassName, activeStyle) { + const { nodePool } = this.props; + + if (activeClassName || activeStyle) { + const decorationNode = nodePool.getElement( + 'SPAN', + activeClassName, + activeStyle + ); + openScopeNode.appendChild(decorationNode); + openScopeNode = decorationNode; + } + + const textNode = nodePool.getTextNode(text); + openScopeNode.appendChild(textNode); + this.textNodes.push(textNode); + } + + buildClassName() { + const { lineDecoration } = this.props; + let className = 'line'; + if (lineDecoration != null) className = className + ' ' + lineDecoration; + return className; + } +} + +class HighlightsComponent { + constructor(props) { + this.props = {}; + this.element = document.createElement('div'); + this.element.className = 'highlights'; + 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); + } + + destroy() { + this.highlightComponentsByKey.forEach(highlightComponent => { + highlightComponent.destroy(); + }); + this.highlightComponentsByKey.clear(); + } + + update(newProps) { + if (this.shouldUpdate(newProps)) { + this.props = newProps; + const { height, width, lineHeight, highlightDecorations } = this.props; + + this.element.style.height = height + 'px'; + this.element.style.width = width + 'px'; + + const visibleHighlightDecorations = new Set(); + if (highlightDecorations) { + for (let i = 0; i < highlightDecorations.length; i++) { + const highlightDecoration = highlightDecorations[i]; + const highlightProps = Object.assign( + { lineHeight }, + highlightDecorations[i] + ); + + let highlightComponent = this.highlightComponentsByKey.get( + highlightDecoration.key + ); + if (highlightComponent) { + highlightComponent.update(highlightProps); + } else { + highlightComponent = new HighlightComponent(highlightProps); + this.element.appendChild(highlightComponent.element); + this.highlightComponentsByKey.set( + highlightDecoration.key, + highlightComponent + ); + } + + highlightDecorations[i].flashRequested = false; + visibleHighlightDecorations.add(highlightDecoration.key); + } + } + + this.highlightComponentsByKey.forEach((highlightComponent, key) => { + if (!visibleHighlightDecorations.has(key)) { + highlightComponent.destroy(); + this.highlightComponentsByKey.delete(key); + } + }); + } + } + + shouldUpdate(newProps) { + const oldProps = this.props; + + if (!newProps.hasInitialMeasurements) return false; + + if (oldProps.width !== newProps.width) return true; + if (oldProps.height !== newProps.height) return true; + if (oldProps.lineHeight !== newProps.lineHeight) return true; + if (!oldProps.highlightDecorations && newProps.highlightDecorations) + return true; + if (oldProps.highlightDecorations && !newProps.highlightDecorations) + return true; + if (oldProps.highlightDecorations && newProps.highlightDecorations) { + if ( + oldProps.highlightDecorations.length !== + newProps.highlightDecorations.length + ) + return true; + + for ( + let i = 0, length = oldProps.highlightDecorations.length; + i < length; + i++ + ) { + const oldHighlight = oldProps.highlightDecorations[i]; + const newHighlight = newProps.highlightDecorations[i]; + if (oldHighlight.className !== newHighlight.className) return true; + if (newHighlight.flashRequested) return true; + if (oldHighlight.startPixelTop !== newHighlight.startPixelTop) + return true; + if (oldHighlight.startPixelLeft !== newHighlight.startPixelLeft) + return true; + if (oldHighlight.endPixelTop !== newHighlight.endPixelTop) return true; + if (oldHighlight.endPixelLeft !== newHighlight.endPixelLeft) + return true; + if (!oldHighlight.screenRange.isEqual(newHighlight.screenRange)) + return true; + } + } + } +} + +class HighlightComponent { + constructor(props) { + this.props = props; + etch.initialize(this); + if (this.props.flashRequested) this.performFlash(); + } + + destroy() { + if (this.timeoutsByClassName) { + this.timeoutsByClassName.forEach(timeout => { + window.clearTimeout(timeout); + }); + this.timeoutsByClassName.clear(); + } + + return etch.destroy(this); + } + + update(newProps) { + this.props = newProps; + etch.updateSync(this); + if (newProps.flashRequested) this.performFlash(); + } + + performFlash() { + const { flashClass, flashDuration } = this.props; + if (!this.timeoutsByClassName) this.timeoutsByClassName = new Map(); + + // If a flash of this class is already in progress, clear it early and + // flash again on the next frame to ensure CSS transitions apply to the + // second flash. + if (this.timeoutsByClassName.has(flashClass)) { + window.clearTimeout(this.timeoutsByClassName.get(flashClass)); + this.timeoutsByClassName.delete(flashClass); + this.element.classList.remove(flashClass); + requestAnimationFrame(() => this.performFlash()); + } else { + this.element.classList.add(flashClass); + this.timeoutsByClassName.set( + flashClass, + window.setTimeout(() => { + this.element.classList.remove(flashClass); + }, flashDuration) + ); + } + } + + render() { + const { + className, + screenRange, + lineHeight, + startPixelTop, + startPixelLeft, + endPixelTop, + endPixelLeft + } = this.props; + const regionClassName = 'region ' + className; + + let children; + if (screenRange.start.row === screenRange.end.row) { + children = $.div({ + className: regionClassName, + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + 'px', + left: startPixelLeft + 'px', + width: endPixelLeft - startPixelLeft + 'px', + height: lineHeight + 'px' + } + }); + } else { + children = []; + children.push( + $.div({ + className: regionClassName, + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + 'px', + left: startPixelLeft + 'px', + right: 0, + height: lineHeight + 'px' + } + }) + ); + + if (screenRange.end.row - screenRange.start.row > 1) { + children.push( + $.div({ + className: regionClassName, + style: { + position: 'absolute', + boxSizing: 'border-box', + top: startPixelTop + lineHeight + 'px', + left: 0, + right: 0, + height: endPixelTop - startPixelTop - lineHeight * 2 + 'px' + } + }) + ); + } + + if (endPixelLeft > 0) { + children.push( + $.div({ + className: regionClassName, + style: { + position: 'absolute', + boxSizing: 'border-box', + top: endPixelTop - lineHeight + 'px', + left: 0, + width: endPixelLeft + 'px', + height: lineHeight + 'px' + } + }) + ); + } + } + + return $.div({ className: 'highlight ' + className }, children); + } +} + +class OverlayComponent { + constructor(props) { + this.props = props; + this.element = document.createElement('atom-overlay'); + if (this.props.className != null) + this.element.classList.add(this.props.className); + this.element.appendChild(this.props.element); + this.element.style.position = 'fixed'; + this.element.style.zIndex = 4; + this.element.style.top = (this.props.pixelTop || 0) + 'px'; + this.element.style.left = (this.props.pixelLeft || 0) + 'px'; + this.currentContentRect = null; + + // Synchronous DOM updates in response to resize events might trigger a + // "loop limit exceeded" error. We disconnect the observer before + // potentially mutating the DOM, and then reconnect it on the next tick. + // Note: ResizeObserver calls its callback when .observe is called + this.resizeObserver = new ResizeObserver(entries => { + const { contentRect } = entries[0]; + + if ( + this.currentContentRect && + (this.currentContentRect.width !== contentRect.width || + this.currentContentRect.height !== contentRect.height) + ) { + this.resizeObserver.disconnect(); + this.props.didResize(this); + process.nextTick(() => { + this.resizeObserver.observe(this.props.element); + }); + } + + this.currentContentRect = contentRect; + }); + this.didAttach(); + this.props.overlayComponents.add(this); + } + + destroy() { + this.props.overlayComponents.delete(this); + this.didDetach(); + } + + getNextUpdatePromise() { + if (!this.nextUpdatePromise) { + this.nextUpdatePromise = new Promise(resolve => { + this.resolveNextUpdatePromise = () => { + this.nextUpdatePromise = null; + this.resolveNextUpdatePromise = null; + resolve(); + }; + }); + } + return this.nextUpdatePromise; + } + + update(newProps) { + const oldProps = this.props; + this.props = Object.assign({}, oldProps, newProps); + if (this.props.pixelTop != null) + this.element.style.top = this.props.pixelTop + 'px'; + if (this.props.pixelLeft != null) + this.element.style.left = this.props.pixelLeft + 'px'; + if (newProps.className !== oldProps.className) { + if (oldProps.className != null) + this.element.classList.remove(oldProps.className); + if (newProps.className != null) + this.element.classList.add(newProps.className); + } + + if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise(); + } + + didAttach() { + this.resizeObserver.observe(this.props.element); + } + + didDetach() { + this.resizeObserver.disconnect(); + } +} + +let rangeForMeasurement; +function clientRectForRange(textNode, startIndex, endIndex) { + if (!rangeForMeasurement) rangeForMeasurement = document.createRange(); + rangeForMeasurement.setStart(textNode, startIndex); + rangeForMeasurement.setEnd(textNode, endIndex); + return rangeForMeasurement.getBoundingClientRect(); +} + +function textDecorationsEqual(oldDecorations, newDecorations) { + if (!oldDecorations && newDecorations) return false; + if (oldDecorations && !newDecorations) return false; + if (oldDecorations && newDecorations) { + if (oldDecorations.length !== newDecorations.length) return false; + for (let j = 0; j < oldDecorations.length; j++) { + if (oldDecorations[j].column !== newDecorations[j].column) return false; + if (oldDecorations[j].className !== newDecorations[j].className) + return false; + if (!objectsEqual(oldDecorations[j].style, newDecorations[j].style)) + return false; + } + } + return true; +} + +function arraysEqual(a, b) { + if (a.length !== b.length) return false; + for (let i = 0, length = a.length; i < length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function objectsEqual(a, b) { + if (!a && b) return false; + if (a && !b) return false; + if (a && b) { + for (const key in a) { + if (a[key] !== b[key]) return false; + } + for (const key in b) { + if (a[key] !== b[key]) return false; + } + } + return true; +} + +function constrainRangeToRows(range, startRow, endRow) { + if (range.start.row < startRow || range.end.row >= endRow) { + range = range.copy(); + if (range.start.row < startRow) { + range.start.row = startRow; + range.start.column = 0; + } + if (range.end.row >= endRow) { + range.end.row = endRow; + range.end.column = 0; + } + } + return range; +} + +function debounce(fn, wait) { + let timestamp, timeout; + + function later() { + const last = Date.now() - timestamp; + if (last < wait && last >= 0) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + fn(); + } + } + + return function() { + timestamp = Date.now(); + if (!timeout) timeout = setTimeout(later, wait); + }; +} + +class NodePool { + constructor() { + this.elementsByType = {}; + this.textNodes = []; + } + + getElement(type, className, style) { + let element; + const elementsByDepth = this.elementsByType[type]; + if (elementsByDepth) { + while (elementsByDepth.length > 0) { + const elements = elementsByDepth[elementsByDepth.length - 1]; + if (elements && elements.length > 0) { + element = elements.pop(); + if (elements.length === 0) elementsByDepth.pop(); + break; + } else { + elementsByDepth.pop(); + } + } + } + + if (element) { + element.className = className || ''; + element.attributeStyleMap.forEach((value, key) => { + if (!style || style[key] == null) element.style[key] = ''; + }); + if (style) Object.assign(element.style, style); + for (const key in element.dataset) delete element.dataset[key]; + while (element.firstChild) element.firstChild.remove(); + return element; + } else { + const newElement = document.createElement(type); + if (className) newElement.className = className; + if (style) Object.assign(newElement.style, style); + return newElement; + } + } + + getTextNode(text) { + if (this.textNodes.length > 0) { + const node = this.textNodes.pop(); + node.textContent = text; + return node; + } else { + return document.createTextNode(text); + } + } + + release(node, depth = 0) { + const { nodeName } = node; + if (nodeName === '#text') { + this.textNodes.push(node); + } else { + let elementsByDepth = this.elementsByType[nodeName]; + if (!elementsByDepth) { + elementsByDepth = []; + this.elementsByType[nodeName] = elementsByDepth; + } + + let elements = elementsByDepth[depth]; + if (!elements) { + elements = []; + elementsByDepth[depth] = elements; + } + + elements.push(node); + for (let i = 0; i < node.childNodes.length; i++) { + this.release(node.childNodes[i], depth + 1); + } + } + } +} + +function roundToPhysicalPixelBoundary(virtualPixelPosition) { + const virtualPixelsPerPhysicalPixel = 1 / window.devicePixelRatio; + return ( + Math.round(virtualPixelPosition / virtualPixelsPerPhysicalPixel) * + virtualPixelsPerPhysicalPixel + ); +} + +function ceilToPhysicalPixelBoundary(virtualPixelPosition) { + const virtualPixelsPerPhysicalPixel = 1 / window.devicePixelRatio; + return ( + Math.ceil(virtualPixelPosition / virtualPixelsPerPhysicalPixel) * + virtualPixelsPerPhysicalPixel + ); +} diff --git a/src/text-editor-element.coffee b/src/text-editor-element.coffee deleted file mode 100644 index beeae6cc624..00000000000 --- a/src/text-editor-element.coffee +++ /dev/null @@ -1,349 +0,0 @@ -{Emitter} = require 'event-kit' -{View, $, callRemoveHooks} = require 'space-pen' -Path = require 'path' -{defaults} = require 'underscore-plus' -TextBuffer = require 'text-buffer' -Grim = require 'grim' -TextEditor = require './text-editor' -TextEditorComponent = require './text-editor-component' -TextEditorView = null - -ShadowStyleSheet = null - -class TextEditorElement extends HTMLElement - model: null - componentDescriptor: null - component: null - attached: false - lineOverdrawMargin: null - focusOnAttach: false - - createdCallback: -> - @emitter = new Emitter - @initializeContent() - @createSpacePenShim() if Grim.includeDeprecatedAPIs - @addEventListener 'focus', @focused.bind(this) - @addEventListener 'blur', @blurred.bind(this) - - initializeContent: (attributes) -> - @classList.add('editor') - @setAttribute('tabindex', -1) - - if atom.config.get('editor.useShadowDOM') - @useShadowDOM = true - - unless ShadowStyleSheet? - ShadowStyleSheet = document.createElement('style') - ShadowStyleSheet.textContent = atom.themes.loadLessStylesheet(require.resolve('../static/text-editor-shadow.less')) - - @createShadowRoot() - - @shadowRoot.appendChild(ShadowStyleSheet.cloneNode(true)) - @stylesElement = document.createElement('atom-styles') - @stylesElement.setAttribute('context', 'atom-text-editor') - @stylesElement.initialize() - - @rootElement = document.createElement('div') - @rootElement.classList.add('editor--private') - - @shadowRoot.appendChild(@stylesElement) - @shadowRoot.appendChild(@rootElement) - else - @useShadowDOM = false - - @classList.add('editor', 'editor-colors') - @stylesElement = document.head.querySelector('atom-styles') - @rootElement = this - - createSpacePenShim: -> - TextEditorView ?= require './text-editor-view' - @__spacePenView = new TextEditorView(this) - - attachedCallback: -> - @buildModel() unless @getModel()? - @mountComponent() unless @component? - @component.checkForVisibilityChange() - if this is document.activeElement - @focused() - @emitter.emit("did-attach") - - detachedCallback: -> - @unmountComponent() - @emitter.emit("did-detach") - - initialize: (model) -> - @setModel(model) - this - - setModel: (model) -> - throw new Error("Model already assigned on TextEditorElement") if @model? - return if model.isDestroyed() - - @model = model - @mountComponent() - @addGrammarScopeAttribute() - @addMiniAttributeIfNeeded() - @addEncodingAttribute() - @model.onDidChangeGrammar => @addGrammarScopeAttribute() - @model.onDidChangeEncoding => @addEncodingAttribute() - @model.onDidDestroy => @unmountComponent() - @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs - @model - - getModel: -> - @model ? @buildModel() - - buildModel: -> - @setModel(new TextEditor( - buffer: new TextBuffer(@textContent) - softWrapped: false - tabLength: 2 - softTabs: true - mini: @hasAttribute('mini') - lineNumberGutterVisible: not @hasAttribute('gutter-hidden') - placeholderText: @getAttribute('placeholder-text') - )) - - mountComponent: -> - @component = new TextEditorComponent( - hostElement: this - rootElement: @rootElement - stylesElement: @stylesElement - editor: @model - lineOverdrawMargin: @lineOverdrawMargin - useShadowDOM: @useShadowDOM - ) - @rootElement.appendChild(@component.getDomNode()) - - if @useShadowDOM - @shadowRoot.addEventListener('blur', @shadowRootBlurred.bind(this), true) - else - inputNode = @component.hiddenInputComponent.getDomNode() - inputNode.addEventListener 'focus', @focused.bind(this) - inputNode.addEventListener 'blur', => @dispatchEvent(new FocusEvent('blur', bubbles: false)) - - unmountComponent: -> - callRemoveHooks(this) - if @component? - @component.destroy() - @component.getDomNode().remove() - @component = null - - focused: -> - @component?.focused() - - blurred: (event) -> - unless @useShadowDOM - if event.relatedTarget is @component.hiddenInputComponent.getDomNode() - event.stopImmediatePropagation() - return - - @component?.blurred() - - # Work around what seems to be a bug in Chromium. Focus can be stolen from the - # hidden input when clicking on the gutter and transferred to the - # already-focused host element. The host element never gets a 'focus' event - # however, which leaves us in a limbo state where the text editor element is - # focused but the hidden input isn't focused. This always refocuses the hidden - # input if a blur event occurs in the shadow DOM that is transferring focus - # back to the host element. - shadowRootBlurred: (event) -> - @component.focused() if event.relatedTarget is this - - addGrammarScopeAttribute: -> - grammarScope = @model.getGrammar()?.scopeName?.replace(/\./g, ' ') - @dataset.grammar = grammarScope - - addMiniAttributeIfNeeded: -> - @setAttributeNode(document.createAttribute("mini")) if @model.isMini() - - addEncodingAttribute: -> - @dataset.encoding = @model.getEncoding() - - hasFocus: -> - this is document.activeElement or @contains(document.activeElement) - - setUpdatedSynchronously: (@updatedSynchronously) -> @updatedSynchronously - - isUpdatedSynchronously: -> @updatedSynchronously - - # Extended: get the width of a character of text displayed in this element. - # - # Returns a {Number} of pixels. - getDefaultCharacterWidth: -> - @getModel().getDefaultCharWidth() - - # Extended: Converts a buffer position to a pixel position. - # - # * `bufferPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # - # Returns an {Object} with two values: `top` and `left`, representing the pixel position. - pixelPositionForBufferPosition: (bufferPosition) -> - @getModel().pixelPositionForBufferPosition(bufferPosition, true) - - # Extended: Converts a screen position to a pixel position. - # - # * `screenPosition` An object that represents a screen position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # - # Returns an {Object} with two values: `top` and `left`, representing the pixel positions. - pixelPositionForScreenPosition: (screenPosition) -> - @getModel().pixelPositionForScreenPosition(screenPosition, true) - - # Extended: Retrieves the number of the row that is visible and currently at the - # top of the editor. - # - # Returns a {Number}. - getFirstVisibleScreenRow: -> - @getModel().getFirstVisibleScreenRow(true) - - # Extended: Retrieves the number of the row that is visible and currently at the - # bottom of the editor. - # - # Returns a {Number}. - getLastVisibleScreenRow: -> - @getModel().getLastVisibleScreenRow(true) - - # Extended: call the given `callback` when the editor is attached to the DOM. - # - # * `callback` {Function} - onDidAttach: (callback) -> - @emitter.on("did-attach", callback) - - # Extended: call the given `callback` when the editor is detached from the DOM. - # - # * `callback` {Function} - onDidDetach: (callback) -> - @emitter.on("did-detach", callback) - -stopEventPropagation = (commandListeners) -> - newCommandListeners = {} - for commandName, commandListener of commandListeners - do (commandListener) -> - newCommandListeners[commandName] = (event) -> - event.stopPropagation() - commandListener.call(@getModel(), event) - newCommandListeners - -stopEventPropagationAndGroupUndo = (commandListeners) -> - newCommandListeners = {} - for commandName, commandListener of commandListeners - do (commandListener) -> - newCommandListeners[commandName] = (event) -> - event.stopPropagation() - model = @getModel() - model.transact atom.config.get('editor.undoGroupingInterval'), -> - commandListener.call(model, event) - newCommandListeners - -atom.commands.add 'atom-text-editor', stopEventPropagation( - 'core:undo': -> @undo() - 'core:redo': -> @redo() - 'core:move-left': -> @moveLeft() - 'core:move-right': -> @moveRight() - 'core:select-left': -> @selectLeft() - 'core:select-right': -> @selectRight() - 'core:select-up': -> @selectUp() - 'core:select-down': -> @selectDown() - 'core:select-all': -> @selectAll() - 'editor:select-word': -> @selectWordsContainingCursors() - 'editor:consolidate-selections': (event) -> event.abortKeyBinding() unless @consolidateSelections() - 'editor:move-to-beginning-of-next-paragraph': -> @moveToBeginningOfNextParagraph() - 'editor:move-to-beginning-of-previous-paragraph': -> @moveToBeginningOfPreviousParagraph() - 'editor:move-to-beginning-of-screen-line': -> @moveToBeginningOfScreenLine() - 'editor:move-to-beginning-of-line': -> @moveToBeginningOfLine() - 'editor:move-to-end-of-screen-line': -> @moveToEndOfScreenLine() - 'editor:move-to-end-of-line': -> @moveToEndOfLine() - 'editor:move-to-first-character-of-line': -> @moveToFirstCharacterOfLine() - 'editor:move-to-beginning-of-word': -> @moveToBeginningOfWord() - 'editor:move-to-end-of-word': -> @moveToEndOfWord() - 'editor:move-to-beginning-of-next-word': -> @moveToBeginningOfNextWord() - 'editor:move-to-previous-word-boundary': -> @moveToPreviousWordBoundary() - 'editor:move-to-next-word-boundary': -> @moveToNextWordBoundary() - 'editor:select-to-beginning-of-next-paragraph': -> @selectToBeginningOfNextParagraph() - 'editor:select-to-beginning-of-previous-paragraph': -> @selectToBeginningOfPreviousParagraph() - 'editor:select-to-end-of-line': -> @selectToEndOfLine() - 'editor:select-to-beginning-of-line': -> @selectToBeginningOfLine() - 'editor:select-to-end-of-word': -> @selectToEndOfWord() - 'editor:select-to-beginning-of-word': -> @selectToBeginningOfWord() - 'editor:select-to-beginning-of-next-word': -> @selectToBeginningOfNextWord() - 'editor:select-to-next-word-boundary': -> @selectToNextWordBoundary() - 'editor:select-to-previous-word-boundary': -> @selectToPreviousWordBoundary() - 'editor:select-to-first-character-of-line': -> @selectToFirstCharacterOfLine() - 'editor:select-line': -> @selectLinesContainingCursors() -) - -atom.commands.add 'atom-text-editor', stopEventPropagationAndGroupUndo( - 'core:backspace': -> @backspace() - 'core:delete': -> @delete() - 'core:cut': -> @cutSelectedText() - 'core:copy': -> @copySelectedText() - 'core:paste': -> @pasteText() - 'editor:delete-to-previous-word-boundary': -> @deleteToPreviousWordBoundary() - 'editor:delete-to-next-word-boundary': -> @deleteToNextWordBoundary() - 'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord() - 'editor:delete-to-beginning-of-line': -> @deleteToBeginningOfLine() - 'editor:delete-to-end-of-line': -> @deleteToEndOfLine() - 'editor:delete-to-end-of-word': -> @deleteToEndOfWord() - 'editor:delete-line': -> @deleteLine() - 'editor:cut-to-end-of-line': -> @cutToEndOfLine() - 'editor:transpose': -> @transpose() - 'editor:upper-case': -> @upperCase() - 'editor:lower-case': -> @lowerCase() -) - -atom.commands.add 'atom-text-editor:not([mini])', stopEventPropagation( - 'core:move-up': -> @moveUp() - 'core:move-down': -> @moveDown() - 'core:move-to-top': -> @moveToTop() - 'core:move-to-bottom': -> @moveToBottom() - 'core:page-up': -> @pageUp() - 'core:page-down': -> @pageDown() - 'core:select-to-top': -> @selectToTop() - 'core:select-to-bottom': -> @selectToBottom() - 'core:select-page-up': -> @selectPageUp() - 'core:select-page-down': -> @selectPageDown() - 'editor:add-selection-below': -> @addSelectionBelow() - 'editor:add-selection-above': -> @addSelectionAbove() - 'editor:split-selections-into-lines': -> @splitSelectionsIntoLines() - 'editor:toggle-soft-tabs': -> @toggleSoftTabs() - '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-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:log-cursor-scope': -> @logCursorScope() - 'editor:copy-path': -> @copyPathToClipboard() - 'editor:toggle-indent-guide': -> atom.config.set('editor.showIndentGuide', not atom.config.get('editor.showIndentGuide')) - 'editor:toggle-line-numbers': -> atom.config.set('editor.showLineNumbers', not atom.config.get('editor.showLineNumbers')) - 'editor:scroll-to-cursor': -> @scrollToCursorPosition() -) - -atom.commands.add 'atom-text-editor:not([mini])', stopEventPropagationAndGroupUndo( - 'editor:indent': -> @indent() - 'editor:auto-indent': -> @autoIndentSelectedRows() - 'editor:indent-selected-rows': -> @indentSelectedRows() - 'editor:outdent-selected-rows': -> @outdentSelectedRows() - 'editor:newline': -> @insertNewline() - 'editor:newline-below': -> @insertNewlineBelow() - 'editor:newline-above': -> @insertNewlineAbove() - 'editor:toggle-line-comments': -> @toggleLineCommentsInSelection() - 'editor:checkout-head-revision': -> @checkoutHeadRevision() - 'editor:move-line-up': -> @moveLineUp() - 'editor:move-line-down': -> @moveLineDown() - 'editor:duplicate-lines': -> @duplicateLines() - 'editor:join-lines': -> @joinLines() -) - -module.exports = TextEditorElement = document.registerElement 'atom-text-editor', prototype: TextEditorElement.prototype diff --git a/src/text-editor-element.js b/src/text-editor-element.js new file mode 100644 index 00000000000..0ce67f7e6cc --- /dev/null +++ b/src/text-editor-element.js @@ -0,0 +1,368 @@ +const { Emitter, Range } = require('atom'); +const Grim = require('grim'); +const TextEditorComponent = require('./text-editor-component'); +const dedent = require('dedent'); + +class TextEditorElement extends HTMLElement { + initialize(component) { + this.component = component; + return this; + } + + get shadowRoot() { + Grim.deprecate(dedent` + The contents of \`atom-text-editor\` elements are no longer encapsulated + within a shadow DOM boundary. Please, stop using \`shadowRoot\` and access + the editor contents directly instead. + `); + + return this; + } + + get rootElement() { + Grim.deprecate(dedent` + The contents of \`atom-text-editor\` elements are no longer encapsulated + within a shadow DOM boundary. Please, stop using \`rootElement\` and access + the editor contents directly instead. + `); + + return this; + } + + constructor() { + super(); + this.emitter = new Emitter(); + this.initialText = this.textContent; + if (this.tabIndex == null) this.tabIndex = -1; + this.addEventListener('focus', event => + this.getComponent().didFocus(event) + ); + this.addEventListener('blur', event => this.getComponent().didBlur(event)); + } + + connectedCallback() { + this.getComponent().didAttach(); + this.emitter.emit('did-attach'); + } + + disconnectedCallback() { + this.emitter.emit('did-detach'); + this.getComponent().didDetach(); + } + + static get observedAttributes() { + return ['mini', 'placeholder-text', 'gutter-hidden', 'readonly']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (this.component) { + switch (name) { + case 'mini': + this.getModel().update({ mini: newValue != null }); + break; + case 'placeholder-text': + this.getModel().update({ placeholderText: newValue }); + break; + case 'gutter-hidden': + this.getModel().update({ lineNumberGutterVisible: newValue == null }); + break; + case 'readonly': + this.getModel().update({ readOnly: newValue != null }); + break; + } + } + } + + // Extended: Get a promise that resolves the next time the element's DOM + // is updated in any way. + // + // This can be useful when you've made a change to the model and need to + // be sure this change has been flushed to the DOM. + // + // Returns a {Promise}. + getNextUpdatePromise() { + return this.getComponent().getNextUpdatePromise(); + } + + getModel() { + return this.getComponent().props.model; + } + + setModel(model) { + this.getComponent().update({ model }); + this.updateModelFromAttributes(); + } + + updateModelFromAttributes() { + const props = { mini: this.hasAttribute('mini') }; + if (this.hasAttribute('placeholder-text')) + props.placeholderText = this.getAttribute('placeholder-text'); + if (this.hasAttribute('gutter-hidden')) + props.lineNumberGutterVisible = false; + + this.getModel().update(props); + if (this.initialText) this.getModel().setText(this.initialText); + } + + onDidAttach(callback) { + return this.emitter.on('did-attach', callback); + } + + onDidDetach(callback) { + return this.emitter.on('did-detach', callback); + } + + measureDimensions() { + this.getComponent().measureDimensions(); + } + + setWidth(width) { + this.style.width = + this.getComponent().getGutterContainerWidth() + width + 'px'; + } + + getWidth() { + return this.getComponent().getScrollContainerWidth(); + } + + setHeight(height) { + this.style.height = height + 'px'; + } + + getHeight() { + return this.getComponent().getScrollContainerHeight(); + } + + onDidChangeScrollLeft(callback) { + return this.emitter.on('did-change-scroll-left', callback); + } + + onDidChangeScrollTop(callback) { + return this.emitter.on('did-change-scroll-top', callback); + } + + // Deprecated: get the width of an `x` character displayed in this element. + // + // Returns a {Number} of pixels. + getDefaultCharacterWidth() { + return this.getComponent().getBaseCharacterWidth(); + } + + // Extended: get the width of an `x` character displayed in this element. + // + // Returns a {Number} of pixels. + getBaseCharacterWidth() { + return this.getComponent().getBaseCharacterWidth(); + } + + getMaxScrollTop() { + return this.getComponent().getMaxScrollTop(); + } + + getScrollHeight() { + return this.getComponent().getScrollHeight(); + } + + getScrollWidth() { + return this.getComponent().getScrollWidth(); + } + + getVerticalScrollbarWidth() { + return this.getComponent().getVerticalScrollbarWidth(); + } + + getHorizontalScrollbarHeight() { + return this.getComponent().getHorizontalScrollbarHeight(); + } + + getScrollTop() { + return this.getComponent().getScrollTop(); + } + + setScrollTop(scrollTop) { + const component = this.getComponent(); + component.setScrollTop(scrollTop); + component.scheduleUpdate(); + } + + getScrollBottom() { + return this.getComponent().getScrollBottom(); + } + + setScrollBottom(scrollBottom) { + return this.getComponent().setScrollBottom(scrollBottom); + } + + getScrollLeft() { + return this.getComponent().getScrollLeft(); + } + + setScrollLeft(scrollLeft) { + const component = this.getComponent(); + component.setScrollLeft(scrollLeft); + component.scheduleUpdate(); + } + + getScrollRight() { + return this.getComponent().getScrollRight(); + } + + setScrollRight(scrollRight) { + return this.getComponent().setScrollRight(scrollRight); + } + + // Essential: Scrolls the editor to the top. + scrollToTop() { + this.setScrollTop(0); + } + + // Essential: Scrolls the editor to the bottom. + scrollToBottom() { + this.setScrollTop(Infinity); + } + + hasFocus() { + return this.getComponent().focused; + } + + // Extended: Converts a buffer position to a pixel position. + // + // * `bufferPosition` A {Point}-like object that represents a buffer position. + // + // Be aware that calling this method with a column that does not translate + // to column 0 on screen could cause a synchronous DOM update in order to + // measure the requested horizontal pixel position if it isn't already + // cached. + // + // Returns an {Object} with two values: `top` and `left`, representing the + // pixel position. + pixelPositionForBufferPosition(bufferPosition) { + const screenPosition = this.getModel().screenPositionForBufferPosition( + bufferPosition + ); + return this.getComponent().pixelPositionForScreenPosition(screenPosition); + } + + // Extended: Converts a screen position to a pixel position. + // + // * `screenPosition` A {Point}-like object that represents a buffer position. + // + // Be aware that calling this method with a non-zero column value could + // cause a synchronous DOM update in order to measure the requested + // horizontal pixel position if it isn't already cached. + // + // Returns an {Object} with two values: `top` and `left`, representing the + // pixel position. + pixelPositionForScreenPosition(screenPosition) { + screenPosition = this.getModel().clipScreenPosition(screenPosition); + return this.getComponent().pixelPositionForScreenPosition(screenPosition); + } + + screenPositionForPixelPosition(pixelPosition) { + return this.getComponent().screenPositionForPixelPosition(pixelPosition); + } + + pixelRectForScreenRange(range) { + range = Range.fromObject(range); + + const start = this.pixelPositionForScreenPosition(range.start); + const end = this.pixelPositionForScreenPosition(range.end); + const lineHeight = this.getComponent().getLineHeight(); + + return { + top: start.top, + left: start.left, + height: end.top + lineHeight - start.top, + width: end.left - start.left + }; + } + + pixelRangeForScreenRange(range) { + range = Range.fromObject(range); + return { + start: this.pixelPositionForScreenPosition(range.start), + end: this.pixelPositionForScreenPosition(range.end) + }; + } + + getComponent() { + if (!this.component) { + this.component = new TextEditorComponent({ + element: this, + mini: this.hasAttribute('mini'), + updatedSynchronously: this.updatedSynchronously, + readOnly: this.hasAttribute('readonly') + }); + this.updateModelFromAttributes(); + } + + return this.component; + } + + setUpdatedSynchronously(updatedSynchronously) { + this.updatedSynchronously = updatedSynchronously; + if (this.component) + this.component.updatedSynchronously = updatedSynchronously; + return updatedSynchronously; + } + + isUpdatedSynchronously() { + return this.component + ? this.component.updatedSynchronously + : this.updatedSynchronously; + } + + // Experimental: Invalidate the passed block {Decoration}'s dimensions, + // forcing them to be recalculated and the surrounding content to be adjusted + // on the next animation frame. + // + // * {blockDecoration} A {Decoration} representing the block decoration you + // want to update the dimensions of. + invalidateBlockDecorationDimensions() { + this.getComponent().invalidateBlockDecorationDimensions(...arguments); + } + + setFirstVisibleScreenRow(row) { + this.getModel().setFirstVisibleScreenRow(row); + } + + getFirstVisibleScreenRow() { + return this.getModel().getFirstVisibleScreenRow(); + } + + getLastVisibleScreenRow() { + return this.getModel().getLastVisibleScreenRow(); + } + + getVisibleRowRange() { + return this.getModel().getVisibleRowRange(); + } + + intersectsVisibleRowRange(startRow, endRow) { + return !( + endRow <= this.getFirstVisibleScreenRow() || + this.getLastVisibleScreenRow() <= startRow + ); + } + + selectionIntersectsVisibleRowRange(selection) { + const { start, end } = selection.getScreenRange(); + return this.intersectsVisibleRowRange(start.row, end.row + 1); + } + + setFirstVisibleScreenColumn(column) { + return this.getModel().setFirstVisibleScreenColumn(column); + } + + getFirstVisibleScreenColumn() { + return this.getModel().getFirstVisibleScreenColumn(); + } + + static createTextEditorElement() { + return document.createElement('atom-text-editor'); + } +} + +window.customElements.define('atom-text-editor', TextEditorElement); + +module.exports = TextEditorElement; diff --git a/src/text-editor-presenter.coffee b/src/text-editor-presenter.coffee deleted file mode 100644 index 3aea57f294a..00000000000 --- a/src/text-editor-presenter.coffee +++ /dev/null @@ -1,1373 +0,0 @@ -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = require 'text-buffer' -_ = require 'underscore-plus' -Decoration = require './decoration' - -module.exports = -class TextEditorPresenter - toggleCursorBlinkHandle: null - startBlinkingCursorsAfterDelay: null - stoppedScrollingTimeoutId: null - mouseWheelScreenRow: null - scopedCharacterWidthsChangeCount: 0 - overlayDimensions: {} - - constructor: (params) -> - {@model, @autoHeight, @explicitHeight, @contentFrameWidth, @scrollTop, @scrollLeft, @boundingClientRect, @windowWidth, @windowHeight, @gutterWidth} = params - {horizontalScrollbarHeight, verticalScrollbarWidth} = params - {@lineHeight, @baseCharacterWidth, @lineOverdrawMargin, @backgroundColor, @gutterBackgroundColor} = params - {@cursorBlinkPeriod, @cursorBlinkResumeDelay, @stoppedScrollingDelay, @focused} = params - @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight - @measuredVerticalScrollbarWidth = verticalScrollbarWidth - @gutterWidth ?= 0 - - @disposables = new CompositeDisposable - @emitter = new Emitter - @characterWidthsByScope = {} - @transferMeasurementsToModel() - @observeModel() - @observeConfig() - @buildState() - @startBlinkingCursors() if @focused - @updating = false - - destroy: -> - @disposables.dispose() - - # Calls your `callback` when some changes in the model occurred and the current state has been updated. - onDidUpdateState: (callback) -> - @emitter.on 'did-update-state', callback - - emitDidUpdateState: -> - @emitter.emit "did-update-state" if @isBatching() - - transferMeasurementsToModel: -> - @model.setHeight(@explicitHeight) if @explicitHeight? - @model.setWidth(@contentFrameWidth) if @contentFrameWidth? - @model.setLineHeightInPixels(@lineHeight) if @lineHeight? - @model.setDefaultCharWidth(@baseCharacterWidth) if @baseCharacterWidth? - @model.setScrollTop(@scrollTop) if @scrollTop? - @model.setScrollLeft(@scrollLeft) if @scrollLeft? - @model.setVerticalScrollbarWidth(@measuredVerticalScrollbarWidth) if @measuredVerticalScrollbarWidth? - @model.setHorizontalScrollbarHeight(@measuredHorizontalScrollbarHeight) if @measuredHorizontalScrollbarHeight? - - # Private: Determines whether {TextEditorPresenter} is currently batching changes. - # Returns a {Boolean}, `true` if is collecting changes, `false` if is applying them. - isBatching: -> - @updating is false - - # Public: Gets this presenter's state, updating it just in time before returning from this function. - # Returns a state {Object}, useful for rendering to screen. - getState: -> - @updating = true - - @updateContentDimensions() - @updateScrollbarDimensions() - @updateStartRow() - @updateEndRow() - - @updateFocusedState() if @shouldUpdateFocusedState - @updateHeightState() if @shouldUpdateHeightState - @updateVerticalScrollState() if @shouldUpdateVerticalScrollState - @updateHorizontalScrollState() if @shouldUpdateHorizontalScrollState - @updateScrollbarsState() if @shouldUpdateScrollbarsState - @updateHiddenInputState() if @shouldUpdateHiddenInputState - @updateContentState() if @shouldUpdateContentState - @updateDecorations() if @shouldUpdateDecorations - @updateLinesState() if @shouldUpdateLinesState - @updateCursorsState() if @shouldUpdateCursorsState - @updateOverlaysState() if @shouldUpdateOverlaysState - @updateLineNumberGutterState() if @shouldUpdateLineNumberGutterState - @updateLineNumbersState() if @shouldUpdateLineNumbersState - @updateGutterOrderState() if @shouldUpdateGutterOrderState - @updateCustomGutterDecorationState() if @shouldUpdateCustomGutterDecorationState - @updating = false - - @resetTrackedUpdates() - - @state - - resetTrackedUpdates: -> - @shouldUpdateFocusedState = false - @shouldUpdateHeightState = false - @shouldUpdateVerticalScrollState = false - @shouldUpdateHorizontalScrollState = false - @shouldUpdateScrollbarsState = false - @shouldUpdateHiddenInputState = false - @shouldUpdateContentState = false - @shouldUpdateDecorations = false - @shouldUpdateLinesState = false - @shouldUpdateCursorsState = false - @shouldUpdateOverlaysState = false - @shouldUpdateLineNumberGutterState = false - @shouldUpdateLineNumbersState = false - @shouldUpdateGutterOrderState = false - @shouldUpdateCustomGutterDecorationState = false - - observeModel: -> - @disposables.add @model.onDidChange => - @updateContentDimensions() - @updateEndRow() - @shouldUpdateHeightState = true - @shouldUpdateVerticalScrollState = true - @shouldUpdateHorizontalScrollState = true - @shouldUpdateScrollbarsState = true - @shouldUpdateContentState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateLineNumberGutterState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateGutterOrderState = true - @shouldUpdateCustomGutterDecorationState = true - - @emitDidUpdateState() - @disposables.add @model.onDidChangeGrammar(@didChangeGrammar.bind(this)) - @disposables.add @model.onDidChangePlaceholderText => - @shouldUpdateContentState = true - - @emitDidUpdateState() - @disposables.add @model.onDidChangeMini => - @shouldUpdateScrollbarsState = true - @shouldUpdateContentState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateLineNumberGutterState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateGutterOrderState = true - @shouldUpdateCustomGutterDecorationState = true - @updateScrollbarDimensions() - @updateCommonGutterState() - - @emitDidUpdateState() - @disposables.add @model.onDidChangeLineNumberGutterVisible => - @shouldUpdateLineNumberGutterState = true - @shouldUpdateGutterOrderState = true - @updateCommonGutterState() - - @emitDidUpdateState() - @disposables.add @model.onDidAddDecoration(@didAddDecoration.bind(this)) - @disposables.add @model.onDidAddCursor(@didAddCursor.bind(this)) - @disposables.add @model.onDidChangeScrollTop(@setScrollTop.bind(this)) - @disposables.add @model.onDidChangeScrollLeft(@setScrollLeft.bind(this)) - @observeDecoration(decoration) for decoration in @model.getDecorations() - @observeCursor(cursor) for cursor in @model.getCursors() - @disposables.add @model.onDidAddGutter(@didAddGutter.bind(this)) - return - - observeConfig: -> - configParams = {scope: @model.getRootScopeDescriptor()} - - @scrollPastEnd = atom.config.get('editor.scrollPastEnd', configParams) - @showLineNumbers = atom.config.get('editor.showLineNumbers', configParams) - @showIndentGuide = atom.config.get('editor.showIndentGuide', configParams) - - if @configDisposables? - @configDisposables?.dispose() - @disposables.remove(@configDisposables) - - @configDisposables = new CompositeDisposable - @disposables.add(@configDisposables) - - @configDisposables.add atom.config.onDidChange 'editor.showIndentGuide', configParams, ({newValue}) => - @showIndentGuide = newValue - @shouldUpdateContentState = true - - @emitDidUpdateState() - @configDisposables.add atom.config.onDidChange 'editor.scrollPastEnd', configParams, ({newValue}) => - @scrollPastEnd = newValue - @shouldUpdateVerticalScrollState = true - @shouldUpdateScrollbarsState = true - @updateScrollHeight() - - @emitDidUpdateState() - @configDisposables.add atom.config.onDidChange 'editor.showLineNumbers', configParams, ({newValue}) => - @showLineNumbers = newValue - @shouldUpdateLineNumberGutterState = true - @shouldUpdateGutterOrderState = true - @updateCommonGutterState() - - @emitDidUpdateState() - - didChangeGrammar: -> - @observeConfig() - @shouldUpdateContentState = true - @shouldUpdateLineNumberGutterState = true - @shouldUpdateGutterOrderState = true - @updateCommonGutterState() - - @emitDidUpdateState() - - buildState: -> - @state = - horizontalScrollbar: {} - verticalScrollbar: {} - hiddenInput: {} - content: - scrollingVertically: false - cursorsVisible: false - lines: {} - highlights: {} - overlays: {} - gutters: [] - # Shared state that is copied into ``@state.gutters`. - @sharedGutterStyles = {} - @customGutterDecorations = {} - @lineNumberGutter = - lineNumbers: {} - - @updateState() - - updateState: -> - @updateContentDimensions() - @updateScrollbarDimensions() - @updateStartRow() - @updateEndRow() - - @updateFocusedState() - @updateHeightState() - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateScrollbarsState() - @updateHiddenInputState() - @updateContentState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() - @updateOverlaysState() - @updateLineNumberGutterState() - @updateLineNumbersState() - @updateCommonGutterState() - @updateGutterOrderState() - @updateCustomGutterDecorationState() - - @resetTrackedUpdates() - - updateFocusedState: -> - @state.focused = @focused - - updateHeightState: -> - if @autoHeight - @state.height = @contentHeight - else - @state.height = null - - updateVerticalScrollState: -> - @state.content.scrollHeight = @scrollHeight - @sharedGutterStyles.scrollHeight = @scrollHeight - @state.verticalScrollbar.scrollHeight = @scrollHeight - - @state.content.scrollTop = @scrollTop - @sharedGutterStyles.scrollTop = @scrollTop - @state.verticalScrollbar.scrollTop = @scrollTop - - updateHorizontalScrollState: -> - @state.content.scrollWidth = @scrollWidth - @state.horizontalScrollbar.scrollWidth = @scrollWidth - - @state.content.scrollLeft = @scrollLeft - @state.horizontalScrollbar.scrollLeft = @scrollLeft - - updateScrollbarsState: -> - @state.horizontalScrollbar.visible = @horizontalScrollbarHeight > 0 - @state.horizontalScrollbar.height = @measuredHorizontalScrollbarHeight - @state.horizontalScrollbar.right = @verticalScrollbarWidth - - @state.verticalScrollbar.visible = @verticalScrollbarWidth > 0 - @state.verticalScrollbar.width = @measuredVerticalScrollbarWidth - @state.verticalScrollbar.bottom = @horizontalScrollbarHeight - - updateHiddenInputState: -> - return unless lastCursor = @model.getLastCursor() - - {top, left, height, width} = @pixelRectForScreenRange(lastCursor.getScreenRange()) - - if @focused - top -= @scrollTop - left -= @scrollLeft - @state.hiddenInput.top = Math.max(Math.min(top, @clientHeight - height), 0) - @state.hiddenInput.left = Math.max(Math.min(left, @clientWidth - width), 0) - else - @state.hiddenInput.top = 0 - @state.hiddenInput.left = 0 - - @state.hiddenInput.height = height - @state.hiddenInput.width = Math.max(width, 2) - - updateContentState: -> - @state.content.scrollWidth = @scrollWidth - @state.content.scrollLeft = @scrollLeft - @state.content.indentGuidesVisible = not @model.isMini() and @showIndentGuide - @state.content.backgroundColor = if @model.isMini() then null else @backgroundColor - @state.content.placeholderText = if @model.isEmpty() then @model.getPlaceholderText() else null - - updateLinesState: -> - return unless @startRow? and @endRow? and @lineHeight? - - visibleLineIds = {} - row = @startRow - while row < @endRow - line = @model.tokenizedLineForScreenRow(row) - unless line? - throw new Error("No line exists for row #{row}. Last screen row: #{@model.getLastScreenRow()}") - - visibleLineIds[line.id] = true - if @state.content.lines.hasOwnProperty(line.id) - @updateLineState(row, line) - else - @buildLineState(row, line) - row++ - - if @mouseWheelScreenRow? - if preservedLine = @model.tokenizedLineForScreenRow(@mouseWheelScreenRow) - visibleLineIds[preservedLine.id] = true - - for id, line of @state.content.lines - unless visibleLineIds.hasOwnProperty(id) - delete @state.content.lines[id] - return - - updateLineState: (row, line) -> - lineState = @state.content.lines[line.id] - lineState.screenRow = row - lineState.top = row * @lineHeight - lineState.decorationClasses = @lineDecorationClassesForRow(row) - - buildLineState: (row, line) -> - @state.content.lines[line.id] = - screenRow: row - text: line.text - openScopes: line.openScopes - tags: line.tags - specialTokens: line.specialTokens - firstNonWhitespaceIndex: line.firstNonWhitespaceIndex - firstTrailingWhitespaceIndex: line.firstTrailingWhitespaceIndex - invisibles: line.invisibles - endOfLineInvisibles: line.endOfLineInvisibles - isOnlyWhitespace: line.isOnlyWhitespace() - indentLevel: line.indentLevel - tabLength: line.tabLength - fold: line.fold - top: row * @lineHeight - decorationClasses: @lineDecorationClassesForRow(row) - - updateCursorsState: -> - @state.content.cursors = {} - @updateCursorState(cursor) for cursor in @model.cursors # using property directly to avoid allocation - return - - updateCursorState: (cursor, destroyOnly = false) -> - delete @state.content.cursors[cursor.id] - - return if destroyOnly - return unless @startRow? and @endRow? and @hasPixelRectRequirements() and @baseCharacterWidth? - return unless cursor.isVisible() and @startRow <= cursor.getScreenRow() < @endRow - - pixelRect = @pixelRectForScreenRange(cursor.getScreenRange()) - pixelRect.width = @baseCharacterWidth if pixelRect.width is 0 - @state.content.cursors[cursor.id] = pixelRect - - @emitDidUpdateState() - - updateOverlaysState: -> - return unless @hasOverlayPositionRequirements() - - visibleDecorationIds = {} - - for decoration in @model.getOverlayDecorations() - continue unless decoration.getMarker().isValid() - - {item, position} = decoration.getProperties() - if position is 'tail' - screenPosition = decoration.getMarker().getTailScreenPosition() - else - screenPosition = decoration.getMarker().getHeadScreenPosition() - - pixelPosition = @pixelPositionForScreenPosition(screenPosition) - - {scrollTop, scrollLeft} = @state.content - - top = pixelPosition.top + @lineHeight - scrollTop - left = pixelPosition.left + @gutterWidth - scrollLeft - - if overlayDimensions = @overlayDimensions[decoration.id] - {itemWidth, itemHeight, contentMargin} = overlayDimensions - - rightDiff = left + @boundingClientRect.left + itemWidth + contentMargin - @windowWidth - left -= rightDiff if rightDiff > 0 - - leftDiff = left + @boundingClientRect.left + contentMargin - left -= leftDiff if leftDiff < 0 - - if top + @boundingClientRect.top + itemHeight > @windowHeight and top - (itemHeight + @lineHeight) >= 0 - top -= itemHeight + @lineHeight - - pixelPosition.top = top - pixelPosition.left = left - - @state.content.overlays[decoration.id] ?= {item} - @state.content.overlays[decoration.id].pixelPosition = pixelPosition - visibleDecorationIds[decoration.id] = true - - for id of @state.content.overlays - delete @state.content.overlays[id] unless visibleDecorationIds[id] - - for id of @overlayDimensions - delete @overlayDimensions[id] unless visibleDecorationIds[id] - - return - - updateLineNumberGutterState: -> - @lineNumberGutter.maxLineNumberDigits = @model.getLineCount().toString().length - - updateCommonGutterState: -> - @sharedGutterStyles.backgroundColor = if @gutterBackgroundColor isnt "rgba(0, 0, 0, 0)" - @gutterBackgroundColor - else - @backgroundColor - - didAddGutter: (gutter) -> - gutterDisposables = new CompositeDisposable - gutterDisposables.add gutter.onDidChangeVisible => - @shouldUpdateGutterOrderState = true - @shouldUpdateCustomGutterDecorationState = true - - @emitDidUpdateState() - gutterDisposables.add gutter.onDidDestroy => - @disposables.remove(gutterDisposables) - gutterDisposables.dispose() - @shouldUpdateGutterOrderState = true - - @emitDidUpdateState() - # It is not necessary to @updateCustomGutterDecorationState here. - # The destroyed gutter will be removed from the list of gutters in @state, - # and thus will be removed from the DOM. - @disposables.add(gutterDisposables) - @shouldUpdateGutterOrderState = true - @shouldUpdateCustomGutterDecorationState = true - - @emitDidUpdateState() - - updateGutterOrderState: -> - @state.gutters = [] - if @model.isMini() - return - for gutter in @model.getGutters() - isVisible = @gutterIsVisible(gutter) - if gutter.name is 'line-number' - content = @lineNumberGutter - else - @customGutterDecorations[gutter.name] ?= {} - content = @customGutterDecorations[gutter.name] - @state.gutters.push({ - gutter, - visible: isVisible, - styles: @sharedGutterStyles, - content, - }) - - # Updates the decoration state for the gutter with the given gutterName. - # @customGutterDecorations is an {Object}, with the form: - # * gutterName : { - # decoration.id : { - # top: # of pixels from top - # height: # of pixels height of this decoration - # item (optional): HTMLElement or space-pen View - # class (optional): {String} class - # } - # } - updateCustomGutterDecorationState: -> - return unless @startRow? and @endRow? and @lineHeight? - - if @model.isMini() - # Mini editors have no gutter decorations. - # We clear instead of reassigning to preserve the reference. - @clearAllCustomGutterDecorations() - - for gutter in @model.getGutters() - gutterName = gutter.name - gutterDecorations = @customGutterDecorations[gutterName] - if gutterDecorations - # Clear the gutter decorations; they are rebuilt. - # We clear instead of reassigning to preserve the reference. - @clearDecorationsForCustomGutterName(gutterName) - else - @customGutterDecorations[gutterName] = {} - return if not @gutterIsVisible(gutter) - - relevantDecorations = @customGutterDecorationsInRange(gutterName, @startRow, @endRow - 1) - relevantDecorations.forEach (decoration) => - decorationRange = decoration.getMarker().getScreenRange() - @customGutterDecorations[gutterName][decoration.id] = - top: @lineHeight * decorationRange.start.row - height: @lineHeight * decorationRange.getRowCount() - item: decoration.getProperties().item - class: decoration.getProperties().class - - clearAllCustomGutterDecorations: -> - allGutterNames = Object.keys(@customGutterDecorations) - for gutterName in allGutterNames - @clearDecorationsForCustomGutterName(gutterName) - - clearDecorationsForCustomGutterName: (gutterName) -> - gutterDecorations = @customGutterDecorations[gutterName] - if gutterDecorations - allDecorationIds = Object.keys(gutterDecorations) - for decorationId in allDecorationIds - delete gutterDecorations[decorationId] - - gutterIsVisible: (gutterModel) -> - isVisible = gutterModel.isVisible() - if gutterModel.name is 'line-number' - isVisible = isVisible and @showLineNumbers - isVisible - - updateLineNumbersState: -> - return unless @startRow? and @endRow? and @lineHeight? - - visibleLineNumberIds = {} - - if @startRow > 0 - rowBeforeStartRow = @startRow - 1 - lastBufferRow = @model.bufferRowForScreenRow(rowBeforeStartRow) - wrapCount = rowBeforeStartRow - @model.screenRowForBufferRow(lastBufferRow) - else - lastBufferRow = null - wrapCount = 0 - - if @endRow > @startRow - for bufferRow, i in @model.bufferRowsForScreenRows(@startRow, @endRow - 1) - if bufferRow is lastBufferRow - wrapCount++ - id = bufferRow + '-' + wrapCount - softWrapped = true - else - id = bufferRow - wrapCount = 0 - lastBufferRow = bufferRow - softWrapped = false - - screenRow = @startRow + i - top = screenRow * @lineHeight - decorationClasses = @lineNumberDecorationClassesForRow(screenRow) - foldable = @model.isFoldableAtScreenRow(screenRow) - - @lineNumberGutter.lineNumbers[id] = {screenRow, bufferRow, softWrapped, top, decorationClasses, foldable} - visibleLineNumberIds[id] = true - - if @mouseWheelScreenRow? - bufferRow = @model.bufferRowForScreenRow(@mouseWheelScreenRow) - wrapCount = @mouseWheelScreenRow - @model.screenRowForBufferRow(bufferRow) - id = bufferRow - id += '-' + wrapCount if wrapCount > 0 - visibleLineNumberIds[id] = true - - for id of @lineNumberGutter.lineNumbers - delete @lineNumberGutter.lineNumbers[id] unless visibleLineNumberIds[id] - - return - - updateStartRow: -> - return unless @scrollTop? and @lineHeight? - - startRow = Math.floor(@scrollTop / @lineHeight) - @lineOverdrawMargin - @startRow = Math.max(0, startRow) - - updateEndRow: -> - return unless @scrollTop? and @lineHeight? and @height? - - startRow = Math.max(0, Math.floor(@scrollTop / @lineHeight)) - visibleLinesCount = Math.ceil(@height / @lineHeight) + 1 - endRow = startRow + visibleLinesCount + @lineOverdrawMargin - @endRow = Math.min(@model.getScreenLineCount(), endRow) - - updateScrollWidth: -> - return unless @contentWidth? and @clientWidth? - - scrollWidth = Math.max(@contentWidth, @clientWidth) - unless @scrollWidth is scrollWidth - @scrollWidth = scrollWidth - @updateScrollLeft() - - updateScrollHeight: -> - return unless @contentHeight? and @clientHeight? - - contentHeight = @contentHeight - if @scrollPastEnd - extraScrollHeight = @clientHeight - (@lineHeight * 3) - contentHeight += extraScrollHeight if extraScrollHeight > 0 - scrollHeight = Math.max(contentHeight, @height) - - unless @scrollHeight is scrollHeight - @scrollHeight = scrollHeight - @updateScrollTop() - - updateContentDimensions: -> - if @lineHeight? - oldContentHeight = @contentHeight - @contentHeight = @lineHeight * @model.getScreenLineCount() - - if @baseCharacterWidth? - oldContentWidth = @contentWidth - clip = @model.tokenizedLineForScreenRow(@model.getLongestScreenRow())?.isSoftWrapped() - @contentWidth = @pixelPositionForScreenPosition([@model.getLongestScreenRow(), @model.getMaxScreenLineLength()], clip).left - @contentWidth += 1 unless @model.isSoftWrapped() # account for cursor width - - if @contentHeight isnt oldContentHeight - @updateHeight() - @updateScrollbarDimensions() - @updateScrollHeight() - - if @contentWidth isnt oldContentWidth - @updateScrollbarDimensions() - @updateScrollWidth() - - updateClientHeight: -> - return unless @height? and @horizontalScrollbarHeight? - - clientHeight = @height - @horizontalScrollbarHeight - unless @clientHeight is clientHeight - @clientHeight = clientHeight - @updateScrollHeight() - @updateScrollTop() - - updateClientWidth: -> - return unless @contentFrameWidth? and @verticalScrollbarWidth? - - clientWidth = @contentFrameWidth - @verticalScrollbarWidth - unless @clientWidth is clientWidth - @clientWidth = clientWidth - @updateScrollWidth() - @updateScrollLeft() - - updateScrollTop: -> - scrollTop = @constrainScrollTop(@scrollTop) - unless @scrollTop is scrollTop - @scrollTop = scrollTop - @updateStartRow() - @updateEndRow() - - constrainScrollTop: (scrollTop) -> - return scrollTop unless scrollTop? and @scrollHeight? and @clientHeight? - Math.max(0, Math.min(scrollTop, @scrollHeight - @clientHeight)) - - updateScrollLeft: -> - @scrollLeft = @constrainScrollLeft(@scrollLeft) - - constrainScrollLeft: (scrollLeft) -> - return scrollLeft unless scrollLeft? and @scrollWidth? and @clientWidth? - Math.max(0, Math.min(scrollLeft, @scrollWidth - @clientWidth)) - - updateScrollbarDimensions: -> - return unless @contentFrameWidth? and @height? - return unless @measuredVerticalScrollbarWidth? and @measuredHorizontalScrollbarHeight? - return unless @contentWidth? and @contentHeight? - - clientWidthWithoutVerticalScrollbar = @contentFrameWidth - clientWidthWithVerticalScrollbar = clientWidthWithoutVerticalScrollbar - @measuredVerticalScrollbarWidth - clientHeightWithoutHorizontalScrollbar = @height - clientHeightWithHorizontalScrollbar = clientHeightWithoutHorizontalScrollbar - @measuredHorizontalScrollbarHeight - - horizontalScrollbarVisible = - not @model.isMini() and - (@contentWidth > clientWidthWithoutVerticalScrollbar or - @contentWidth > clientWidthWithVerticalScrollbar and @contentHeight > clientHeightWithoutHorizontalScrollbar) - - verticalScrollbarVisible = - not @model.isMini() and - (@contentHeight > clientHeightWithoutHorizontalScrollbar or - @contentHeight > clientHeightWithHorizontalScrollbar and @contentWidth > clientWidthWithoutVerticalScrollbar) - - horizontalScrollbarHeight = - if horizontalScrollbarVisible - @measuredHorizontalScrollbarHeight - else - 0 - - verticalScrollbarWidth = - if verticalScrollbarVisible - @measuredVerticalScrollbarWidth - else - 0 - - unless @horizontalScrollbarHeight is horizontalScrollbarHeight - @horizontalScrollbarHeight = horizontalScrollbarHeight - @updateClientHeight() - - unless @verticalScrollbarWidth is verticalScrollbarWidth - @verticalScrollbarWidth = verticalScrollbarWidth - @updateClientWidth() - - lineDecorationClassesForRow: (row) -> - return null if @model.isMini() - - decorationClasses = null - for id, decoration of @lineDecorationsByScreenRow[row] - decorationClasses ?= [] - decorationClasses.push(decoration.getProperties().class) - decorationClasses - - lineNumberDecorationClassesForRow: (row) -> - return null if @model.isMini() - - decorationClasses = null - for id, decoration of @lineNumberDecorationsByScreenRow[row] - decorationClasses ?= [] - decorationClasses.push(decoration.getProperties().class) - decorationClasses - - # Returns a {Set} of {Decoration}s on the given custom gutter from startRow to endRow (inclusive). - customGutterDecorationsInRange: (gutterName, startRow, endRow) -> - decorations = new Set - - return decorations if @model.isMini() or gutterName is 'line-number' or - not @customGutterDecorationsByGutterNameAndScreenRow[gutterName] - - for screenRow in [@startRow..@endRow - 1] - for id, decoration of @customGutterDecorationsByGutterNameAndScreenRow[gutterName][screenRow] - decorations.add(decoration) - decorations - - getCursorBlinkPeriod: -> @cursorBlinkPeriod - - getCursorBlinkResumeDelay: -> @cursorBlinkResumeDelay - - setFocused: (focused) -> - unless @focused is focused - @focused = focused - if @focused - @startBlinkingCursors() - else - @stopBlinkingCursors(false) - @shouldUpdateFocusedState = true - @shouldUpdateHiddenInputState = true - - @emitDidUpdateState() - - setScrollTop: (scrollTop) -> - scrollTop = @constrainScrollTop(scrollTop) - - unless @scrollTop is scrollTop or Number.isNaN(scrollTop) - @scrollTop = scrollTop - @model.setScrollTop(scrollTop) - @updateStartRow() - @updateEndRow() - @didStartScrolling() - @shouldUpdateVerticalScrollState = true - @shouldUpdateHiddenInputState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateCursorsState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateCustomGutterDecorationState = true - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - getScrollTop: -> - @scrollTop - - didStartScrolling: -> - if @stoppedScrollingTimeoutId? - clearTimeout(@stoppedScrollingTimeoutId) - @stoppedScrollingTimeoutId = null - @stoppedScrollingTimeoutId = setTimeout(@didStopScrolling.bind(this), @stoppedScrollingDelay) - @state.content.scrollingVertically = true - @emitDidUpdateState() - - didStopScrolling: -> - @state.content.scrollingVertically = false - if @mouseWheelScreenRow? - @mouseWheelScreenRow = null - @shouldUpdateLinesState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateCustomGutterDecorationState = true - - @emitDidUpdateState() - - setScrollLeft: (scrollLeft) -> - scrollLeft = @constrainScrollLeft(scrollLeft) - unless @scrollLeft is scrollLeft or Number.isNaN(scrollLeft) - oldScrollLeft = @scrollLeft - @scrollLeft = scrollLeft - @model.setScrollLeft(scrollLeft) - @shouldUpdateHorizontalScrollState = true - @shouldUpdateHiddenInputState = true - @shouldUpdateCursorsState = true unless oldScrollLeft? - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - getScrollLeft: -> - @scrollLeft - - setHorizontalScrollbarHeight: (horizontalScrollbarHeight) -> - unless @measuredHorizontalScrollbarHeight is horizontalScrollbarHeight - oldHorizontalScrollbarHeight = @measuredHorizontalScrollbarHeight - @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight - @model.setHorizontalScrollbarHeight(horizontalScrollbarHeight) - @updateScrollbarDimensions() - @shouldUpdateScrollbarsState = true - @shouldUpdateVerticalScrollState = true - @shouldUpdateHorizontalScrollState = true - @shouldUpdateCursorsState = true unless oldHorizontalScrollbarHeight? - - @emitDidUpdateState() - - setVerticalScrollbarWidth: (verticalScrollbarWidth) -> - unless @measuredVerticalScrollbarWidth is verticalScrollbarWidth - oldVerticalScrollbarWidth = @measuredVerticalScrollbarWidth - @measuredVerticalScrollbarWidth = verticalScrollbarWidth - @model.setVerticalScrollbarWidth(verticalScrollbarWidth) - @updateScrollbarDimensions() - @shouldUpdateScrollbarsState = true - @shouldUpdateVerticalScrollState = true - @shouldUpdateHorizontalScrollState = true - @shouldUpdateCursorsState = true unless oldVerticalScrollbarWidth? - - @emitDidUpdateState() - - setAutoHeight: (autoHeight) -> - unless @autoHeight is autoHeight - @autoHeight = autoHeight - @shouldUpdateHeightState = true - - @emitDidUpdateState() - - setExplicitHeight: (explicitHeight) -> - unless @explicitHeight is explicitHeight - @explicitHeight = explicitHeight - @model.setHeight(explicitHeight) - @updateHeight() - @shouldUpdateVerticalScrollState = true - @shouldUpdateScrollbarsState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateCursorsState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateCustomGutterDecorationState = true - - @emitDidUpdateState() - - updateHeight: -> - height = @explicitHeight ? @contentHeight - unless @height is height - @height = height - @updateScrollbarDimensions() - @updateClientHeight() - @updateScrollHeight() - @updateEndRow() - - setContentFrameWidth: (contentFrameWidth) -> - unless @contentFrameWidth is contentFrameWidth - oldContentFrameWidth = @contentFrameWidth - @contentFrameWidth = contentFrameWidth - @model.setWidth(contentFrameWidth) - @updateScrollbarDimensions() - @updateClientWidth() - @shouldUpdateVerticalScrollState = true - @shouldUpdateHorizontalScrollState = true - @shouldUpdateScrollbarsState = true - @shouldUpdateContentState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateCursorsState = true unless oldContentFrameWidth? - - @emitDidUpdateState() - - setBoundingClientRect: (boundingClientRect) -> - unless @clientRectsEqual(@boundingClientRect, boundingClientRect) - @boundingClientRect = boundingClientRect - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - clientRectsEqual: (clientRectA, clientRectB) -> - clientRectA? and clientRectB? and - clientRectA.top is clientRectB.top and - clientRectA.left is clientRectB.left and - clientRectA.width is clientRectB.width and - clientRectA.height is clientRectB.height - - setWindowSize: (width, height) -> - if @windowWidth isnt width or @windowHeight isnt height - @windowWidth = width - @windowHeight = height - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - setBackgroundColor: (backgroundColor) -> - unless @backgroundColor is backgroundColor - @backgroundColor = backgroundColor - @shouldUpdateContentState = true - @shouldUpdateLineNumberGutterState = true - @updateCommonGutterState() - @shouldUpdateGutterOrderState = true - - @emitDidUpdateState() - - setGutterBackgroundColor: (gutterBackgroundColor) -> - unless @gutterBackgroundColor is gutterBackgroundColor - @gutterBackgroundColor = gutterBackgroundColor - @shouldUpdateLineNumberGutterState = true - @updateCommonGutterState() - @shouldUpdateGutterOrderState = true - - @emitDidUpdateState() - - setGutterWidth: (gutterWidth) -> - if @gutterWidth isnt gutterWidth - @gutterWidth = gutterWidth - @updateOverlaysState() - - setLineHeight: (lineHeight) -> - unless @lineHeight is lineHeight - @lineHeight = lineHeight - @model.setLineHeightInPixels(lineHeight) - @updateContentDimensions() - @updateScrollHeight() - @updateHeight() - @updateStartRow() - @updateEndRow() - @shouldUpdateHeightState = true - @shouldUpdateHorizontalScrollState = true - @shouldUpdateVerticalScrollState = true - @shouldUpdateScrollbarsState = true - @shouldUpdateHiddenInputState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateCursorsState = true - @shouldUpdateLineNumbersState = true - @shouldUpdateCustomGutterDecorationState = true - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - setMouseWheelScreenRow: (mouseWheelScreenRow) -> - unless @mouseWheelScreenRow is mouseWheelScreenRow - @mouseWheelScreenRow = mouseWheelScreenRow - @didStartScrolling() - - setBaseCharacterWidth: (baseCharacterWidth) -> - unless @baseCharacterWidth is baseCharacterWidth - @baseCharacterWidth = baseCharacterWidth - @model.setDefaultCharWidth(baseCharacterWidth) - @characterWidthsChanged() - - getScopedCharacterWidth: (scopeNames, char) -> - @getScopedCharacterWidths(scopeNames)[char] - - getScopedCharacterWidths: (scopeNames) -> - scope = @characterWidthsByScope - for scopeName in scopeNames - scope[scopeName] ?= {} - scope = scope[scopeName] - scope.characterWidths ?= {} - scope.characterWidths - - batchCharacterMeasurement: (fn) -> - oldChangeCount = @scopedCharacterWidthsChangeCount - @batchingCharacterMeasurement = true - @model.batchCharacterMeasurement(fn) - @batchingCharacterMeasurement = false - @characterWidthsChanged() if oldChangeCount isnt @scopedCharacterWidthsChangeCount - - setScopedCharacterWidth: (scopeNames, character, width) -> - @getScopedCharacterWidths(scopeNames)[character] = width - @model.setScopedCharWidth(scopeNames, character, width) - @scopedCharacterWidthsChangeCount++ - @characterWidthsChanged() unless @batchingCharacterMeasurement - - characterWidthsChanged: -> - @updateContentDimensions() - - @shouldUpdateHorizontalScrollState = true - @shouldUpdateVerticalScrollState = true - @shouldUpdateScrollbarsState = true - @shouldUpdateHiddenInputState = true - @shouldUpdateContentState = true - @shouldUpdateDecorations = true - @shouldUpdateLinesState = true - @shouldUpdateCursorsState = true - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - clearScopedCharacterWidths: -> - @characterWidthsByScope = {} - @model.clearScopedCharWidths() - - hasPixelPositionRequirements: -> - @lineHeight? and @baseCharacterWidth? - - pixelPositionForScreenPosition: (screenPosition, clip=true) -> - screenPosition = Point.fromObject(screenPosition) - screenPosition = @model.clipScreenPosition(screenPosition) if clip - - targetRow = screenPosition.row - targetColumn = screenPosition.column - baseCharacterWidth = @baseCharacterWidth - - top = targetRow * @lineHeight - left = 0 - column = 0 - - iterator = @model.tokenizedLineForScreenRow(targetRow).getTokenIterator() - while iterator.next() - characterWidths = @getScopedCharacterWidths(iterator.getScopes()) - - valueIndex = 0 - text = iterator.getText() - while valueIndex < text.length - if iterator.isPairedCharacter() - char = text - charLength = 2 - valueIndex += 2 - else - char = text[valueIndex] - charLength = 1 - valueIndex++ - - return {top, left} if column is targetColumn - - left += characterWidths[char] ? baseCharacterWidth unless char is '\0' - column += charLength - {top, left} - - hasPixelRectRequirements: -> - @hasPixelPositionRequirements() and @scrollWidth? - - hasOverlayPositionRequirements: -> - @hasPixelRectRequirements() and @boundingClientRect? and @windowWidth and @windowHeight - - pixelRectForScreenRange: (screenRange) -> - if screenRange.end.row > screenRange.start.row - top = @pixelPositionForScreenPosition(screenRange.start).top - left = 0 - height = (screenRange.end.row - screenRange.start.row + 1) * @lineHeight - width = @scrollWidth - else - {top, left} = @pixelPositionForScreenPosition(screenRange.start, false) - height = @lineHeight - width = @pixelPositionForScreenPosition(screenRange.end, false).left - left - - {top, left, width, height} - - observeDecoration: (decoration) -> - decorationDisposables = new CompositeDisposable - decorationDisposables.add decoration.getMarker().onDidChange(@decorationMarkerDidChange.bind(this, decoration)) - if decoration.isType('highlight') - decorationDisposables.add decoration.onDidFlash(@highlightDidFlash.bind(this, decoration)) - decorationDisposables.add decoration.onDidChangeProperties(@decorationPropertiesDidChange.bind(this, decoration)) - decorationDisposables.add decoration.onDidDestroy => - @disposables.remove(decorationDisposables) - decorationDisposables.dispose() - @didDestroyDecoration(decoration) - @disposables.add(decorationDisposables) - - decorationMarkerDidChange: (decoration, change) -> - if decoration.isType('line') or decoration.isType('gutter') - return if change.textChanged - - intersectsVisibleRowRange = false - oldRange = new Range(change.oldTailScreenPosition, change.oldHeadScreenPosition) - newRange = new Range(change.newTailScreenPosition, change.newHeadScreenPosition) - - if oldRange.intersectsRowRange(@startRow, @endRow - 1) - @removeFromLineDecorationCaches(decoration, oldRange) - intersectsVisibleRowRange = true - - if newRange.intersectsRowRange(@startRow, @endRow - 1) - @addToLineDecorationCaches(decoration, newRange) - intersectsVisibleRowRange = true - - if intersectsVisibleRowRange - @shouldUpdateLinesState = true if decoration.isType('line') - if decoration.isType('line-number') - @shouldUpdateLineNumbersState = true - else if decoration.isType('gutter') - @shouldUpdateCustomGutterDecorationState = true - - if decoration.isType('highlight') - return if change.textChanged - - @updateHighlightState(decoration) - - if decoration.isType('overlay') - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - decorationPropertiesDidChange: (decoration, event) -> - {oldProperties} = event - if decoration.isType('line') or decoration.isType('gutter') - @removePropertiesFromLineDecorationCaches( - decoration.id, - oldProperties, - decoration.getMarker().getScreenRange()) - @addToLineDecorationCaches(decoration, decoration.getMarker().getScreenRange()) - if decoration.isType('line') or Decoration.isType(oldProperties, 'line') - @shouldUpdateLinesState = true - if decoration.isType('line-number') or Decoration.isType(oldProperties, 'line-number') - @shouldUpdateLineNumbersState = true - if (decoration.isType('gutter') and not decoration.isType('line-number')) or - (Decoration.isType(oldProperties, 'gutter') and not Decoration.isType(oldProperties, 'line-number')) - @shouldUpdateCustomGutterDecorationState = true - else if decoration.isType('overlay') - @shouldUpdateOverlaysState = true - else if decoration.isType('highlight') - @updateHighlightState(decoration, event) - - @emitDidUpdateState() - - didDestroyDecoration: (decoration) -> - if decoration.isType('line') or decoration.isType('gutter') - @removeFromLineDecorationCaches(decoration, decoration.getMarker().getScreenRange()) - @shouldUpdateLinesState = true if decoration.isType('line') - if decoration.isType('line-number') - @shouldUpdateLineNumbersState = true - else if decoration.isType('gutter') - @shouldUpdateCustomGutterDecorationState = true - if decoration.isType('highlight') - @updateHighlightState(decoration) - if decoration.isType('overlay') - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - highlightDidFlash: (decoration) -> - flash = decoration.consumeNextFlash() - if decorationState = @state.content.highlights[decoration.id] - decorationState.flashCount++ - decorationState.flashClass = flash.class - decorationState.flashDuration = flash.duration - @emitDidUpdateState() - - didAddDecoration: (decoration) -> - @observeDecoration(decoration) - - if decoration.isType('line') or decoration.isType('gutter') - @addToLineDecorationCaches(decoration, decoration.getMarker().getScreenRange()) - @shouldUpdateLinesState = true if decoration.isType('line') - if decoration.isType('line-number') - @shouldUpdateLineNumbersState = true - else if decoration.isType('gutter') - @shouldUpdateCustomGutterDecorationState = true - else if decoration.isType('highlight') - @updateHighlightState(decoration) - else if decoration.isType('overlay') - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - updateDecorations: -> - @lineDecorationsByScreenRow = {} - @lineNumberDecorationsByScreenRow = {} - @customGutterDecorationsByGutterNameAndScreenRow = {} - @highlightDecorationsById = {} - - visibleHighlights = {} - return unless 0 <= @startRow <= @endRow <= Infinity - - for markerId, decorations of @model.decorationsForScreenRowRange(@startRow, @endRow - 1) - range = @model.getMarker(markerId).getScreenRange() - for decoration in decorations - if decoration.isType('line') or decoration.isType('gutter') - @addToLineDecorationCaches(decoration, range) - else if decoration.isType('highlight') - visibleHighlights[decoration.id] = @updateHighlightState(decoration) - - for id of @state.content.highlights - unless visibleHighlights[id] - delete @state.content.highlights[id] - - return - - removeFromLineDecorationCaches: (decoration, range) -> - @removePropertiesFromLineDecorationCaches(decoration.id, decoration.getProperties(), range) - - removePropertiesFromLineDecorationCaches: (decorationId, decorationProperties, range) -> - gutterName = decorationProperties.gutterName - for row in [range.start.row..range.end.row] by 1 - delete @lineDecorationsByScreenRow[row]?[decorationId] - delete @lineNumberDecorationsByScreenRow[row]?[decorationId] - delete @customGutterDecorationsByGutterNameAndScreenRow[gutterName]?[row]?[decorationId] if gutterName - return - - addToLineDecorationCaches: (decoration, range) -> - marker = decoration.getMarker() - properties = decoration.getProperties() - - return unless marker.isValid() - - if range.isEmpty() - return if properties.onlyNonEmpty - else - return if properties.onlyEmpty - omitLastRow = range.end.column is 0 - - for row in [range.start.row..range.end.row] by 1 - continue if properties.onlyHead and row isnt marker.getHeadScreenPosition().row - continue if omitLastRow and row is range.end.row - - if decoration.isType('line') - @lineDecorationsByScreenRow[row] ?= {} - @lineDecorationsByScreenRow[row][decoration.id] = decoration - - if decoration.isType('line-number') - @lineNumberDecorationsByScreenRow[row] ?= {} - @lineNumberDecorationsByScreenRow[row][decoration.id] = decoration - else if decoration.isType('gutter') - gutterName = decoration.getProperties().gutterName - @customGutterDecorationsByGutterNameAndScreenRow[gutterName] ?= {} - @customGutterDecorationsByGutterNameAndScreenRow[gutterName][row] ?= {} - @customGutterDecorationsByGutterNameAndScreenRow[gutterName][row][decoration.id] = decoration - - return - - updateHighlightState: (decoration) -> - return unless @startRow? and @endRow? and @lineHeight? and @hasPixelPositionRequirements() - - properties = decoration.getProperties() - marker = decoration.getMarker() - range = marker.getScreenRange() - - if decoration.isDestroyed() or not marker.isValid() or range.isEmpty() or not range.intersectsRowRange(@startRow, @endRow - 1) - delete @state.content.highlights[decoration.id] - @emitDidUpdateState() - return - - if range.start.row < @startRow - range.start.row = @startRow - range.start.column = 0 - if range.end.row >= @endRow - range.end.row = @endRow - range.end.column = 0 - - if range.isEmpty() - delete @state.content.highlights[decoration.id] - @emitDidUpdateState() - return - - highlightState = @state.content.highlights[decoration.id] ?= { - flashCount: 0 - flashDuration: null - flashClass: null - } - highlightState.class = properties.class - highlightState.deprecatedRegionClass = properties.deprecatedRegionClass - highlightState.regions = @buildHighlightRegions(range) - @emitDidUpdateState() - - true - - buildHighlightRegions: (screenRange) -> - lineHeightInPixels = @lineHeight - startPixelPosition = @pixelPositionForScreenPosition(screenRange.start, true) - endPixelPosition = @pixelPositionForScreenPosition(screenRange.end, true) - spannedRows = screenRange.end.row - screenRange.start.row + 1 - - if spannedRows is 1 - [ - top: startPixelPosition.top - height: lineHeightInPixels - left: startPixelPosition.left - width: endPixelPosition.left - startPixelPosition.left - ] - else - regions = [] - - # First row, extending from selection start to the right side of screen - regions.push( - top: startPixelPosition.top - left: startPixelPosition.left - height: lineHeightInPixels - right: 0 - ) - - # Middle rows, extending from left side to right side of screen - if spannedRows > 2 - regions.push( - top: startPixelPosition.top + lineHeightInPixels - height: endPixelPosition.top - startPixelPosition.top - lineHeightInPixels - left: 0 - right: 0 - ) - - # Last row, extending from left side of screen to selection end - if screenRange.end.column > 0 - regions.push( - top: endPixelPosition.top - height: lineHeightInPixels - left: 0 - width: endPixelPosition.left - ) - - regions - - setOverlayDimensions: (decorationId, itemWidth, itemHeight, contentMargin) -> - @overlayDimensions[decorationId] ?= {} - overlayState = @overlayDimensions[decorationId] - dimensionsAreEqual = overlayState.itemWidth is itemWidth and - overlayState.itemHeight is itemHeight and - overlayState.contentMargin is contentMargin - unless dimensionsAreEqual - overlayState.itemWidth = itemWidth - overlayState.itemHeight = itemHeight - overlayState.contentMargin = contentMargin - @shouldUpdateOverlaysState = true - - @emitDidUpdateState() - - observeCursor: (cursor) -> - didChangePositionDisposable = cursor.onDidChangePosition => - @shouldUpdateHiddenInputState = true if cursor.isLastCursor() - @pauseCursorBlinking() - @updateCursorState(cursor) - - @emitDidUpdateState() - - didChangeVisibilityDisposable = cursor.onDidChangeVisibility => - @updateCursorState(cursor) - - didDestroyDisposable = cursor.onDidDestroy => - @disposables.remove(didChangePositionDisposable) - @disposables.remove(didChangeVisibilityDisposable) - @disposables.remove(didDestroyDisposable) - @shouldUpdateHiddenInputState = true - @updateCursorState(cursor, true) - - @emitDidUpdateState() - - @disposables.add(didChangePositionDisposable) - @disposables.add(didChangeVisibilityDisposable) - @disposables.add(didDestroyDisposable) - - didAddCursor: (cursor) -> - @observeCursor(cursor) - @shouldUpdateHiddenInputState = true - @pauseCursorBlinking() - @updateCursorState(cursor) - - @emitDidUpdateState() - - startBlinkingCursors: -> - unless @toggleCursorBlinkHandle - @state.content.cursorsVisible = true - @toggleCursorBlinkHandle = setInterval(@toggleCursorBlink.bind(this), @getCursorBlinkPeriod() / 2) - - stopBlinkingCursors: (visible) -> - if @toggleCursorBlinkHandle - @state.content.cursorsVisible = visible - clearInterval(@toggleCursorBlinkHandle) - @toggleCursorBlinkHandle = null - - toggleCursorBlink: -> - @state.content.cursorsVisible = not @state.content.cursorsVisible - @emitDidUpdateState() - - pauseCursorBlinking: -> - @stopBlinkingCursors(true) - @startBlinkingCursorsAfterDelay ?= _.debounce(@startBlinkingCursors, @getCursorBlinkResumeDelay()) - @startBlinkingCursorsAfterDelay() - @emitDidUpdateState() diff --git a/src/text-editor-registry.js b/src/text-editor-registry.js new file mode 100644 index 00000000000..b5264fb5d9c --- /dev/null +++ b/src/text-editor-registry.js @@ -0,0 +1,348 @@ +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'], + ['editor.atomicSoftTabs', 'atomicSoftTabs'], + ['editor.showInvisibles', 'showInvisibles'], + ['editor.tabLength', 'tabLength'], + ['editor.invisibles', 'invisibles'], + ['editor.showCursorOnSelection', 'showCursorOnSelection'], + ['editor.showIndentGuide', 'showIndentGuide'], + ['editor.showLineNumbers', 'showLineNumbers'], + ['editor.softWrap', 'softWrapped'], + ['editor.softWrapHangingIndent', 'softWrapHangingIndentLength'], + ['editor.softWrapAtPreferredLineLength', 'softWrapAtPreferredLineLength'], + ['editor.preferredLineLength', 'preferredLineLength'], + ['editor.maxScreenLineLength', 'maxScreenLineLength'], + ['editor.autoIndent', 'autoIndent'], + ['editor.autoIndentOnPaste', 'autoIndentOnPaste'], + ['editor.scrollPastEnd', 'scrollPastEnd'], + ['editor.undoGroupingInterval', 'undoGroupingInterval'], + ['editor.scrollSensitivity', 'scrollSensitivity'] +]; + +// Experimental: This global registry tracks registered `TextEditors`. +// +// If you want to add functionality to a wider set of text editors than just +// those appearing within workspace panes, use `atom.textEditors.observe` to +// invoke a callback for all current and future registered text editors. +// +// If you want packages to be able to add functionality to your non-pane text +// editors (such as a search field in a custom user interface element), register +// 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. +module.exports = class TextEditorRegistry { + constructor({ config, assert, packageManager }) { + this.config = config; + this.assert = assert; + this.packageManager = packageManager; + this.clear(); + } + + deserialize(state) { + this.editorGrammarOverrides = state.editorGrammarOverrides; + } + + serialize() { + return { + editorGrammarOverrides: Object.assign({}, this.editorGrammarOverrides) + }; + } + + clear() { + if (this.subscriptions) { + this.subscriptions.dispose(); + } + + this.subscriptions = new CompositeDisposable(); + this.editors = new Set(); + this.emitter = new Emitter(); + this.scopesWithConfigSubscriptions = new Set(); + this.editorsWithMaintainedConfig = new Set(); + this.editorsWithMaintainedGrammar = new Set(); + this.editorGrammarOverrides = {}; + this.editorGrammarScores = new WeakMap(); + } + + destroy() { + this.subscriptions.dispose(); + this.editorsWithMaintainedConfig = null; + } + + // Register a `TextEditor`. + // + // * `editor` The editor to register. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // added editor. To avoid any memory leaks this should be called when the + // editor is destroyed. + add(editor) { + this.editors.add(editor); + editor.registered = true; + this.emitter.emit('did-add-editor', editor); + + return new Disposable(() => this.remove(editor)); + } + + build(params) { + params = Object.assign({ assert: this.assert }, params); + + let scope = null; + if (params.buffer) { + const { grammar } = params.buffer.getLanguageMode(); + if (grammar) { + scope = new ScopeDescriptor({ scopes: [grammar.scopeName] }); + } + } + + Object.assign(params, this.textEditorParamsForScope(scope)); + + return new TextEditor(params); + } + + // Remove a `TextEditor`. + // + // * `editor` The editor to remove. + // + // Returns a {Boolean} indicating whether the editor was successfully removed. + remove(editor) { + const removed = this.editors.delete(editor); + editor.registered = false; + return removed; + } + + // Gets the currently active text editor. + // + // Returns the currently active text editor, or `null` if there is none. + getActiveTextEditor() { + for (let ed of this.editors) { + // fast path, works as long as there's a shadow DOM inside the text editor + if (ed.getElement() === document.activeElement) { + return ed; + } else { + let editorElement = ed.getElement(); + let current = document.activeElement; + while (current) { + if (current === editorElement) { + return ed; + } + current = current.parentNode; + } + } + } + return null; + } + + // Invoke the given callback with all the current and future registered + // `TextEditors`. + // + // * `callback` {Function} to be called with current and future text editors. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observe(callback) { + this.editors.forEach(callback); + return this.emitter.on('did-add-editor', callback); + } + + // Keep a {TextEditor}'s configuration in sync with Atom's settings. + // + // * `editor` The editor whose configuration will be maintained. + // + // Returns a {Disposable} that can be used to stop updating the editor's + // configuration. + maintainConfig(editor) { + if (this.editorsWithMaintainedConfig.has(editor)) { + return new Disposable(noop); + } + this.editorsWithMaintainedConfig.add(editor); + + this.updateAndMonitorEditorSettings(editor); + const languageChangeSubscription = editor.buffer.onDidChangeLanguageMode( + (newLanguageMode, oldLanguageMode) => { + this.updateAndMonitorEditorSettings(editor, oldLanguageMode); + } + ); + this.subscriptions.add(languageChangeSubscription); + + const updateTabTypes = () => { + const configOptions = { scope: editor.getRootScopeDescriptor() }; + editor.setSoftTabs( + shouldEditorUseSoftTabs( + editor, + this.config.get('editor.tabType', configOptions), + this.config.get('editor.softTabs', configOptions) + ) + ); + }; + + updateTabTypes(); + const tokenizeSubscription = editor.onDidTokenize(updateTabTypes); + this.subscriptions.add(tokenizeSubscription); + + return new Disposable(() => { + this.editorsWithMaintainedConfig.delete(editor); + tokenizeSubscription.dispose(); + languageChangeSubscription.dispose(); + this.subscriptions.remove(languageChangeSubscription); + this.subscriptions.remove(tokenizeSubscription); + }); + } + + // 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) { + atom.grammars.maintainLanguageMode(editor.getBuffer()); + } + + // 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. + // * `languageId` The {String} language ID for the desired {Grammar}. + setGrammarOverride(editor, languageId) { + atom.grammars.assignLanguageMode(editor.getBuffer(), languageId); + } + + // 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 atom.grammars.getAssignedLanguageId(editor.getBuffer()); + } + + // Deprecated: Remove any grammar override that has been set for the given {TextEditor}. + // + // * `editor` The editor. + clearGrammarOverride(editor) { + atom.grammars.autoAssignLanguageMode(editor.getBuffer()); + } + + async updateAndMonitorEditorSettings(editor, oldLanguageMode) { + await this.packageManager.getActivatePromise(); + this.updateEditorSettingsForLanguageMode(editor, oldLanguageMode); + this.subscribeToSettingsForEditorScope(editor); + } + + updateEditorSettingsForLanguageMode(editor, oldLanguageMode) { + const newLanguageMode = editor.buffer.getLanguageMode(); + + if (oldLanguageMode) { + const newSettings = this.textEditorParamsForScope( + newLanguageMode.rootScopeDescriptor + ); + const oldSettings = this.textEditorParamsForScope( + oldLanguageMode.rootScopeDescriptor + ); + + 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 (_.size(updatedSettings) > 0) { + editor.update(updatedSettings); + } + } else { + editor.update( + this.textEditorParamsForScope(newLanguageMode.rootScopeDescriptor) + ); + } + } + + subscribeToSettingsForEditorScope(editor) { + if (!this.editorsWithMaintainedConfig) return; + + const scopeDescriptor = editor.getRootScopeDescriptor(); + const scopeChain = scopeDescriptor.getScopeChain(); + + if (!this.scopesWithConfigSubscriptions.has(scopeChain)) { + this.scopesWithConfigSubscriptions.add(scopeChain); + const configOptions = { scope: scopeDescriptor }; + + for (const [settingKey, paramName] of EDITOR_PARAMS_BY_SETTING_KEY) { + this.subscriptions.add( + this.config.onDidChange(settingKey, configOptions, ({ newValue }) => { + this.editorsWithMaintainedConfig.forEach(editor => { + if (editor.getRootScopeDescriptor().isEqual(scopeDescriptor)) { + editor.update({ [paramName]: newValue }); + } + }); + }) + ); + } + + const updateTabTypes = () => { + const tabType = this.config.get('editor.tabType', configOptions); + const softTabs = this.config.get('editor.softTabs', configOptions); + this.editorsWithMaintainedConfig.forEach(editor => { + if (editor.getRootScopeDescriptor().isEqual(scopeDescriptor)) { + editor.setSoftTabs( + shouldEditorUseSoftTabs(editor, tabType, softTabs) + ); + } + }); + }; + + this.subscriptions.add( + this.config.onDidChange( + 'editor.tabType', + configOptions, + updateTabTypes + ), + this.config.onDidChange( + 'editor.softTabs', + configOptions, + updateTabTypes + ) + ); + } + } + + textEditorParamsForScope(scopeDescriptor) { + const result = {}; + const configOptions = { scope: scopeDescriptor }; + for (const [settingKey, paramName] of EDITOR_PARAMS_BY_SETTING_KEY) { + result[paramName] = this.config.get(settingKey, configOptions); + } + return result; + } +}; + +function shouldEditorUseSoftTabs(editor, tabType, softTabs) { + switch (tabType) { + case 'hard': + return false; + case 'soft': + return true; + case 'auto': + switch (editor.usesSoftTabs()) { + case true: + return true; + case false: + return false; + default: + return softTabs; + } + } +} + +function noop() {} diff --git a/src/text-editor-view.coffee b/src/text-editor-view.coffee deleted file mode 100644 index b86367ef45f..00000000000 --- a/src/text-editor-view.coffee +++ /dev/null @@ -1,324 +0,0 @@ -{View, $} = require 'space-pen' -{defaults} = require 'underscore-plus' -TextBuffer = require 'text-buffer' -TextEditor = require './text-editor' -TextEditorElement = require './text-editor-element' -TextEditorComponent = require './text-editor-component' -{deprecate} = require 'grim' - -# Deprecated: Represents the entire visual pane in Atom. -# -# The TextEditorView manages the {TextEditor}, which manages the file buffers. -# `TextEditorView` is intentionally sparse. Most of the things you'll want -# to do are on {TextEditor}. -# -# ## Examples -# -# Requiring in packages -# -# ```coffee -# {TextEditorView} = require 'atom' -# -# miniEditorView = new TextEditorView(mini: true) -# ``` -# -# Iterating over the open editor views -# -# ```coffee -# for editorView in atom.workspaceView.getEditorViews() -# console.log(editorView.getModel().getPath()) -# ``` -# -# Subscribing to every current and future editor -# -# ```coffee -# atom.workspace.eachEditorView (editorView) -> -# console.log(editorView.getModel().getPath()) -# ``` -module.exports = -class TextEditorView extends View - # The constructor for setting up an `TextEditorView` instance. - # - # * `modelOrParams` Either an {TextEditor}, or an object with one property, `mini`. - # If `mini` is `true`, a "miniature" `TextEditor` is constructed. - # Typically, this is ideal for scenarios where you need an Atom editor, - # but without all the chrome, like scrollbars, gutter, _e.t.c._. - # - constructor: (modelOrParams, props) -> - # Handle direct construction with an editor or params - unless modelOrParams instanceof HTMLElement - if modelOrParams instanceof TextEditor - model = modelOrParams - else - {editor, mini, placeholderText, attributes} = modelOrParams - model = editor ? new TextEditor - buffer: new TextBuffer - softWrapped: false - tabLength: 2 - softTabs: true - mini: mini - placeholderText: placeholderText - - element = new TextEditorElement - element.lineOverdrawMargin = props?.lineOverdrawMargin - element.setAttribute(name, value) for name, value of attributes if attributes? - element.setModel(model) - return element.__spacePenView - - # Handle construction with an element - @element = modelOrParams - super - - setModel: (@model) -> - @editor = @model - - @root = $(@element.rootElement) - - @scrollView = @root.find('.scroll-view') - - if atom.config.get('editor.useShadowDOM') - @underlayer = $("
    ").appendTo(this) - @overlayer = $("
    ").appendTo(this) - else - @underlayer = @find('.highlights').addClass('underlayer') - @overlayer = @find('.lines').addClass('overlayer') - - @hiddenInput = @root.find('.hidden-input') - - @hiddenInput.on = (args...) => - args[0] = 'blur' if args[0] is 'focusout' - $::on.apply(this, args) - - @subscribe atom.config.observe 'editor.showLineNumbers', => - @gutter = @root.find('.gutter') - - @gutter.removeClassFromAllLines = (klass) => - deprecate('Use decorations instead: http://blog.atom.io/2014/07/24/decorations.html') - @gutter.find('.line-number').removeClass(klass) - - @gutter.getLineNumberElement = (bufferRow) => - deprecate('Use decorations instead: http://blog.atom.io/2014/07/24/decorations.html') - @gutter.find("[data-buffer-row='#{bufferRow}']") - - @gutter.addClassToLine = (bufferRow, klass) => - deprecate('Use decorations instead: http://blog.atom.io/2014/07/24/decorations.html') - lines = @gutter.find("[data-buffer-row='#{bufferRow}']") - lines.addClass(klass) - lines.length > 0 - - find: -> - shadowResult = @root.find.apply(@root, arguments) - if shadowResult.length > 0 - shadowResult - else - super - - # Public: Get the underlying editor model for this view. - # - # Returns an {TextEditor} - getModel: -> @model - - getEditor: -> @model - - Object.defineProperty @::, 'lineHeight', get: -> @model.getLineHeightInPixels() - Object.defineProperty @::, 'charWidth', get: -> @model.getDefaultCharWidth() - Object.defineProperty @::, 'firstRenderedScreenRow', get: -> @component.getRenderedRowRange()[0] - Object.defineProperty @::, 'lastRenderedScreenRow', get: -> @component.getRenderedRowRange()[1] - Object.defineProperty @::, 'active', get: -> @is(@getPaneView()?.activeView) - Object.defineProperty @::, 'isFocused', get: -> document.activeElement is @element or document.activeElement is @element.component?.hiddenInputComponent?.getDomNode() - Object.defineProperty @::, 'mini', get: -> @model?.isMini() - Object.defineProperty @::, 'component', get: -> @element?.component - - afterAttach: (onDom) -> - return unless onDom - return if @attached - @attached = true - @trigger 'editor:attached', [this] - - beforeRemove: -> - @trigger 'editor:detached', [this] - @trigger 'editor:will-be-removed', [this] - @attached = false - - remove: (selector, keepData) -> - @model.destroy() unless keepData - super - - scrollTop: (scrollTop) -> - if scrollTop? - @model.setScrollTop(scrollTop) - else - @model.getScrollTop() - - scrollLeft: (scrollLeft) -> - if scrollLeft? - @model.setScrollLeft(scrollLeft) - else - @model.getScrollLeft() - - scrollToBottom: -> - deprecate 'Use TextEditor::scrollToBottom instead. You can get the editor via editorView.getModel()' - @model.setScrollBottom(Infinity) - - scrollToScreenPosition: (screenPosition, options) -> - deprecate 'Use TextEditor::scrollToScreenPosition instead. You can get the editor via editorView.getModel()' - @model.scrollToScreenPosition(screenPosition, options) - - scrollToBufferPosition: (bufferPosition, options) -> - deprecate 'Use TextEditor::scrollToBufferPosition instead. You can get the editor via editorView.getModel()' - @model.scrollToBufferPosition(bufferPosition, options) - - scrollToCursorPosition: -> - deprecate 'Use TextEditor::scrollToCursorPosition instead. You can get the editor via editorView.getModel()' - @model.scrollToCursorPosition() - - pixelPositionForBufferPosition: (bufferPosition) -> - deprecate 'Use TextEditorElement::pixelPositionForBufferPosition instead. You can get the editor via editorView.getModel()' - @model.pixelPositionForBufferPosition(bufferPosition, true) - - pixelPositionForScreenPosition: (screenPosition) -> - deprecate 'Use TextEditorElement::pixelPositionForScreenPosition instead. You can get the editor via editorView.getModel()' - @model.pixelPositionForScreenPosition(screenPosition, true) - - appendToLinesView: (view) -> - view.css('position', 'absolute') - view.css('z-index', 1) - @overlayer.append(view) - - splitLeft: -> - deprecate """ - Use Pane::splitLeft instead. - To duplicate this editor into the split use: - editorView.getPaneView().getModel().splitLeft(copyActiveItem: true) - """ - pane = @getPaneView() - pane?.splitLeft(pane?.copyActiveItem()).activeView - - splitRight: -> - deprecate """ - Use Pane::splitRight instead. - To duplicate this editor into the split use: - editorView.getPaneView().getModel().splitRight(copyActiveItem: true) - """ - pane = @getPaneView() - pane?.splitRight(pane?.copyActiveItem()).activeView - - splitUp: -> - deprecate """ - Use Pane::splitUp instead. - To duplicate this editor into the split use: - editorView.getPaneView().getModel().splitUp(copyActiveItem: true) - """ - pane = @getPaneView() - pane?.splitUp(pane?.copyActiveItem()).activeView - - splitDown: -> - deprecate """ - Use Pane::splitDown instead. - To duplicate this editor into the split use: - editorView.getPaneView().getModel().splitDown(copyActiveItem: true) - """ - pane = @getPaneView() - pane?.splitDown(pane?.copyActiveItem()).activeView - - # Public: Get this {TextEditorView}'s {PaneView}. - # - # Returns a {PaneView} - getPaneView: -> - @parent('.item-views').parents('atom-pane').view() - getPane: -> - deprecate 'Use TextEditorView::getPaneView() instead' - @getPaneView() - - show: -> - super - @component?.checkForVisibilityChange() - - hide: -> - super - @component?.checkForVisibilityChange() - - pageDown: -> - deprecate('Use editorView.getModel().pageDown()') - @model.pageDown() - - pageUp: -> - deprecate('Use editorView.getModel().pageUp()') - @model.pageUp() - - getFirstVisibleScreenRow: -> - deprecate 'Use TextEditorElement::getFirstVisibleScreenRow instead.' - @model.getFirstVisibleScreenRow(true) - - getLastVisibleScreenRow: -> - deprecate 'Use TextEditor::getLastVisibleScreenRow instead. You can get the editor via editorView.getModel()' - @model.getLastVisibleScreenRow() - - getFontFamily: -> - deprecate 'This is going away. Use atom.config.get("editor.fontFamily") instead' - @component?.getFontFamily() - - setFontFamily: (fontFamily) -> - deprecate 'This is going away. Use atom.config.set("editor.fontFamily", "my-font") instead' - @component?.setFontFamily(fontFamily) - - getFontSize: -> - deprecate 'This is going away. Use atom.config.get("editor.fontSize") instead' - @component?.getFontSize() - - setFontSize: (fontSize) -> - deprecate 'This is going away. Use atom.config.set("editor.fontSize", 12) instead' - @component?.setFontSize(fontSize) - - setLineHeight: (lineHeight) -> - deprecate 'This is going away. Use atom.config.set("editor.lineHeight", 1.5) instead' - @component.setLineHeight(lineHeight) - - setWidthInChars: (widthInChars) -> - @component.getDOMNode().style.width = (@model.getDefaultCharWidth() * widthInChars) + 'px' - - setShowIndentGuide: (showIndentGuide) -> - deprecate 'This is going away. Use atom.config.set("editor.showIndentGuide", true|false) instead' - atom.config.set("editor.showIndentGuide", showIndentGuide) - - setSoftWrap: (softWrapped) -> - deprecate 'Use TextEditor::setSoftWrapped instead. You can get the editor via editorView.getModel()' - @model.setSoftWrapped(softWrapped) - - setShowInvisibles: (showInvisibles) -> - deprecate 'This is going away. Use atom.config.set("editor.showInvisibles", true|false) instead' - @component.setShowInvisibles(showInvisibles) - - getText: -> - @model.getText() - - setText: (text) -> - @model.setText(text) - - insertText: (text) -> - @model.insertText(text) - - isInputEnabled: -> - @component.isInputEnabled() - - setInputEnabled: (inputEnabled) -> - @component.setInputEnabled(inputEnabled) - - requestDisplayUpdate: -> - deprecate('Please remove from your code. ::requestDisplayUpdate no longer does anything') - - updateDisplay: -> - deprecate('Please remove from your code. ::updateDisplay no longer does anything') - - resetDisplay: -> - deprecate('Please remove from your code. ::resetDisplay no longer does anything') - - redraw: -> - deprecate('Please remove from your code. ::redraw no longer does anything') - - setPlaceholderText: (placeholderText) -> - deprecate('Use TextEditor::setPlaceholderText instead. eg. editorView.getModel().setPlaceholderText(text)') - @model.setPlaceholderText(placeholderText) - - lineElementForScreenRow: (screenRow) -> - $(@component.lineNodeForScreenRow(screenRow)) diff --git a/src/text-editor.coffee b/src/text-editor.coffee deleted file mode 100644 index 4489d82afa6..00000000000 --- a/src/text-editor.coffee +++ /dev/null @@ -1,3115 +0,0 @@ -_ = require 'underscore-plus' -path = require 'path' -Serializable = require 'serializable' -Delegator = require 'delegato' -{includeDeprecatedAPIs, deprecate} = require 'grim' -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = TextBuffer = require 'text-buffer' -LanguageMode = require './language-mode' -DisplayBuffer = require './display-buffer' -Cursor = require './cursor' -Model = require './model' -Selection = require './selection' -TextMateScopeSelector = require('first-mate').ScopeSelector -{Directory} = require "pathwatcher" -GutterContainer = require './gutter-container' - -# Public: 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. If you're -# interested in the visual appearance of editors, use {TextEditorView} instead. -# -# A single {TextBuffer} can belong to multiple editors. For example, if the -# same file is open in two different panes, Atom creates a separate editor for -# each pane. If the buffer is manipulated the changes are reflected in both -# editors, but each maintains its own cursor position, folded lines, etc. -# -# ## Accessing TextEditor Instances -# -# The easiest way to get hold of `TextEditor` objects is by registering a callback -# with `::observeTextEditors` on the `atom.workspace` global. Your callback will -# then be called with all current editor instances and also when any editor is -# created in the future. -# -# ```coffee -# atom.workspace.observeTextEditors (editor) -> -# editor.insertText('Hello World') -# ``` -# -# ## Buffer vs. Screen Coordinates -# -# Because editors support folds and soft-wrapping, the lines on screen don't -# always match the lines in the buffer. For example, a long line that soft wraps -# twice renders as three lines on screen, but only represents one line in the -# buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds -# to row 11 in the buffer. -# -# Your choice of coordinates systems will depend on what you're trying to -# achieve. For example, if you're writing a command that jumps the cursor up or -# down by 10 lines, you'll want to use screen coordinates because the user -# probably wants to skip lines *on screen*. However, if you're writing a package -# that jumps between method definitions, you'll want to work in buffer -# coordinates. -# -# **When in doubt, just default to buffer coordinates**, then experiment with -# soft wraps and folds to ensure your code interacts with them correctly. -module.exports = -class TextEditor extends Model - Serializable.includeInto(this) - atom.deserializers.add(this) - Delegator.includeInto(this) - - deserializing: false - callDisplayBufferCreatedHook: false - registerEditor: false - buffer: null - languageMode: null - cursors: null - selections: null - suppressSelectionMerging: false - updateBatchDepth: 0 - selectionFlashDuration: 500 - gutterContainer: null - - @delegatesMethods 'suggestedIndentForBufferRow', 'autoIndentBufferRow', 'autoIndentBufferRows', - 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', - toProperty: 'languageMode' - - constructor: ({@softTabs, initialLine, initialColumn, tabLength, softWrapped, @displayBuffer, buffer, registerEditor, suppressCursorCreation, @mini, @placeholderText, lineNumberGutterVisible}={}) -> - super - - @emitter = new Emitter - @disposables = new CompositeDisposable - @cursors = [] - @selections = [] - - buffer ?= new TextBuffer - @displayBuffer ?= new DisplayBuffer({buffer, tabLength, softWrapped, ignoreInvisibles: @mini}) - @buffer = @displayBuffer.buffer - @softTabs = @usesSoftTabs() ? @softTabs ? atom.config.get('editor.softTabs') ? true - - for marker in @findMarkers(@getSelectionMarkerAttributes()) - marker.setProperties(preserveFolds: true) - @addSelection(marker) - - @subscribeToBuffer() - @subscribeToDisplayBuffer() - - if @getCursors().length is 0 and not suppressCursorCreation - initialLine = Math.max(parseInt(initialLine) or 0, 0) - initialColumn = Math.max(parseInt(initialColumn) or 0, 0) - @addCursorAtBufferPosition([initialLine, initialColumn]) - - @languageMode = new LanguageMode(this) - - @setEncoding(atom.config.get('core.fileEncoding', scope: @getRootScopeDescriptor())) - - @disposables.add @displayBuffer.onDidChangeScrollTop (scrollTop) => - @emit 'scroll-top-changed', scrollTop if includeDeprecatedAPIs - @emitter.emit 'did-change-scroll-top', scrollTop - - @disposables.add @displayBuffer.onDidChangeScrollLeft (scrollLeft) => - @emit 'scroll-left-changed', scrollLeft if includeDeprecatedAPIs - @emitter.emit 'did-change-scroll-left', scrollLeft - - @gutterContainer = new GutterContainer(this) - @lineNumberGutter = @gutterContainer.addGutter - name: 'line-number' - priority: 0 - visible: lineNumberGutterVisible - - atom.workspace?.editorAdded(this) if registerEditor - - serializeParams: -> - id: @id - softTabs: @softTabs - scrollTop: @scrollTop - scrollLeft: @scrollLeft - displayBuffer: @displayBuffer.serialize() - - deserializeParams: (params) -> - params.displayBuffer = DisplayBuffer.deserialize(params.displayBuffer) - params.registerEditor = true - params - - subscribeToBuffer: -> - @buffer.retain() - @disposables.add @buffer.onDidChangePath => - unless atom.project.getPaths().length > 0 - atom.project.setPaths([path.dirname(@getPath())]) - @emit "title-changed" if includeDeprecatedAPIs - @emitter.emit 'did-change-title', @getTitle() - @emit "path-changed" if includeDeprecatedAPIs - @emitter.emit 'did-change-path', @getPath() - @disposables.add @buffer.onDidChangeEncoding => - @emitter.emit 'did-change-encoding', @getEncoding() - @disposables.add @buffer.onDidDestroy => @destroy() - - # TODO: remove these when we remove the deprecations. They are old events. - if includeDeprecatedAPIs - @subscribe @buffer.onDidStopChanging => @emit "contents-modified" - @subscribe @buffer.onDidConflict => @emit "contents-conflicted" - @subscribe @buffer.onDidChangeModified => @emit "modified-status-changed" - - @preserveCursorPositionOnBufferReload() - - subscribeToDisplayBuffer: -> - @disposables.add @displayBuffer.onDidCreateMarker @handleMarkerCreated - @disposables.add @displayBuffer.onDidUpdateMarkers => @mergeIntersectingSelections() - @disposables.add @displayBuffer.onDidChangeGrammar => @handleGrammarChange() - @disposables.add @displayBuffer.onDidTokenize => @handleTokenization() - @disposables.add @displayBuffer.onDidChange (e) => - @emit 'screen-lines-changed', e if includeDeprecatedAPIs - @emitter.emit 'did-change', e - - # TODO: remove these when we remove the deprecations. Though, no one is likely using them - if includeDeprecatedAPIs - @subscribe @displayBuffer.onDidChangeSoftWrapped (softWrapped) => @emit 'soft-wrap-changed', softWrapped - @subscribe @displayBuffer.onDidAddDecoration (decoration) => @emit 'decoration-added', decoration - @subscribe @displayBuffer.onDidRemoveDecoration (decoration) => @emit 'decoration-removed', decoration - - destroyed: -> - @unsubscribe() if includeDeprecatedAPIs - @disposables.dispose() - selection.destroy() for selection in @getSelections() - @buffer.release() - @displayBuffer.destroy() - @languageMode.destroy() - @gutterContainer.destroy() - @emitter.emit 'did-destroy' - - ### - Section: Event Subscription - ### - - # Essential: Calls your `callback` when the buffer's title has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeTitle: (callback) -> - @emitter.on 'did-change-title', callback - - # Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePath: (callback) -> - @emitter.on 'did-change-path', callback - - # Essential: Invoke the given callback synchronously when the content of the - # buffer changes. - # - # Because observers are invoked synchronously, it's important not to perform - # any expensive operations via this method. Consider {::onDidStopChanging} to - # delay expensive operations until after changes stop occurring. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - # Essential: Invoke `callback` when the buffer's contents change. It is - # emit asynchronously 300ms after the last buffer change. This is a good place - # to handle changes to the buffer without compromising typing performance. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidStopChanging: (callback) -> - @getBuffer().onDidStopChanging(callback) - - # Essential: Calls your `callback` when a {Cursor} is moved. If there are - # multiple cursors, your callback will be called for each cursor. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferPosition` {Point} - # * `oldScreenPosition` {Point} - # * `newBufferPosition` {Point} - # * `newScreenPosition` {Point} - # * `textChanged` {Boolean} - # * `cursor` {Cursor} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeCursorPosition: (callback) -> - @emitter.on 'did-change-cursor-position', callback - - # Essential: Calls your `callback` when a selection's screen range changes. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferRange` {Range} - # * `oldScreenRange` {Range} - # * `newBufferRange` {Range} - # * `newScreenRange` {Range} - # * `selection` {Selection} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeSelectionRange: (callback) -> - @emitter.on 'did-change-selection-range', callback - - # Extended: Calls your `callback` when soft wrap was enabled or disabled. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeSoftWrapped: (callback) -> - @displayBuffer.onDidChangeSoftWrapped(callback) - - # Extended: Calls your `callback` when the buffer's encoding has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeEncoding: (callback) -> - @emitter.on 'did-change-encoding', callback - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. Immediately calls your callback with - # the current grammar. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGrammar: (callback) -> - callback(@getGrammar()) - @onDidChangeGrammar(callback) - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeGrammar: (callback) -> - @emitter.on 'did-change-grammar', callback - - # Extended: Calls your `callback` when the result of {::isModified} changes. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeModified: (callback) -> - @getBuffer().onDidChangeModified(callback) - - # Extended: Calls your `callback` when the buffer's underlying file changes on - # disk at a moment when the result of {::isModified} is true. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidConflict: (callback) -> - @getBuffer().onDidConflict(callback) - - # Extended: Calls your `callback` before text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # * `cancel` {Function} Call to prevent the text from being inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillInsertText: (callback) -> - @emitter.on 'will-insert-text', callback - - # Extended: Calls your `callback` adter text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidInsertText: (callback) -> - @emitter.on 'did-insert-text', callback - - # Public: Invoke the given callback after the buffer is saved to disk. - # - # * `callback` {Function} to be called after the buffer is saved. - # * `event` {Object} with the following keys: - # * `path` The path to which the buffer was saved. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidSave: (callback) -> - @getBuffer().onDidSave(callback) - - # Public: Invoke the given callback when the editor is destroyed. - # - # * `callback` {Function} to be called when the editor is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # Immediately calls your callback for each existing cursor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeCursors: (callback) -> - callback(cursor) for cursor in @getCursors() - @onDidAddCursor(callback) - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddCursor: (callback) -> - @emitter.on 'did-add-cursor', callback - - # Extended: Calls your `callback` when a {Cursor} is removed from the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveCursor: (callback) -> - @emitter.on 'did-remove-cursor', callback - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # Immediately calls your callback for each existing selection. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeSelections: (callback) -> - callback(selection) for selection in @getSelections() - @onDidAddSelection(callback) - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddSelection: (callback) -> - @emitter.on 'did-add-selection', callback - - # Extended: Calls your `callback` when a {Selection} is removed from the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveSelection: (callback) -> - @emitter.on 'did-remove-selection', callback - - # Extended: Calls your `callback` with each {Decoration} added to the editor. - # Calls your `callback` immediately for any existing decorations. - # - # * `callback` {Function} - # * `decoration` {Decoration} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeDecorations: (callback) -> - @displayBuffer.observeDecorations(callback) - - # Extended: Calls your `callback` when a {Decoration} is added to the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddDecoration: (callback) -> - @displayBuffer.onDidAddDecoration(callback) - - # Extended: Calls your `callback` when a {Decoration} is removed from the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveDecoration: (callback) -> - @displayBuffer.onDidRemoveDecoration(callback) - - # Extended: Calls your `callback` when the placeholder text is changed. - # - # * `callback` {Function} - # * `placeholderText` {String} new text - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePlaceholderText: (callback) -> - @emitter.on 'did-change-placeholder-text', callback - - onDidChangeCharacterWidths: (callback) -> - @displayBuffer.onDidChangeCharacterWidths(callback) - - onDidChangeScrollTop: (callback) -> - @emitter.on 'did-change-scroll-top', callback - - onDidChangeScrollLeft: (callback) -> - @emitter.on 'did-change-scroll-left', callback - - # TODO Remove once the tabs package no longer uses .on subscriptions - onDidChangeIcon: (callback) -> - @emitter.on 'did-change-icon', callback - - # Public: Retrieves the current {TextBuffer}. - getBuffer: -> @buffer - - # Retrieves the current buffer's URI. - getURI: -> @buffer.getUri() - - # Create an {TextEditor} with its initial state based on this object - copy: -> - displayBuffer = @displayBuffer.copy() - softTabs = @getSoftTabs() - newEditor = new TextEditor({@buffer, displayBuffer, @tabLength, softTabs, suppressCursorCreation: true, registerEditor: true}) - for marker in @findMarkers(editorId: @id) - marker.copy(editorId: newEditor.id, preserveFolds: true) - newEditor - - # Controls visibility based on the given {Boolean}. - setVisible: (visible) -> @displayBuffer.setVisible(visible) - - setMini: (mini) -> - if mini isnt @mini - @mini = mini - @displayBuffer.setIgnoreInvisibles(@mini) - @emitter.emit 'did-change-mini', @mini - @mini - - isMini: -> @mini - - onDidChangeMini: (callback) -> - @emitter.on 'did-change-mini', callback - - setLineNumberGutterVisible: (lineNumberGutterVisible) -> - unless lineNumberGutterVisible is @lineNumberGutter.isVisible() - if lineNumberGutterVisible - @lineNumberGutter.show() - else - @lineNumberGutter.hide() - @emitter.emit 'did-change-line-number-gutter-visible', @lineNumberGutter.isVisible() - @lineNumberGutter.isVisible() - - isLineNumberGutterVisible: -> @lineNumberGutter.isVisible() - - onDidChangeLineNumberGutterVisible: (callback) -> - @emitter.on 'did-change-line-number-gutter-visible', callback - - # Public: Creates and returns a {Gutter}. - # See {GutterContainer::addGutter} for more details. - addGutter: (options) -> - @gutterContainer.addGutter(options) - - # Public: Returns the {Array} of all gutters on this editor. - getGutters: -> - @gutterContainer.getGutters() - - # Public: Returns the {Gutter} with the given name, or null if it doesn't exist. - gutterWithName: (name) -> - @gutterContainer.gutterWithName(name) - - # Calls your `callback` when a {Gutter} is added to the editor. - # Immediately calls your callback for each existing gutter. - # - # * `callback` {Function} - # * `gutter` {Gutter} that currently exists/was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGutters: (callback) -> - @gutterContainer.observeGutters callback - - # Calls your `callback` when a {Gutter} is added to the editor. - # - # * `callback` {Function} - # * `gutter` {Gutter} that was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddGutter: (callback) -> - @gutterContainer.onDidAddGutter callback - - # Calls your `callback` when a {Gutter} is removed from the editor. - # - # * `callback` {Function} - # * `name` The name of the {Gutter} that was removed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveGutter: (callback) -> - @gutterContainer.onDidRemoveGutter callback - - # Set the number of characters that can be displayed horizontally in the - # editor. - # - # * `editorWidthInChars` A {Number} representing the width of the {TextEditorView} - # in characters. - setEditorWidthInChars: (editorWidthInChars) -> - @displayBuffer.setEditorWidthInChars(editorWidthInChars) - - ### - Section: File Details - ### - - # Essential: Get the editor's title for display in other parts of the - # UI such as the tabs. - # - # If the editor's buffer is saved, its title is the file name. If it is - # unsaved, its title is "untitled". - # - # Returns a {String}. - getTitle: -> - if sessionPath = @getPath() - path.basename(sessionPath) - else - 'untitled' - - # Essential: Get the editor's long title for display in other parts of the UI - # such as the window title. - # - # If the editor's buffer is saved, its long title is formatted as - # " - ". If it is unsaved, its title is "untitled" - # - # Returns a {String}. - getLongTitle: -> - if sessionPath = @getPath() - fileName = path.basename(sessionPath) - directory = atom.project.relativize(path.dirname(sessionPath)) - directory = if directory.length > 0 then directory else path.basename(path.dirname(sessionPath)) - "#{fileName} - #{directory}" - else - 'untitled' - - # Essential: Returns the {String} path of this editor's text buffer. - getPath: -> @buffer.getPath() - - # Extended: Returns the {String} character set encoding of this editor's text - # buffer. - getEncoding: -> @buffer.getEncoding() - - # Extended: Set the character set encoding to use in this editor's text - # buffer. - # - # * `encoding` The {String} character set encoding name such as 'utf8' - setEncoding: (encoding) -> @buffer.setEncoding(encoding) - - # Essential: Returns {Boolean} `true` if this editor has been modified. - isModified: -> @buffer.isModified() - - # Essential: Returns {Boolean} `true` if this editor has no content. - isEmpty: -> @buffer.isEmpty() - - # Copies the current file path to the native clipboard. - copyPathToClipboard: -> - if filePath = @getPath() - atom.clipboard.write(filePath) - - ### - Section: File Operations - ### - - # Essential: Saves the editor's text buffer. - # - # See {TextBuffer::save} for more details. - save: -> @buffer.save() - - # Public: Saves the editor's text buffer as the given path. - # - # See {TextBuffer::saveAs} for more details. - # - # * `filePath` A {String} path. - saveAs: (filePath) -> @buffer.saveAs(filePath) - - # Determine whether the user should be prompted to save before closing - # this editor. - shouldPromptToSave: ({windowCloseRequested}={}) -> - if windowCloseRequested - @isModified() - else - @isModified() and not @buffer.hasMultipleEditors() - - checkoutHeadRevision: -> - if filePath = this.getPath() - atom.project.repositoryForDirectory(new Directory(path.dirname(filePath))) - .then (repository) => - repository?.checkoutHeadForEditor(this) - else - Promise.resolve(false) - - ### - Section: Reading Text - ### - - # Essential: Returns a {String} representing the entire contents of the editor. - getText: -> @buffer.getText() - - # Essential: Get the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # - # Returns a {String}. - getTextInBufferRange: (range) -> - @buffer.getTextInRange(range) - - # Essential: Returns a {Number} representing the number of lines in the buffer. - getLineCount: -> @buffer.getLineCount() - - # Essential: Returns a {Number} representing the number of screen lines in the - # editor. This accounts for folds. - getScreenLineCount: -> @displayBuffer.getLineCount() - - # Essential: Returns a {Number} representing the last zero-indexed buffer row - # number of the editor. - getLastBufferRow: -> @buffer.getLastRow() - - # Essential: Returns a {Number} representing the last zero-indexed screen row - # number of the editor. - getLastScreenRow: -> @displayBuffer.getLastRow() - - # Essential: Returns a {String} representing the contents of the line at the - # given buffer row. - # - # * `bufferRow` A {Number} representing a zero-indexed buffer row. - lineTextForBufferRow: (bufferRow) -> @buffer.lineForRow(bufferRow) - - # Essential: Returns a {String} representing the contents of the line at the - # given screen row. - # - # * `screenRow` A {Number} representing a zero-indexed screen row. - lineTextForScreenRow: (screenRow) -> @displayBuffer.tokenizedLineForScreenRow(screenRow)?.text - - # Gets the screen line for the given screen row. - # - # * `screenRow` - A {Number} indicating the screen row. - # - # Returns {TokenizedLine} - tokenizedLineForScreenRow: (screenRow) -> @displayBuffer.tokenizedLineForScreenRow(screenRow) - - # {Delegates to: DisplayBuffer.tokenizedLinesForScreenRows} - tokenizedLinesForScreenRows: (start, end) -> @displayBuffer.tokenizedLinesForScreenRows(start, end) - - bufferRowForScreenRow: (row) -> @displayBuffer.bufferRowForScreenRow(row) - - # {Delegates to: DisplayBuffer.bufferRowsForScreenRows} - bufferRowsForScreenRows: (startRow, endRow) -> @displayBuffer.bufferRowsForScreenRows(startRow, endRow) - - screenRowForBufferRow: (row) -> @displayBuffer.screenRowForBufferRow(row) - - # {Delegates to: DisplayBuffer.getMaxLineLength} - getMaxScreenLineLength: -> @displayBuffer.getMaxLineLength() - - getLongestScreenRow: -> @displayBuffer.getLongestScreenRow() - - # Returns the range for the given buffer row. - # - # * `row` A row {Number}. - # * `options` (optional) An options hash with an `includeNewline` key. - # - # Returns a {Range}. - bufferRangeForBufferRow: (row, {includeNewline}={}) -> @buffer.rangeForRow(row, includeNewline) - - # Get the text in the given {Range}. - # - # Returns a {String}. - getTextInRange: (range) -> @buffer.getTextInRange(range) - - # {Delegates to: TextBuffer.isRowBlank} - isBufferRowBlank: (bufferRow) -> @buffer.isRowBlank(bufferRow) - - # {Delegates to: TextBuffer.nextNonBlankRow} - nextNonBlankBufferRow: (bufferRow) -> @buffer.nextNonBlankRow(bufferRow) - - # {Delegates to: TextBuffer.getEndPosition} - getEofBufferPosition: -> @buffer.getEndPosition() - - # Public: Get the {Range} of the paragraph surrounding the most recently added - # cursor. - # - # Returns a {Range}. - getCurrentParagraphBufferRange: -> - @getLastCursor().getCurrentParagraphBufferRange() - - - ### - Section: Mutating Text - ### - - # Essential: Replaces the entire contents of the buffer with the given {String}. - setText: (text) -> @buffer.setText(text) - - # Essential: Set the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # * `text` A {String} - # * `options` (optional) {Object} - # * `normalizeLineEndings` (optional) {Boolean} (default: true) - # * `undo` (optional) {String} 'skip' will skip the undo system - # - # Returns the {Range} of the newly-inserted text. - setTextInBufferRange: (range, text, options) -> @getBuffer().setTextInRange(range, text, options) - - # Essential: For each selection, replace the selected text with the given text. - # - # * `text` A {String} representing the text to insert. - # * `options` (optional) See {Selection::insertText}. - # - # Returns a {Range} when the text has been inserted - # Returns a {Boolean} false when the text has not been inserted - insertText: (text, options={}) -> - willInsert = true - cancel = -> willInsert = false - willInsertEvent = {cancel, text} - @emit('will-insert-text', willInsertEvent) if includeDeprecatedAPIs - @emitter.emit 'will-insert-text', willInsertEvent - - if willInsert - options.autoIndentNewline ?= @shouldAutoIndent() - options.autoDecreaseIndent ?= @shouldAutoIndent() - @mutateSelectedText (selection) => - range = selection.insertText(text, options) - didInsertEvent = {text, range} - @emit('did-insert-text', didInsertEvent) if includeDeprecatedAPIs - @emitter.emit 'did-insert-text', didInsertEvent - range - else - false - - # Essential: For each selection, replace the selected text with a newline. - insertNewline: -> - @insertText('\n') - - # Essential: For each selection, if the selection is empty, delete the character - # following the cursor. Otherwise delete the selected text. - delete: -> - @mutateSelectedText (selection) -> selection.delete() - - # Essential: For each selection, if the selection is empty, delete the character - # preceding the cursor. Otherwise delete the selected text. - backspace: -> - @mutateSelectedText (selection) -> selection.backspace() - - # Extended: Mutate the text of all the selections in a single transaction. - # - # All the changes made inside the given {Function} can be reverted with a - # single call to {::undo}. - # - # * `fn` A {Function} that will be called once for each {Selection}. The first - # argument will be a {Selection} and the second argument will be the - # {Number} index of that selection. - mutateSelectedText: (fn) -> - @mergeIntersectingSelections => - @transact => - fn(selection, index) for selection, index in @getSelectionsOrderedByBufferPosition() - - # Move lines intersection the most recent selection up by one row in screen - # coordinates. - moveLineUp: -> - selection = @getSelectedBufferRange() - return if selection.start.row is 0 - lastRow = @buffer.getLastRow() - return if selection.isEmpty() and selection.start.row is lastRow and @buffer.getLastLine() is '' - - @transact => - foldedRows = [] - rows = [selection.start.row..selection.end.row] - if selection.start.row isnt selection.end.row and selection.end.column is 0 - rows.pop() unless @isFoldedAtBufferRow(selection.end.row) - - # Move line around the fold that is directly above the selection - precedingScreenRow = @screenPositionForBufferPosition([selection.start.row]).translate([-1]) - precedingBufferRow = @bufferPositionForScreenPosition(precedingScreenRow).row - if fold = @largestFoldContainingBufferRow(precedingBufferRow) - insertDelta = fold.getBufferRange().getRowCount() - else - insertDelta = 1 - - for row in rows - if fold = @displayBuffer.largestFoldStartingAtBufferRow(row) - bufferRange = fold.getBufferRange() - startRow = bufferRange.start.row - endRow = bufferRange.end.row - foldedRows.push(startRow - insertDelta) - else - startRow = row - endRow = row - - insertPosition = Point.fromObject([startRow - insertDelta]) - endPosition = Point.min([endRow + 1], @buffer.getEndPosition()) - lines = @buffer.getTextInRange([[startRow], endPosition]) - if endPosition.row is lastRow and endPosition.column > 0 and not @buffer.lineEndingForRow(endPosition.row) - lines = "#{lines}\n" - - @buffer.deleteRows(startRow, endRow) - - # Make sure the inserted text doesn't go into an existing fold - if fold = @displayBuffer.largestFoldStartingAtBufferRow(insertPosition.row) - @unfoldBufferRow(insertPosition.row) - foldedRows.push(insertPosition.row + endRow - startRow + fold.getBufferRange().getRowCount()) - - @buffer.insert(insertPosition, lines) - - # Restore folds that existed before the lines were moved - for foldedRow in foldedRows when 0 <= foldedRow <= @getLastBufferRow() - @foldBufferRow(foldedRow) - - @setSelectedBufferRange(selection.translate([-insertDelta]), preserveFolds: true, autoscroll: true) - - # Move lines intersecting the most recent selection down by one row in screen - # coordinates. - moveLineDown: -> - selection = @getSelectedBufferRange() - lastRow = @buffer.getLastRow() - return if selection.end.row is lastRow - return if selection.end.row is lastRow - 1 and @buffer.getLastLine() is '' - - @transact => - foldedRows = [] - rows = [selection.end.row..selection.start.row] - if selection.start.row isnt selection.end.row and selection.end.column is 0 - rows.shift() unless @isFoldedAtBufferRow(selection.end.row) - - # Move line around the fold that is directly below the selection - followingScreenRow = @screenPositionForBufferPosition([selection.end.row]).translate([1]) - followingBufferRow = @bufferPositionForScreenPosition(followingScreenRow).row - if fold = @largestFoldContainingBufferRow(followingBufferRow) - insertDelta = fold.getBufferRange().getRowCount() - else - insertDelta = 1 - - for row in rows - if fold = @displayBuffer.largestFoldStartingAtBufferRow(row) - bufferRange = fold.getBufferRange() - startRow = bufferRange.start.row - endRow = bufferRange.end.row - foldedRows.push(endRow + insertDelta) - else - startRow = row - endRow = row - - if endRow + 1 is lastRow - endPosition = [endRow, @buffer.lineLengthForRow(endRow)] - else - endPosition = [endRow + 1] - lines = @buffer.getTextInRange([[startRow], endPosition]) - @buffer.deleteRows(startRow, endRow) - - insertPosition = Point.min([startRow + insertDelta], @buffer.getEndPosition()) - if insertPosition.row is @buffer.getLastRow() and insertPosition.column > 0 - lines = "\n#{lines}" - - # Make sure the inserted text doesn't go into an existing fold - if fold = @displayBuffer.largestFoldStartingAtBufferRow(insertPosition.row) - @unfoldBufferRow(insertPosition.row) - foldedRows.push(insertPosition.row + fold.getBufferRange().getRowCount()) - - @buffer.insert(insertPosition, lines) - - # Restore folds that existed before the lines were moved - for foldedRow in foldedRows when 0 <= foldedRow <= @getLastBufferRow() - @foldBufferRow(foldedRow) - - @setSelectedBufferRange(selection.translate([insertDelta]), preserveFolds: true, autoscroll: true) - - # Duplicate the most recent cursor's current line. - duplicateLines: -> - @transact => - for selection in @getSelectionsOrderedByBufferPosition().reverse() - selectedBufferRange = selection.getBufferRange() - if selection.isEmpty() - {start} = selection.getScreenRange() - selection.selectToScreenPosition([start.row + 1, 0]) - - [startRow, endRow] = selection.getBufferRowRange() - endRow++ - - foldedRowRanges = - @outermostFoldsInBufferRowRange(startRow, endRow) - .map (fold) -> fold.getBufferRowRange() - - rangeToDuplicate = [[startRow, 0], [endRow, 0]] - textToDuplicate = @getTextInBufferRange(rangeToDuplicate) - textToDuplicate = '\n' + textToDuplicate if endRow > @getLastBufferRow() - @buffer.insert([endRow, 0], textToDuplicate) - - delta = endRow - startRow - selection.setBufferRange(selectedBufferRange.translate([delta, 0])) - for [foldStartRow, foldEndRow] in foldedRowRanges - @createFold(foldStartRow + delta, foldEndRow + delta) - return - - replaceSelectedText: (options={}, fn) -> - {selectWordIfEmpty} = options - @mutateSelectedText (selection) -> - range = selection.getBufferRange() - if selectWordIfEmpty and selection.isEmpty() - selection.selectWord() - text = selection.getText() - selection.deleteSelectedText() - selection.insertText(fn(text)) - selection.setBufferRange(range) - - # Split multi-line selections into one selection per line. - # - # Operates on all selections. This method breaks apart all multi-line - # selections to create multiple single-line selections that cumulatively cover - # the same original area. - splitSelectionsIntoLines: -> - @mergeIntersectingSelections => - for selection in @getSelections() - range = selection.getBufferRange() - continue if range.isSingleLine() - - selection.destroy() - {start, end} = range - @addSelectionForBufferRange([start, [start.row, Infinity]]) - {row} = start - while ++row < end.row - @addSelectionForBufferRange([[row, 0], [row, Infinity]]) - @addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) unless end.column is 0 - return - - # Extended: For each selection, transpose the selected text. - # - # If the selection is empty, the characters preceding and following the cursor - # are swapped. Otherwise, the selected characters are reversed. - transpose: -> - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectRight() - text = selection.getText() - selection.delete() - selection.cursor.moveLeft() - selection.insertText text - else - selection.insertText selection.getText().split('').reverse().join('') - - # Extended: Convert the selected text to upper case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - upperCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toUpperCase() - - # Extended: Convert the selected text to lower case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - lowerCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toLowerCase() - - # Extended: Toggle line comments for rows intersecting selections. - # - # If the current grammar doesn't support comments, does nothing. - toggleLineCommentsInSelection: -> - @mutateSelectedText (selection) -> selection.toggleLineComments() - - # Convert multiple lines to a single line. - # - # Operates on all selections. If the selection is empty, joins the current - # line with the next line. Otherwise it joins all lines that intersect the - # selection. - # - # Joining a line means that multiple lines are converted to a single line with - # the contents of each of the original non-empty lines separated by a space. - joinLines: -> - @mutateSelectedText (selection) -> selection.joinLines() - - # Extended: For each cursor, insert a newline at beginning the following line. - insertNewlineBelow: -> - @transact => - @moveToEndOfLine() - @insertNewline() - - # Extended: For each cursor, insert a newline at the end of the preceding line. - insertNewlineAbove: -> - @transact => - bufferRow = @getCursorBufferPosition().row - indentLevel = @indentationForBufferRow(bufferRow) - onFirstLine = bufferRow is 0 - - @moveToBeginningOfLine() - @moveLeft() - @insertNewline() - - if @shouldAutoIndent() and @indentationForBufferRow(bufferRow) < indentLevel - @setIndentationForBufferRow(bufferRow, indentLevel) - - if onFirstLine - @moveUp() - @moveToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfWord() - - # Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the - # previous word boundary. - deleteToPreviousWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToPreviousWordBoundary() - - # Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the - # next word boundary. - deleteToNextWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToNextWordBoundary() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing line that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfLine() - - # Extended: For each selection, if the selection is not empty, deletes the - # selection; otherwise, deletes all characters of the containing line - # following the cursor. If the cursor is already at the end of the line, - # deletes the following newline. - deleteToEndOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word following the cursor. Otherwise delete the selected - # text. - deleteToEndOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfWord() - - # Extended: Delete all lines intersecting selections. - deleteLine: -> - @mergeSelectionsOnSameRows() - @mutateSelectedText (selection) -> selection.deleteLine() - - ### - Section: History - ### - - # Essential: Undo the last change. - undo: -> - @buffer.undo() - @getLastSelection().autoscroll() - - # Essential: Redo the last change. - redo: -> - @buffer.redo(this) - @getLastSelection().autoscroll() - - # Extended: Batch multiple operations as a single undo/redo step. - # - # Any group of operations that are logically grouped from the perspective of - # undoing and redoing should be performed in a transaction. If you want to - # abort the transaction, call {::abortTransaction} to terminate the function's - # execution and revert any changes performed up to the abortion. - # - # * `groupingInterval` (optional) The {Number} of milliseconds for which this - # transaction should be considered 'groupable' after it begins. If a transaction - # with a positive `groupingInterval` is committed while the previous transaction is - # still 'groupable', the two transactions are merged with respect to undo and redo. - # * `fn` A {Function} to call inside the transaction. - transact: (groupingInterval, fn) -> - @buffer.transact(groupingInterval, fn) - - # Deprecated: Start an open-ended transaction. - beginTransaction: (groupingInterval) -> @buffer.beginTransaction(groupingInterval) - - # Deprecated: Commit an open-ended transaction started with {::beginTransaction}. - commitTransaction: -> @buffer.commitTransaction() - - # Extended: Abort an open transaction, undoing any operations performed so far - # within the transaction. - abortTransaction: -> @buffer.abortTransaction() - - # Extended: Create a pointer to the current state of the buffer for use - # with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. - # - # Returns a checkpoint value. - createCheckpoint: -> @buffer.createCheckpoint() - - # Extended: Revert the buffer to the state it was in when the given - # checkpoint was created. - # - # The redo stack will be empty following this operation, so changes since the - # checkpoint will be lost. If the given checkpoint is no longer present in the - # undo history, no changes will be made to the buffer and this method will - # return `false`. - # - # Returns a {Boolean} indicating whether the operation succeeded. - revertToCheckpoint: (checkpoint) -> @buffer.revertToCheckpoint(checkpoint) - - # Extended: Group all changes since the given checkpoint into a single - # transaction for purposes of undo/redo. - # - # If the given checkpoint is no longer present in the undo history, no - # grouping will be performed and this method will return `false`. - # - # Returns a {Boolean} indicating whether the operation succeeded. - groupChangesSinceCheckpoint: (checkpoint) -> @buffer.groupChangesSinceCheckpoint(checkpoint) - - ### - Section: TextEditor Coordinates - ### - - # Essential: Convert a position in buffer-coordinates to screen-coordinates. - # - # The position is clipped via {::clipBufferPosition} prior to the conversion. - # The position is also clipped via {::clipScreenPosition} following the - # conversion, which only makes a difference when `options` are supplied. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - screenPositionForBufferPosition: (bufferPosition, options) -> @displayBuffer.screenPositionForBufferPosition(bufferPosition, options) - - # Essential: Convert a position in screen-coordinates to buffer-coordinates. - # - # The position is clipped via {::clipScreenPosition} prior to the conversion. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - bufferPositionForScreenPosition: (screenPosition, options) -> @displayBuffer.bufferPositionForScreenPosition(screenPosition, options) - - # Essential: Convert a range in buffer-coordinates to screen-coordinates. - # - # * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. - # - # Returns a {Range}. - screenRangeForBufferRange: (bufferRange) -> @displayBuffer.screenRangeForBufferRange(bufferRange) - - # Essential: Convert a range in screen-coordinates to buffer-coordinates. - # - # * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. - # - # Returns a {Range}. - bufferRangeForScreenRange: (screenRange) -> @displayBuffer.bufferRangeForScreenRange(screenRange) - - # Extended: Clip the given {Point} to a valid position in the buffer. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the buffer, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at buffer row 2 is 10 characters long - # editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `bufferPosition` The {Point} representing the position to clip. - # - # Returns a {Point}. - clipBufferPosition: (bufferPosition) -> @buffer.clipPosition(bufferPosition) - - # Extended: Clip the start and end of the given range to valid positions in the - # buffer. See {::clipBufferPosition} for more information. - # - # * `range` The {Range} to clip. - # - # Returns a {Range}. - clipBufferRange: (range) -> @buffer.clipRange(range) - - # Extended: Clip the given {Point} to a valid position on screen. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the screen, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at screen row 2 is 10 characters long - # editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `screenPosition` The {Point} representing the position to clip. - # * `options` (optional) {Object} - # * `wrapBeyondNewlines` {Boolean} if `true`, continues wrapping past newlines - # * `wrapAtSoftNewlines` {Boolean} if `true`, continues wrapping past soft newlines - # * `screenLine` {Boolean} if `true`, indicates that you're using a line number, not a row number - # - # Returns a {Point}. - clipScreenPosition: (screenPosition, options) -> @displayBuffer.clipScreenPosition(screenPosition, options) - - # Extended: Clip the start and end of the given range to valid positions on screen. - # See {::clipScreenPosition} for more information. - # - # * `range` The {Range} to clip. - # * `options` (optional) See {::clipScreenPosition} `options`. - # Returns a {Range}. - clipScreenRange: (range, options) -> @displayBuffer.clipScreenRange(range, options) - - ### - Section: Decorations - ### - - # Essential: Adds a decoration that tracks a {Marker}. When the marker moves, - # is invalidated, or is destroyed, the decoration will be updated to reflect - # the marker's state. - # - # There are three types of supported decorations: - # - # * __line__: Adds your CSS `class` to the line nodes within the range - # marked by the marker - # * __gutter__: Adds your CSS `class` to the line number nodes within the - # range marked by the marker - # * __highlight__: Adds a new highlight div to the editor surrounding the - # range marked by the marker. When the user selects text, the selection is - # visualized with a highlight decoration internally. The structure of this - # highlight will be - # ```html - #
    - # - #
    - #
    - # ``` - # - # ## Arguments - # - # * `marker` A {Marker} you want this decoration to follow. - # * `decorationParams` An {Object} representing the decoration e.g. - # `{type: 'line-number', class: 'linter-error'}` - # * `type` There are a few supported decoration types: `gutter`, `line`, - # `highlight`, and `overlay`. The behavior of the types are as follows: - # * `gutter` Adds the given `class` to the line numbers overlapping the - # rows spanned by the marker. - # * `line` Adds the given `class` to the lines overlapping the rows - # spanned by the marker. - # * `highlight` Creates a `.highlight` div with the nested class with up - # to 3 nested regions that fill the area spanned by the marker. - # * `overlay` Positions the view associated with the given item at the - # head or tail of the given marker, depending on the `position` - # property. - # * `class` This CSS class will be applied to the decorated line number, - # line, or highlight. - # * `onlyHead` (optional) If `true`, the decoration will only be applied to - # the head of the marker. Only applicable to the `line` and `gutter` - # types. - # * `onlyEmpty` (optional) If `true`, the decoration will only be applied if - # the associated marker is empty. Only applicable to the `line` and - # `gutter` types. - # * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied - # if the associated marker is non-empty. Only applicable to the `line` - # and gutter types. - # * `position` (optional) Only applicable to decorations of type `overlay`, - # controls where the overlay view is positioned relative to the marker. - # Values can be `'head'` (the default), or `'tail'`. - # * `gutterName` (optional) Only applicable to the `gutter` type. If provided, - # the decoration will be applied to the gutter with the specified name. - # - # Returns a {Decoration} object - decorateMarker: (marker, decorationParams) -> - if includeDeprecatedAPIs and decorationParams.type is 'gutter' and not decorationParams.gutterName - deprecate("Decorations of `type: 'gutter'` have been renamed to `type: 'line-number'`.") - decorationParams.type = 'line-number' - @displayBuffer.decorateMarker(marker, decorationParams) - - # Public: Get all the decorations within a screen row range. - # - # * `startScreenRow` the {Number} beginning screen row - # * `endScreenRow` the {Number} end screen row (inclusive) - # - # Returns an {Object} of decorations in the form - # `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` - # where the keys are {Marker} IDs, and the values are an array of decoration - # params objects attached to the marker. - # Returns an empty object when no decorations are found - decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> - @displayBuffer.decorationsForScreenRowRange(startScreenRow, endScreenRow) - - # Extended: Get all decorations. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getDecorations: (propertyFilter) -> - @displayBuffer.getDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineDecorations: (propertyFilter) -> - @displayBuffer.getLineDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line-number'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineNumberDecorations: (propertyFilter) -> - @displayBuffer.getLineNumberDecorations(propertyFilter) - - # Extended: Get all decorations of type 'highlight'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getHighlightDecorations: (propertyFilter) -> - @displayBuffer.getHighlightDecorations(propertyFilter) - - # Extended: Get all decorations of type 'overlay'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getOverlayDecorations: (propertyFilter) -> - @displayBuffer.getOverlayDecorations(propertyFilter) - - decorationForId: (id) -> - @displayBuffer.decorationForId(id) - - ### - Section: Markers - ### - - # Essential: Create a marker with the given range in buffer coordinates. This - # marker will maintain its logical location as the buffer is changed, so if - # you mark a particular word, the marker will remain over that word even if - # the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `reversed` (optional) Creates the marker in a reversed orientation. (default: false) - # * `persistent` (optional) Whether to include this marker when serializing the buffer. (default: true) - # * `invalidate` (optional) Determines the rules by which changes to the - # buffer *invalidate* the marker. (default: 'overlap') It can be any of - # the following strategies, in order of fragility - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {Marker}. - markBufferRange: (args...) -> - @displayBuffer.markBufferRange(args...) - - # Essential: Create a marker with the given range in screen coordinates. This - # marker will maintain its logical location as the buffer is changed, so if - # you mark a particular word, the marker will remain over that word even if - # the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `reversed` (optional) Creates the marker in a reversed orientation. (default: false) - # * `persistent` (optional) Whether to include this marker when serializing the buffer. (default: true) - # * `invalidate` (optional) Determines the rules by which changes to the - # buffer *invalidate* the marker. (default: 'overlap') It can be any of - # the following strategies, in order of fragility - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {Marker}. - markScreenRange: (args...) -> - @displayBuffer.markScreenRange(args...) - - # Essential: Mark the given position in buffer coordinates. - # - # * `position` A {Point} or {Array} of `[row, column]`. - # * `options` (optional) See {TextBuffer::markRange}. - # - # Returns a {Marker}. - markBufferPosition: (args...) -> - @displayBuffer.markBufferPosition(args...) - - # Essential: Mark the given position in screen coordinates. - # - # * `position` A {Point} or {Array} of `[row, column]`. - # * `options` (optional) See {TextBuffer::markRange}. - # - # Returns a {Marker}. - markScreenPosition: (args...) -> - @displayBuffer.markScreenPosition(args...) - - # Essential: Find all {Marker}s that match the given properties. - # - # This method finds markers based on the given properties. Markers can be - # associated with custom properties that will be compared with basic equality. - # In addition, there are several special properties that will be compared - # with the range of the markers rather than their properties. - # - # * `properties` An {Object} containing properties that each returned marker - # must satisfy. Markers can be associated with custom properties, which are - # compared with basic equality. In addition, several reserved properties - # can be used to filter markers based on their current range: - # * `startBufferRow` Only include markers starting at this row in buffer - # coordinates. - # * `endBufferRow` Only include markers ending at this row in buffer - # coordinates. - # * `containsBufferRange` Only include markers containing this {Range} or - # in range-compatible {Array} in buffer coordinates. - # * `containsBufferPosition` Only include markers containing this {Point} - # or {Array} of `[row, column]` in buffer coordinates. - findMarkers: (properties) -> - @displayBuffer.findMarkers(properties) - - # Extended: Get the {Marker} for the given marker id. - # - # * `id` {Number} id of the marker - getMarker: (id) -> - @displayBuffer.getMarker(id) - - # Extended: Get all {Marker}s. Consider using {::findMarkers} - getMarkers: -> - @displayBuffer.getMarkers() - - # Extended: Get the number of markers in this editor's buffer. - # - # Returns a {Number}. - getMarkerCount: -> - @buffer.getMarkerCount() - - # {Delegates to: DisplayBuffer.destroyMarker} - destroyMarker: (args...) -> - @displayBuffer.destroyMarker(args...) - - ### - Section: Cursors - ### - - # Essential: Get the position of the most recently added cursor in buffer - # coordinates. - # - # Returns a {Point} - getCursorBufferPosition: -> - @getLastCursor().getBufferPosition() - - # Essential: Get the position of all the cursor positions in buffer coordinates. - # - # Returns {Array} of {Point}s in the order they were added - getCursorBufferPositions: -> - cursor.getBufferPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in buffer coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorBufferPosition: (position, options) -> - @moveCursors (cursor) -> cursor.setBufferPosition(position, options) - - # Essential: Get the position of the most recently added cursor in screen - # coordinates. - # - # Returns a {Point}. - getCursorScreenPosition: -> - @getLastCursor().getScreenPosition() - - # Essential: Get the position of all the cursor positions in screen coordinates. - # - # Returns {Array} of {Point}s in the order the cursors were added - getCursorScreenPositions: -> - cursor.getScreenPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in screen coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorScreenPosition: (position, options) -> - @moveCursors (cursor) -> cursor.setScreenPosition(position, options) - - # Essential: Add a cursor at the given position in buffer coordinates. - # - # * `bufferPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtBufferPosition: (bufferPosition, options) -> - @markBufferPosition(bufferPosition, @getSelectionMarkerAttributes()) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Add a cursor at the position in screen coordinates. - # - # * `screenPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtScreenPosition: (screenPosition, options) -> - @markScreenPosition(screenPosition, @getSelectionMarkerAttributes()) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Returns {Boolean} indicating whether or not there are multiple cursors. - hasMultipleCursors: -> - @getCursors().length > 1 - - # Essential: Move every cursor up one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveUp: (lineCount) -> - @moveCursors (cursor) -> cursor.moveUp(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor down one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveDown: (lineCount) -> - @moveCursors (cursor) -> cursor.moveDown(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor left one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveLeft: (columnCount) -> - @moveCursors (cursor) -> cursor.moveLeft(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor right one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveRight: (columnCount) -> - @moveCursors (cursor) -> cursor.moveRight(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor to the beginning of its line in buffer coordinates. - moveToBeginningOfLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfLine() - - # Essential: Move every cursor to the beginning of its line in screen coordinates. - moveToBeginningOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfScreenLine() - - # Essential: Move every cursor to the first non-whitespace character of its line. - moveToFirstCharacterOfLine: -> - @moveCursors (cursor) -> cursor.moveToFirstCharacterOfLine() - - # Essential: Move every cursor to the end of its line in buffer coordinates. - moveToEndOfLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfLine() - - # Essential: Move every cursor to the end of its line in screen coordinates. - moveToEndOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfScreenLine() - - # Essential: Move every cursor to the beginning of its surrounding word. - moveToBeginningOfWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfWord() - - # Essential: Move every cursor to the end of its surrounding word. - moveToEndOfWord: -> - @moveCursors (cursor) -> cursor.moveToEndOfWord() - - # Cursor Extended - - # Extended: Move every cursor to the top of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToTop: -> - @moveCursors (cursor) -> cursor.moveToTop() - - # Extended: Move every cursor to the bottom of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToBottom: -> - @moveCursors (cursor) -> cursor.moveToBottom() - - # Extended: Move every cursor to the beginning of the next word. - moveToBeginningOfNextWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextWord() - - # Extended: Move every cursor to the previous word boundary. - moveToPreviousWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToPreviousWordBoundary() - - # Extended: Move every cursor to the next word boundary. - moveToNextWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToNextWordBoundary() - - # Extended: Move every cursor to the beginning of the next paragraph. - moveToBeginningOfNextParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextParagraph() - - # Extended: Move every cursor to the beginning of the previous paragraph. - moveToBeginningOfPreviousParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfPreviousParagraph() - - # Extended: Returns the most recently added {Cursor} - getLastCursor: -> - _.last(@cursors) - - # Extended: Returns the word surrounding the most recently added cursor. - # - # * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. - getWordUnderCursor: (options) -> - @getTextInBufferRange(@getLastCursor().getCurrentWordBufferRange(options)) - - # Extended: Get an Array of all {Cursor}s. - getCursors: -> - @cursors.slice() - - # Extended: Get all {Cursors}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getCursorsOrderedByBufferPosition: -> - @getCursors().sort (a, b) -> a.compare(b) - - # Add a cursor based on the given {Marker}. - addCursor: (marker) -> - cursor = new Cursor(editor: this, marker: marker) - @cursors.push(cursor) - @decorateMarker(marker, type: 'line-number', class: 'cursor-line') - @decorateMarker(marker, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) - @decorateMarker(marker, type: 'line', class: 'cursor-line', onlyEmpty: true) - @emit 'cursor-added', cursor if includeDeprecatedAPIs - @emitter.emit 'did-add-cursor', cursor - cursor - - # Remove the given cursor from this editor. - removeCursor: (cursor) -> - _.remove(@cursors, cursor) - @emit 'cursor-removed', cursor if includeDeprecatedAPIs - @emitter.emit 'did-remove-cursor', cursor - - moveCursors: (fn) -> - fn(cursor) for cursor in @getCursors() - @mergeCursors() - - cursorMoved: (event) -> - @emit 'cursor-moved', event if includeDeprecatedAPIs - @emitter.emit 'did-change-cursor-position', event - - # Merge cursors that have the same screen position - mergeCursors: -> - positions = {} - for cursor in @getCursors() - position = cursor.getBufferPosition().toString() - if positions.hasOwnProperty(position) - cursor.destroy() - else - positions[position] = true - return - - preserveCursorPositionOnBufferReload: -> - cursorPosition = null - @disposables.add @buffer.onWillReload => - cursorPosition = @getCursorBufferPosition() - @disposables.add @buffer.onDidReload => - @setCursorBufferPosition(cursorPosition) if cursorPosition - cursorPosition = null - - ### - Section: Selections - ### - - # Essential: Get the selected text of the most recently added selection. - # - # Returns a {String}. - getSelectedText: -> - @getLastSelection().getText() - - # Essential: Get the {Range} of the most recently added selection in buffer - # coordinates. - # - # Returns a {Range}. - getSelectedBufferRange: -> - @getLastSelection().getBufferRange() - - # Essential: Get the {Range}s of all selections in buffer coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedBufferRanges: -> - selection.getBufferRange() for selection in @getSelections() - - # Essential: Set the selected range in buffer coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedBufferRange: (bufferRange, options) -> - @setSelectedBufferRanges([bufferRange], options) - - # Essential: Set the selected ranges in buffer coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedBufferRanges: (bufferRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedBufferRanges") unless bufferRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[bufferRanges.length...] - - @mergeIntersectingSelections options, => - for bufferRange, i in bufferRanges - bufferRange = Range.fromObject(bufferRange) - if selections[i] - selections[i].setBufferRange(bufferRange, options) - else - @addSelectionForBufferRange(bufferRange, options) - return - - # Essential: Get the {Range} of the most recently added selection in screen - # coordinates. - # - # Returns a {Range}. - getSelectedScreenRange: -> - @getLastSelection().getScreenRange() - - # Essential: Get the {Range}s of all selections in screen coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedScreenRanges: -> - selection.getScreenRange() for selection in @getSelections() - - # Essential: Set the selected range in screen coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `screenRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRange: (screenRange, options) -> - @setSelectedBufferRange(@bufferRangeForScreenRange(screenRange, options), options) - - # Essential: Set the selected ranges in screen coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRanges: (screenRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedScreenRanges") unless screenRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[screenRanges.length...] - - @mergeIntersectingSelections options, => - for screenRange, i in screenRanges - screenRange = Range.fromObject(screenRange) - if selections[i] - selections[i].setScreenRange(screenRange, options) - else - @addSelectionForScreenRange(screenRange, options) - return - - # Essential: Add a selection for the given range in buffer coordinates. - # - # * `bufferRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # - # Returns the added {Selection}. - addSelectionForBufferRange: (bufferRange, options={}) -> - @markBufferRange(bufferRange, _.defaults(@getSelectionMarkerAttributes(), options)) - @getLastSelection().autoscroll() unless options.autoscroll is false - @getLastSelection() - - # Essential: Add a selection for the given range in screen coordinates. - # - # * `screenRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # - # Returns the added {Selection}. - addSelectionForScreenRange: (screenRange, options={}) -> - @markScreenRange(screenRange, _.defaults(@getSelectionMarkerAttributes(), options)) - @getLastSelection().autoscroll() unless options.autoscroll is false - @getLastSelection() - - # Essential: Select from the current cursor position to the given position in - # buffer coordinates. - # - # This method may merge selections that end up intesecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToBufferPosition: (position) -> - lastSelection = @getLastSelection() - lastSelection.selectToBufferPosition(position) - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Select from the current cursor position to the given position in - # screen coordinates. - # - # This method may merge selections that end up intesecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToScreenPosition: (position) -> - lastSelection = @getLastSelection() - lastSelection.selectToScreenPosition(position) - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Move the cursor of each selection one character upward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intesecting. - selectUp: (rowCount) -> - @expandSelectionsBackward (selection) -> selection.selectUp(rowCount) - - # Essential: Move the cursor of each selection one character downward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intesecting. - selectDown: (rowCount) -> - @expandSelectionsForward (selection) -> selection.selectDown(rowCount) - - # Essential: Move the cursor of each selection one character leftward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intesecting. - selectLeft: (columnCount) -> - @expandSelectionsBackward (selection) -> selection.selectLeft(columnCount) - - # Essential: Move the cursor of each selection one character rightward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intesecting. - selectRight: (columnCount) -> - @expandSelectionsForward (selection) -> selection.selectRight(columnCount) - - # Essential: Select from the top of the buffer to the end of the last selection - # in the buffer. - # - # This method merges multiple selections into a single selection. - selectToTop: -> - @expandSelectionsBackward (selection) -> selection.selectToTop() - - # Essential: Selects from the top of the first selection in the buffer to the end - # of the buffer. - # - # This method merges multiple selections into a single selection. - selectToBottom: -> - @expandSelectionsForward (selection) -> selection.selectToBottom() - - # Essential: Select all text in the buffer. - # - # This method merges multiple selections into a single selection. - selectAll: -> - @expandSelectionsForward (selection) -> selection.selectAll() - - # Essential: Move the cursor of each selection to the beginning of its line - # while preserving the selection's tail position. - # - # This method may merge selections that end up intesecting. - selectToBeginningOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfLine() - - # Essential: Move the cursor of each selection to the first non-whitespace - # character of its line while preserving the selection's tail position. If the - # cursor is already on the first character of the line, move it to the - # beginning of the line. - # - # This method may merge selections that end up intersecting. - selectToFirstCharacterOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToFirstCharacterOfLine() - - # Essential: Move the cursor of each selection to the end of its line while - # preserving the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToEndOfLine: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfLine() - - # Essential: Expand selections to the beginning of their containing word. - # - # Operates on all selections. Moves the cursor to the beginning of the - # containing word while preserving the selection's tail position. - selectToBeginningOfWord: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfWord() - - # Essential: Expand selections to the end of their containing word. - # - # Operates on all selections. Moves the cursor to the end of the containing - # word while preserving the selection's tail position. - selectToEndOfWord: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfWord() - - # Essential: For each cursor, select the containing line. - # - # This method merges selections on successive lines. - selectLinesContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectLine() - - # Essential: Select the word surrounding each cursor. - selectWordsContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectWord() - - # Selection Extended - - # Extended: For each selection, move its cursor to the preceding word boundary - # while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToPreviousWordBoundary: -> - @expandSelectionsBackward (selection) -> selection.selectToPreviousWordBoundary() - - # Extended: For each selection, move its cursor to the next word boundary while - # maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToNextWordBoundary: -> - @expandSelectionsForward (selection) -> selection.selectToNextWordBoundary() - - # Extended: Expand selections to the beginning of the next word. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # word while preserving the selection's tail position. - selectToBeginningOfNextWord: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextWord() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfNextParagraph: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextParagraph() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfPreviousParagraph: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfPreviousParagraph() - - # Extended: Select the range of the given marker if it is valid. - # - # * `marker` A {Marker} - # - # Returns the selected {Range} or `undefined` if the marker is invalid. - selectMarker: (marker) -> - if marker.isValid() - range = marker.getBufferRange() - @setSelectedBufferRange(range) - range - - # Extended: Get the most recently added {Selection}. - # - # Returns a {Selection}. - getLastSelection: -> - _.last(@selections) - - # Extended: Get current {Selection}s. - # - # Returns: An {Array} of {Selection}s. - getSelections: -> - @selections.slice() - - # Extended: Get all {Selection}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getSelectionsOrderedByBufferPosition: -> - @getSelections().sort (a, b) -> a.compare(b) - - # Extended: Determine if a given range in buffer coordinates intersects a - # selection. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # - # Returns a {Boolean}. - selectionIntersectsBufferRange: (bufferRange) -> - _.any @getSelections(), (selection) -> - selection.intersectsBufferRange(bufferRange) - - # Selections Private - - # Add a similarly-shaped selection to the next eligible line below - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next following non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionBelow: -> - @expandSelectionsForward (selection) -> selection.addSelectionBelow() - - # Add a similarly-shaped selection to the next eligible line above - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next preceding non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionAbove: -> - @expandSelectionsBackward (selection) -> selection.addSelectionAbove() - - # Calls the given function with each selection, then merges selections - expandSelectionsForward: (fn) -> - @mergeIntersectingSelections => - fn(selection) for selection in @getSelections() - return - - # Calls the given function with each selection, then merges selections in the - # reversed orientation - expandSelectionsBackward: (fn) -> - @mergeIntersectingSelections reversed: true, => - fn(selection) for selection in @getSelections() - return - - finalizeSelections: -> - selection.finalize() for selection in @getSelections() - return - - selectionsForScreenRows: (startRow, endRow) -> - @getSelections().filter (selection) -> selection.intersectsScreenRowRange(startRow, endRow) - - # Merges intersecting selections. If passed a function, it executes - # the function with merging suppressed, then merges intersecting selections - # afterward. - mergeIntersectingSelections: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - exclusive = not currentSelection.isEmpty() and not previousSelection.isEmpty() - - previousSelection.intersectsWith(currentSelection, exclusive) - - mergeSelectionsOnSameRows: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - screenRange = currentSelection.getScreenRange() - - previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) - - mergeSelections: (args...) -> - mergePredicate = args.pop() - fn = args.pop() if _.isFunction(_.last(args)) - options = args.pop() ? {} - - return fn?() if @suppressSelectionMerging - - if fn? - @suppressSelectionMerging = true - result = fn() - @suppressSelectionMerging = false - - reducer = (disjointSelections, selection) -> - adjacentSelection = _.last(disjointSelections) - if mergePredicate(adjacentSelection, selection) - adjacentSelection.merge(selection, options) - disjointSelections - else - disjointSelections.concat([selection]) - - [head, tail...] = @getSelectionsOrderedByBufferPosition() - _.reduce(tail, reducer, [head]) - return result if fn? - - # Add a {Selection} based on the given {Marker}. - # - # * `marker` The {Marker} to highlight - # * `options` (optional) An {Object} that pertains to the {Selection} constructor. - # - # Returns the new {Selection}. - addSelection: (marker, options={}) -> - unless marker.getProperties().preserveFolds - @destroyFoldsContainingBufferRange(marker.getBufferRange()) - cursor = @addCursor(marker) - selection = new Selection(_.extend({editor: this, marker, cursor}, options)) - @selections.push(selection) - selectionBufferRange = selection.getBufferRange() - @mergeIntersectingSelections(preserveFolds: marker.getProperties().preserveFolds) - - if selection.destroyed - for selection in @getSelections() - if selection.intersectsBufferRange(selectionBufferRange) - return selection - else - @emit 'selection-added', selection if includeDeprecatedAPIs - @emitter.emit 'did-add-selection', selection - selection - - # Remove the given selection. - removeSelection: (selection) -> - _.remove(@selections, selection) - @emit 'selection-removed', selection if includeDeprecatedAPIs - @emitter.emit 'did-remove-selection', selection - - # Reduce one or more selections to a single empty selection based on the most - # recently added cursor. - clearSelections: (options) -> - @consolidateSelections() - @getLastSelection().clear(options) - - # Reduce multiple selections to the most recently added selection. - consolidateSelections: -> - selections = @getSelections() - if selections.length > 1 - selection.destroy() for selection in selections[0...-1] - true - else - false - - # Called by the selection - selectionRangeChanged: (event) -> - @emit 'selection-screen-range-changed', event if includeDeprecatedAPIs - @emitter.emit 'did-change-selection-range', event - - ### - Section: Searching and Replacing - ### - - # Essential: Scan regular expression matches in the entire buffer, calling the - # given iterator function on each match. - # - # `::scan` functions as the replace method as well via the `replace` - # - # If you're programmatically modifying the results, you may want to try - # {::backwardsScanInBufferRange} to avoid tripping over your own changes. - # - # * `regex` A {RegExp} to search for. - # * `iterator` A {Function} that's called on each match - # * `object` {Object} - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scan: (regex, iterator) -> @buffer.scan(regex, iterator) - - # Public: Scan regular expression matches in a given range, calling the given - # iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scanInBufferRange: (regex, range, iterator) -> @buffer.scanInRange(regex, range, iterator) - - # Public: Scan regular expression matches in a given range in reverse order, - # calling the given iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - backwardsScanInBufferRange: (regex, range, iterator) -> @buffer.backwardsScanInRange(regex, range, iterator) - - ### - Section: Tab Behavior - ### - - # Essential: Returns a {Boolean} indicating whether softTabs are enabled for this - # editor. - getSoftTabs: -> @softTabs - - # Essential: Enable or disable soft tabs for this editor. - # - # * `softTabs` A {Boolean} - setSoftTabs: (@softTabs) -> @softTabs - - # Essential: Toggle soft tabs for this editor - toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs()) - - # Essential: Get the on-screen length of tab characters. - # - # Returns a {Number}. - getTabLength: -> @displayBuffer.getTabLength() - - # Essential: Set the on-screen length of tab characters. Setting this to a - # {Number} This will override the `editor.tabLength` setting. - # - # * `tabLength` {Number} length of a single tab. Setting to `null` will - # fallback to using the `editor.tabLength` config setting - setTabLength: (tabLength) -> @displayBuffer.setTabLength(tabLength) - - # Extended: Determine if the buffer uses hard or soft tabs. - # - # Returns `true` if the first non-comment line with leading whitespace starts - # with a space character. Returns `false` if it starts with a hard tab (`\t`). - # - # Returns a {Boolean} or undefined if no non-comment lines had leading - # whitespace. - usesSoftTabs: -> - # FIXME Remove once this can be specified as a scoped setting in the - # language-make package - return false if @getGrammar().scopeName is 'source.makefile' - - for bufferRow in [0..@buffer.getLastRow()] - continue if @displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow).isComment() - - line = @buffer.lineForRow(bufferRow) - return true if line[0] is ' ' - return false if line[0] is '\t' - - undefined - - # Extended: Get the text representing a single level of indent. - # - # If soft tabs are enabled, the text is composed of N spaces, where N is the - # tab length. Otherwise the text is a tab character (`\t`). - # - # Returns a {String}. - getTabText: -> @buildIndentString(1) - - # If soft tabs are enabled, convert all hard tabs to soft tabs in the given - # {Range}. - normalizeTabsInBufferRange: (bufferRange) -> - return unless @getSoftTabs() - @scanInBufferRange /\t/g, bufferRange, ({replace}) => replace(@getTabText()) - - ### - Section: Soft Wrap Behavior - ### - - # Essential: Determine whether lines in this editor are soft-wrapped. - # - # Returns a {Boolean}. - isSoftWrapped: (softWrapped) -> @displayBuffer.isSoftWrapped() - - # Essential: Enable or disable soft wrapping for this editor. - # - # * `softWrapped` A {Boolean} - # - # Returns a {Boolean}. - setSoftWrapped: (softWrapped) -> @displayBuffer.setSoftWrapped(softWrapped) - - # Essential: Toggle soft wrapping for this editor - # - # Returns a {Boolean}. - toggleSoftWrapped: -> @setSoftWrapped(not @isSoftWrapped()) - - # Public: Gets the column at which column will soft wrap - getSoftWrapColumn: -> @displayBuffer.getSoftWrapColumn() - - ### - Section: Indentation - ### - - # Essential: Get the indentation level of the given a buffer row. - # - # Returns how deeply the given row is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # - # Returns a {Number}. - indentationForBufferRow: (bufferRow) -> - @indentLevelForLine(@lineTextForBufferRow(bufferRow)) - - # Essential: Set the indentation level for the given buffer row. - # - # Inserts or removes hard tabs or spaces based on the soft tabs and tab length - # settings of this editor in order to bring it to the given indentation level. - # Note that if soft tabs are enabled and the tab length is 2, a row with 4 - # leading spaces would have an indentation level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # * `newLevel` A {Number} indicating the new indentation level. - # * `options` (optional) An {Object} with the following keys: - # * `preserveLeadingWhitespace` `true` to preserve any whitespace already at - # the beginning of the line (default: false). - setIndentationForBufferRow: (bufferRow, newLevel, {preserveLeadingWhitespace}={}) -> - if preserveLeadingWhitespace - endColumn = 0 - else - endColumn = @lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length - newIndentString = @buildIndentString(newLevel) - @buffer.setTextInRange([[bufferRow, 0], [bufferRow, endColumn]], newIndentString) - - # Extended: Indent rows intersecting selections by one level. - indentSelectedRows: -> - @mutateSelectedText (selection) -> selection.indentSelectedRows() - - # Extended: Outdent rows intersecting selections by one level. - outdentSelectedRows: -> - @mutateSelectedText (selection) -> selection.outdentSelectedRows() - - # Extended: Get the indentation level of the given line of text. - # - # Returns how deeply the given line is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `line` A {String} representing a line of text. - # - # Returns a {Number}. - indentLevelForLine: (line) -> - @displayBuffer.indentLevelForLine(line) - - # Extended: Indent rows intersecting selections based on the grammar's suggested - # indent level. - autoIndentSelectedRows: -> - @mutateSelectedText (selection) -> selection.autoIndentSelectedRows() - - # Indent all lines intersecting selections. See {Selection::indent} for more - # information. - indent: (options={}) -> - options.autoIndent ?= @shouldAutoIndent() - @mutateSelectedText (selection) -> selection.indent(options) - - # Constructs the string used for tabs. - buildIndentString: (number, column=0) -> - if @getSoftTabs() - tabStopViolation = column % @getTabLength() - _.multiplyString(" ", Math.floor(number * @getTabLength()) - tabStopViolation) - else - _.multiplyString("\t", Math.floor(number)) - - ### - Section: Grammars - ### - - # Essential: Get the current {Grammar} of this editor. - getGrammar: -> - @displayBuffer.getGrammar() - - # Essential: 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) -> - @displayBuffer.setGrammar(grammar) - - # Reload the grammar based on the file name. - reloadGrammar: -> - @displayBuffer.reloadGrammar() - - ### - Section: Managing Syntax Scopes - ### - - # Essential: Returns a {ScopeDescriptor} that includes this editor's language. - # e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with - # {Config::get} to get language specific config values. - getRootScopeDescriptor: -> - @displayBuffer.getRootScopeDescriptor() - - # 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"]` - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # - # Returns a {ScopeDescriptor}. - scopeDescriptorForBufferPosition: (bufferPosition) -> - @displayBuffer.scopeDescriptorForBufferPosition(bufferPosition) - - # Extended: Get the range in buffer coordinates of all tokens surrounding the - # cursor that match the given scope selector. - # - # For example, if you wanted to find the string surrounding the cursor, you - # could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. - # - # * `scopeSelector` {String} selector. e.g. `'.source.ruby'` - # - # Returns a {Range}. - bufferRangeForScopeAtCursor: (scopeSelector) -> - @displayBuffer.bufferRangeForScopeAtPosition(scopeSelector, @getCursorBufferPosition()) - - # Extended: Determine if the given row is entirely a comment - isBufferRowCommented: (bufferRow) -> - if match = @lineTextForBufferRow(bufferRow).match(/\S/) - @commentScopeSelector ?= new TextMateScopeSelector('comment.*') - @commentScopeSelector.matches(@scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) - - logCursorScope: -> - scopeDescriptor = @getLastCursor().getScopeDescriptor() - list = scopeDescriptor.scopes.toString().split(',') - list = list.map (item) -> "* #{item}" - content = "Scopes at Cursor\n#{list.join('\n')}" - - atom.notifications.addInfo(content, dismissable: true) - - # {Delegates to: DisplayBuffer.tokenForBufferPosition} - tokenForBufferPosition: (bufferPosition) -> @displayBuffer.tokenForBufferPosition(bufferPosition) - - ### - Section: Clipboard Operations - ### - - # Essential: For each selection, copy the selected text. - copySelectedText: -> - maintainClipboard = false - for selection in @getSelectionsOrderedByBufferPosition() - if selection.isEmpty() - previousRange = selection.getBufferRange() - selection.selectLine() - selection.copy(maintainClipboard, true) - selection.setBufferRange(previousRange) - else - selection.copy(maintainClipboard, false) - maintainClipboard = true - return - - # Essential: For each selection, cut the selected text. - cutSelectedText: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectLine() - selection.cut(maintainClipboard, true) - else - selection.cut(maintainClipboard, false) - maintainClipboard = true - - # Essential: For each selection, replace the selected text with the contents of - # the clipboard. - # - # If the clipboard contains the same number of selections as the current - # editor, each selection will be replaced with the content of the - # corresponding clipboard selection text. - # - # * `options` (optional) See {Selection::insertText}. - pasteText: (options={}) -> - {text: clipboardText, metadata} = atom.clipboard.readWithMetadata() - metadata ?= {} - options.autoIndent = @shouldAutoIndentOnPaste() - - @mutateSelectedText (selection, index) => - if metadata.selections?.length is @getSelections().length - {text, indentBasis, fullLine} = metadata.selections[index] - else - {indentBasis, fullLine} = metadata - text = clipboardText - - delete options.indentBasis - {cursor} = selection - if indentBasis? - containsNewlines = text.indexOf('\n') isnt -1 - if containsNewlines or not cursor.hasPrecedingCharactersOnLine() - options.indentBasis ?= indentBasis - - if fullLine and selection.isEmpty() - oldPosition = selection.getBufferRange().start - selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]) - selection.insertText(text, options) - newPosition = oldPosition.translate([1, 0]) - selection.setBufferRange([newPosition, newPosition]) - else - selection.insertText(text, options) - - # Public: For each selection, if the selection is empty, cut all characters - # of the containing line following the cursor. Otherwise cut the selected - # text. - cutToEndOfLine: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - selection.cutToEndOfLine(maintainClipboard) - maintainClipboard = true - - ### - Section: Folds - ### - - # Essential: Fold the most recent cursor's row based on its indentation level. - # - # The fold will extend from the nearest preceding line with a lower - # indentation level up to the nearest following row with a lower indentation - # level. - foldCurrentRow: -> - bufferRow = @bufferPositionForScreenPosition(@getCursorScreenPosition()).row - @foldBufferRow(bufferRow) - - # Essential: Unfold the most recent cursor's row by one level. - unfoldCurrentRow: -> - bufferRow = @bufferPositionForScreenPosition(@getCursorScreenPosition()).row - @unfoldBufferRow(bufferRow) - - # Essential: Fold the given row in buffer coordinates based on its indentation - # level. - # - # If the given row is foldable, the fold will begin there. Otherwise, it will - # begin at the first foldable row preceding the given row. - # - # * `bufferRow` A {Number}. - foldBufferRow: (bufferRow) -> - @languageMode.foldBufferRow(bufferRow) - - # Essential: Unfold all folds containing the given row in buffer coordinates. - # - # * `bufferRow` A {Number} - unfoldBufferRow: (bufferRow) -> - @displayBuffer.unfoldBufferRow(bufferRow) - - # Extended: For each selection, fold the rows it intersects. - foldSelectedLines: -> - selection.fold() for selection in @getSelections() - return - - # Extended: Fold all foldable lines. - foldAll: -> - @languageMode.foldAll() - - # Extended: Unfold all existing folds. - unfoldAll: -> - @languageMode.unfoldAll() - - # Extended: Fold all foldable lines at the given indent level. - # - # * `level` A {Number}. - foldAllAtIndentLevel: (level) -> - @languageMode.foldAllAtIndentLevel(level) - - # Extended: Determine whether the given row in buffer coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtBufferRow: (bufferRow) -> - # @languageMode.isFoldableAtBufferRow(bufferRow) - @displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow)?.foldable ? false - - # Extended: Determine whether the given row in screen coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtScreenRow: (screenRow) -> - bufferRow = @displayBuffer.bufferRowForScreenRow(screenRow) - @isFoldableAtBufferRow(bufferRow) - - # Extended: Fold the given buffer row if it isn't currently folded, and unfold - # it otherwise. - toggleFoldAtBufferRow: (bufferRow) -> - if @isFoldedAtBufferRow(bufferRow) - @unfoldBufferRow(bufferRow) - else - @foldBufferRow(bufferRow) - - # Extended: Determine whether the most recently added cursor's row is folded. - # - # Returns a {Boolean}. - isFoldedAtCursorRow: -> - @isFoldedAtScreenRow(@getCursorScreenPosition().row) - - # Extended: Determine whether the given row in buffer coordinates is folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtBufferRow: (bufferRow) -> - @displayBuffer.isFoldedAtBufferRow(bufferRow) - - # Extended: Determine whether the given row in screen coordinates is folded. - # - # * `screenRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtScreenRow: (screenRow) -> - @displayBuffer.isFoldedAtScreenRow(screenRow) - - # TODO: Rename to foldRowRange? - createFold: (startRow, endRow) -> - @displayBuffer.createFold(startRow, endRow) - - # {Delegates to: DisplayBuffer.destroyFoldWithId} - destroyFoldWithId: (id) -> - @displayBuffer.destroyFoldWithId(id) - - # Remove any {Fold}s found that intersect the given buffer range. - destroyFoldsIntersectingBufferRange: (bufferRange) -> - @destroyFoldsContainingBufferRange(bufferRange) - - for row in [bufferRange.end.row..bufferRange.start.row] - fold.destroy() for fold in @displayBuffer.foldsStartingAtBufferRow(row) - - return - - # Remove any {Fold}s found that contain the given buffer range. - destroyFoldsContainingBufferRange: (bufferRange) -> - @unfoldBufferRow(bufferRange.start.row) - @unfoldBufferRow(bufferRange.end.row) - - # {Delegates to: DisplayBuffer.largestFoldContainingBufferRow} - largestFoldContainingBufferRow: (bufferRow) -> - @displayBuffer.largestFoldContainingBufferRow(bufferRow) - - # {Delegates to: DisplayBuffer.largestFoldStartingAtScreenRow} - largestFoldStartingAtScreenRow: (screenRow) -> - @displayBuffer.largestFoldStartingAtScreenRow(screenRow) - - # {Delegates to: DisplayBuffer.outermostFoldsForBufferRowRange} - outermostFoldsInBufferRowRange: (startRow, endRow) -> - @displayBuffer.outermostFoldsInBufferRowRange(startRow, endRow) - - ### - Section: Scrolling the TextEditor - ### - - # Essential: Scroll the editor to reveal the most recently added cursor if it is - # off-screen. - # - # * `options` (optional) {Object} - # * `center` Center the editor around the cursor if possible. (default: true) - scrollToCursorPosition: (options) -> - @getLastCursor().autoscroll(center: options?.center ? true) - - # Essential: Scrolls the editor to the given buffer position. - # - # * `bufferPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToBufferPosition: (bufferPosition, options) -> - @displayBuffer.scrollToBufferPosition(bufferPosition, options) - - # Essential: Scrolls the editor to the given screen position. - # - # * `screenPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToScreenPosition: (screenPosition, options) -> - @displayBuffer.scrollToScreenPosition(screenPosition, options) - - # Essential: Scrolls the editor to the top - scrollToTop: -> - @setScrollTop(0) - - # Essential: Scrolls the editor to the bottom - scrollToBottom: -> - @setScrollBottom(Infinity) - - scrollToScreenRange: (screenRange, options) -> @displayBuffer.scrollToScreenRange(screenRange, options) - - horizontallyScrollable: -> @displayBuffer.horizontallyScrollable() - - verticallyScrollable: -> @displayBuffer.verticallyScrollable() - - getHorizontalScrollbarHeight: -> @displayBuffer.getHorizontalScrollbarHeight() - setHorizontalScrollbarHeight: (height) -> @displayBuffer.setHorizontalScrollbarHeight(height) - - getVerticalScrollbarWidth: -> @displayBuffer.getVerticalScrollbarWidth() - setVerticalScrollbarWidth: (width) -> @displayBuffer.setVerticalScrollbarWidth(width) - - pageUp: -> - newScrollTop = @getScrollTop() - @getHeight() - @moveUp(@getRowsPerPage()) - @setScrollTop(newScrollTop) - - pageDown: -> - newScrollTop = @getScrollTop() + @getHeight() - @moveDown(@getRowsPerPage()) - @setScrollTop(newScrollTop) - - selectPageUp: -> - @selectUp(@getRowsPerPage()) - - selectPageDown: -> - @selectDown(@getRowsPerPage()) - - # Returns the number of rows per page - getRowsPerPage: -> - Math.max(1, Math.ceil(@getHeight() / @getLineHeightInPixels())) - - ### - Section: Config - ### - - shouldAutoIndent: -> - atom.config.get("editor.autoIndent", scope: @getRootScopeDescriptor()) - - shouldAutoIndentOnPaste: -> - atom.config.get("editor.autoIndentOnPaste", scope: @getRootScopeDescriptor()) - - ### - Section: Event Handlers - ### - - handleTokenization: -> - @softTabs = @usesSoftTabs() ? @softTabs - - handleGrammarChange: -> - @unfoldAll() - @emit 'grammar-changed' if includeDeprecatedAPIs - @emitter.emit 'did-change-grammar', @getGrammar() - - handleMarkerCreated: (marker) => - if marker.matchesProperties(@getSelectionMarkerAttributes()) - @addSelection(marker) - - ### - Section: TextEditor Rendering - ### - - # Public: Retrieves the greyed out placeholder of a mini editor. - # - # Returns a {String}. - getPlaceholderText: -> - @placeholderText - - # Public: Set the greyed out placeholder of a mini editor. Placeholder text - # will be displayed when the editor has no content. - # - # * `placeholderText` {String} text that is displayed when the editor has no content. - setPlaceholderText: (placeholderText) -> - return if @placeholderText is placeholderText - @placeholderText = placeholderText - @emitter.emit 'did-change-placeholder-text', @placeholderText - - getFirstVisibleScreenRow: (suppressDeprecation) -> - unless suppressDeprecation - deprecate("This is now a view method. Call TextEditorElement::getFirstVisibleScreenRow instead.") - @getVisibleRowRange()[0] - - getLastVisibleScreenRow: (suppressDeprecation) -> - unless suppressDeprecation - deprecate("This is now a view method. Call TextEditorElement::getLastVisibleScreenRow instead.") - @getVisibleRowRange()[1] - - pixelPositionForBufferPosition: (bufferPosition, suppressDeprecation) -> - unless suppressDeprecation - deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead") - @displayBuffer.pixelPositionForBufferPosition(bufferPosition) - - pixelPositionForScreenPosition: (screenPosition, suppressDeprecation) -> - unless suppressDeprecation - deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead") - @displayBuffer.pixelPositionForScreenPosition(screenPosition) - - getSelectionMarkerAttributes: -> - type: 'selection', editorId: @id, invalidate: 'never' - - getVerticalScrollMargin: -> @displayBuffer.getVerticalScrollMargin() - setVerticalScrollMargin: (verticalScrollMargin) -> @displayBuffer.setVerticalScrollMargin(verticalScrollMargin) - - getHorizontalScrollMargin: -> @displayBuffer.getHorizontalScrollMargin() - setHorizontalScrollMargin: (horizontalScrollMargin) -> @displayBuffer.setHorizontalScrollMargin(horizontalScrollMargin) - - getLineHeightInPixels: -> @displayBuffer.getLineHeightInPixels() - setLineHeightInPixels: (lineHeightInPixels) -> @displayBuffer.setLineHeightInPixels(lineHeightInPixels) - - batchCharacterMeasurement: (fn) -> @displayBuffer.batchCharacterMeasurement(fn) - - getScopedCharWidth: (scopeNames, char) -> @displayBuffer.getScopedCharWidth(scopeNames, char) - setScopedCharWidth: (scopeNames, char, width) -> @displayBuffer.setScopedCharWidth(scopeNames, char, width) - - getScopedCharWidths: (scopeNames) -> @displayBuffer.getScopedCharWidths(scopeNames) - - clearScopedCharWidths: -> @displayBuffer.clearScopedCharWidths() - - getDefaultCharWidth: -> @displayBuffer.getDefaultCharWidth() - setDefaultCharWidth: (defaultCharWidth) -> @displayBuffer.setDefaultCharWidth(defaultCharWidth) - - setHeight: (height) -> @displayBuffer.setHeight(height) - getHeight: -> @displayBuffer.getHeight() - - getClientHeight: -> @displayBuffer.getClientHeight() - - setWidth: (width) -> @displayBuffer.setWidth(width) - getWidth: -> @displayBuffer.getWidth() - - getScrollTop: -> @displayBuffer.getScrollTop() - setScrollTop: (scrollTop) -> @displayBuffer.setScrollTop(scrollTop) - - getScrollBottom: -> @displayBuffer.getScrollBottom() - setScrollBottom: (scrollBottom) -> @displayBuffer.setScrollBottom(scrollBottom) - - getScrollLeft: -> @displayBuffer.getScrollLeft() - setScrollLeft: (scrollLeft) -> @displayBuffer.setScrollLeft(scrollLeft) - - getScrollRight: -> @displayBuffer.getScrollRight() - setScrollRight: (scrollRight) -> @displayBuffer.setScrollRight(scrollRight) - - getScrollHeight: -> @displayBuffer.getScrollHeight() - getScrollWidth: -> @displayBuffer.getScrollWidth() - - getVisibleRowRange: -> @displayBuffer.getVisibleRowRange() - - intersectsVisibleRowRange: (startRow, endRow) -> @displayBuffer.intersectsVisibleRowRange(startRow, endRow) - - selectionIntersectsVisibleRowRange: (selection) -> @displayBuffer.selectionIntersectsVisibleRowRange(selection) - - screenPositionForPixelPosition: (pixelPosition) -> @displayBuffer.screenPositionForPixelPosition(pixelPosition) - - pixelRectForScreenRange: (screenRange) -> @displayBuffer.pixelRectForScreenRange(screenRange) - - ### - Section: Utility - ### - - inspect: -> - "" - - logScreenLines: (start, end) -> @displayBuffer.logLines(start, end) - -if includeDeprecatedAPIs - TextEditor.delegatesProperties '$lineHeightInPixels', '$defaultCharWidth', '$height', '$width', - '$verticalScrollbarWidth', '$horizontalScrollbarHeight', '$scrollTop', '$scrollLeft', - toProperty: 'displayBuffer' - - TextEditor::getViewClass = -> - require './text-editor-view' - - TextEditor::joinLine = -> - deprecate("Use TextEditor::joinLines() instead") - @joinLines() - - TextEditor::scopesAtCursor = -> - deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' - @getLastCursor().getScopeDescriptor().getScopesArray() - - TextEditor::getCursorScopes = -> - deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' - @scopesAtCursor() - - TextEditor::getUri = -> - deprecate("Use `::getURI` instead") - @getURI() - - TextEditor::lineForBufferRow = (bufferRow) -> - deprecate 'Use TextEditor::lineTextForBufferRow(bufferRow) instead' - @lineTextForBufferRow(bufferRow) - - TextEditor::lineForScreenRow = (screenRow) -> - deprecate "TextEditor::tokenizedLineForScreenRow(bufferRow) is the new name. But it's private. Try to use TextEditor::lineTextForScreenRow instead" - @tokenizedLineForScreenRow(screenRow) - - TextEditor::linesForScreenRows = (start, end) -> - deprecate "Use TextEditor::tokenizedLinesForScreenRows instead" - @tokenizedLinesForScreenRows(start, end) - - TextEditor::lineLengthForBufferRow = (row) -> - deprecate "Use editor.lineTextForBufferRow(row).length instead" - @lineTextForBufferRow(row).length - - TextEditor::duplicateLine = -> - deprecate("Use TextEditor::duplicateLines() instead") - @duplicateLines() - - TextEditor::scopesForBufferPosition = (bufferPosition) -> - deprecate 'Use ::scopeDescriptorForBufferPosition instead. The return value has changed! It now returns a `ScopeDescriptor`' - @scopeDescriptorForBufferPosition(bufferPosition).getScopesArray() - - TextEditor::toggleSoftWrap = -> - deprecate("Use TextEditor::toggleSoftWrapped instead") - @toggleSoftWrapped() - - TextEditor::setSoftWrap = (softWrapped) -> - deprecate("Use TextEditor::setSoftWrapped instead") - @setSoftWrapped(softWrapped) - - TextEditor::backspaceToBeginningOfWord = -> - deprecate("Use TextEditor::deleteToBeginningOfWord() instead") - @deleteToBeginningOfWord() - - TextEditor::backspaceToBeginningOfLine = -> - deprecate("Use TextEditor::deleteToBeginningOfLine() instead") - @deleteToBeginningOfLine() - - TextEditor::getGutterDecorations = (propertyFilter) -> - deprecate("Use ::getLineNumberDecorations instead") - @getLineNumberDecorations(propertyFilter) - - TextEditor::getCursorScreenRow = -> - deprecate('Use `editor.getCursorScreenPosition().row` instead') - @getCursorScreenPosition().row - - TextEditor::moveCursorUp = (lineCount) -> - deprecate("Use TextEditor::moveUp() instead") - @moveUp(lineCount) - - TextEditor::moveCursorDown = (lineCount) -> - deprecate("Use TextEditor::moveDown() instead") - @moveDown(lineCount) - - TextEditor::moveCursorLeft = -> - deprecate("Use TextEditor::moveLeft() instead") - @moveLeft() - - TextEditor::moveCursorRight = -> - deprecate("Use TextEditor::moveRight() instead") - @moveRight() - - TextEditor::moveCursorToBeginningOfLine = -> - deprecate("Use TextEditor::moveToBeginningOfLine() instead") - @moveToBeginningOfLine() - - TextEditor::moveCursorToBeginningOfScreenLine = -> - deprecate("Use TextEditor::moveToBeginningOfScreenLine() instead") - @moveToBeginningOfScreenLine() - - TextEditor::moveCursorToFirstCharacterOfLine = -> - deprecate("Use TextEditor::moveToFirstCharacterOfLine() instead") - @moveToFirstCharacterOfLine() - - TextEditor::moveCursorToEndOfLine = -> - deprecate("Use TextEditor::moveToEndOfLine() instead") - @moveToEndOfLine() - - TextEditor::moveCursorToEndOfScreenLine = -> - deprecate("Use TextEditor::moveToEndOfScreenLine() instead") - @moveToEndOfScreenLine() - - TextEditor::moveCursorToBeginningOfWord = -> - deprecate("Use TextEditor::moveToBeginningOfWord() instead") - @moveToBeginningOfWord() - - TextEditor::moveCursorToEndOfWord = -> - deprecate("Use TextEditor::moveToEndOfWord() instead") - @moveToEndOfWord() - - TextEditor::moveCursorToTop = -> - deprecate("Use TextEditor::moveToTop() instead") - @moveToTop() - - TextEditor::moveCursorToBottom = -> - deprecate("Use TextEditor::moveToBottom() instead") - @moveToBottom() - - TextEditor::moveCursorToBeginningOfNextWord = -> - deprecate("Use TextEditor::moveToBeginningOfNextWord() instead") - @moveToBeginningOfNextWord() - - TextEditor::moveCursorToPreviousWordBoundary = -> - deprecate("Use TextEditor::moveToPreviousWordBoundary() instead") - @moveToPreviousWordBoundary() - - TextEditor::moveCursorToNextWordBoundary = -> - deprecate("Use TextEditor::moveToNextWordBoundary() instead") - @moveToNextWordBoundary() - - TextEditor::moveCursorToBeginningOfNextParagraph = -> - deprecate("Use TextEditor::moveToBeginningOfNextParagraph() instead") - @moveToBeginningOfNextParagraph() - - TextEditor::moveCursorToBeginningOfPreviousParagraph = -> - deprecate("Use TextEditor::moveToBeginningOfPreviousParagraph() instead") - @moveToBeginningOfPreviousParagraph() - - TextEditor::getCursor = -> - deprecate("Use TextEditor::getLastCursor() instead") - @getLastCursor() - - TextEditor::selectLine = -> - deprecate('Use TextEditor::selectLinesContainingCursors instead') - @selectLinesContainingCursors() - - TextEditor::selectWord = -> - deprecate('Use TextEditor::selectWordsContainingCursors instead') - @selectWordsContainingCursors() - - TextEditor::getSelection = (index) -> - if index? - deprecate("Use TextEditor::getSelections()[index] instead when getting a specific selection") - @getSelections()[index] - else - deprecate("Use TextEditor::getLastSelection() instead") - @getLastSelection() - - TextEditor::getSoftWrapped = -> - deprecate("Use TextEditor::isSoftWrapped instead") - @displayBuffer.isSoftWrapped() - - EmitterMixin = require('emissary').Emitter - TextEditor::on = (eventName) -> - switch eventName - when 'title-changed' - deprecate("Use TextEditor::onDidChangeTitle instead") - when 'path-changed' - deprecate("Use TextEditor::onDidChangePath instead") - when 'modified-status-changed' - deprecate("Use TextEditor::onDidChangeModified instead") - when 'soft-wrap-changed' - deprecate("Use TextEditor::onDidChangeSoftWrapped instead") - when 'grammar-changed' - deprecate("Use TextEditor::onDidChangeGrammar instead") - when 'character-widths-changed' - deprecate("Use TextEditor::onDidChangeCharacterWidths instead") - when 'contents-modified' - deprecate("Use TextEditor::onDidStopChanging instead") - when 'contents-conflicted' - deprecate("Use TextEditor::onDidConflict instead") - - when 'will-insert-text' - deprecate("Use TextEditor::onWillInsertText instead") - when 'did-insert-text' - deprecate("Use TextEditor::onDidInsertText instead") - - when 'cursor-added' - deprecate("Use TextEditor::onDidAddCursor instead") - when 'cursor-removed' - deprecate("Use TextEditor::onDidRemoveCursor instead") - when 'cursor-moved' - deprecate("Use TextEditor::onDidChangeCursorPosition instead") - - when 'selection-added' - deprecate("Use TextEditor::onDidAddSelection instead") - when 'selection-removed' - deprecate("Use TextEditor::onDidRemoveSelection instead") - when 'selection-screen-range-changed' - deprecate("Use TextEditor::onDidChangeSelectionRange instead") - - when 'decoration-added' - deprecate("Use TextEditor::onDidAddDecoration instead") - when 'decoration-removed' - deprecate("Use TextEditor::onDidRemoveDecoration instead") - when 'decoration-updated' - deprecate("Use Decoration::onDidChangeProperties instead. You will get the decoration back from `TextEditor::decorateMarker()`") - when 'decoration-changed' - deprecate("Use Marker::onDidChange instead. e.g. `editor::decorateMarker(...).getMarker().onDidChange()`") - - when 'screen-lines-changed' - deprecate("Use TextEditor::onDidChange instead") - - when 'scroll-top-changed' - deprecate("Use TextEditor::onDidChangeScrollTop instead") - when 'scroll-left-changed' - deprecate("Use TextEditor::onDidChangeScrollLeft instead") - - else - deprecate("TextEditor::on is deprecated. Use documented event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) diff --git a/src/text-editor.js b/src/text-editor.js new file mode 100644 index 00000000000..ed6b0e68f8d --- /dev/null +++ b/src/text-editor.js @@ -0,0 +1,5855 @@ +const _ = require('underscore-plus'); +const path = require('path'); +const fs = require('fs-plus'); +const Grim = require('grim'); +const dedent = require('dedent'); +const { CompositeDisposable, Disposable, Emitter } = require('event-kit'); +const TextBuffer = require('text-buffer'); +const { Point, Range } = TextBuffer; +const DecorationManager = require('./decoration-manager'); +const Cursor = require('./cursor'); +const Selection = require('./selection'); +const NullGrammar = require('./null-grammar'); +const TextMateLanguageMode = require('./text-mate-language-mode'); +const ScopeDescriptor = require('./scope-descriptor'); + +const TextMateScopeSelector = require('first-mate').ScopeSelector; +const GutterContainer = require('./gutter-container'); +let TextEditorComponent = null; +let TextEditorElement = null; +const { + isDoubleWidthCharacter, + isHalfWidthCharacter, + isKoreanCharacter, + isWrapBoundary +} = require('./text-utils'); + +const SERIALIZATION_VERSION = 1; +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. +// +// A single {TextBuffer} can belong to multiple editors. For example, if the +// same file is open in two different panes, Atom creates a separate editor for +// each pane. If the buffer is manipulated the changes are reflected in both +// editors, but each maintains its own cursor position, folded lines, etc. +// +// ## Accessing TextEditor Instances +// +// The easiest way to get hold of `TextEditor` objects is by registering a callback +// with `::observeTextEditors` on the `atom.workspace` global. Your callback will +// then be called with all current editor instances and also when any editor is +// created in the future. +// +// ```js +// atom.workspace.observeTextEditors(editor => { +// editor.insertText('Hello World') +// }) +// ``` +// +// ## Buffer vs. Screen Coordinates +// +// Because editors support folds and soft-wrapping, the lines on screen don't +// always match the lines in the buffer. For example, a long line that soft wraps +// twice renders as three lines on screen, but only represents one line in the +// buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds +// to row 11 in the buffer. +// +// Your choice of coordinates systems will depend on what you're trying to +// achieve. For example, if you're writing a command that jumps the cursor up or +// down by 10 lines, you'll want to use screen coordinates because the user +// probably wants to skip lines *on screen*. However, if you're writing a package +// that jumps between method definitions, you'll want to work in buffer +// coordinates. +// +// **When in doubt, just default to buffer coordinates**, then experiment with +// soft wraps and folds to ensure your code interacts with them correctly. +module.exports = class TextEditor { + static setClipboard(clipboard) { + this.clipboard = clipboard; + } + + static setScheduler(scheduler) { + if (TextEditorComponent == null) { + TextEditorComponent = require('./text-editor-component'); + } + return TextEditorComponent.setScheduler(scheduler); + } + + static didUpdateStyles() { + if (TextEditorComponent == null) { + TextEditorComponent = require('./text-editor-component'); + } + return TextEditorComponent.didUpdateStyles(); + } + + static didUpdateScrollbarStyles() { + if (TextEditorComponent == null) { + TextEditorComponent = require('./text-editor-component'); + } + return TextEditorComponent.didUpdateScrollbarStyles(); + } + + static viewForItem(item) { + return item.element || item; + } + + static deserialize(state, atomEnvironment) { + if (state.version !== SERIALIZATION_VERSION) return null; + + let bufferId = state.tokenizedBuffer + ? state.tokenizedBuffer.bufferId + : state.bufferId; + + 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 + } else { + throw error; + } + } + + state.assert = atomEnvironment.assert.bind(atomEnvironment); + + // Semantics of the readOnly flag have changed since its introduction. + // Only respect readOnly2, which has been set with the current readOnly semantics. + delete state.readOnly; + state.readOnly = state.readOnly2; + delete state.readOnly2; + + const editor = new TextEditor(state); + if (state.registered) { + const disposable = atomEnvironment.textEditors.add(editor); + editor.onDidDestroy(() => disposable.dispose()); + } + return editor; + } + + constructor(params = {}) { + if (this.constructor.clipboard == null) { + throw new Error( + 'Must call TextEditor.setClipboard at least once before creating TextEditor instances' + ); + } + + 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.keyboardInputEnabled = + params.keyboardInputEnabled != null ? params.keyboardInputEnabled : true; + this.readOnly = params.readOnly != null ? params.readOnly : false; + this.placeholderText = params.placeholderText; + this.showLineNumbers = params.showLineNumbers; + this.assert = params.assert || (condition => condition); + this.showInvisibles = + params.showInvisibles != null ? params.showInvisibles : true; + this.autoHeight = params.autoHeight; + this.autoWidth = params.autoWidth; + this.scrollPastEnd = + params.scrollPastEnd != null ? params.scrollPastEnd : false; + this.scrollSensitivity = + params.scrollSensitivity != null ? params.scrollSensitivity : 40; + this.editorWidthInChars = params.editorWidthInChars; + this.invisibles = params.invisibles; + this.showIndentGuide = params.showIndentGuide; + this.softWrapped = params.softWrapped; + this.softWrapAtPreferredLineLength = params.softWrapAtPreferredLineLength; + this.preferredLineLength = params.preferredLineLength; + this.showCursorOnSelection = + params.showCursorOnSelection != null + ? params.showCursorOnSelection + : true; + this.maxScreenLineLength = params.maxScreenLineLength; + this.softTabs = params.softTabs != null ? params.softTabs : true; + 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.softWrapped = params.softWrapped != null ? params.softWrapped : false; + this.softWrapAtPreferredLineLength = + params.softWrapAtPreferredLineLength != null + ? params.softWrapAtPreferredLineLength + : false; + this.preferredLineLength = + params.preferredLineLength != null ? params.preferredLineLength : 80; + this.maxScreenLineLength = + params.maxScreenLineLength != null ? params.maxScreenLineLength : 500; + this.showLineNumbers = + params.showLineNumbers != null ? params.showLineNumbers : true; + const { tabLength = 2 } = params; + + this.alive = true; + this.doBackgroundWork = this.doBackgroundWork.bind(this); + this.serializationVersion = 1; + this.suppressSelectionMerging = false; + this.selectionFlashDuration = 500; + this.gutterContainer = null; + this.verticalScrollMargin = 2; + this.horizontalScrollMargin = 6; + this.lineHeightInPixels = null; + this.defaultCharWidth = null; + this.height = null; + this.width = null; + this.registered = false; + this.atomicSoftTabs = true; + this.emitter = new Emitter(); + this.disposables = new CompositeDisposable(); + this.cursors = []; + this.cursorsByMarkerId = new Map(); + this.selections = []; + this.hasTerminatedPendingState = false; + + 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 }) + ); + } + + 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; + } else { + const displayLayerParams = { + invisibles: this.getInvisibles(), + softWrapColumn: this.getSoftWrapColumn(), + showIndentGuides: this.doesShowIndentGuide(), + atomicSoftTabs: + params.atomicSoftTabs != null ? params.atomicSoftTabs : true, + tabLength, + ratioForCharacter: this.ratioForCharacter.bind(this), + isWrapBoundary, + foldCharacter: ZERO_WIDTH_NBSP, + softWrapHangingIndent: + params.softWrapHangingIndentLength != null + ? params.softWrapHangingIndentLength + : 0 + }; + + this.displayLayer = this.buffer.getDisplayLayer(params.displayLayerId); + if (this.displayLayer) { + this.displayLayer.reset(displayLayerParams); + this.selectionsMarkerLayer = this.displayLayer.getMarkerLayer( + params.selectionsMarkerLayerId + ); + } else { + this.displayLayer = this.buffer.addDisplayLayer(displayLayerParams); + } + } + + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork); + this.disposables.add( + new Disposable(() => { + if (this.backgroundWorkHandle != null) + return cancelIdleCallback(this.backgroundWorkHandle); + }) + ); + + this.defaultMarkerLayer = this.displayLayer.addMarkerLayer(); + if (!this.selectionsMarkerLayer) { + this.selectionsMarkerLayer = this.addMarkerLayer({ + maintainHistory: true, + persistent: true, + role: 'selections' + }); + } + + this.decorationManager = new DecorationManager(this); + this.decorateMarkerLayer(this.selectionsMarkerLayer, { type: 'cursor' }); + if (!this.isMini()) this.decorateCursorLine(); + + this.decorateMarkerLayer(this.displayLayer.foldsMarkerLayer, { + type: 'line-number', + class: 'folded' + }); + + for (let marker of this.selectionsMarkerLayer.getMarkers()) { + this.addSelection(marker); + } + + this.subscribeToBuffer(); + this.subscribeToDisplayLayer(); + + if (this.cursors.length === 0 && !params.suppressCursorCreation) { + const initialLine = Math.max(parseInt(params.initialLine) || 0, 0); + const initialColumn = Math.max(parseInt(params.initialColumn) || 0, 0); + this.addCursorAtBufferPosition([initialLine, initialColumn]); + } + + this.gutterContainer = new GutterContainer(this); + this.lineNumberGutter = this.gutterContainer.addGutter({ + name: 'line-number', + type: 'line-number', + priority: 0, + visible: params.lineNumberGutterVisible + }); + } + + get element() { + return this.getElement(); + } + + get editorElement() { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.editorElement\` has always been private, but now + it is gone. Reading the \`editorElement\` property still returns a + reference to the editor element but this field will be removed in a + later version of Atom, so we recommend using the \`element\` property instead.\ + `); + + return this.getElement(); + } + + get displayBuffer() { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.displayBuffer\` has always been private, but now + it is gone. Reading the \`displayBuffer\` property now returns a reference + to the containing \`TextEditor\`, which now provides *some* of the API of + the defunct \`DisplayBuffer\` class.\ + `); + return this; + } + + get languageMode() { + return this.buffer.getLanguageMode(); + } + + get tokenizedBuffer() { + return this.buffer.getLanguageMode(); + } + + get rowsPerPage() { + return this.getRowsPerPage(); + } + + decorateCursorLine() { + this.cursorLineDecorations = [ + this.decorateMarkerLayer(this.selectionsMarkerLayer, { + type: 'line', + class: 'cursor-line', + onlyEmpty: true + }), + this.decorateMarkerLayer(this.selectionsMarkerLayer, { + type: 'line-number', + class: 'cursor-line' + }), + this.decorateMarkerLayer(this.selectionsMarkerLayer, { + type: 'line-number', + class: 'cursor-line-no-selection', + onlyHead: true, + onlyEmpty: true + }) + ]; + } + + doBackgroundWork(deadline) { + const previousLongestRow = this.getApproximateLongestScreenRow(); + if (this.displayLayer.doBackgroundWork(deadline)) { + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork); + } else { + this.backgroundWorkHandle = null; + } + + if ( + this.component && + this.getApproximateLongestScreenRow() !== previousLongestRow + ) { + this.component.scheduleUpdate(); + } + } + + update(params) { + const displayLayerParams = {}; + + for (let param of Object.keys(params)) { + const value = params[param]; + + switch (param) { + case 'autoIndent': + this.updateAutoIndent(value, false); + break; + + case 'autoIndentOnPaste': + this.updateAutoIndentOnPaste(value, false); + break; + + case 'undoGroupingInterval': + this.updateUndoGroupingInterval(value, false); + break; + + case 'scrollSensitivity': + this.updateScrollSensitivity(value, false); + break; + + case 'encoding': + this.updateEncoding(value, false); + break; + + case 'softTabs': + this.updateSoftTabs(value, false); + break; + + case 'atomicSoftTabs': + this.updateAtomicSoftTabs(value, false, displayLayerParams); + break; + + case 'tabLength': + this.updateTabLength(value, false, displayLayerParams); + break; + + case 'softWrapped': + this.updateSoftWrapped(value, false, displayLayerParams); + break; + + case 'softWrapHangingIndentLength': + this.updateSoftWrapHangingIndentLength( + value, + false, + displayLayerParams + ); + break; + + case 'softWrapAtPreferredLineLength': + this.updateSoftWrapAtPreferredLineLength( + value, + false, + displayLayerParams + ); + break; + + case 'preferredLineLength': + this.updatePreferredLineLength(value, false, displayLayerParams); + break; + + case 'maxScreenLineLength': + this.updateMaxScreenLineLength(value, false, displayLayerParams); + break; + + case 'mini': + this.updateMini(value, false, displayLayerParams); + break; + + case 'readOnly': + this.updateReadOnly(value, false); + break; + + case 'keyboardInputEnabled': + this.updateKeyboardInputEnabled(value, false); + break; + + case 'placeholderText': + this.updatePlaceholderText(value, false); + break; + + case 'lineNumberGutterVisible': + this.updateLineNumberGutterVisible(value, false); + break; + + case 'showIndentGuide': + this.updateShowIndentGuide(value, false, displayLayerParams); + break; + + case 'showLineNumbers': + this.updateShowLineNumbers(value, false); + break; + + case 'showInvisibles': + this.updateShowInvisibles(value, false, displayLayerParams); + break; + + case 'invisibles': + this.updateInvisibles(value, false, displayLayerParams); + break; + + case 'editorWidthInChars': + this.updateEditorWidthInChars(value, false, displayLayerParams); + break; + + case 'width': + this.updateWidth(value, false, displayLayerParams); + break; + + case 'scrollPastEnd': + this.updateScrollPastEnd(value, false); + break; + + case 'autoHeight': + this.updateAutoHight(value, false); + break; + + case 'autoWidth': + this.updateAutoWidth(value, false); + break; + + case 'showCursorOnSelection': + this.updateShowCursorOnSelection(value, false); + break; + + default: + if (param !== 'ref' && param !== 'key') { + throw new TypeError(`Invalid TextEditor parameter: '${param}'`); + } + } + } + + return this.finishUpdate(displayLayerParams); + } + + finishUpdate(displayLayerParams = {}) { + this.displayLayer.reset(displayLayerParams); + + if (this.component) { + return this.component.getNextUpdatePromise(); + } else { + return Promise.resolve(); + } + } + + updateAutoIndent(value, finish) { + this.autoIndent = value; + if (finish) this.finishUpdate(); + } + + updateAutoIndentOnPaste(value, finish) { + this.autoIndentOnPaste = value; + if (finish) this.finishUpdate(); + } + + updateUndoGroupingInterval(value, finish) { + this.undoGroupingInterval = value; + if (finish) this.finishUpdate(); + } + + updateScrollSensitivity(value, finish) { + this.scrollSensitivity = value; + if (finish) this.finishUpdate(); + } + + updateEncoding(value, finish) { + this.buffer.setEncoding(value); + if (finish) this.finishUpdate(); + } + + updateSoftTabs(value, finish) { + if (value !== this.softTabs) { + this.softTabs = value; + } + if (finish) this.finishUpdate(); + } + + updateAtomicSoftTabs(value, finish, displayLayerParams = {}) { + if (value !== this.displayLayer.atomicSoftTabs) { + displayLayerParams.atomicSoftTabs = value; + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateTabLength(value, finish, displayLayerParams = {}) { + if (value > 0 && value !== this.displayLayer.tabLength) { + displayLayerParams.tabLength = value; + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateSoftWrapped(value, finish, displayLayerParams = {}) { + if (value !== this.softWrapped) { + this.softWrapped = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + this.emitter.emit('did-change-soft-wrapped', this.isSoftWrapped()); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateSoftWrapHangingIndentLength(value, finish, displayLayerParams = {}) { + if (value !== this.displayLayer.softWrapHangingIndent) { + displayLayerParams.softWrapHangingIndent = value; + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateSoftWrapAtPreferredLineLength(value, finish, displayLayerParams = {}) { + if (value !== this.softWrapAtPreferredLineLength) { + this.softWrapAtPreferredLineLength = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updatePreferredLineLength(value, finish, displayLayerParams = {}) { + if (value !== this.preferredLineLength) { + this.preferredLineLength = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateMaxScreenLineLength(value, finish, displayLayerParams = {}) { + if (value !== this.maxScreenLineLength) { + this.maxScreenLineLength = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateMini(value, finish, displayLayerParams = {}) { + if (value !== this.mini) { + this.mini = value; + this.emitter.emit('did-change-mini', value); + displayLayerParams.invisibles = this.getInvisibles(); + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + displayLayerParams.showIndentGuides = this.doesShowIndentGuide(); + if (this.mini) { + for (let decoration of this.cursorLineDecorations) { + decoration.destroy(); + } + this.cursorLineDecorations = null; + } else { + this.decorateCursorLine(); + } + if (this.component != null) { + this.component.scheduleUpdate(); + } + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateReadOnly(value, finish) { + if (value !== this.readOnly) { + this.readOnly = value; + if (this.component != null) { + this.component.scheduleUpdate(); + } + } + if (finish) this.finishUpdate(); + } + + updateKeyboardInputEnabled(value, finish) { + if (value !== this.keyboardInputEnabled) { + this.keyboardInputEnabled = value; + if (this.component != null) { + this.component.scheduleUpdate(); + } + } + if (finish) this.finishUpdate(); + } + + updatePlaceholderText(value, finish) { + if (value !== this.placeholderText) { + this.placeholderText = value; + this.emitter.emit('did-change-placeholder-text', value); + } + if (finish) this.finishUpdate(); + } + + updateLineNumberGutterVisible(value, finish) { + if (value !== this.lineNumberGutterVisible) { + if (value) { + this.lineNumberGutter.show(); + } else { + this.lineNumberGutter.hide(); + } + this.emitter.emit( + 'did-change-line-number-gutter-visible', + this.lineNumberGutter.isVisible() + ); + } + if (finish) this.finishUpdate(); + } + + updateShowIndentGuide(value, finish, displayLayerParams = {}) { + if (value !== this.showIndentGuide) { + this.showIndentGuide = value; + displayLayerParams.showIndentGuides = this.doesShowIndentGuide(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateShowLineNumbers(value, finish) { + if (value !== this.showLineNumbers) { + this.showLineNumbers = value; + if (this.component != null) { + this.component.scheduleUpdate(); + } + } + if (finish) this.finishUpdate(); + } + + updateShowInvisibles(value, finish, displayLayerParams = {}) { + if (value !== this.showInvisibles) { + this.showInvisibles = value; + displayLayerParams.invisibles = this.getInvisibles(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateInvisibles(value, finish, displayLayerParams = {}) { + if (!_.isEqual(value, this.invisibles)) { + this.invisibles = value; + displayLayerParams.invisibles = this.getInvisibles(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateEditorWidthInChars(value, finish, displayLayerParams = {}) { + if (value > 0 && value !== this.editorWidthInChars) { + this.editorWidthInChars = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateWidth(value, finish, displayLayerParams = {}) { + if (value !== this.width) { + this.width = value; + displayLayerParams.softWrapColumn = this.getSoftWrapColumn(); + } + if (finish) this.finishUpdate(displayLayerParams); + } + + updateScrollPastEnd(value, finish) { + if (value !== this.scrollPastEnd) { + this.scrollPastEnd = value; + if (this.component) this.component.scheduleUpdate(); + } + if (finish) this.finishUpdate(); + } + + updateAutoHight(value, finish) { + if (value !== this.autoHeight) { + this.autoHeight = value; + } + if (finish) this.finishUpdate(); + } + + updateAutoWidth(value, finish) { + if (value !== this.autoWidth) { + this.autoWidth = value; + } + if (finish) this.finishUpdate(); + } + + updateShowCursorOnSelection(value, finish) { + if (value !== this.showCursorOnSelection) { + this.showCursorOnSelection = value; + if (this.component) this.component.scheduleUpdate(); + } + if (finish) this.finishUpdate(); + } + + scheduleComponentUpdate() { + if (this.component) this.component.scheduleUpdate(); + } + + serialize() { + return { + deserializer: 'TextEditor', + version: SERIALIZATION_VERSION, + + 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, + readOnly2: this.readOnly, // readOnly encompassed both readOnly and keyboardInputEnabled + keyboardInputEnabled: this.keyboardInputEnabled, + editorWidthInChars: this.editorWidthInChars, + width: this.width, + maxScreenLineLength: this.maxScreenLineLength, + registered: this.registered, + invisibles: this.invisibles, + showInvisibles: this.showInvisibles, + showIndentGuide: this.showIndentGuide, + autoHeight: this.autoHeight, + autoWidth: this.autoWidth + }; + } + + 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()); + }) + ); + this.disposables.add( + this.buffer.onDidChangeEncoding(() => { + this.emitter.emit('did-change-encoding', this.getEncoding()); + }) + ); + this.disposables.add(this.buffer.onDidDestroy(() => this.destroy())); + this.disposables.add( + this.buffer.onDidChangeModified(() => { + if (!this.hasTerminatedPendingState && this.buffer.isModified()) + this.terminatePendingState(); + }) + ); + } + + terminatePendingState() { + if (!this.hasTerminatedPendingState) + this.emitter.emit('did-terminate-pending-state'); + this.hasTerminatedPendingState = true; + } + + onDidTerminatePendingState(callback) { + return this.emitter.on('did-terminate-pending-state', callback); + } + + subscribeToDisplayLayer() { + this.disposables.add( + this.displayLayer.onDidChange(changes => { + this.mergeIntersectingSelections(); + if (this.component) this.component.didChangeDisplayLayer(changes); + this.emitter.emit( + 'did-change', + changes.map(change => new ChangeEvent(change)) + ); + }) + ); + this.disposables.add( + this.displayLayer.onDidReset(() => { + this.mergeIntersectingSelections(); + if (this.component) this.component.didResetDisplayLayer(); + this.emitter.emit('did-change', {}); + }) + ); + this.disposables.add( + this.selectionsMarkerLayer.onDidCreateMarker(this.addSelection.bind(this)) + ); + return this.disposables.add( + this.selectionsMarkerLayer.onDidUpdate(() => + this.component != null + ? this.component.didUpdateSelections() + : undefined + ) + ); + } + + destroy() { + if (!this.alive) return; + this.alive = false; + this.disposables.dispose(); + this.displayLayer.destroy(); + for (let selection of this.selections.slice()) { + selection.destroy(); + } + this.buffer.release(); + this.gutterContainer.destroy(); + this.emitter.emit('did-destroy'); + this.emitter.clear(); + if (this.component) this.component.element.component = null; + this.component = null; + this.lineNumberGutter.element = null; + } + + isAlive() { + return this.alive; + } + + isDestroyed() { + return !this.alive; + } + + /* + Section: Event Subscription + */ + + // Essential: Calls your `callback` when the buffer's title has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeTitle(callback) { + return this.emitter.on('did-change-title', callback); + } + + // Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePath(callback) { + return this.emitter.on('did-change-path', callback); + } + + // Essential: Invoke the given callback synchronously when the content of the + // buffer changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider {::onDidStopChanging} to + // delay expensive operations until after changes stop occurring. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChange(callback) { + return this.emitter.on('did-change', callback); + } + + // Essential: Invoke `callback` when the buffer's contents change. It is + // emit asynchronously 300ms after the last buffer change. This is a good place + // to handle changes to the buffer without compromising typing performance. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChanging(callback) { + return this.getBuffer().onDidStopChanging(callback); + } + + // Essential: Calls your `callback` when a {Cursor} is moved. If there are + // multiple cursors, your callback will be called for each cursor. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferPosition` {Point} + // * `oldScreenPosition` {Point} + // * `newBufferPosition` {Point} + // * `newScreenPosition` {Point} + // * `textChanged` {Boolean} + // * `cursor` {Cursor} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeCursorPosition(callback) { + return this.emitter.on('did-change-cursor-position', callback); + } + + // Essential: Calls your `callback` when a selection's screen range changes. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferRange` {Range} + // * `oldScreenRange` {Range} + // * `newBufferRange` {Range} + // * `newScreenRange` {Range} + // * `selection` {Selection} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeSelectionRange(callback) { + return this.emitter.on('did-change-selection-range', callback); + } + + // Extended: Calls your `callback` when soft wrap was enabled or disabled. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeSoftWrapped(callback) { + return this.emitter.on('did-change-soft-wrapped', callback); + } + + // Extended: Calls your `callback` when the buffer's encoding has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeEncoding(callback) { + return this.emitter.on('did-change-encoding', callback); + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. Immediately calls your callback with + // the current grammar. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGrammar(callback) { + callback(this.getGrammar()); + return this.onDidChangeGrammar(callback); + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeGrammar(callback) { + return this.buffer.onDidChangeLanguageMode(() => { + callback(this.buffer.getLanguageMode().grammar); + }); + } + + // Extended: Calls your `callback` when the result of {::isModified} changes. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeModified(callback) { + return this.getBuffer().onDidChangeModified(callback); + } + + // Extended: Calls your `callback` when the buffer's underlying file changes on + // disk at a moment when the result of {::isModified} is true. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidConflict(callback) { + return this.getBuffer().onDidConflict(callback); + } + + // Extended: Calls your `callback` before text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // * `cancel` {Function} Call to prevent the text from being inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillInsertText(callback) { + return this.emitter.on('will-insert-text', callback); + } + + // Extended: Calls your `callback` after text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidInsertText(callback) { + return this.emitter.on('did-insert-text', callback); + } + + // Essential: Invoke the given callback after the buffer is saved to disk. + // + // * `callback` {Function} to be called after the buffer is saved. + // * `event` {Object} with the following keys: + // * `path` The path to which the buffer was saved. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidSave(callback) { + return this.getBuffer().onDidSave(callback); + } + + // Essential: Invoke the given callback when the editor is destroyed. + // + // * `callback` {Function} to be called when the editor is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy(callback) { + return this.emitter.once('did-destroy', callback); + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // Immediately calls your callback for each existing cursor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeCursors(callback) { + this.getCursors().forEach(callback); + return this.onDidAddCursor(callback); + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddCursor(callback) { + return this.emitter.on('did-add-cursor', callback); + } + + // Extended: Calls your `callback` when a {Cursor} is removed from the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveCursor(callback) { + return this.emitter.on('did-remove-cursor', callback); + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // Immediately calls your callback for each existing selection. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeSelections(callback) { + this.getSelections().forEach(callback); + return this.onDidAddSelection(callback); + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddSelection(callback) { + return this.emitter.on('did-add-selection', callback); + } + + // Extended: Calls your `callback` when a {Selection} is removed from the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveSelection(callback) { + return this.emitter.on('did-remove-selection', callback); + } + + // Extended: Calls your `callback` with each {Decoration} added to the editor. + // Calls your `callback` immediately for any existing decorations. + // + // * `callback` {Function} + // * `decoration` {Decoration} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeDecorations(callback) { + return this.decorationManager.observeDecorations(callback); + } + + // Extended: Calls your `callback` when a {Decoration} is added to the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddDecoration(callback) { + return this.decorationManager.onDidAddDecoration(callback); + } + + // Extended: Calls your `callback` when a {Decoration} is removed from the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveDecoration(callback) { + return this.decorationManager.onDidRemoveDecoration(callback); + } + + // Called by DecorationManager when a decoration is added. + didAddDecoration(decoration) { + if (this.component && decoration.isType('block')) { + this.component.addBlockDecoration(decoration); + } + } + + // Extended: Calls your `callback` when the placeholder text is changed. + // + // * `callback` {Function} + // * `placeholderText` {String} new text + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePlaceholderText(callback) { + return this.emitter.on('did-change-placeholder-text', callback); + } + + onDidChangeScrollTop(callback) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.' + ); + return this.getElement().onDidChangeScrollTop(callback); + } + + onDidChangeScrollLeft(callback) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::onDidChangeScrollLeft instead.' + ); + return this.getElement().onDidChangeScrollLeft(callback); + } + + onDidRequestAutoscroll(callback) { + return this.emitter.on('did-request-autoscroll', callback); + } + + // TODO Remove once the tabs package no longer uses .on subscriptions + onDidChangeIcon(callback) { + return this.emitter.on('did-change-icon', callback); + } + + onDidUpdateDecorations(callback) { + return this.decorationManager.onDidUpdateDecorations(callback); + } + + // Retrieves the current buffer's URI. + getURI() { + return this.buffer.getUri(); + } + + // Create an {TextEditor} with its initial state based on this object + copy() { + const displayLayer = this.displayLayer.copy(); + const selectionsMarkerLayer = displayLayer.getMarkerLayer( + this.buffer.getMarkerLayer(this.selectionsMarkerLayer.id).copy().id + ); + const softTabs = this.getSoftTabs(); + return new TextEditor({ + buffer: this.buffer, + selectionsMarkerLayer, + softTabs, + suppressCursorCreation: true, + tabLength: this.getTabLength(), + initialScrollTopRow: this.getScrollTopRow(), + initialScrollLeftColumn: this.getScrollLeftColumn(), + assert: this.assert, + displayLayer, + grammar: this.getGrammar(), + autoWidth: this.autoWidth, + autoHeight: this.autoHeight, + showCursorOnSelection: this.showCursorOnSelection + }); + } + + // Controls visibility based on the given {Boolean}. + setVisible(visible) { + if (visible) { + const languageMode = this.buffer.getLanguageMode(); + if (languageMode.startTokenizing) languageMode.startTokenizing(); + } + } + + setMini(mini) { + this.updateMini(mini, true); + } + + isMini() { + return this.mini; + } + + setReadOnly(readOnly) { + this.updateReadOnly(readOnly, true); + } + + isReadOnly() { + return this.readOnly; + } + + enableKeyboardInput(enabled) { + this.updateKeyboardInputEnabled(enabled, true); + } + + isKeyboardInputEnabled() { + return this.keyboardInputEnabled; + } + + onDidChangeMini(callback) { + return this.emitter.on('did-change-mini', callback); + } + + setLineNumberGutterVisible(lineNumberGutterVisible) { + this.updateLineNumberGutterVisible(lineNumberGutterVisible, true); + } + + isLineNumberGutterVisible() { + return this.lineNumberGutter.isVisible(); + } + + anyLineNumberGutterVisible() { + return this.getGutters().some( + gutter => gutter.type === 'line-number' && gutter.visible + ); + } + + onDidChangeLineNumberGutterVisible(callback) { + return this.emitter.on('did-change-line-number-gutter-visible', callback); + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // Immediately calls your callback for each existing gutter. + // + // * `callback` {Function} + // * `gutter` {Gutter} that currently exists/was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGutters(callback) { + return this.gutterContainer.observeGutters(callback); + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // + // * `callback` {Function} + // * `gutter` {Gutter} that was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddGutter(callback) { + return this.gutterContainer.onDidAddGutter(callback); + } + + // Essential: Calls your `callback` when a {Gutter} is removed from the editor. + // + // * `callback` {Function} + // * `name` The name of the {Gutter} that was removed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveGutter(callback) { + return this.gutterContainer.onDidRemoveGutter(callback); + } + + // Set the number of characters that can be displayed horizontally in the + // editor. + // + // * `editorWidthInChars` A {Number} representing the width of the + // {TextEditorElement} in characters. + setEditorWidthInChars(editorWidthInChars) { + this.updateEditorWidthInChars(editorWidthInChars, true); + } + + // Returns the editor width in characters. + getEditorWidthInChars() { + if (this.width != null && this.defaultCharWidth > 0) { + return Math.max(0, Math.floor(this.width / this.defaultCharWidth)); + } else { + return this.editorWidthInChars; + } + } + + /* + Section: Buffer + */ + + // Essential: Retrieves the current {TextBuffer}. + getBuffer() { + return this.buffer; + } + + /* + Section: File Details + */ + + // Essential: Get the editor's title for display in other parts of the + // UI such as the tabs. + // + // If the editor's buffer is saved, its title is the file name. If it is + // unsaved, its title is "untitled". + // + // Returns a {String}. + getTitle() { + return this.getFileName() || 'untitled'; + } + + // Essential: Get unique title for display in other parts of the UI, such as + // the window title. + // + // If the editor's buffer is unsaved, its title is "untitled" + // If the editor's buffer is saved, its unique title is formatted as one + // of the following, + // * "" when it is the only editing buffer with this file name. + // * "" when other buffers have this file name. + // + // Returns a {String} + getLongTitle() { + if (this.getPath()) { + const fileName = this.getFileName(); + + let myPathSegments; + const openEditorPathSegmentsWithSameFilename = []; + for (const textEditor of atom.workspace.getTextEditors()) { + if (textEditor.getFileName() === fileName) { + const pathSegments = fs + .tildify(textEditor.getDirectoryPath()) + .split(path.sep); + openEditorPathSegmentsWithSameFilename.push(pathSegments); + if (textEditor === this) myPathSegments = pathSegments; + } + } + + if ( + !myPathSegments || + openEditorPathSegmentsWithSameFilename.length === 1 + ) + return fileName; + + let commonPathSegmentCount; + for (let i = 0, { length } = myPathSegments; i < length; i++) { + const myPathSegment = myPathSegments[i]; + if ( + openEditorPathSegmentsWithSameFilename.some( + segments => + segments.length === i + 1 || segments[i] !== myPathSegment + ) + ) { + commonPathSegmentCount = i; + break; + } + } + + return `${fileName} \u2014 ${path.join( + ...myPathSegments.slice(commonPathSegmentCount) + )}`; + } else { + return 'untitled'; + } + } + + // Essential: Returns the {String} path of this editor's text buffer. + getPath() { + return this.buffer.getPath(); + } + + getFileName() { + const fullPath = this.getPath(); + if (fullPath) return path.basename(fullPath); + } + + getDirectoryPath() { + const fullPath = this.getPath(); + if (fullPath) return path.dirname(fullPath); + } + + // Extended: Returns the {String} character set encoding of this editor's text + // buffer. + getEncoding() { + return this.buffer.getEncoding(); + } + + // Extended: Set the character set encoding to use in this editor's text + // buffer. + // + // * `encoding` The {String} character set encoding name such as 'utf8' + setEncoding(encoding) { + this.buffer.setEncoding(encoding); + } + + // Essential: Returns {Boolean} `true` if this editor has been modified. + isModified() { + return this.buffer.isModified(); + } + + // Essential: Returns {Boolean} `true` if this editor has no content. + isEmpty() { + return this.buffer.isEmpty(); + } + + /* + Section: File Operations + */ + + // Essential: Saves the editor's text buffer. + // + // See {TextBuffer::save} for more details. + save() { + return this.buffer.save(); + } + + // Essential: Saves the editor's text buffer as the given path. + // + // See {TextBuffer::saveAs} for more details. + // + // * `filePath` A {String} path. + saveAs(filePath) { + return this.buffer.saveAs(filePath); + } + + // Determine whether the user should be prompted to save before closing + // this editor. + shouldPromptToSave({ windowCloseRequested, projectHasPaths } = {}) { + if ( + windowCloseRequested && + projectHasPaths && + atom.stateStore.isConnected() + ) { + return this.buffer.isInConflict(); + } else { + return this.isModified() && !this.buffer.hasMultipleEditors(); + } + } + + // Returns an {Object} to configure dialog shown when this editor is saved + // via {Pane::saveItemAs}. + getSaveDialogOptions() { + return {}; + } + + /* + Section: Reading Text + */ + + // Essential: Returns a {String} representing the entire contents of the editor. + getText() { + return this.buffer.getText(); + } + + // Essential: Get the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // + // Returns a {String}. + getTextInBufferRange(range) { + return this.buffer.getTextInRange(range); + } + + // Essential: Returns a {Number} representing the number of lines in the buffer. + getLineCount() { + return this.buffer.getLineCount(); + } + + // Essential: Returns a {Number} representing the number of screen lines in the + // editor. This accounts for folds. + getScreenLineCount() { + return this.displayLayer.getScreenLineCount(); + } + + getApproximateScreenLineCount() { + return this.displayLayer.getApproximateScreenLineCount(); + } + + // Essential: Returns a {Number} representing the last zero-indexed buffer row + // number of the editor. + getLastBufferRow() { + return this.buffer.getLastRow(); + } + + // Essential: Returns a {Number} representing the last zero-indexed screen row + // number of the editor. + getLastScreenRow() { + return this.getScreenLineCount() - 1; + } + + // Essential: Returns a {String} representing the contents of the line at the + // given buffer row. + // + // * `bufferRow` A {Number} representing a zero-indexed buffer row. + lineTextForBufferRow(bufferRow) { + return this.buffer.lineForRow(bufferRow); + } + + // Essential: Returns a {String} representing the contents of the line at the + // given screen row. + // + // * `screenRow` A {Number} representing a zero-indexed screen row. + lineTextForScreenRow(screenRow) { + const screenLine = this.screenLineForScreenRow(screenRow); + if (screenLine) return screenLine.lineText; + } + + logScreenLines(start = 0, end = this.getLastScreenRow()) { + for (let row = start; row <= end; row++) { + const line = this.lineTextForScreenRow(row); + console.log(row, this.bufferRowForScreenRow(row), line, line.length); + } + } + + tokensForScreenRow(screenRow) { + const tokens = []; + let lineTextIndex = 0; + const currentTokenScopes = []; + const { lineText, tags } = this.screenLineForScreenRow(screenRow); + for (const tag of tags) { + if (this.displayLayer.isOpenTag(tag)) { + currentTokenScopes.push(this.displayLayer.classNameForTag(tag)); + } else if (this.displayLayer.isCloseTag(tag)) { + currentTokenScopes.pop(); + } else { + tokens.push({ + text: lineText.substr(lineTextIndex, tag), + scopes: currentTokenScopes.slice() + }); + lineTextIndex += tag; + } + } + return tokens; + } + + screenLineForScreenRow(screenRow) { + return this.displayLayer.getScreenLine(screenRow); + } + + bufferRowForScreenRow(screenRow) { + return this.displayLayer.translateScreenPosition(Point(screenRow, 0)).row; + } + + bufferRowsForScreenRows(startScreenRow, endScreenRow) { + return this.displayLayer.bufferRowsForScreenRows( + startScreenRow, + endScreenRow + 1 + ); + } + + screenRowForBufferRow(row) { + return this.displayLayer.translateBufferPosition(Point(row, 0)).row; + } + + getRightmostScreenPosition() { + return this.displayLayer.getRightmostScreenPosition(); + } + + getApproximateRightmostScreenPosition() { + return this.displayLayer.getApproximateRightmostScreenPosition(); + } + + getMaxScreenLineLength() { + return this.getRightmostScreenPosition().column; + } + + getLongestScreenRow() { + return this.getRightmostScreenPosition().row; + } + + getApproximateLongestScreenRow() { + return this.getApproximateRightmostScreenPosition().row; + } + + lineLengthForScreenRow(screenRow) { + return this.displayLayer.lineLengthForScreenRow(screenRow); + } + + // Returns the range for the given buffer row. + // + // * `row` A row {Number}. + // * `options` (optional) An options hash with an `includeNewline` key. + // + // Returns a {Range}. + bufferRangeForBufferRow(row, options) { + return this.buffer.rangeForRow(row, options && options.includeNewline); + } + + // Get the text in the given {Range}. + // + // Returns a {String}. + getTextInRange(range) { + return this.buffer.getTextInRange(range); + } + + // {Delegates to: TextBuffer.isRowBlank} + isBufferRowBlank(bufferRow) { + return this.buffer.isRowBlank(bufferRow); + } + + // {Delegates to: TextBuffer.nextNonBlankRow} + nextNonBlankBufferRow(bufferRow) { + return this.buffer.nextNonBlankRow(bufferRow); + } + + // {Delegates to: TextBuffer.getEndPosition} + getEofBufferPosition() { + return this.buffer.getEndPosition(); + } + + // Essential: Get the {Range} of the paragraph surrounding the most recently added + // cursor. + // + // Returns a {Range}. + getCurrentParagraphBufferRange() { + return this.getLastCursor().getCurrentParagraphBufferRange(); + } + + /* + Section: Mutating Text + */ + + // Essential: Replaces the entire contents of the buffer with the given {String}. + // + // * `text` A {String} to replace with + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + setText(text, options = {}) { + if (!this.ensureWritable('setText', options)) return; + return this.buffer.setText(text); + } + + // Essential: Set the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // * `text` A {String} + // * `options` (optional) {Object} + // * `normalizeLineEndings` (optional) {Boolean} (default: true) + // * `undo` (optional) *Deprecated* {String} 'skip' will skip the undo system. This property is deprecated. Call groupLastChanges() on the {TextBuffer} afterward instead. + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + // + // Returns the {Range} of the newly-inserted text. + setTextInBufferRange(range, text, options = {}) { + if (!this.ensureWritable('setTextInBufferRange', options)) return; + return this.getBuffer().setTextInRange(range, text, options); + } + + // Essential: For each selection, replace the selected text with the given text. + // + // * `text` A {String} representing the text to insert. + // * `options` (optional) See {Selection::insertText}. + // + // Returns a {Range} when the text has been inserted. Returns a {Boolean} `false` when the text has not been inserted. + insertText(text, options = {}) { + if (!this.ensureWritable('insertText', options)) return; + 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(); + 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. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewline(options = {}) { + return this.insertText('\n', options); + } + + // Essential: For each selection, if the selection is empty, delete the character + // following the cursor. Otherwise delete the selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + delete(options = {}) { + if (!this.ensureWritable('delete', options)) return; + return this.mutateSelectedText(selection => selection.delete(options)); + } + + // Essential: For each selection, if the selection is empty, delete the character + // preceding the cursor. Otherwise delete the selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + backspace(options = {}) { + if (!this.ensureWritable('backspace', options)) return; + return this.mutateSelectedText(selection => selection.backspace(options)); + } + + // Extended: Mutate the text of all the selections in a single transaction. + // + // All the changes made inside the given {Function} can be reverted with a + // single call to {::undo}. + // + // * `fn` A {Function} that will be called once for each {Selection}. The first + // argument will be a {Selection} and the second argument will be the + // {Number} index of that selection. + mutateSelectedText(fn, groupingInterval = 0) { + return this.mergeIntersectingSelections(() => { + return this.transact(groupingInterval, () => { + return this.getSelectionsOrderedByBufferPosition().map( + (selection, index) => fn(selection, index) + ); + }); + }); + } + + // Move lines intersecting the most recent selection or multiple selections + // up by one row in screen coordinates. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveLineUp(options = {}) { + if (!this.ensureWritable('moveLineUp', options)) return; + + const selections = this.getSelectedBufferRanges().sort((a, b) => + a.compare(b) + ); + + if (selections[0].start.row === 0) return; + if ( + selections[selections.length - 1].start.row === this.getLastBufferRow() && + this.buffer.getLastLine() === '' + ) + return; + + this.transact(() => { + const newSelectionRanges = []; + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift(); + const selectionsToMove = [selection]; + + while ( + selection.end.row === + (selections[0] != null ? selections[0].start.row : undefined) + ) { + selectionsToMove.push(selections[0]); + selection.end.row = selections[0].end.row; + selections.shift(); + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row; + let endRow = selection.end.row; + if ( + selection.end.row > selection.start.row && + selection.end.column === 0 + ) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow--; + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow); + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1); + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)); + + // If selected line range is preceded by a fold, one line above on screen + // could be multiple lines in the buffer. + const precedingRow = this.displayLayer.findBoundaryPrecedingBufferRow( + startRow - 1 + ); + const insertDelta = linesRange.start.row - precedingRow; + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([-insertDelta, 0])); + + // Delete lines spanned by selection and insert them on the preceding buffer row + let lines = this.buffer.getTextInRange(linesRange); + if (lines[lines.length - 1] !== '\n') { + lines += this.buffer.lineEndingForRow(linesRange.end.row - 2); + } + this.buffer.delete(linesRange); + this.buffer.insert([precedingRow, 0], lines); + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold); + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([-insertDelta, 0])); + } + } + + this.setSelectedBufferRanges(newSelectionRanges, { + autoscroll: false, + preserveFolds: true + }); + if (this.shouldAutoIndent()) this.autoIndentSelectedRows(); + this.scrollToBufferPosition([newSelectionRanges[0].start.row, 0]); + }); + } + + // Move lines intersecting the most recent selection or multiple selections + // down by one row in screen coordinates. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveLineDown(options = {}) { + if (!this.ensureWritable('moveLineDown', options)) return; + + const selections = this.getSelectedBufferRanges(); + selections.sort((a, b) => b.compare(a)); + + this.transact(() => { + this.consolidateSelections(); + const newSelectionRanges = []; + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift(); + const selectionsToMove = [selection]; + + // if the current selection start row matches the next selections' end row - make them one selection + while ( + selection.start.row === + (selections[0] != null ? selections[0].end.row : undefined) + ) { + selectionsToMove.push(selections[0]); + selection.start.row = selections[0].start.row; + selections.shift(); + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row; + let endRow = selection.end.row; + if ( + selection.end.row > selection.start.row && + selection.end.column === 0 + ) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow--; + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow); + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1); + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)); + + // If selected line range is followed by a fold, one line below on screen + // could be multiple lines in the buffer. But at the same time, if the + // next buffer row is wrapped, one line in the buffer can represent many + // screen rows. + const followingRow = Math.min( + this.buffer.getLineCount(), + this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1) + ); + const insertDelta = followingRow - linesRange.end.row; + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([insertDelta, 0])); + + // Delete lines spanned by selection and insert them on the following correct buffer row + let lines = this.buffer.getTextInRange(linesRange); + if (followingRow - 1 === this.buffer.getLastRow()) { + lines = `\n${lines}`; + } + + this.buffer.insert([followingRow, 0], lines); + this.buffer.delete(linesRange); + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold); + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([insertDelta, 0])); + } + } + + this.setSelectedBufferRanges(newSelectionRanges, { + autoscroll: false, + preserveFolds: true + }); + if (this.shouldAutoIndent()) this.autoIndentSelectedRows(); + this.scrollToBufferPosition([newSelectionRanges[0].start.row - 1, 0]); + }); + } + + // Move any active selections one column to the left. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveSelectionLeft(options = {}) { + if (!this.ensureWritable('moveSelectionLeft', options)) return; + const selections = this.getSelectedBufferRanges(); + const noSelectionAtStartOfLine = selections.every( + selection => selection.start.column !== 0 + ); + + const translationDelta = [0, -1]; + const translatedRanges = []; + + if (noSelectionAtStartOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToLeftOfSelection = new Range( + selection.start.translate(translationDelta), + selection.start + ); + const charTextToLeftOfSelection = this.buffer.getTextInRange( + charToLeftOfSelection + ); + + this.buffer.insert(selection.end, charTextToLeftOfSelection); + this.buffer.delete(charToLeftOfSelection); + translatedRanges.push(selection.translate(translationDelta)); + } + + this.setSelectedBufferRanges(translatedRanges); + }); + } + } + + // Move any active selections one column to the right. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveSelectionRight(options = {}) { + if (!this.ensureWritable('moveSelectionRight', options)) return; + const selections = this.getSelectedBufferRanges(); + const noSelectionAtEndOfLine = selections.every(selection => { + return ( + selection.end.column !== this.buffer.lineLengthForRow(selection.end.row) + ); + }); + + const translationDelta = [0, 1]; + const translatedRanges = []; + + if (noSelectionAtEndOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToRightOfSelection = new Range( + selection.end, + selection.end.translate(translationDelta) + ); + const charTextToRightOfSelection = this.buffer.getTextInRange( + charToRightOfSelection + ); + + this.buffer.delete(charToRightOfSelection); + this.buffer.insert(selection.start, charTextToRightOfSelection); + translatedRanges.push(selection.translate(translationDelta)); + } + + this.setSelectedBufferRanges(translatedRanges); + }); + } + } + + // Duplicate all lines containing active selections. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + duplicateLines(options = {}) { + if (!this.ensureWritable('duplicateLines', options)) return; + this.transact(() => { + const selections = this.getSelectionsOrderedByBufferPosition(); + const previousSelectionRanges = []; + + let i = selections.length - 1; + while (i >= 0) { + const j = i; + previousSelectionRanges[i] = selections[i].getBufferRange(); + if (selections[i].isEmpty()) { + const { start } = selections[i].getScreenRange(); + selections[i].setScreenRange([[start.row, 0], [start.row + 1, 0]], { + preserveFolds: true + }); + } + let [startRow, endRow] = selections[i].getBufferRowRange(); + endRow++; + while (i > 0) { + const [ + previousSelectionStartRow, + previousSelectionEndRow + ] = selections[i - 1].getBufferRowRange(); + if (previousSelectionEndRow === startRow) { + startRow = previousSelectionStartRow; + previousSelectionRanges[i - 1] = selections[i - 1].getBufferRange(); + i--; + } else { + break; + } + } + + const intersectingFolds = this.displayLayer.foldsIntersectingBufferRange( + [[startRow, 0], [endRow, 0]] + ); + let textToDuplicate = this.getTextInBufferRange([ + [startRow, 0], + [endRow, 0] + ]); + if (endRow > this.getLastBufferRow()) + textToDuplicate = `\n${textToDuplicate}`; + this.buffer.insert([endRow, 0], textToDuplicate); + + const insertedRowCount = endRow - startRow; + + for (let k = i; k <= j; k++) { + selections[k].setBufferRange( + previousSelectionRanges[k].translate([insertedRowCount, 0]) + ); + } + + for (const fold of intersectingFolds) { + const foldRange = this.displayLayer.bufferRangeForFold(fold); + this.displayLayer.foldBufferRange( + foldRange.translate([insertedRowCount, 0]) + ); + } + + i--; + } + }); + } + + replaceSelectedText(options, fn) { + this.mutateSelectedText(selection => { + selection.getBufferRange(); + if (options && options.selectWordIfEmpty && selection.isEmpty()) { + selection.selectWord(); + } + const text = selection.getText(); + selection.deleteSelectedText(); + const range = selection.insertText(fn(text)); + selection.setBufferRange(range); + }); + } + + // Split multi-line selections into one selection per line. + // + // Operates on all selections. This method breaks apart all multi-line + // selections to create multiple single-line selections that cumulatively cover + // the same original area. + splitSelectionsIntoLines() { + this.mergeIntersectingSelections(() => { + for (const selection of this.getSelections()) { + const range = selection.getBufferRange(); + if (range.isSingleLine()) continue; + + const { start, end } = range; + this.addSelectionForBufferRange([start, [start.row, Infinity]]); + let { row } = start; + while (++row < end.row) { + this.addSelectionForBufferRange([[row, 0], [row, Infinity]]); + } + if (end.column !== 0) + this.addSelectionForBufferRange([ + [end.row, 0], + [end.row, end.column] + ]); + selection.destroy(); + } + }); + } + + // Extended: For each selection, transpose the selected text. + // + // If the selection is empty, the characters preceding and following the cursor + // are swapped. Otherwise, the selected characters are reversed. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + transpose(options = {}) { + if (!this.ensureWritable('transpose', options)) return; + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectRight(); + const text = selection.getText(); + selection.delete(); + selection.cursor.moveLeft(); + selection.insertText(text); + } else { + selection.insertText( + selection + .getText() + .split('') + .reverse() + .join('') + ); + } + }); + } + + // Extended: Convert the selected text to upper case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + upperCase(options = {}) { + if (!this.ensureWritable('upperCase', options)) return; + this.replaceSelectedText({ selectWordIfEmpty: true }, text => + text.toUpperCase(options) + ); + } + + // Extended: Convert the selected text to lower case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + lowerCase(options = {}) { + if (!this.ensureWritable('lowerCase', options)) return; + this.replaceSelectedText({ selectWordIfEmpty: true }, text => + text.toLowerCase(options) + ); + } + + // Extended: Toggle line comments for rows intersecting selections. + // + // If the current grammar doesn't support comments, does nothing. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + toggleLineCommentsInSelection(options = {}) { + if (!this.ensureWritable('toggleLineCommentsInSelection', options)) return; + this.mutateSelectedText(selection => selection.toggleLineComments(options)); + } + + // Convert multiple lines to a single line. + // + // Operates on all selections. If the selection is empty, joins the current + // line with the next line. Otherwise it joins all lines that intersect the + // selection. + // + // Joining a line means that multiple lines are converted to a single line with + // the contents of each of the original non-empty lines separated by a space. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + joinLines(options = {}) { + if (!this.ensureWritable('joinLines', options)) return; + this.mutateSelectedText(selection => selection.joinLines()); + } + + // Extended: For each cursor, insert a newline at beginning the following line. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewlineBelow(options = {}) { + if (!this.ensureWritable('insertNewlineBelow', options)) return; + this.transact(() => { + this.moveToEndOfLine(); + this.insertNewline(options); + }); + } + + // Extended: For each cursor, insert a newline at the end of the preceding line. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewlineAbove(options = {}) { + if (!this.ensureWritable('insertNewlineAbove', options)) return; + this.transact(() => { + const bufferRow = this.getCursorBufferPosition().row; + const indentLevel = this.indentationForBufferRow(bufferRow); + const onFirstLine = bufferRow === 0; + + this.moveToBeginningOfLine(); + this.moveLeft(); + this.insertNewline(options); + + if ( + this.shouldAutoIndent() && + this.indentationForBufferRow(bufferRow) < indentLevel + ) { + this.setIndentationForBufferRow(bufferRow, indentLevel); + } + + if (onFirstLine) { + this.moveUp(); + this.moveToEndOfLine(); + } + }); + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word that precede the cursor. Otherwise delete the + // selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfWord(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfWord', options)) return; + this.mutateSelectedText(selection => + selection.deleteToBeginningOfWord(options) + ); + } + + // Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the + // previous word boundary. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToPreviousWordBoundary(options = {}) { + if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return; + this.mutateSelectedText(selection => + selection.deleteToPreviousWordBoundary(options) + ); + } + + // Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the + // next word boundary. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToNextWordBoundary(options = {}) { + if (!this.ensureWritable('deleteToNextWordBoundary', options)) return; + this.mutateSelectedText(selection => + selection.deleteToNextWordBoundary(options) + ); + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfSubword(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return; + this.mutateSelectedText(selection => + selection.deleteToBeginningOfSubword(options) + ); + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfSubword(options = {}) { + if (!this.ensureWritable('deleteToEndOfSubword', options)) return; + this.mutateSelectedText(selection => + selection.deleteToEndOfSubword(options) + ); + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing line that precede the cursor. Otherwise delete the + // selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfLine(options = {}) { + if (!this.ensureWritable('deleteToBeginningOfLine', options)) return; + this.mutateSelectedText(selection => + selection.deleteToBeginningOfLine(options) + ); + } + + // Extended: For each selection, if the selection is not empty, deletes the + // selection; otherwise, deletes all characters of the containing line + // following the cursor. If the cursor is already at the end of the line, + // deletes the following newline. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfLine(options = {}) { + if (!this.ensureWritable('deleteToEndOfLine', options)) return; + this.mutateSelectedText(selection => selection.deleteToEndOfLine(options)); + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word following the cursor. Otherwise delete the selected + // text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfWord(options = {}) { + if (!this.ensureWritable('deleteToEndOfWord', options)) return; + this.mutateSelectedText(selection => selection.deleteToEndOfWord(options)); + } + + // Extended: Delete all lines intersecting selections. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteLine(options = {}) { + if (!this.ensureWritable('deleteLine', options)) return; + this.mergeSelectionsOnSameRows(); + this.mutateSelectedText(selection => selection.deleteLine(options)); + } + + // Private: Ensure that this editor is not marked read-only before allowing a buffer modification to occur. If + // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error. + ensureWritable(methodName, opts) { + if (!opts.bypassReadOnly && this.isReadOnly()) { + if (atom.inDevMode() || atom.inSpecMode()) { + const e = new Error('Attempt to mutate a read-only TextEditor'); + e.detail = + `Your package is attempting to call ${methodName} on an editor that has been marked read-only. ` + + 'Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before attempting ' + + 'modifications.'; + throw e; + } + + return false; + } + + return true; + } + + /* + Section: History + */ + + // Essential: Undo the last change. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + undo(options = {}) { + if (!this.ensureWritable('undo', options)) return; + this.avoidMergingSelections(() => + this.buffer.undo({ selectionsMarkerLayer: this.selectionsMarkerLayer }) + ); + this.getLastSelection().autoscroll(); + } + + // Essential: Redo the last change. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + redo(options = {}) { + if (!this.ensureWritable('redo', options)) return; + this.avoidMergingSelections(() => + this.buffer.redo({ selectionsMarkerLayer: this.selectionsMarkerLayer }) + ); + this.getLastSelection().autoscroll(); + } + + // Extended: Batch multiple operations as a single undo/redo step. + // + // Any group of operations that are logically grouped from the perspective of + // undoing and redoing should be performed in a transaction. If you want to + // abort the transaction, call {::abortTransaction} to terminate the function's + // execution and revert any changes performed up to the abortion. + // + // * `groupingInterval` (optional) The {Number} of milliseconds for which this + // transaction should be considered 'groupable' after it begins. If a transaction + // with a positive `groupingInterval` is committed while the previous transaction is + // still 'groupable', the two transactions are merged with respect to undo and redo. + // * `fn` A {Function} to call inside the transaction. + transact(groupingInterval, fn) { + const options = { selectionsMarkerLayer: this.selectionsMarkerLayer }; + if (typeof groupingInterval === 'function') { + fn = groupingInterval; + } else { + options.groupingInterval = groupingInterval; + } + return this.buffer.transact(options, fn); + } + + // Extended: Abort an open transaction, undoing any operations performed so far + // within the transaction. + abortTransaction() { + return this.buffer.abortTransaction(); + } + + // Extended: Create a pointer to the current state of the buffer for use + // with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. + // + // Returns a checkpoint value. + createCheckpoint() { + return this.buffer.createCheckpoint({ + selectionsMarkerLayer: this.selectionsMarkerLayer + }); + } + + // Extended: Revert the buffer to the state it was in when the given + // checkpoint was created. + // + // The redo stack will be empty following this operation, so changes since the + // checkpoint will be lost. If the given checkpoint is no longer present in the + // undo history, no changes will be made to the buffer and this method will + // return `false`. + // + // * `checkpoint` The checkpoint to revert to. + // + // Returns a {Boolean} indicating whether the operation succeeded. + revertToCheckpoint(checkpoint) { + return this.buffer.revertToCheckpoint(checkpoint); + } + + // Extended: Group all changes since the given checkpoint into a single + // transaction for purposes of undo/redo. + // + // If the given checkpoint is no longer present in the undo history, no + // grouping will be performed and this method will return `false`. + // + // * `checkpoint` The checkpoint from which to group changes. + // + // Returns a {Boolean} indicating whether the operation succeeded. + groupChangesSinceCheckpoint(checkpoint) { + return this.buffer.groupChangesSinceCheckpoint(checkpoint, { + selectionsMarkerLayer: this.selectionsMarkerLayer + }); + } + + /* + Section: TextEditor Coordinates + */ + + // Essential: Convert a position in buffer-coordinates to screen-coordinates. + // + // The position is clipped via {::clipBufferPosition} prior to the conversion. + // The position is also clipped via {::clipScreenPosition} following the + // conversion, which only makes a difference when `options` are supplied. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + screenPositionForBufferPosition(bufferPosition, options) { + if (options && options.clip) { + Grim.deprecate( + 'The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.' + ); + if (options.clipDirection) options.clipDirection = options.clip; + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate( + "The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapAtSoftNewlines + ? 'forward' + : 'backward'; + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate( + "The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapBeyondNewlines + ? 'forward' + : 'backward'; + } + + return this.displayLayer.translateBufferPosition(bufferPosition, options); + } + + // Essential: Convert a position in screen-coordinates to buffer-coordinates. + // + // The position is clipped via {::clipScreenPosition} prior to the conversion. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + bufferPositionForScreenPosition(screenPosition, options) { + if (options && options.clip) { + Grim.deprecate( + 'The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.' + ); + if (options.clipDirection) options.clipDirection = options.clip; + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate( + "The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapAtSoftNewlines + ? 'forward' + : 'backward'; + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate( + "The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapBeyondNewlines + ? 'forward' + : 'backward'; + } + + return this.displayLayer.translateScreenPosition(screenPosition, options); + } + + // Essential: Convert a range in buffer-coordinates to screen-coordinates. + // + // * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. + // + // Returns a {Range}. + screenRangeForBufferRange(bufferRange, options) { + bufferRange = Range.fromObject(bufferRange); + const start = this.screenPositionForBufferPosition( + bufferRange.start, + options + ); + const end = this.screenPositionForBufferPosition(bufferRange.end, options); + return new Range(start, end); + } + + // Essential: Convert a range in screen-coordinates to buffer-coordinates. + // + // * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. + // + // Returns a {Range}. + bufferRangeForScreenRange(screenRange) { + screenRange = Range.fromObject(screenRange); + const start = this.bufferPositionForScreenPosition(screenRange.start); + const end = this.bufferPositionForScreenPosition(screenRange.end); + return new Range(start, end); + } + + // Extended: Clip the given {Point} to a valid position in the buffer. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the buffer, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```js + // editor.clipBufferPosition([-1, -1]) // -> `[0, 0]` + // + // // When the line at buffer row 2 is 10 characters long + // editor.clipBufferPosition([2, Infinity]) // -> `[2, 10]` + // ``` + // + // * `bufferPosition` The {Point} representing the position to clip. + // + // Returns a {Point}. + clipBufferPosition(bufferPosition) { + return this.buffer.clipPosition(bufferPosition); + } + + // Extended: Clip the start and end of the given range to valid positions in the + // buffer. See {::clipBufferPosition} for more information. + // + // * `range` The {Range} to clip. + // + // Returns a {Range}. + clipBufferRange(range) { + return this.buffer.clipRange(range); + } + + // Extended: Clip the given {Point} to a valid position on screen. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the screen, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```js + // editor.clipScreenPosition([-1, -1]) // -> `[0, 0]` + // + // // When the line at screen row 2 is 10 characters long + // editor.clipScreenPosition([2, Infinity]) // -> `[2, 10]` + // ``` + // + // * `screenPosition` The {Point} representing the position to clip. + // * `options` (optional) {Object} + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {Point}. + clipScreenPosition(screenPosition, options) { + if (options && options.clip) { + Grim.deprecate( + 'The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.' + ); + if (options.clipDirection) options.clipDirection = options.clip; + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate( + "The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapAtSoftNewlines + ? 'forward' + : 'backward'; + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate( + "The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapBeyondNewlines + ? 'forward' + : 'backward'; + } + + return this.displayLayer.clipScreenPosition(screenPosition, options); + } + + // Extended: Clip the start and end of the given range to valid positions on screen. + // See {::clipScreenPosition} for more information. + // + // * `range` The {Range} to clip. + // * `options` (optional) See {::clipScreenPosition} `options`. + // + // Returns a {Range}. + clipScreenRange(screenRange, options) { + screenRange = Range.fromObject(screenRange); + const start = this.displayLayer.clipScreenPosition( + screenRange.start, + options + ); + const end = this.displayLayer.clipScreenPosition(screenRange.end, options); + return Range(start, end); + } + + /* + Section: Decorations + */ + + // Essential: Add a decoration that tracks a {DisplayMarker}. When the + // marker moves, is invalidated, or is destroyed, the decoration will be + // updated to reflect the marker's state. + // + // The following are the supported decorations types: + // + // * __line__: Adds the given CSS `class` to the lines overlapping the rows + // spanned by the marker. + // * __line-number__: Adds the given CSS `class` to the line numbers overlapping + // the rows spanned by the marker + // * __text__: Injects spans into all text overlapping the marked range, then adds + // the given `class` or `style` to these spans. Use this to manipulate the foreground + // color or styling of text in a range. + // * __highlight__: Creates an absolutely-positioned `.highlight` div to the editor + // containing nested divs that cover the marked region. For example, when the user + // selects text, the selection is implemented with a highlight decoration. The structure + // of this highlight will be: + // ```html + //
    + // + //
    + //
    + // ``` + // * __overlay__: Positions the view associated with the given item at the head + // or tail of the given `DisplayMarker`, depending on the `position` property. + // * __gutter__: Tracks a {DisplayMarker} in a {Gutter}. Gutter decorations are created + // by calling {Gutter::decorateMarker} on the desired `Gutter` instance. + // * __block__: Positions the view associated with the given item before or + // after the row of the given {DisplayMarker}, depending on the `position` property. + // Block decorations at the same screen row are ordered by their `order` property. + // * __cursor__: Render a cursor at the head of the {DisplayMarker}. If multiple cursor decorations + // are created for the same marker, their class strings and style objects are combined + // into a single cursor. This decoration type may be used to style existing cursors + // by passing in their markers or to render artificial cursors that don't actually + // exist in the model by passing a marker that isn't associated with a real cursor. + // + // ## Arguments + // + // * `marker` A {DisplayMarker} you want this decoration to follow. + // * `decorationParams` An {Object} representing the decoration e.g. + // `{type: 'line-number', class: 'linter-error'}` + // * `type` Determines the behavior and appearance of this {Decoration}. Supported decoration types + // and their uses are listed above. + // * `class` This CSS class will be applied to the decorated line number, + // line, text spans, highlight regions, cursors, or overlay. + // * `style` An {Object} containing CSS style properties to apply to the + // relevant DOM node. Currently this only works with a `type` of `cursor` + // or `text`. + // * `item` (optional) An {HTMLElement} or a model {Object} with a + // corresponding view registered. Only applicable to the `gutter`, + // `overlay` and `block` decoration types. + // * `onlyHead` (optional) If `true`, the decoration will only be applied to + // the head of the `DisplayMarker`. Only applicable to the `line` and + // `line-number` decoration types. + // * `onlyEmpty` (optional) If `true`, the decoration will only be applied if + // the associated `DisplayMarker` is empty. Only applicable to the `gutter`, + // `line`, and `line-number` decoration types. + // * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied + // if the associated `DisplayMarker` is non-empty. Only applicable to the + // `gutter`, `line`, and `line-number` decoration types. + // * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied + // to the last row of a non-empty range, even if it ends at column 0. + // Defaults to `true`. Only applicable to the `gutter`, `line`, and + // `line-number` decoration types. + // * `position` (optional) Only applicable to decorations of type `overlay` and `block`. + // Controls where the view is positioned relative to the `TextEditorMarker`. + // Values can be `'head'` (the default) or `'tail'` for overlay decorations, and + // `'before'` (the default) or `'after'` for block decorations. + // * `order` (optional) Only applicable to decorations of type `block`. Controls + // where the view is positioned relative to other block decorations at the + // same screen row. If unspecified, block decorations render oldest to newest. + // * `avoidOverflow` (optional) Only applicable to decorations of type + // `overlay`. Determines whether the decoration adjusts its horizontal or + // vertical position to remain fully visible when it would otherwise + // overflow the editor. Defaults to `true`. + // + // Returns the created {Decoration} object. + decorateMarker(marker, decorationParams) { + return this.decorationManager.decorateMarker(marker, decorationParams); + } + + // Essential: Add a decoration to every marker in the given marker layer. Can + // be used to decorate a large number of markers without having to create and + // manage many individual decorations. + // + // * `markerLayer` A {DisplayMarkerLayer} or {MarkerLayer} to decorate. + // * `decorationParams` The same parameters that are passed to + // {TextEditor::decorateMarker}, except the `type` cannot be `overlay` or `gutter`. + // + // Returns a {LayerDecoration}. + decorateMarkerLayer(markerLayer, decorationParams) { + return this.decorationManager.decorateMarkerLayer( + markerLayer, + decorationParams + ); + } + + // Deprecated: Get all the decorations within a screen row range on the default + // layer. + // + // * `startScreenRow` the {Number} beginning screen row + // * `endScreenRow` the {Number} end screen row (inclusive) + // + // Returns an {Object} of decorations in the form + // `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` + // where the keys are {DisplayMarker} IDs, and the values are an array of decoration + // params objects attached to the marker. + // Returns an empty object when no decorations are found + decorationsForScreenRowRange(startScreenRow, endScreenRow) { + return this.decorationManager.decorationsForScreenRowRange( + startScreenRow, + endScreenRow + ); + } + + decorationsStateForScreenRowRange(startScreenRow, endScreenRow) { + return this.decorationManager.decorationsStateForScreenRowRange( + startScreenRow, + endScreenRow + ); + } + + // Extended: Get all decorations. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getDecorations(propertyFilter) { + return this.decorationManager.getDecorations(propertyFilter); + } + + // Extended: Get all decorations of type 'line'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineDecorations(propertyFilter) { + return this.decorationManager.getLineDecorations(propertyFilter); + } + + // Extended: Get all decorations of type 'line-number'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineNumberDecorations(propertyFilter) { + return this.decorationManager.getLineNumberDecorations(propertyFilter); + } + + // Extended: Get all decorations of type 'highlight'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getHighlightDecorations(propertyFilter) { + return this.decorationManager.getHighlightDecorations(propertyFilter); + } + + // Extended: Get all decorations of type 'overlay'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getOverlayDecorations(propertyFilter) { + return this.decorationManager.getOverlayDecorations(propertyFilter); + } + + /* + Section: Markers + */ + + // Essential: Create a marker on the default marker layer with the given range + // in buffer coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferRange(bufferRange, options) { + return this.defaultMarkerLayer.markBufferRange(bufferRange, options); + } + + // Essential: Create a marker on the default marker layer with the given range + // in screen coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markScreenRange(screenRange, options) { + return this.defaultMarkerLayer.markScreenRange(screenRange, options); + } + + // Essential: Create a marker on the default marker layer with the given buffer + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `bufferPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferPosition(bufferPosition, options) { + return this.defaultMarkerLayer.markBufferPosition(bufferPosition, options); + } + + // Essential: Create a marker on the default marker layer with the given screen + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `screenPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {DisplayMarker}. + markScreenPosition(screenPosition, options) { + return this.defaultMarkerLayer.markScreenPosition(screenPosition, options); + } + + // Essential: Find all {DisplayMarker}s on the default marker layer that + // match the given properties. + // + // This method finds markers based on the given properties. Markers can be + // associated with custom properties that will be compared with basic equality. + // In addition, there are several special properties that will be compared + // with the range of the markers rather than their properties. + // + // * `properties` An {Object} containing properties that each returned marker + // must satisfy. Markers can be associated with custom properties, which are + // compared with basic equality. In addition, several reserved properties + // can be used to filter markers based on their current range: + // * `startBufferRow` Only include markers starting at this row in buffer + // coordinates. + // * `endBufferRow` Only include markers ending at this row in buffer + // coordinates. + // * `containsBufferRange` Only include markers containing this {Range} or + // in range-compatible {Array} in buffer coordinates. + // * `containsBufferPosition` Only include markers containing this {Point} + // or {Array} of `[row, column]` in buffer coordinates. + // + // Returns an {Array} of {DisplayMarker}s + findMarkers(params) { + return this.defaultMarkerLayer.findMarkers(params); + } + + // Extended: Get the {DisplayMarker} on the default layer for the given + // marker id. + // + // * `id` {Number} id of the marker + getMarker(id) { + return this.defaultMarkerLayer.getMarker(id); + } + + // Extended: Get all {DisplayMarker}s on the default marker layer. Consider + // using {::findMarkers} + getMarkers() { + return this.defaultMarkerLayer.getMarkers(); + } + + // Extended: Get the number of markers in the default marker layer. + // + // Returns a {Number}. + getMarkerCount() { + return this.defaultMarkerLayer.getMarkerCount(); + } + + destroyMarker(id) { + const marker = this.getMarker(id); + if (marker) marker.destroy(); + } + + // Essential: Create a marker layer to group related markers. + // + // * `options` An {Object} containing the following keys: + // * `maintainHistory` A {Boolean} indicating whether marker state should be + // restored on undo/redo. Defaults to `false`. + // * `persistent` A {Boolean} indicating whether or not this marker layer + // should be serialized and deserialized along with the rest of the + // buffer. Defaults to `false`. If `true`, the marker layer's id will be + // maintained across the serialization boundary, allowing you to retrieve + // it via {::getMarkerLayer}. + // + // Returns a {DisplayMarkerLayer}. + addMarkerLayer(options) { + return this.displayLayer.addMarkerLayer(options); + } + + // Essential: Get a {DisplayMarkerLayer} by id. + // + // * `id` The id of the marker layer to retrieve. + // + // Returns a {DisplayMarkerLayer} or `undefined` if no layer exists with the + // given id. + getMarkerLayer(id) { + return this.displayLayer.getMarkerLayer(id); + } + + // Essential: Get the default {DisplayMarkerLayer}. + // + // All marker APIs not tied to an explicit layer interact with this default + // layer. + // + // Returns a {DisplayMarkerLayer}. + getDefaultMarkerLayer() { + return this.defaultMarkerLayer; + } + + /* + Section: Cursors + */ + + // Essential: Get the position of the most recently added cursor in buffer + // coordinates. + // + // Returns a {Point} + getCursorBufferPosition() { + return this.getLastCursor().getBufferPosition(); + } + + // Essential: Get the position of all the cursor positions in buffer coordinates. + // + // Returns {Array} of {Point}s in the order they were added + getCursorBufferPositions() { + return this.getCursors().map(cursor => cursor.getBufferPosition()); + } + + // Essential: Move the cursor to the given position in buffer coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} containing the following keys: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorBufferPosition(position, options) { + return this.moveCursors(cursor => + cursor.setBufferPosition(position, options) + ); + } + + // Essential: Get a {Cursor} at given screen coordinates {Point} + // + // * `position` A {Point} or {Array} of `[row, column]` + // + // Returns the first matched {Cursor} or undefined + getCursorAtScreenPosition(position) { + const selection = this.getSelectionAtScreenPosition(position); + if (selection && selection.getHeadScreenPosition().isEqual(position)) { + return selection.cursor; + } + } + + // Essential: Get the position of the most recently added cursor in screen + // coordinates. + // + // Returns a {Point}. + getCursorScreenPosition() { + return this.getLastCursor().getScreenPosition(); + } + + // Essential: Get the position of all the cursor positions in screen coordinates. + // + // Returns {Array} of {Point}s in the order the cursors were added + getCursorScreenPositions() { + return this.getCursors().map(cursor => cursor.getScreenPosition()); + } + + // Essential: Move the cursor to the given position in screen coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorScreenPosition(position, options) { + if (options && options.clip) { + Grim.deprecate( + 'The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.' + ); + if (options.clipDirection) options.clipDirection = options.clip; + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate( + "The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapAtSoftNewlines + ? 'forward' + : 'backward'; + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate( + "The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead." + ); + if (options.clipDirection) + options.clipDirection = options.wrapBeyondNewlines + ? 'forward' + : 'backward'; + } + + return this.moveCursors(cursor => + cursor.setScreenPosition(position, options) + ); + } + + // Essential: Add a cursor at the given position in buffer coordinates. + // + // * `bufferPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtBufferPosition(bufferPosition, options) { + this.selectionsMarkerLayer.markBufferPosition(bufferPosition, { + invalidate: 'never' + }); + if (!options || options.autoscroll !== false) + this.getLastSelection().cursor.autoscroll(); + return this.getLastSelection().cursor; + } + + // Essential: Add a cursor at the position in screen coordinates. + // + // * `screenPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtScreenPosition(screenPosition, options) { + this.selectionsMarkerLayer.markScreenPosition(screenPosition, { + invalidate: 'never' + }); + if (!options || options.autoscroll !== false) + this.getLastSelection().cursor.autoscroll(); + return this.getLastSelection().cursor; + } + + // Essential: Returns {Boolean} indicating whether or not there are multiple cursors. + hasMultipleCursors() { + return this.getCursors().length > 1; + } + + // Essential: Move every cursor up one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveUp(lineCount) { + return this.moveCursors(cursor => + cursor.moveUp(lineCount, { moveToEndOfSelection: true }) + ); + } + + // Essential: Move every cursor down one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveDown(lineCount) { + return this.moveCursors(cursor => + cursor.moveDown(lineCount, { moveToEndOfSelection: true }) + ); + } + + // Essential: Move every cursor left one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveLeft(columnCount) { + return this.moveCursors(cursor => + cursor.moveLeft(columnCount, { moveToEndOfSelection: true }) + ); + } + + // Essential: Move every cursor right one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveRight(columnCount) { + return this.moveCursors(cursor => + cursor.moveRight(columnCount, { moveToEndOfSelection: true }) + ); + } + + // Essential: Move every cursor to the beginning of its line in buffer coordinates. + moveToBeginningOfLine() { + return this.moveCursors(cursor => cursor.moveToBeginningOfLine()); + } + + // Essential: Move every cursor to the beginning of its line in screen coordinates. + moveToBeginningOfScreenLine() { + return this.moveCursors(cursor => cursor.moveToBeginningOfScreenLine()); + } + + // Essential: Move every cursor to the first non-whitespace character of its line. + moveToFirstCharacterOfLine() { + return this.moveCursors(cursor => cursor.moveToFirstCharacterOfLine()); + } + + // Essential: Move every cursor to the end of its line in buffer coordinates. + moveToEndOfLine() { + return this.moveCursors(cursor => cursor.moveToEndOfLine()); + } + + // Essential: Move every cursor to the end of its line in screen coordinates. + moveToEndOfScreenLine() { + return this.moveCursors(cursor => cursor.moveToEndOfScreenLine()); + } + + // Essential: Move every cursor to the beginning of its surrounding word. + moveToBeginningOfWord() { + return this.moveCursors(cursor => cursor.moveToBeginningOfWord()); + } + + // Essential: Move every cursor to the end of its surrounding word. + moveToEndOfWord() { + return this.moveCursors(cursor => cursor.moveToEndOfWord()); + } + + // Cursor Extended + + // Extended: Move every cursor to the top of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToTop() { + return this.moveCursors(cursor => cursor.moveToTop()); + } + + // Extended: Move every cursor to the bottom of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToBottom() { + return this.moveCursors(cursor => cursor.moveToBottom()); + } + + // Extended: Move every cursor to the beginning of the next word. + moveToBeginningOfNextWord() { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextWord()); + } + + // Extended: Move every cursor to the previous word boundary. + moveToPreviousWordBoundary() { + return this.moveCursors(cursor => cursor.moveToPreviousWordBoundary()); + } + + // Extended: Move every cursor to the next word boundary. + moveToNextWordBoundary() { + return this.moveCursors(cursor => cursor.moveToNextWordBoundary()); + } + + // Extended: Move every cursor to the previous subword boundary. + moveToPreviousSubwordBoundary() { + return this.moveCursors(cursor => cursor.moveToPreviousSubwordBoundary()); + } + + // Extended: Move every cursor to the next subword boundary. + moveToNextSubwordBoundary() { + return this.moveCursors(cursor => cursor.moveToNextSubwordBoundary()); + } + + // Extended: Move every cursor to the beginning of the next paragraph. + moveToBeginningOfNextParagraph() { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextParagraph()); + } + + // Extended: Move every cursor to the beginning of the previous paragraph. + moveToBeginningOfPreviousParagraph() { + return this.moveCursors(cursor => + cursor.moveToBeginningOfPreviousParagraph() + ); + } + + // Extended: Returns the most recently added {Cursor} + getLastCursor() { + this.createLastSelectionIfNeeded(); + return _.last(this.cursors); + } + + // Extended: Returns the word surrounding the most recently added cursor. + // + // * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. + getWordUnderCursor(options) { + return this.getTextInBufferRange( + this.getLastCursor().getCurrentWordBufferRange(options) + ); + } + + // Extended: Get an Array of all {Cursor}s. + getCursors() { + this.createLastSelectionIfNeeded(); + return this.cursors.slice(); + } + + // 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. + getCursorsOrderedByBufferPosition() { + return this.getCursors().sort((a, b) => a.compare(b)); + } + + cursorsForScreenRowRange(startScreenRow, endScreenRow) { + const cursors = []; + for (let marker of this.selectionsMarkerLayer.findMarkers({ + intersectsScreenRowRange: [startScreenRow, endScreenRow] + })) { + const cursor = this.cursorsByMarkerId.get(marker.id); + if (cursor) cursors.push(cursor); + } + return cursors; + } + + // Add a cursor based on the given {DisplayMarker}. + addCursor(marker) { + const cursor = new Cursor({ + editor: this, + marker, + showCursorOnSelection: this.showCursorOnSelection + }); + this.cursors.push(cursor); + this.cursorsByMarkerId.set(marker.id, cursor); + return cursor; + } + + moveCursors(fn) { + return this.transact(() => { + this.getCursors().forEach(fn); + return this.mergeCursors(); + }); + } + + cursorMoved(event) { + return this.emitter.emit('did-change-cursor-position', event); + } + + // Merge cursors that have the same screen position + mergeCursors() { + const positions = {}; + for (let cursor of this.getCursors()) { + const position = cursor.getBufferPosition().toString(); + if (positions.hasOwnProperty(position)) { + cursor.destroy(); + } else { + positions[position] = true; + } + } + } + + /* + Section: Selections + */ + + // Essential: Get the selected text of the most recently added selection. + // + // Returns a {String}. + getSelectedText() { + return this.getLastSelection().getText(); + } + + // Essential: Get the {Range} of the most recently added selection in buffer + // coordinates. + // + // Returns a {Range}. + getSelectedBufferRange() { + return this.getLastSelection().getBufferRange(); + } + + // Essential: Get the {Range}s of all selections in buffer coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedBufferRanges() { + return this.getSelections().map(selection => selection.getBufferRange()); + } + + // Essential: Set the selected range in buffer coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRange(bufferRange, options) { + return this.setSelectedBufferRanges([bufferRange], options); + } + + // Essential: Set the selected ranges in buffer coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRanges(bufferRanges, options = {}) { + if (!bufferRanges.length) + throw new Error('Passed an empty array to setSelectedBufferRanges'); + + const selections = this.getSelections(); + for (let selection of selections.slice(bufferRanges.length)) { + selection.destroy(); + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < bufferRanges.length; i++) { + let bufferRange = bufferRanges[i]; + bufferRange = Range.fromObject(bufferRange); + if (selections[i]) { + selections[i].setBufferRange(bufferRange, options); + } else { + this.addSelectionForBufferRange(bufferRange, options); + } + } + }); + } + + // Essential: Get the {Range} of the most recently added selection in screen + // coordinates. + // + // Returns a {Range}. + getSelectedScreenRange() { + return this.getLastSelection().getScreenRange(); + } + + // Essential: Get the {Range}s of all selections in screen coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedScreenRanges() { + return this.getSelections().map(selection => selection.getScreenRange()); + } + + // Essential: Set the selected range in screen coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `screenRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRange(screenRange, options) { + return this.setSelectedBufferRange( + this.bufferRangeForScreenRange(screenRange, options), + options + ); + } + + // Essential: Set the selected ranges in screen coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRanges(screenRanges, options = {}) { + if (!screenRanges.length) + throw new Error('Passed an empty array to setSelectedScreenRanges'); + + const selections = this.getSelections(); + for (let selection of selections.slice(screenRanges.length)) { + selection.destroy(); + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < screenRanges.length; i++) { + let screenRange = screenRanges[i]; + screenRange = Range.fromObject(screenRange); + if (selections[i]) { + selections[i].setScreenRange(screenRange, options); + } else { + this.addSelectionForScreenRange(screenRange, options); + } + } + }); + } + + // Essential: Add a selection for the given range in buffer coordinates. + // + // * `bufferRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // + // Returns the added {Selection}. + addSelectionForBufferRange(bufferRange, options = {}) { + bufferRange = Range.fromObject(bufferRange); + if (!options.preserveFolds) { + this.displayLayer.destroyFoldsContainingBufferPositions( + [bufferRange.start, bufferRange.end], + true + ); + } + this.selectionsMarkerLayer.markBufferRange(bufferRange, { + invalidate: 'never', + reversed: options.reversed != null ? options.reversed : false + }); + if (options.autoscroll !== false) this.getLastSelection().autoscroll(); + return this.getLastSelection(); + } + + // Essential: Add a selection for the given range in screen coordinates. + // + // * `screenRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // Returns the added {Selection}. + addSelectionForScreenRange(screenRange, options = {}) { + return this.addSelectionForBufferRange( + this.bufferRangeForScreenRange(screenRange), + options + ); + } + + // Essential: Select from the current cursor position to the given position in + // buffer coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToBufferPosition(position) { + const lastSelection = this.getLastSelection(); + lastSelection.selectToBufferPosition(position); + return this.mergeIntersectingSelections({ + reversed: lastSelection.isReversed() + }); + } + + // Essential: Select from the current cursor position to the given position in + // screen coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToScreenPosition(position, options) { + const lastSelection = this.getLastSelection(); + lastSelection.selectToScreenPosition(position, options); + if (!options || !options.suppressSelectionMerge) { + return this.mergeIntersectingSelections({ + reversed: lastSelection.isReversed() + }); + } + } + + // Essential: Move the cursor of each selection one character upward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectUp(rowCount) { + return this.expandSelectionsBackward(selection => + selection.selectUp(rowCount) + ); + } + + // Essential: Move the cursor of each selection one character downward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectDown(rowCount) { + return this.expandSelectionsForward(selection => + selection.selectDown(rowCount) + ); + } + + // Essential: Move the cursor of each selection one character leftward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectLeft(columnCount) { + return this.expandSelectionsBackward(selection => + selection.selectLeft(columnCount) + ); + } + + // Essential: Move the cursor of each selection one character rightward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectRight(columnCount) { + return this.expandSelectionsForward(selection => + selection.selectRight(columnCount) + ); + } + + // Essential: Select from the top of the buffer to the end of the last selection + // in the buffer. + // + // This method merges multiple selections into a single selection. + selectToTop() { + return this.expandSelectionsBackward(selection => selection.selectToTop()); + } + + // Essential: Selects from the top of the first selection in the buffer to the end + // of the buffer. + // + // This method merges multiple selections into a single selection. + selectToBottom() { + return this.expandSelectionsForward(selection => + selection.selectToBottom() + ); + } + + // Essential: Select all text in the buffer. + // + // This method merges multiple selections into a single selection. + selectAll() { + return this.expandSelectionsForward(selection => selection.selectAll()); + } + + // Essential: Move the cursor of each selection to the beginning of its line + // while preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToBeginningOfLine() { + return this.expandSelectionsBackward(selection => + selection.selectToBeginningOfLine() + ); + } + + // Essential: Move the cursor of each selection to the first non-whitespace + // character of its line while preserving the selection's tail position. If the + // cursor is already on the first character of the line, move it to the + // beginning of the line. + // + // This method may merge selections that end up intersecting. + selectToFirstCharacterOfLine() { + return this.expandSelectionsBackward(selection => + selection.selectToFirstCharacterOfLine() + ); + } + + // Essential: Move the cursor of each selection to the end of its line while + // preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToEndOfLine() { + return this.expandSelectionsForward(selection => + selection.selectToEndOfLine() + ); + } + + // Essential: Expand selections to the beginning of their containing word. + // + // Operates on all selections. Moves the cursor to the beginning of the + // containing word while preserving the selection's tail position. + selectToBeginningOfWord() { + return this.expandSelectionsBackward(selection => + selection.selectToBeginningOfWord() + ); + } + + // Essential: Expand selections to the end of their containing word. + // + // Operates on all selections. Moves the cursor to the end of the containing + // word while preserving the selection's tail position. + selectToEndOfWord() { + return this.expandSelectionsForward(selection => + selection.selectToEndOfWord() + ); + } + + // Extended: For each selection, move its cursor to the preceding subword + // boundary while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousSubwordBoundary() { + return this.expandSelectionsBackward(selection => + selection.selectToPreviousSubwordBoundary() + ); + } + + // Extended: For each selection, move its cursor to the next subword boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextSubwordBoundary() { + return this.expandSelectionsForward(selection => + selection.selectToNextSubwordBoundary() + ); + } + + // Essential: For each cursor, select the containing line. + // + // This method merges selections on successive lines. + selectLinesContainingCursors() { + return this.expandSelectionsForward(selection => selection.selectLine()); + } + + // Essential: Select the word surrounding each cursor. + selectWordsContainingCursors() { + return this.expandSelectionsForward(selection => selection.selectWord()); + } + + // Selection Extended + + // Extended: For each selection, move its cursor to the preceding word boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousWordBoundary() { + return this.expandSelectionsBackward(selection => + selection.selectToPreviousWordBoundary() + ); + } + + // Extended: For each selection, move its cursor to the next word boundary while + // maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextWordBoundary() { + return this.expandSelectionsForward(selection => + selection.selectToNextWordBoundary() + ); + } + + // Extended: Expand selections to the beginning of the next word. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // word while preserving the selection's tail position. + selectToBeginningOfNextWord() { + return this.expandSelectionsForward(selection => + selection.selectToBeginningOfNextWord() + ); + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfNextParagraph() { + return this.expandSelectionsForward(selection => + selection.selectToBeginningOfNextParagraph() + ); + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfPreviousParagraph() { + 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} + // + // Returns the selected {Range} or `undefined` if the marker is invalid. + selectMarker(marker) { + if (marker.isValid()) { + const range = marker.getBufferRange(); + this.setSelectedBufferRange(range); + return range; + } + } + + // Extended: Get the most recently added {Selection}. + // + // Returns a {Selection}. + getLastSelection() { + this.createLastSelectionIfNeeded(); + return _.last(this.selections); + } + + getSelectionAtScreenPosition(position) { + const markers = this.selectionsMarkerLayer.findMarkers({ + containsScreenPosition: position + }); + if (markers.length > 0) + return this.cursorsByMarkerId.get(markers[0].id).selection; + } + + // Extended: Get current {Selection}s. + // + // Returns: An {Array} of {Selection}s. + getSelections() { + this.createLastSelectionIfNeeded(); + return this.selections.slice(); + } + + // Extended: Get all {Selection}s, ordered by their position in the buffer + // instead of the order in which they were added. + // + // Returns an {Array} of {Selection}s. + getSelectionsOrderedByBufferPosition() { + return this.getSelections().sort((a, b) => a.compare(b)); + } + + // Extended: Determine if a given range in buffer coordinates intersects a + // selection. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // + // Returns a {Boolean}. + selectionIntersectsBufferRange(bufferRange) { + return this.getSelections().some(selection => + selection.intersectsBufferRange(bufferRange) + ); + } + + // Selections Private + + // Add a similarly-shaped selection to the next eligible line below + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next following non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionBelow() { + return this.expandSelectionsForward(selection => + selection.addSelectionBelow() + ); + } + + // Add a similarly-shaped selection to the next eligible line above + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next preceding non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionAbove() { + return this.expandSelectionsBackward(selection => + selection.addSelectionAbove() + ); + } + + // Calls the given function with each selection, then merges selections + expandSelectionsForward(fn) { + this.mergeIntersectingSelections(() => this.getSelections().forEach(fn)); + } + + // Calls the given function with each selection, then merges selections in the + // reversed orientation + expandSelectionsBackward(fn) { + this.mergeIntersectingSelections({ reversed: true }, () => + this.getSelections().forEach(fn) + ); + } + + finalizeSelections() { + for (let selection of this.getSelections()) { + selection.finalize(); + } + } + + selectionsForScreenRows(startRow, endRow) { + return this.getSelections().filter(selection => + selection.intersectsScreenRowRange(startRow, endRow) + ); + } + + // Merges intersecting selections. If passed a function, it executes + // the function with merging suppressed, then merges intersecting selections + // afterward. + mergeIntersectingSelections(...args) { + return this.mergeSelections( + ...args, + (previousSelection, currentSelection) => { + const exclusive = + !currentSelection.isEmpty() && !previousSelection.isEmpty(); + return previousSelection.intersectsWith(currentSelection, exclusive); + } + ); + } + + mergeSelectionsOnSameRows(...args) { + return this.mergeSelections( + ...args, + (previousSelection, currentSelection) => { + const screenRange = currentSelection.getScreenRange(); + return previousSelection.intersectsScreenRowRange( + screenRange.start.row, + screenRange.end.row + ); + } + ); + } + + avoidMergingSelections(...args) { + return this.mergeSelections(...args, () => false); + } + + mergeSelections(...args) { + const mergePredicate = args.pop(); + let fn = args.pop(); + let options = args.pop(); + if (typeof fn !== 'function') { + options = fn; + fn = () => {}; + } + + if (this.suppressSelectionMerging) return fn(); + + this.suppressSelectionMerging = true; + const result = fn(); + this.suppressSelectionMerging = false; + + const selections = this.getSelectionsOrderedByBufferPosition(); + let lastSelection = selections.shift(); + for (const selection of selections) { + if (mergePredicate(lastSelection, selection)) { + lastSelection.merge(selection, options); + } else { + lastSelection = selection; + } + } + + return result; + } + + // Add a {Selection} based on the given {DisplayMarker}. + // + // * `marker` The {DisplayMarker} to highlight + // * `options` (optional) An {Object} that pertains to the {Selection} constructor. + // + // Returns the new {Selection}. + addSelection(marker, options = {}) { + const cursor = this.addCursor(marker); + let selection = new Selection( + Object.assign({ editor: this, marker, cursor }, options) + ); + this.selections.push(selection); + const selectionBufferRange = selection.getBufferRange(); + this.mergeIntersectingSelections({ preserveFolds: options.preserveFolds }); + + if (selection.destroyed) { + for (selection of this.getSelections()) { + if (selection.intersectsBufferRange(selectionBufferRange)) + return selection; + } + } else { + this.emitter.emit('did-add-cursor', cursor); + this.emitter.emit('did-add-selection', selection); + return selection; + } + } + + // Remove the given selection. + removeSelection(selection) { + _.remove(this.cursors, selection.cursor); + _.remove(this.selections, selection); + this.cursorsByMarkerId.delete(selection.cursor.marker.id); + this.emitter.emit('did-remove-cursor', selection.cursor); + return this.emitter.emit('did-remove-selection', selection); + } + + // Reduce one or more selections to a single empty selection based on the most + // recently added cursor. + clearSelections(options) { + this.consolidateSelections(); + this.getLastSelection().clear(options); + } + + // Reduce multiple selections to the least recently added selection. + consolidateSelections() { + const selections = this.getSelections(); + if (selections.length > 1) { + for (let selection of selections.slice(1, selections.length)) { + selection.destroy(); + } + selections[0].autoscroll({ center: true }); + return true; + } else { + return false; + } + } + + // Called by the selection + selectionRangeChanged(event) { + if (this.component) this.component.didChangeSelectionRange(); + this.emitter.emit('did-change-selection-range', event); + } + + createLastSelectionIfNeeded() { + if (this.selections.length === 0) { + this.addSelectionForBufferRange([[0, 0], [0, 0]], { + autoscroll: false, + preserveFolds: true + }); + } + } + + /* + Section: Searching and Replacing + */ + + // Essential: Scan regular expression matches in the entire buffer, calling the + // given iterator function on each match. + // + // `::scan` functions as the replace method as well via the `replace` + // + // If you're programmatically modifying the results, you may want to try + // {::backwardsScanInBufferRange} to avoid tripping over your own changes. + // + // * `regex` A {RegExp} to search for. + // * `options` (optional) {Object} + // * `leadingContextLineCount` {Number} default `0`; The number of lines + // before the matched line to include in the results object. + // * `trailingContextLineCount` {Number} default `0`; The number of lines + // after the matched line to include in the results object. + // * `iterator` A {Function} that's called on each match + // * `object` {Object} + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scan(regex, options = {}, iterator) { + if (_.isFunction(options)) { + iterator = options; + options = {}; + } + + return this.buffer.scan(regex, options, iterator); + } + + // Essential: Scan regular expression matches in a given range, calling the given + // iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scanInBufferRange(regex, range, iterator) { + return this.buffer.scanInRange(regex, range, iterator); + } + + // Essential: Scan regular expression matches in a given range in reverse order, + // calling the given iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + backwardsScanInBufferRange(regex, range, iterator) { + return this.buffer.backwardsScanInRange(regex, range, iterator); + } + + /* + Section: Tab Behavior + */ + + // Essential: Returns a {Boolean} indicating whether softTabs are enabled for this + // editor. + getSoftTabs() { + return this.softTabs; + } + + // Essential: Enable or disable soft tabs for this editor. + // + // * `softTabs` A {Boolean} + setSoftTabs(softTabs) { + this.softTabs = softTabs; + this.updateSoftTabs(this.softTabs, true); + } + + // Returns a {Boolean} indicating whether atomic soft tabs are enabled for this editor. + hasAtomicSoftTabs() { + return this.displayLayer.atomicSoftTabs; + } + + // Essential: Toggle soft tabs for this editor + toggleSoftTabs() { + this.setSoftTabs(!this.getSoftTabs()); + } + + // Essential: Get the on-screen length of tab characters. + // + // Returns a {Number}. + 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. + // + // * `tabLength` {Number} length of a single tab. Setting to `null` will + // fallback to using the `editor.tabLength` config setting + setTabLength(tabLength) { + this.updateTabLength(tabLength, true); + } + + // Returns an {Object} representing the current invisible character + // substitutions for this editor, whose keys are names of invisible characters + // and whose values are 1-character {Strings}s that are displayed in place of + // those invisible characters + getInvisibles() { + if (!this.mini && this.showInvisibles && this.invisibles != null) { + return this.invisibles; + } else { + return {}; + } + } + + doesShowIndentGuide() { + return this.showIndentGuide && !this.mini; + } + + getSoftWrapHangingIndentLength() { + return this.displayLayer.softWrapHangingIndent; + } + + // Extended: Determine if the buffer uses hard or soft tabs. + // + // Returns `true` if the first non-comment line with leading whitespace starts + // with a space character. Returns `false` if it starts with a hard tab (`\t`). + // + // 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++ + ) { + if (hasIsRowCommented && languageMode.isRowCommented(bufferRow)) continue; + const line = this.buffer.lineForRow(bufferRow); + if (line[0] === ' ') return true; + if (line[0] === '\t') return false; + } + } + + // Extended: Get the text representing a single level of indent. + // + // If soft tabs are enabled, the text is composed of N spaces, where N is the + // tab length. Otherwise the text is a tab character (`\t`). + // + // Returns a {String}. + getTabText() { + return this.buildIndentString(1); + } + + // If soft tabs are enabled, convert all hard tabs to soft tabs in the given + // {Range}. + normalizeTabsInBufferRange(bufferRange) { + if (!this.getSoftTabs()) { + return; + } + return this.scanInBufferRange(/\t/g, bufferRange, ({ replace }) => + replace(this.getTabText()) + ); + } + + /* + Section: Soft Wrap Behavior + */ + + // Essential: Determine whether lines in this editor are soft-wrapped. + // + // Returns a {Boolean}. + isSoftWrapped() { + return this.softWrapped; + } + + // Essential: Enable or disable soft wrapping for this editor. + // + // * `softWrapped` A {Boolean} + // + // Returns a {Boolean}. + setSoftWrapped(softWrapped) { + this.updateSoftWrapped(softWrapped, true); + return this.isSoftWrapped(); + } + + getPreferredLineLength() { + return this.preferredLineLength; + } + + // Essential: Toggle soft wrapping for this editor + // + // Returns a {Boolean}. + toggleSoftWrapped() { + return this.setSoftWrapped(!this.isSoftWrapped()); + } + + // Essential: Gets the column at which column will soft wrap + getSoftWrapColumn() { + if (this.isSoftWrapped() && !this.mini) { + if (this.softWrapAtPreferredLineLength) { + return Math.min(this.getEditorWidthInChars(), this.preferredLineLength); + } else { + return this.getEditorWidthInChars(); + } + } else { + return this.maxScreenLineLength; + } + } + + /* + Section: Indentation + */ + + // Essential: Get the indentation level of the given buffer row. + // + // Determines how deeply the given row is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // + // Returns a {Number}. + indentationForBufferRow(bufferRow) { + return this.indentLevelForLine(this.lineTextForBufferRow(bufferRow)); + } + + // Essential: Set the indentation level for the given buffer row. + // + // Inserts or removes hard tabs or spaces based on the soft tabs and tab length + // settings of this editor in order to bring it to the given indentation level. + // Note that if soft tabs are enabled and the tab length is 2, a row with 4 + // leading spaces would have an indentation level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // * `newLevel` A {Number} indicating the new indentation level. + // * `options` (optional) An {Object} with the following keys: + // * `preserveLeadingWhitespace` `true` to preserve any whitespace already at + // the beginning of the line (default: false). + setIndentationForBufferRow( + bufferRow, + newLevel, + { preserveLeadingWhitespace } = {} + ) { + let endColumn; + if (preserveLeadingWhitespace) { + endColumn = 0; + } else { + endColumn = this.lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length; + } + const newIndentString = this.buildIndentString(newLevel); + return this.buffer.setTextInRange( + [[bufferRow, 0], [bufferRow, endColumn]], + newIndentString + ); + } + + // Extended: Indent rows intersecting selections by one level. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + indentSelectedRows(options = {}) { + if (!this.ensureWritable('indentSelectedRows', options)) return; + return this.mutateSelectedText(selection => + selection.indentSelectedRows(options) + ); + } + + // Extended: Outdent rows intersecting selections by one level. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + outdentSelectedRows(options = {}) { + if (!this.ensureWritable('outdentSelectedRows', options)) return; + return this.mutateSelectedText(selection => + selection.outdentSelectedRows(options) + ); + } + + // Extended: Get the indentation level of the given line of text. + // + // Determines how deeply the given line is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `line` A {String} representing a line of text. + // + // Returns a {Number}. + 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 + // indent level. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + autoIndentSelectedRows(options = {}) { + if (!this.ensureWritable('autoIndentSelectedRows', options)) return; + return this.mutateSelectedText(selection => + selection.autoIndentSelectedRows(options) + ); + } + + // Indent all lines intersecting selections. See {Selection::indent} for more + // information. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + indent(options = {}) { + if (!this.ensureWritable('indent', options)) return; + if (options.autoIndent == null) + options.autoIndent = this.shouldAutoIndent(); + this.mutateSelectedText(selection => selection.indent(options)); + } + + // Constructs the string used for indents. + buildIndentString(level, column = 0) { + if (this.getSoftTabs()) { + const tabStopViolation = column % this.getTabLength(); + return _.multiplyString( + ' ', + Math.floor(level * this.getTabLength()) - tabStopViolation + ); + } else { + const excessWhitespace = _.multiplyString( + ' ', + Math.round((level - Math.floor(level)) * this.getTabLength()) + ); + return _.multiplyString('\t', Math.floor(level)) + excessWhitespace; + } + } + + /* + Section: Grammars + */ + + // Essential: Get the current {Grammar} of this editor. + getGrammar() { + const languageMode = this.buffer.getLanguageMode(); + return ( + (languageMode.getGrammar && languageMode.getGrammar()) || NullGrammar + ); + } + + // 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) { + const buffer = this.getBuffer(); + buffer.setLanguageMode( + atom.grammars.languageModeForGrammarAndBuffer(grammar, buffer) + ); + } + + // Experimental: Get a notification when async tokenization is completed. + onDidTokenize(callback) { + return this.emitter.on('did-tokenize', callback); + } + + /* + Section: Managing Syntax Scopes + */ + + // Essential: Returns a {ScopeDescriptor} that includes this editor's language. + // e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with + // {Config::get} to get language specific config values. + getRootScopeDescriptor() { + return this.buffer.getLanguageMode().rootScopeDescriptor; + } + + // 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, 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]`. + // + // Returns a {ScopeDescriptor}. + scopeDescriptorForBufferPosition(bufferPosition) { + const languageMode = this.buffer.getLanguageMode(); + return languageMode.scopeDescriptorForPosition + ? languageMode.scopeDescriptorForPosition(bufferPosition) + : new ScopeDescriptor({ scopes: ['text'] }); + } + + // Essential: Get the syntactic tree {ScopeDescriptor} for the given position in buffer + // coordinates or the syntactic {ScopeDescriptor} for TextMate language mode + // + // For example, if called with a position inside the parameter list of a + // JavaScript class function, this method returns a {ScopeDescriptor} with + // the following syntax nodes array: + // `["source.js", "program", "expression_statement", "assignment_expression", "class", "class_body", "method_definition", "formal_parameters", "identifier"]` + // if tree-sitter is used + // and the following scopes array: + // `["source.js"]` + // if textmate is used + // + // * `bufferPosition` A {Point} or {Array} of `[row, column]`. + // + // Returns a {ScopeDescriptor}. + syntaxTreeScopeDescriptorForBufferPosition(bufferPosition) { + const languageMode = this.buffer.getLanguageMode(); + return languageMode.syntaxTreeScopeDescriptorForPosition + ? languageMode.syntaxTreeScopeDescriptorForPosition(bufferPosition) + : this.scopeDescriptorForBufferPosition(bufferPosition); + } + + // Extended: Get the range in buffer coordinates of all tokens surrounding the + // cursor that match the given scope selector. + // + // For example, if you wanted to find the string surrounding the cursor, you + // could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. + // + // * `scopeSelector` {String} selector. e.g. `'.source.ruby'` + // + // Returns a {Range}. + bufferRangeForScopeAtCursor(scopeSelector) { + return this.bufferRangeForScopeAtPosition( + scopeSelector, + this.getCursorBufferPosition() + ); + } + + // Extended: Get the range in buffer coordinates of all tokens surrounding the + // given position in buffer coordinates that match the given scope selector. + // + // For example, if you wanted to find the string surrounding the cursor, you + // could call `editor.bufferRangeForScopeAtPosition(".string.quoted", this.getCursorBufferPosition())`. + // + // * `scopeSelector` {String} selector. e.g. `'.source.ruby'` + // * `bufferPosition` A {Point} or {Array} of [row, column] + // + // Returns a {Range}. + bufferRangeForScopeAtPosition(scopeSelector, bufferPosition) { + return this.buffer + .getLanguageMode() + .bufferRangeForScopeAtPosition(scopeSelector, bufferPosition); + } + + // Extended: Determine if the given row is entirely a comment + isBufferRowCommented(bufferRow) { + const match = this.lineTextForBufferRow(bufferRow).match(/\S/); + if (match) { + if (!this.commentScopeSelector) + this.commentScopeSelector = new TextMateScopeSelector('comment.*'); + return this.commentScopeSelector.matches( + this.scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes + ); + } + } + + // Get the scope descriptor at the cursor. + getCursorScope() { + return this.getLastCursor().getScopeDescriptor(); + } + + // Get the syntax nodes at the cursor. + getCursorSyntaxTreeScope() { + return this.getLastCursor().getSyntaxTreeScopeDescriptor(); + } + + tokenForBufferPosition(bufferPosition) { + return this.buffer.getLanguageMode().tokenForPosition(bufferPosition); + } + + /* + Section: Clipboard Operations + */ + + // Essential: For each selection, copy the selected text. + copySelectedText() { + let maintainClipboard = false; + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (selection.isEmpty()) { + const previousRange = selection.getBufferRange(); + selection.selectLine(); + selection.copy(maintainClipboard, true); + selection.setBufferRange(previousRange); + } else { + selection.copy(maintainClipboard, false); + } + maintainClipboard = true; + } + } + + // Private: For each selection, only copy highlighted text. + copyOnlySelectedText() { + let maintainClipboard = false; + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (!selection.isEmpty()) { + selection.copy(maintainClipboard, false); + maintainClipboard = true; + } + } + } + + // Essential: For each selection, cut the selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutSelectedText(options = {}) { + if (!this.ensureWritable('cutSelectedText', options)) return; + let maintainClipboard = false; + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectLine(); + selection.cut(maintainClipboard, true, options.bypassReadOnly); + } else { + selection.cut(maintainClipboard, false, options.bypassReadOnly); + } + maintainClipboard = true; + }); + } + + // Essential: For each selection, replace the selected text with the contents of + // the clipboard. + // + // If the clipboard contains the same number of selections as the current + // editor, each selection will be replaced with the content of the + // corresponding clipboard selection text. + // + // * `options` (optional) See {Selection::insertText}. + pasteText(options = {}) { + if (!this.ensureWritable('parseText', options)) return; + options = Object.assign({}, options); + let { + text: clipboardText, + metadata + } = this.constructor.clipboard.readWithMetadata(); + if (!this.emitWillInsertTextEvent(clipboardText)) return false; + + if (!metadata) metadata = {}; + if (options.autoIndent == null) + options.autoIndent = this.shouldAutoIndentOnPaste(); + + this.mutateSelectedText((selection, index) => { + let fullLine, indentBasis, text; + if ( + metadata.selections && + metadata.selections.length === this.getSelections().length + ) { + ({ text, indentBasis, fullLine } = metadata.selections[index]); + } else { + ({ indentBasis, fullLine } = metadata); + text = clipboardText; + } + + if ( + indentBasis != null && + (text.includes('\n') || + !selection.cursor.hasPrecedingCharactersOnLine()) + ) { + options.indentBasis = indentBasis; + } else { + options.indentBasis = null; + } + + let range; + if (fullLine && selection.isEmpty()) { + const oldPosition = selection.getBufferRange().start; + selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]); + range = selection.insertText(text, options); + const newPosition = oldPosition.translate([1, 0]); + selection.setBufferRange([newPosition, newPosition]); + } else { + range = selection.insertText(text, options); + } + + this.emitter.emit('did-insert-text', { text, range }); + }); + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing screen line following the cursor. Otherwise cut the selected + // text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutToEndOfLine(options = {}) { + if (!this.ensureWritable('cutToEndOfLine', options)) return; + let maintainClipboard = false; + this.mutateSelectedText(selection => { + selection.cutToEndOfLine(maintainClipboard, options); + maintainClipboard = true; + }); + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing buffer line following the cursor. Otherwise cut the + // selected text. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutToEndOfBufferLine(options = {}) { + if (!this.ensureWritable('cutToEndOfBufferLine', options)) return; + let maintainClipboard = false; + this.mutateSelectedText(selection => { + selection.cutToEndOfBufferLine(maintainClipboard, options); + maintainClipboard = true; + }); + } + + /* + Section: Folds + */ + + // Essential: Fold the most recent cursor's row based on its indentation level. + // + // The fold will extend from the nearest preceding line with a lower + // indentation level up to the nearest following row with a lower indentation + // level. + foldCurrentRow() { + const { row } = this.getCursorBufferPosition(); + 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(); + return this.displayLayer.destroyFoldsContainingBufferPositions( + [Point(row, Infinity)], + false + ); + } + + // Essential: Fold the given row in buffer coordinates based on its indentation + // level. + // + // If the given row is foldable, the fold will begin there. Otherwise, it will + // begin at the first foldable row preceding the given row. + // + // * `bufferRow` A {Number}. + foldBufferRow(bufferRow) { + let position = Point(bufferRow, Infinity); + const languageMode = this.buffer.getLanguageMode(); + while (true) { + 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); + } else { + const firstExistingFoldRange = this.displayLayer.bufferRangeForFold( + existingFolds[0] + ); + if (firstExistingFoldRange.start.isLessThan(position)) { + position = Point(firstExistingFoldRange.start.row, 0); + continue; + } + } + } + break; + } + } + + // Essential: Unfold all folds containing the given row in buffer coordinates. + // + // * `bufferRow` A {Number} + unfoldBufferRow(bufferRow) { + const position = Point(bufferRow, Infinity); + return this.displayLayer.destroyFoldsContainingBufferPositions([position]); + } + + // Extended: For each selection, fold the rows it intersects. + foldSelectedLines() { + for (let selection of this.selections) { + selection.fold(); + } + } + + // 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 foldableRanges || []) { + this.displayLayer.foldBufferRange(range); + } + } + + // Extended: Unfold all existing folds. + unfoldAll() { + const result = this.displayLayer.destroyAllFolds(); + if (result.length > 0) this.scrollToCursorPosition(); + return result; + } + + // Extended: Fold all foldable lines at the given indent level. + // + // * `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 foldableRanges || []) { + this.displayLayer.foldBufferRange(range); + } + } + + // Extended: Determine whether the given row in buffer coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtBufferRow(bufferRow) { + const languageMode = this.buffer.getLanguageMode(); + return ( + languageMode.isFoldableAtRow && languageMode.isFoldableAtRow(bufferRow) + ); + } + + // Extended: Determine whether the given row in screen coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtScreenRow(screenRow) { + return this.isFoldableAtBufferRow(this.bufferRowForScreenRow(screenRow)); + } + + // Extended: Fold the given buffer row if it isn't currently folded, and unfold + // it otherwise. + toggleFoldAtBufferRow(bufferRow) { + if (this.isFoldedAtBufferRow(bufferRow)) { + return this.unfoldBufferRow(bufferRow); + } else { + return this.foldBufferRow(bufferRow); + } + } + + // Extended: Determine whether the most recently added cursor's row is folded. + // + // Returns a {Boolean}. + isFoldedAtCursorRow() { + return this.isFoldedAtBufferRow(this.getCursorBufferPosition().row); + } + + // Extended: Determine whether the given row in buffer coordinates is folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtBufferRow(bufferRow) { + const range = Range( + Point(bufferRow, 0), + Point(bufferRow, this.buffer.lineLengthForRow(bufferRow)) + ); + return this.displayLayer.foldsIntersectingBufferRange(range).length > 0; + } + + // Extended: Determine whether the given row in screen coordinates is folded. + // + // * `screenRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtScreenRow(screenRow) { + return this.isFoldedAtBufferRow(this.bufferRowForScreenRow(screenRow)); + } + + // Creates a new fold between two row numbers. + // + // startRow - The row {Number} to start folding at + // endRow - The row {Number} to end the fold + // + // Returns the new {Fold}. + foldBufferRowRange(startRow, endRow) { + return this.foldBufferRange( + Range(Point(startRow, Infinity), Point(endRow, Infinity)) + ); + } + + foldBufferRange(range) { + return this.displayLayer.foldBufferRange(range); + } + + // Remove any {Fold}s found that intersect the given buffer range. + destroyFoldsIntersectingBufferRange(bufferRange) { + return this.displayLayer.destroyFoldsIntersectingBufferRange(bufferRange); + } + + // Remove any {Fold}s found that contain the given array of buffer positions. + destroyFoldsContainingBufferPositions(bufferPositions, excludeEndpoints) { + return this.displayLayer.destroyFoldsContainingBufferPositions( + bufferPositions, + excludeEndpoints + ); + } + + /* + Section: Gutters + */ + + // Essential: Add a custom {Gutter}. + // + // * `options` An {Object} with the following fields: + // * `name` (required) A unique {String} to identify this gutter. + // * `priority` (optional) A {Number} that determines stacking order between + // gutters. Lower priority items are forced closer to the edges of the + // window. (default: -100) + // * `visible` (optional) {Boolean} specifying whether the gutter is visible + // initially after being created. (default: true) + // * `type` (optional) {String} specifying the type of gutter to create. `'decorated'` + // gutters are useful as a destination for decorations created with {Gutter::decorateMarker}. + // `'line-number'` gutters. + // * `class` (optional) {String} added to the CSS classnames of the gutter's root DOM element. + // * `labelFn` (optional) {Function} called by a `'line-number'` gutter to generate the label for each line number + // element. Should return a {String} that will be used to label the corresponding line. + // * `lineData` an {Object} containing information about each line to label. + // * `bufferRow` {Number} indicating the zero-indexed buffer index of this line. + // * `screenRow` {Number} indicating the zero-indexed screen index. + // * `foldable` {Boolean} that is `true` if a fold may be created here. + // * `softWrapped` {Boolean} if this screen row is the soft-wrapped continuation of the same buffer row. + // * `maxDigits` {Number} the maximum number of digits necessary to represent any known screen row. + // * `onMouseDown` (optional) {Function} to be called when a mousedown event is received by a line-number + // element within this `type: 'line-number'` {Gutter}. If unspecified, the default behavior is to select the + // clicked buffer row. + // * `lineData` an {Object} containing information about the line that's being clicked. + // * `bufferRow` {Number} of the originating line element + // * `screenRow` {Number} + // * `onMouseMove` (optional) {Function} to be called when a mousemove event occurs on a line-number element within + // within this `type: 'line-number'` {Gutter}. + // * `lineData` an {Object} containing information about the line that's being clicked. + // * `bufferRow` {Number} of the originating line element + // * `screenRow` {Number} + // + // Returns the newly-created {Gutter}. + addGutter(options) { + return this.gutterContainer.addGutter(options); + } + + // Essential: Get this editor's gutters. + // + // Returns an {Array} of {Gutter}s. + getGutters() { + return this.gutterContainer.getGutters(); + } + + getLineNumberGutter() { + return this.lineNumberGutter; + } + + // Essential: Get the gutter with the given name. + // + // Returns a {Gutter}, or `null` if no gutter exists for the given name. + gutterWithName(name) { + return this.gutterContainer.gutterWithName(name); + } + + /* + Section: Scrolling the TextEditor + */ + + // Essential: Scroll the editor to reveal the most recently added cursor if it is + // off-screen. + // + // * `options` (optional) {Object} + // * `center` Center the editor around the cursor if possible. (default: true) + scrollToCursorPosition(options) { + this.getLastCursor().autoscroll({ + center: options && options.center !== false + }); + } + + // Essential: Scrolls the editor to the given buffer position. + // + // * `bufferPosition` An object that represents a buffer position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToBufferPosition(bufferPosition, options) { + return this.scrollToScreenPosition( + this.screenPositionForBufferPosition(bufferPosition), + options + ); + } + + // Essential: Scrolls the editor to the given screen position. + // + // * `screenPosition` An object that represents a screen position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToScreenPosition(screenPosition, options) { + this.scrollToScreenRange( + new Range(screenPosition, screenPosition), + options + ); + } + + scrollToTop() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::scrollToTop instead.' + ); + this.getElement().scrollToTop(); + } + + scrollToBottom() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::scrollToTop instead.' + ); + this.getElement().scrollToBottom(); + } + + scrollToScreenRange(screenRange, options = {}) { + if (options.clip !== false) screenRange = this.clipScreenRange(screenRange); + const scrollEvent = { screenRange, options }; + if (this.component) this.component.didRequestAutoscroll(scrollEvent); + this.emitter.emit('did-request-autoscroll', scrollEvent); + } + + getHorizontalScrollbarHeight() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getHorizontalScrollbarHeight instead.' + ); + return this.getElement().getHorizontalScrollbarHeight(); + } + + getVerticalScrollbarWidth() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getVerticalScrollbarWidth instead.' + ); + return this.getElement().getVerticalScrollbarWidth(); + } + + pageUp() { + this.moveUp(this.getRowsPerPage()); + } + + pageDown() { + this.moveDown(this.getRowsPerPage()); + } + + selectPageUp() { + this.selectUp(this.getRowsPerPage()); + } + + selectPageDown() { + this.selectDown(this.getRowsPerPage()); + } + + // Returns the number of rows per page + getRowsPerPage() { + if (this.component) { + const clientHeight = this.component.getScrollContainerClientHeight(); + const lineHeight = this.component.getLineHeight(); + return Math.max(1, Math.ceil(clientHeight / lineHeight)); + } else { + return 1; + } + } + + /* + Section: Config + */ + + // Experimental: Is auto-indentation enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndent() { + return this.autoIndent; + } + + // Experimental: Is auto-indentation on paste enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndentOnPaste() { + return this.autoIndentOnPaste; + } + + // Experimental: Does this editor allow scrolling past the last line? + // + // Returns a {Boolean}. + getScrollPastEnd() { + if (this.getAutoHeight()) { + return false; + } else { + return this.scrollPastEnd; + } + } + + // Experimental: How fast does the editor scroll in response to mouse wheel + // movements? + // + // Returns a positive {Number}. + getScrollSensitivity() { + return this.scrollSensitivity; + } + + // Experimental: Does this editor show cursors while there is a selection? + // + // Returns a positive {Boolean}. + getShowCursorOnSelection() { + return this.showCursorOnSelection; + } + + // Experimental: Are line numbers enabled for this editor? + // + // Returns a {Boolean} + doesShowLineNumbers() { + return this.showLineNumbers; + } + + // Experimental: Get the time interval within which text editing operations + // are grouped together in the editor's undo history. + // + // Returns the time interval {Number} in milliseconds. + getUndoGroupingInterval() { + return this.undoGroupingInterval; + } + + // Experimental: Get the characters that are *not* considered part of words, + // for the purpose of word-based cursor movements. + // + // Returns a {String} containing the non-word characters. + getNonWordCharacters(position) { + const languageMode = this.buffer.getLanguageMode(); + return ( + (languageMode.getNonWordCharacters && + languageMode.getNonWordCharacters(position || Point(0, 0))) || + DEFAULT_NON_WORD_CHARACTERS + ); + } + + /* + Section: Event Handlers + */ + + handleLanguageModeChange() { + this.unfoldAll(); + 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); + } + + /* + Section: TextEditor Rendering + */ + + // Get the Element for the editor. + getElement() { + if (!this.component) { + if (!TextEditorComponent) + TextEditorComponent = require('./text-editor-component'); + if (!TextEditorElement) + TextEditorElement = require('./text-editor-element'); + this.component = new TextEditorComponent({ + model: this, + updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, + initialScrollTopRow: this.initialScrollTopRow, + initialScrollLeftColumn: this.initialScrollLeftColumn + }); + } + return this.component.element; + } + + getAllowedLocations() { + return ['center']; + } + + // Essential: Retrieves the greyed out placeholder of a mini editor. + // + // Returns a {String}. + getPlaceholderText() { + return this.placeholderText; + } + + // Essential: Set the greyed out placeholder of a mini editor. Placeholder text + // will be displayed when the editor has no content. + // + // * `placeholderText` {String} text that is displayed when the editor has no content. + setPlaceholderText(placeholderText) { + this.updatePlaceholderText(placeholderText, true); + } + + pixelPositionForBufferPosition(bufferPosition) { + Grim.deprecate( + 'This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead' + ); + return this.getElement().pixelPositionForBufferPosition(bufferPosition); + } + + pixelPositionForScreenPosition(screenPosition) { + Grim.deprecate( + 'This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead' + ); + return this.getElement().pixelPositionForScreenPosition(screenPosition); + } + + getVerticalScrollMargin() { + const maxScrollMargin = Math.floor( + (this.height / this.getLineHeightInPixels() - 1) / 2 + ); + return Math.min(this.verticalScrollMargin, maxScrollMargin); + } + + setVerticalScrollMargin(verticalScrollMargin) { + this.verticalScrollMargin = verticalScrollMargin; + return this.verticalScrollMargin; + } + + getHorizontalScrollMargin() { + return Math.min( + this.horizontalScrollMargin, + Math.floor((this.width / this.getDefaultCharWidth() - 1) / 2) + ); + } + setHorizontalScrollMargin(horizontalScrollMargin) { + this.horizontalScrollMargin = horizontalScrollMargin; + return this.horizontalScrollMargin; + } + + getLineHeightInPixels() { + return this.lineHeightInPixels; + } + setLineHeightInPixels(lineHeightInPixels) { + this.lineHeightInPixels = lineHeightInPixels; + return this.lineHeightInPixels; + } + + getKoreanCharWidth() { + return this.koreanCharWidth; + } + getHalfWidthCharWidth() { + return this.halfWidthCharWidth; + } + getDoubleWidthCharWidth() { + return this.doubleWidthCharWidth; + } + getDefaultCharWidth() { + return this.defaultCharWidth; + } + + ratioForCharacter(character) { + if (isKoreanCharacter(character)) { + return this.getKoreanCharWidth() / this.getDefaultCharWidth(); + } else if (isHalfWidthCharacter(character)) { + return this.getHalfWidthCharWidth() / this.getDefaultCharWidth(); + } else if (isDoubleWidthCharacter(character)) { + return this.getDoubleWidthCharWidth() / this.getDefaultCharWidth(); + } else { + return 1; + } + } + + setDefaultCharWidth( + defaultCharWidth, + doubleWidthCharWidth, + halfWidthCharWidth, + koreanCharWidth + ) { + if (doubleWidthCharWidth == null) { + doubleWidthCharWidth = defaultCharWidth; + } + if (halfWidthCharWidth == null) { + halfWidthCharWidth = defaultCharWidth; + } + if (koreanCharWidth == null) { + koreanCharWidth = defaultCharWidth; + } + if ( + defaultCharWidth !== this.defaultCharWidth || + (doubleWidthCharWidth !== this.doubleWidthCharWidth && + halfWidthCharWidth !== this.halfWidthCharWidth && + koreanCharWidth !== this.koreanCharWidth) + ) { + this.defaultCharWidth = defaultCharWidth; + this.doubleWidthCharWidth = doubleWidthCharWidth; + this.halfWidthCharWidth = halfWidthCharWidth; + this.koreanCharWidth = koreanCharWidth; + if (this.isSoftWrapped()) { + this.displayLayer.reset({ + softWrapColumn: this.getSoftWrapColumn() + }); + } + } + return defaultCharWidth; + } + + setHeight(height) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setHeight instead.' + ); + this.getElement().setHeight(height); + } + + getHeight() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getHeight instead.' + ); + return this.getElement().getHeight(); + } + + getAutoHeight() { + return this.autoHeight != null ? this.autoHeight : true; + } + + getAutoWidth() { + return this.autoWidth != null ? this.autoWidth : false; + } + + setWidth(width) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setWidth instead.' + ); + this.getElement().setWidth(width); + } + + getWidth() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getWidth instead.' + ); + return this.getElement().getWidth(); + } + + // Use setScrollTopRow instead of this method + setFirstVisibleScreenRow(screenRow) { + this.setScrollTopRow(screenRow); + } + + getFirstVisibleScreenRow() { + return this.getElement().component.getFirstVisibleRow(); + } + + getLastVisibleScreenRow() { + return this.getElement().component.getLastVisibleRow(); + } + + getVisibleRowRange() { + return [this.getFirstVisibleScreenRow(), this.getLastVisibleScreenRow()]; + } + + // Use setScrollLeftColumn instead of this method + setFirstVisibleScreenColumn(column) { + return this.setScrollLeftColumn(column); + } + + getFirstVisibleScreenColumn() { + return this.getElement().component.getFirstVisibleColumn(); + } + + getScrollTop() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollTop instead.' + ); + return this.getElement().getScrollTop(); + } + + setScrollTop(scrollTop) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setScrollTop instead.' + ); + this.getElement().setScrollTop(scrollTop); + } + + getScrollBottom() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollBottom instead.' + ); + return this.getElement().getScrollBottom(); + } + + setScrollBottom(scrollBottom) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setScrollBottom instead.' + ); + this.getElement().setScrollBottom(scrollBottom); + } + + getScrollLeft() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollLeft instead.' + ); + return this.getElement().getScrollLeft(); + } + + setScrollLeft(scrollLeft) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setScrollLeft instead.' + ); + this.getElement().setScrollLeft(scrollLeft); + } + + getScrollRight() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollRight instead.' + ); + return this.getElement().getScrollRight(); + } + + setScrollRight(scrollRight) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::setScrollRight instead.' + ); + this.getElement().setScrollRight(scrollRight); + } + + getScrollHeight() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollHeight instead.' + ); + return this.getElement().getScrollHeight(); + } + + getScrollWidth() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getScrollWidth instead.' + ); + return this.getElement().getScrollWidth(); + } + + getMaxScrollTop() { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::getMaxScrollTop instead.' + ); + return this.getElement().getMaxScrollTop(); + } + + getScrollTopRow() { + return this.getElement().component.getScrollTopRow(); + } + + setScrollTopRow(scrollTopRow) { + this.getElement().component.setScrollTopRow(scrollTopRow); + } + + getScrollLeftColumn() { + return this.getElement().component.getScrollLeftColumn(); + } + + setScrollLeftColumn(scrollLeftColumn) { + this.getElement().component.setScrollLeftColumn(scrollLeftColumn); + } + + intersectsVisibleRowRange(startRow, endRow) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.' + ); + return this.getElement().intersectsVisibleRowRange(startRow, endRow); + } + + selectionIntersectsVisibleRowRange(selection) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::selectionIntersectsVisibleRowRange instead.' + ); + return this.getElement().selectionIntersectsVisibleRowRange(selection); + } + + screenPositionForPixelPosition(pixelPosition) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::screenPositionForPixelPosition instead.' + ); + return this.getElement().screenPositionForPixelPosition(pixelPosition); + } + + pixelRectForScreenRange(screenRange) { + Grim.deprecate( + 'This is now a view method. Call TextEditorElement::pixelRectForScreenRange instead.' + ); + return this.getElement().pixelRectForScreenRange(screenRange); + } + + /* + Section: Utility + */ + + inspect() { + return ``; + } + + emitWillInsertTextEvent(text) { + let result = true; + const cancel = () => { + result = false; + }; + this.emitter.emit('will-insert-text', { cancel, text }); + return result; + } + + /* + Section: Language Mode Delegated Methods + */ + + suggestedIndentForBufferRow(bufferRow, options) { + const languageMode = this.buffer.getLanguageMode(); + return ( + languageMode.suggestedIndentForBufferRow && + languageMode.suggestedIndentForBufferRow( + bufferRow, + this.getTabLength(), + options + ) + ); + } + + // Given a buffer row, indent it. + // + // * bufferRow - The row {Number}. + // * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. + autoIndentBufferRow(bufferRow, options) { + const indentLevel = this.suggestedIndentForBufferRow(bufferRow, options); + return this.setIndentationForBufferRow(bufferRow, indentLevel, options); + } + + // Indents all the rows between two buffer row numbers. + // + // * startRow - The row {Number} to start at + // * endRow - The row {Number} to end at + autoIndentBufferRows(startRow, endRow) { + let row = startRow; + while (row <= endRow) { + this.autoIndentBufferRow(row); + row++; + } + } + + autoDecreaseIndentForBufferRow(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, options = {}) { + const languageMode = this.buffer.getLanguageMode(); + let { commentStartString, commentEndString } = + (languageMode.commentStringsForPosition && + languageMode.commentStringsForPosition(new Point(start, 0))) || + {}; + if (!commentStartString) return; + commentStartString = commentStartString.trim(); + + if (commentEndString) { + commentEndString = commentEndString.trim(); + const startDelimiterColumnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(start), + commentStartString + ); + if (startDelimiterColumnRange) { + const endDelimiterColumnRange = columnRangeForEndDelimiter( + this.buffer.lineForRow(end), + commentEndString + ); + if (endDelimiterColumnRange) { + this.buffer.transact(() => { + this.buffer.delete([ + [end, endDelimiterColumnRange[0]], + [end, endDelimiterColumnRange[1]] + ]); + this.buffer.delete([ + [start, startDelimiterColumnRange[0]], + [start, startDelimiterColumnRange[1]] + ]); + }); + } + } else { + this.buffer.transact(() => { + const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0] + .length; + this.buffer.insert([start, indentLength], commentStartString + ' '); + this.buffer.insert( + [end, this.buffer.lineLengthForRow(end)], + ' ' + commentEndString + ); + + // Prevent the cursor from selecting / passing the delimiters + // See https://github.com/atom/atom/pull/17519 + if (options.correctSelection && options.selection) { + const endLineLength = this.buffer.lineLengthForRow(end); + const oldRange = options.selection.getBufferRange(); + if (oldRange.isEmpty()) { + if (oldRange.start.column === endLineLength) { + const endCol = endLineLength - commentEndString.length - 1; + options.selection.setBufferRange( + [[end, endCol], [end, endCol]], + { autoscroll: false } + ); + } + } else { + const startDelta = + oldRange.start.column === indentLength + ? [0, commentStartString.length + 1] + : [0, 0]; + const endDelta = + oldRange.end.column === endLineLength + ? [0, -commentEndString.length - 1] + : [0, 0]; + options.selection.setBufferRange( + oldRange.translate(startDelta, endDelta), + { autoscroll: false } + ); + } + } + }); + } + } else { + let hasCommentedLines = false; + let hasUncommentedLines = false; + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row); + if (NON_WHITESPACE_REGEXP.test(line)) { + if (columnRangeForStartDelimiter(line, commentStartString)) { + hasCommentedLines = true; + } else { + hasUncommentedLines = true; + } + } + } + + const shouldUncomment = hasCommentedLines && !hasUncommentedLines; + + if (shouldUncomment) { + for (let row = start; row <= end; row++) { + const columnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(row), + commentStartString + ); + if (columnRange) + this.buffer.delete([[row, columnRange[0]], [row, columnRange[1]]]); + } + } else { + let minIndentLevel = Infinity; + let minBlankIndentLevel = Infinity; + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row); + const indentLevel = this.indentLevelForLine(line); + if (NON_WHITESPACE_REGEXP.test(line)) { + if (indentLevel < minIndentLevel) minIndentLevel = indentLevel; + } else { + if (indentLevel < minBlankIndentLevel) + minBlankIndentLevel = indentLevel; + } + } + minIndentLevel = Number.isFinite(minIndentLevel) + ? minIndentLevel + : Number.isFinite(minBlankIndentLevel) + ? minBlankIndentLevel + : 0; + + const indentString = this.buildIndentString(minIndentLevel); + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row); + if (NON_WHITESPACE_REGEXP.test(line)) { + const indentColumn = columnForIndentLevel( + line, + minIndentLevel, + this.getTabLength() + ); + this.buffer.insert( + Point(row, indentColumn), + commentStartString + ' ' + ); + } else { + this.buffer.setTextInRange( + new Range(new Point(row, 0), new Point(row, Infinity)), + indentString + commentStartString + ' ' + ); + } + } + } + } + } + + rowRangeForParagraphAtBufferRow(bufferRow) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(bufferRow))) + return; + + 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 (languageMode.isRowCommented(startRow - 1) !== isCommented) break; + startRow--; + } + + let endRow = bufferRow; + const rowCount = this.getLineCount(); + while (endRow + 1 < rowCount) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(endRow + 1))) + break; + if (languageMode.isRowCommented(endRow + 1) !== isCommented) break; + endRow++; + } + + return new Range( + new Point(startRow, 0), + new Point(endRow, this.buffer.lineLengthForRow(endRow)) + ); + } +}; + +function columnForIndentLevel(line, indentLevel, tabLength) { + let column = 0; + let indentLength = 0; + const goalIndentLength = indentLevel * tabLength; + while (indentLength < goalIndentLength) { + const char = line[column]; + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength); + } else if (char === ' ') { + indentLength++; + } else { + break; + } + column++; + } + return column; +} + +function columnRangeForStartDelimiter(line, delimiter) { + const startColumn = line.search(NON_WHITESPACE_REGEXP); + if (startColumn === -1) return null; + if (!line.startsWith(delimiter, startColumn)) return null; + + let endColumn = startColumn + delimiter.length; + if (line[endColumn] === ' ') endColumn++; + return [startColumn, endColumn]; +} + +function columnRangeForEndDelimiter(line, delimiter) { + let startColumn = line.lastIndexOf(delimiter); + if (startColumn === -1) return null; + + const endColumn = startColumn + delimiter.length; + if (NON_WHITESPACE_REGEXP.test(line.slice(endColumn))) return null; + if (line[startColumn - 1] === ' ') startColumn--; + return [startColumn, endColumn]; +} + +class ChangeEvent { + constructor({ oldRange, newRange }) { + this.oldRange = oldRange; + this.newRange = newRange; + } + + get start() { + return this.newRange.start; + } + + get oldExtent() { + return this.oldRange.getExtent(); + } + + get newExtent() { + return this.newRange.getExtent(); + } +} diff --git a/src/text-mate-language-mode.js b/src/text-mate-language-mode.js new file mode 100644 index 00000000000..13d5ed13f0e --- /dev/null +++ b/src/text-mate-language-mode.js @@ -0,0 +1,1014 @@ +const _ = require('underscore-plus'); +const { CompositeDisposable, Emitter } = require('event-kit'); +const { Point, Range } = require('text-buffer'); +const TokenizedLine = require('./tokenized-line'); +const TokenIterator = require('./token-iterator'); +const ScopeDescriptor = require('./scope-descriptor'); +const NullGrammar = require('./null-grammar'); +const { OnigRegExp } = require('oniguruma'); +const { + toFirstMateScopeId, + fromFirstMateScopeId +} = require('./first-mate-helpers'); +const { selectorMatchesAnyScope } = require('./selectors'); + +const NON_WHITESPACE_REGEX = /\S/; + +let nextId = 0; +const prefixedScopes = new Map(); + +class TextMateLanguageMode { + constructor(params) { + this.emitter = new Emitter(); + this.disposables = new CompositeDisposable(); + this.tokenIterator = new TokenIterator(this); + this.regexesByPattern = {}; + + this.alive = true; + this.tokenizationStarted = false; + this.id = params.id != null ? params.id : nextId++; + this.buffer = params.buffer; + this.largeFileMode = params.largeFileMode; + this.config = params.config; + this.largeFileMode = + params.largeFileMode != null + ? params.largeFileMode + : this.buffer.buffer.getLength() >= 2 * 1024 * 1024; + + this.grammar = params.grammar || NullGrammar; + this.rootScopeDescriptor = new ScopeDescriptor({ + scopes: [this.grammar.scopeName] + }); + this.disposables.add( + this.grammar.onDidUpdate(() => this.retokenizeLines()) + ); + this.retokenizeLines(); + } + + destroy() { + if (!this.alive) return; + this.alive = false; + this.disposables.dispose(); + this.tokenizedLines.length = 0; + } + + isAlive() { + return this.alive; + } + + isDestroyed() { + 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 + */ + + // Get the suggested indentation level for an existing line in the buffer. + // + // * bufferRow - A {Number} indicating the buffer row + // + // Returns a {Number}. + suggestedIndentForBufferRow(bufferRow, tabLength, options) { + const line = this.buffer.lineForRow(bufferRow); + const tokenizedLine = this.tokenizedLineForRow(bufferRow); + const iterator = tokenizedLine.getTokenIterator(); + iterator.next(); + const scopeDescriptor = new ScopeDescriptor({ + scopes: iterator.getScopes() + }); + return this._suggestedIndentForLineWithScopeAtBufferRow( + bufferRow, + line, + scopeDescriptor, + tabLength, + options + ); + } + + // Get the suggested indentation level for a given line of text, if it were inserted at the given + // row in the buffer. + // + // * bufferRow - A {Number} indicating the buffer row + // + // Returns a {Number}. + suggestedIndentForLineAtBufferRow(bufferRow, line, tabLength) { + const tokenizedLine = this.buildTokenizedLineForRowWithText( + bufferRow, + line + ); + 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 + // typing. This may return a different result from {::suggestedIndentForBufferRow} in order + // to avoid unexpected changes in indentation. It may also return undefined if no change should + // be made. + // + // * bufferRow - The row {Number} + // + // Returns a {Number}. + suggestedIndentForEditedBufferRow(bufferRow, tabLength) { + const line = this.buffer.lineForRow(bufferRow); + const currentIndentLevel = this.indentLevelForLine(line, tabLength); + if (currentIndentLevel === 0) return; + + const scopeDescriptor = this.scopeDescriptorForPosition( + new Point(bufferRow, 0) + ); + const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor( + scopeDescriptor + ); + if (!decreaseIndentRegex) return; + + if (!decreaseIndentRegex.testSync(line)) return; + + const precedingRow = this.buffer.previousNonBlankRow(bufferRow); + if (precedingRow == null) return; + + const precedingLine = this.buffer.lineForRow(precedingRow); + let desiredIndentLevel = this.indentLevelForLine(precedingLine, tabLength); + + const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor( + scopeDescriptor + ); + if (increaseIndentRegex) { + if (!increaseIndentRegex.testSync(precedingLine)) desiredIndentLevel -= 1; + } + + const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor( + scopeDescriptor + ); + if (decreaseNextIndentRegex) { + if (decreaseNextIndentRegex.testSync(precedingLine)) + desiredIndentLevel -= 1; + } + + if (desiredIndentLevel < 0) return 0; + if (desiredIndentLevel >= currentIndentLevel) return; + return desiredIndentLevel; + } + + _suggestedIndentForLineWithScopeAtBufferRow( + bufferRow, + line, + scopeDescriptor, + tabLength, + options + ) { + const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor( + scopeDescriptor + ); + const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor( + scopeDescriptor + ); + const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor( + scopeDescriptor + ); + + let precedingRow; + if (!options || options.skipBlankLines !== false) { + precedingRow = this.buffer.previousNonBlankRow(bufferRow); + if (precedingRow == null) return 0; + } else { + precedingRow = bufferRow - 1; + if (precedingRow < 0) return 0; + } + + const precedingLine = this.buffer.lineForRow(precedingRow); + let desiredIndentLevel = this.indentLevelForLine(precedingLine, tabLength); + if (!increaseIndentRegex) return desiredIndentLevel; + + if (!this.isRowCommented(precedingRow)) { + if (increaseIndentRegex && increaseIndentRegex.testSync(precedingLine)) + desiredIndentLevel += 1; + if ( + decreaseNextIndentRegex && + decreaseNextIndentRegex.testSync(precedingLine) + ) + desiredIndentLevel -= 1; + } + + if (!this.buffer.isRowBlank(precedingRow)) { + if (decreaseIndentRegex && decreaseIndentRegex.testSync(line)) + desiredIndentLevel -= 1; + } + + return Math.max(desiredIndentLevel, 0); + } + + /* + Section - Comments + */ + + commentStringsForPosition(position) { + 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 + }; + } + + /* + Section - Syntax Highlighting + */ + + buildHighlightIterator() { + return new TextMateHighlightIterator(this); + } + + classNameForScopeId(id) { + const scope = this.grammar.scopeForId(toFirstMateScopeId(id)); + if (scope) { + let prefixedScope = prefixedScopes.get(scope); + if (prefixedScope) { + return prefixedScope; + } else { + prefixedScope = `syntax--${scope.replace(/\./g, ' syntax--')}`; + prefixedScopes.set(scope, prefixedScope); + return prefixedScope; + } + } else { + return null; + } + } + + getInvalidatedRanges() { + return []; + } + + onDidChangeHighlighting(fn) { + return this.emitter.on('did-change-highlighting', fn); + } + + onDidTokenize(callback) { + return this.emitter.on('did-tokenize', callback); + } + + getGrammarSelectionContent() { + return this.buffer.getTextInRange([[0, 0], [10, 0]]); + } + + updateForInjection(grammar) { + if (!grammar.injectionSelector) return; + for (const tokenizedLine of this.tokenizedLines) { + if (tokenizedLine) { + for (let token of tokenizedLine.tokens) { + if (grammar.injectionSelector.matches(token.scopes)) { + this.retokenizeLines(); + return; + } + } + } + } + } + + retokenizeLines() { + if (!this.alive) return; + this.fullyTokenized = false; + this.tokenizedLines = new Array(this.buffer.getLineCount()); + this.invalidRows = []; + if (this.largeFileMode || this.grammar.name === 'Null Grammar') { + this.markTokenizationComplete(); + } else { + this.invalidateRow(0); + } + } + + startTokenizing() { + this.tokenizationStarted = true; + if (this.grammar.name !== 'Null Grammar' && !this.largeFileMode) { + this.tokenizeInBackground(); + } + } + + tokenizeInBackground() { + if (!this.tokenizationStarted || this.pendingChunk || !this.alive) return; + + this.pendingChunk = true; + _.defer(() => { + this.pendingChunk = false; + if (this.isAlive() && this.buffer.isAlive()) this.tokenizeNextChunk(); + }); + } + + tokenizeNextChunk() { + let rowsRemaining = this.chunkSize; + + while (this.firstInvalidRow() != null && rowsRemaining > 0) { + let endRow, filledRegion; + const startRow = this.invalidRows.shift(); + const lastRow = this.buffer.getLastRow(); + if (startRow > lastRow) continue; + + let row = startRow; + while (true) { + const previousStack = this.stackForRow(row); + this.tokenizedLines[row] = this.buildTokenizedLineForRow( + row, + this.stackForRow(row - 1), + this.openScopesForRow(row) + ); + if (--rowsRemaining === 0) { + filledRegion = false; + endRow = row; + break; + } + if ( + row === lastRow || + _.isEqual(this.stackForRow(row), previousStack) + ) { + filledRegion = true; + endRow = row; + break; + } + row++; + } + + this.validateRow(endRow); + if (!filledRegion) this.invalidateRow(endRow + 1); + + this.emitter.emit( + 'did-change-highlighting', + Range(Point(startRow, 0), Point(endRow + 1, 0)) + ); + } + + if (this.firstInvalidRow() != null) { + this.tokenizeInBackground(); + } else { + this.markTokenizationComplete(); + } + } + + markTokenizationComplete() { + if (!this.fullyTokenized) { + this.emitter.emit('did-tokenize'); + } + this.fullyTokenized = true; + } + + firstInvalidRow() { + return this.invalidRows[0]; + } + + validateRow(row) { + while (this.invalidRows[0] <= row) this.invalidRows.shift(); + } + + invalidateRow(row) { + this.invalidRows.push(row); + this.invalidRows.sort((a, b) => a - b); + this.tokenizeInBackground(); + } + + updateInvalidRows(start, end, delta) { + this.invalidRows = this.invalidRows.map(row => { + if (row < start) { + return row; + } else if (start <= row && row <= end) { + return end + delta + 1; + } else if (row > end) { + return row + delta; + } + }); + } + + bufferDidChange(e) { + this.changeCount = this.buffer.changeCount; + + const { oldRange, newRange } = e; + const start = oldRange.start.row; + const end = oldRange.end.row; + const delta = newRange.end.row - oldRange.end.row; + const oldLineCount = oldRange.end.row - oldRange.start.row + 1; + const newLineCount = newRange.end.row - newRange.start.row + 1; + + this.updateInvalidRows(start, end, delta); + const previousEndStack = this.stackForRow(end); // used in spill detection below + if (this.largeFileMode || this.grammar.name === 'Null Grammar') { + _.spliceWithArray( + this.tokenizedLines, + start, + oldLineCount, + new Array(newLineCount) + ); + } else { + const newTokenizedLines = this.buildTokenizedLinesForRows( + start, + end + delta, + this.stackForRow(start - 1), + this.openScopesForRow(start) + ); + _.spliceWithArray( + this.tokenizedLines, + start, + oldLineCount, + newTokenizedLines + ); + const newEndStack = this.stackForRow(end + delta); + if (newEndStack && !_.isEqual(newEndStack, previousEndStack)) { + this.invalidateRow(end + delta + 1); + } + } + } + + bufferDidFinishTransaction() {} + + isFoldableAtRow(row) { + return this.endRowForFoldAtRow(row, 1, true) != null; + } + + buildTokenizedLinesForRows( + startRow, + endRow, + startingStack, + startingopenScopes + ) { + let ruleStack = startingStack; + let openScopes = startingopenScopes; + const stopTokenizingAt = startRow + this.chunkSize; + const tokenizedLines = []; + for (let row = startRow, end = endRow; row <= end; row++) { + let tokenizedLine; + if ((ruleStack || row === 0) && row < stopTokenizingAt) { + tokenizedLine = this.buildTokenizedLineForRow( + row, + ruleStack, + openScopes + ); + ruleStack = tokenizedLine.ruleStack; + openScopes = this.scopesFromTags(openScopes, tokenizedLine.tags); + } + tokenizedLines.push(tokenizedLine); + } + + if (endRow >= stopTokenizingAt) { + this.invalidateRow(stopTokenizingAt); + this.tokenizeInBackground(); + } + + return tokenizedLines; + } + + buildTokenizedLineForRow(row, ruleStack, openScopes) { + return this.buildTokenizedLineForRowWithText( + row, + this.buffer.lineForRow(row), + ruleStack, + openScopes + ); + } + + buildTokenizedLineForRowWithText( + row, + text, + currentRuleStack = this.stackForRow(row - 1), + openScopes = this.openScopesForRow(row) + ) { + const lineEnding = this.buffer.lineEndingForRow(row); + const { tags, ruleStack } = this.grammar.tokenizeLine( + text, + currentRuleStack, + row === 0, + false + ); + return new TokenizedLine({ + openScopes, + text, + tags, + ruleStack, + lineEnding, + tokenIterator: this.tokenIterator, + grammar: this.grammar + }); + } + + tokenizedLineForRow(bufferRow) { + if (bufferRow >= 0 && bufferRow <= this.buffer.getLastRow()) { + const tokenizedLine = this.tokenizedLines[bufferRow]; + if (tokenizedLine) { + return tokenizedLine; + } else { + const text = this.buffer.lineForRow(bufferRow); + const lineEnding = this.buffer.lineEndingForRow(bufferRow); + const tags = [ + this.grammar.startIdForScope(this.grammar.scopeName), + text.length, + this.grammar.endIdForScope(this.grammar.scopeName) + ]; + this.tokenizedLines[bufferRow] = new TokenizedLine({ + openScopes: [], + text, + tags, + lineEnding, + tokenIterator: this.tokenIterator, + grammar: this.grammar + }); + return this.tokenizedLines[bufferRow]; + } + } + } + + tokenizedLinesForRows(startRow, endRow) { + const result = []; + for (let row = startRow, end = endRow; row <= end; row++) { + result.push(this.tokenizedLineForRow(row)); + } + return result; + } + + stackForRow(bufferRow) { + return ( + this.tokenizedLines[bufferRow] && this.tokenizedLines[bufferRow].ruleStack + ); + } + + openScopesForRow(bufferRow) { + const precedingLine = this.tokenizedLines[bufferRow - 1]; + if (precedingLine) { + return this.scopesFromTags(precedingLine.openScopes, precedingLine.tags); + } else { + return []; + } + } + + scopesFromTags(startingScopes, tags) { + const scopes = startingScopes.slice(); + for (const tag of tags) { + if (tag < 0) { + if (tag % 2 === -1) { + scopes.push(tag); + } else { + const matchingStartTag = tag + 1; + while (true) { + if (scopes.pop() === matchingStartTag) break; + if (scopes.length === 0) { + break; + } + } + } + } + } + return scopes; + } + + indentLevelForLine(line, 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; + } + + scopeDescriptorForPosition(position) { + let scopes; + const { row, column } = this.buffer.clipPosition( + Point.fromObject(position) + ); + + const iterator = this.tokenizedLineForRow(row).getTokenIterator(); + while (iterator.next()) { + if (iterator.getBufferEnd() > column) { + scopes = iterator.getScopes(); + break; + } + } + + // rebuild scope of last token if we iterated off the end + if (!scopes) { + scopes = iterator.getScopes(); + scopes.push(...iterator.getScopeEnds().reverse()); + } + + return new ScopeDescriptor({ scopes }); + } + + tokenForPosition(position) { + const { row, column } = Point.fromObject(position); + return this.tokenizedLineForRow(row).tokenAtBufferColumn(column); + } + + tokenStartPositionForPosition(position) { + let { row, column } = Point.fromObject(position); + column = this.tokenizedLineForRow(row).tokenStartColumnForBufferColumn( + column + ); + return new Point(row, column); + } + + bufferRangeForScopeAtPosition(selector, position) { + let endColumn, tag, tokenIndex; + position = Point.fromObject(position); + + const { openScopes, tags } = this.tokenizedLineForRow(position.row); + const scopes = openScopes.map(tag => this.grammar.scopeForId(tag)); + + let startColumn = 0; + for (tokenIndex = 0; tokenIndex < tags.length; tokenIndex++) { + tag = tags[tokenIndex]; + if (tag < 0) { + if (tag % 2 === -1) { + scopes.push(this.grammar.scopeForId(tag)); + } else { + scopes.pop(); + } + } else { + endColumn = startColumn + tag; + if (endColumn >= position.column) { + break; + } else { + startColumn = endColumn; + } + } + } + + if (!selectorMatchesAnyScope(selector, scopes)) return; + + const startScopes = scopes.slice(); + for ( + let startTokenIndex = tokenIndex - 1; + startTokenIndex >= 0; + startTokenIndex-- + ) { + tag = tags[startTokenIndex]; + if (tag < 0) { + if (tag % 2 === -1) { + startScopes.pop(); + } else { + startScopes.push(this.grammar.scopeForId(tag)); + } + } else { + if (!selectorMatchesAnyScope(selector, startScopes)) { + break; + } + startColumn -= tag; + } + } + + const endScopes = scopes.slice(); + for ( + let endTokenIndex = tokenIndex + 1, end = tags.length; + endTokenIndex < end; + endTokenIndex++ + ) { + tag = tags[endTokenIndex]; + if (tag < 0) { + if (tag % 2 === -1) { + endScopes.push(this.grammar.scopeForId(tag)); + } else { + endScopes.pop(); + } + } else { + if (!selectorMatchesAnyScope(selector, endScopes)) { + break; + } + endColumn += tag; + } + } + + return new Range( + new Point(position.row, startColumn), + new Point(position.row, endColumn) + ); + } + + isRowCommented(row) { + return this.tokenizedLines[row] && this.tokenizedLines[row].isComment(); + } + + getFoldableRangeContainingPoint(point, tabLength) { + if (point.column >= this.buffer.lineLengthForRow(point.row)) { + const endRow = this.endRowForFoldAtRow(point.row, tabLength); + if (endRow != null) { + return Range(Point(point.row, Infinity), Point(endRow, Infinity)); + } + } + + for (let row = point.row - 1; row >= 0; row--) { + const endRow = this.endRowForFoldAtRow(row, tabLength); + if (endRow != null && endRow >= point.row) { + return Range(Point(row, Infinity), Point(endRow, Infinity)); + } + } + return null; + } + + getFoldableRangesAtIndentLevel(indentLevel, tabLength) { + const result = []; + let row = 0; + const lineCount = this.buffer.getLineCount(); + while (row < lineCount) { + if ( + this.indentLevelForLine(this.buffer.lineForRow(row), tabLength) === + indentLevel + ) { + const endRow = this.endRowForFoldAtRow(row, tabLength); + if (endRow != null) { + result.push(Range(Point(row, Infinity), Point(endRow, Infinity))); + row = endRow + 1; + continue; + } + } + row++; + } + return result; + } + + getFoldableRanges(tabLength) { + const result = []; + let row = 0; + const lineCount = this.buffer.getLineCount(); + while (row < lineCount) { + const endRow = this.endRowForFoldAtRow(row, tabLength); + if (endRow != null) { + result.push(Range(Point(row, Infinity), Point(endRow, Infinity))); + } + row++; + } + return result; + } + + endRowForFoldAtRow(row, tabLength, existenceOnly = false) { + if (this.isRowCommented(row)) { + return this.endRowForCommentFoldAtRow(row, existenceOnly); + } else { + return this.endRowForCodeFoldAtRow(row, tabLength, existenceOnly); + } + } + + endRowForCommentFoldAtRow(row, existenceOnly) { + if (this.isRowCommented(row - 1)) return; + + let endRow; + for ( + let nextRow = row + 1, end = this.buffer.getLineCount(); + nextRow < end; + nextRow++ + ) { + if (!this.isRowCommented(nextRow)) break; + endRow = nextRow; + if (existenceOnly) break; + } + + return endRow; + } + + endRowForCodeFoldAtRow(row, tabLength, existenceOnly) { + let foldEndRow; + const line = this.buffer.lineForRow(row); + if (!NON_WHITESPACE_REGEX.test(line)) return; + const startIndentLevel = this.indentLevelForLine(line, tabLength); + const scopeDescriptor = this.scopeDescriptorForPosition([row, 0]); + const foldEndRegex = this.foldEndRegexForScopeDescriptor(scopeDescriptor); + for ( + let nextRow = row + 1, end = this.buffer.getLineCount(); + nextRow < end; + nextRow++ + ) { + const line = this.buffer.lineForRow(nextRow); + if (!NON_WHITESPACE_REGEX.test(line)) continue; + const indentation = this.indentLevelForLine(line, tabLength); + if (indentation < startIndentLevel) { + break; + } else if (indentation === startIndentLevel) { + if (foldEndRegex && foldEndRegex.searchSync(line)) foldEndRow = nextRow; + break; + } + foldEndRow = nextRow; + if (existenceOnly) break; + } + return foldEndRow; + } + + increaseIndentRegexForScopeDescriptor(scope) { + return this.regexForPattern( + this.config.get('editor.increaseIndentPattern', { scope }) + ); + } + + decreaseIndentRegexForScopeDescriptor(scope) { + return this.regexForPattern( + this.config.get('editor.decreaseIndentPattern', { scope }) + ); + } + + decreaseNextIndentRegexForScopeDescriptor(scope) { + return this.regexForPattern( + this.config.get('editor.decreaseNextIndentPattern', { scope }) + ); + } + + foldEndRegexForScopeDescriptor(scope) { + return this.regexForPattern( + this.config.get('editor.foldEndPattern', { scope }) + ); + } + + regexForPattern(pattern) { + if (pattern) { + if (!this.regexesByPattern[pattern]) { + this.regexesByPattern[pattern] = new OnigRegExp(pattern); + } + return this.regexesByPattern[pattern]; + } + } + + logLines(start = 0, end = this.buffer.getLastRow()) { + for (let row = start; row <= end; row++) { + const line = this.tokenizedLines[row].text; + console.log(row, line, line.length); + } + } +} + +TextMateLanguageMode.prototype.chunkSize = 50; + +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/text-utils.coffee b/src/text-utils.coffee deleted file mode 100644 index 37955dcd6ae..00000000000 --- a/src/text-utils.coffee +++ /dev/null @@ -1,73 +0,0 @@ -isHighSurrogate = (charCode) -> - 0xD800 <= charCode <= 0xDBFF - -isLowSurrogate = (charCode) -> - 0xDC00 <= charCode <= 0xDFFF - -isVariationSelector = (charCode) -> - 0xFE00 <= charCode <= 0xFE0F - -isCombiningCharacter = (charCode) -> - 0x0300 <= charCode <= 0x036F or - 0x1AB0 <= charCode <= 0x1AFF or - 0x1DC0 <= charCode <= 0x1DFF or - 0x20D0 <= charCode <= 0x20FF or - 0xFE20 <= charCode <= 0xFE2F - -# Are the given character codes a high/low surrogate pair? -# -# * `charCodeA` The first character code {Number}. -# * `charCode2` The second character code {Number}. -# -# Return a {Boolean}. -isSurrogatePair = (charCodeA, charCodeB) -> - isHighSurrogate(charCodeA) and isLowSurrogate(charCodeB) - -# Are the given character codes a variation sequence? -# -# * `charCodeA` The first character code {Number}. -# * `charCode2` The second character code {Number}. -# -# Return a {Boolean}. -isVariationSequence = (charCodeA, charCodeB) -> - not isVariationSelector(charCodeA) and isVariationSelector(charCodeB) - -# Are the given character codes a combined character pair? -# -# * `charCodeA` The first character code {Number}. -# * `charCode2` The second character code {Number}. -# -# Return a {Boolean}. -isCombinedCharacter = (charCodeA, charCodeB) -> - not isCombiningCharacter(charCodeA) and isCombiningCharacter(charCodeB) - -# Is the character at the given index the start of high/low surrogate pair -# a variation sequence, or a combined character? -# -# * `string` The {String} to check for a surrogate pair, variation sequence, -# or combined character. -# * `index` The {Number} index to look for a surrogate pair, variation -# sequence, or combined character. -# -# Return a {Boolean}. -isPairedCharacter = (string, index=0) -> - charCodeA = string.charCodeAt(index) - charCodeB = string.charCodeAt(index + 1) - isSurrogatePair(charCodeA, charCodeB) or - isVariationSequence(charCodeA, charCodeB) or - isCombinedCharacter(charCodeA, charCodeB) - -# Does the given string contain at least surrogate pair, variation sequence, -# or combined character? -# -# * `string` The {String} to check for the presence of paired characters. -# -# Returns a {Boolean}. -hasPairedCharacter = (string) -> - index = 0 - while index < string.length - return true if isPairedCharacter(string, index) - index++ - false - -module.exports = {isPairedCharacter, hasPairedCharacter} diff --git a/src/text-utils.js b/src/text-utils.js new file mode 100644 index 00000000000..d5f9e23b484 --- /dev/null +++ b/src/text-utils.js @@ -0,0 +1,141 @@ +const isHighSurrogate = charCode => charCode >= 0xd800 && charCode <= 0xdbff; + +const isLowSurrogate = charCode => charCode >= 0xdc00 && charCode <= 0xdfff; + +const isVariationSelector = charCode => + charCode >= 0xfe00 && charCode <= 0xfe0f; + +const isCombiningCharacter = charCode => + (charCode >= 0x0300 && charCode <= 0x036f) || + (charCode >= 0x1ab0 && charCode <= 0x1aff) || + (charCode >= 0x1dc0 && charCode <= 0x1dff) || + (charCode >= 0x20d0 && charCode <= 0x20ff) || + (charCode >= 0xfe20 && charCode <= 0xfe2f); + +// Are the given character codes a high/low surrogate pair? +// +// * `charCodeA` The first character code {Number}. +// * `charCode2` The second character code {Number}. +// +// Return a {Boolean}. +const isSurrogatePair = (charCodeA, charCodeB) => + isHighSurrogate(charCodeA) && isLowSurrogate(charCodeB); + +// Are the given character codes a variation sequence? +// +// * `charCodeA` The first character code {Number}. +// * `charCode2` The second character code {Number}. +// +// Return a {Boolean}. +const isVariationSequence = (charCodeA, charCodeB) => + !isVariationSelector(charCodeA) && isVariationSelector(charCodeB); + +// Are the given character codes a combined character pair? +// +// * `charCodeA` The first character code {Number}. +// * `charCode2` The second character code {Number}. +// +// Return a {Boolean}. +const isCombinedCharacter = (charCodeA, charCodeB) => + !isCombiningCharacter(charCodeA) && isCombiningCharacter(charCodeB); + +// Is the character at the given index the start of high/low surrogate pair +// a variation sequence, or a combined character? +// +// * `string` The {String} to check for a surrogate pair, variation sequence, +// or combined character. +// * `index` The {Number} index to look for a surrogate pair, variation +// sequence, or combined character. +// +// Return a {Boolean}. +const isPairedCharacter = (string, index = 0) => { + const charCodeA = string.charCodeAt(index); + const charCodeB = string.charCodeAt(index + 1); + return ( + isSurrogatePair(charCodeA, charCodeB) || + isVariationSequence(charCodeA, charCodeB) || + isCombinedCharacter(charCodeA, charCodeB) + ); +}; + +const IsJapaneseKanaCharacter = charCode => + charCode >= 0x3000 && charCode <= 0x30ff; + +const isCJKUnifiedIdeograph = charCode => + charCode >= 0x4e00 && charCode <= 0x9fff; + +const isFullWidthForm = charCode => + (charCode >= 0xff01 && charCode <= 0xff5e) || + (charCode >= 0xffe0 && charCode <= 0xffe6); + +const isDoubleWidthCharacter = character => { + const charCode = character.charCodeAt(0); + + return ( + IsJapaneseKanaCharacter(charCode) || + isCJKUnifiedIdeograph(charCode) || + isFullWidthForm(charCode) + ); +}; + +const isHalfWidthCharacter = character => { + const charCode = character.charCodeAt(0); + + return ( + (charCode >= 0xff65 && charCode <= 0xffdc) || + (charCode >= 0xffe8 && charCode <= 0xffee) + ); +}; + +const isKoreanCharacter = character => { + const charCode = character.charCodeAt(0); + + return ( + (charCode >= 0xac00 && charCode <= 0xd7a3) || + (charCode >= 0x1100 && charCode <= 0x11ff) || + (charCode >= 0x3130 && charCode <= 0x318f) || + (charCode >= 0xa960 && charCode <= 0xa97f) || + (charCode >= 0xd7b0 && charCode <= 0xd7ff) + ); +}; + +const isCJKCharacter = character => + isDoubleWidthCharacter(character) || + isHalfWidthCharacter(character) || + isKoreanCharacter(character); + +const isWordStart = (previousCharacter, character) => + (previousCharacter === ' ' || + previousCharacter === '\t' || + previousCharacter === '-' || + previousCharacter === '/') && + (character !== ' ' && character !== '\t'); + +const isWrapBoundary = (previousCharacter, character) => + isWordStart(previousCharacter, character) || isCJKCharacter(character); + +// Does the given string contain at least surrogate pair, variation sequence, +// or combined character? +// +// * `string` The {String} to check for the presence of paired characters. +// +// Returns a {Boolean}. +const hasPairedCharacter = string => { + let index = 0; + while (index < string.length) { + if (isPairedCharacter(string, index)) { + return true; + } + index++; + } + return false; +}; + +module.exports = { + isPairedCharacter, + hasPairedCharacter, + isDoubleWidthCharacter, + isHalfWidthCharacter, + isKoreanCharacter, + isWrapBoundary +}; diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee deleted file mode 100644 index 01bb9652a8d..00000000000 --- a/src/theme-manager.coffee +++ /dev/null @@ -1,383 +0,0 @@ -path = require 'path' -_ = require 'underscore-plus' -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -{File} = require 'pathwatcher' -fs = require 'fs-plus' -Q = require 'q' -Grim = require 'grim' - -# Extended: Handles loading and activating available themes. -# -# An instance of this class is always available as the `atom.themes` global. -module.exports = -class ThemeManager - constructor: ({@packageManager, @resourcePath, @configDirPath, @safeMode}) -> - @emitter = new Emitter - @styleSheetDisposablesBySourcePath = {} - @lessCache = null - @initialLoadComplete = false - @packageManager.registerPackageActivator(this, ['theme']) - @sheetsByStyleElement = new WeakMap - - stylesElement = document.head.querySelector('atom-styles') - stylesElement.onDidAddStyleElement @styleElementAdded.bind(this) - stylesElement.onDidRemoveStyleElement @styleElementRemoved.bind(this) - stylesElement.onDidUpdateStyleElement @styleElementUpdated.bind(this) - - styleElementAdded: (styleElement) -> - {sheet} = styleElement - @sheetsByStyleElement.set(styleElement, sheet) - @emit 'stylesheet-added', sheet if Grim.includeDeprecatedAPIs - @emitter.emit 'did-add-stylesheet', sheet - @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-stylesheets' - - styleElementRemoved: (styleElement) -> - sheet = @sheetsByStyleElement.get(styleElement) - @emit 'stylesheet-removed', sheet if Grim.includeDeprecatedAPIs - @emitter.emit 'did-remove-stylesheet', sheet - @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-stylesheets' - - styleElementUpdated: ({sheet}) -> - @emit 'stylesheet-removed', sheet if Grim.includeDeprecatedAPIs - @emitter.emit 'did-remove-stylesheet', sheet - @emit 'stylesheet-added', sheet if Grim.includeDeprecatedAPIs - @emitter.emit 'did-add-stylesheet', sheet - @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-stylesheets' - - ### - Section: Event Subscription - ### - - # Essential: Invoke `callback` when style sheet changes associated with - # updating the list of active themes have completed. - # - # * `callback` {Function} - onDidChangeActiveThemes: (callback) -> - @emitter.on 'did-change-active-themes', callback - @emitter.on 'did-reload-all', callback # TODO: Remove once deprecated pre-1.0 APIs are gone - - ### - Section: Accessing Available Themes - ### - - getAvailableNames: -> - # TODO: Maybe should change to list all the available themes out there? - @getLoadedNames() - - ### - Section: Accessing Loaded Themes - ### - - # Public: Get an array of all the loaded theme names. - getLoadedThemeNames: -> - theme.name for theme in @getLoadedThemes() - - # Public: Get an array of all the loaded themes. - getLoadedThemes: -> - pack for pack in @packageManager.getLoadedPackages() when pack.isTheme() - - ### - Section: Accessing Active Themes - ### - - # Public: Get an array of all the active theme names. - getActiveThemeNames: -> - theme.name for theme in @getActiveThemes() - - # Public: Get an array of all the active themes. - getActiveThemes: -> - pack for pack in @packageManager.getActivePackages() when pack.isTheme() - - activatePackages: -> @activateThemes() - - ### - Section: Managing Enabled Themes - ### - - # Public: Get the enabled theme names from the config. - # - # Returns an array of theme names in the order that they should be activated. - getEnabledThemeNames: -> - themeNames = atom.config.get('core.themes') ? [] - themeNames = [themeNames] unless _.isArray(themeNames) - themeNames = themeNames.filter (themeName) -> - if themeName and typeof themeName is 'string' - return true if atom.packages.resolvePackagePath(themeName) - console.warn("Enabled theme '#{themeName}' is not installed.") - false - - # Use a built-in syntax and UI theme any time the configured themes are not - # available. - if themeNames.length < 2 - builtInThemeNames = [ - 'atom-dark-syntax' - 'atom-dark-ui' - 'atom-light-syntax' - 'atom-light-ui' - 'base16-tomorrow-dark-theme' - 'base16-tomorrow-light-theme' - 'solarized-dark-syntax' - 'solarized-light-syntax' - ] - themeNames = _.intersection(themeNames, builtInThemeNames) - if themeNames.length is 0 - themeNames = ['atom-dark-syntax', 'atom-dark-ui'] - else if themeNames.length is 1 - if _.endsWith(themeNames[0], '-ui') - themeNames.unshift('atom-dark-syntax') - else - themeNames.push('atom-dark-ui') - - # Reverse so the first (top) theme is loaded after the others. We want - # the first/top theme to override later themes in the stack. - themeNames.reverse() - - ### - Section: Private - ### - - # Resolve and apply the stylesheet specified by the path. - # - # This supports both CSS and Less stylsheets. - # - # * `stylesheetPath` A {String} path to the stylesheet that can be an absolute - # path or a relative path that will be resolved against the load path. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # required stylesheet. - requireStylesheet: (stylesheetPath) -> - if fullPath = @resolveStylesheet(stylesheetPath) - content = @loadStylesheet(fullPath) - @applyStylesheet(fullPath, content) - else - throw new Error("Could not find a file at path '#{stylesheetPath}'") - - unwatchUserStylesheet: -> - @userStylsheetSubscriptions?.dispose() - @userStylsheetSubscriptions = null - @userStylesheetFile = null - @userStyleSheetDisposable?.dispose() - @userStyleSheetDisposable = null - - loadUserStylesheet: -> - @unwatchUserStylesheet() - - userStylesheetPath = atom.styles.getUserStyleSheetPath() - return unless fs.isFileSync(userStylesheetPath) - - try - @userStylesheetFile = new File(userStylesheetPath) - @userStylsheetSubscriptions = new CompositeDisposable() - reloadStylesheet = => @loadUserStylesheet() - @userStylsheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet)) - @userStylsheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet)) - @userStylsheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet)) - catch error - message = """ - Unable to watch path: `#{path.basename(userStylesheetPath)}`. Make sure - you have permissions to `#{userStylesheetPath}`. - - On linux there are currently problems with watch sizes. See - [this document][watches] for more info. - [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path - """ - atom.notifications.addError(message, dismissable: true) - - try - userStylesheetContents = @loadStylesheet(userStylesheetPath, true) - catch - return - - @userStyleSheetDisposable = atom.styles.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2) - - loadBaseStylesheets: -> - @requireStylesheet('../static/bootstrap') - @reloadBaseStylesheets() - - reloadBaseStylesheets: -> - @requireStylesheet('../static/atom') - if nativeStylesheetPath = fs.resolveOnLoadPath(process.platform, ['css', 'less']) - @requireStylesheet(nativeStylesheetPath) - - stylesheetElementForId: (id) -> - document.head.querySelector("atom-styles style[source-path=\"#{id}\"]") - - resolveStylesheet: (stylesheetPath) -> - if path.extname(stylesheetPath).length > 0 - fs.resolveOnLoadPath(stylesheetPath) - else - fs.resolveOnLoadPath(stylesheetPath, ['css', 'less']) - - loadStylesheet: (stylesheetPath, importFallbackVariables) -> - if path.extname(stylesheetPath) is '.less' - @loadLessStylesheet(stylesheetPath, importFallbackVariables) - else - fs.readFileSync(stylesheetPath, 'utf8') - - loadLessStylesheet: (lessStylesheetPath, importFallbackVariables=false) -> - unless @lessCache? - LessCompileCache = require './less-compile-cache' - @lessCache = new LessCompileCache({@resourcePath, importPaths: @getImportPaths()}) - - try - if importFallbackVariables - baseVarImports = """ - @import "variables/ui-variables"; - @import "variables/syntax-variables"; - """ - less = fs.readFileSync(lessStylesheetPath, 'utf8') - @lessCache.cssForFile(lessStylesheetPath, [baseVarImports, less].join('\n')) - else - @lessCache.read(lessStylesheetPath) - catch error - error.less = true - if error.line? - # Adjust line numbers for import fallbacks - error.line -= 2 if importFallbackVariables - - message = "Error compiling Less stylesheet: `#{lessStylesheetPath}`" - detail = """ - Line number: #{error.line} - #{error.message} - """ - else - message = "Error loading Less stylesheet: `#{lessStylesheetPath}`" - detail = error.message - - atom.notifications.addError(message, {detail, dismissable: true}) - throw error - - removeStylesheet: (stylesheetPath) -> - @styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose() - - applyStylesheet: (path, text) -> - @styleSheetDisposablesBySourcePath[path] = atom.styles.addStyleSheet(text, sourcePath: path) - - stringToId: (string) -> - string.replace(/\\/g, '/') - - activateThemes: -> - deferred = Q.defer() - - # atom.config.observe runs the callback once, then on subsequent changes. - atom.config.observe 'core.themes', => - @deactivateThemes() - - @refreshLessCache() # Update cache for packages in core.themes config - - promises = [] - for themeName in @getEnabledThemeNames() - if @packageManager.resolvePackagePath(themeName) - promises.push(@packageManager.activatePackage(themeName)) - else - console.warn("Failed to activate theme '#{themeName}' because it isn't installed.") - - Q.all(promises).then => - @addActiveThemeClasses() - @refreshLessCache() # Update cache again now that @getActiveThemes() is populated - @loadUserStylesheet() - @reloadBaseStylesheets() - @initialLoadComplete = true - @emit 'reloaded' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-active-themes' - deferred.resolve() - - deferred.promise - - deactivateThemes: -> - @removeActiveThemeClasses() - @unwatchUserStylesheet() - @packageManager.deactivatePackage(pack.name) for pack in @getActiveThemes() - null - - isInitialLoadComplete: -> @initialLoadComplete - - addActiveThemeClasses: -> - workspaceElement = atom.views.getView(atom.workspace) - for pack in @getActiveThemes() - workspaceElement.classList.add("theme-#{pack.name}") - return - - removeActiveThemeClasses: -> - workspaceElement = atom.views.getView(atom.workspace) - for pack in @getActiveThemes() - workspaceElement.classList.remove("theme-#{pack.name}") - return - - refreshLessCache: -> - @lessCache?.setImportPaths(@getImportPaths()) - - getImportPaths: -> - activeThemes = @getActiveThemes() - if activeThemes.length > 0 - themePaths = (theme.getStylesheetsPath() for theme in activeThemes when theme) - else - themePaths = [] - for themeName in @getEnabledThemeNames() - if themePath = @packageManager.resolvePackagePath(themeName) - deprecatedPath = path.join(themePath, 'stylesheets') - if fs.isDirectorySync(deprecatedPath) - themePaths.push(deprecatedPath) - else - themePaths.push(path.join(themePath, 'styles')) - - themePaths.filter (themePath) -> fs.isDirectorySync(themePath) - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - EmitterMixin.includeInto(ThemeManager) - - ThemeManager::on = (eventName) -> - switch eventName - when 'reloaded' - Grim.deprecate 'Use ThemeManager::onDidChangeActiveThemes instead' - when 'stylesheet-added' - Grim.deprecate 'Use ThemeManager::onDidAddStylesheet instead' - when 'stylesheet-removed' - Grim.deprecate 'Use ThemeManager::onDidRemoveStylesheet instead' - when 'stylesheet-updated' - Grim.deprecate 'Use ThemeManager::onDidUpdateStylesheet instead' - when 'stylesheets-changed' - Grim.deprecate 'Use ThemeManager::onDidChangeStylesheets instead' - else - Grim.deprecate 'ThemeManager::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - - ThemeManager::onDidReloadAll = (callback) -> - Grim.deprecate("Use `::onDidChangeActiveThemes` instead.") - @onDidChangeActiveThemes(callback) - - ThemeManager::onDidAddStylesheet = (callback) -> - Grim.deprecate("Use atom.styles.onDidAddStyleElement instead") - @emitter.on 'did-add-stylesheet', callback - - ThemeManager::onDidRemoveStylesheet = (callback) -> - Grim.deprecate("Use atom.styles.onDidRemoveStyleElement instead") - @emitter.on 'did-remove-stylesheet', callback - - ThemeManager::onDidUpdateStylesheet = (callback) -> - Grim.deprecate("Use atom.styles.onDidUpdateStyleElement instead") - @emitter.on 'did-update-stylesheet', callback - - ThemeManager::onDidChangeStylesheets = (callback) -> - Grim.deprecate("Use atom.styles.onDidAdd/RemoveStyleElement instead") - @emitter.on 'did-change-stylesheets', callback - - ThemeManager::getUserStylesheetPath = -> - Grim.deprecate("Call atom.styles.getUserStyleSheetPath() instead") - atom.styles.getUserStyleSheetPath() - - ThemeManager::getLoadedNames = -> - Grim.deprecate("Use `::getLoadedThemeNames` instead.") - @getLoadedThemeNames() - - ThemeManager::getActiveNames = -> - Grim.deprecate("Use `::getActiveThemeNames` instead.") - @getActiveThemeNames() - - ThemeManager::setEnabledThemes = (enabledThemeNames) -> - Grim.deprecate("Use `atom.config.set('core.themes', arrayOfThemeNames)` instead") - atom.config.set('core.themes', enabledThemeNames) diff --git a/src/theme-manager.js b/src/theme-manager.js new file mode 100644 index 00000000000..a25d08937ca --- /dev/null +++ b/src/theme-manager.js @@ -0,0 +1,464 @@ +/* global snapshotAuxiliaryData */ + +const path = require('path'); +const _ = require('underscore-plus'); +const { Emitter, CompositeDisposable } = require('event-kit'); +const { File } = require('pathwatcher'); +const fs = require('fs-plus'); +const LessCompileCache = require('./less-compile-cache'); + +// Extended: Handles loading and activating available themes. +// +// An instance of this class is always available as the `atom.themes` global. +module.exports = class ThemeManager { + constructor({ + packageManager, + config, + styleManager, + notificationManager, + viewRegistry + }) { + this.packageManager = packageManager; + this.config = config; + this.styleManager = styleManager; + this.notificationManager = notificationManager; + this.viewRegistry = viewRegistry; + this.emitter = new Emitter(); + this.styleSheetDisposablesBySourcePath = {}; + this.lessCache = null; + this.initialLoadComplete = false; + this.packageManager.registerPackageActivator(this, ['theme']); + this.packageManager.onDidActivateInitialPackages(() => { + this.onDidChangeActiveThemes(() => + this.packageManager.reloadActivePackageStyleSheets() + ); + }); + } + + initialize({ resourcePath, configDirPath, safeMode, devMode }) { + this.resourcePath = resourcePath; + this.configDirPath = configDirPath; + this.safeMode = safeMode; + this.lessSourcesByRelativeFilePath = null; + if (devMode || typeof snapshotAuxiliaryData === 'undefined') { + this.lessSourcesByRelativeFilePath = {}; + this.importedFilePathsByRelativeImportPath = {}; + } else { + this.lessSourcesByRelativeFilePath = + snapshotAuxiliaryData.lessSourcesByRelativeFilePath; + this.importedFilePathsByRelativeImportPath = + snapshotAuxiliaryData.importedFilePathsByRelativeImportPath; + } + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke `callback` when style sheet changes associated with + // 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); + } + + /* + Section: Accessing Available Themes + */ + + getAvailableNames() { + // TODO: Maybe should change to list all the available themes out there? + return this.getLoadedNames(); + } + + /* + Section: Accessing Loaded Themes + */ + + // Public: Returns an {Array} of {String}s of all the loaded theme names. + getLoadedThemeNames() { + return this.getLoadedThemes().map(theme => theme.name); + } + + // Public: Returns an {Array} of all the loaded themes. + getLoadedThemes() { + return this.packageManager + .getLoadedPackages() + .filter(pack => pack.isTheme()); + } + + /* + Section: Accessing Active Themes + */ + + // Public: Returns an {Array} of {String}s of all the active theme names. + getActiveThemeNames() { + return this.getActiveThemes().map(theme => theme.name); + } + + // Public: Returns an {Array} of all the active themes. + getActiveThemes() { + return this.packageManager + .getActivePackages() + .filter(pack => pack.isTheme()); + } + + activatePackages() { + return this.activateThemes(); + } + + /* + Section: Managing Enabled Themes + */ + + warnForNonExistentThemes() { + let themeNames = this.config.get('core.themes') || []; + if (!Array.isArray(themeNames)) { + themeNames = [themeNames]; + } + for (let themeName of themeNames) { + if ( + !themeName || + typeof themeName !== 'string' || + !this.packageManager.resolvePackagePath(themeName) + ) { + console.warn(`Enabled theme '${themeName}' is not installed.`); + } + } + } + + // Public: Get the enabled theme names from the config. + // + // Returns an array of theme names in the order that they should be activated. + getEnabledThemeNames() { + let themeNames = this.config.get('core.themes') || []; + if (!Array.isArray(themeNames)) { + themeNames = [themeNames]; + } + themeNames = themeNames.filter( + themeName => + typeof themeName === 'string' && + this.packageManager.resolvePackagePath(themeName) + ); + + // Use a built-in syntax and UI theme any time the configured themes are not + // available. + if (themeNames.length < 2) { + const builtInThemeNames = [ + 'atom-dark-syntax', + 'atom-dark-ui', + 'atom-light-syntax', + 'atom-light-ui', + 'base16-tomorrow-dark-theme', + 'base16-tomorrow-light-theme', + 'solarized-dark-syntax', + 'solarized-light-syntax' + ]; + themeNames = _.intersection(themeNames, builtInThemeNames); + if (themeNames.length === 0) { + themeNames = ['one-dark-syntax', 'one-dark-ui']; + } else if (themeNames.length === 1) { + if (themeNames[0].endsWith('-ui')) { + themeNames.unshift('one-dark-syntax'); + } else { + themeNames.push('one-dark-ui'); + } + } + } + + // Reverse so the first (top) theme is loaded after the others. We want + // the first/top theme to override later themes in the stack. + return themeNames.reverse(); + } + + /* + Section: Private + */ + + // Resolve and apply the stylesheet specified by the path. + // + // This supports both CSS and Less stylesheets. + // + // * `stylesheetPath` A {String} path to the stylesheet that can be an absolute + // path or a relative path that will be resolved against the load path. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // required stylesheet. + requireStylesheet( + stylesheetPath, + priority, + skipDeprecatedSelectorsTransformation + ) { + let fullPath = this.resolveStylesheet(stylesheetPath); + if (fullPath) { + const content = this.loadStylesheet(fullPath); + return this.applyStylesheet( + fullPath, + content, + priority, + skipDeprecatedSelectorsTransformation + ); + } else { + throw new Error(`Could not find a file at path '${stylesheetPath}'`); + } + } + + unwatchUserStylesheet() { + if (this.userStylesheetSubscriptions != null) + this.userStylesheetSubscriptions.dispose(); + this.userStylesheetSubscriptions = null; + this.userStylesheetFile = null; + if (this.userStyleSheetDisposable != null) + this.userStyleSheetDisposable.dispose(); + this.userStyleSheetDisposable = null; + } + + loadUserStylesheet() { + this.unwatchUserStylesheet(); + + const userStylesheetPath = this.styleManager.getUserStyleSheetPath(); + if (!fs.isFileSync(userStylesheetPath)) { + return; + } + + try { + this.userStylesheetFile = new File(userStylesheetPath); + this.userStylesheetSubscriptions = new CompositeDisposable(); + const reloadStylesheet = () => this.loadUserStylesheet(); + this.userStylesheetSubscriptions.add( + this.userStylesheetFile.onDidChange(reloadStylesheet) + ); + this.userStylesheetSubscriptions.add( + this.userStylesheetFile.onDidRename(reloadStylesheet) + ); + this.userStylesheetSubscriptions.add( + this.userStylesheetFile.onDidDelete(reloadStylesheet) + ); + } catch (error) { + const message = `\ +Unable to watch path: \`${path.basename(userStylesheetPath)}\`. Make sure +you have permissions to \`${userStylesheetPath}\`. + +On linux there are currently problems with watch sizes. See +[this document][watches] for more info. +[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\ +`; + this.notificationManager.addError(message, { dismissable: true }); + } + + let userStylesheetContents; + try { + userStylesheetContents = this.loadStylesheet(userStylesheetPath, true); + } catch (error) { + return; + } + + this.userStyleSheetDisposable = this.styleManager.addStyleSheet( + userStylesheetContents, + { sourcePath: userStylesheetPath, priority: 2 } + ); + } + + loadBaseStylesheets() { + this.reloadBaseStylesheets(); + } + + reloadBaseStylesheets() { + this.requireStylesheet('../static/atom', -2, true); + } + + stylesheetElementForId(id) { + const escapedId = id.replace(/\\/g, '\\\\'); + return document.head.querySelector( + `atom-styles style[source-path="${escapedId}"]` + ); + } + + resolveStylesheet(stylesheetPath) { + if (path.extname(stylesheetPath).length > 0) { + return fs.resolveOnLoadPath(stylesheetPath); + } else { + return fs.resolveOnLoadPath(stylesheetPath, ['css', 'less']); + } + } + + loadStylesheet(stylesheetPath, importFallbackVariables) { + if (path.extname(stylesheetPath) === '.less') { + return this.loadLessStylesheet(stylesheetPath, importFallbackVariables); + } else { + return fs.readFileSync(stylesheetPath, 'utf8'); + } + } + + loadLessStylesheet(lessStylesheetPath, importFallbackVariables = false) { + if (this.lessCache == null) { + this.lessCache = new LessCompileCache({ + resourcePath: this.resourcePath, + lessSourcesByRelativeFilePath: this.lessSourcesByRelativeFilePath, + importedFilePathsByRelativeImportPath: this + .importedFilePathsByRelativeImportPath, + importPaths: this.getImportPaths() + }); + } + + try { + if (importFallbackVariables) { + const baseVarImports = `\ +@import "variables/ui-variables"; +@import "variables/syntax-variables";\ +`; + const relativeFilePath = path.relative( + this.resourcePath, + lessStylesheetPath + ); + const lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath]; + + let content, digest; + if (lessSource != null) { + ({ content } = lessSource); + ({ digest } = lessSource); + } else { + content = + baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8'); + digest = null; + } + + return this.lessCache.cssForFile(lessStylesheetPath, content, digest); + } else { + return this.lessCache.read(lessStylesheetPath); + } + } catch (error) { + let detail, message; + error.less = true; + if (error.line != null) { + // Adjust line numbers for import fallbacks + if (importFallbackVariables) { + error.line -= 2; + } + + message = `Error compiling Less stylesheet: \`${lessStylesheetPath}\``; + detail = `Line number: ${error.line}\n${error.message}`; + } else { + message = `Error loading Less stylesheet: \`${lessStylesheetPath}\``; + detail = error.message; + } + + this.notificationManager.addError(message, { detail, dismissable: true }); + throw error; + } + } + + removeStylesheet(stylesheetPath) { + if (this.styleSheetDisposablesBySourcePath[stylesheetPath] != null) { + this.styleSheetDisposablesBySourcePath[stylesheetPath].dispose(); + } + } + + applyStylesheet(path, text, priority, skipDeprecatedSelectorsTransformation) { + this.styleSheetDisposablesBySourcePath[ + path + ] = this.styleManager.addStyleSheet(text, { + priority, + skipDeprecatedSelectorsTransformation, + sourcePath: path + }); + + return this.styleSheetDisposablesBySourcePath[path]; + } + + activateThemes() { + return new Promise(resolve => { + // @config.observe runs the callback once, then on subsequent changes. + this.config.observe('core.themes', () => { + this.deactivateThemes().then(() => { + this.warnForNonExistentThemes(); + this.refreshLessCache(); // Update cache for packages in core.themes config + + const promises = []; + for (const themeName of this.getEnabledThemeNames()) { + if (this.packageManager.resolvePackagePath(themeName)) { + promises.push(this.packageManager.activatePackage(themeName)); + } else { + console.warn( + `Failed to activate theme '${themeName}' because it isn't installed.` + ); + } + } + + return Promise.all(promises).then(() => { + this.addActiveThemeClasses(); + this.refreshLessCache(); // Update cache again now that @getActiveThemes() is populated + this.loadUserStylesheet(); + this.reloadBaseStylesheets(); + this.initialLoadComplete = true; + this.emitter.emit('did-change-active-themes'); + resolve(); + }); + }); + }); + }); + } + + deactivateThemes() { + this.removeActiveThemeClasses(); + this.unwatchUserStylesheet(); + const results = this.getActiveThemes().map(pack => + this.packageManager.deactivatePackage(pack.name) + ); + return Promise.all( + results.filter(r => r != null && typeof r.then === 'function') + ); + } + + isInitialLoadComplete() { + return this.initialLoadComplete; + } + + addActiveThemeClasses() { + const workspaceElement = this.viewRegistry.getView(this.workspace); + if (workspaceElement) { + for (const pack of this.getActiveThemes()) { + workspaceElement.classList.add(`theme-${pack.name}`); + } + } + } + + removeActiveThemeClasses() { + const workspaceElement = this.viewRegistry.getView(this.workspace); + for (const pack of this.getActiveThemes()) { + workspaceElement.classList.remove(`theme-${pack.name}`); + } + } + + refreshLessCache() { + if (this.lessCache) this.lessCache.setImportPaths(this.getImportPaths()); + } + + getImportPaths() { + let themePaths; + const activeThemes = this.getActiveThemes(); + if (activeThemes.length > 0) { + themePaths = activeThemes + .filter(theme => theme) + .map(theme => theme.getStylesheetsPath()); + } else { + themePaths = []; + for (const themeName of this.getEnabledThemeNames()) { + const themePath = this.packageManager.resolvePackagePath(themeName); + if (themePath) { + const deprecatedPath = path.join(themePath, 'stylesheets'); + if (fs.isDirectorySync(deprecatedPath)) { + themePaths.push(deprecatedPath); + } else { + themePaths.push(path.join(themePath, 'styles')); + } + } + } + } + + return themePaths.filter(themePath => fs.isDirectorySync(themePath)); + } +}; diff --git a/src/theme-package.coffee b/src/theme-package.coffee deleted file mode 100644 index f14750d2fb2..00000000000 --- a/src/theme-package.coffee +++ /dev/null @@ -1,31 +0,0 @@ -Q = require 'q' -Package = require './package' - -module.exports = -class ThemePackage extends Package - getType: -> 'theme' - - getStyleSheetPriority: -> 1 - - enable: -> - atom.config.unshiftAtKeyPath('core.themes', @name) - - disable: -> - atom.config.removeAtKeyPath('core.themes', @name) - - load: -> - @loadTime = 0 - this - - activate: -> - return @activationDeferred.promise if @activationDeferred? - - @activationDeferred = Q.defer() - @measure 'activateTime', => - try - @loadStylesheets() - @activateNow() - catch error - @handleError("Failed to activate the #{@name} theme", error) - - @activationDeferred.promise diff --git a/src/theme-package.js b/src/theme-package.js new file mode 100644 index 00000000000..b9abb9d92f1 --- /dev/null +++ b/src/theme-package.js @@ -0,0 +1,57 @@ +const path = require('path'); +const Package = require('./package'); + +module.exports = class ThemePackage extends Package { + getType() { + return 'theme'; + } + + getStyleSheetPriority() { + return 1; + } + + enable() { + this.config.unshiftAtKeyPath('core.themes', this.name); + } + + disable() { + this.config.removeAtKeyPath('core.themes', this.name); + } + + preload() { + this.loadTime = 0; + this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata(); + } + + finishLoading() { + this.path = path.join(this.packageManager.resourcePath, this.path); + } + + load() { + this.loadTime = 0; + this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata(); + return this; + } + + activate() { + if (this.activationPromise == null) { + this.activationPromise = new Promise((resolve, reject) => { + this.resolveActivationPromise = resolve; + this.rejectActivationPromise = reject; + this.measure('activateTime', () => { + try { + this.loadStylesheets(); + this.activateNow(); + } catch (error) { + this.handleError( + `Failed to activate the ${this.name} theme`, + error + ); + } + }); + }); + } + + return this.activationPromise; + } +}; diff --git a/src/title-bar.js b/src/title-bar.js new file mode 100644 index 00000000000..267c92356d8 --- /dev/null +++ b/src/title-bar.js @@ -0,0 +1,53 @@ +module.exports = class TitleBar { + constructor({ workspace, themes, applicationDelegate }) { + this.dblclickHandler = this.dblclickHandler.bind(this); + this.workspace = workspace; + this.themes = themes; + this.applicationDelegate = applicationDelegate; + this.element = document.createElement('div'); + this.element.classList.add('title-bar'); + + this.titleElement = document.createElement('div'); + this.titleElement.classList.add('title'); + this.element.appendChild(this.titleElement); + + this.element.addEventListener('dblclick', this.dblclickHandler); + + this.workspace.onDidChangeWindowTitle(() => this.updateTitle()); + this.themes.onDidChangeActiveThemes(() => this.updateWindowSheetOffset()); + + this.updateTitle(); + this.updateWindowSheetOffset(); + } + + dblclickHandler() { + // User preference deciding which action to take on a title bar double-click + switch ( + this.applicationDelegate.getUserDefault( + 'AppleActionOnDoubleClick', + 'string' + ) + ) { + case 'Minimize': + this.applicationDelegate.minimizeWindow(); + break; + case 'Maximize': + if (this.applicationDelegate.isWindowMaximized()) { + this.applicationDelegate.unmaximizeWindow(); + } else { + this.applicationDelegate.maximizeWindow(); + } + break; + } + } + + updateTitle() { + this.titleElement.textContent = document.title; + } + + updateWindowSheetOffset() { + this.applicationDelegate + .getCurrentWindow() + .setSheetOffset(this.element.offsetHeight); + } +}; diff --git a/src/token-iterator.coffee b/src/token-iterator.coffee deleted file mode 100644 index 202b044ba35..00000000000 --- a/src/token-iterator.coffee +++ /dev/null @@ -1,83 +0,0 @@ -{SoftTab, HardTab, PairedCharacter, SoftWrapIndent} = require './special-token-symbols' - -module.exports = -class TokenIterator - constructor: (line) -> - @reset(line) if line? - - reset: (@line) -> - @index = null - @bufferStart = @line.startBufferColumn - @bufferEnd = @bufferStart - @screenStart = 0 - @screenEnd = 0 - @scopes = @line.openScopes.map (id) -> atom.grammars.scopeForId(id) - @scopeStarts = @scopes.slice() - @scopeEnds = [] - this - - next: -> - {tags} = @line - - if @index? - @index++ - @scopeEnds.length = 0 - @scopeStarts.length = 0 - @bufferStart = @bufferEnd - @screenStart = @screenEnd - else - @index = 0 - - while @index < tags.length - tag = tags[@index] - if tag < 0 - if tag % 2 is 0 - @scopeEnds.push(atom.grammars.scopeForId(tag + 1)) - @scopes.pop() - else - scope = atom.grammars.scopeForId(tag) - @scopeStarts.push(scope) - @scopes.push(scope) - @index++ - else - if @isHardTab() - @screenEnd = @screenStart + tag - @bufferEnd = @bufferStart + 1 - else if @isSoftWrapIndentation() - @screenEnd = @screenStart + tag - @bufferEnd = @bufferStart + 0 - else - @screenEnd = @screenStart + tag - @bufferEnd = @bufferStart + tag - return true - - false - - getBufferStart: -> @bufferStart - getBufferEnd: -> @bufferEnd - - getScreenStart: -> @screenStart - getScreenEnd: -> @screenEnd - - getScopeStarts: -> @scopeStarts - getScopeEnds: -> @scopeEnds - - getScopes: -> @scopes - - getText: -> - @line.text.substring(@screenStart, @screenEnd) - - isSoftTab: -> - @line.specialTokens[@index] is SoftTab - - isHardTab: -> - @line.specialTokens[@index] is HardTab - - isSoftWrapIndentation: -> - @line.specialTokens[@index] is SoftWrapIndent - - isPairedCharacter: -> - @line.specialTokens[@index] is PairedCharacter - - isAtomic: -> - @isSoftTab() or @isHardTab() or @isSoftWrapIndentation() or @isPairedCharacter() diff --git a/src/token-iterator.js b/src/token-iterator.js new file mode 100644 index 00000000000..c9fcfe81e9b --- /dev/null +++ b/src/token-iterator.js @@ -0,0 +1,80 @@ +module.exports = class TokenIterator { + constructor(languageMode) { + this.languageMode = languageMode; + } + + reset(line) { + this.line = line; + this.index = null; + this.startColumn = 0; + this.endColumn = 0; + this.scopes = this.line.openScopes.map(id => + this.languageMode.grammar.scopeForId(id) + ); + this.scopeStarts = this.scopes.slice(); + this.scopeEnds = []; + return this; + } + + next() { + const { tags } = this.line; + + if (this.index != null) { + this.startColumn = this.endColumn; + this.scopeEnds.length = 0; + this.scopeStarts.length = 0; + this.index++; + } else { + this.index = 0; + } + + while (this.index < tags.length) { + const tag = tags[this.index]; + if (tag < 0) { + const scope = this.languageMode.grammar.scopeForId(tag); + if (tag % 2 === 0) { + if (this.scopeStarts[this.scopeStarts.length - 1] === scope) { + this.scopeStarts.pop(); + } else { + this.scopeEnds.push(scope); + } + this.scopes.pop(); + } else { + this.scopeStarts.push(scope); + this.scopes.push(scope); + } + this.index++; + } else { + this.endColumn += tag; + this.text = this.line.text.substring(this.startColumn, this.endColumn); + return true; + } + } + + return false; + } + + getScopes() { + return this.scopes; + } + + getScopeStarts() { + return this.scopeStarts; + } + + getScopeEnds() { + return this.scopeEnds; + } + + getText() { + return this.text; + } + + getBufferStart() { + return this.startColumn; + } + + getBufferEnd() { + return this.endColumn; + } +}; diff --git a/src/token.coffee b/src/token.coffee index 60e8194f884..902f54fa208 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -1,47 +1,25 @@ _ = require 'underscore-plus' StartDotRegex = /^\.?/ -WhitespaceRegex = /\S/ # Represents a single unit of text as selected by a grammar. module.exports = class Token value: null - hasPairedCharacter: false scopes: null - isAtomic: null - isHardTab: null - firstNonWhitespaceIndex: null - firstTrailingWhitespaceIndex: null - hasInvisibleCharacters: false constructor: (properties) -> - {@value, @scopes, @isAtomic, @isHardTab, @bufferDelta} = properties - {@hasInvisibleCharacters, @hasPairedCharacter, @isSoftWrapIndentation} = properties - @firstNonWhitespaceIndex = properties.firstNonWhitespaceIndex ? null - @firstTrailingWhitespaceIndex = properties.firstTrailingWhitespaceIndex ? null - - @screenDelta = @value.length - @bufferDelta ?= @screenDelta + {@value, @scopes} = properties isEqual: (other) -> # TODO: scopes is deprecated. This is here for the sake of lang package tests - @value is other.value and _.isEqual(@scopes, other.scopes) and !!@isAtomic is !!other.isAtomic + @value is other.value and _.isEqual(@scopes, other.scopes) isBracket: -> /^meta\.brace\b/.test(_.last(@scopes)) - isOnlyWhitespace: -> - not WhitespaceRegex.test(@value) - matchesScopeSelector: (selector) -> targetClasses = selector.replace(StartDotRegex, '').split('.') _.any @scopes, (scope) -> scopeClasses = scope.split('.') _.isSubset(targetClasses, scopeClasses) - - hasLeadingWhitespace: -> - @firstNonWhitespaceIndex? and @firstNonWhitespaceIndex > 0 - - hasTrailingWhitespace: -> - @firstTrailingWhitespaceIndex? and @firstTrailingWhitespaceIndex < @value.length diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee deleted file mode 100644 index 60ebe16f094..00000000000 --- a/src/tokenized-buffer.coffee +++ /dev/null @@ -1,506 +0,0 @@ -_ = require 'underscore-plus' -{CompositeDisposable, Emitter} = require 'event-kit' -{Point, Range} = require 'text-buffer' -{ScopeSelector} = require 'first-mate' -Serializable = require 'serializable' -Model = require './model' -TokenizedLine = require './tokenized-line' -TokenIterator = require './token-iterator' -Token = require './token' -ScopeDescriptor = require './scope-descriptor' -Grim = require 'grim' - -module.exports = -class TokenizedBuffer extends Model - Serializable.includeInto(this) - - grammar: null - currentGrammarScore: null - buffer: null - tabLength: null - tokenizedLines: null - chunkSize: 50 - invalidRows: null - visible: false - configSettings: null - - constructor: ({@buffer, @tabLength, @ignoreInvisibles}) -> - @emitter = new Emitter - @disposables = new CompositeDisposable - @tokenIterator = new TokenIterator - - @disposables.add atom.grammars.onDidAddGrammar(@grammarAddedOrUpdated) - @disposables.add atom.grammars.onDidUpdateGrammar(@grammarAddedOrUpdated) - - @disposables.add @buffer.preemptDidChange (e) => @handleBufferChange(e) - @disposables.add @buffer.onDidChangePath (@bufferPath) => @reloadGrammar() - - @reloadGrammar() - - destroyed: -> - @disposables.dispose() - - serializeParams: -> - bufferPath: @buffer.getPath() - tabLength: @tabLength - ignoreInvisibles: @ignoreInvisibles - - deserializeParams: (params) -> - params.buffer = atom.project.bufferForPathSync(params.bufferPath) - params - - observeGrammar: (callback) -> - callback(@grammar) - @onDidChangeGrammar(callback) - - onDidChangeGrammar: (callback) -> - @emitter.on 'did-change-grammar', callback - - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - onDidTokenize: (callback) -> - @emitter.on 'did-tokenize', callback - - grammarAddedOrUpdated: (grammar) => - if grammar.injectionSelector? - @retokenizeLines() if @hasTokenForSelector(grammar.injectionSelector) - else - newScore = grammar.getScore(@buffer.getPath(), @buffer.getText()) - @setGrammar(grammar, newScore) if newScore > @currentGrammarScore - - setGrammar: (grammar, score) -> - return if grammar is @grammar - - @grammar = grammar - @rootScopeDescriptor = new ScopeDescriptor(scopes: [@grammar.scopeName]) - @currentGrammarScore = score ? grammar.getScore(@buffer.getPath(), @buffer.getText()) - - @grammarUpdateDisposable?.dispose() - @grammarUpdateDisposable = @grammar.onDidUpdate => @retokenizeLines() - @disposables.add(@grammarUpdateDisposable) - - scopeOptions = {scope: @rootScopeDescriptor} - @configSettings = - tabLength: atom.config.get('editor.tabLength', scopeOptions) - invisibles: atom.config.get('editor.invisibles', scopeOptions) - showInvisibles: atom.config.get('editor.showInvisibles', scopeOptions) - - if @configSubscriptions? - @configSubscriptions.dispose() - @disposables.remove(@configSubscriptions) - @configSubscriptions = new CompositeDisposable - @configSubscriptions.add atom.config.onDidChange 'editor.tabLength', scopeOptions, ({newValue}) => - @configSettings.tabLength = newValue - @retokenizeLines() - ['invisibles', 'showInvisibles'].forEach (key) => - @configSubscriptions.add atom.config.onDidChange "editor.#{key}", scopeOptions, ({newValue}) => - oldInvisibles = @getInvisiblesToShow() - @configSettings[key] = newValue - @retokenizeLines() unless _.isEqual(@getInvisiblesToShow(), oldInvisibles) - @disposables.add(@configSubscriptions) - - @retokenizeLines() - - @emit 'grammar-changed', grammar if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change-grammar', grammar - - reloadGrammar: -> - if grammar = atom.grammars.selectGrammar(@buffer.getPath(), @buffer.getText()) - @setGrammar(grammar) - else - throw new Error("No grammar found for path: #{path}") - - hasTokenForSelector: (selector) -> - for {tokens} in @tokenizedLines - for token in tokens - return true if selector.matches(token.scopes) - false - - retokenizeLines: -> - lastRow = @buffer.getLastRow() - @tokenizedLines = @buildPlaceholderTokenizedLinesForRows(0, lastRow) - @invalidRows = [] - @invalidateRow(0) - @fullyTokenized = false - event = {start: 0, end: lastRow, delta: 0} - @emit 'changed', event if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change', event - - setVisible: (@visible) -> - @tokenizeInBackground() if @visible - - getTabLength: -> - @tabLength ? @configSettings.tabLength - - setTabLength: (tabLength) -> - return if tabLength is @tabLength - - @tabLength = tabLength - @retokenizeLines() - - setIgnoreInvisibles: (ignoreInvisibles) -> - if ignoreInvisibles isnt @ignoreInvisibles - @ignoreInvisibles = ignoreInvisibles - if @configSettings.showInvisibles and @configSettings.invisibles? - @retokenizeLines() - - tokenizeInBackground: -> - return if not @visible or @pendingChunk or not @isAlive() - - @pendingChunk = true - _.defer => - @pendingChunk = false - @tokenizeNextChunk() if @isAlive() and @buffer.isAlive() - - tokenizeNextChunk: -> - # Short circuit null grammar which can just use the placeholder tokens - if @grammar is atom.grammars.nullGrammar and @firstInvalidRow()? - @invalidRows = [] - @markTokenizationComplete() - return - - rowsRemaining = @chunkSize - - while @firstInvalidRow()? and rowsRemaining > 0 - startRow = @invalidRows.shift() - lastRow = @getLastRow() - continue if startRow > lastRow - - row = startRow - loop - previousStack = @stackForRow(row) - @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1), @openScopesForRow(row)) - if --rowsRemaining is 0 - filledRegion = false - endRow = row - break - if row is lastRow or _.isEqual(@stackForRow(row), previousStack) - filledRegion = true - endRow = row - break - row++ - - @validateRow(endRow) - @invalidateRow(endRow + 1) unless filledRegion - - [startRow, endRow] = @updateFoldableStatus(startRow, endRow) - - event = {start: startRow, end: endRow, delta: 0} - @emit 'changed', event if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change', event - - if @firstInvalidRow()? - @tokenizeInBackground() - else - @markTokenizationComplete() - - markTokenizationComplete: -> - unless @fullyTokenized - @emit 'tokenized' if Grim.includeDeprecatedAPIs - @emitter.emit 'did-tokenize' - @fullyTokenized = true - - firstInvalidRow: -> - @invalidRows[0] - - validateRow: (row) -> - @invalidRows.shift() while @invalidRows[0] <= row - return - - invalidateRow: (row) -> - @invalidRows.push(row) - @invalidRows.sort (a, b) -> a - b - @tokenizeInBackground() - - updateInvalidRows: (start, end, delta) -> - @invalidRows = @invalidRows.map (row) -> - if row < start - row - else if start <= row <= end - end + delta + 1 - else if row > end - row + delta - - handleBufferChange: (e) -> - {oldRange, newRange} = e - start = oldRange.start.row - end = oldRange.end.row - delta = newRange.end.row - oldRange.end.row - - @updateInvalidRows(start, end, delta) - previousEndStack = @stackForRow(end) # used in spill detection below - newTokenizedLines = @buildTokenizedLinesForRows(start, end + delta, @stackForRow(start - 1), @openScopesForRow(start)) - _.spliceWithArray(@tokenizedLines, start, end - start + 1, newTokenizedLines) - - start = @retokenizeWhitespaceRowsIfIndentLevelChanged(start - 1, -1) - end = @retokenizeWhitespaceRowsIfIndentLevelChanged(newRange.end.row + 1, 1) - delta - - newEndStack = @stackForRow(end + delta) - if newEndStack and not _.isEqual(newEndStack, previousEndStack) - @invalidateRow(end + delta + 1) - - [start, end] = @updateFoldableStatus(start, end + delta) - end -= delta - - event = {start, end, delta, bufferChange: e} - @emit 'changed', event if Grim.includeDeprecatedAPIs - @emitter.emit 'did-change', event - - retokenizeWhitespaceRowsIfIndentLevelChanged: (row, increment) -> - line = @tokenizedLines[row] - if line?.isOnlyWhitespace() and @indentLevelForRow(row) isnt line.indentLevel - while line?.isOnlyWhitespace() - @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1), @openScopesForRow(row)) - row += increment - line = @tokenizedLines[row] - - row - increment - - updateFoldableStatus: (startRow, endRow) -> - scanStartRow = @buffer.previousNonBlankRow(startRow) ? startRow - scanStartRow-- while scanStartRow > 0 and @tokenizedLineForRow(scanStartRow).isComment() - scanEndRow = @buffer.nextNonBlankRow(endRow) ? endRow - - for row in [scanStartRow..scanEndRow] by 1 - foldable = @isFoldableAtRow(row) - line = @tokenizedLineForRow(row) - unless line.foldable is foldable - line.foldable = foldable - startRow = Math.min(startRow, row) - endRow = Math.max(endRow, row) - - [startRow, endRow] - - isFoldableAtRow: (row) -> - @isFoldableCodeAtRow(row) or @isFoldableCommentAtRow(row) - - # Returns a {Boolean} indicating whether the given buffer row starts - # a a foldable row range due to the code's indentation patterns. - isFoldableCodeAtRow: (row) -> - return false if @buffer.isRowBlank(row) or @tokenizedLineForRow(row).isComment() - nextRow = @buffer.nextNonBlankRow(row) - return false unless nextRow? - - @indentLevelForRow(nextRow) > @indentLevelForRow(row) - - isFoldableCommentAtRow: (row) -> - previousRow = row - 1 - nextRow = row + 1 - return false if nextRow > @buffer.getLastRow() - - (row is 0 or not @tokenizedLineForRow(previousRow).isComment()) and - @tokenizedLineForRow(row).isComment() and - @tokenizedLineForRow(nextRow).isComment() - - buildTokenizedLinesForRows: (startRow, endRow, startingStack, startingopenScopes) -> - ruleStack = startingStack - openScopes = startingopenScopes - stopTokenizingAt = startRow + @chunkSize - tokenizedLines = for row in [startRow..endRow] - if (ruleStack or row is 0) and row < stopTokenizingAt - tokenizedLine = @buildTokenizedLineForRow(row, ruleStack, openScopes) - ruleStack = tokenizedLine.ruleStack - openScopes = @scopesFromTags(openScopes, tokenizedLine.tags) - else - tokenizedLine = @buildPlaceholderTokenizedLineForRow(row, openScopes) - tokenizedLine - - if endRow >= stopTokenizingAt - @invalidateRow(stopTokenizingAt) - @tokenizeInBackground() - - tokenizedLines - - buildPlaceholderTokenizedLinesForRows: (startRow, endRow) -> - @buildPlaceholderTokenizedLineForRow(row) for row in [startRow..endRow] - - buildPlaceholderTokenizedLineForRow: (row) -> - openScopes = [@grammar.startIdForScope(@grammar.scopeName)] - text = @buffer.lineForRow(row) - tags = [text.length] - tabLength = @getTabLength() - indentLevel = @indentLevelForRow(row) - lineEnding = @buffer.lineEndingForRow(row) - new TokenizedLine({openScopes, text, tags, tabLength, indentLevel, invisibles: @getInvisiblesToShow(), lineEnding, @tokenIterator}) - - buildTokenizedLineForRow: (row, ruleStack, openScopes) -> - @buildTokenizedLineForRowWithText(row, @buffer.lineForRow(row), ruleStack, openScopes) - - buildTokenizedLineForRowWithText: (row, text, ruleStack = @stackForRow(row - 1), openScopes = @openScopesForRow(row)) -> - lineEnding = @buffer.lineEndingForRow(row) - tabLength = @getTabLength() - indentLevel = @indentLevelForRow(row) - {tags, ruleStack} = @grammar.tokenizeLine(text, ruleStack, row is 0, false) - new TokenizedLine({openScopes, text, tags, ruleStack, tabLength, lineEnding, indentLevel, invisibles: @getInvisiblesToShow(), @tokenIterator}) - - getInvisiblesToShow: -> - if @configSettings.showInvisibles and not @ignoreInvisibles - @configSettings.invisibles - else - null - - tokenizedLineForRow: (bufferRow) -> - @tokenizedLines[bufferRow] - - stackForRow: (bufferRow) -> - @tokenizedLines[bufferRow]?.ruleStack - - openScopesForRow: (bufferRow) -> - if bufferRow > 0 - precedingLine = @tokenizedLines[bufferRow - 1] - @scopesFromTags(precedingLine.openScopes, precedingLine.tags) - else - [] - - scopesFromTags: (startingScopes, tags) -> - scopes = startingScopes.slice() - for tag in tags when tag < 0 - if (tag % 2) is -1 - scopes.push(tag) - else - expectedScope = tag + 1 - poppedScope = scopes.pop() - unless poppedScope is expectedScope - throw new Error("Encountered an invalid scope end id. Popped #{poppedScope}, expected to pop #{expectedScope}.") - scopes - - indentLevelForRow: (bufferRow) -> - line = @buffer.lineForRow(bufferRow) - indentLevel = 0 - - if line is '' - nextRow = bufferRow + 1 - lineCount = @getLineCount() - while nextRow < lineCount - nextLine = @buffer.lineForRow(nextRow) - unless nextLine is '' - indentLevel = Math.ceil(@indentLevelForLine(nextLine)) - break - nextRow++ - - previousRow = bufferRow - 1 - while previousRow >= 0 - previousLine = @buffer.lineForRow(previousRow) - unless previousLine is '' - indentLevel = Math.max(Math.ceil(@indentLevelForLine(previousLine)), indentLevel) - break - previousRow-- - - indentLevel - else - @indentLevelForLine(line) - - indentLevelForLine: (line) -> - if match = line.match(/^[\t ]+/) - leadingWhitespace = match[0] - tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 - spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 - tabCount + (spaceCount / @getTabLength()) - else - 0 - - scopeDescriptorForPosition: (position) -> - {row, column} = Point.fromObject(position) - - iterator = @tokenizedLines[row].getTokenIterator() - while iterator.next() - if iterator.getScreenEnd() > column - scopes = iterator.getScopes() - break - - # rebuild scope of last token if we iterated off the end - unless scopes? - scopes = iterator.getScopes() - scopes.push(iterator.getScopeEnds().reverse()...) - - new ScopeDescriptor({scopes}) - - tokenForPosition: (position) -> - {row, column} = Point.fromObject(position) - @tokenizedLines[row].tokenAtBufferColumn(column) - - tokenStartPositionForPosition: (position) -> - {row, column} = Point.fromObject(position) - column = @tokenizedLines[row].tokenStartColumnForBufferColumn(column) - new Point(row, column) - - bufferRangeForScopeAtPosition: (selector, position) -> - selector = new ScopeSelector(selector.replace(/^\./, '')) - position = Point.fromObject(position) - - {openScopes, tags} = @tokenizedLines[position.row] - scopes = openScopes.map (tag) -> atom.grammars.scopeForId(tag) - - startColumn = 0 - for tag, tokenIndex in tags - if tag < 0 - if tag % 2 is -1 - scopes.push(atom.grammars.scopeForId(tag)) - else - scopes.pop() - else - endColumn = startColumn + tag - if endColumn > position.column - break - else - startColumn = endColumn - - return unless selector.matches(scopes) - - startScopes = scopes.slice() - for startTokenIndex in [(tokenIndex - 1)..0] by -1 - tag = tags[startTokenIndex] - if tag < 0 - if tag % 2 is -1 - startScopes.pop() - else - startScopes.push(atom.grammars.scopeForId(tag)) - else - break unless selector.matches(startScopes) - startColumn -= tag - - endScopes = scopes.slice() - for endTokenIndex in [(tokenIndex + 1)...tags.length] by 1 - tag = tags[endTokenIndex] - if tag < 0 - if tag % 2 is -1 - endScopes.push(atom.grammars.scopeForId(tag)) - else - endScopes.pop() - else - break unless selector.matches(endScopes) - endColumn += tag - - new Range(new Point(position.row, startColumn), new Point(position.row, endColumn)) - - # Gets the row number of the last line. - # - # Returns a {Number}. - getLastRow: -> - @buffer.getLastRow() - - getLineCount: -> - @buffer.getLineCount() - - logLines: (start=0, end=@buffer.getLastRow()) -> - for row in [start..end] - line = @tokenizedLineForRow(row).text - console.log row, line, line.length - return - -if Grim.includeDeprecatedAPIs - EmitterMixin = require('emissary').Emitter - - TokenizedBuffer::on = (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use TokenizedBuffer::onDidChange instead") - when 'grammar-changed' - Grim.deprecate("Use TokenizedBuffer::onDidChangeGrammar instead") - when 'tokenized' - Grim.deprecate("Use TokenizedBuffer::onDidTokenize instead") - else - Grim.deprecate("TokenizedBuffer::on is deprecated. Use event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 45af81e57d7..abace0fcde3 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -1,425 +1,43 @@ -_ = require 'underscore-plus' -{isPairedCharacter} = require './text-utils' Token = require './token' -{SoftTab, HardTab, PairedCharacter, SoftWrapIndent} = require './special-token-symbols' - -NonWhitespaceRegex = /\S/ -LeadingWhitespaceRegex = /^\s*/ -TrailingWhitespaceRegex = /\s*$/ -RepeatedSpaceRegex = /[ ]/g CommentScopeRegex = /(\b|\.)comment/ + idCounter = 1 module.exports = class TokenizedLine - endOfLineInvisibles: null - lineIsWhitespaceOnly: false - firstNonWhitespaceIndex: 0 - foldable: false - constructor: (properties) -> @id = idCounter++ return unless properties? - @specialTokens = {} - {@openScopes, @text, @tags, @lineEnding, @ruleStack, @tokenIterator} = properties - {@startBufferColumn, @fold, @tabLength, @indentLevel, @invisibles} = properties - - @startBufferColumn ?= 0 - @bufferDelta = @text.length - - @transformContent() - @buildEndOfLineInvisibles() if @invisibles? and @lineEnding? - - transformContent: -> - text = '' - bufferColumn = 0 - screenColumn = 0 - tokenIndex = 0 - tokenOffset = 0 - firstNonWhitespaceColumn = null - lastNonWhitespaceColumn = null - - while bufferColumn < @text.length - # advance to next token if we've iterated over its length - if tokenOffset is @tags[tokenIndex] - tokenIndex++ - tokenOffset = 0 - - # advance to next token tag - tokenIndex++ while @tags[tokenIndex] < 0 - - character = @text[bufferColumn] - - # split out unicode surrogate pairs - if isPairedCharacter(@text, bufferColumn) - prefix = tokenOffset - suffix = @tags[tokenIndex] - tokenOffset - 2 - splitTokens = [] - splitTokens.push(prefix) if prefix > 0 - splitTokens.push(2) - splitTokens.push(suffix) if suffix > 0 - - @tags.splice(tokenIndex, 1, splitTokens...) - - firstNonWhitespaceColumn ?= screenColumn - lastNonWhitespaceColumn = screenColumn + 1 - - text += @text.substr(bufferColumn, 2) - screenColumn += 2 - bufferColumn += 2 - - tokenIndex++ if prefix > 0 - @specialTokens[tokenIndex] = PairedCharacter - tokenIndex++ - tokenOffset = 0 - - # split out leading soft tabs - else if character is ' ' - if firstNonWhitespaceColumn? - text += ' ' - else - if (screenColumn + 1) % @tabLength is 0 - @specialTokens[tokenIndex] = SoftTab - suffix = @tags[tokenIndex] - @tabLength - @tags.splice(tokenIndex, 1, @tabLength) - @tags.splice(tokenIndex + 1, 0, suffix) if suffix > 0 - text += @invisibles?.space ? ' ' - - screenColumn++ - bufferColumn++ - tokenOffset++ - - # expand hard tabs to the next tab stop - else if character is '\t' - tabLength = @tabLength - (screenColumn % @tabLength) - if @invisibles?.tab - text += @invisibles.tab - else - text += ' ' - text += ' ' for i in [1...tabLength] by 1 - - prefix = tokenOffset - suffix = @tags[tokenIndex] - tokenOffset - 1 - splitTokens = [] - splitTokens.push(prefix) if prefix > 0 - splitTokens.push(tabLength) - splitTokens.push(suffix) if suffix > 0 - - @tags.splice(tokenIndex, 1, splitTokens...) - - screenColumn += tabLength - bufferColumn++ - - tokenIndex++ if prefix > 0 - @specialTokens[tokenIndex] = HardTab - tokenIndex++ - tokenOffset = 0 - - # continue past any other character - else - firstNonWhitespaceColumn ?= screenColumn - lastNonWhitespaceColumn = screenColumn - - text += character - screenColumn++ - bufferColumn++ - tokenOffset++ - - @text = text - - @firstNonWhitespaceIndex = firstNonWhitespaceColumn - if lastNonWhitespaceColumn? - if lastNonWhitespaceColumn + 1 < @text.length - @firstTrailingWhitespaceIndex = lastNonWhitespaceColumn + 1 - if @invisibles?.space - @text = - @text.substring(0, @firstTrailingWhitespaceIndex) + - @text.substring(@firstTrailingWhitespaceIndex) - .replace(RepeatedSpaceRegex, @invisibles.space) - else - @lineIsWhitespaceOnly = true - @firstTrailingWhitespaceIndex = 0 + {@openScopes, @text, @tags, @ruleStack, @tokenIterator, @grammar, tokens} = properties + @cachedTokens = tokens getTokenIterator: -> @tokenIterator.reset(this) Object.defineProperty @prototype, 'tokens', get: -> - iterator = @getTokenIterator() - tokens = [] - - while iterator.next() - properties = { - value: iterator.getText() - scopes: iterator.getScopes().slice() - isAtomic: iterator.isAtomic() - isHardTab: iterator.isHardTab() - hasPairedCharacter: iterator.isPairedCharacter() - isSoftWrapIndentation: iterator.isSoftWrapIndentation() - } - - if iterator.isHardTab() - properties.bufferDelta = 1 - properties.hasInvisibleCharacters = true if @invisibles?.tab - - if iterator.getScreenStart() < @firstNonWhitespaceIndex - properties.firstNonWhitespaceIndex = - Math.min(@firstNonWhitespaceIndex, iterator.getScreenEnd()) - iterator.getScreenStart() - properties.hasInvisibleCharacters = true if @invisibles?.space - - if @lineEnding? and iterator.getScreenEnd() > @firstTrailingWhitespaceIndex - properties.firstTrailingWhitespaceIndex = - Math.max(0, @firstTrailingWhitespaceIndex - iterator.getScreenStart()) - properties.hasInvisibleCharacters = true if @invisibles?.space - - tokens.push(new Token(properties)) - - tokens - - copy: -> - copy = new TokenizedLine - copy.tokenIterator = @tokenIterator - copy.indentLevel = @indentLevel - copy.openScopes = @openScopes - copy.text = @text - copy.tags = @tags - copy.specialTokens = @specialTokens - copy.firstNonWhitespaceIndex = @firstNonWhitespaceIndex - copy.firstTrailingWhitespaceIndex = @firstTrailingWhitespaceIndex - copy.lineEnding = @lineEnding - copy.endOfLineInvisibles = @endOfLineInvisibles - copy.ruleStack = @ruleStack - copy.startBufferColumn = @startBufferColumn - copy.fold = @fold - copy - - # This clips a given screen column to a valid column that's within the line - # and not in the middle of any atomic tokens. - # - # column - A {Number} representing the column to clip - # options - A hash with the key clip. Valid values for this key: - # 'closest' (default): clip to the closest edge of an atomic token. - # 'forward': clip to the forward edge. - # 'backward': clip to the backward edge. - # - # Returns a {Number} representing the clipped column. - clipScreenColumn: (column, options={}) -> - return 0 if @tags.length is 0 - - {clip} = options - column = Math.min(column, @getMaxScreenColumn()) - - tokenStartColumn = 0 - - iterator = @getTokenIterator() - while iterator.next() - break if iterator.getScreenEnd() > column - - if iterator.isSoftWrapIndentation() - iterator.next() while iterator.isSoftWrapIndentation() - iterator.getScreenStart() - else if iterator.isAtomic() and iterator.getScreenStart() < column - if clip is 'forward' - iterator.getScreenEnd() - else if clip is 'backward' - iterator.getScreenStart() - else #'closest' - if column > ((iterator.getScreenStart() + iterator.getScreenEnd()) / 2) - iterator.getScreenEnd() - else - iterator.getScreenStart() + if @cachedTokens + @cachedTokens else - column + iterator = @getTokenIterator() + tokens = [] - screenColumnForBufferColumn: (targetBufferColumn, options) -> - iterator = @getTokenIterator() - while iterator.next() - tokenBufferStart = iterator.getBufferStart() - tokenBufferEnd = iterator.getBufferEnd() - if tokenBufferStart <= targetBufferColumn < tokenBufferEnd - overshoot = targetBufferColumn - tokenBufferStart - return Math.min( - iterator.getScreenStart() + overshoot, - iterator.getScreenEnd() - ) - iterator.getScreenEnd() + while iterator.next() + tokens.push(new Token({ + value: iterator.getText() + scopes: iterator.getScopes().slice() + })) - bufferColumnForScreenColumn: (targetScreenColumn) -> - iterator = @getTokenIterator() - while iterator.next() - tokenScreenStart = iterator.getScreenStart() - tokenScreenEnd = iterator.getScreenEnd() - if tokenScreenStart <= targetScreenColumn < tokenScreenEnd - overshoot = targetScreenColumn - tokenScreenStart - return Math.min( - iterator.getBufferStart() + overshoot, - iterator.getBufferEnd() - ) - iterator.getBufferEnd() - - getMaxScreenColumn: -> - if @fold - 0 - else - @text.length - - getMaxBufferColumn: -> - @startBufferColumn + @bufferDelta - - # Given a boundary column, finds the point where this line would wrap. - # - # maxColumn - The {Number} where you want soft wrapping to occur - # - # Returns a {Number} representing the `line` position where the wrap would take place. - # Returns `null` if a wrap wouldn't occur. - findWrapColumn: (maxColumn) -> - return unless maxColumn? - return unless @text.length > maxColumn - - if /\s/.test(@text[maxColumn]) - # search forward for the start of a word past the boundary - for column in [maxColumn..@text.length] - return column if /\S/.test(@text[column]) - - return @text.length - else - # search backward for the start of the word on the boundary - for column in [maxColumn..@firstNonWhitespaceIndex] - return column + 1 if /\s/.test(@text[column]) - - return maxColumn - - softWrapAt: (column, hangingIndent) -> - return [null, this] if column is 0 - - leftText = @text.substring(0, column) - rightText = @text.substring(column) - - leftTags = [] - rightTags = [] - - leftSpecialTokens = {} - rightSpecialTokens = {} - - rightOpenScopes = @openScopes.slice() - - screenColumn = 0 - - for tag, index in @tags - # tag represents a token - if tag >= 0 - # token ends before the soft wrap column - if screenColumn + tag <= column - if specialToken = @specialTokens[index] - leftSpecialTokens[index] = specialToken - leftTags.push(tag) - screenColumn += tag - - # token starts before and ends after the split column - else if screenColumn <= column - leftSuffix = column - screenColumn - rightPrefix = screenColumn + tag - column - - leftTags.push(leftSuffix) if leftSuffix > 0 - - softWrapIndent = @indentLevel * @tabLength + (hangingIndent ? 0) - for i in [0...softWrapIndent] by 1 - rightText = ' ' + rightText - remainingSoftWrapIndent = softWrapIndent - while remainingSoftWrapIndent > 0 - indentToken = Math.min(remainingSoftWrapIndent, @tabLength) - rightSpecialTokens[rightTags.length] = SoftWrapIndent - rightTags.push(indentToken) - remainingSoftWrapIndent -= indentToken - - rightTags.push(rightPrefix) if rightPrefix > 0 - - screenColumn += tag - - # token is after split column - else - if specialToken = @specialTokens[index] - rightSpecialTokens[rightTags.length] = specialToken - rightTags.push(tag) - - # tag represents the start or end of a scop - else if (tag % 2) is -1 - if screenColumn < column - leftTags.push(tag) - rightOpenScopes.push(tag) - else - rightTags.push(tag) - else - if screenColumn < column - leftTags.push(tag) - rightOpenScopes.pop() - else - rightTags.push(tag) - - splitBufferColumn = @bufferColumnForScreenColumn(column) - - leftFragment = new TokenizedLine - leftFragment.tokenIterator = @tokenIterator - leftFragment.openScopes = @openScopes - leftFragment.text = leftText - leftFragment.tags = leftTags - leftFragment.specialTokens = leftSpecialTokens - leftFragment.startBufferColumn = @startBufferColumn - leftFragment.bufferDelta = splitBufferColumn - @startBufferColumn - leftFragment.ruleStack = @ruleStack - leftFragment.invisibles = @invisibles - leftFragment.lineEnding = null - leftFragment.indentLevel = @indentLevel - leftFragment.tabLength = @tabLength - leftFragment.firstNonWhitespaceIndex = Math.min(column, @firstNonWhitespaceIndex) - leftFragment.firstTrailingWhitespaceIndex = Math.min(column, @firstTrailingWhitespaceIndex) - - rightFragment = new TokenizedLine - rightFragment.tokenIterator = @tokenIterator - rightFragment.openScopes = rightOpenScopes - rightFragment.text = rightText - rightFragment.tags = rightTags - rightFragment.specialTokens = rightSpecialTokens - rightFragment.startBufferColumn = splitBufferColumn - rightFragment.bufferDelta = @bufferDelta - splitBufferColumn - rightFragment.ruleStack = @ruleStack - rightFragment.invisibles = @invisibles - rightFragment.lineEnding = @lineEnding - rightFragment.indentLevel = @indentLevel - rightFragment.tabLength = @tabLength - rightFragment.endOfLineInvisibles = @endOfLineInvisibles - rightFragment.firstNonWhitespaceIndex = Math.max(softWrapIndent, @firstNonWhitespaceIndex - column + softWrapIndent) - rightFragment.firstTrailingWhitespaceIndex = Math.max(softWrapIndent, @firstTrailingWhitespaceIndex - column + softWrapIndent) - - [leftFragment, rightFragment] - - isSoftWrapped: -> - @lineEnding is null - - isColumnInsideSoftWrapIndentation: (targetColumn) -> - targetColumn < @getSoftWrapIndentationDelta() - - getSoftWrapIndentationDelta: -> - delta = 0 - for tag, index in @tags - if tag >= 0 - if @specialTokens[index] is SoftWrapIndent - delta += tag - else - break - delta - - hasOnlySoftWrapIndentation: -> - @getSoftWrapIndentationDelta() is @text.length + tokens tokenAtBufferColumn: (bufferColumn) -> @tokens[@tokenIndexAtBufferColumn(bufferColumn)] tokenIndexAtBufferColumn: (bufferColumn) -> - delta = 0 + column = 0 for token, index in @tokens - delta += token.bufferDelta - return index if delta > bufferColumn + column += token.value.length + return index if column > bufferColumn index - 1 tokenStartColumnForBufferColumn: (bufferColumn) -> @@ -430,31 +48,38 @@ class TokenizedLine delta = nextDelta delta - buildEndOfLineInvisibles: -> - @endOfLineInvisibles = [] - {cr, eol} = @invisibles - - switch @lineEnding - when '\r\n' - @endOfLineInvisibles.push(cr) if cr - @endOfLineInvisibles.push(eol) if eol - when '\n' - @endOfLineInvisibles.push(eol) if eol - isComment: -> - iterator = @getTokenIterator() - while iterator.next() - scopes = iterator.getScopes() - continue if scopes.length is 1 - continue unless NonWhitespaceRegex.test(iterator.getText()) - for scope in scopes - return true if CommentScopeRegex.test(scope) - break + return @isCommentLine if @isCommentLine? + + @isCommentLine = false + + for tag in @openScopes + if @isCommentOpenTag(tag) + @isCommentLine = true + return @isCommentLine + + startIndex = 0 + for tag in @tags + # If we haven't encountered any comment scope when reading the first + # non-whitespace chunk of text, then we consider this as not being a + # comment line. + if tag > 0 + break unless isWhitespaceOnly(@text.substr(startIndex, tag)) + startIndex += tag + + if @isCommentOpenTag(tag) + @isCommentLine = true + return @isCommentLine + + @isCommentLine + + isCommentOpenTag: (tag) -> + if tag < 0 and (tag & 1) is 1 + scope = @grammar.scopeForId(tag) + if CommentScopeRegex.test(scope) + return true false - isOnlyWhitespace: -> - @lineIsWhitespaceOnly - tokenAtIndex: (index) -> @tokens[index] @@ -462,3 +87,9 @@ class TokenizedLine count = 0 count++ for tag in @tags when tag >= 0 count + +isWhitespaceOnly = (text) -> + for char in text + if char isnt '\t' and char isnt ' ' + return false + return true diff --git a/src/tooltip-manager.coffee b/src/tooltip-manager.coffee deleted file mode 100644 index ee2054b5a2f..00000000000 --- a/src/tooltip-manager.coffee +++ /dev/null @@ -1,106 +0,0 @@ -_ = require 'underscore-plus' -{Disposable} = require 'event-kit' -{$} = require './space-pen-extensions' - -# Essential: Associates tooltips with HTML elements or selectors. -# -# You can get the `TooltipManager` via `atom.tooltips`. -# -# ## Examples -# -# The essence of displaying a tooltip -# -# ```coffee -# # display it -# disposable = atom.tooltips.add(div, {title: 'This is a tooltip'}) -# -# # remove it -# disposable.dispose() -# ``` -# -# In practice there are usually multiple tooltips. So we add them to a -# CompositeDisposable -# -# ```coffee -# {CompositeDisposable} = require 'atom' -# subscriptions = new CompositeDisposable -# -# div1 = document.createElement('div') -# div2 = document.createElement('div') -# subscriptions.add atom.tooltips.add(div1, {title: 'This is a tooltip'}) -# subscriptions.add atom.tooltips.add(div2, {title: 'Another tooltip'}) -# -# # remove them all -# subscriptions.dispose() -# ``` -# -# You can display a key binding in the tooltip as well with the -# `keyBindingCommand` option. -# -# ```coffee -# disposable = atom.tooltips.add @caseOptionButton, -# title: "Match Case" -# keyBindingCommand: 'find-and-replace:toggle-case-option' -# keyBindingTarget: @findEditor.element -# ``` -module.exports = -class TooltipManager - defaults: - delay: - show: 1000 - hide: 100 - container: 'body' - html: true - placement: 'auto top' - viewportPadding: 2 - - # Essential: Add a tooltip to the given element. - # - # * `target` An `HTMLElement` - # * `options` See http://getbootstrap.com/javascript/#tooltips for a full list - # of options. You can also supply the following additional options: - # * `title` {String} Text in the tip. - # * `keyBindingCommand` A {String} containing a command name. If you specify - # this option and a key binding exists that matches the command, it will - # be appended to the title or rendered alone if no title is specified. - # * `keyBindingTarget` An `HTMLElement` on which to look up the key binding. - # If this option is not supplied, the first of all matching key bindings - # for the given command will be rendered. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # tooltip. - add: (target, options) -> - requireBootstrapTooltip() - - {keyBindingCommand, keyBindingTarget} = options - - if keyBindingCommand? - bindings = atom.keymaps.findKeyBindings(command: keyBindingCommand, target: keyBindingTarget) - keystroke = getKeystroke(bindings) - if options.title? and keystroke? - options.title += " " + getKeystroke(bindings) - else if keystroke? - options.title = getKeystroke(bindings) - - $target = $(target) - $target.tooltip(_.defaults(options, @defaults)) - - new Disposable -> - tooltip = $target.data('bs.tooltip') - if tooltip? - tooltip.leave(currentTarget: target) - tooltip.hide() - $target.tooltip('destroy') - -humanizeKeystrokes = (keystroke) -> - keystrokes = keystroke.split(' ') - keystrokes = (_.humanizeKeystroke(stroke) for stroke in keystrokes) - keystrokes.join(' ') - -getKeystroke = (bindings) -> - if bindings?.length - "#{humanizeKeystrokes(bindings[0].keystrokes)}" - else - -requireBootstrapTooltip = _.once -> - atom.requireWithGlobals('bootstrap/js/tooltip', {jQuery: $}) diff --git a/src/tooltip-manager.js b/src/tooltip-manager.js new file mode 100644 index 00000000000..4d08b6b83ac --- /dev/null +++ b/src/tooltip-manager.js @@ -0,0 +1,209 @@ +const _ = require('underscore-plus'); +const { Disposable, CompositeDisposable } = require('event-kit'); +let Tooltip = null; + +// Essential: Associates tooltips with HTML elements. +// +// You can get the `TooltipManager` via `atom.tooltips`. +// +// ## Examples +// +// The essence of displaying a tooltip +// +// ```js +// // display it +// const disposable = atom.tooltips.add(div, {title: 'This is a tooltip'}) +// +// // remove it +// disposable.dispose() +// ``` +// +// In practice there are usually multiple tooltips. So we add them to a +// CompositeDisposable +// +// ```js +// const {CompositeDisposable} = require('atom') +// const subscriptions = new CompositeDisposable() +// +// const div1 = document.createElement('div') +// const div2 = document.createElement('div') +// subscriptions.add(atom.tooltips.add(div1, {title: 'This is a tooltip'})) +// subscriptions.add(atom.tooltips.add(div2, {title: 'Another tooltip'})) +// +// // remove them all +// subscriptions.dispose() +// ``` +// +// You can display a key binding in the tooltip as well with the +// `keyBindingCommand` option. +// +// ```js +// disposable = atom.tooltips.add(this.caseOptionButton, { +// title: 'Match Case', +// keyBindingCommand: 'find-and-replace:toggle-case-option', +// keyBindingTarget: this.findEditor.element +// }) +// ``` +module.exports = class TooltipManager { + constructor({ keymapManager, viewRegistry }) { + this.defaults = { + trigger: 'hover', + container: 'body', + html: true, + placement: 'auto top', + viewportPadding: 2 + }; + + this.hoverDefaults = { + delay: { show: 1000, hide: 100 } + }; + + this.keymapManager = keymapManager; + this.viewRegistry = viewRegistry; + this.tooltips = new Map(); + } + + // Essential: Add a tooltip to the given element. + // + // * `target` An `HTMLElement` + // * `options` An object with one or more of the following options: + // * `title` A {String} or {Function} to use for the text in the tip. If + // a function is passed, `this` will be set to the `target` element. This + // option is mutually exclusive with the `item` option. + // * `html` A {Boolean} affecting the interpretation of the `title` option. + // If `true` (the default), the `title` string will be interpreted as HTML. + // Otherwise it will be interpreted as plain text. + // * `item` A view (object with an `.element` property) or a DOM element + // containing custom content for the tooltip. This option is mutually + // exclusive with the `title` option. + // * `class` A {String} with a class to apply to the tooltip element to + // enable custom styling. + // * `placement` A {String} or {Function} returning a string to indicate + // the position of the tooltip relative to `element`. Can be `'top'`, + // `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is + // specified, it will dynamically reorient the tooltip. For example, if + // placement is `'auto left'`, the tooltip will display to the left when + // possible, otherwise it will display right. + // When a function is used to determine the placement, it is called with + // the tooltip DOM node as its first argument and the triggering element + // DOM node as its second. The `this` context is set to the tooltip + // instance. + // * `trigger` A {String} indicating how the tooltip should be displayed. + // Choose from one of the following options: + // * `'hover'` Show the tooltip when the mouse hovers over the element. + // This is the default. + // * `'click'` Show the tooltip when the element is clicked. The tooltip + // will be hidden after clicking the element again or anywhere else + // outside of the tooltip itself. + // * `'focus'` Show the tooltip when the element is focused. + // * `'manual'` Show the tooltip immediately and only hide it when the + // returned disposable is disposed. + // * `delay` An object specifying the show and hide delay in milliseconds. + // Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and + // otherwise defaults to `0` for both values. + // * `keyBindingCommand` A {String} containing a command name. If you specify + // this option and a key binding exists that matches the command, it will + // be appended to the title or rendered alone if no title is specified. + // * `keyBindingTarget` An `HTMLElement` on which to look up the key binding. + // If this option is not supplied, the first of all matching key bindings + // for the given command will be rendered. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // tooltip. + add(target, options) { + if (target.jquery) { + const disposable = new CompositeDisposable(); + for (let i = 0; i < target.length; i++) { + disposable.add(this.add(target[i], options)); + } + return disposable; + } + + if (Tooltip == null) { + Tooltip = require('./tooltip'); + } + + const { keyBindingCommand, keyBindingTarget } = options; + + if (keyBindingCommand != null) { + const bindings = this.keymapManager.findKeyBindings({ + command: keyBindingCommand, + target: keyBindingTarget + }); + const keystroke = getKeystroke(bindings); + if (options.title != null && keystroke != null) { + options.title += ` ${getKeystroke(bindings)}`; + } else if (keystroke != null) { + options.title = getKeystroke(bindings); + } + } + + delete options.selector; + options = _.defaults(options, this.defaults); + if (options.trigger === 'hover') { + options = _.defaults(options, this.hoverDefaults); + } + + const tooltip = new Tooltip(target, options, this.viewRegistry); + + if (!this.tooltips.has(target)) { + this.tooltips.set(target, []); + } + this.tooltips.get(target).push(tooltip); + + const hideTooltip = function() { + tooltip.leave({ currentTarget: target }); + tooltip.hide(); + }; + + // note: adding a listener here adds a new listener for every tooltip element that's registered. Adding unnecessary listeners is bad for performance. It would be better to add/remove listeners when tooltips are actually created in the dom. + window.addEventListener('resize', hideTooltip); + + const disposable = new Disposable(() => { + window.removeEventListener('resize', hideTooltip); + + hideTooltip(); + tooltip.destroy(); + + if (this.tooltips.has(target)) { + const tooltipsForTarget = this.tooltips.get(target); + const index = tooltipsForTarget.indexOf(tooltip); + if (index !== -1) { + tooltipsForTarget.splice(index, 1); + } + if (tooltipsForTarget.length === 0) { + this.tooltips.delete(target); + } + } + }); + + return disposable; + } + + // Extended: Find the tooltips that have been applied to the given element. + // + // * `target` The `HTMLElement` to find tooltips on. + // + // Returns an {Array} of `Tooltip` objects that match the `target`. + findTooltips(target) { + if (this.tooltips.has(target)) { + return this.tooltips.get(target).slice(); + } else { + return []; + } + } +}; + +function humanizeKeystrokes(keystroke) { + let keystrokes = keystroke.split(' '); + keystrokes = keystrokes.map(stroke => _.humanizeKeystroke(stroke)); + return keystrokes.join(' '); +} + +function getKeystroke(bindings) { + if (bindings && bindings.length) { + return `${humanizeKeystrokes( + bindings[0].keystrokes + )}`; + } +} diff --git a/src/tooltip.js b/src/tooltip.js new file mode 100644 index 00000000000..3d59f8036db --- /dev/null +++ b/src/tooltip.js @@ -0,0 +1,691 @@ +'use strict'; + +const EventKit = require('event-kit'); +const tooltipComponentsByElement = new WeakMap(); +const listen = require('./delegated-listener'); + +// This tooltip class is derived from Bootstrap 3, but modified to not require +// jQuery, which is an expensive dependency we want to eliminate. + +let followThroughTimer = null; + +const Tooltip = function(element, options, viewRegistry) { + this.options = null; + this.enabled = null; + this.timeout = null; + this.hoverState = null; + this.element = null; + this.inState = null; + this.viewRegistry = viewRegistry; + + this.init(element, options); +}; + +Tooltip.VERSION = '3.3.5'; + +Tooltip.FOLLOW_THROUGH_DURATION = 300; + +Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: + '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } +}; + +Tooltip.prototype.init = function(element, options) { + this.enabled = true; + this.element = element; + this.options = this.getOptions(options); + this.disposables = new EventKit.CompositeDisposable(); + this.mutationObserver = new MutationObserver(this.handleMutations.bind(this)); + + if (this.options.viewport) { + if (typeof this.options.viewport === 'function') { + this.viewport = this.options.viewport.call(this, this.element); + } else { + this.viewport = document.querySelector( + this.options.viewport.selector || this.options.viewport + ); + } + } + this.inState = { click: false, hover: false, focus: false }; + + if (this.element instanceof document.constructor && !this.options.selector) { + throw new Error( + '`selector` option must be specified when initializing tooltip on the window.document object!' + ); + } + + const triggers = this.options.trigger.split(' '); + + for (let i = triggers.length; i--; ) { + var trigger = triggers[i]; + + if (trigger === 'click') { + this.disposables.add( + listen( + this.element, + 'click', + this.options.selector, + this.toggle.bind(this) + ) + ); + this.hideOnClickOutsideOfTooltip = event => { + const tooltipElement = this.getTooltipElement(); + if (tooltipElement === event.target) return; + if (tooltipElement.contains(event.target)) return; + if (this.element === event.target) return; + if (this.element.contains(event.target)) return; + this.hide(); + }; + } else if (trigger === 'manual') { + this.show(); + } else { + let eventIn, eventOut; + + if (trigger === 'hover') { + this.hideOnKeydownOutsideOfTooltip = () => this.hide(); + if (this.options.selector) { + eventIn = 'mouseover'; + eventOut = 'mouseout'; + } else { + eventIn = 'mouseenter'; + eventOut = 'mouseleave'; + } + } else { + eventIn = 'focusin'; + eventOut = 'focusout'; + } + + this.disposables.add( + listen( + this.element, + eventIn, + this.options.selector, + this.enter.bind(this) + ) + ); + this.disposables.add( + listen( + this.element, + eventOut, + this.options.selector, + this.leave.bind(this) + ) + ); + } + } + + this.options.selector + ? (this._options = extend({}, this.options, { + trigger: 'manual', + selector: '' + })) + : this.fixTitle(); +}; + +Tooltip.prototype.startObservingMutations = function() { + this.mutationObserver.observe(this.getTooltipElement(), { + attributes: true, + childList: true, + characterData: true, + subtree: true + }); +}; + +Tooltip.prototype.stopObservingMutations = function() { + this.mutationObserver.disconnect(); +}; + +Tooltip.prototype.handleMutations = function() { + window.requestAnimationFrame( + function() { + this.stopObservingMutations(); + this.recalculatePosition(); + this.startObservingMutations(); + }.bind(this) + ); +}; + +Tooltip.prototype.getDefaults = function() { + return Tooltip.DEFAULTS; +}; + +Tooltip.prototype.getOptions = function(options) { + options = extend({}, this.getDefaults(), options); + + if (options.delay && typeof options.delay === 'number') { + options.delay = { + show: options.delay, + hide: options.delay + }; + } + + return options; +}; + +Tooltip.prototype.getDelegateOptions = function() { + const options = {}; + const defaults = this.getDefaults(); + + if (this._options) { + for (const key of Object.getOwnPropertyNames(this._options)) { + const value = this._options[key]; + if (defaults[key] !== value) options[key] = value; + } + } + + return options; +}; + +Tooltip.prototype.enter = function(event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).enter(event); + return; + } + + this.inState[event.type === 'focusin' ? 'focus' : 'hover'] = true; + } + + if ( + this.getTooltipElement().classList.contains('in') || + this.hoverState === 'in' + ) { + this.hoverState = 'in'; + return; + } + + clearTimeout(this.timeout); + + this.hoverState = 'in'; + + if (!this.options.delay || !this.options.delay.show || followThroughTimer) { + return this.show(); + } + + this.timeout = setTimeout( + function() { + if (this.hoverState === 'in') this.show(); + }.bind(this), + this.options.delay.show + ); +}; + +Tooltip.prototype.isInStateTrue = function() { + for (const key in this.inState) { + if (this.inState[key]) return true; + } + + return false; +}; + +Tooltip.prototype.leave = function(event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).leave(event); + return; + } + + this.inState[event.type === 'focusout' ? 'focus' : 'hover'] = false; + } + + if (this.isInStateTrue()) return; + + clearTimeout(this.timeout); + + this.hoverState = 'out'; + + if (!this.options.delay || !this.options.delay.hide) return this.hide(); + + this.timeout = setTimeout( + function() { + if (this.hoverState === 'out') this.hide(); + }.bind(this), + this.options.delay.hide + ); +}; + +Tooltip.prototype.show = function() { + if (this.hasContent() && this.enabled) { + if (this.hideOnClickOutsideOfTooltip) { + window.addEventListener('click', this.hideOnClickOutsideOfTooltip, { + capture: true + }); + } + + if (this.hideOnKeydownOutsideOfTooltip) { + window.addEventListener( + 'keydown', + this.hideOnKeydownOutsideOfTooltip, + true + ); + } + + const tip = this.getTooltipElement(); + this.startObservingMutations(); + const tipId = this.getUID('tooltip'); + + this.setContent(); + tip.setAttribute('id', tipId); + this.element.setAttribute('aria-describedby', tipId); + + if (this.options.animation) tip.classList.add('fade'); + + let placement = + typeof this.options.placement === 'function' + ? this.options.placement.call(this, tip, this.element) + : this.options.placement; + + const autoToken = /\s?auto?\s?/i; + const autoPlace = autoToken.test(placement); + if (autoPlace) placement = placement.replace(autoToken, '') || 'top'; + + tip.remove(); + tip.style.top = '0px'; + tip.style.left = '0px'; + tip.style.display = 'block'; + tip.classList.add(placement); + + document.body.appendChild(tip); + + const pos = this.element.getBoundingClientRect(); + const actualWidth = tip.offsetWidth; + const actualHeight = tip.offsetHeight; + + if (autoPlace) { + const orgPlacement = placement; + const viewportDim = this.viewport.getBoundingClientRect(); + + placement = + placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom + ? 'top' + : placement === 'top' && pos.top - actualHeight < viewportDim.top + ? 'bottom' + : placement === 'right' && pos.right + actualWidth > viewportDim.width + ? 'left' + : placement === 'left' && pos.left - actualWidth < viewportDim.left + ? 'right' + : placement; + + tip.classList.remove(orgPlacement); + tip.classList.add(placement); + } + + const calculatedOffset = this.getCalculatedOffset( + placement, + pos, + actualWidth, + actualHeight + ); + + this.applyPlacement(calculatedOffset, placement); + + const prevHoverState = this.hoverState; + this.hoverState = null; + + if (prevHoverState === 'out') this.leave(); + } +}; + +Tooltip.prototype.applyPlacement = function(offset, placement) { + const tip = this.getTooltipElement(); + + const width = tip.offsetWidth; + const height = tip.offsetHeight; + + // manually read margins because getBoundingClientRect includes difference + const computedStyle = window.getComputedStyle(tip); + const marginTop = parseInt(computedStyle.marginTop, 10); + const marginLeft = parseInt(computedStyle.marginLeft, 10); + + offset.top += marginTop; + offset.left += marginLeft; + + tip.style.top = offset.top + 'px'; + tip.style.left = offset.left + 'px'; + + tip.classList.add('in'); + + // check to see if placing tip in new offset caused the tip to resize itself + const actualWidth = tip.offsetWidth; + const actualHeight = tip.offsetHeight; + + if (placement === 'top' && actualHeight !== height) { + offset.top = offset.top + height - actualHeight; + } + + const delta = this.getViewportAdjustedDelta( + placement, + offset, + actualWidth, + actualHeight + ); + + if (delta.left) offset.left += delta.left; + else offset.top += delta.top; + + const isVertical = /top|bottom/.test(placement); + const arrowDelta = isVertical + ? delta.left * 2 - width + actualWidth + : delta.top * 2 - height + actualHeight; + const arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight'; + + tip.style.top = offset.top + 'px'; + tip.style.left = offset.left + 'px'; + + this.replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical); +}; + +Tooltip.prototype.replaceArrow = function(delta, dimension, isVertical) { + const arrow = this.getArrowElement(); + const amount = 50 * (1 - delta / dimension) + '%'; + + if (isVertical) { + arrow.style.left = amount; + arrow.style.top = ''; + } else { + arrow.style.top = amount; + arrow.style.left = ''; + } +}; + +Tooltip.prototype.setContent = function() { + const tip = this.getTooltipElement(); + + if (this.options.class) { + tip.classList.add(this.options.class); + } + + const inner = tip.querySelector('.tooltip-inner'); + if (this.options.item) { + inner.appendChild(this.viewRegistry.getView(this.options.item)); + } else { + const title = this.getTitle(); + if (this.options.html) { + inner.innerHTML = title; + } else { + inner.textContent = title; + } + } + + tip.classList.remove('fade', 'in', 'top', 'bottom', 'left', 'right'); +}; + +Tooltip.prototype.hide = function(callback) { + this.inState = {}; + + if (this.hideOnClickOutsideOfTooltip) { + window.removeEventListener('click', this.hideOnClickOutsideOfTooltip, true); + } + + if (this.hideOnKeydownOutsideOfTooltip) { + window.removeEventListener( + 'keydown', + this.hideOnKeydownOutsideOfTooltip, + true + ); + } + + this.tip && this.tip.classList.remove('in'); + this.stopObservingMutations(); + + if (this.hoverState !== 'in') this.tip && this.tip.remove(); + + this.element.removeAttribute('aria-describedby'); + + callback && callback(); + + this.hoverState = null; + + clearTimeout(followThroughTimer); + followThroughTimer = setTimeout(function() { + followThroughTimer = null; + }, Tooltip.FOLLOW_THROUGH_DURATION); + + return this; +}; + +Tooltip.prototype.fixTitle = function() { + if ( + this.element.getAttribute('title') || + typeof this.element.getAttribute('data-original-title') !== 'string' + ) { + this.element.setAttribute( + 'data-original-title', + this.element.getAttribute('title') || '' + ); + this.element.setAttribute('title', ''); + } +}; + +Tooltip.prototype.hasContent = function() { + return this.getTitle() || this.options.item; +}; + +Tooltip.prototype.getCalculatedOffset = function( + placement, + pos, + actualWidth, + actualHeight +) { + return placement === 'bottom' + ? { + top: pos.top + pos.height, + left: pos.left + pos.width / 2 - actualWidth / 2 + } + : placement === 'top' + ? { + top: pos.top - actualHeight, + left: pos.left + pos.width / 2 - actualWidth / 2 + } + : placement === 'left' + ? { + top: pos.top + pos.height / 2 - actualHeight / 2, + left: pos.left - actualWidth + } + : /* placement === 'right' */ { + top: pos.top + pos.height / 2 - actualHeight / 2, + left: pos.left + pos.width + }; +}; + +Tooltip.prototype.getViewportAdjustedDelta = function( + placement, + pos, + actualWidth, + actualHeight +) { + const delta = { top: 0, left: 0 }; + if (!this.viewport) return delta; + + const viewportPadding = + (this.options.viewport && this.options.viewport.padding) || 0; + const viewportDimensions = this.viewport.getBoundingClientRect(); + + if (/right|left/.test(placement)) { + const topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll; + const bottomEdgeOffset = + pos.top + viewportPadding - viewportDimensions.scroll + actualHeight; + if (topEdgeOffset < viewportDimensions.top) { + // top overflow + delta.top = viewportDimensions.top - topEdgeOffset; + } else if ( + bottomEdgeOffset > + viewportDimensions.top + viewportDimensions.height + ) { + // bottom overflow + delta.top = + viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset; + } + } else { + const leftEdgeOffset = pos.left - viewportPadding; + const rightEdgeOffset = pos.left + viewportPadding + actualWidth; + if (leftEdgeOffset < viewportDimensions.left) { + // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset; + } else if (rightEdgeOffset > viewportDimensions.right) { + // right overflow + delta.left = + viewportDimensions.left + viewportDimensions.width - rightEdgeOffset; + } + } + + return delta; +}; + +Tooltip.prototype.getTitle = function() { + const title = this.element.getAttribute('data-original-title'); + if (title) { + return title; + } else { + return typeof this.options.title === 'function' + ? this.options.title.call(this.element) + : this.options.title; + } +}; + +Tooltip.prototype.getUID = function(prefix) { + do prefix += ~~(Math.random() * 1000000); + while (document.getElementById(prefix)); + return prefix; +}; + +Tooltip.prototype.getTooltipElement = function() { + if (!this.tip) { + let div = document.createElement('div'); + div.innerHTML = this.options.template; + if (div.children.length !== 1) { + throw new Error( + 'Tooltip `template` option must consist of exactly 1 top-level element!' + ); + } + this.tip = div.firstChild; + } + return this.tip; +}; + +Tooltip.prototype.getArrowElement = function() { + this.arrow = + this.arrow || this.getTooltipElement().querySelector('.tooltip-arrow'); + return this.arrow; +}; + +Tooltip.prototype.enable = function() { + this.enabled = true; +}; + +Tooltip.prototype.disable = function() { + this.enabled = false; +}; + +Tooltip.prototype.toggleEnabled = function() { + this.enabled = !this.enabled; +}; + +Tooltip.prototype.toggle = function(event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).toggle(event); + return; + } + + this.inState.click = !this.inState.click; + if (this.isInStateTrue()) this.enter(); + else this.leave(); + } else { + this.getTooltipElement().classList.contains('in') + ? this.leave() + : this.enter(); + } +}; + +Tooltip.prototype.destroy = function() { + clearTimeout(this.timeout); + this.tip && this.tip.remove(); + this.disposables.dispose(); +}; + +Tooltip.prototype.getDelegateComponent = function(element) { + let component = tooltipComponentsByElement.get(element); + if (!component) { + component = new Tooltip( + element, + this.getDelegateOptions(), + this.viewRegistry + ); + tooltipComponentsByElement.set(element, component); + } + return component; +}; + +Tooltip.prototype.recalculatePosition = function() { + const tip = this.getTooltipElement(); + + let placement = + typeof this.options.placement === 'function' + ? this.options.placement.call(this, tip, this.element) + : this.options.placement; + + const autoToken = /\s?auto?\s?/i; + const autoPlace = autoToken.test(placement); + if (autoPlace) placement = placement.replace(autoToken, '') || 'top'; + + tip.classList.add(placement); + + const pos = this.element.getBoundingClientRect(); + const actualWidth = tip.offsetWidth; + const actualHeight = tip.offsetHeight; + + if (autoPlace) { + const orgPlacement = placement; + const viewportDim = this.viewport.getBoundingClientRect(); + + placement = + placement === 'bottom' && pos.bottom + actualHeight > viewportDim.bottom + ? 'top' + : placement === 'top' && pos.top - actualHeight < viewportDim.top + ? 'bottom' + : placement === 'right' && pos.right + actualWidth > viewportDim.width + ? 'left' + : placement === 'left' && pos.left - actualWidth < viewportDim.left + ? 'right' + : placement; + + tip.classList.remove(orgPlacement); + tip.classList.add(placement); + } + + const calculatedOffset = this.getCalculatedOffset( + placement, + pos, + actualWidth, + actualHeight + ); + this.applyPlacement(calculatedOffset, placement); +}; + +function extend() { + const args = Array.prototype.slice.apply(arguments); + const target = args.shift(); + let source = args.shift(); + while (source) { + for (const key of Object.getOwnPropertyNames(source)) { + target[key] = source[key]; + } + source = args.shift(); + } + return target; +} + +module.exports = Tooltip; diff --git a/src/tree-indenter.js b/src/tree-indenter.js new file mode 100644 index 00000000000..c536edd4870 --- /dev/null +++ b/src/tree-indenter.js @@ -0,0 +1,155 @@ +// const log = console.debug // in dev +const log = () => {}; // in production + +module.exports = class TreeIndenter { + constructor(languageMode, scopes = undefined) { + this.languageMode = languageMode; + this.scopes = + scopes || + languageMode.config.get('editor.scopes', { + scope: this.languageMode.rootScopeDescriptor + }); + log('[TreeIndenter] constructor', this.scopes); + } + + /** tree indenter is configured for this language */ + get isConfigured() { + return !!this.scopes; + } + + // Given a position, walk up the syntax tree, to find the highest level + // node that still starts here. This is to identify the column where this + // node (e.g., an HTML closing tag) ends. + _getHighestSyntaxNodeAtPosition(row, column = null) { + if (column == null) { + // Find the first character on the row that is not whitespace + 1 + column = this.languageMode.buffer.lineForRow(row).search(/\S/); + } + + let syntaxNode; + if (column >= 0) { + syntaxNode = this.languageMode.getSyntaxNodeAtPosition({ row, column }); + while ( + syntaxNode && + syntaxNode.parent && + syntaxNode.parent.startPosition.row === syntaxNode.startPosition.row && + syntaxNode.parent.endPosition.row === syntaxNode.startPosition.row && + syntaxNode.parent.startPosition.column === + syntaxNode.startPosition.column + ) { + syntaxNode = syntaxNode.parent; + } + return syntaxNode; + } + } + + /** Walk up the tree. Everytime we meet a scope type, check whether we + are coming from the first (resp. last) child. If so, we are opening + (resp. closing) that scope, i.e., do not count it. Otherwise, add 1. + + This is the core function. + + It might make more sense to reverse the direction of this walk, i.e., + go from root to leaf instead. + */ + _treeWalk(node, lastScope = null) { + if (node == null || node.parent == null) { + return 0; + } else { + let increment = 0; + + const notFirstOrLastSibling = + node.previousSibling != null && node.nextSibling != null; + + const isScope = this.scopes.indent[node.parent.type]; + notFirstOrLastSibling && isScope && increment++; + + const isScope2 = this.scopes.indentExceptFirst[node.parent.type]; + !increment && isScope2 && node.previousSibling != null && increment++; + + const isScope3 = this.scopes.indentExceptFirstOrBlock[node.parent.type]; + !increment && isScope3 && node.previousSibling != null && increment++; + + // apply current row, single line, type-based rules, e.g., 'else' or 'private:' + let typeDent = 0; + this.scopes.types.indent[node.type] && typeDent++; + this.scopes.types.outdent[node.type] && increment && typeDent--; + increment += typeDent; + + // check whether the last (lower) indentation happened due to a scope that + // started on the same row and ends directly before this. + if ( + lastScope && + increment > 0 && + // previous (lower) scope was a two-sided scope, reduce if starts on + // same row and ends right before + // TODO: this currently only works for scopes that have a single-character + // closing delimiter (like statement_blocks, but not HTML, for instance). + ((node.parent.startPosition.row === lastScope.node.startPosition.row && + node.parent.endIndex <= lastScope.node.endIndex + 1) || + // or this is a special scope (like if, while) and it's ends coincide + (isScope3 && + (lastScope.node.endIndex === node.endIndex || + node.parent.endIndex === node.endIndex))) + ) { + log('ignoring repeat', node.parent.type, lastScope); + increment = 0; + } else { + lastScope && + log( + node.parent.startPosition.row, + lastScope.node.startPosition.row, + node.parent.endIndex, + lastScope.node.endIndex, + isScope3, + node.endIndex + ); + } + + log('treewalk', { + node, + notFirstOrLastSibling, + type: node.parent.type, + increment + }); + const newLastScope = + isScope || isScope2 ? { node: node.parent } : lastScope; + return this._treeWalk(node.parent, newLastScope) + increment; + } + } + + suggestedIndentForBufferRow(row, tabLength, options) { + // get current indentation for row + const line = this.languageMode.buffer.lineForRow(row); + const currentIndentation = this.languageMode.indentLevelForLine( + line, + tabLength + ); + + const syntaxNode = this._getHighestSyntaxNodeAtPosition(row); + if (!syntaxNode) { + const previousRow = Math.max(row - 1, 0); + const previousIndentation = this.languageMode.indentLevelForLine( + this.languageMode.indentLevelForLine(previousRow), + tabLength + ); + return previousIndentation; + } + let indentation = this._treeWalk(syntaxNode); + + // Special case for comments + if ( + (syntaxNode.type === 'comment' || syntaxNode.type === 'description') && + syntaxNode.startPosition.row < row && + syntaxNode.endPosition.row > row + ) { + indentation += 1; + } + + if (options && options.preserveLeadingWhitespace) { + indentation -= currentIndentation; + } + + return indentation; + } +}; diff --git a/src/tree-sitter-grammar.js b/src/tree-sitter-grammar.js new file mode 100644 index 00000000000..e76b8aed45e --- /dev/null +++ b/src/tree-sitter-grammar.js @@ -0,0 +1,194 @@ +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.name = params.name; + this.scopeName = params.scopeName; + + // TODO - Remove the `RegExp` spelling and only support `Regex`, once all of the existing + // Tree-sitter grammars are updated to spell it `Regex`. + this.contentRegex = buildRegex(params.contentRegex || params.contentRegExp); + this.injectionRegex = buildRegex( + params.injectionRegex || params.injectionRegExp + ); + this.firstLineRegex = buildRegex(params.firstLineRegex); + + this.folds = params.folds || []; + this.folds.forEach(normalizeFoldSpecification); + + this.commentStrings = { + commentStartString: params.comments && params.comments.start, + commentEndString: params.comments && params.comments.end + }; + + const scopeSelectors = {}; + for (const key in params.scopes || {}) { + const classes = preprocessScopes(params.scopes[key]); + const selectors = key.split(/,\s+/); + for (let selector of selectors) { + selector = selector.trim(); + if (!selector) continue; + if (scopeSelectors[selector]) { + scopeSelectors[selector] = [].concat( + scopeSelectors[selector], + classes + ); + } else { + scopeSelectors[selector] = classes; + } + } + } + + this.scopeMap = new SyntaxScopeMap(scopeSelectors); + this.fileTypes = params.fileTypes || []; + this.injectionPointsByType = {}; + + for (const injectionPoint of params.injectionPoints || []) { + this.addInjectionPoint(injectionPoint); + } + + // 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.classNamesById = new Map(); + this.scopeNamesById = new Map(); + this.idsByScope = Object.create(null); + this.nextScopeId = 256 + 1; + this.registration = null; + } + + inspect() { + return `TreeSitterGrammar {scopeName: ${this.scopeName}}`; + } + + idForScope(scopeName) { + if (!scopeName) { + return undefined; + } + let id = this.idsByScope[scopeName]; + if (!id) { + id = this.nextScopeId += 2; + const className = scopeName + .split('.') + .map(s => `syntax--${s}`) + .join(' '); + this.idsByScope[scopeName] = id; + this.classNamesById.set(id, className); + this.scopeNamesById.set(id, scopeName); + } + return id; + } + + classNameForScopeId(id) { + return this.classNamesById.get(id); + } + + scopeNameForScopeId(id) { + return this.scopeNamesById.get(id); + } + + activate() { + this.registration = this.registry.addGrammar(this); + } + + deactivate() { + if (this.registration) this.registration.dispose(); + } + + addInjectionPoint(injectionPoint) { + let injectionPoints = this.injectionPointsByType[injectionPoint.type]; + if (!injectionPoints) { + injectionPoints = this.injectionPointsByType[injectionPoint.type] = []; + } + injectionPoints.push(injectionPoint); + } + + removeInjectionPoint(injectionPoint) { + const injectionPoints = this.injectionPointsByType[injectionPoint.type]; + if (injectionPoints) { + const index = injectionPoints.indexOf(injectionPoint); + if (index !== -1) injectionPoints.splice(index, 1); + if (injectionPoints.length === 0) { + delete this.injectionPointsByType[injectionPoint.type]; + } + } + } + + /* + Section - Backward compatibility shims + */ + + onDidUpdate(callback) { + // do nothing + } + + tokenizeLines(text, compatibilityMode = true) { + return text.split('\n').map(line => this.tokenizeLine(line, null, false)); + } + + tokenizeLine(line, ruleStack, firstLine) { + return { + value: line, + scopes: [this.scopeName] + }; + } +}; + +const preprocessScopes = value => + typeof value === 'string' + ? value + : Array.isArray(value) + ? value.map(preprocessScopes) + : value.match + ? { match: new RegExp(value.match), scopes: preprocessScopes(value.scopes) } + : Object.assign({}, value, { scopes: preprocessScopes(value.scopes) }); + +const NODE_NAME_REGEX = /[\w_]+/; + +function matcherForSpec(spec) { + if (typeof spec === 'string') { + if (spec[0] === '"' && spec[spec.length - 1] === '"') { + return { + type: spec.substr(1, spec.length - 2), + named: false + }; + } + + if (!NODE_NAME_REGEX.test(spec)) { + return { type: spec, named: false }; + } + + return { type: spec, named: true }; + } + return spec; +} + +function normalizeFoldSpecification(spec) { + if (spec.type) { + if (Array.isArray(spec.type)) { + spec.matchers = spec.type.map(matcherForSpec); + } else { + spec.matchers = [matcherForSpec(spec.type)]; + } + } + + if (spec.start) normalizeFoldSpecification(spec.start); + if (spec.end) normalizeFoldSpecification(spec.end); +} + +function buildRegex(value) { + // Allow multiple alternatives to be specified via an array, for + // readability of the grammar file + if (Array.isArray(value)) value = value.map(_ => `(${_})`).join('|'); + if (typeof value === 'string') return new RegExp(value); + return null; +} diff --git a/src/tree-sitter-language-mode.js b/src/tree-sitter-language-mode.js new file mode 100644 index 00000000000..3ffe4038e2b --- /dev/null +++ b/src/tree-sitter-language-mode.js @@ -0,0 +1,1556 @@ +const Parser = require('tree-sitter'); +const { Point, Range, spliceArray } = require('text-buffer'); +const { Patch } = require('superstring'); +const { Emitter } = require('event-kit'); +const ScopeDescriptor = require('./scope-descriptor'); +const Token = require('./token'); +const TokenizedLine = require('./tokenized-line'); +const TextMateLanguageMode = require('./text-mate-language-mode'); +const { matcherForSelector } = require('./selectors'); +const TreeIndenter = require('./tree-indenter'); + +let nextId = 0; +const MAX_RANGE = new Range(Point.ZERO, Point.INFINITY).freeze(); +const PARSER_POOL = []; +const WORD_REGEX = /\w/; + +class TreeSitterLanguageMode { + static _patchSyntaxNode() { + if (!Parser.SyntaxNode.prototype.hasOwnProperty('range')) { + Object.defineProperty(Parser.SyntaxNode.prototype, 'range', { + get() { + return rangeForNode(this); + } + }); + } + } + + constructor({ buffer, grammar, config, grammars, syncTimeoutMicros }) { + TreeSitterLanguageMode._patchSyntaxNode(); + this.id = nextId++; + this.buffer = buffer; + this.grammar = grammar; + this.config = config; + this.grammarRegistry = grammars; + this.rootLanguageLayer = new LanguageLayer(null, this, grammar, 0); + this.injectionsMarkerLayer = buffer.addMarkerLayer(); + + if (syncTimeoutMicros != null) { + this.syncTimeoutMicros = syncTimeoutMicros; + } + + this.rootScopeDescriptor = new ScopeDescriptor({ + scopes: [this.grammar.scopeName] + }); + this.emitter = new Emitter(); + this.isFoldableCache = []; + this.hasQueuedParse = false; + + this.grammarForLanguageString = this.grammarForLanguageString.bind(this); + + this.rootLanguageLayer + .update(null) + .then(() => this.emitter.emit('did-tokenize')); + + // 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 = {}; + } + + async parseCompletePromise() { + let done = false; + while (!done) { + if (this.rootLanguageLayer.currentParsePromise) { + await this.rootLanguageLayer.currentParsePromises; + } else { + done = true; + for (const marker of this.injectionsMarkerLayer.getMarkers()) { + if (marker.languageLayer.currentParsePromise) { + done = false; + await marker.languageLayer.currentParsePromise; + break; + } + } + } + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + destroy() { + this.injectionsMarkerLayer.destroy(); + this.rootLanguageLayer = null; + } + + getLanguageId() { + return this.grammar.scopeName; + } + + bufferDidChange({ oldRange, newRange, oldText, newText }) { + const edit = this.rootLanguageLayer._treeEditForBufferChange( + oldRange.start, + oldRange.end, + newRange.end, + oldText, + newText + ); + this.rootLanguageLayer.handleTextChange(edit, oldText, newText); + for (const marker of this.injectionsMarkerLayer.getMarkers()) { + marker.languageLayer.handleTextChange(edit, oldText, newText); + } + } + + bufferDidFinishTransaction({ changes }) { + for (let i = 0, { length } = changes; i < length; i++) { + const { oldRange, newRange } = changes[i]; + spliceArray( + this.isFoldableCache, + newRange.start.row, + oldRange.end.row - oldRange.start.row, + { length: newRange.end.row - newRange.start.row } + ); + } + this.rootLanguageLayer.update(null); + } + + parse(language, oldTree, ranges) { + const parser = PARSER_POOL.pop() || new Parser(); + parser.setLanguage(language); + const result = parser.parseTextBuffer(this.buffer.buffer, oldTree, { + syncTimeoutMicros: this.syncTimeoutMicros, + includedRanges: ranges + }); + + if (result.then) { + return result.then(tree => { + PARSER_POOL.push(parser); + return tree; + }); + } else { + PARSER_POOL.push(parser); + return result; + } + } + + get tree() { + return this.rootLanguageLayer.tree; + } + + updateForInjection(grammar) { + this.rootLanguageLayer.updateInjections(grammar); + } + + /* + Section - Highlighting + */ + + buildHighlightIterator() { + if (!this.rootLanguageLayer) return new NullLanguageModeHighlightIterator(); + return new HighlightIterator(this); + } + + onDidTokenize(callback) { + return this.emitter.on('did-tokenize', callback); + } + + onDidChangeHighlighting(callback) { + return this.emitter.on('did-change-highlighting', callback); + } + + classNameForScopeId(scopeId) { + return this.grammar.classNameForScopeId(scopeId); + } + + /* + Section - Commenting + */ + + commentStringsForPosition(position) { + const range = + this.firstNonWhitespaceRange(position.row) || + new Range(position, position); + const { grammar } = this.getSyntaxNodeAndGrammarContainingRange(range); + return grammar.commentStrings; + } + + isRowCommented(row) { + const range = this.firstNonWhitespaceRange(row); + if (range) { + const firstNode = this.getSyntaxNodeContainingRange(range); + if (firstNode) return firstNode.type.includes('comment'); + } + return false; + } + + /* + Section - Indentation + */ + + suggestedIndentForLineAtBufferRow(row, line, tabLength) { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + line, + this.rootScopeDescriptor, + tabLength + ); + } + + suggestedIndentForBufferRow(row, tabLength, options) { + if (!this.treeIndenter) { + this.treeIndenter = new TreeIndenter(this); + } + + if (this.treeIndenter.isConfigured) { + const indent = this.treeIndenter.suggestedIndentForBufferRow( + row, + tabLength, + options + ); + return indent; + } else { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + this.buffer.lineForRow(row), + this.rootScopeDescriptor, + tabLength, + options + ); + } + } + + indentLevelForLine(line, 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); + } + + /** + * TODO: Make this method generate folds for nested languages (currently, + * folds are only generated for the root language layer). + */ + getFoldableRangesAtIndentLevel(goalLevel) { + let result = []; + let stack = [{ node: this.tree.rootNode, level: 0 }]; + while (stack.length > 0) { + const { node, level } = stack.pop(); + + const range = this.getFoldableRangeForNode(node, this.grammar); + 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) { + if (!this.tree) return null; + + let smallestRange; + this._forEachTreeWithRange(new Range(point, point), (tree, grammar) => { + let node = tree.rootNode.descendantForPosition( + this.buffer.clipPosition(point) + ); + while (node) { + if (existenceOnly && node.startPosition.row < point.row) return; + if (node.endPosition.row > point.row) { + const range = this.getFoldableRangeForNode(node, grammar); + if (range && rangeIsSmaller(range, smallestRange)) { + smallestRange = range; + return; + } + } + node = node.parent; + } + }); + + return existenceOnly + ? smallestRange && smallestRange.start.row === point.row + : smallestRange; + } + + _forEachTreeWithRange(range, callback) { + if (this.rootLanguageLayer.tree) { + callback(this.rootLanguageLayer.tree, this.rootLanguageLayer.grammar); + } + + const injectionMarkers = this.injectionsMarkerLayer.findMarkers({ + intersectsRange: range + }); + + for (const injectionMarker of injectionMarkers) { + const { tree, grammar } = injectionMarker.languageLayer; + if (tree) callback(tree, grammar); + } + } + + getFoldableRangeForNode(node, grammar, existenceOnly) { + const { children } = node; + const childCount = children.length; + + for (var i = 0, { length } = grammar.folds; i < length; i++) { + const foldSpec = grammar.folds[i]; + + if (foldSpec.matchers && !hasMatchingFoldSpec(foldSpec.matchers, node)) + continue; + + let foldStart; + const startEntry = foldSpec.start; + if (startEntry) { + let foldStartNode; + if (startEntry.index != null) { + foldStartNode = children[startEntry.index]; + if ( + !foldStartNode || + (startEntry.matchers && + !hasMatchingFoldSpec(startEntry.matchers, foldStartNode)) + ) + continue; + } else { + foldStartNode = children.find(child => + hasMatchingFoldSpec(startEntry.matchers, child) + ); + if (!foldStartNode) continue; + } + foldStart = new Point(foldStartNode.endPosition.row, Infinity); + } else { + foldStart = new Point(node.startPosition.row, Infinity); + } + + let foldEnd; + const endEntry = foldSpec.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 { + foldEndNode = children.find(child => + hasMatchingFoldSpec(endEntry.matchers, child) + ); + if (!foldEndNode) continue; + } + + if (foldEndNode.startPosition.row <= foldStart.row) continue; + + foldEnd = foldEndNode.startPosition; + if ( + this.buffer.findInRangeSync( + WORD_REGEX, + new Range(foldEnd, new Point(foldEnd.row, Infinity)) + ) + ) { + foldEnd = new Point(foldEnd.row - 1, Infinity); + } + } 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); + } + } + + /* + Section - Syntax Tree APIs + */ + + getSyntaxNodeContainingRange(range, where = _ => true) { + return this.getSyntaxNodeAndGrammarContainingRange(range, where).node; + } + + getSyntaxNodeAndGrammarContainingRange(range, where = _ => true) { + const startIndex = this.buffer.characterIndexForPosition(range.start); + const endIndex = this.buffer.characterIndexForPosition(range.end); + const searchEndIndex = Math.max(0, endIndex - 1); + + let smallestNode = null; + let smallestNodeGrammar = this.grammar; + this._forEachTreeWithRange(range, (tree, grammar) => { + let node = tree.rootNode.descendantForIndex(startIndex, searchEndIndex); + while (node) { + if ( + nodeContainsIndices(node, startIndex, endIndex) && + where(node, grammar) + ) { + if (nodeIsSmaller(node, smallestNode)) { + smallestNode = node; + smallestNodeGrammar = grammar; + } + break; + } + node = node.parent; + } + }); + + return { node: smallestNode, grammar: smallestNodeGrammar }; + } + + getRangeForSyntaxNodeContainingRange(range, where) { + const node = this.getSyntaxNodeContainingRange(range, where); + return node && node.range; + } + + getSyntaxNodeAtPosition(position, where) { + return this.getSyntaxNodeContainingRange( + new Range(position, position), + where + ); + } + + bufferRangeForScopeAtPosition(selector, position) { + const nodeCursorAdapter = new NodeCursorAdaptor(); + if (typeof selector === 'string') { + const match = matcherForSelector(selector); + selector = (node, grammar) => { + const rules = grammar.scopeMap.get([node.type], [0], node.named); + nodeCursorAdapter.node = node; + const scopeName = applyLeafRules(rules, nodeCursorAdapter); + if (scopeName != null) { + return match(scopeName); + } + }; + } + if (selector === null) selector = undefined; + const node = this.getSyntaxNodeAtPosition(position, selector); + return node && node.range; + } + + /* + Section - Backward compatibility shims + */ + + tokenizedLineForRow(row) { + const lineText = this.buffer.lineForRow(row); + const tokens = []; + + const iterator = this.buildHighlightIterator(); + let start = { row, column: 0 }; + const scopes = iterator.seek(start, row); + while (true) { + const end = iterator.getPosition(); + if (end.row > row) { + end.row = row; + end.column = lineText.length; + } + + if (end.column > start.column) { + tokens.push( + new Token({ + value: lineText.substring(start.column, end.column), + scopes: scopes.map(s => this.grammar.scopeNameForScopeId(s)) + }) + ); + } + + if (end.column < lineText.length) { + const closeScopeCount = iterator.getCloseScopeIds().length; + for (let i = 0; i < closeScopeCount; i++) { + scopes.pop(); + } + scopes.push(...iterator.getOpenScopeIds()); + start = end; + iterator.moveToSuccessor(); + } else { + break; + } + } + + return new TokenizedLine({ + openScopes: [], + text: lineText, + tokens, + tags: [], + ruleStack: [], + lineEnding: this.buffer.lineEndingForRow(row), + tokenIterator: null, + grammar: this.grammar + }); + } + + syntaxTreeScopeDescriptorForPosition(point) { + const nodes = []; + point = this.buffer.clipPosition(Point.fromObject(point)); + + // If the position is the end of a line, get node of left character instead of newline + // This is to match TextMate behaviour, see https://github.com/atom/atom/issues/18463 + if ( + point.column > 0 && + point.column === this.buffer.lineLengthForRow(point.row) + ) { + point = point.copy(); + point.column--; + } + + this._forEachTreeWithRange(new Range(point, point), tree => { + let node = tree.rootNode.descendantForPosition(point); + while (node) { + nodes.push(node); + node = node.parent; + } + }); + + // The nodes are mostly already sorted from smallest to largest, + // but for files with multiple syntax trees (e.g. ERB), each tree's + // nodes are separate. Sort the nodes from largest to smallest. + nodes.reverse(); + nodes.sort( + (a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex + ); + + const nodeTypes = nodes.map(node => node.type); + nodeTypes.unshift(this.grammar.scopeName); + return new ScopeDescriptor({ scopes: nodeTypes }); + } + + scopeDescriptorForPosition(point) { + point = this.buffer.clipPosition(Point.fromObject(point)); + + // If the position is the end of a line, get scope of left character instead of newline + // This is to match TextMate behaviour, see https://github.com/atom/atom/issues/18463 + if ( + point.column > 0 && + point.column === this.buffer.lineLengthForRow(point.row) + ) { + point = point.copy(); + point.column--; + } + + const iterator = this.buildHighlightIterator(); + const scopes = []; + for (const scope of iterator.seek(point, point.row + 1)) { + scopes.push(this.grammar.scopeNameForScopeId(scope)); + } + if (point.isEqual(iterator.getPosition())) { + for (const scope of iterator.getOpenScopeIds()) { + scopes.push(this.grammar.scopeNameForScopeId(scope)); + } + } + if (scopes.length === 0 || scopes[0] !== this.grammar.scopeName) { + scopes.unshift(this.grammar.scopeName); + } + return new ScopeDescriptor({ scopes }); + } + + tokenForPosition(point) { + const node = this.getSyntaxNodeAtPosition(point); + const scopes = this.scopeDescriptorForPosition(point).getScopesArray(); + return new Token({ value: node.text, scopes }); + } + + getGrammar() { + return this.grammar; + } + + /* + Section - Private + */ + + firstNonWhitespaceRange(row) { + return this.buffer.findInRangeSync( + /\S/, + new Range(new Point(row, 0), new Point(row, Infinity)) + ); + } + + grammarForLanguageString(languageString) { + return this.grammarRegistry.treeSitterGrammarForLanguageString( + languageString + ); + } + + emitRangeUpdate(range) { + 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); + } +} + +class LanguageLayer { + constructor(marker, languageMode, grammar, depth) { + this.marker = marker; + this.languageMode = languageMode; + this.grammar = grammar; + this.tree = null; + this.currentParsePromise = null; + this.patchSinceCurrentParseStarted = null; + this.depth = depth; + } + + buildHighlightIterator() { + if (this.tree) { + return new LayerHighlightIterator(this, this.tree.walk()); + } else { + return new NullLayerHighlightIterator(); + } + } + + handleTextChange(edit, oldText, newText) { + const { startPosition, oldEndPosition, newEndPosition } = edit; + + if (this.tree) { + this.tree.edit(edit); + if (this.editedRange) { + if (startPosition.isLessThan(this.editedRange.start)) { + this.editedRange.start = startPosition; + } + if (oldEndPosition.isLessThan(this.editedRange.end)) { + this.editedRange.end = newEndPosition.traverse( + this.editedRange.end.traversalFrom(oldEndPosition) + ); + } else { + this.editedRange.end = newEndPosition; + } + } else { + this.editedRange = new Range(startPosition, newEndPosition); + } + } + + if (this.patchSinceCurrentParseStarted) { + this.patchSinceCurrentParseStarted.splice( + startPosition, + oldEndPosition.traversalFrom(startPosition), + newEndPosition.traversalFrom(startPosition), + oldText, + newText + ); + } + } + + destroy() { + this.tree = null; + this.destroyed = true; + this.marker.destroy(); + for (const marker of this.languageMode.injectionsMarkerLayer.getMarkers()) { + if (marker.parentLanguageLayer === this) { + marker.languageLayer.destroy(); + } + } + } + + async update(nodeRangeSet) { + if (!this.currentParsePromise) { + while ( + !this.destroyed && + (!this.tree || this.tree.rootNode.hasChanges()) + ) { + const params = { async: false }; + this.currentParsePromise = this._performUpdate(nodeRangeSet, params); + if (!params.async) break; + await this.currentParsePromise; + } + this.currentParsePromise = null; + } + } + + updateInjections(grammar) { + if (grammar.injectionRegex) { + if (!this.currentParsePromise) + this.currentParsePromise = Promise.resolve(); + this.currentParsePromise = this.currentParsePromise.then(async () => { + await this._populateInjections(MAX_RANGE, null); + this.currentParsePromise = null; + }); + } + } + + async _performUpdate(nodeRangeSet, params) { + let includedRanges = null; + if (nodeRangeSet) { + includedRanges = nodeRangeSet.getRanges(this.languageMode.buffer); + if (includedRanges.length === 0) { + const range = this.marker.getRange(); + this.destroy(); + this.languageMode.emitRangeUpdate(range); + return; + } + } + + let affectedRange = this.editedRange; + this.editedRange = null; + + this.patchSinceCurrentParseStarted = new Patch(); + let tree = this.languageMode.parse( + this.grammar.languageModule, + this.tree, + includedRanges + ); + if (tree.then) { + params.async = true; + tree = await tree; + } + + const changes = this.patchSinceCurrentParseStarted.getChanges(); + this.patchSinceCurrentParseStarted = null; + for (const { + oldStart, + newStart, + oldEnd, + newEnd, + oldText, + newText + } of changes) { + const newExtent = Point.fromObject(newEnd).traversalFrom(newStart); + tree.edit( + this._treeEditForBufferChange( + newStart, + oldEnd, + Point.fromObject(oldStart).traverse(newExtent), + oldText, + newText + ) + ); + } + + if (this.tree) { + const rangesWithSyntaxChanges = this.tree.getChangedRanges(tree); + this.tree = tree; + + if (rangesWithSyntaxChanges.length > 0) { + for (const range of rangesWithSyntaxChanges) { + this.languageMode.emitRangeUpdate(rangeForNode(range)); + } + + const combinedRangeWithSyntaxChange = new Range( + rangesWithSyntaxChanges[0].startPosition, + last(rangesWithSyntaxChanges).endPosition + ); + + if (affectedRange) { + this.languageMode.emitRangeUpdate(affectedRange); + affectedRange = affectedRange.union(combinedRangeWithSyntaxChange); + } else { + affectedRange = combinedRangeWithSyntaxChange; + } + } + } else { + this.tree = tree; + this.languageMode.emitRangeUpdate(rangeForNode(tree.rootNode)); + if (includedRanges) { + affectedRange = new Range( + includedRanges[0].startPosition, + last(includedRanges).endPosition + ); + } else { + affectedRange = MAX_RANGE; + } + } + + if (affectedRange) { + const injectionPromise = this._populateInjections( + affectedRange, + nodeRangeSet + ); + if (injectionPromise) { + params.async = true; + return injectionPromise; + } + } + } + + _populateInjections(range, nodeRangeSet) { + const existingInjectionMarkers = this.languageMode.injectionsMarkerLayer + .findMarkers({ intersectsRange: range }) + .filter(marker => marker.parentLanguageLayer === this); + + if (existingInjectionMarkers.length > 0) { + range = range.union( + new Range( + existingInjectionMarkers[0].getRange().start, + last(existingInjectionMarkers).getRange().end + ) + ); + } + + const markersToUpdate = new Map(); + const nodes = this.tree.rootNode.descendantsOfType( + Object.keys(this.grammar.injectionPointsByType), + range.start, + range.end + ); + + let existingInjectionMarkerIndex = 0; + for (const node of nodes) { + for (const injectionPoint of this.grammar.injectionPointsByType[ + node.type + ]) { + const languageName = injectionPoint.language(node); + if (!languageName) continue; + + const grammar = this.languageMode.grammarForLanguageString( + languageName + ); + if (!grammar) continue; + + const contentNodes = injectionPoint.content(node); + if (!contentNodes) continue; + + const injectionNodes = [].concat(contentNodes); + if (!injectionNodes.length) continue; + + const injectionRange = rangeForNode(node); + + let marker; + for ( + let i = existingInjectionMarkerIndex, + n = existingInjectionMarkers.length; + i < n; + i++ + ) { + const existingMarker = existingInjectionMarkers[i]; + const comparison = existingMarker.getRange().compare(injectionRange); + if (comparison > 0) { + break; + } else if (comparison === 0) { + existingInjectionMarkerIndex = i; + if (existingMarker.languageLayer.grammar === grammar) { + marker = existingMarker; + break; + } + } else { + existingInjectionMarkerIndex = i; + } + } + + if (!marker) { + marker = this.languageMode.injectionsMarkerLayer.markRange( + injectionRange + ); + marker.languageLayer = new LanguageLayer( + marker, + this.languageMode, + grammar, + this.depth + 1 + ); + marker.parentLanguageLayer = this; + } + + markersToUpdate.set( + marker, + new NodeRangeSet( + nodeRangeSet, + injectionNodes, + injectionPoint.newlinesBetween, + injectionPoint.includeChildren + ) + ); + } + } + + for (const marker of existingInjectionMarkers) { + if (!markersToUpdate.has(marker)) { + this.languageMode.emitRangeUpdate(marker.getRange()); + marker.languageLayer.destroy(); + } + } + + if (markersToUpdate.size > 0) { + const promises = []; + for (const [marker, nodeRangeSet] of markersToUpdate) { + promises.push(marker.languageLayer.update(nodeRangeSet)); + } + return Promise.all(promises); + } + } + + _treeEditForBufferChange(start, oldEnd, newEnd, oldText, newText) { + const startIndex = this.languageMode.buffer.characterIndexForPosition( + start + ); + return { + startIndex, + oldEndIndex: startIndex + oldText.length, + newEndIndex: startIndex + newText.length, + startPosition: start, + oldEndPosition: oldEnd, + newEndPosition: newEnd + }; + } +} + +class HighlightIterator { + constructor(languageMode) { + this.languageMode = languageMode; + this.iterators = null; + } + + seek(targetPosition, endRow) { + const injectionMarkers = this.languageMode.injectionsMarkerLayer.findMarkers( + { + intersectsRange: new Range(targetPosition, new Point(endRow + 1, 0)) + } + ); + + const containingTags = []; + const containingTagStartIndices = []; + const targetIndex = this.languageMode.buffer.characterIndexForPosition( + targetPosition + ); + + this.iterators = []; + const iterator = this.languageMode.rootLanguageLayer.buildHighlightIterator(); + if (iterator.seek(targetIndex, containingTags, containingTagStartIndices)) { + this.iterators.push(iterator); + } + + // Populate the iterators array with all of the iterators whose syntax + // trees span the given position. + for (const marker of injectionMarkers) { + const iterator = marker.languageLayer.buildHighlightIterator(); + if ( + iterator.seek(targetIndex, containingTags, containingTagStartIndices) + ) { + this.iterators.push(iterator); + } + } + + // Sort the iterators so that the last one in the array is the earliest + // in the document, and represents the current position. + this.iterators.sort((a, b) => b.compare(a)); + this.detectCoveredScope(); + + return containingTags; + } + + moveToSuccessor() { + // Advance the earliest layer iterator to its next scope boundary. + let leader = last(this.iterators); + + // Maintain the sorting of the iterators by their position in the document. + if (leader.moveToSuccessor()) { + const leaderIndex = this.iterators.length - 1; + let i = leaderIndex; + while (i > 0 && this.iterators[i - 1].compare(leader) < 0) i--; + if (i < leaderIndex) { + this.iterators.splice(i, 0, this.iterators.pop()); + } + } else { + // If the layer iterator was at the end of its syntax tree, then remove + // it from the array. + this.iterators.pop(); + } + + this.detectCoveredScope(); + } + + // Detect whether or not another more deeply-nested language layer has a + // scope boundary at this same position. If so, the current language layer's + // scope boundary should not be reported. + detectCoveredScope() { + const layerCount = this.iterators.length; + if (layerCount > 1) { + const first = this.iterators[layerCount - 1]; + const next = this.iterators[layerCount - 2]; + if ( + next.offset === first.offset && + next.atEnd === first.atEnd && + next.depth > first.depth && + !next.isAtInjectionBoundary() + ) { + this.currentScopeIsCovered = true; + return; + } + } + this.currentScopeIsCovered = false; + } + + getPosition() { + const iterator = last(this.iterators); + if (iterator) { + return iterator.getPosition(); + } else { + return Point.INFINITY; + } + } + + getCloseScopeIds() { + const iterator = last(this.iterators); + if (iterator && !this.currentScopeIsCovered) { + return iterator.getCloseScopeIds(); + } + return []; + } + + getOpenScopeIds() { + const iterator = last(this.iterators); + if (iterator && !this.currentScopeIsCovered) { + return iterator.getOpenScopeIds(); + } + return []; + } + + logState() { + const iterator = last(this.iterators); + if (iterator && iterator.treeCursor) { + console.log( + iterator.getPosition(), + iterator.treeCursor.nodeType, + `depth=${iterator.languageLayer.depth}`, + new Range( + iterator.languageLayer.tree.rootNode.startPosition, + iterator.languageLayer.tree.rootNode.endPosition + ).toString() + ); + if (this.currentScopeIsCovered) { + console.log('covered'); + } else { + console.log( + 'close', + iterator.closeTags.map(id => + this.languageMode.grammar.scopeNameForScopeId(id) + ) + ); + console.log( + 'open', + iterator.openTags.map(id => + this.languageMode.grammar.scopeNameForScopeId(id) + ) + ); + } + } + } +} + +class LayerHighlightIterator { + constructor(languageLayer, treeCursor) { + this.languageLayer = languageLayer; + this.depth = this.languageLayer.depth; + + // The iterator is always positioned at either the start or the end of some node + // in the syntax tree. + this.atEnd = false; + this.treeCursor = treeCursor; + this.offset = 0; + + // 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 = []; + this.containingNodeEndIndices = []; + + // 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(targetIndex, containingTags, containingTagStartIndices) { + while (this.treeCursor.gotoParent()) {} + + this.atEnd = true; + this.closeTags.length = 0; + this.openTags.length = 0; + this.containingNodeTypes.length = 0; + this.containingNodeChildIndices.length = 0; + this.containingNodeEndIndices.length = 0; + + const containingTagEndIndices = []; + + if (targetIndex >= this.treeCursor.endIndex) { + return false; + } + + let childIndex = -1; + for (;;) { + this.containingNodeTypes.push(this.treeCursor.nodeType); + this.containingNodeChildIndices.push(childIndex); + this.containingNodeEndIndices.push(this.treeCursor.endIndex); + + const scopeId = this._currentScopeId(); + if (scopeId) { + if (this.treeCursor.startIndex < targetIndex) { + insertContainingTag( + scopeId, + this.treeCursor.startIndex, + containingTags, + containingTagStartIndices + ); + containingTagEndIndices.push(this.treeCursor.endIndex); + } else { + this.atEnd = false; + this.openTags.push(scopeId); + this._moveDown(); + break; + } + } + + childIndex = this.treeCursor.gotoFirstChildForIndex(targetIndex); + if (childIndex === null) break; + if (this.treeCursor.startIndex >= targetIndex) this.atEnd = false; + } + + if (this.atEnd) { + this.offset = this.treeCursor.endIndex; + for (let i = 0, { length } = containingTags; i < length; i++) { + if (containingTagEndIndices[i] === this.offset) { + this.closeTags.push(containingTags[i]); + } + } + } else { + this.offset = this.treeCursor.startIndex; + } + + return true; + } + + moveToSuccessor() { + this.closeTags.length = 0; + this.openTags.length = 0; + + while (!this.closeTags.length && !this.openTags.length) { + if (this.atEnd) { + if (this._moveRight()) { + const scopeId = this._currentScopeId(); + if (scopeId) this.openTags.push(scopeId); + this.atEnd = false; + this._moveDown(); + } else if (this._moveUp(true)) { + this.atEnd = true; + } else { + return false; + } + } else if (!this._moveDown()) { + const scopeId = this._currentScopeId(); + if (scopeId) this.closeTags.push(scopeId); + this.atEnd = true; + this._moveUp(false); + } + } + + if (this.atEnd) { + this.offset = this.treeCursor.endIndex; + } else { + this.offset = this.treeCursor.startIndex; + } + + return true; + } + + getPosition() { + if (this.atEnd) { + return this.treeCursor.endPosition; + } else { + return this.treeCursor.startPosition; + } + } + + compare(other) { + const result = this.offset - other.offset; + if (result !== 0) return result; + if (this.atEnd && !other.atEnd) return -1; + if (other.atEnd && !this.atEnd) return 1; + return this.languageLayer.depth - other.languageLayer.depth; + } + + getCloseScopeIds() { + return this.closeTags.slice(); + } + + getOpenScopeIds() { + return this.openTags.slice(); + } + + isAtInjectionBoundary() { + return this.containingNodeTypes.length === 1; + } + + // Private methods + + _moveUp(atLastChild) { + let result = false; + const { endIndex } = this.treeCursor; + let depth = this.containingNodeEndIndices.length; + + // The iterator should not move up until it has visited all of the children of this node. + while ( + depth > 1 && + (atLastChild || this.containingNodeEndIndices[depth - 2] === endIndex) + ) { + atLastChild = false; + result = true; + this.treeCursor.gotoParent(); + this.containingNodeTypes.pop(); + this.containingNodeChildIndices.pop(); + this.containingNodeEndIndices.pop(); + --depth; + const scopeId = this._currentScopeId(); + if (scopeId) this.closeTags.push(scopeId); + } + return result; + } + + _moveDown() { + let result = false; + const { startIndex } = this.treeCursor; + + // Once the iterator has found a scope boundary, it needs to stay at the same + // position, so it should not move down if the first child node starts later than the + // current node. + while (this.treeCursor.gotoFirstChild()) { + if ( + (this.closeTags.length || this.openTags.length) && + this.treeCursor.startIndex > startIndex + ) { + this.treeCursor.gotoParent(); + break; + } + + result = true; + this.containingNodeTypes.push(this.treeCursor.nodeType); + this.containingNodeChildIndices.push(0); + this.containingNodeEndIndices.push(this.treeCursor.endIndex); + + const scopeId = this._currentScopeId(); + if (scopeId) this.openTags.push(scopeId); + } + + return result; + } + + _moveRight() { + if (this.treeCursor.gotoNextSibling()) { + const depth = this.containingNodeTypes.length; + this.containingNodeTypes[depth - 1] = this.treeCursor.nodeType; + this.containingNodeChildIndices[depth - 1]++; + this.containingNodeEndIndices[depth - 1] = this.treeCursor.endIndex; + return true; + } + } + + _currentScopeId() { + const value = this.languageLayer.grammar.scopeMap.get( + this.containingNodeTypes, + this.containingNodeChildIndices, + this.treeCursor.nodeIsNamed + ); + const scopeName = applyLeafRules(value, this.treeCursor); + const node = this.treeCursor.currentNode; + if (!node.childCount) { + return this.languageLayer.languageMode.grammar.idForScope( + scopeName, + node.text + ); + } else if (scopeName) { + return this.languageLayer.languageMode.grammar.idForScope(scopeName); + } + } +} + +const applyLeafRules = (rules, cursor) => { + if (!rules || typeof rules === 'string') return rules; + if (Array.isArray(rules)) { + for (let i = 0, { length } = rules; i !== length; ++i) { + const result = applyLeafRules(rules[i], cursor); + if (result) return result; + } + return undefined; + } + if (typeof rules === 'object') { + if (rules.exact) { + return cursor.nodeText === rules.exact + ? applyLeafRules(rules.scopes, cursor) + : undefined; + } + if (rules.match) { + return rules.match.test(cursor.nodeText) + ? applyLeafRules(rules.scopes, cursor) + : undefined; + } + } +}; + +class NodeCursorAdaptor { + get nodeText() { + return this.node.text; + } +} + +class NullLanguageModeHighlightIterator { + seek() { + return []; + } + compare() { + return 1; + } + moveToSuccessor() {} + getPosition() { + return Point.INFINITY; + } + getOpenScopeIds() { + return []; + } + getCloseScopeIds() { + return []; + } +} + +class NullLayerHighlightIterator { + seek() { + return null; + } + compare() { + return 1; + } + moveToSuccessor() {} + getPosition() { + return Point.INFINITY; + } + getOpenScopeIds() { + return []; + } + getCloseScopeIds() { + return []; + } +} + +class NodeRangeSet { + constructor(previous, nodes, newlinesBetween, includeChildren) { + this.previous = previous; + this.nodes = nodes; + this.newlinesBetween = newlinesBetween; + this.includeChildren = includeChildren; + } + + getRanges(buffer) { + const previousRanges = this.previous && this.previous.getRanges(buffer); + const result = []; + + for (const node of this.nodes) { + let position = node.startPosition; + let index = node.startIndex; + + if (!this.includeChildren) { + for (const child of node.children) { + const nextIndex = child.startIndex; + if (nextIndex > index) { + this._pushRange(buffer, previousRanges, result, { + startIndex: index, + endIndex: nextIndex, + startPosition: position, + endPosition: child.startPosition + }); + } + position = child.endPosition; + index = child.endIndex; + } + } + + if (node.endIndex > index) { + this._pushRange(buffer, previousRanges, result, { + startIndex: index, + endIndex: node.endIndex, + startPosition: position, + endPosition: node.endPosition + }); + } + } + + return result; + } + + _pushRange(buffer, previousRanges, newRanges, newRange) { + if (!previousRanges) { + if (this.newlinesBetween) { + const { startIndex, startPosition } = newRange; + this._ensureNewline(buffer, newRanges, startIndex, startPosition); + } + newRanges.push(newRange); + return; + } + + for (const previousRange of previousRanges) { + if (previousRange.endIndex <= newRange.startIndex) continue; + if (previousRange.startIndex >= newRange.endIndex) break; + const startIndex = Math.max( + previousRange.startIndex, + newRange.startIndex + ); + const endIndex = Math.min(previousRange.endIndex, newRange.endIndex); + const startPosition = Point.max( + previousRange.startPosition, + newRange.startPosition + ); + const endPosition = Point.min( + previousRange.endPosition, + newRange.endPosition + ); + if (this.newlinesBetween) { + this._ensureNewline(buffer, newRanges, startIndex, startPosition); + } + newRanges.push({ startIndex, endIndex, startPosition, endPosition }); + } + } + + // For injection points with `newlinesBetween` enabled, ensure that a + // newline is included between each disjoint range. + _ensureNewline(buffer, newRanges, startIndex, startPosition) { + const lastRange = newRanges[newRanges.length - 1]; + if (lastRange && lastRange.endPosition.row < startPosition.row) { + newRanges.push({ + startPosition: new Point( + startPosition.row - 1, + buffer.lineLengthForRow(startPosition.row - 1) + ), + endPosition: new Point(startPosition.row, 0), + startIndex: startIndex - startPosition.column - 1, + endIndex: startIndex - startPosition.column + }); + } + } +} + +function insertContainingTag(tag, index, tags, indices) { + const i = indices.findIndex(existingIndex => existingIndex > index); + if (i === -1) { + tags.push(tag); + indices.push(index); + } else { + tags.splice(i, 0, tag); + indices.splice(i, 0, index); + } +} + +// Return true iff `mouse` is smaller than `house`. Only correct if +// mouse and house overlap. +// +// * `mouse` {Range} +// * `house` {Range} +function rangeIsSmaller(mouse, house) { + if (!house) return true; + const mvec = vecFromRange(mouse); + const hvec = vecFromRange(house); + return Point.min(mvec, hvec) === mvec; +} + +function vecFromRange({ start, end }) { + return end.translate(start.negate()); +} + +function rangeForNode(node) { + return new Range(node.startPosition, node.endPosition); +} + +function nodeContainsIndices(node, start, end) { + if (node.startIndex < start) return node.endIndex >= end; + if (node.startIndex === start) return node.endIndex > end; + return false; +} + +function nodeIsSmaller(left, right) { + if (!left) return false; + if (!right) return true; + return left.endIndex - left.startIndex < right.endIndex - right.startIndex; +} + +function last(array) { + return array[array.length - 1]; +} + +function hasMatchingFoldSpec(specs, node) { + return specs.some( + ({ type, named }) => type === node.type && named === node.isNamed + ); +} + +// TODO: Remove this once TreeSitterLanguageMode implements its own auto-indent system. +[ + '_suggestedIndentForLineWithScopeAtBufferRow', + 'suggestedIndentForEditedBufferRow', + 'increaseIndentRegexForScopeDescriptor', + 'decreaseIndentRegexForScopeDescriptor', + 'decreaseNextIndentRegexForScopeDescriptor', + 'regexForPattern', + 'getNonWordCharacters' +].forEach(methodName => { + TreeSitterLanguageMode.prototype[methodName] = + TextMateLanguageMode.prototype[methodName]; +}); + +TreeSitterLanguageMode.LanguageLayer = LanguageLayer; +TreeSitterLanguageMode.prototype.syncTimeoutMicros = 1000; + +module.exports = TreeSitterLanguageMode; diff --git a/src/typescript.coffee b/src/typescript.coffee deleted file mode 100644 index 3a54941f33d..00000000000 --- a/src/typescript.coffee +++ /dev/null @@ -1,106 +0,0 @@ -### -Cache for source code transpiled by TypeScript. - -Inspired by https://github.com/atom/atom/blob/7a719d585db96ff7d2977db9067e1d9d4d0adf1a/src/babel.coffee -### - -crypto = require 'crypto' -fs = require 'fs-plus' -path = require 'path' -tss = null # Defer until used - -stats = - hits: 0 - misses: 0 - -defaultOptions = - target: 1 # ES5 - module: 'commonjs' - sourceMap: true - -createTypeScriptVersionAndOptionsDigest = (version, options) -> - shasum = crypto.createHash('sha1') - # Include the version of typescript in the hash. - shasum.update('typescript', 'utf8') - shasum.update('\0', 'utf8') - shasum.update(version, 'utf8') - shasum.update('\0', 'utf8') - shasum.update(JSON.stringify(options)) - shasum.digest('hex') - -cacheDir = null -jsCacheDir = null - -getCachePath = (sourceCode) -> - digest = crypto.createHash('sha1').update(sourceCode, 'utf8').digest('hex') - - unless jsCacheDir? - tssVersion = require('typescript-simple/package.json').version - jsCacheDir = path.join(cacheDir, createTypeScriptVersionAndOptionsDigest(tssVersion, defaultOptions)) - - path.join(jsCacheDir, "#{digest}.js") - -getCachedJavaScript = (cachePath) -> - if fs.isFileSync(cachePath) - try - cachedJavaScript = fs.readFileSync(cachePath, 'utf8') - stats.hits++ - return cachedJavaScript - null - -# Returns the TypeScript options that should be used to transpile filePath. -createOptions = (filePath) -> - options = filename: filePath - for key, value of defaultOptions - options[key] = value - options - -transpile = (sourceCode, filePath, cachePath) -> - options = createOptions(filePath) - unless tss? - {TypeScriptSimple} = require 'typescript-simple' - tss = new TypeScriptSimple(options, false) - js = tss.compile(sourceCode, filePath) - stats.misses++ - - try - fs.writeFileSync(cachePath, js) - - js - -# Function that obeys the contract of an entry in the require.extensions map. -# Returns the transpiled version of the JavaScript code at filePath, which is -# either generated on the fly or pulled from cache. -loadFile = (module, filePath) -> - sourceCode = fs.readFileSync(filePath, 'utf8') - cachePath = getCachePath(sourceCode) - js = getCachedJavaScript(cachePath) ? transpile(sourceCode, filePath, cachePath) - module._compile(js, filePath) - -register = -> - Object.defineProperty(require.extensions, '.ts', { - enumerable: true - writable: false - value: loadFile - }) - -setCacheDirectory = (newCacheDir) -> - if cacheDir isnt newCacheDir - cacheDir = newCacheDir - jsCacheDir = null - -module.exports = - register: register - setCacheDirectory: setCacheDirectory - getCacheMisses: -> stats.misses - getCacheHits: -> stats.hits - - # Visible for testing. - createTypeScriptVersionAndOptionsDigest: createTypeScriptVersionAndOptionsDigest - - addPathToCache: (filePath) -> - return if path.extname(filePath) isnt '.ts' - - sourceCode = fs.readFileSync(filePath, 'utf8') - cachePath = getCachePath(sourceCode) - transpile(sourceCode, filePath, cachePath) diff --git a/src/typescript.js b/src/typescript.js new file mode 100644 index 00000000000..2995224b1b3 --- /dev/null +++ b/src/typescript.js @@ -0,0 +1,60 @@ +'use strict'; + +const _ = require('underscore-plus'); +const crypto = require('crypto'); +const path = require('path'); + +const defaultOptions = { + target: 1, + module: 'commonjs', + sourceMap: true +}; + +let TypeScriptSimple = null; +let typescriptVersionDir = null; + +exports.shouldCompile = function() { + return true; +}; + +exports.getCachePath = function(sourceCode) { + if (typescriptVersionDir == null) { + const version = require('typescript-simple/package.json').version; + typescriptVersionDir = path.join( + 'ts', + createVersionAndOptionsDigest(version, defaultOptions) + ); + } + + return path.join( + typescriptVersionDir, + crypto + .createHash('sha1') + .update(sourceCode, 'utf8') + .digest('hex') + '.js' + ); +}; + +exports.compile = function(sourceCode, filePath) { + if (!TypeScriptSimple) { + TypeScriptSimple = require('typescript-simple').TypeScriptSimple; + } + + if (process.platform === 'win32') { + filePath = 'file:///' + path.resolve(filePath).replace(/\\/g, '/'); + } + + const options = _.defaults({ filename: filePath }, defaultOptions); + return new TypeScriptSimple(options, false).compile(sourceCode, filePath); +}; + +function createVersionAndOptionsDigest(version, options) { + return crypto + .createHash('sha1') + .update('typescript', 'utf8') + .update('\0', 'utf8') + .update(version, 'utf8') + .update('\0', 'utf8') + .update(JSON.stringify(options), 'utf8') + .digest('hex'); +} diff --git a/src/update-process-env.js b/src/update-process-env.js new file mode 100644 index 00000000000..21ea4552dcd --- /dev/null +++ b/src/update-process-env.js @@ -0,0 +1,143 @@ +const fs = require('fs'); +const childProcess = require('child_process'); + +const ENVIRONMENT_VARIABLES_TO_PRESERVE = new Set([ + 'NODE_ENV', + 'NODE_PATH', + 'ATOM_HOME', + 'ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT' +]); + +const PLATFORMS_KNOWN_TO_WORK = new Set(['darwin', 'linux']); + +// Shell command that returns env var=value lines separated by \0s so that +// newlines are handled properly. Note: need to use %c to inject the \0s +// to work with some non GNU awks. +const ENV_COMMAND = + 'command awk \'BEGIN{for(v in ENVIRON) printf("%s=%s%c", v, ENVIRON[v], 0)}\''; + +async function updateProcessEnv(launchEnv) { + let envToAssign; + if (launchEnv) { + if (shouldGetEnvFromShell(launchEnv)) { + envToAssign = await getEnvFromShell(launchEnv); + } else if (launchEnv.PWD || launchEnv.PROMPT || launchEnv.PSModulePath) { + envToAssign = launchEnv; + } + } + + if (envToAssign) { + for (let key in process.env) { + if (!ENVIRONMENT_VARIABLES_TO_PRESERVE.has(key)) { + delete process.env[key]; + } + } + + for (let key in envToAssign) { + if ( + !ENVIRONMENT_VARIABLES_TO_PRESERVE.has(key) || + (!process.env[key] && envToAssign[key]) + ) { + process.env[key] = envToAssign[key]; + } + } + + if (envToAssign.ATOM_HOME && fs.existsSync(envToAssign.ATOM_HOME)) { + process.env.ATOM_HOME = envToAssign.ATOM_HOME; + } + } +} + +function shouldGetEnvFromShell(env) { + if (!PLATFORMS_KNOWN_TO_WORK.has(process.platform)) { + return false; + } + + if (!env || !env.SHELL || env.SHELL.trim() === '') { + return false; + } + + const disableSellingOut = + env.ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT || + process.env.ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT; + + if (disableSellingOut === 'true') { + return false; + } + + return true; +} + +async function getEnvFromShell(env) { + let { stdout, error } = await new Promise(resolve => { + let child; + let error; + let stdout = ''; + let done = false; + const cleanup = () => { + if (!done && child) { + child.kill(); + done = true; + } + }; + process.once('exit', cleanup); + setTimeout(() => { + cleanup(); + }, 5000); + child = childProcess.spawn(env.SHELL, ['-ilc', ENV_COMMAND], { + encoding: 'utf8', + detached: true, + stdio: ['ignore', 'pipe', process.stderr] + }); + const buffers = []; + child.on('error', e => { + done = true; + error = e; + }); + child.stdout.on('data', data => { + buffers.push(data); + }); + child.on('close', (code, signal) => { + done = true; + process.removeListener('exit', cleanup); + if (buffers.length) { + stdout = Buffer.concat(buffers).toString('utf8'); + } + + resolve({ stdout, error }); + }); + }); + + if (error) { + if (error.handle) { + error.handle(); + } + console.log( + 'warning: ' + + env.SHELL + + ' -ilc "' + + ENV_COMMAND + + '" failed with signal (' + + error.signal + + ')' + ); + console.log(error); + } + + if (!stdout || stdout.trim() === '') { + return null; + } + + let result = {}; + for (let line of stdout.split('\0')) { + if (line.includes('=')) { + let components = line.split('='); + let key = components.shift(); + let value = components.join('='); + result[key] = value; + } + } + return result; +} + +module.exports = { updateProcessEnv, shouldGetEnvFromShell }; diff --git a/src/uri-handler-registry.js b/src/uri-handler-registry.js new file mode 100644 index 00000000000..7b232ccc636 --- /dev/null +++ b/src/uri-handler-registry.js @@ -0,0 +1,134 @@ +const url = require('url'); +const { Emitter, Disposable } = require('event-kit'); + +// Private: Associates listener functions with URIs from outside the application. +// +// The global URI handler registry maps URIs to listener functions. URIs are mapped +// based on the hostname of the URI; the format is atom://package/command?args. +// The "core" package name is reserved for URIs handled by Atom core (it is not possible +// to register a package with the name "core"). +// +// Because URI handling can be triggered from outside the application (e.g. from +// the user's browser), package authors should take great care to ensure that malicious +// activities cannot be performed by an attacker. A good rule to follow is that +// **URI handlers should not take action on behalf of the user**. For example, clicking +// a link to open a pane item that prompts the user to install a package is okay; +// automatically installing the package right away is not. +// +// Packages can register their desire to handle URIs via a special key in their +// `package.json` called "uriHandler". The value of this key should be an object +// that contains, at minimum, a key named "method". This is the name of the method +// on your package object that Atom will call when it receives a URI your package +// is responsible for handling. It will pass the parsed URI as the first argument (by using +// [Node's `url.parse(uri, true)`](https://nodejs.org/docs/latest/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost)) +// and the raw URI string as the second argument. +// +// By default, Atom will defer activation of your package until a URI it needs to handle +// is triggered. If you need your package to activate right away, you can add +// `"deferActivation": false` to your "uriHandler" configuration object. When activation +// is deferred, once Atom receives a request for a URI in your package's namespace, it will +// activate your package and then call `methodName` on it as before. +// +// If your package specifies a deprecated `urlMain` property, you cannot register URI handlers +// via the `uriHandler` key. +// +// ## Example +// +// Here is a sample package that will be activated and have its `handleURI` method called +// when a URI beginning with `atom://my-package` is triggered: +// +// `package.json`: +// +// ```javascript +// { +// "name": "my-package", +// "main": "./lib/my-package.js", +// "uriHandler": { +// "method": "handleURI" +// } +// } +// ``` +// +// `lib/my-package.js` +// +// ```javascript +// module.exports = { +// activate: function() { +// // code to activate your package +// } +// +// handleURI(parsedUri, rawUri) { +// // parse and handle uri +// } +// } +// ``` +module.exports = class URIHandlerRegistry { + constructor(maxHistoryLength = 50) { + this.registrations = new Map(); + this.history = []; + this.maxHistoryLength = maxHistoryLength; + this._id = 0; + + this.emitter = new Emitter(); + } + + registerHostHandler(host, callback) { + if (typeof callback !== 'function') { + throw new Error( + 'Cannot register a URI host handler with a non-function callback' + ); + } + + if (this.registrations.has(host)) { + throw new Error( + `There is already a URI host handler for the host ${host}` + ); + } else { + this.registrations.set(host, callback); + } + + return new Disposable(() => { + this.registrations.delete(host); + }); + } + + async handleURI(uri) { + const parsed = url.parse(uri, true); + const { protocol, slashes, auth, port, host } = parsed; + if (protocol !== 'atom:' || slashes !== true || auth || port) { + throw new Error( + `URIHandlerRegistry#handleURI asked to handle an invalid URI: ${uri}` + ); + } + + const registration = this.registrations.get(host); + const historyEntry = { id: ++this._id, uri: uri, handled: false, host }; + try { + if (registration) { + historyEntry.handled = true; + await registration(parsed, uri); + } + } finally { + this.history.unshift(historyEntry); + if (this.history.length > this.maxHistoryLength) { + this.history.length = this.maxHistoryLength; + } + this.emitter.emit('history-change'); + } + } + + getRecentlyHandledURIs() { + return this.history; + } + + onHistoryChange(cb) { + return this.emitter.on('history-change', cb); + } + + destroy() { + this.emitter.dispose(); + this.registrations = new Map(); + this.history = []; + this._id = 0; + } +}; diff --git a/src/view-registry.coffee b/src/view-registry.coffee deleted file mode 100644 index 24c53bd57a7..00000000000 --- a/src/view-registry.coffee +++ /dev/null @@ -1,221 +0,0 @@ -{find} = require 'underscore-plus' -Grim = require 'grim' -{Disposable} = require 'event-kit' - -# Essential: `ViewRegistry` handles the association between model and view -# types in Atom. We call this association a View Provider. As in, for a given -# model, this class can provide a view via {::getView}, as long as the -# model/view association was registered via {::addViewProvider} -# -# If you're adding your own kind of pane item, a good strategy for all but the -# simplest items is to separate the model and the view. The model handles -# application logic and is the primary point of API interaction. The view -# just handles presentation. -# -# View providers inform the workspace how your model objects should be -# presented in the DOM. A view provider must always return a DOM node, which -# makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) -# an ideal tool for implementing views in Atom. -# -# You can access the `ViewRegistry` object via `atom.views`. -# -# ## Examples -# -# ### Getting the workspace element -# -# ```coffee -# workspaceElement = atom.views.getView(atom.workspace) -# ``` -# -# ### Getting An Editor Element -# -# ```coffee -# textEditor = atom.workspace.getActiveTextEditor() -# textEditorElement = atom.views.getView(textEditor) -# ``` -# -# ### Getting A Pane Element -# -# ```coffee -# pane = atom.workspace.getActivePane() -# paneElement = atom.views.getView(pane) -# ``` -module.exports = -class ViewRegistry - documentPollingInterval: 200 - documentUpdateRequested: false - documentReadInProgress: false - performDocumentPollAfterUpdate: false - pollIntervalHandle: null - - constructor: -> - @views = new WeakMap - @providers = [] - @documentWriters = [] - @documentReaders = [] - @documentPollers = [] - - # Essential: Add a provider that will be used to construct views in the - # workspace's view layer based on model objects in its model layer. - # - # ## Examples - # - # Text editors are divided into a model and a view layer, so when you interact - # with methods like `atom.workspace.getActiveTextEditor()` you're only going - # to get the model object. We display text editors on screen by teaching the - # workspace what view constructor it should use to represent them: - # - # ```coffee - # atom.views.addViewProvider - # modelConstructor: TextEditor - # viewConstructor: TextEditorElement - # ``` - # - # * `providerSpec` {Object} containing the following keys: - # * `modelConstructor` Constructor {Function} for your model. - # * `viewConstructor` (Optional) Constructor {Function} for your view. It - # should be a subclass of `HTMLElement` (that is, your view should be a - # DOM node) and have a `::setModel()` method which will be called - # immediately after construction. If you don't supply this property, you - # must supply the `createView` property with a function that never returns - # `undefined`. - # * `createView` (Optional) Factory {Function} that must return a subclass - # of `HTMLElement` or `undefined`. If this property is not present or the - # function returns `undefined`, the view provider will fall back to the - # `viewConstructor` property. If you don't provide this property, you must - # provider a `viewConstructor` property. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # added provider. - addViewProvider: (modelConstructor, createView) -> - if arguments.length is 1 - Grim.deprecate("atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.") - provider = modelConstructor - else - provider = {modelConstructor, createView} - - @providers.push(provider) - new Disposable => - @providers = @providers.filter (p) -> p isnt provider - - # Essential: Get the view associated with an object in the workspace. - # - # If you're just *using* the workspace, you shouldn't need to access the view - # layer, but view layer access may be necessary if you want to perform DOM - # manipulation that isn't supported via the model API. - # - # ## Examples - # - # ### Getting An Editor Element - # - # ```coffee - # textEditor = atom.workspace.getActiveTextEditor() - # textEditorElement = atom.views.getView(textEditor) - # ``` - # - # ### Getting A Pane Element - # - # ```coffee - # pane = atom.workspace.getActivePane() - # paneElement = atom.views.getView(pane) - # ``` - # - # ### Getting The Workspace Element - # - # ```coffee - # workspaceElement = atom.views.getView(atom.workspace) - # ``` - # - # * `object` The object for which you want to retrieve a view. This can be a - # pane item, a pane, or the workspace itself. - # - # Returns a DOM element. - getView: (object) -> - return unless object? - - if view = @views.get(object) - view - else - view = @createView(object) - @views.set(object, view) - view - - createView: (object) -> - if object instanceof HTMLElement - object - else if object?.jquery - object[0] - else if provider = @findProvider(object) - element = provider.createView?(object) - unless element? - element = new provider.viewConstructor - element.initialize?(object) ? element.setModel?(object) - element - else if viewConstructor = object?.getViewClass?() - view = new viewConstructor(object) - view[0] - else - throw new Error("Can't create a view for #{object.constructor.name} instance. Please register a view provider.") - - findProvider: (object) -> - find @providers, ({modelConstructor}) -> object instanceof modelConstructor - - updateDocument: (fn) -> - @documentWriters.push(fn) - @requestDocumentUpdate() unless @documentReadInProgress - new Disposable => - @documentWriters = @documentWriters.filter (writer) -> writer isnt fn - - readDocument: (fn) -> - @documentReaders.push(fn) - @requestDocumentUpdate() - new Disposable => - @documentReaders = @documentReaders.filter (reader) -> reader isnt fn - - pollDocument: (fn) -> - @startPollingDocument() if @documentPollers.length is 0 - @documentPollers.push(fn) - new Disposable => - @documentPollers = @documentPollers.filter (poller) -> poller isnt fn - @stopPollingDocument() if @documentPollers.length is 0 - - pollAfterNextUpdate: -> - @performDocumentPollAfterUpdate = true - - clearDocumentRequests: -> - @documentReaders = [] - @documentWriters = [] - @documentPollers = [] - @documentUpdateRequested = false - @stopPollingDocument() - - requestDocumentUpdate: -> - unless @documentUpdateRequested - @documentUpdateRequested = true - requestAnimationFrame(@performDocumentUpdate) - - performDocumentUpdate: => - @documentUpdateRequested = false - writer() while writer = @documentWriters.shift() - - @documentReadInProgress = true - reader() while reader = @documentReaders.shift() - @performDocumentPoll() if @performDocumentPollAfterUpdate - @performDocumentPollAfterUpdate = false - @documentReadInProgress = false - - # process updates requested as a result of reads - writer() while writer = @documentWriters.shift() - - startPollingDocument: -> - @pollIntervalHandle = window.setInterval(@performDocumentPoll, @documentPollingInterval) - - stopPollingDocument: -> - window.clearInterval(@pollIntervalHandle) - - performDocumentPoll: => - if @documentUpdateRequested - @performDocumentPollAfterUpdate = true - else - poller() for poller in @documentPollers - return diff --git a/src/view-registry.js b/src/view-registry.js new file mode 100644 index 00000000000..698dbc3251e --- /dev/null +++ b/src/view-registry.js @@ -0,0 +1,287 @@ +const Grim = require('grim'); +const { Disposable } = require('event-kit'); + +const AnyConstructor = Symbol('any-constructor'); + +// Essential: `ViewRegistry` handles the association between model and view +// types in Atom. We call this association a View Provider. As in, for a given +// model, this class can provide a view via {::getView}, as long as the +// model/view association was registered via {::addViewProvider} +// +// If you're adding your own kind of pane item, a good strategy for all but the +// simplest items is to separate the model and the view. The model handles +// application logic and is the primary point of API interaction. The view +// just handles presentation. +// +// Note: Models can be any object, but must implement a `getTitle()` function +// if they are to be displayed in a {Pane} +// +// View providers inform the workspace how your model objects should be +// presented in the DOM. A view provider must always return a DOM node, which +// makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) +// an ideal tool for implementing views in Atom. +// +// You can access the `ViewRegistry` object via `atom.views`. +module.exports = class ViewRegistry { + constructor(atomEnvironment) { + this.animationFrameRequest = null; + this.documentReadInProgress = false; + this.performDocumentUpdate = this.performDocumentUpdate.bind(this); + this.atomEnvironment = atomEnvironment; + this.clear(); + } + + clear() { + this.views = new WeakMap(); + this.providers = []; + this.clearDocumentRequests(); + } + + // Essential: Add a provider that will be used to construct views in the + // workspace's view layer based on model objects in its model layer. + // + // ## Examples + // + // Text editors are divided into a model and a view layer, so when you interact + // with methods like `atom.workspace.getActiveTextEditor()` you're only going + // to get the model object. We display text editors on screen by teaching the + // workspace what view constructor it should use to represent them: + // + // ```coffee + // atom.views.addViewProvider TextEditor, (textEditor) -> + // textEditorElement = new TextEditorElement + // textEditorElement.initialize(textEditor) + // textEditorElement + // ``` + // + // * `modelConstructor` (optional) Constructor {Function} for your model. If + // a constructor is given, the `createView` function will only be used + // for model objects inheriting from that constructor. Otherwise, it will + // will be called for any object. + // * `createView` Factory {Function} that is passed an instance of your model + // and must return a subclass of `HTMLElement` or `undefined`. If it returns + // `undefined`, then the registry will continue to search for other view + // providers. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // added provider. + addViewProvider(modelConstructor, createView) { + let provider; + if (arguments.length === 1) { + switch (typeof modelConstructor) { + case 'function': + provider = { + createView: modelConstructor, + modelConstructor: AnyConstructor + }; + break; + case 'object': + Grim.deprecate( + 'atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.' + ); + provider = modelConstructor; + break; + default: + throw new TypeError('Arguments to addViewProvider must be functions'); + } + } else { + provider = { modelConstructor, createView }; + } + + this.providers.push(provider); + return new Disposable(() => { + this.providers = this.providers.filter(p => p !== provider); + }); + } + + getViewProviderCount() { + return this.providers.length; + } + + // Essential: Get the view associated with an object in the workspace. + // + // If you're just *using* the workspace, you shouldn't need to access the view + // layer, but view layer access may be necessary if you want to perform DOM + // manipulation that isn't supported via the model API. + // + // ## View Resolution Algorithm + // + // The view associated with the object is resolved using the following + // sequence + // + // 1. Is the object an instance of `HTMLElement`? If true, return the object. + // 2. Does the object have a method named `getElement` that returns an + // instance of `HTMLElement`? If true, return that value. + // 3. Does the object have a property named `element` with a value which is + // an instance of `HTMLElement`? If true, return the property value. + // 4. Is the object a jQuery object, indicated by the presence of a `jquery` + // property? If true, return the root DOM element (i.e. `object[0]`). + // 5. Has a view provider been registered for the object? If true, use the + // provider to create a view associated with the object, and return the + // view. + // + // If no associated view is returned by the sequence an error is thrown. + // + // Returns a DOM element. + getView(object) { + if (object == null) { + return; + } + + let view = this.views.get(object); + if (!view) { + view = this.createView(object); + this.views.set(object, view); + } + return view; + } + + createView(object) { + if (object instanceof HTMLElement) { + return object; + } + + let element; + if (object && typeof object.getElement === 'function') { + element = object.getElement(); + if (element instanceof HTMLElement) { + return element; + } + } + + if (object && object.element instanceof HTMLElement) { + return object.element; + } + + if (object && object.jquery) { + return object[0]; + } + + for (let provider of this.providers) { + if (provider.modelConstructor === AnyConstructor) { + element = provider.createView(object, this.atomEnvironment); + if (element) { + return element; + } + continue; + } + + if (object instanceof provider.modelConstructor) { + element = + provider.createView && + provider.createView(object, this.atomEnvironment); + if (element) { + return element; + } + + let ViewConstructor = provider.viewConstructor; + if (ViewConstructor) { + element = new ViewConstructor(); + if (element.initialize) { + element.initialize(object); + } else if (element.setModel) { + element.setModel(object); + } + return element; + } + } + } + + if (object && object.getViewClass) { + let ViewConstructor = object.getViewClass(); + if (ViewConstructor) { + const view = new ViewConstructor(object); + return view[0]; + } + } + + throw new Error( + `Can't create a view for ${ + object.constructor.name + } instance. Please register a view provider.` + ); + } + + updateDocument(fn) { + this.documentWriters.push(fn); + if (!this.documentReadInProgress) { + this.requestDocumentUpdate(); + } + return new Disposable(() => { + this.documentWriters = this.documentWriters.filter( + writer => writer !== fn + ); + }); + } + + readDocument(fn) { + this.documentReaders.push(fn); + this.requestDocumentUpdate(); + return new Disposable(() => { + this.documentReaders = this.documentReaders.filter( + reader => reader !== fn + ); + }); + } + + getNextUpdatePromise() { + if (this.nextUpdatePromise == null) { + this.nextUpdatePromise = new Promise(resolve => { + this.resolveNextUpdatePromise = resolve; + }); + } + + return this.nextUpdatePromise; + } + + clearDocumentRequests() { + this.documentReaders = []; + this.documentWriters = []; + this.nextUpdatePromise = null; + this.resolveNextUpdatePromise = null; + if (this.animationFrameRequest != null) { + cancelAnimationFrame(this.animationFrameRequest); + this.animationFrameRequest = null; + } + } + + requestDocumentUpdate() { + if (this.animationFrameRequest == null) { + this.animationFrameRequest = requestAnimationFrame( + this.performDocumentUpdate + ); + } + } + + performDocumentUpdate() { + const { resolveNextUpdatePromise } = this; + this.animationFrameRequest = null; + this.nextUpdatePromise = null; + this.resolveNextUpdatePromise = null; + + let writer = this.documentWriters.shift(); + while (writer) { + writer(); + writer = this.documentWriters.shift(); + } + + let reader = this.documentReaders.shift(); + this.documentReadInProgress = true; + while (reader) { + reader(); + reader = this.documentReaders.shift(); + } + this.documentReadInProgress = false; + + // process updates requested as a result of reads + writer = this.documentWriters.shift(); + while (writer) { + writer(); + writer = this.documentWriters.shift(); + } + + if (resolveNextUpdatePromise) { + resolveNextUpdatePromise(); + } + } +}; diff --git a/src/window-bootstrap.coffee b/src/window-bootstrap.coffee deleted file mode 100644 index 886ba26dc48..00000000000 --- a/src/window-bootstrap.coffee +++ /dev/null @@ -1,13 +0,0 @@ -# Like sands through the hourglass, so are the days of our lives. -require './window' - -Atom = require './atom' -window.atom = Atom.loadOrCreate('editor') -atom.initialize() -atom.startEditorWindow() - -# Workaround for focus getting cleared upon window creation -windowFocused = -> - window.removeEventListener('focus', windowFocused) - setTimeout (-> document.querySelector('atom-workspace').focus()), 0 -window.addEventListener('focus', windowFocused) diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee deleted file mode 100644 index 7d67e87abd1..00000000000 --- a/src/window-event-handler.coffee +++ /dev/null @@ -1,201 +0,0 @@ -path = require 'path' -{$} = require './space-pen-extensions' -{Disposable} = require 'event-kit' -ipc = require 'ipc' -shell = require 'shell' -{Subscriber} = require 'emissary' -fs = require 'fs-plus' - -# Handles low-level events related to the window. -module.exports = -class WindowEventHandler - Subscriber.includeInto(this) - - constructor: -> - @reloadRequested = false - - @subscribe ipc, 'message', (message, detail) -> - switch message - when 'open-locations' - needsProjectPaths = atom.project?.getPaths().length is 0 - - for {pathToOpen, initialLine, initialColumn} in detail - if pathToOpen? and needsProjectPaths - if fs.existsSync(pathToOpen) - atom.project.addPath(pathToOpen) - else - dirToOpen = path.dirname(pathToOpen) - if fs.existsSync(dirToOpen) - atom.project.addPath(dirToOpen) - - unless fs.isDirectorySync(pathToOpen) - atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) - - return - - when 'update-available' - atom.updateAvailable(detail) - - # FIXME: Remove this when deprecations are removed - {releaseVersion} = detail - detail = [releaseVersion] - if workspaceElement = atom.views.getView(atom.workspace) - atom.commands.dispatch workspaceElement, "window:update-available", detail - - @subscribe ipc, 'command', (command, args...) -> - activeElement = document.activeElement - # Use the workspace element view if body has focus - if activeElement is document.body and workspaceElement = atom.views.getView(atom.workspace) - activeElement = workspaceElement - - atom.commands.dispatch(activeElement, command, args[0]) - - @subscribe ipc, 'context-command', (command, args...) -> - $(atom.contextMenu.activeElement).trigger(command, args...) - - @subscribe $(window), 'focus', -> document.body.classList.remove('is-blurred') - - @subscribe $(window), 'blur', -> document.body.classList.add('is-blurred') - - @subscribe $(window), 'beforeunload', => - confirmed = atom.workspace?.confirmClose(windowCloseRequested: true) - atom.hide() if confirmed and not @reloadRequested and atom.getCurrentWindow().isWebViewFocused() - @reloadRequested = false - - atom.storeDefaultWindowDimensions() - atom.storeWindowDimensions() - atom.unloadEditorWindow() if confirmed - - confirmed - - @subscribe $(window), 'blur', -> atom.storeDefaultWindowDimensions() - - @subscribe $(window), 'unload', -> atom.removeEditorWindow() - - @subscribeToCommand $(window), 'window:toggle-full-screen', -> atom.toggleFullScreen() - - @subscribeToCommand $(window), 'window:close', -> atom.close() - - @subscribeToCommand $(window), 'window:reload', => - @reloadRequested = true - atom.reload() - - @subscribeToCommand $(window), 'window:toggle-dev-tools', -> atom.toggleDevTools() - - if process.platform in ['win32', 'linux'] - @subscribeToCommand $(window), 'window:toggle-menu-bar', -> - atom.config.set('core.autoHideMenuBar', not atom.config.get('core.autoHideMenuBar')) - - @subscribeToCommand $(document), 'core:focus-next', @focusNext - - @subscribeToCommand $(document), 'core:focus-previous', @focusPrevious - - document.addEventListener 'keydown', @onKeydown - - document.addEventListener 'drop', @onDrop - @subscribe new Disposable => - document.removeEventListener('drop', @onDrop) - - document.addEventListener 'dragover', @onDragOver - @subscribe new Disposable => - document.removeEventListener('dragover', @onDragOver) - - @subscribe $(document), 'click', 'a', @openLink - - # Prevent form submits from changing the current window's URL - @subscribe $(document), 'submit', 'form', (e) -> e.preventDefault() - - @subscribe $(document), 'contextmenu', (e) -> - e.preventDefault() - atom.contextMenu.showForEvent(e) - - @handleNativeKeybindings() - - # Wire commands that should be handled by the native menu - # for elements with the `.native-key-bindings` class. - handleNativeKeybindings: -> - menu = null - bindCommandToAction = (command, action) => - @subscribe $(document), command, (event) -> - if event.target.webkitMatchesSelector('.native-key-bindings') - menu ?= require('remote').require('menu') - menu.sendActionToFirstResponder(action) - true - - bindCommandToAction('core:copy', 'copy:') - bindCommandToAction('core:paste', 'paste:') - bindCommandToAction('core:undo', 'undo:') - bindCommandToAction('core:redo', 'redo:') - bindCommandToAction('core:select-all', 'selectAll:') - - onKeydown: (event) -> - atom.keymaps.handleKeyboardEvent(event) - event.stopImmediatePropagation() - - onDrop: (event) -> - event.preventDefault() - event.stopPropagation() - - onDragOver: (event) -> - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'none' - - openLink: ({target, currentTarget}) -> - location = target?.getAttribute('href') or currentTarget?.getAttribute('href') - if location and location[0] isnt '#' and /^https?:\/\//.test(location) - shell.openExternal(location) - false - - eachTabIndexedElement: (callback) -> - for element in $('[tabindex]') - element = $(element) - continue if element.isDisabled() - - tabIndex = parseInt(element.attr('tabindex')) - continue unless tabIndex >= 0 - - callback(element, tabIndex) - return - - focusNext: => - focusedTabIndex = parseInt($(':focus').attr('tabindex')) or -Infinity - - nextElement = null - nextTabIndex = Infinity - lowestElement = null - lowestTabIndex = Infinity - @eachTabIndexedElement (element, tabIndex) -> - if tabIndex < lowestTabIndex - lowestTabIndex = tabIndex - lowestElement = element - - if focusedTabIndex < tabIndex < nextTabIndex - nextTabIndex = tabIndex - nextElement = element - - if nextElement? - nextElement.focus() - else if lowestElement? - lowestElement.focus() - - focusPrevious: => - focusedTabIndex = parseInt($(':focus').attr('tabindex')) or Infinity - - previousElement = null - previousTabIndex = -Infinity - highestElement = null - highestTabIndex = -Infinity - @eachTabIndexedElement (element, tabIndex) -> - if tabIndex > highestTabIndex - highestTabIndex = tabIndex - highestElement = element - - if focusedTabIndex > tabIndex > previousTabIndex - previousTabIndex = tabIndex - previousElement = element - - if previousElement? - previousElement.focus() - else if highestElement? - highestElement.focus() diff --git a/src/window-event-handler.js b/src/window-event-handler.js new file mode 100644 index 00000000000..b796defb6d6 --- /dev/null +++ b/src/window-event-handler.js @@ -0,0 +1,324 @@ +const { Disposable, CompositeDisposable } = require('event-kit'); +const listen = require('./delegated-listener'); +const { debounce } = require('underscore-plus'); + +// Handles low-level events related to the `window`. +module.exports = class WindowEventHandler { + constructor({ atomEnvironment, applicationDelegate }) { + this.handleDocumentKeyEvent = this.handleDocumentKeyEvent.bind(this); + 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); + this.handleWindowToggleFullScreen = this.handleWindowToggleFullScreen.bind( + this + ); + this.handleWindowClose = this.handleWindowClose.bind(this); + this.handleWindowReload = this.handleWindowReload.bind(this); + this.handleWindowToggleDevTools = this.handleWindowToggleDevTools.bind( + this + ); + this.handleWindowToggleMenuBar = this.handleWindowToggleMenuBar.bind(this); + this.handleLinkClick = this.handleLinkClick.bind(this); + this.handleDocumentContextmenu = this.handleDocumentContextmenu.bind(this); + this.atomEnvironment = atomEnvironment; + this.applicationDelegate = applicationDelegate; + this.reloadRequested = false; + this.subscriptions = new CompositeDisposable(); + + this.handleNativeKeybindings(); + } + + initialize(window, document) { + this.window = window; + this.document = document; + this.subscriptions.add( + this.atomEnvironment.commands.add(this.window, { + 'window:toggle-full-screen': this.handleWindowToggleFullScreen, + 'window:close': this.handleWindowClose, + 'window:reload': this.handleWindowReload, + 'window:toggle-dev-tools': this.handleWindowToggleDevTools + }) + ); + + if (['win32', 'linux'].includes(process.platform)) { + this.subscriptions.add( + this.atomEnvironment.commands.add(this.window, { + 'window:toggle-menu-bar': this.handleWindowToggleMenuBar + }) + ); + } + + this.subscriptions.add( + this.atomEnvironment.commands.add(this.document, { + 'core:focus-next': this.handleFocusNext, + 'core:focus-previous': this.handleFocusPrevious + }) + ); + + 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', + debounce(this.handleWindowResize, 500) + ); + + this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent); + this.addEventListener( + this.document, + 'keydown', + this.handleDocumentKeyEvent + ); + this.addEventListener(this.document, 'drop', this.handleDocumentDrop); + this.addEventListener( + this.document, + 'dragover', + this.handleDocumentDragover + ); + this.addEventListener( + this.document, + 'contextmenu', + this.handleDocumentContextmenu + ); + this.subscriptions.add( + listen(this.document, 'click', 'a', this.handleLinkClick) + ); + this.subscriptions.add( + listen(this.document, 'submit', 'form', this.handleFormSubmit) + ); + + this.subscriptions.add( + this.applicationDelegate.onDidEnterFullScreen(this.handleEnterFullScreen) + ); + this.subscriptions.add( + this.applicationDelegate.onDidLeaveFullScreen(this.handleLeaveFullScreen) + ); + } + + // Wire commands that should be handled by Chromium for elements with the + // `.native-key-bindings` class. + handleNativeKeybindings() { + const bindCommandToAction = (command, action) => { + this.subscriptions.add( + this.atomEnvironment.commands.add( + '.native-key-bindings', + command, + event => + this.applicationDelegate.getCurrentWindow().webContents[action](), + false + ) + ); + }; + + bindCommandToAction('core:copy', 'copy'); + bindCommandToAction('core:paste', 'paste'); + bindCommandToAction('core:undo', 'undo'); + bindCommandToAction('core:redo', 'redo'); + bindCommandToAction('core:select-all', 'selectAll'); + bindCommandToAction('core:cut', 'cut'); + } + + unsubscribe() { + this.subscriptions.dispose(); + } + + on(target, eventName, handler) { + target.on(eventName, handler); + this.subscriptions.add( + new Disposable(function() { + target.removeListener(eventName, handler); + }) + ); + } + + addEventListener(target, eventName, handler) { + target.addEventListener(eventName, handler); + this.subscriptions.add( + new Disposable(function() { + target.removeEventListener(eventName, handler); + }) + ); + } + + handleDocumentKeyEvent(event) { + this.atomEnvironment.keymaps.handleKeyboardEvent(event); + event.stopImmediatePropagation(); + } + + handleDrop(event) { + event.preventDefault(); + event.stopPropagation(); + } + + handleDragover(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'none'; + } + + eachTabIndexedElement(callback) { + for (let element of this.document.querySelectorAll('[tabindex]')) { + if (element.disabled) { + continue; + } + if (!(element.tabIndex >= 0)) { + continue; + } + callback(element, element.tabIndex); + } + } + + handleFocusNext() { + const focusedTabIndex = + this.document.activeElement.tabIndex != null + ? this.document.activeElement.tabIndex + : -Infinity; + + let nextElement = null; + let nextTabIndex = Infinity; + let lowestElement = null; + let lowestTabIndex = Infinity; + this.eachTabIndexedElement(function(element, tabIndex) { + if (tabIndex < lowestTabIndex) { + lowestTabIndex = tabIndex; + lowestElement = element; + } + + if (focusedTabIndex < tabIndex && tabIndex < nextTabIndex) { + nextTabIndex = tabIndex; + nextElement = element; + } + }); + + if (nextElement != null) { + nextElement.focus(); + } else if (lowestElement != null) { + lowestElement.focus(); + } + } + + handleFocusPrevious() { + const focusedTabIndex = + this.document.activeElement.tabIndex != null + ? this.document.activeElement.tabIndex + : Infinity; + + let previousElement = null; + let previousTabIndex = -Infinity; + let highestElement = null; + let highestTabIndex = -Infinity; + this.eachTabIndexedElement(function(element, tabIndex) { + if (tabIndex > highestTabIndex) { + highestTabIndex = tabIndex; + highestElement = element; + } + + if (focusedTabIndex > tabIndex && tabIndex > previousTabIndex) { + previousTabIndex = tabIndex; + previousElement = element; + } + }); + + if (previousElement != null) { + previousElement.focus(); + } else if (highestElement != null) { + highestElement.focus(); + } + } + + handleWindowFocus() { + this.document.body.classList.remove('is-blurred'); + } + + handleWindowBlur() { + this.document.body.classList.add('is-blurred'); + this.atomEnvironment.storeWindowDimensions(); + } + + handleWindowResize() { + this.atomEnvironment.storeWindowDimensions(); + } + + handleEnterFullScreen() { + this.document.body.classList.add('fullscreen'); + } + + handleLeaveFullScreen() { + this.document.body.classList.remove('fullscreen'); + } + + handleWindowBeforeunload(event) { + if ( + !this.reloadRequested && + !this.atomEnvironment.inSpecMode() && + this.atomEnvironment.getCurrentWindow().isWebViewFocused() + ) { + this.atomEnvironment.hide(); + } + this.reloadRequested = false; + this.atomEnvironment.storeWindowDimensions(); + this.atomEnvironment.unloadEditorWindow(); + this.atomEnvironment.destroy(); + } + + handleWindowToggleFullScreen() { + this.atomEnvironment.toggleFullScreen(); + } + + handleWindowClose() { + this.atomEnvironment.close(); + } + + handleWindowReload() { + this.reloadRequested = true; + this.atomEnvironment.reload(); + } + + handleWindowToggleDevTools() { + this.atomEnvironment.toggleDevTools(); + } + + handleWindowToggleMenuBar() { + this.atomEnvironment.config.set( + 'core.autoHideMenuBar', + !this.atomEnvironment.config.get('core.autoHideMenuBar') + ); + + if (this.atomEnvironment.config.get('core.autoHideMenuBar')) { + const detail = + 'To toggle, press the Alt key or execute the window:toggle-menu-bar command'; + this.atomEnvironment.notifications.addInfo('Menu bar hidden', { detail }); + } + } + + handleLinkClick(event) { + event.preventDefault(); + const uri = event.currentTarget && event.currentTarget.getAttribute('href'); + if (uri && uri[0] !== '#') { + if (/^https?:\/\//.test(uri)) { + this.applicationDelegate.openExternal(uri); + } else if (uri.startsWith('atom://')) { + this.atomEnvironment.uriHandlerRegistry.handleURI(uri); + } + } + } + + handleFormSubmit(event) { + // Prevent form submits from changing the current window's URL + event.preventDefault(); + } + + handleDocumentContextmenu(event) { + event.preventDefault(); + this.atomEnvironment.contextMenu.showForEvent(event); + } +}; diff --git a/src/window.coffee b/src/window.coffee deleted file mode 100644 index 9554218cace..00000000000 --- a/src/window.coffee +++ /dev/null @@ -1,27 +0,0 @@ -# Public: Measure how long a function takes to run. -# -# description - A {String} description that will be logged to the console when -# the function completes. -# fn - A {Function} to measure the duration of. -# -# Returns the value returned by the given function. -window.measure = (description, fn) -> - start = Date.now() - value = fn() - result = Date.now() - start - console.log description, result - value - -# Public: Create a dev tools profile for a function. -# -# description - A {String} description that will be available in the Profiles -# tab of the dev tools. -# fn - A {Function} to profile. -# -# Returns the value returned by the given function. -window.profile = (description, fn) -> - measure description, -> - console.profile(description) - value = fn() - console.profileEnd(description) - value diff --git a/src/window.js b/src/window.js new file mode 100644 index 00000000000..a13a5e64858 --- /dev/null +++ b/src/window.js @@ -0,0 +1,30 @@ +// Public: Measure how long a function takes to run. +// +// description - A {String} description that will be logged to the console when +// the function completes. +// fn - A {Function} to measure the duration of. +// +// Returns the value returned by the given function. +window.measure = function(description, fn) { + let start = Date.now(); + let value = fn(); + let result = Date.now() - start; + console.log(description, result); + return value; +}; + +// Public: Create a dev tools profile for a function. +// +// description - A {String} description that will be available in the Profiles +// tab of the dev tools. +// fn - A {Function} to profile. +// +// Returns the value returned by the given function. +window.profile = function(description, fn) { + window.measure(description, function() { + console.profile(description); + let value = fn(); + console.profileEnd(description); + return value; + }); +}; diff --git a/src/workspace-center.js b/src/workspace-center.js new file mode 100644 index 00000000000..f803c124368 --- /dev/null +++ b/src/workspace-center.js @@ -0,0 +1,343 @@ +'use strict'; + +const TextEditor = require('./text-editor'); +const PaneContainer = require('./pane-container'); + +// Essential: Represents the workspace at the center of the entire window. +module.exports = class WorkspaceCenter { + constructor(params) { + params.location = 'center'; + this.paneContainer = new PaneContainer(params); + this.didActivate = params.didActivate; + this.paneContainer.onDidActivatePane(() => this.didActivate(this)); + this.paneContainer.onDidChangeActivePane(pane => { + params.didChangeActivePane(this, pane); + }); + this.paneContainer.onDidChangeActivePaneItem(item => { + params.didChangeActivePaneItem(this, item); + }); + this.paneContainer.onDidDestroyPaneItem(item => + params.didDestroyPaneItem(item) + ); + } + + destroy() { + this.paneContainer.destroy(); + } + + serialize() { + return this.paneContainer.serialize(); + } + + deserialize(state, deserializerManager) { + this.paneContainer.deserialize(state, deserializerManager); + } + + activate() { + this.getActivePane().activate(); + } + + getLocation() { + return 'center'; + } + + setDraggingItem() { + // No-op + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback with all current and future text + // editors in the workspace center. + // + // * `callback` {Function} to be called with current and future text editors. + // * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + // of subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors(callback) { + for (let textEditor of this.getTextEditors()) { + callback(textEditor); + } + return this.onDidAddTextEditor(({ textEditor }) => callback(textEditor)); + } + + // Essential: Invoke the given callback with all current and future panes items + // in the workspace center. + // + // * `callback` {Function} to be called with current and future pane items. + // * `item` An item that is present in {::getPaneItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePaneItems(callback) { + return this.paneContainer.observePaneItems(callback); + } + + // Essential: Invoke the given callback when the active pane item changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider + // {::onDidStopChangingActivePaneItem} to delay operations until after changes + // stop occurring. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePaneItem(callback) { + return this.paneContainer.onDidChangeActivePaneItem(callback); + } + + // Essential: Invoke the given callback when the active pane item stops + // changing. + // + // Observers are called asynchronously 100ms after the last active pane item + // change. Handling changes here rather than in the synchronous + // {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly + // changing or closing tabs and ensures critical UI feedback, like changing the + // highlighted tab, gets priority over work that can be done asynchronously. + // + // * `callback` {Function} to be called when the active pane item stopts + // changing. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChangingActivePaneItem(callback) { + return this.paneContainer.onDidStopChangingActivePaneItem(callback); + } + + // Essential: Invoke the given callback with the current active pane item and + // with all future active pane items in the workspace center. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The current active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePaneItem(callback) { + return this.paneContainer.observeActivePaneItem(callback); + } + + // Extended: Invoke the given callback when a pane is added to the workspace + // center. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `pane` The added pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPane(callback) { + return this.paneContainer.onDidAddPane(callback); + } + + // Extended: Invoke the given callback before a pane is destroyed in the + // workspace center. + // + // * `callback` {Function} to be called before panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The pane to be destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroyPane(callback) { + return this.paneContainer.onWillDestroyPane(callback); + } + + // Extended: Invoke the given callback when a pane is destroyed in the + // workspace center. + // + // * `callback` {Function} to be called panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The destroyed pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroyPane(callback) { + return this.paneContainer.onDidDestroyPane(callback); + } + + // Extended: Invoke the given callback with all current and future panes in the + // workspace center. + // + // * `callback` {Function} to be called with current and future panes. + // * `pane` A {Pane} that is present in {::getPanes} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePanes(callback) { + return this.paneContainer.observePanes(callback); + } + + // Extended: Invoke the given callback when the active pane changes. + // + // * `callback` {Function} to be called when the active pane changes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePane(callback) { + return this.paneContainer.onDidChangeActivePane(callback); + } + + // Extended: Invoke the given callback with the current active pane and when + // the active pane changes. + // + // * `callback` {Function} to be called with the current and future active# + // panes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePane(callback) { + return this.paneContainer.observeActivePane(callback); + } + + // Extended: Invoke the given callback when a pane item is added to the + // workspace center. + // + // * `callback` {Function} to be called when pane items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `pane` {Pane} containing the added item. + // * `index` {Number} indicating the index of the added item in its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPaneItem(callback) { + return this.paneContainer.onDidAddPaneItem(callback); + } + + // Extended: Invoke the given callback when a pane item is about to be + // destroyed, before the user is prompted to save it. + // + // * `callback` {Function} to be called before pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The item to be destroyed. + // * `pane` {Pane} containing the item to be destroyed. + // * `index` {Number} indicating the index of the item to be destroyed in + // its pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onWillDestroyPaneItem(callback) { + return this.paneContainer.onWillDestroyPaneItem(callback); + } + + // Extended: Invoke the given callback when a pane item is destroyed. + // + // * `callback` {Function} to be called when pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The destroyed item. + // * `pane` {Pane} containing the destroyed item. + // * `index` {Number} indicating the index of the destroyed item in its + // pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onDidDestroyPaneItem(callback) { + return this.paneContainer.onDidDestroyPaneItem(callback); + } + + // Extended: Invoke the given callback when a text editor is added to the + // workspace center. + // + // * `callback` {Function} to be called when panes are added. + // * `event` {Object} with the following keys: + // * `textEditor` {TextEditor} that was added. + // * `pane` {Pane} containing the added text editor. + // * `index` {Number} indicating the index of the added text editor in its + // pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddTextEditor(callback) { + return this.onDidAddPaneItem(({ item, pane, index }) => { + if (item instanceof TextEditor) { + callback({ textEditor: item, pane, index }); + } + }); + } + + /* + Section: Pane Items + */ + + // Essential: Get all pane items in the workspace center. + // + // Returns an {Array} of items. + getPaneItems() { + return this.paneContainer.getPaneItems(); + } + + // Essential: Get the active {Pane}'s active item. + // + // Returns an pane item {Object}. + getActivePaneItem() { + return this.paneContainer.getActivePaneItem(); + } + + // Essential: Get all text editors in the workspace center. + // + // Returns an {Array} of {TextEditor}s. + getTextEditors() { + return this.getPaneItems().filter(item => item instanceof TextEditor); + } + + // Essential: Get the active item if it is an {TextEditor}. + // + // Returns an {TextEditor} or `undefined` if the current active item is not an + // {TextEditor}. + getActiveTextEditor() { + const activeItem = this.getActivePaneItem(); + if (activeItem instanceof TextEditor) { + return activeItem; + } + } + + // Save all pane items. + saveAll() { + this.paneContainer.saveAll(); + } + + confirmClose(options) { + return this.paneContainer.confirmClose(options); + } + + /* + Section: Panes + */ + + // Extended: Get all panes in the workspace center. + // + // Returns an {Array} of {Pane}s. + getPanes() { + return this.paneContainer.getPanes(); + } + + // Extended: Get the active {Pane}. + // + // Returns a {Pane}. + getActivePane() { + return this.paneContainer.getActivePane(); + } + + // Extended: Make the next pane active. + activateNextPane() { + return this.paneContainer.activateNextPane(); + } + + // Extended: Make the previous pane active. + activatePreviousPane() { + return this.paneContainer.activatePreviousPane(); + } + + paneForURI(uri) { + return this.paneContainer.paneForURI(uri); + } + + paneForItem(item) { + return this.paneContainer.paneForItem(item); + } + + // Destroy (close) the active pane. + destroyActivePane() { + const activePane = this.getActivePane(); + if (activePane != null) { + activePane.destroy(); + } + } +}; diff --git a/src/workspace-element.coffee b/src/workspace-element.coffee deleted file mode 100644 index 4f5dd6c5d38..00000000000 --- a/src/workspace-element.coffee +++ /dev/null @@ -1,173 +0,0 @@ -ipc = require 'ipc' -path = require 'path' -{Disposable, CompositeDisposable} = require 'event-kit' -Grim = require 'grim' -scrollbarStyle = require 'scrollbar-style' -{callAttachHooks} = require 'space-pen' -WorkspaceView = null - -module.exports = -class WorkspaceElement extends HTMLElement - globalTextEditorStyleSheet: null - - createdCallback: -> - @subscriptions = new CompositeDisposable - @initializeGlobalTextEditorStyleSheet() - @initializeContent() - @observeScrollbarStyle() - @observeTextEditorFontConfig() - @createSpacePenShim() if Grim.includeDeprecatedAPIs - - attachedCallback: -> - callAttachHooks(this) if Grim.includeDeprecatedAPIs - @focus() - - detachedCallback: -> - @subscriptions.dispose() - @model.destroy() - - initializeGlobalTextEditorStyleSheet: -> - atom.styles.addStyleSheet('atom-text-editor {}', sourcePath: 'global-text-editor-styles') - @globalTextEditorStyleSheet = document.head.querySelector('style[source-path="global-text-editor-styles"]').sheet - - initializeContent: -> - @classList.add 'workspace' - @setAttribute 'tabindex', -1 - - @verticalAxis = document.createElement('atom-workspace-axis') - @verticalAxis.classList.add('vertical') - - @horizontalAxis = document.createElement('atom-workspace-axis') - @horizontalAxis.classList.add('horizontal') - @horizontalAxis.appendChild(@verticalAxis) - - @appendChild(@horizontalAxis) - - observeScrollbarStyle: -> - @subscriptions.add scrollbarStyle.observePreferredScrollbarStyle (style) => - switch style - when 'legacy' - @classList.remove('scrollbars-visible-when-scrolling') - @classList.add("scrollbars-visible-always") - when 'overlay' - @classList.remove('scrollbars-visible-always') - @classList.add("scrollbars-visible-when-scrolling") - - observeTextEditorFontConfig: -> - @subscriptions.add atom.config.observe 'editor.fontSize', @setTextEditorFontSize.bind(this) - @subscriptions.add atom.config.observe 'editor.fontFamily', @setTextEditorFontFamily.bind(this) - @subscriptions.add atom.config.observe 'editor.lineHeight', @setTextEditorLineHeight.bind(this) - - createSpacePenShim: -> - WorkspaceView ?= require './workspace-view' - @__spacePenView = new WorkspaceView(this) - - initialize: (@model) -> - @paneContainer = atom.views.getView(@model.paneContainer) - @verticalAxis.appendChild(@paneContainer) - @addEventListener 'focus', @handleFocus.bind(this) - - @panelContainers = - top: atom.views.getView(@model.panelContainers.top) - left: atom.views.getView(@model.panelContainers.left) - right: atom.views.getView(@model.panelContainers.right) - bottom: atom.views.getView(@model.panelContainers.bottom) - modal: atom.views.getView(@model.panelContainers.modal) - - @horizontalAxis.insertBefore(@panelContainers.left, @verticalAxis) - @horizontalAxis.appendChild(@panelContainers.right) - - @verticalAxis.insertBefore(@panelContainers.top, @paneContainer) - @verticalAxis.appendChild(@panelContainers.bottom) - - @appendChild(@panelContainers.modal) - - @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs - this - - getModel: -> @model - - setTextEditorFontSize: (fontSize) -> - @updateGlobalEditorStyle('font-size', fontSize + 'px') - - setTextEditorFontFamily: (fontFamily) -> - @updateGlobalEditorStyle('font-family', fontFamily) - - setTextEditorLineHeight: (lineHeight) -> - @updateGlobalEditorStyle('line-height', lineHeight) - - updateGlobalEditorStyle: (property, value) -> - editorRule = @globalTextEditorStyleSheet.cssRules[0] - editorRule.style[property] = value - atom.themes.emitter.emit 'did-update-stylesheet', @globalTextEditorStyleSheet - - handleFocus: (event) -> - @model.getActivePane().activate() - - focusPaneViewAbove: -> @paneContainer.focusPaneViewAbove() - - focusPaneViewBelow: -> @paneContainer.focusPaneViewBelow() - - focusPaneViewOnLeft: -> @paneContainer.focusPaneViewOnLeft() - - focusPaneViewOnRight: -> @paneContainer.focusPaneViewOnRight() - - runPackageSpecs: -> - if activePath = atom.workspace.getActivePaneItem()?.getPath?() - [projectPath] = atom.project.relativizePath(activePath) - else - [projectPath] = atom.project.getPaths() - ipc.send('run-package-specs', path.join(projectPath, 'spec')) if projectPath - -atom.commands.add 'atom-workspace', - 'window:increase-font-size': -> @getModel().increaseFontSize() - 'window:decrease-font-size': -> @getModel().decreaseFontSize() - 'window:reset-font-size': -> @getModel().resetFontSize() - 'application:about': -> ipc.send('command', 'application:about') - 'application:run-all-specs': -> ipc.send('command', 'application:run-all-specs') - 'application:run-benchmarks': -> ipc.send('command', 'application:run-benchmarks') - 'application:show-settings': -> ipc.send('command', 'application:show-settings') - 'application:quit': -> ipc.send('command', 'application:quit') - 'application:hide': -> ipc.send('command', 'application:hide') - 'application:hide-other-applications': -> ipc.send('command', 'application:hide-other-applications') - 'application:install-update': -> ipc.send('command', 'application:install-update') - 'application:unhide-all-applications': -> ipc.send('command', 'application:unhide-all-applications') - 'application:new-window': -> ipc.send('command', 'application:new-window') - 'application:new-file': -> ipc.send('command', 'application:new-file') - 'application:open': -> ipc.send('command', 'application:open') - 'application:open-file': -> ipc.send('command', 'application:open-file') - 'application:open-folder': -> ipc.send('command', 'application:open-folder') - 'application:open-dev': -> ipc.send('command', 'application:open-dev') - 'application:open-safe': -> ipc.send('command', 'application:open-safe') - 'application:open-api-preview': -> ipc.send('command', 'application:open-api-preview') - 'application:open-dev-api-preview': -> ipc.send('command', 'application:open-dev-api-preview') - 'application:add-project-folder': -> atom.addProjectFolder() - 'application:minimize': -> ipc.send('command', 'application:minimize') - 'application:zoom': -> ipc.send('command', 'application:zoom') - 'application:bring-all-windows-to-front': -> ipc.send('command', 'application:bring-all-windows-to-front') - 'application:open-your-config': -> ipc.send('command', 'application:open-your-config') - 'application:open-your-init-script': -> ipc.send('command', 'application:open-your-init-script') - 'application:open-your-keymap': -> ipc.send('command', 'application:open-your-keymap') - 'application:open-your-snippets': -> ipc.send('command', 'application:open-your-snippets') - 'application:open-your-stylesheet': -> ipc.send('command', 'application:open-your-stylesheet') - 'application:open-license': -> @getModel().openLicense() - 'window:run-package-specs': -> @runPackageSpecs() - 'window:focus-next-pane': -> @getModel().activateNextPane() - 'window:focus-previous-pane': -> @getModel().activatePreviousPane() - 'window:focus-pane-above': -> @focusPaneViewAbove() - 'window:focus-pane-below': -> @focusPaneViewBelow() - 'window:focus-pane-on-left': -> @focusPaneViewOnLeft() - 'window:focus-pane-on-right': -> @focusPaneViewOnRight() - 'window:save-all': -> @getModel().saveAll() - 'window:toggle-invisibles': -> atom.config.set("editor.showInvisibles", not atom.config.get("editor.showInvisibles")) - 'window:log-deprecation-warnings': -> Grim.logDeprecations() - 'window:toggle-auto-indent': -> atom.config.set("editor.autoIndent", not atom.config.get("editor.autoIndent")) - 'pane:reopen-closed-item': -> @getModel().reopenItem() - 'core:close': -> @getModel().destroyActivePaneItemOrEmptyPane() - 'core:save': -> @getModel().saveActivePaneItem() - 'core:save-as': -> @getModel().saveActivePaneItemAs() - -if process.platform is 'darwin' - atom.commands.add 'atom-workspace', 'window:install-shell-commands', -> @getModel().installShellCommands() - -module.exports = WorkspaceElement = document.registerElement 'atom-workspace', prototype: WorkspaceElement.prototype diff --git a/src/workspace-element.js b/src/workspace-element.js new file mode 100644 index 00000000000..9400ef028c6 --- /dev/null +++ b/src/workspace-element.js @@ -0,0 +1,486 @@ +'use strict'; + +const { ipcRenderer } = require('electron'); +const path = require('path'); +const fs = require('fs-plus'); +const { CompositeDisposable, Disposable } = require('event-kit'); +const scrollbarStyle = require('scrollbar-style'); +const _ = require('underscore-plus'); + +class WorkspaceElement extends HTMLElement { + connectedCallback() { + this.focus(); + this.htmlElement = document.querySelector('html'); + this.htmlElement.addEventListener('mouseleave', this.handleCenterLeave); + } + + disconnectedCallback() { + this.subscriptions.dispose(); + this.htmlElement.removeEventListener('mouseleave', this.handleCenterLeave); + } + + initializeContent() { + this.classList.add('workspace'); + this.setAttribute('tabindex', -1); + + this.verticalAxis = document.createElement('atom-workspace-axis'); + this.verticalAxis.classList.add('vertical'); + + this.horizontalAxis = document.createElement('atom-workspace-axis'); + this.horizontalAxis.classList.add('horizontal'); + this.horizontalAxis.appendChild(this.verticalAxis); + + this.appendChild(this.horizontalAxis); + } + + observeScrollbarStyle() { + this.subscriptions.add( + scrollbarStyle.observePreferredScrollbarStyle(style => { + switch (style) { + case 'legacy': + this.classList.remove('scrollbars-visible-when-scrolling'); + this.classList.add('scrollbars-visible-always'); + break; + case 'overlay': + this.classList.remove('scrollbars-visible-always'); + this.classList.add('scrollbars-visible-when-scrolling'); + break; + } + }) + ); + } + + observeTextEditorFontConfig() { + this.updateGlobalTextEditorStyleSheet(); + this.subscriptions.add( + this.config.onDidChange( + 'editor.fontSize', + this.updateGlobalTextEditorStyleSheet.bind(this) + ) + ); + this.subscriptions.add( + this.config.onDidChange( + 'editor.fontFamily', + this.updateGlobalTextEditorStyleSheet.bind(this) + ) + ); + this.subscriptions.add( + this.config.onDidChange( + 'editor.lineHeight', + this.updateGlobalTextEditorStyleSheet.bind(this) + ) + ); + } + + updateGlobalTextEditorStyleSheet() { + const styleSheetSource = `atom-workspace { + --editor-font-size: ${this.config.get('editor.fontSize')}px; + --editor-font-family: ${this.config.get('editor.fontFamily')}; + --editor-line-height: ${this.config.get('editor.lineHeight')}; +}`; + this.styleManager.addStyleSheet(styleSheetSource, { + sourcePath: 'global-text-editor-styles', + priority: -1 + }); + } + + initialize(model, { config, project, styleManager, viewRegistry }) { + this.handleCenterEnter = this.handleCenterEnter.bind(this); + this.handleCenterLeave = this.handleCenterLeave.bind(this); + this.handleEdgesMouseMove = _.throttle( + this.handleEdgesMouseMove.bind(this), + 100 + ); + this.handleDockDragEnd = this.handleDockDragEnd.bind(this); + this.handleDragStart = this.handleDragStart.bind(this); + this.handleDragEnd = this.handleDragEnd.bind(this); + this.handleDrop = this.handleDrop.bind(this); + + this.model = model; + this.viewRegistry = viewRegistry; + this.project = project; + this.config = config; + this.styleManager = styleManager; + if (this.viewRegistry == null) { + throw new Error( + 'Must pass a viewRegistry parameter when initializing WorkspaceElements' + ); + } + if (this.project == null) { + throw new Error( + 'Must pass a project parameter when initializing WorkspaceElements' + ); + } + if (this.config == null) { + throw new Error( + 'Must pass a config parameter when initializing WorkspaceElements' + ); + } + if (this.styleManager == null) { + throw new Error( + 'Must pass a styleManager parameter when initializing WorkspaceElements' + ); + } + + this.subscriptions = new CompositeDisposable( + new Disposable(() => { + this.paneContainer.removeEventListener( + 'mouseenter', + this.handleCenterEnter + ); + this.paneContainer.removeEventListener( + 'mouseleave', + this.handleCenterLeave + ); + window.removeEventListener('mousemove', this.handleEdgesMouseMove); + window.removeEventListener('dragend', this.handleDockDragEnd); + window.removeEventListener('dragstart', this.handleDragStart); + window.removeEventListener('dragend', this.handleDragEnd, true); + window.removeEventListener('drop', this.handleDrop, true); + }), + ...[ + this.model.getLeftDock(), + this.model.getRightDock(), + this.model.getBottomDock() + ].map(dock => + dock.onDidChangeHovered(hovered => { + if (hovered) this.hoveredDock = dock; + else if (dock === this.hoveredDock) this.hoveredDock = null; + this.checkCleanupDockHoverEvents(); + }) + ) + ); + this.initializeContent(); + this.observeScrollbarStyle(); + this.observeTextEditorFontConfig(); + + this.paneContainer = this.model.getCenter().paneContainer.getElement(); + this.verticalAxis.appendChild(this.paneContainer); + this.addEventListener('focus', this.handleFocus.bind(this)); + + this.addEventListener('mousewheel', this.handleMousewheel.bind(this), { + capture: true + }); + window.addEventListener('dragstart', this.handleDragStart); + window.addEventListener('mousemove', this.handleEdgesMouseMove); + + this.panelContainers = { + top: this.model.panelContainers.top.getElement(), + left: this.model.panelContainers.left.getElement(), + right: this.model.panelContainers.right.getElement(), + bottom: this.model.panelContainers.bottom.getElement(), + header: this.model.panelContainers.header.getElement(), + footer: this.model.panelContainers.footer.getElement(), + modal: this.model.panelContainers.modal.getElement() + }; + + this.horizontalAxis.insertBefore( + this.panelContainers.left, + this.verticalAxis + ); + this.horizontalAxis.appendChild(this.panelContainers.right); + + this.verticalAxis.insertBefore( + this.panelContainers.top, + this.paneContainer + ); + this.verticalAxis.appendChild(this.panelContainers.bottom); + + this.insertBefore(this.panelContainers.header, this.horizontalAxis); + this.appendChild(this.panelContainers.footer); + + this.appendChild(this.panelContainers.modal); + + this.paneContainer.addEventListener('mouseenter', this.handleCenterEnter); + this.paneContainer.addEventListener('mouseleave', this.handleCenterLeave); + + return this; + } + + destroy() { + this.subscriptions.dispose(); + } + + getModel() { + return this.model; + } + + handleDragStart(event) { + if (!isTab(event.target)) return; + const { item } = event.target; + if (!item) return; + this.model.setDraggingItem(item); + window.addEventListener('dragend', this.handleDragEnd, { capture: true }); + window.addEventListener('drop', this.handleDrop, { capture: true }); + } + + handleDragEnd(event) { + this.dragEnded(); + } + + handleDrop(event) { + this.dragEnded(); + } + + dragEnded() { + this.model.setDraggingItem(null); + window.removeEventListener('dragend', this.handleDragEnd, true); + window.removeEventListener('drop', this.handleDrop, true); + } + + handleCenterEnter(event) { + // Just re-entering the center isn't enough to hide the dock toggle buttons, since they poke + // into the center and we want to give an affordance. + this.cursorInCenter = true; + this.checkCleanupDockHoverEvents(); + } + + handleCenterLeave(event) { + // If the cursor leaves the center, we start listening to determine whether one of the docs is + // being hovered. + this.cursorInCenter = false; + this.updateHoveredDock({ x: event.pageX, y: event.pageY }); + window.addEventListener('dragend', this.handleDockDragEnd); + } + + handleEdgesMouseMove(event) { + this.updateHoveredDock({ x: event.pageX, y: event.pageY }); + } + + handleDockDragEnd(event) { + this.updateHoveredDock({ x: event.pageX, y: event.pageY }); + } + + updateHoveredDock(mousePosition) { + // If we haven't left the currently hovered dock, don't change anything. + if ( + this.hoveredDock && + this.hoveredDock.pointWithinHoverArea(mousePosition, true) + ) + return; + + const docks = [ + this.model.getLeftDock(), + this.model.getRightDock(), + this.model.getBottomDock() + ]; + const nextHoveredDock = docks.find( + dock => + dock !== this.hoveredDock && dock.pointWithinHoverArea(mousePosition) + ); + docks.forEach(dock => { + dock.setHovered(dock === nextHoveredDock); + }); + } + + checkCleanupDockHoverEvents() { + if (this.cursorInCenter && !this.hoveredDock) { + window.removeEventListener('dragend', this.handleDockDragEnd); + } + } + + handleMousewheel(event) { + if ( + event.ctrlKey && + this.config.get('editor.zoomFontWhenCtrlScrolling') && + event.target.closest('atom-text-editor') != null + ) { + if (event.wheelDeltaY > 0) { + this.model.increaseFontSize(); + } else if (event.wheelDeltaY < 0) { + this.model.decreaseFontSize(); + } + event.preventDefault(); + event.stopPropagation(); + } + } + + handleFocus(event) { + this.model.getActivePane().activate(); + } + + focusPaneViewAbove() { + this.focusPaneViewInDirection('above'); + } + + focusPaneViewBelow() { + this.focusPaneViewInDirection('below'); + } + + focusPaneViewOnLeft() { + this.focusPaneViewInDirection('left'); + } + + focusPaneViewOnRight() { + this.focusPaneViewInDirection('right'); + } + + focusPaneViewInDirection(direction, pane) { + const activePane = this.model.getActivePane(); + const paneToFocus = this.nearestVisiblePaneInDirection( + direction, + activePane + ); + paneToFocus && paneToFocus.focus(); + } + + moveActiveItemToPaneAbove(params) { + this.moveActiveItemToNearestPaneInDirection('above', params); + } + + moveActiveItemToPaneBelow(params) { + this.moveActiveItemToNearestPaneInDirection('below', params); + } + + moveActiveItemToPaneOnLeft(params) { + this.moveActiveItemToNearestPaneInDirection('left', params); + } + + moveActiveItemToPaneOnRight(params) { + this.moveActiveItemToNearestPaneInDirection('right', params); + } + + moveActiveItemToNearestPaneInDirection(direction, params) { + const activePane = this.model.getActivePane(); + const nearestPaneView = this.nearestVisiblePaneInDirection( + direction, + activePane + ); + if (nearestPaneView == null) { + return; + } + if (params && params.keepOriginal) { + activePane + .getContainer() + .copyActiveItemToPane(nearestPaneView.getModel()); + } else { + activePane + .getContainer() + .moveActiveItemToPane(nearestPaneView.getModel()); + } + nearestPaneView.focus(); + } + + nearestVisiblePaneInDirection(direction, pane) { + const distance = function(pointA, pointB) { + const x = pointB.x - pointA.x; + const y = pointB.y - pointA.y; + return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + }; + + const paneView = pane.getElement(); + const box = this.boundingBoxForPaneView(paneView); + + const paneViews = atom.workspace + .getVisiblePanes() + .map(otherPane => otherPane.getElement()) + .filter(otherPaneView => { + const otherBox = this.boundingBoxForPaneView(otherPaneView); + switch (direction) { + case 'left': + return otherBox.right.x <= box.left.x; + case 'right': + return otherBox.left.x >= box.right.x; + case 'above': + return otherBox.bottom.y <= box.top.y; + case 'below': + return otherBox.top.y >= box.bottom.y; + } + }) + .sort((paneViewA, paneViewB) => { + const boxA = this.boundingBoxForPaneView(paneViewA); + const boxB = this.boundingBoxForPaneView(paneViewB); + switch (direction) { + case 'left': + return ( + distance(box.left, boxA.right) - distance(box.left, boxB.right) + ); + case 'right': + return ( + distance(box.right, boxA.left) - distance(box.right, boxB.left) + ); + case 'above': + return ( + distance(box.top, boxA.bottom) - distance(box.top, boxB.bottom) + ); + case 'below': + return ( + distance(box.bottom, boxA.top) - distance(box.bottom, boxB.top) + ); + } + }); + + return paneViews[0]; + } + + boundingBoxForPaneView(paneView) { + const boundingBox = paneView.getBoundingClientRect(); + + return { + left: { x: boundingBox.left, y: boundingBox.top }, + right: { x: boundingBox.right, y: boundingBox.top }, + top: { x: boundingBox.left, y: boundingBox.top }, + bottom: { x: boundingBox.left, y: boundingBox.bottom } + }; + } + + runPackageSpecs(options = {}) { + const activePaneItem = this.model.getActivePaneItem(); + const activePath = + activePaneItem && typeof activePaneItem.getPath === 'function' + ? activePaneItem.getPath() + : null; + let projectPath; + if (activePath != null) { + [projectPath] = this.project.relativizePath(activePath); + } else { + [projectPath] = this.project.getPaths(); + } + if (projectPath) { + let specPath = path.join(projectPath, 'spec'); + const testPath = path.join(projectPath, 'test'); + if (!fs.existsSync(specPath) && fs.existsSync(testPath)) { + specPath = testPath; + } + + ipcRenderer.send('run-package-specs', specPath, options); + } + } + + runBenchmarks() { + const activePaneItem = this.model.getActivePaneItem(); + const activePath = + activePaneItem && typeof activePaneItem.getPath === 'function' + ? activePaneItem.getPath() + : null; + let projectPath; + if (activePath) { + [projectPath] = this.project.relativizePath(activePath); + } else { + [projectPath] = this.project.getPaths(); + } + + if (projectPath) { + ipcRenderer.send('run-benchmarks', path.join(projectPath, 'benchmarks')); + } + } +} + +function isTab(element) { + let el = element; + while (el != null) { + if (el.getAttribute && el.getAttribute('is') === 'tabs-tab') return true; + el = el.parentElement; + } + return false; +} + +window.customElements.define('atom-workspace', WorkspaceElement); + +function createWorkspaceElement() { + return document.createElement('atom-workspace'); +} + +module.exports = { + createWorkspaceElement +}; diff --git a/src/workspace-view.coffee b/src/workspace-view.coffee deleted file mode 100644 index 6fe9f6848bd..00000000000 --- a/src/workspace-view.coffee +++ /dev/null @@ -1,340 +0,0 @@ -ipc = require 'ipc' -path = require 'path' -Q = require 'q' -_ = require 'underscore-plus' -Delegator = require 'delegato' -{deprecate, logDeprecationWarnings} = require 'grim' -{$, $$, View} = require './space-pen-extensions' -fs = require 'fs-plus' -Workspace = require './workspace' -PaneView = require './pane-view' -PaneContainerView = require './pane-container-view' -TextEditor = require './text-editor' - -# Deprecated: The top-level view for the entire window. An instance of this class is -# available via the `atom.workspaceView` global. -# -# It is backed by a model object, an instance of {Workspace}, which is available -# via the `atom.workspace` global or {::getModel}. You should prefer to interact -# with the model object when possible, but it won't always be possible with the -# current API. -# -# ## Adding Perimeter Panels -# -# Use the following methods if possible to attach panels to the perimeter of the -# workspace rather than manipulating the DOM directly to better insulate you to -# changes in the workspace markup: -# -# * {::prependToTop} -# * {::appendToTop} -# * {::prependToBottom} -# * {::appendToBottom} -# * {::prependToLeft} -# * {::appendToLeft} -# * {::prependToRight} -# * {::appendToRight} -# -# ## Requiring in package specs -# -# If you need a `WorkspaceView` instance to test your package, require it via -# the built-in `atom` module. -# -# ```coffee -# {WorkspaceView} = require 'atom' -# ``` -# -# You can assign it to the `atom.workspaceView` global in the spec or just use -# it as a local, depending on what you're trying to accomplish. Building the -# `WorkspaceView` is currently expensive, so you should try build a {Workspace} -# instead if possible. -module.exports = -class WorkspaceView extends View - Delegator.includeInto(this) - - @delegatesProperty 'fullScreen', 'destroyedItemURIs', toProperty: 'model' - @delegatesMethods 'open', 'openSync', - 'saveActivePaneItem', 'saveActivePaneItemAs', 'saveAll', 'destroyActivePaneItem', - 'destroyActivePane', 'increaseFontSize', 'decreaseFontSize', toProperty: 'model' - - constructor: (@element) -> - unless @element? - return atom.views.getView(atom.workspace).__spacePenView - super - @deprecateViewEvents() - @attachedEditorViews = new WeakSet - - setModel: (@model) -> - @horizontal = @find('atom-workspace-axis.horizontal') - @vertical = @find('atom-workspace-axis.vertical') - @panes = @find('atom-pane-container').view() - @subscribe @model.onDidOpen => @trigger 'uri-opened' - - beforeRemove: -> - @model?.destroy() - - ### - Section: Accessing the Workspace Model - ### - - # Essential: Get the underlying model object. - # - # Returns a {Workspace}. - getModel: -> @model - - ### - Section: Accessing Views - ### - - # Essential: Register a function to be called for every current and future - # editor view in the workspace (only includes {TextEditorView}s that are pane - # items). - # - # * `callback` A {Function} with an {TextEditorView} as its only argument. - # * `editorView` {TextEditorView} - # - # Returns a subscription object with an `.off` method that you can call to - # unregister the callback. - eachEditorView: (callback) -> - for editorView in @getEditorViews() - @attachedEditorViews.add(editorView) - callback(editorView) - - attachedCallback = (e, editorView) => - unless @attachedEditorViews.has(editorView) - @attachedEditorViews.add(editorView) - callback(editorView) unless editorView.mini - - @on('editor:attached', attachedCallback) - - off: => @off('editor:attached', attachedCallback) - - # Essential: Register a function to be called for every current and future - # pane view in the workspace. - # - # * `callback` A {Function} with a {PaneView} as its only argument. - # * `paneView` {PaneView} - # - # Returns a subscription object with an `.off` method that you can call to - # unregister the callback. - eachPaneView: (callback) -> - @panes.eachPaneView(callback) - - # Essential: Get all existing pane views. - # - # Prefer {Workspace::getPanes} if you don't need access to the view objects. - # Also consider using {::eachPaneView} if you want to register a callback for - # all current and *future* pane views. - # - # Returns an Array of all open {PaneView}s. - getPaneViews: -> - @panes.getPaneViews() - - # Essential: Get the active pane view. - # - # Prefer {Workspace::getActivePane} if you don't actually need access to the - # view. - # - # Returns a {PaneView}. - getActivePaneView: -> - @panes.getActivePaneView() - - # Essential: Get the view associated with the active pane item. - # - # Returns a view. - getActiveView: -> - @panes.getActiveView() - - ### - Section: Adding elements to the workspace - ### - - prependToTop: (element) -> - deprecate 'Please use Workspace::addTopPanel() instead' - @vertical.prepend(element) - - appendToTop: (element) -> - deprecate 'Please use Workspace::addTopPanel() instead' - @panes.before(element) - - prependToBottom: (element) -> - deprecate 'Please use Workspace::addBottomPanel() instead' - @panes.after(element) - - appendToBottom: (element) -> - deprecate 'Please use Workspace::addBottomPanel() instead' - @vertical.append(element) - - prependToLeft: (element) -> - deprecate 'Please use Workspace::addLeftPanel() instead' - @horizontal.prepend(element) - - appendToLeft: (element) -> - deprecate 'Please use Workspace::addLeftPanel() instead' - @vertical.before(element) - - prependToRight: (element) -> - deprecate 'Please use Workspace::addRightPanel() instead' - @vertical.after(element) - - appendToRight: (element) -> - deprecate 'Please use Workspace::addRightPanel() instead' - @horizontal.append(element) - - ### - Section: Focusing pane views - ### - - # Focus the previous pane by id. - focusPreviousPaneView: -> @model.activatePreviousPane() - - # Focus the next pane by id. - focusNextPaneView: -> @model.activateNextPane() - - # Focus the pane directly above the active pane. - focusPaneViewAbove: -> @panes.focusPaneViewAbove() - - # Focus the pane directly below the active pane. - focusPaneViewBelow: -> @panes.focusPaneViewBelow() - - # Focus the pane directly to the left of the active pane. - focusPaneViewOnLeft: -> @panes.focusPaneViewOnLeft() - - # Focus the pane directly to the right of the active pane. - focusPaneViewOnRight: -> @panes.focusPaneViewOnRight() - - ### - Section: Private - ### - - # Prompts to save all unsaved items - confirmClose: -> - @model.confirmClose() - - # Get all editor views. - # - # You should prefer {Workspace::getEditors} unless you absolutely need access - # to the view objects. Also consider using {::eachEditorView}, which will call - # a callback for all current and *future* editor views. - # - # Returns an {Array} of {TextEditorView}s. - getEditorViews: -> - for editorElement in @panes.element.querySelectorAll('atom-pane > .item-views > atom-text-editor') - $(editorElement).view() - - ### - Section: Deprecated - ### - - deprecateViewEvents: -> - originalWorkspaceViewOn = @on - - @on = (eventName) => - switch eventName - when 'beep' - deprecate('Use Atom::onDidBeep instead') - when 'cursor:moved' - deprecate('Use TextEditor::onDidChangeCursorPosition instead') - when 'editor:attached' - deprecate('Use Workspace::onDidAddTextEditor instead') - when 'editor:detached' - deprecate('Use TextEditor::onDidDestroy instead') - when 'editor:will-be-removed' - deprecate('Use TextEditor::onDidDestroy instead') - when 'pane:active-item-changed' - deprecate('Use Pane::onDidChangeActiveItem instead') - when 'pane:active-item-modified-status-changed' - deprecate('Use Pane::onDidChangeActiveItem and call onDidChangeModified on the active item instead') - when 'pane:active-item-title-changed' - deprecate('Use Pane::onDidChangeActiveItem and call onDidChangeTitle on the active item instead') - when 'pane:attached' - deprecate('Use Workspace::onDidAddPane instead') - when 'pane:became-active' - deprecate('Use Pane::onDidActivate instead') - when 'pane:became-inactive' - deprecate('Use Pane::onDidChangeActive instead') - when 'pane:item-added' - deprecate('Use Pane::onDidAddItem instead') - when 'pane:item-moved' - deprecate('Use Pane::onDidMoveItem instead') - when 'pane:item-removed' - deprecate('Use Pane::onDidRemoveItem instead') - when 'pane:removed' - deprecate('Use Pane::onDidDestroy instead') - when 'pane-container:active-pane-item-changed' - deprecate('Use Workspace::onDidChangeActivePaneItem instead') - when 'selection:changed' - deprecate('Use TextEditor::onDidChangeSelectionRange instead') - when 'uri-opened' - deprecate('Use Workspace::onDidOpen instead') - originalWorkspaceViewOn.apply(this, arguments) - - TextEditorView = require './text-editor-view' - originalEditorViewOn = TextEditorView::on - TextEditorView::on = (eventName) -> - switch eventName - when 'cursor:moved' - deprecate('Use TextEditor::onDidChangeCursorPosition instead') - when 'editor:attached' - deprecate('Use TextEditor::onDidAddTextEditor instead') - when 'editor:detached' - deprecate('Use TextEditor::onDidDestroy instead') - when 'editor:will-be-removed' - deprecate('Use TextEditor::onDidDestroy instead') - when 'selection:changed' - deprecate('Use TextEditor::onDidChangeSelectionRange instead') - originalEditorViewOn.apply(this, arguments) - - originalPaneViewOn = PaneView::on - PaneView::on = (eventName) -> - switch eventName - when 'cursor:moved' - deprecate('Use TextEditor::onDidChangeCursorPosition instead') - when 'editor:attached' - deprecate('Use TextEditor::onDidAddTextEditor instead') - when 'editor:detached' - deprecate('Use TextEditor::onDidDestroy instead') - when 'editor:will-be-removed' - deprecate('Use TextEditor::onDidDestroy instead') - when 'pane:active-item-changed' - deprecate('Use Pane::onDidChangeActiveItem instead') - when 'pane:active-item-modified-status-changed' - deprecate('Use Pane::onDidChangeActiveItem and call onDidChangeModified on the active item instead') - when 'pane:active-item-title-changed' - deprecate('Use Pane::onDidChangeActiveItem and call onDidChangeTitle on the active item instead') - when 'pane:attached' - deprecate('Use Workspace::onDidAddPane instead') - when 'pane:became-active' - deprecate('Use Pane::onDidActivate instead') - when 'pane:became-inactive' - deprecate('Use Pane::onDidChangeActive instead') - when 'pane:item-added' - deprecate('Use Pane::onDidAddItem instead') - when 'pane:item-moved' - deprecate('Use Pane::onDidMoveItem instead') - when 'pane:item-removed' - deprecate('Use Pane::onDidRemoveItem instead') - when 'pane:removed' - deprecate('Use Pane::onDidDestroy instead') - when 'selection:changed' - deprecate('Use TextEditor::onDidChangeSelectionRange instead') - originalPaneViewOn.apply(this, arguments) - - # Deprecated - eachPane: (callback) -> - deprecate("Use WorkspaceView::eachPaneView instead") - @eachPaneView(callback) - - # Deprecated - getPanes: -> - deprecate("Use WorkspaceView::getPaneViews instead") - @getPaneViews() - - # Deprecated - getActivePane: -> - deprecate("Use WorkspaceView::getActivePaneView instead") - @getActivePaneView() - - # Deprecated: Call {Workspace::getActivePaneItem} instead. - getActivePaneItem: -> - deprecate("Use Workspace::getActivePaneItem instead") - @model.getActivePaneItem() diff --git a/src/workspace.coffee b/src/workspace.coffee deleted file mode 100644 index 0e6fb7e406b..00000000000 --- a/src/workspace.coffee +++ /dev/null @@ -1,970 +0,0 @@ -{includeDeprecatedAPIs, deprecate} = require 'grim' -_ = require 'underscore-plus' -path = require 'path' -{join} = path -Q = require 'q' -Serializable = require 'serializable' -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -Grim = require 'grim' -fs = require 'fs-plus' -Model = require './model' -TextEditor = require './text-editor' -PaneContainer = require './pane-container' -Pane = require './pane' -Panel = require './panel' -PanelElement = require './panel-element' -PanelContainer = require './panel-container' -PanelContainerElement = require './panel-container-element' -WorkspaceElement = require './workspace-element' -Task = require './task' - -# Essential: Represents the state of the user interface for the entire window. -# An instance of this class is available via the `atom.workspace` global. -# -# Interact with this object to open files, be notified of current and future -# editors, and manipulate panes. To add panels, you'll need to use the -# {WorkspaceView} class for now until we establish APIs at the model layer. -# -# * `editor` {TextEditor} the new editor -# -module.exports = -class Workspace extends Model - atom.deserializers.add(this) - Serializable.includeInto(this) - - constructor: (params) -> - super - - unless Grim.includeDeprecatedAPIs - @paneContainer = params?.paneContainer - @fullScreen = params?.fullScreen ? false - @destroyedItemURIs = params?.destroyedItemURIs ? [] - - @emitter = new Emitter - @openers = [] - - @paneContainer ?= new PaneContainer() - @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) - - @panelContainers = - top: new PanelContainer({location: 'top'}) - left: new PanelContainer({location: 'left'}) - right: new PanelContainer({location: 'right'}) - bottom: new PanelContainer({location: 'bottom'}) - modal: new PanelContainer({location: 'modal'}) - - @subscribeToActiveItem() - - @addOpener (filePath) -> - switch filePath - when 'atom://.atom/stylesheet' - atom.project.open(atom.styles.getUserStyleSheetPath()) - when 'atom://.atom/keymap' - atom.project.open(atom.keymaps.getUserKeymapPath()) - when 'atom://.atom/config' - atom.project.open(atom.config.getUserConfigPath()) - when 'atom://.atom/init-script' - atom.project.open(atom.getUserInitScriptPath()) - - atom.views.addViewProvider Workspace, (model) -> - new WorkspaceElement().initialize(model) - - atom.views.addViewProvider PanelContainer, (model) -> - new PanelContainerElement().initialize(model) - - atom.views.addViewProvider Panel, (model) -> - new PanelElement().initialize(model) - - @subscribeToFontSize() - - # Called by the Serializable mixin during deserialization - deserializeParams: (params) -> - for packageName in params.packagesWithActiveGrammars ? [] - atom.packages.getLoadedPackage(packageName)?.loadGrammarsSync() - - params.paneContainer = PaneContainer.deserialize(params.paneContainer) - params - - # Called by the Serializable mixin during serialization. - serializeParams: -> - paneContainer: @paneContainer.serialize() - fullScreen: atom.isFullScreen() - packagesWithActiveGrammars: @getPackageNamesWithActiveGrammars() - - getPackageNamesWithActiveGrammars: -> - packageNames = [] - addGrammar = ({includedGrammarScopes, packageName}={}) -> - return unless packageName - # Prevent cycles - return if packageNames.indexOf(packageName) isnt -1 - - packageNames.push(packageName) - for scopeName in includedGrammarScopes ? [] - addGrammar(atom.grammars.grammarForScopeName(scopeName)) - return - - editors = @getTextEditors() - addGrammar(editor.getGrammar()) for editor in editors - - if editors.length > 0 - for grammar in atom.grammars.getGrammars() when grammar.injectionSelector - addGrammar(grammar) - - _.uniq(packageNames) - - editorAdded: (editor) -> - @emit 'editor-created', editor if includeDeprecatedAPIs - - installShellCommands: -> - require('./command-installer').installShellCommandsInteractively() - - subscribeToActiveItem: -> - @updateWindowTitle() - @updateDocumentEdited() - atom.project.onDidChangePaths @updateWindowTitle - - @observeActivePaneItem (item) => - @updateWindowTitle() - @updateDocumentEdited() - - @activeItemSubscriptions?.dispose() - @activeItemSubscriptions = new CompositeDisposable - - if typeof item?.onDidChangeTitle is 'function' - titleSubscription = item.onDidChangeTitle(@updateWindowTitle) - else if typeof item?.on is 'function' - titleSubscription = item.on('title-changed', @updateWindowTitle) - unless typeof titleSubscription?.dispose is 'function' - titleSubscription = new Disposable => item.off('title-changed', @updateWindowTitle) - - if typeof item?.onDidChangeModified is 'function' - modifiedSubscription = item.onDidChangeModified(@updateDocumentEdited) - else if typeof item?.on? is 'function' - modifiedSubscription = item.on('modified-status-changed', @updateDocumentEdited) - unless typeof modifiedSubscription?.dispose is 'function' - modifiedSubscription = new Disposable => item.off('modified-status-changed', @updateDocumentEdited) - - @activeItemSubscriptions.add(titleSubscription) if titleSubscription? - @activeItemSubscriptions.add(modifiedSubscription) if modifiedSubscription? - - # Updates the application's title and proxy icon based on whichever file is - # open. - updateWindowTitle: => - appName = 'Atom' - projectPaths = atom.project?.getPaths() ? [] - if item = @getActivePaneItem() - itemPath = item.getPath?() - itemTitle = item.getTitle?() - projectPath = _.find projectPaths, (projectPath) -> - itemPath is projectPath or itemPath?.startsWith(projectPath + path.sep) - itemTitle ?= "untitled" - projectPath ?= projectPaths[0] - - if item? and projectPath? - document.title = "#{itemTitle} - #{projectPath} - #{appName}" - atom.setRepresentedFilename(itemPath ? projectPath) - else if projectPath? - document.title = "#{projectPath} - #{appName}" - atom.setRepresentedFilename(projectPath) - else - document.title = "#{itemTitle} - #{appName}" - atom.setRepresentedFilename("") - - # On OS X, fades the application window's proxy icon when the current file - # has been modified. - updateDocumentEdited: => - modified = @getActivePaneItem()?.isModified?() ? false - atom.setDocumentEdited(modified) - - ### - Section: Event Subscription - ### - - # Essential: Invoke the given callback with all current and future text - # editors in the workspace. - # - # * `callback` {Function} to be called with current and future text editors. - # * `editor` An {TextEditor} that is present in {::getTextEditors} at the time - # of subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeTextEditors: (callback) -> - callback(textEditor) for textEditor in @getTextEditors() - @onDidAddTextEditor ({textEditor}) -> callback(textEditor) - - # Essential: Invoke the given callback with all current and future panes items - # in the workspace. - # - # * `callback` {Function} to be called with current and future pane items. - # * `item` An item that is present in {::getPaneItems} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observePaneItems: (callback) -> @paneContainer.observePaneItems(callback) - - # Essential: Invoke the given callback when the active pane item changes. - # - # * `callback` {Function} to be called when the active pane item changes. - # * `item` The active pane item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActivePaneItem: (callback) -> @paneContainer.onDidChangeActivePaneItem(callback) - - # Essential: Invoke the given callback with the current active pane item and - # with all future active pane items in the workspace. - # - # * `callback` {Function} to be called when the active pane item changes. - # * `item` The current active pane item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActivePaneItem: (callback) -> @paneContainer.observeActivePaneItem(callback) - - # Essential: Invoke the given callback whenever an item is opened. Unlike - # {::onDidAddPaneItem}, observers will be notified for items that are already - # present in the workspace when they are reopened. - # - # * `callback` {Function} to be called whenever an item is opened. - # * `event` {Object} with the following keys: - # * `uri` {String} representing the opened URI. Could be `undefined`. - # * `item` The opened item. - # * `pane` The pane in which the item was opened. - # * `index` The index of the opened item on its pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidOpen: (callback) -> - @emitter.on 'did-open', callback - - # Extended: Invoke the given callback when a pane is added to the workspace. - # - # * `callback` {Function} to be called panes are added. - # * `event` {Object} with the following keys: - # * `pane` The added pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddPane: (callback) -> @paneContainer.onDidAddPane(callback) - - # Extended: Invoke the given callback when a pane is destroyed in the - # workspace. - # - # * `callback` {Function} to be called panes are destroyed. - # * `event` {Object} with the following keys: - # * `pane` The destroyed pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroyPane: (callback) -> @paneContainer.onDidDestroyPane(callback) - - # Extended: Invoke the given callback with all current and future panes in the - # workspace. - # - # * `callback` {Function} to be called with current and future panes. - # * `pane` A {Pane} that is present in {::getPanes} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observePanes: (callback) -> @paneContainer.observePanes(callback) - - # Extended: Invoke the given callback when the active pane changes. - # - # * `callback` {Function} to be called when the active pane changes. - # * `pane` A {Pane} that is the current return value of {::getActivePane}. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActivePane: (callback) -> @paneContainer.onDidChangeActivePane(callback) - - # Extended: Invoke the given callback with the current active pane and when - # the active pane changes. - # - # * `callback` {Function} to be called with the current and future active# - # panes. - # * `pane` A {Pane} that is the current return value of {::getActivePane}. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActivePane: (callback) -> @paneContainer.observeActivePane(callback) - - # Extended: Invoke the given callback when a pane item is added to the - # workspace. - # - # * `callback` {Function} to be called when pane items are added. - # * `event` {Object} with the following keys: - # * `item` The added pane item. - # * `pane` {Pane} containing the added item. - # * `index` {Number} indicating the index of the added item in its pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddPaneItem: (callback) -> @paneContainer.onDidAddPaneItem(callback) - - # Extended: Invoke the given callback when a pane item is about to be - # destroyed, before the user is prompted to save it. - # - # * `callback` {Function} to be called before pane items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The item to be destroyed. - # * `pane` {Pane} containing the item to be destroyed. - # * `index` {Number} indicating the index of the item to be destroyed in - # its pane. - # - # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onWillDestroyPaneItem: (callback) -> @paneContainer.onWillDestroyPaneItem(callback) - - # Extended: Invoke the given callback when a pane item is destroyed. - # - # * `callback` {Function} to be called when pane items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The destroyed item. - # * `pane` {Pane} containing the destroyed item. - # * `index` {Number} indicating the index of the destroyed item in its - # pane. - # - # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onDidDestroyPaneItem: (callback) -> @paneContainer.onDidDestroyPaneItem(callback) - - # Extended: Invoke the given callback when a text editor is added to the - # workspace. - # - # * `callback` {Function} to be called panes are added. - # * `event` {Object} with the following keys: - # * `textEditor` {TextEditor} that was added. - # * `pane` {Pane} containing the added text editor. - # * `index` {Number} indicating the index of the added text editor in its - # pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddTextEditor: (callback) -> - @onDidAddPaneItem ({item, pane, index}) -> - callback({textEditor: item, pane, index}) if item instanceof TextEditor - - ### - Section: Opening - ### - - # Essential: Opens the given URI in Atom asynchronously. - # If the URI is already open, the existing item for that URI will be - # activated. If no URI is given, or no registered opener can open - # the URI, a new empty {TextEditor} will be created. - # - # * `uri` (optional) A {String} containing a URI. - # * `options` (optional) {Object} - # * `initialLine` A {Number} indicating which row to move the cursor to - # initially. Defaults to `0`. - # * `initialColumn` A {Number} indicating which column to move the cursor to - # initially. Defaults to `0`. - # * `split` Either 'left' or 'right'. If 'left', the item will be opened in - # leftmost pane of the current active pane's row. If 'right', the - # item will be opened in the rightmost pane of the current active pane's row. - # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on - # containing pane. Defaults to `true`. - # * `searchAllPanes` A {Boolean}. If `true`, the workspace will attempt to - # activate an existing item for the given URI on any pane. - # If `false`, only the active pane will be searched for - # an existing item for the same URI. Defaults to `false`. - # - # Returns a promise that resolves to the {TextEditor} for the file URI. - open: (uri, options={}) -> - searchAllPanes = options.searchAllPanes - split = options.split - uri = atom.project.resolvePath(uri) - - pane = @paneContainer.paneForURI(uri) if searchAllPanes - pane ?= switch split - when 'left' - @getActivePane().findLeftmostSibling() - when 'right' - @getActivePane().findOrCreateRightmostSibling() - else - @getActivePane() - - @openURIInPane(uri, pane, options) - - # Open Atom's license in the active pane. - openLicense: -> - @open(join(atom.getLoadSettings().resourcePath, 'LICENSE.md')) - - # Synchronously open the given URI in the active pane. **Only use this method - # in specs. Calling this in production code will block the UI thread and - # everyone will be mad at you.** - # - # * `uri` A {String} containing a URI. - # * `options` An optional options {Object} - # * `initialLine` A {Number} indicating which row to move the cursor to - # initially. Defaults to `0`. - # * `initialColumn` A {Number} indicating which column to move the cursor to - # initially. Defaults to `0`. - # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on - # the containing pane. Defaults to `true`. - openSync: (uri='', options={}) -> - # TODO: Remove deprecated changeFocus option - if includeDeprecatedAPIs and options.changeFocus? - deprecate("The `changeFocus` option has been renamed to `activatePane`") - options.activatePane = options.changeFocus - delete options.changeFocus - - {initialLine, initialColumn} = options - activatePane = options.activatePane ? true - - uri = atom.project.resolvePath(uri) - item = @getActivePane().itemForURI(uri) - if uri - item ?= opener(uri, options) for opener in @getOpeners() when not item - item ?= atom.project.openSync(uri, {initialLine, initialColumn}) - - @getActivePane().activateItem(item) - @itemOpened(item) - @getActivePane().activate() if activatePane - item - - openURIInPane: (uri, pane, options={}) -> - # TODO: Remove deprecated changeFocus option - if includeDeprecatedAPIs and options.changeFocus? - deprecate("The `changeFocus` option has been renamed to `activatePane`") - options.activatePane = options.changeFocus - delete options.changeFocus - - activatePane = options.activatePane ? true - - if uri? - item = pane.itemForURI(uri) - item ?= opener(uri, options) for opener in @getOpeners() when not item - - try - item ?= atom.project.open(uri, options) - catch error - switch error.code - when 'EFILETOOLARGE' - atom.notifications.addWarning("#{error.message} Large file support is being tracked at [atom/atom#307](https://github.com/atom/atom/issues/307).") - when 'EACCES' - atom.notifications.addWarning("Permission denied '#{error.path}'") - when 'EPERM', 'EBUSY' - atom.notifications.addWarning("Unable to open '#{error.path}'", detail: error.message) - else - throw error - return Q() - - Q(item) - .then (item) => - if not pane - pane = new Pane(items: [item]) - @paneContainer.root = pane - @itemOpened(item) - pane.activateItem(item) - pane.activate() if activatePane - if options.initialLine? or options.initialColumn? - item.setCursorBufferPosition?([options.initialLine, options.initialColumn]) - index = pane.getActiveItemIndex() - @emit "uri-opened" if includeDeprecatedAPIs - @emitter.emit 'did-open', {uri, pane, item, index} - item - - # Public: Asynchronously reopens the last-closed item's URI if it hasn't already been - # reopened. - # - # Returns a promise that is resolved when the item is opened - reopenItem: -> - if uri = @destroyedItemURIs.pop() - @open(uri) - else - Q() - - # Public: Register an opener for a uri. - # - # An {TextEditor} will be used if no openers return a value. - # - # ## Examples - # - # ```coffee - # atom.workspace.addOpener (uri) -> - # if path.extname(uri) is '.toml' - # return new TomlEditor(uri) - # ``` - # - # * `opener` A {Function} to be called when a path is being opened. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # opener. - addOpener: (opener) -> - if includeDeprecatedAPIs - packageName = @getCallingPackageName() - - wrappedOpener = (uri, options) -> - item = opener(uri, options) - if item? and typeof item.getUri is 'function' and typeof item.getURI isnt 'function' - Grim.deprecate("Pane item with class `#{item.constructor.name}` should implement `::getURI` instead of `::getUri`.", {packageName}) - if item? and typeof item.on is 'function' and typeof item.onDidChangeTitle isnt 'function' - Grim.deprecate("If you would like your pane item with class `#{item.constructor.name}` to support title change behavior, please implement a `::onDidChangeTitle()` method. `::on` methods for items are no longer supported. If not, ignore this message.", {packageName}) - if item? and typeof item.on is 'function' and typeof item.onDidChangeModified isnt 'function' - Grim.deprecate("If you would like your pane item with class `#{item.constructor.name}` to support modified behavior, please implement a `::onDidChangeModified()` method. If not, ignore this message. `::on` methods for items are no longer supported.", {packageName}) - item - - @openers.push(wrappedOpener) - new Disposable => _.remove(@openers, wrappedOpener) - else - @openers.push(opener) - new Disposable => _.remove(@openers, opener) - - getOpeners: -> - @openers - - ### - Section: Pane Items - ### - - # Essential: Get all pane items in the workspace. - # - # Returns an {Array} of items. - getPaneItems: -> - @paneContainer.getPaneItems() - - # Essential: Get the active {Pane}'s active item. - # - # Returns an pane item {Object}. - getActivePaneItem: -> - @paneContainer.getActivePaneItem() - - # Essential: Get all text editors in the workspace. - # - # Returns an {Array} of {TextEditor}s. - getTextEditors: -> - @getPaneItems().filter (item) -> item instanceof TextEditor - - # Essential: Get the active item if it is an {TextEditor}. - # - # Returns an {TextEditor} or `undefined` if the current active item is not an - # {TextEditor}. - getActiveTextEditor: -> - activeItem = @getActivePaneItem() - activeItem if activeItem instanceof TextEditor - - # Save all pane items. - saveAll: -> - @paneContainer.saveAll() - - confirmClose: (options) -> - @paneContainer.confirmClose(options) - - # Save the active pane item. - # - # If the active pane item currently has a URI according to the item's - # `.getURI` method, calls `.save` on the item. Otherwise - # {::saveActivePaneItemAs} # will be called instead. This method does nothing - # if the active item does not implement a `.save` method. - saveActivePaneItem: -> - @getActivePane().saveActiveItem() - - # Prompt the user for a path and save the active pane item to it. - # - # Opens a native dialog where the user selects a path on disk, then calls - # `.saveAs` on the item with the selected path. This method does nothing if - # the active item does not implement a `.saveAs` method. - saveActivePaneItemAs: -> - @getActivePane().saveActiveItemAs() - - # Destroy (close) the active pane item. - # - # Removes the active pane item and calls the `.destroy` method on it if one is - # defined. - destroyActivePaneItem: -> - @getActivePane().destroyActiveItem() - - ### - Section: Panes - ### - - # Extended: Get all panes in the workspace. - # - # Returns an {Array} of {Pane}s. - getPanes: -> - @paneContainer.getPanes() - - # Extended: Get the active {Pane}. - # - # Returns a {Pane}. - getActivePane: -> - @paneContainer.getActivePane() - - # Extended: Make the next pane active. - activateNextPane: -> - @paneContainer.activateNextPane() - - # Extended: Make the previous pane active. - activatePreviousPane: -> - @paneContainer.activatePreviousPane() - - # Extended: Get the first {Pane} with an item for the given URI. - # - # * `uri` {String} uri - # - # Returns a {Pane} or `undefined` if no pane exists for the given URI. - paneForURI: (uri) -> - @paneContainer.paneForURI(uri) - - # Extended: Get the {Pane} containing the given item. - # - # * `item` Item the returned pane contains. - # - # Returns a {Pane} or `undefined` if no pane exists for the given item. - paneForItem: (item) -> - @paneContainer.paneForItem(item) - - # Destroy (close) the active pane. - destroyActivePane: -> - @getActivePane()?.destroy() - - # Destroy the active pane item or the active pane if it is empty. - destroyActivePaneItemOrEmptyPane: -> - if @getActivePaneItem()? then @destroyActivePaneItem() else @destroyActivePane() - - # Increase the editor font size by 1px. - increaseFontSize: -> - atom.config.set("editor.fontSize", atom.config.get("editor.fontSize") + 1) - - # Decrease the editor font size by 1px. - decreaseFontSize: -> - fontSize = atom.config.get("editor.fontSize") - atom.config.set("editor.fontSize", fontSize - 1) if fontSize > 1 - - # Restore to the window's original editor font size. - resetFontSize: -> - if @originalFontSize - atom.config.set("editor.fontSize", @originalFontSize) - - subscribeToFontSize: -> - atom.config.onDidChange 'editor.fontSize', ({oldValue}) => - @originalFontSize ?= oldValue - - # Removes the item's uri from the list of potential items to reopen. - itemOpened: (item) -> - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - - if uri? - _.remove(@destroyedItemURIs, uri) - - # Adds the destroyed item's uri to the list of items to reopen. - didDestroyPaneItem: ({item}) => - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - - if uri? - @destroyedItemURIs.push(uri) - - # Called by Model superclass when destroyed - destroyed: -> - @paneContainer.destroy() - @activeItemSubscriptions?.dispose() - - - ### - Section: Panels - - Panels are used to display UI related to an editor window. They are placed at one of the four - edges of the window: left, right, top or bottom. If there are multiple panels on the same window - edge they are stacked in order of priority: higher priority is closer to the center, lower - priority towards the edge. - - *Note:* If your panel changes its size throughout its lifetime, consider giving it a higher - priority, allowing fixed size panels to be closer to the edge. This allows control targets to - remain more static for easier targeting by users that employ mice or trackpads. (See - [atom/atom#4834](https://github.com/atom/atom/issues/4834) for discussion.) - ### - - # Essential: Get an {Array} of all the panel items at the bottom of the editor window. - getBottomPanels: -> - @getPanels('bottom') - - # Essential: Adds a panel item to the bottom of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addBottomPanel: (options) -> - @addPanel('bottom', options) - - # Essential: Get an {Array} of all the panel items to the left of the editor window. - getLeftPanels: -> - @getPanels('left') - - # Essential: Adds a panel item to the left of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addLeftPanel: (options) -> - @addPanel('left', options) - - # Essential: Get an {Array} of all the panel items to the right of the editor window. - getRightPanels: -> - @getPanels('right') - - # Essential: Adds a panel item to the right of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addRightPanel: (options) -> - @addPanel('right', options) - - # Essential: Get an {Array} of all the panel items at the top of the editor window. - getTopPanels: -> - @getPanels('top') - - # Essential: Adds a panel item to the top of the editor window above the tabs. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addTopPanel: (options) -> - @addPanel('top', options) - - # Essential: Get an {Array} of all the modal panel items - getModalPanels: -> - @getPanels('modal') - - # Essential: Adds a panel item as a modal dialog. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addModalPanel: (options={}) -> - @addPanel('modal', options) - - # Essential: Returns the {Panel} associated with the given item. Returns - # `null` when the item has no panel. - # - # * `item` Item the panel contains - panelForItem: (item) -> - for location, container of @panelContainers - panel = container.panelForItem(item) - return panel if panel? - null - - getPanels: (location) -> - @panelContainers[location].getPanels() - - addPanel: (location, options) -> - options ?= {} - @panelContainers[location].addPanel(new Panel(options)) - - ### - Section: Searching and Replacing - ### - - # Public: Performs a search across all the files in the workspace. - # - # * `regex` {RegExp} to search with. - # * `options` (optional) {Object} (default: {}) - # * `paths` An {Array} of glob patterns to search within - # * `iterator` {Function} callback on each file found - # - # Returns a `Promise`. - scan: (regex, options={}, iterator) -> - if _.isFunction(options) - iterator = options - options = {} - - deferred = Q.defer() - - searchOptions = - ignoreCase: regex.ignoreCase - inclusions: options.paths - includeHidden: true - excludeVcsIgnores: atom.config.get('core.excludeVcsIgnoredPaths') - exclusions: atom.config.get('core.ignoredNames') - follow: atom.config.get('core.followSymlinks') - - task = Task.once require.resolve('./scan-handler'), atom.project.getPaths(), regex.source, searchOptions, -> - deferred.resolve() - - task.on 'scan:result-found', (result) -> - iterator(result) unless atom.project.isPathModified(result.filePath) - - task.on 'scan:file-error', (error) -> - iterator(null, error) - - if _.isFunction(options.onPathsSearched) - task.on 'scan:paths-searched', (numberOfPathsSearched) -> - options.onPathsSearched(numberOfPathsSearched) - - for buffer in atom.project.getBuffers() when buffer.isModified() - filePath = buffer.getPath() - continue unless atom.project.contains(filePath) - matches = [] - buffer.scan regex, (match) -> matches.push match - iterator {filePath, matches} if matches.length > 0 - - promise = deferred.promise - promise.cancel = -> - task.terminate() - deferred.resolve('cancelled') - promise - - # Public: Performs a replace across all the specified files in the project. - # - # * `regex` A {RegExp} to search with. - # * `replacementText` Text to replace all matches of regex with - # * `filePaths` List of file path strings to run the replace on. - # * `iterator` A {Function} callback on each file with replacements: - # * `options` {Object} with keys `filePath` and `replacements` - # - # Returns a `Promise`. - replace: (regex, replacementText, filePaths, iterator) -> - deferred = Q.defer() - - openPaths = (buffer.getPath() for buffer in atom.project.getBuffers()) - outOfProcessPaths = _.difference(filePaths, openPaths) - - inProcessFinished = not openPaths.length - outOfProcessFinished = not outOfProcessPaths.length - checkFinished = -> - deferred.resolve() if outOfProcessFinished and inProcessFinished - - unless outOfProcessFinished.length - flags = 'g' - flags += 'i' if regex.ignoreCase - - task = Task.once require.resolve('./replace-handler'), outOfProcessPaths, regex.source, flags, replacementText, -> - outOfProcessFinished = true - checkFinished() - - task.on 'replace:path-replaced', iterator - task.on 'replace:file-error', (error) -> iterator(null, error) - - for buffer in atom.project.getBuffers() - continue unless buffer.getPath() in filePaths - replacements = buffer.replace(regex, replacementText, iterator) - iterator({filePath: buffer.getPath(), replacements}) if replacements - - inProcessFinished = true - checkFinished() - - deferred.promise - -if includeDeprecatedAPIs - Workspace.properties - paneContainer: null - fullScreen: false - destroyedItemURIs: -> [] - - Object.defineProperty Workspace::, 'activePaneItem', - get: -> - Grim.deprecate "Use ::getActivePaneItem() instead of the ::activePaneItem property" - @getActivePaneItem() - - Object.defineProperty Workspace::, 'activePane', - get: -> - Grim.deprecate "Use ::getActivePane() instead of the ::activePane property" - @getActivePane() - - StackTraceParser = require 'stacktrace-parser' - - Workspace::getCallingPackageName = -> - error = new Error - Error.captureStackTrace(error) - stack = StackTraceParser.parse(error.stack) - - packagePaths = @getPackagePathsByPackageName() - - for i in [0...stack.length] - stackFramePath = stack[i].file - - # Empty when it was run from the dev console - return unless stackFramePath - - for packageName, packagePath of packagePaths - continue if stackFramePath is 'node.js' - relativePath = path.relative(packagePath, stackFramePath) - return packageName unless /^\.\./.test(relativePath) - return - - Workspace::getPackagePathsByPackageName = -> - packagePathsByPackageName = {} - for pack in atom.packages.getLoadedPackages() - packagePath = pack.path - if packagePath.indexOf('.atom/dev/packages') > -1 or packagePath.indexOf('.atom/packages') > -1 - packagePath = fs.realpathSync(packagePath) - packagePathsByPackageName[pack.name] = packagePath - packagePathsByPackageName - - Workspace::eachEditor = (callback) -> - deprecate("Use Workspace::observeTextEditors instead") - - callback(editor) for editor in @getEditors() - @subscribe this, 'editor-created', (editor) -> callback(editor) - - Workspace::getEditors = -> - deprecate("Use Workspace::getTextEditors instead") - - editors = [] - for pane in @paneContainer.getPanes() - editors.push(item) for item in pane.getItems() when item instanceof TextEditor - - editors - - Workspace::on = (eventName) -> - switch eventName - when 'editor-created' - deprecate("Use Workspace::onDidAddTextEditor or Workspace::observeTextEditors instead.") - when 'uri-opened' - deprecate("Use Workspace::onDidOpen or Workspace::onDidAddPaneItem instead. https://atom.io/docs/api/latest/Workspace#instance-onDidOpen") - else - deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") - - super - - Workspace::reopenItemSync = -> - deprecate("Use Workspace::reopenItem instead") - if uri = @destroyedItemURIs.pop() - @openSync(uri) - - Workspace::registerOpener = (opener) -> - Grim.deprecate("Call Workspace::addOpener instead") - @addOpener(opener) - - Workspace::unregisterOpener = (opener) -> - Grim.deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") - _.remove(@openers, opener) - - Workspace::getActiveEditor = -> - Grim.deprecate "Call ::getActiveTextEditor instead" - @getActivePane()?.getActiveEditor() - - Workspace::paneForUri = (uri) -> - deprecate("Use ::paneForURI instead.") - @paneForURI(uri) diff --git a/src/workspace.js b/src/workspace.js new file mode 100644 index 00000000000..3b040c3b064 --- /dev/null +++ b/src/workspace.js @@ -0,0 +1,2271 @@ +const _ = require('underscore-plus'); +const url = require('url'); +const path = require('path'); +const { Emitter, Disposable, CompositeDisposable } = require('event-kit'); +const fs = require('fs-plus'); +const { Directory } = require('pathwatcher'); +const Grim = require('grim'); +const DefaultDirectorySearcher = require('./default-directory-searcher'); +const RipgrepDirectorySearcher = require('./ripgrep-directory-searcher'); +const Dock = require('./dock'); +const Model = require('./model'); +const StateStore = require('./state-store'); +const TextEditor = require('./text-editor'); +const Panel = require('./panel'); +const PanelContainer = require('./panel-container'); +const Task = require('./task'); +const WorkspaceCenter = require('./workspace-center'); +const { createWorkspaceElement } = require('./workspace-element'); + +const STOPPED_CHANGING_ACTIVE_PANE_ITEM_DELAY = 100; +const ALL_LOCATIONS = ['center', 'left', 'right', 'bottom']; + +// Essential: Represents the state of the user interface for the entire window. +// An instance of this class is available via the `atom.workspace` global. +// +// Interact with this object to open files, be notified of current and future +// editors, and manipulate panes. To add panels, use {Workspace::addTopPanel} +// and friends. +// +// ## Workspace Items +// +// The term "item" refers to anything that can be displayed +// in a pane within the workspace, either in the {WorkspaceCenter} or in one +// of the three {Dock}s. The workspace expects items to conform to the +// following interface: +// +// ### Required Methods +// +// #### `getTitle()` +// +// Returns a {String} containing the title of the item to display on its +// associated tab. +// +// ### Optional Methods +// +// #### `getElement()` +// +// If your item already *is* a DOM element, you do not need to implement this +// method. Otherwise it should return the element you want to display to +// represent this item. +// +// #### `destroy()` +// +// Destroys the item. This will be called when the item is removed from its +// parent pane. +// +// #### `onDidDestroy(callback)` +// +// Called by the workspace so it can be notified when the item is destroyed. +// Must return a {Disposable}. +// +// #### `serialize()` +// +// Serialize the state of the item. Must return an object that can be passed to +// `JSON.stringify`. The state should include a field called `deserializer`, +// which names a deserializer declared in your `package.json`. This method is +// invoked on items when serializing the workspace so they can be restored to +// the same location later. +// +// #### `getURI()` +// +// Returns the URI associated with the item. +// +// #### `getLongTitle()` +// +// Returns a {String} containing a longer version of the title to display in +// places like the window title or on tabs their short titles are ambiguous. +// +// #### `onDidChangeTitle(callback)` +// +// Called by the workspace so it can be notified when the item's title changes. +// Must return a {Disposable}. +// +// #### `getIconName()` +// +// Return a {String} with the name of an icon. If this method is defined and +// returns a string, the item's tab element will be rendered with the `icon` and +// `icon-${iconName}` CSS classes. +// +// ### `onDidChangeIcon(callback)` +// +// Called by the workspace so it can be notified when the item's icon changes. +// Must return a {Disposable}. +// +// #### `getDefaultLocation()` +// +// Tells the workspace where your item should be opened in absence of a user +// override. Items can appear in the center or in a dock on the left, right, or +// bottom of the workspace. +// +// Returns a {String} with one of the following values: `'center'`, `'left'`, +// `'right'`, `'bottom'`. If this method is not defined, `'center'` is the +// default. +// +// #### `getAllowedLocations()` +// +// Tells the workspace where this item can be moved. Returns an {Array} of one +// or more of the following values: `'center'`, `'left'`, `'right'`, or +// `'bottom'`. +// +// #### `isPermanentDockItem()` +// +// Tells the workspace whether or not this item can be closed by the user by +// clicking an `x` on its tab. Use of this feature is discouraged unless there's +// a very good reason not to allow users to close your item. Items can be made +// permanent *only* when they are contained in docks. Center pane items can +// always be removed. Note that it is currently still possible to close dock +// items via the `Close Pane` option in the context menu and via Atom APIs, so +// you should still be prepared to handle your dock items being destroyed by the +// user even if you implement this method. +// +// #### `save()` +// +// Saves the item. +// +// #### `saveAs(path)` +// +// Saves the item to the specified path. +// +// #### `getPath()` +// +// Returns the local path associated with this item. This is only used to set +// the initial location of the "save as" dialog. +// +// #### `isModified()` +// +// Returns whether or not the item is modified to reflect modification in the +// UI. +// +// #### `onDidChangeModified()` +// +// Called by the workspace so it can be notified when item's modified status +// changes. Must return a {Disposable}. +// +// #### `copy()` +// +// Create a copy of the item. If defined, the workspace will call this method to +// duplicate the item when splitting panes via certain split commands. +// +// #### `getPreferredHeight()` +// +// If this item is displayed in the bottom {Dock}, called by the workspace when +// initially displaying the dock to set its height. Once the dock has been +// resized by the user, their height will override this value. +// +// Returns a {Number}. +// +// #### `getPreferredWidth()` +// +// If this item is displayed in the left or right {Dock}, called by the +// workspace when initially displaying the dock to set its width. Once the dock +// has been resized by the user, their width will override this value. +// +// Returns a {Number}. +// +// #### `onDidTerminatePendingState(callback)` +// +// If the workspace is configured to use *pending pane items*, the workspace +// will subscribe to this method to terminate the pending state of the item. +// Must return a {Disposable}. +// +// #### `shouldPromptToSave()` +// +// This method indicates whether Atom should prompt the user to save this item +// when the user closes or reloads the window. Returns a boolean. +module.exports = class Workspace extends Model { + constructor(params) { + super(...arguments); + + this.updateWindowTitle = this.updateWindowTitle.bind(this); + this.updateDocumentEdited = this.updateDocumentEdited.bind(this); + this.didDestroyPaneItem = this.didDestroyPaneItem.bind(this); + this.didChangeActivePaneOnPaneContainer = this.didChangeActivePaneOnPaneContainer.bind( + this + ); + this.didChangeActivePaneItemOnPaneContainer = this.didChangeActivePaneItemOnPaneContainer.bind( + this + ); + this.didActivatePaneContainer = this.didActivatePaneContainer.bind(this); + + this.enablePersistence = params.enablePersistence; + this.packageManager = params.packageManager; + this.config = params.config; + this.project = params.project; + this.notificationManager = params.notificationManager; + this.viewRegistry = params.viewRegistry; + this.grammarRegistry = params.grammarRegistry; + this.applicationDelegate = params.applicationDelegate; + this.assert = params.assert; + this.deserializerManager = params.deserializerManager; + this.textEditorRegistry = params.textEditorRegistry; + this.styleManager = params.styleManager; + this.draggingItem = false; + this.itemLocationStore = new StateStore('AtomPreviousItemLocations', 1); + + this.emitter = new Emitter(); + this.openers = []; + this.destroyedItemURIs = []; + this.stoppedChangingActivePaneItemTimeout = null; + + this.scandalDirectorySearcher = new DefaultDirectorySearcher(); + this.ripgrepDirectorySearcher = new RipgrepDirectorySearcher(); + this.consumeServices(this.packageManager); + + this.paneContainers = { + center: this.createCenter(), + left: this.createDock('left'), + right: this.createDock('right'), + bottom: this.createDock('bottom') + }; + this.activePaneContainer = this.paneContainers.center; + this.hasActiveTextEditor = false; + + this.panelContainers = { + top: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'top' + }), + left: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'left', + dock: this.paneContainers.left + }), + right: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'right', + dock: this.paneContainers.right + }), + bottom: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'bottom', + dock: this.paneContainers.bottom + }), + header: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'header' + }), + footer: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'footer' + }), + modal: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'modal' + }) + }; + + this.incoming = new Map(); + } + + get paneContainer() { + Grim.deprecate( + '`atom.workspace.paneContainer` has always been private, but it is now gone. Please use `atom.workspace.getCenter()` instead and consult the workspace API docs for public methods.' + ); + return this.paneContainers.center.paneContainer; + } + + getElement() { + if (!this.element) { + this.element = createWorkspaceElement().initialize(this, { + config: this.config, + project: this.project, + viewRegistry: this.viewRegistry, + styleManager: this.styleManager + }); + } + return this.element; + } + + createCenter() { + return new WorkspaceCenter({ + config: this.config, + applicationDelegate: this.applicationDelegate, + notificationManager: this.notificationManager, + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry, + didActivate: this.didActivatePaneContainer, + didChangeActivePane: this.didChangeActivePaneOnPaneContainer, + didChangeActivePaneItem: this.didChangeActivePaneItemOnPaneContainer, + didDestroyPaneItem: this.didDestroyPaneItem + }); + } + + createDock(location) { + return new Dock({ + location, + config: this.config, + applicationDelegate: this.applicationDelegate, + deserializerManager: this.deserializerManager, + notificationManager: this.notificationManager, + viewRegistry: this.viewRegistry, + didActivate: this.didActivatePaneContainer, + didChangeActivePane: this.didChangeActivePaneOnPaneContainer, + didChangeActivePaneItem: this.didChangeActivePaneItemOnPaneContainer, + didDestroyPaneItem: this.didDestroyPaneItem + }); + } + + reset(packageManager) { + this.packageManager = packageManager; + this.emitter.dispose(); + this.emitter = new Emitter(); + + this.paneContainers.center.destroy(); + this.paneContainers.left.destroy(); + this.paneContainers.right.destroy(); + this.paneContainers.bottom.destroy(); + + _.values(this.panelContainers).forEach(panelContainer => { + panelContainer.destroy(); + }); + + this.paneContainers = { + center: this.createCenter(), + left: this.createDock('left'), + right: this.createDock('right'), + bottom: this.createDock('bottom') + }; + this.activePaneContainer = this.paneContainers.center; + this.hasActiveTextEditor = false; + + this.panelContainers = { + top: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'top' + }), + left: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'left', + dock: this.paneContainers.left + }), + right: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'right', + dock: this.paneContainers.right + }), + bottom: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'bottom', + dock: this.paneContainers.bottom + }), + header: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'header' + }), + footer: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'footer' + }), + modal: new PanelContainer({ + viewRegistry: this.viewRegistry, + location: 'modal' + }) + }; + + this.openers = []; + this.destroyedItemURIs = []; + if (this.element) { + this.element.destroy(); + this.element = null; + } + this.consumeServices(this.packageManager); + } + + initialize() { + // we set originalFontSize to avoid breaking packages that might have relied on it + this.originalFontSize = this.config.get('defaultFontSize'); + + this.project.onDidChangePaths(this.updateWindowTitle); + this.subscribeToAddedItems(); + this.subscribeToMovedItems(); + this.subscribeToDockToggling(); + } + + consumeServices({ serviceHub }) { + this.directorySearchers = []; + serviceHub.consume('atom.directory-searcher', '^0.1.0', provider => + this.directorySearchers.unshift(provider) + ); + } + + // Called by the Serializable mixin during serialization. + serialize() { + return { + deserializer: 'Workspace', + packagesWithActiveGrammars: this.getPackageNamesWithActiveGrammars(), + destroyedItemURIs: this.destroyedItemURIs.slice(), + // Ensure deserializing 1.17 state with pre 1.17 Atom does not error + // TODO: Remove after 1.17 has been on stable for a while + paneContainer: { version: 2 }, + paneContainers: { + center: this.paneContainers.center.serialize(), + left: this.paneContainers.left.serialize(), + right: this.paneContainers.right.serialize(), + bottom: this.paneContainers.bottom.serialize() + } + }; + } + + deserialize(state, deserializerManager) { + const packagesWithActiveGrammars = + state.packagesWithActiveGrammars != null + ? state.packagesWithActiveGrammars + : []; + for (let packageName of packagesWithActiveGrammars) { + const pkg = this.packageManager.getLoadedPackage(packageName); + if (pkg != null) { + pkg.loadGrammarsSync(); + } + } + if (state.destroyedItemURIs != null) { + this.destroyedItemURIs = state.destroyedItemURIs; + } + + if (state.paneContainers) { + this.paneContainers.center.deserialize( + state.paneContainers.center, + deserializerManager + ); + this.paneContainers.left.deserialize( + state.paneContainers.left, + deserializerManager + ); + this.paneContainers.right.deserialize( + state.paneContainers.right, + deserializerManager + ); + this.paneContainers.bottom.deserialize( + state.paneContainers.bottom, + deserializerManager + ); + } else if (state.paneContainer) { + // TODO: Remove this fallback once a lot of time has passed since 1.17 was released + this.paneContainers.center.deserialize( + state.paneContainer, + deserializerManager + ); + } + + this.hasActiveTextEditor = this.getActiveTextEditor() != null; + + this.updateWindowTitle(); + } + + getPackageNamesWithActiveGrammars() { + const packageNames = []; + const addGrammar = ({ includedGrammarScopes, packageName } = {}) => { + if (!packageName) { + return; + } + // Prevent cycles + if (packageNames.indexOf(packageName) !== -1) { + return; + } + + packageNames.push(packageName); + for (let scopeName of includedGrammarScopes != null + ? includedGrammarScopes + : []) { + addGrammar(this.grammarRegistry.grammarForScopeName(scopeName)); + } + }; + + const editors = this.getTextEditors(); + for (let editor of editors) { + addGrammar(editor.getGrammar()); + } + + if (editors.length > 0) { + for (let grammar of this.grammarRegistry.getGrammars()) { + if (grammar.injectionSelector) { + addGrammar(grammar); + } + } + } + + return _.uniq(packageNames); + } + + didActivatePaneContainer(paneContainer) { + if (paneContainer !== this.getActivePaneContainer()) { + this.activePaneContainer = paneContainer; + this.didChangeActivePaneItem( + this.activePaneContainer.getActivePaneItem() + ); + this.emitter.emit( + 'did-change-active-pane-container', + this.activePaneContainer + ); + this.emitter.emit( + 'did-change-active-pane', + this.activePaneContainer.getActivePane() + ); + this.emitter.emit( + 'did-change-active-pane-item', + this.activePaneContainer.getActivePaneItem() + ); + } + } + + didChangeActivePaneOnPaneContainer(paneContainer, pane) { + if (paneContainer === this.getActivePaneContainer()) { + this.emitter.emit('did-change-active-pane', pane); + } + } + + didChangeActivePaneItemOnPaneContainer(paneContainer, item) { + if (paneContainer === this.getActivePaneContainer()) { + this.didChangeActivePaneItem(item); + this.emitter.emit('did-change-active-pane-item', item); + } + + if (paneContainer === this.getCenter()) { + const hadActiveTextEditor = this.hasActiveTextEditor; + this.hasActiveTextEditor = item instanceof TextEditor; + + if (this.hasActiveTextEditor || hadActiveTextEditor) { + const itemValue = this.hasActiveTextEditor ? item : undefined; + this.emitter.emit('did-change-active-text-editor', itemValue); + } + } + } + + didChangeActivePaneItem(item) { + this.updateWindowTitle(); + this.updateDocumentEdited(); + if (this.activeItemSubscriptions) this.activeItemSubscriptions.dispose(); + this.activeItemSubscriptions = new CompositeDisposable(); + + let modifiedSubscription, titleSubscription; + + if (item != null && typeof item.onDidChangeTitle === 'function') { + titleSubscription = item.onDidChangeTitle(this.updateWindowTitle); + } else if (item != null && typeof item.on === 'function') { + titleSubscription = item.on('title-changed', this.updateWindowTitle); + if ( + titleSubscription == null || + typeof titleSubscription.dispose !== 'function' + ) { + titleSubscription = new Disposable(() => { + item.off('title-changed', this.updateWindowTitle); + }); + } + } + + if (item != null && typeof item.onDidChangeModified === 'function') { + modifiedSubscription = item.onDidChangeModified( + this.updateDocumentEdited + ); + } else if (item != null && typeof item.on === 'function') { + modifiedSubscription = item.on( + 'modified-status-changed', + this.updateDocumentEdited + ); + if ( + modifiedSubscription == null || + typeof modifiedSubscription.dispose !== 'function' + ) { + modifiedSubscription = new Disposable(() => { + item.off('modified-status-changed', this.updateDocumentEdited); + }); + } + } + + if (titleSubscription != null) { + this.activeItemSubscriptions.add(titleSubscription); + } + if (modifiedSubscription != null) { + this.activeItemSubscriptions.add(modifiedSubscription); + } + + this.cancelStoppedChangingActivePaneItemTimeout(); + this.stoppedChangingActivePaneItemTimeout = setTimeout(() => { + this.stoppedChangingActivePaneItemTimeout = null; + this.emitter.emit('did-stop-changing-active-pane-item', item); + }, STOPPED_CHANGING_ACTIVE_PANE_ITEM_DELAY); + } + + cancelStoppedChangingActivePaneItemTimeout() { + if (this.stoppedChangingActivePaneItemTimeout != null) { + clearTimeout(this.stoppedChangingActivePaneItemTimeout); + } + } + + setDraggingItem(draggingItem) { + _.values(this.paneContainers).forEach(dock => { + dock.setDraggingItem(draggingItem); + }); + } + + subscribeToAddedItems() { + this.onDidAddPaneItem(({ item, pane, index }) => { + if (item instanceof TextEditor) { + const subscriptions = new CompositeDisposable( + this.textEditorRegistry.add(item), + this.textEditorRegistry.maintainConfig(item) + ); + 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 + }); + // It's important to call handleGrammarUsed after emitting the did-add event: + // if we activate a package between adding the editor to the registry and emitting + // the package may receive the editor twice from `observeTextEditors`. + // (Note that the item can be destroyed by an `observeTextEditors` handler.) + if (!item.isDestroyed()) { + subscriptions.add( + item.observeGrammar(this.handleGrammarUsed.bind(this)) + ); + } + } + }); + } + + subscribeToDockToggling() { + const docks = [ + this.getLeftDock(), + this.getRightDock(), + this.getBottomDock() + ]; + docks.forEach(dock => { + dock.onDidChangeVisible(visible => { + if (visible) return; + const { activeElement } = document; + const dockElement = dock.getElement(); + if ( + dockElement === activeElement || + dockElement.contains(activeElement) + ) { + this.getCenter().activate(); + } + }); + }); + } + + subscribeToMovedItems() { + for (const paneContainer of this.getPaneContainers()) { + paneContainer.observePanes(pane => { + pane.onDidAddItem(({ item }) => { + if (typeof item.getURI === 'function' && this.enablePersistence) { + const uri = item.getURI(); + if (uri) { + const location = paneContainer.getLocation(); + let defaultLocation; + if (typeof item.getDefaultLocation === 'function') { + defaultLocation = item.getDefaultLocation(); + } + defaultLocation = defaultLocation || 'center'; + if (location === defaultLocation) { + this.itemLocationStore.delete(item.getURI()); + } else { + this.itemLocationStore.save(item.getURI(), location); + } + } + } + }); + }); + } + } + + // Updates the application's title and proxy icon based on whichever file is + // open. + updateWindowTitle() { + let itemPath, itemTitle, projectPath, representedPath; + const appName = atom.getAppName(); + const left = this.project.getPaths(); + const projectPaths = left != null ? left : []; + const item = this.getActivePaneItem(); + if (item) { + itemPath = + typeof item.getPath === 'function' ? item.getPath() : undefined; + const longTitle = + typeof item.getLongTitle === 'function' + ? item.getLongTitle() + : undefined; + itemTitle = + longTitle == null + ? typeof item.getTitle === 'function' + ? item.getTitle() + : undefined + : longTitle; + projectPath = _.find( + projectPaths, + projectPath => + itemPath === projectPath || + (itemPath != null + ? itemPath.startsWith(projectPath + path.sep) + : undefined) + ); + } + if (itemTitle == null) { + itemTitle = 'untitled'; + } + if (projectPath == null) { + projectPath = itemPath ? path.dirname(itemPath) : projectPaths[0]; + } + if (projectPath != null) { + projectPath = fs.tildify(projectPath); + } + + const titleParts = []; + if (item != null && projectPath != null) { + titleParts.push(itemTitle, projectPath); + representedPath = itemPath != null ? itemPath : projectPath; + } else if (projectPath != null) { + titleParts.push(projectPath); + representedPath = projectPath; + } else { + titleParts.push(itemTitle); + representedPath = ''; + } + + if (process.platform !== 'darwin') { + titleParts.push(appName); + } + + document.title = titleParts.join(' \u2014 '); + this.applicationDelegate.setRepresentedFilename(representedPath); + this.emitter.emit('did-change-window-title'); + } + + // On macOS, fades the application window's proxy icon when the current file + // has been modified. + updateDocumentEdited() { + const activePaneItem = this.getActivePaneItem(); + const modified = + activePaneItem != null && typeof activePaneItem.isModified === 'function' + ? activePaneItem.isModified() || false + : false; + this.applicationDelegate.setWindowDocumentEdited(modified); + } + + /* + Section: Event Subscription + */ + + onDidChangeActivePaneContainer(callback) { + return this.emitter.on('did-change-active-pane-container', callback); + } + + // Essential: Invoke the given callback with all current and future text + // editors in the workspace. + // + // * `callback` {Function} to be called with current and future text editors. + // * `editor` A {TextEditor} that is present in {::getTextEditors} at the time + // of subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors(callback) { + for (let textEditor of this.getTextEditors()) { + callback(textEditor); + } + return this.onDidAddTextEditor(({ textEditor }) => callback(textEditor)); + } + + // Essential: Invoke the given callback with all current and future panes items + // in the workspace. + // + // * `callback` {Function} to be called with current and future pane items. + // * `item` An item that is present in {::getPaneItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePaneItems(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.observePaneItems(callback) + ) + ); + } + + // Essential: Invoke the given callback when the active pane item changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider + // {::onDidStopChangingActivePaneItem} to delay operations until after changes + // stop occurring. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePaneItem(callback) { + return this.emitter.on('did-change-active-pane-item', callback); + } + + // Essential: Invoke the given callback when the active pane item stops + // changing. + // + // Observers are called asynchronously 100ms after the last active pane item + // change. Handling changes here rather than in the synchronous + // {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly + // changing or closing tabs and ensures critical UI feedback, like changing the + // highlighted tab, gets priority over work that can be done asynchronously. + // + // * `callback` {Function} to be called when the active pane item stops + // changing. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChangingActivePaneItem(callback) { + return this.emitter.on('did-stop-changing-active-pane-item', callback); + } + + // Essential: Invoke the given callback when a text editor becomes the active + // text editor and when there is no longer an active text editor. + // + // * `callback` {Function} to be called when the active text editor changes. + // * `editor` The active {TextEditor} or undefined if there is no longer an + // active text editor. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActiveTextEditor(callback) { + return this.emitter.on('did-change-active-text-editor', callback); + } + + // Essential: Invoke the given callback with the current active pane item and + // with all future active pane items in the workspace. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The current active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePaneItem(callback) { + callback(this.getActivePaneItem()); + return this.onDidChangeActivePaneItem(callback); + } + + // Essential: Invoke the given callback with the current active text editor + // (if any), with all future active text editors, and when there is no longer + // an active text editor. + // + // * `callback` {Function} to be called when the active text editor changes. + // * `editor` The active {TextEditor} or undefined if there is not an + // active text editor. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActiveTextEditor(callback) { + callback(this.getActiveTextEditor()); + + return this.onDidChangeActiveTextEditor(callback); + } + + // Essential: Invoke the given callback whenever an item is opened. Unlike + // {::onDidAddPaneItem}, observers will be notified for items that are already + // present in the workspace when they are reopened. + // + // * `callback` {Function} to be called whenever an item is opened. + // * `event` {Object} with the following keys: + // * `uri` {String} representing the opened URI. Could be `undefined`. + // * `item` The opened item. + // * `pane` The pane in which the item was opened. + // * `index` The index of the opened item on its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidOpen(callback) { + return this.emitter.on('did-open', callback); + } + + // Extended: Invoke the given callback when a pane is added to the workspace. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `pane` The added pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPane(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onDidAddPane(callback) + ) + ); + } + + // Extended: Invoke the given callback before a pane is destroyed in the + // workspace. + // + // * `callback` {Function} to be called before panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The pane to be destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroyPane(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onWillDestroyPane(callback) + ) + ); + } + + // Extended: Invoke the given callback when a pane is destroyed in the + // workspace. + // + // * `callback` {Function} to be called panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The destroyed pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroyPane(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onDidDestroyPane(callback) + ) + ); + } + + // Extended: Invoke the given callback with all current and future panes in the + // workspace. + // + // * `callback` {Function} to be called with current and future panes. + // * `pane` A {Pane} that is present in {::getPanes} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePanes(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.observePanes(callback) + ) + ); + } + + // Extended: Invoke the given callback when the active pane changes. + // + // * `callback` {Function} to be called when the active pane changes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePane(callback) { + return this.emitter.on('did-change-active-pane', callback); + } + + // Extended: Invoke the given callback with the current active pane and when + // the active pane changes. + // + // * `callback` {Function} to be called with the current and future active# + // panes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePane(callback) { + callback(this.getActivePane()); + return this.onDidChangeActivePane(callback); + } + + // Extended: Invoke the given callback when a pane item is added to the + // workspace. + // + // * `callback` {Function} to be called when pane items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `pane` {Pane} containing the added item. + // * `index` {Number} indicating the index of the added item in its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPaneItem(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onDidAddPaneItem(callback) + ) + ); + } + + // Extended: Invoke the given callback when a pane item is about to be + // destroyed, before the user is prompted to save it. + // + // * `callback` {Function} to be called before pane items are destroyed. If this function returns + // a {Promise}, then the item will not be destroyed until the promise resolves. + // * `event` {Object} with the following keys: + // * `item` The item to be destroyed. + // * `pane` {Pane} containing the item to be destroyed. + // * `index` {Number} indicating the index of the item to be destroyed in + // its pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onWillDestroyPaneItem(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onWillDestroyPaneItem(callback) + ) + ); + } + + // Extended: Invoke the given callback when a pane item is destroyed. + // + // * `callback` {Function} to be called when pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The destroyed item. + // * `pane` {Pane} containing the destroyed item. + // * `index` {Number} indicating the index of the destroyed item in its + // pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onDidDestroyPaneItem(callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => + container.onDidDestroyPaneItem(callback) + ) + ); + } + + // Extended: Invoke the given callback when a text editor is added to the + // workspace. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `textEditor` {TextEditor} that was added. + // * `pane` {Pane} containing the added text editor. + // * `index` {Number} indicating the index of the added text editor in its + // pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddTextEditor(callback) { + return this.emitter.on('did-add-text-editor', callback); + } + + onDidChangeWindowTitle(callback) { + return this.emitter.on('did-change-window-title', callback); + } + + /* + Section: Opening + */ + + // Essential: Opens the given URI in Atom asynchronously. + // If the URI is already open, the existing item for that URI will be + // activated. If no URI is given, or no registered opener can open + // the URI, a new empty {TextEditor} will be created. + // + // * `uri` (optional) A {String} containing a URI. + // * `options` (optional) {Object} + // * `initialLine` A {Number} indicating which row to move the cursor to + // initially. Defaults to `0`. + // * `initialColumn` A {Number} indicating which column to move the cursor to + // initially. Defaults to `0`. + // * `split` Either 'left', 'right', 'up' or 'down'. + // If 'left', the item will be opened in leftmost pane of the current active pane's row. + // If 'right', the item will be opened in the rightmost pane of the current active pane's row. If only one pane exists in the row, a new pane will be created. + // If 'up', the item will be opened in topmost pane of the current active pane's column. + // If 'down', the item will be opened in the bottommost pane of the current active pane's column. If only one pane exists in the column, a new pane will be created. + // * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + // containing pane. Defaults to `true`. + // * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + // on containing pane. Defaults to `true`. + // * `pending` A {Boolean} indicating whether or not the item should be opened + // in a pending state. Existing pending items in a pane are replaced with + // new pending items when they are opened. + // * `searchAllPanes` A {Boolean}. If `true`, the workspace will attempt to + // activate an existing item for the given URI on any pane. + // If `false`, only the active pane will be searched for + // an existing item for the same URI. Defaults to `false`. + // * `location` (optional) A {String} containing the name of the location + // in which this item should be opened (one of "left", "right", "bottom", + // or "center"). If omitted, Atom will fall back to the last location in + // which a user has placed an item with the same URI or, if this is a new + // URI, the default location specified by the item. NOTE: This option + // should almost always be omitted to honor user preference. + // + // Returns a {Promise} that resolves to the {TextEditor} for the file URI. + async open(itemOrURI, options = {}) { + let uri, item; + if (typeof itemOrURI === 'string') { + uri = this.project.resolvePath(itemOrURI); + } else if (itemOrURI) { + item = itemOrURI; + if (typeof item.getURI === 'function') uri = item.getURI(); + } + + let resolveItem = () => {}; + if (uri) { + const incomingItem = this.incoming.get(uri); + if (!incomingItem) { + this.incoming.set( + uri, + new Promise(resolve => { + resolveItem = resolve; + }) + ); + } else { + await incomingItem; + } + } + + try { + if (!atom.config.get('core.allowPendingPaneItems')) { + options.pending = false; + } + + // Avoid adding URLs as recent documents to work-around this Spotlight crash: + // https://github.com/atom/atom/issues/10071 + if (uri && (!url.parse(uri).protocol || process.platform === 'win32')) { + this.applicationDelegate.addRecentDocument(uri); + } + + let pane, itemExistsInWorkspace; + + // Try to find an existing item in the workspace. + if (item || uri) { + if (options.pane) { + pane = options.pane; + } else if (options.searchAllPanes) { + pane = item ? this.paneForItem(item) : this.paneForURI(uri); + } else { + // If an item with the given URI is already in the workspace, assume + // that item's pane container is the preferred location for that URI. + let container; + if (uri) container = this.paneContainerForURI(uri); + if (!container) container = this.getActivePaneContainer(); + + // The `split` option affects where we search for the item. + pane = container.getActivePane(); + switch (options.split) { + case 'left': + pane = pane.findLeftmostSibling(); + break; + case 'right': + pane = pane.findRightmostSibling(); + break; + case 'up': + pane = pane.findTopmostSibling(); + break; + case 'down': + pane = pane.findBottommostSibling(); + break; + } + } + + if (pane) { + if (item) { + itemExistsInWorkspace = pane.getItems().includes(item); + } else { + item = pane.itemForURI(uri); + itemExistsInWorkspace = item != null; + } + } + } + + // If we already have an item at this stage, we won't need to do an async + // lookup of the URI, so we yield the event loop to ensure this method + // is consistently asynchronous. + if (item) await Promise.resolve(); + + if (!itemExistsInWorkspace) { + item = item || (await this.createItemForURI(uri, options)); + if (!item) return; + + if (options.pane) { + pane = options.pane; + } else { + let location = options.location; + if (!location && !options.split && uri && this.enablePersistence) { + location = await this.itemLocationStore.load(uri); + } + if (!location && typeof item.getDefaultLocation === 'function') { + location = item.getDefaultLocation(); + } + + const allowedLocations = + typeof item.getAllowedLocations === 'function' + ? item.getAllowedLocations() + : ALL_LOCATIONS; + location = allowedLocations.includes(location) + ? location + : allowedLocations[0]; + + const container = this.paneContainers[location] || this.getCenter(); + pane = container.getActivePane(); + switch (options.split) { + case 'left': + pane = pane.findLeftmostSibling(); + break; + case 'right': + pane = pane.findOrCreateRightmostSibling(); + break; + case 'up': + pane = pane.findTopmostSibling(); + break; + case 'down': + pane = pane.findOrCreateBottommostSibling(); + break; + } + } + } + + if (!options.pending && pane.getPendingItem() === item) { + pane.clearPendingItem(); + } + + this.itemOpened(item); + + if (options.activateItem === false) { + pane.addItem(item, { pending: options.pending }); + } else { + pane.activateItem(item, { pending: options.pending }); + } + + if (options.activatePane !== false) { + pane.activate(); + } + + let initialColumn = 0; + let initialLine = 0; + if (!Number.isNaN(options.initialLine)) { + initialLine = options.initialLine; + } + if (!Number.isNaN(options.initialColumn)) { + initialColumn = options.initialColumn; + } + if (initialLine >= 0 || initialColumn >= 0) { + if (typeof item.setCursorBufferPosition === 'function') { + item.setCursorBufferPosition([initialLine, initialColumn]); + } + if (typeof item.unfoldBufferRow === 'function') { + item.unfoldBufferRow(initialLine); + } + if (typeof item.scrollToBufferPosition === 'function') { + item.scrollToBufferPosition([initialLine, initialColumn], { + center: true + }); + } + } + + const index = pane.getActiveItemIndex(); + this.emitter.emit('did-open', { uri, pane, item, index }); + if (uri) { + this.incoming.delete(uri); + } + } finally { + resolveItem(); + } + return item; + } + + // Essential: Search the workspace for items matching the given URI and hide them. + // + // * `itemOrURI` The item to hide or a {String} containing the URI + // of the item to hide. + // + // Returns a {Boolean} indicating whether any items were found (and hidden). + hide(itemOrURI) { + let foundItems = false; + + // If any visible item has the given URI, hide it + for (const container of this.getPaneContainers()) { + const isCenter = container === this.getCenter(); + if (isCenter || container.isVisible()) { + for (const pane of container.getPanes()) { + const activeItem = pane.getActiveItem(); + const foundItem = + activeItem != null && + (activeItem === itemOrURI || + (typeof activeItem.getURI === 'function' && + activeItem.getURI() === itemOrURI)); + if (foundItem) { + foundItems = true; + // We can't really hide the center so we just destroy the item. + if (isCenter) { + pane.destroyItem(activeItem); + } else { + container.hide(); + } + } + } + } + } + + return foundItems; + } + + // Essential: Search the workspace for items matching the given URI. If any are found, hide them. + // Otherwise, open the URL. + // + // * `itemOrURI` (optional) The item to toggle or a {String} containing the URI + // of the item to toggle. + // + // Returns a Promise that resolves when the item is shown or hidden. + toggle(itemOrURI) { + if (this.hide(itemOrURI)) { + return Promise.resolve(); + } else { + return this.open(itemOrURI, { searchAllPanes: true }); + } + } + + // Open Atom's license in the active pane. + openLicense() { + return this.open(path.join(process.resourcesPath, 'LICENSE.md')); + } + + // Synchronously open the given URI in the active pane. **Only use this method + // in specs. Calling this in production code will block the UI thread and + // everyone will be mad at you.** + // + // * `uri` A {String} containing a URI. + // * `options` An optional options {Object} + // * `initialLine` A {Number} indicating which row to move the cursor to + // initially. Defaults to `0`. + // * `initialColumn` A {Number} indicating which column to move the cursor to + // initially. Defaults to `0`. + // * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + // the containing pane. Defaults to `true`. + // * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + // on containing pane. Defaults to `true`. + openSync(uri_ = '', options = {}) { + const { initialLine, initialColumn } = options; + const activatePane = + options.activatePane != null ? options.activatePane : true; + const activateItem = + options.activateItem != null ? options.activateItem : true; + + const uri = this.project.resolvePath(uri_); + let item = this.getActivePane().itemForURI(uri); + if (uri && item == null) { + for (const opener of this.getOpeners()) { + item = opener(uri, options); + if (item) break; + } + } + if (item == null) { + item = this.project.openSync(uri, { initialLine, initialColumn }); + } + + if (activateItem) { + this.getActivePane().activateItem(item); + } + this.itemOpened(item); + if (activatePane) { + this.getActivePane().activate(); + } + return item; + } + + openURIInPane(uri, pane) { + return this.open(uri, { pane }); + } + + // Public: Creates a new item that corresponds to the provided URI. + // + // If no URI is given, or no registered opener can open the URI, a new empty + // {TextEditor} will be created. + // + // * `uri` A {String} containing a URI. + // + // Returns a {Promise} that resolves to the {TextEditor} (or other item) for the given URI. + async createItemForURI(uri, options) { + if (uri != null) { + for (const opener of this.getOpeners()) { + const item = opener(uri, options); + if (item != null) return item; + } + } + + try { + const item = await this.openTextFile(uri, options); + return item; + } catch (error) { + switch (error.code) { + case 'CANCELLED': + return Promise.resolve(); + case 'EACCES': + this.notificationManager.addWarning( + `Permission denied '${error.path}'` + ); + return Promise.resolve(); + case 'EPERM': + case 'EBUSY': + case 'ENXIO': + case 'EIO': + case 'ENOTCONN': + case 'UNKNOWN': + case 'ECONNRESET': + case 'EINVAL': + case 'EMFILE': + case 'ENOTDIR': + case 'EAGAIN': + this.notificationManager.addWarning( + `Unable to open '${error.path != null ? error.path : uri}'`, + { detail: error.message } + ); + return Promise.resolve(); + default: + throw error; + } + } + } + + async openTextFile(uri, options) { + const filePath = this.project.resolvePath(uri); + + if (filePath != null) { + try { + fs.closeSync(fs.openSync(filePath, 'r')); + } catch (error) { + // allow ENOENT errors to create an editor for paths that dont exist + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + const fileSize = fs.getSizeSync(filePath); + + if (fileSize >= this.config.get('core.warnOnLargeFileLimit') * 1048576) { + // 40MB by default + await new Promise((resolve, reject) => { + this.applicationDelegate.confirm( + { + message: + 'Atom will be unresponsive during the loading of very large files.', + detail: 'Do you still want to load this file?', + buttons: ['Proceed', 'Cancel'] + }, + response => { + if (response === 1) { + const error = new Error(); + error.code = 'CANCELLED'; + reject(error); + } else { + resolve(); + } + } + ); + }); + } + + const buffer = await this.project.bufferForPath(filePath, options); + return this.textEditorRegistry.build( + Object.assign({ buffer, autoHeight: false }, options) + ); + } + + handleGrammarUsed(grammar) { + if (grammar == null) { + return; + } + this.packageManager.triggerActivationHook( + `${grammar.scopeName}:root-scope-used` + ); + this.packageManager.triggerActivationHook( + `${grammar.packageName}:grammar-used` + ); + } + + // Public: Returns a {Boolean} that is `true` if `object` is a `TextEditor`. + // + // * `object` An {Object} you want to perform the check against. + isTextEditor(object) { + return object instanceof TextEditor; + } + + // Extended: Create a new text editor. + // + // Returns a {TextEditor}. + buildTextEditor(params) { + const editor = this.textEditorRegistry.build(params); + const subscription = this.textEditorRegistry.maintainConfig(editor); + editor.onDidDestroy(() => subscription.dispose()); + return editor; + } + + // Public: Asynchronously reopens the last-closed item's URI if it hasn't already been + // reopened. + // + // Returns a {Promise} that is resolved when the item is opened + reopenItem() { + const uri = this.destroyedItemURIs.pop(); + if (uri) { + return this.open(uri); + } else { + return Promise.resolve(); + } + } + + // Public: Register an opener for a uri. + // + // When a URI is opened via {Workspace::open}, Atom loops through its registered + // opener functions until one returns a value for the given uri. + // Openers are expected to return an object that inherits from HTMLElement or + // a model which has an associated view in the {ViewRegistry}. + // A {TextEditor} will be used if no opener returns a value. + // + // ## Examples + // + // ```coffee + // atom.workspace.addOpener (uri) -> + // if path.extname(uri) is '.toml' + // return new TomlEditor(uri) + // ``` + // + // * `opener` A {Function} to be called when a path is being opened. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // opener. + // + // Note that the opener will be called if and only if the URI is not already open + // in the current pane. The searchAllPanes flag expands the search from the + // current pane to all panes. If you wish to open a view of a different type for + // a file that is already open, consider changing the protocol of the URI. For + // example, perhaps you wish to preview a rendered version of the file `/foo/bar/baz.quux` + // that is already open in a text editor view. You could signal this by calling + // {Workspace::open} on the URI `quux-preview://foo/bar/baz.quux`. Then your opener + // can check the protocol for quux-preview and only handle those URIs that match. + // + // To defer your package's activation until a specific URL is opened, add a + // `workspaceOpeners` field to your `package.json` containing an array of URL + // strings. + addOpener(opener) { + this.openers.push(opener); + return new Disposable(() => { + _.remove(this.openers, opener); + }); + } + + getOpeners() { + return this.openers; + } + + /* + Section: Pane Items + */ + + // Essential: Get all pane items in the workspace. + // + // Returns an {Array} of items. + getPaneItems() { + return _.flatten( + this.getPaneContainers().map(container => container.getPaneItems()) + ); + } + + // Essential: Get the active {Pane}'s active item. + // + // Returns a pane item {Object}. + getActivePaneItem() { + return this.getActivePaneContainer().getActivePaneItem(); + } + + // Essential: Get all text editors in the workspace, if they are pane items. + // + // Returns an {Array} of {TextEditor}s. + getTextEditors() { + return this.getPaneItems().filter(item => item instanceof TextEditor); + } + + // Essential: Get the workspace center's active item if it is a {TextEditor}. + // + // Returns a {TextEditor} or `undefined` if the workspace center's current + // active item is not a {TextEditor}. + getActiveTextEditor() { + const activeItem = this.getCenter().getActivePaneItem(); + if (activeItem instanceof TextEditor) { + return activeItem; + } + } + + // Save all pane items. + saveAll() { + this.getPaneContainers().forEach(container => { + container.saveAll(); + }); + } + + confirmClose(options) { + return Promise.all( + this.getPaneContainers().map(container => container.confirmClose(options)) + ).then(results => !results.includes(false)); + } + + // Save the active pane item. + // + // If the active pane item currently has a URI according to the item's + // `.getURI` method, calls `.save` on the item. Otherwise + // {::saveActivePaneItemAs} # will be called instead. This method does nothing + // if the active item does not implement a `.save` method. + saveActivePaneItem() { + return this.getCenter() + .getActivePane() + .saveActiveItem(); + } + + // Prompt the user for a path and save the active pane item to it. + // + // Opens a native dialog where the user selects a path on disk, then calls + // `.saveAs` on the item with the selected path. This method does nothing if + // the active item does not implement a `.saveAs` method. + saveActivePaneItemAs() { + this.getCenter() + .getActivePane() + .saveActiveItemAs(); + } + + // Destroy (close) the active pane item. + // + // Removes the active pane item and calls the `.destroy` method on it if one is + // defined. + destroyActivePaneItem() { + return this.getActivePane().destroyActiveItem(); + } + + /* + Section: Panes + */ + + // Extended: Get the most recently focused pane container. + // + // Returns a {Dock} or the {WorkspaceCenter}. + getActivePaneContainer() { + return this.activePaneContainer; + } + + // Extended: Get all panes in the workspace. + // + // Returns an {Array} of {Pane}s. + getPanes() { + return _.flatten( + this.getPaneContainers().map(container => container.getPanes()) + ); + } + + getVisiblePanes() { + return _.flatten( + this.getVisiblePaneContainers().map(container => container.getPanes()) + ); + } + + // Extended: Get the active {Pane}. + // + // Returns a {Pane}. + getActivePane() { + return this.getActivePaneContainer().getActivePane(); + } + + // Extended: Make the next pane active. + activateNextPane() { + return this.getActivePaneContainer().activateNextPane(); + } + + // Extended: Make the previous pane active. + activatePreviousPane() { + return this.getActivePaneContainer().activatePreviousPane(); + } + + // Extended: Get the first pane container that contains an item with the given + // URI. + // + // * `uri` {String} uri + // + // Returns a {Dock}, the {WorkspaceCenter}, or `undefined` if no item exists + // with the given URI. + paneContainerForURI(uri) { + return this.getPaneContainers().find(container => + container.paneForURI(uri) + ); + } + + // Extended: Get the first pane container that contains the given item. + // + // * `item` the Item that the returned pane container must contain. + // + // Returns a {Dock}, the {WorkspaceCenter}, or `undefined` if no item exists + // with the given URI. + paneContainerForItem(uri) { + return this.getPaneContainers().find(container => + container.paneForItem(uri) + ); + } + + // Extended: Get the first {Pane} that contains an item with the given URI. + // + // * `uri` {String} uri + // + // Returns a {Pane} or `undefined` if no item exists with the given URI. + paneForURI(uri) { + for (let location of this.getPaneContainers()) { + const pane = location.paneForURI(uri); + if (pane != null) { + return pane; + } + } + } + + // Extended: Get the {Pane} containing the given item. + // + // * `item` the Item that the returned pane must contain. + // + // Returns a {Pane} or `undefined` if no pane exists for the given item. + paneForItem(item) { + for (let location of this.getPaneContainers()) { + const pane = location.paneForItem(item); + if (pane != null) { + return pane; + } + } + } + + // Destroy (close) the active pane. + destroyActivePane() { + const activePane = this.getActivePane(); + if (activePane != null) { + activePane.destroy(); + } + } + + // Close the active center pane item, or the active center pane if it is + // empty, or the current window if there is only the empty root pane. + closeActivePaneItemOrEmptyPaneOrWindow() { + if (this.getCenter().getActivePaneItem() != null) { + this.getCenter() + .getActivePane() + .destroyActiveItem(); + } else if (this.getCenter().getPanes().length > 1) { + this.getCenter().destroyActivePane(); + } else if (this.config.get('core.closeEmptyWindows')) { + atom.close(); + } + } + + // Increase the editor font size by 1px. + increaseFontSize() { + this.config.set('editor.fontSize', this.config.get('editor.fontSize') + 1); + } + + // Decrease the editor font size by 1px. + decreaseFontSize() { + const fontSize = this.config.get('editor.fontSize'); + if (fontSize > 1) { + this.config.set('editor.fontSize', fontSize - 1); + } + } + + // Restore to the window's default editor font size. + resetFontSize() { + this.config.set( + 'editor.fontSize', + this.config.get('editor.defaultFontSize') + ); + } + + // Removes the item's uri from the list of potential items to reopen. + itemOpened(item) { + let uri; + if (typeof item.getURI === 'function') { + uri = item.getURI(); + } else if (typeof item.getUri === 'function') { + uri = item.getUri(); + } + + if (uri != null) { + _.remove(this.destroyedItemURIs, uri); + } + } + + // Adds the destroyed item's uri to the list of items to reopen. + didDestroyPaneItem({ item }) { + let uri; + if (typeof item.getURI === 'function') { + uri = item.getURI(); + } else if (typeof item.getUri === 'function') { + uri = item.getUri(); + } + + if (uri != null) { + this.destroyedItemURIs.push(uri); + } + } + + // Called by Model superclass when destroyed + destroyed() { + this.paneContainers.center.destroy(); + this.paneContainers.left.destroy(); + this.paneContainers.right.destroy(); + this.paneContainers.bottom.destroy(); + this.cancelStoppedChangingActivePaneItemTimeout(); + if (this.activeItemSubscriptions != null) { + this.activeItemSubscriptions.dispose(); + } + if (this.element) this.element.destroy(); + } + + /* + Section: Pane Locations + */ + + // Essential: Get the {WorkspaceCenter} at the center of the editor window. + getCenter() { + return this.paneContainers.center; + } + + // Essential: Get the {Dock} to the left of the editor window. + getLeftDock() { + return this.paneContainers.left; + } + + // Essential: Get the {Dock} to the right of the editor window. + getRightDock() { + return this.paneContainers.right; + } + + // Essential: Get the {Dock} below the editor window. + getBottomDock() { + return this.paneContainers.bottom; + } + + getPaneContainers() { + return [ + this.paneContainers.center, + this.paneContainers.left, + this.paneContainers.right, + this.paneContainers.bottom + ]; + } + + getVisiblePaneContainers() { + const center = this.getCenter(); + return atom.workspace + .getPaneContainers() + .filter(container => container === center || container.isVisible()); + } + + /* + Section: Panels + + Panels are used to display UI related to an editor window. They are placed at one of the four + edges of the window: left, right, top or bottom. If there are multiple panels on the same window + edge they are stacked in order of priority: higher priority is closer to the center, lower + priority towards the edge. + + *Note:* If your panel changes its size throughout its lifetime, consider giving it a higher + priority, allowing fixed size panels to be closer to the edge. This allows control targets to + remain more static for easier targeting by users that employ mice or trackpads. (See + [atom/atom#4834](https://github.com/atom/atom/issues/4834) for discussion.) + */ + + // Essential: Get an {Array} of all the panel items at the bottom of the editor window. + getBottomPanels() { + return this.getPanels('bottom'); + } + + // Essential: Adds a panel item to the bottom of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addBottomPanel(options) { + return this.addPanel('bottom', options); + } + + // Essential: Get an {Array} of all the panel items to the left of the editor window. + getLeftPanels() { + return this.getPanels('left'); + } + + // Essential: Adds a panel item to the left of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addLeftPanel(options) { + return this.addPanel('left', options); + } + + // Essential: Get an {Array} of all the panel items to the right of the editor window. + getRightPanels() { + return this.getPanels('right'); + } + + // Essential: Adds a panel item to the right of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addRightPanel(options) { + return this.addPanel('right', options); + } + + // Essential: Get an {Array} of all the panel items at the top of the editor window. + getTopPanels() { + return this.getPanels('top'); + } + + // Essential: Adds a panel item to the top of the editor window above the tabs. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addTopPanel(options) { + return this.addPanel('top', options); + } + + // Essential: Get an {Array} of all the panel items in the header. + getHeaderPanels() { + return this.getPanels('header'); + } + + // Essential: Adds a panel item to the header. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addHeaderPanel(options) { + return this.addPanel('header', options); + } + + // Essential: Get an {Array} of all the panel items in the footer. + getFooterPanels() { + return this.getPanels('footer'); + } + + // Essential: Adds a panel item to the footer. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addFooterPanel(options) { + return this.addPanel('footer', options); + } + + // Essential: Get an {Array} of all the modal panel items + getModalPanels() { + return this.getPanels('modal'); + } + + // Essential: Adds a panel item as a modal dialog. + // + // * `options` {Object} + // * `item` Your panel content. It can be a DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // model option. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // * `autoFocus` (optional) {Boolean|Element} true if you want modal focus managed for you by Atom. + // Atom will automatically focus on this element or your modal panel's first tabbable element when the modal + // opens and will restore the previously selected element when the modal closes. Atom will + // also automatically restrict user tab focus within your modal while it is open. + // (default: false) + // + // Returns a {Panel} + addModalPanel(options = {}) { + return this.addPanel('modal', options); + } + + // Essential: Returns the {Panel} associated with the given item. Returns + // `null` when the item has no panel. + // + // * `item` Item the panel contains + panelForItem(item) { + for (let location in this.panelContainers) { + const container = this.panelContainers[location]; + const panel = container.panelForItem(item); + if (panel != null) { + return panel; + } + } + return null; + } + + getPanels(location) { + return this.panelContainers[location].getPanels(); + } + + addPanel(location, options) { + if (options == null) { + options = {}; + } + return this.panelContainers[location].addPanel( + new Panel(options, this.viewRegistry) + ); + } + + /* + Section: Searching and Replacing + */ + + // Public: Performs a search across all files in the workspace. + // + // * `regex` {RegExp} to search with. + // * `options` (optional) {Object} + // * `paths` An {Array} of glob patterns to search within. + // * `onPathsSearched` (optional) {Function} to be periodically called + // with number of paths searched. + // * `leadingContextLineCount` {Number} default `0`; The number of lines + // before the matched line to include in the results object. + // * `trailingContextLineCount` {Number} default `0`; The number of lines + // after the matched line to include in the results object. + // * `iterator` {Function} callback on each file found. + // + // Returns a {Promise} with a `cancel()` method that will cancel all + // of the underlying searches that were started as part of this scan. + scan(regex, options = {}, iterator) { + if (_.isFunction(options)) { + iterator = options; + options = {}; + } + + // Find a searcher for every Directory in the project. Each searcher that is matched + // will be associated with an Array of Directory objects in the Map. + const directoriesForSearcher = new Map(); + for (const directory of this.project.getDirectories()) { + let searcher = options.ripgrep + ? this.ripgrepDirectorySearcher + : this.scandalDirectorySearcher; + for (const directorySearcher of this.directorySearchers) { + if (directorySearcher.canSearchDirectory(directory)) { + searcher = directorySearcher; + break; + } + } + let directories = directoriesForSearcher.get(searcher); + if (!directories) { + directories = []; + directoriesForSearcher.set(searcher, directories); + } + directories.push(directory); + } + + // Define the onPathsSearched callback. + let onPathsSearched; + if (_.isFunction(options.onPathsSearched)) { + // Maintain a map of directories to the number of search results. When notified of a new count, + // replace the entry in the map and update the total. + const onPathsSearchedOption = options.onPathsSearched; + let totalNumberOfPathsSearched = 0; + const numberOfPathsSearchedForSearcher = new Map(); + onPathsSearched = function(searcher, numberOfPathsSearched) { + const oldValue = numberOfPathsSearchedForSearcher.get(searcher); + if (oldValue) { + totalNumberOfPathsSearched -= oldValue; + } + numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched); + totalNumberOfPathsSearched += numberOfPathsSearched; + return onPathsSearchedOption(totalNumberOfPathsSearched); + }; + } else { + onPathsSearched = function() {}; + } + + // Kick off all of the searches and unify them into one Promise. + const allSearches = []; + directoriesForSearcher.forEach((directories, searcher) => { + const searchOptions = { + inclusions: options.paths || [], + includeHidden: true, + excludeVcsIgnores: this.config.get('core.excludeVcsIgnoredPaths'), + exclusions: this.config.get('core.ignoredNames'), + follow: this.config.get('core.followSymlinks'), + leadingContextLineCount: options.leadingContextLineCount || 0, + trailingContextLineCount: options.trailingContextLineCount || 0, + PCRE2: options.PCRE2, + didMatch: result => { + if (!this.project.isPathModified(result.filePath)) { + return iterator(result); + } + }, + didError(error) { + return iterator(null, error); + }, + didSearchPaths(count) { + return onPathsSearched(searcher, count); + } + }; + const directorySearcher = searcher.search( + directories, + regex, + searchOptions + ); + allSearches.push(directorySearcher); + }); + const searchPromise = Promise.all(allSearches); + + for (let buffer of this.project.getBuffers()) { + if (buffer.isModified()) { + const filePath = buffer.getPath(); + if (!this.project.contains(filePath)) { + continue; + } + var matches = []; + buffer.scan(regex, match => matches.push(match)); + if (matches.length > 0) { + iterator({ filePath, matches }); + } + } + } + + // Make sure the Promise that is returned to the client is cancelable. To be consistent + // with the existing behavior, instead of cancel() rejecting the promise, it should + // resolve it with the special value 'cancelled'. At least the built-in find-and-replace + // package relies on this behavior. + let isCancelled = false; + const cancellablePromise = new Promise((resolve, reject) => { + const onSuccess = function() { + if (isCancelled) { + resolve('cancelled'); + } else { + resolve(null); + } + }; + + const onFailure = function(error) { + for (let promise of allSearches) { + promise.cancel(); + } + reject(error); + }; + + searchPromise.then(onSuccess, onFailure); + }); + cancellablePromise.cancel = () => { + isCancelled = true; + // Note that cancelling all of the members of allSearches will cause all of the searches + // to resolve, which causes searchPromise to resolve, which is ultimately what causes + // cancellablePromise to resolve. + allSearches.map(promise => promise.cancel()); + }; + + return cancellablePromise; + } + + // Public: Performs a replace across all the specified files in the project. + // + // * `regex` A {RegExp} to search with. + // * `replacementText` {String} to replace all matches of regex with. + // * `filePaths` An {Array} of file path strings to run the replace on. + // * `iterator` A {Function} callback on each file with replacements: + // * `options` {Object} with keys `filePath` and `replacements`. + // + // Returns a {Promise}. + replace(regex, replacementText, filePaths, iterator) { + return new Promise((resolve, reject) => { + let buffer; + const openPaths = this.project + .getBuffers() + .map(buffer => buffer.getPath()); + const outOfProcessPaths = _.difference(filePaths, openPaths); + + let inProcessFinished = !openPaths.length; + let outOfProcessFinished = !outOfProcessPaths.length; + const checkFinished = () => { + if (outOfProcessFinished && inProcessFinished) { + resolve(); + } + }; + + if (!outOfProcessFinished.length) { + let flags = 'g'; + if (regex.multiline) { + flags += 'm'; + } + if (regex.ignoreCase) { + flags += 'i'; + } + + const task = Task.once( + require.resolve('./replace-handler'), + outOfProcessPaths, + regex.source, + flags, + replacementText, + () => { + outOfProcessFinished = true; + checkFinished(); + } + ); + + task.on('replace:path-replaced', iterator); + task.on('replace:file-error', error => { + iterator(null, error); + }); + } + + for (buffer of this.project.getBuffers()) { + if (!filePaths.includes(buffer.getPath())) { + continue; + } + const replacements = buffer.replace(regex, replacementText, iterator); + if (replacements) { + iterator({ filePath: buffer.getPath(), replacements }); + } + } + + inProcessFinished = true; + checkFinished(); + }); + } + + checkoutHeadRevision(editor) { + if (editor.getPath()) { + 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', + 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 { + checkoutHead(); + } + } + } +}; diff --git a/static/atom-ui/README.md b/static/atom-ui/README.md new file mode 100644 index 00000000000..b3c02c7dfd1 --- /dev/null +++ b/static/atom-ui/README.md @@ -0,0 +1,13 @@ +# :sparkles: Atom UI :sparkles: + +This is Atom's UI library. Originally forked from Bootstrap `3.3.6`, then merged with some core styles and now tweaked to Atom's needy needs. + +## Components + +Here a list of [all components](atom-ui.less). Open the [Styleguide](https://github.com/atom/styleguide) package (`cmd-ctrl-shift-g`) to see them in action and how to use them. + +![Styleguide](https://cloud.githubusercontent.com/assets/378023/15767543/ccecf9bc-2983-11e6-9c5e-d228d39f52b0.png) + +## Feature requests + +If you need something, feel free to open an issue and it might can be added. :v: diff --git a/static/atom-ui/_index.less b/static/atom-ui/_index.less new file mode 100644 index 00000000000..4a1db024b66 --- /dev/null +++ b/static/atom-ui/_index.less @@ -0,0 +1,35 @@ +// Atom UI + +// Private! Don't use these in packages. +// If you need something, feel free to open an issue and it might can be made public +@import "styles/private/scaffolding.less"; + +@import "styles/private/alerts.less"; +@import "styles/private/close.less"; +@import "styles/private/code.less"; +@import "styles/private/forms.less"; +@import "styles/private/links.less"; +@import "styles/private/navs.less"; +@import "styles/private/sections.less"; +@import "styles/private/tables.less"; +@import "styles/private/utilities.less"; + + +// Public components +// Open the Styleguide to see them in action +@import "styles/badges.less"; +@import "styles/button-groups.less"; +@import "styles/buttons.less"; +@import "styles/git-status.less"; +@import "styles/icons.less"; +@import "styles/inputs.less"; +@import "styles/layout.less"; +@import "styles/lists.less"; +@import "styles/loading.less"; +@import "styles/messages.less"; +@import "styles/modals.less"; +@import "styles/panels.less"; +@import "styles/select-list.less"; +@import "styles/site-colors.less"; +@import "styles/text.less"; +@import "styles/tooltip.less"; diff --git a/static/atom-ui/styles/badges.less b/static/atom-ui/styles/badges.less new file mode 100644 index 00000000000..048113d89b0 --- /dev/null +++ b/static/atom-ui/styles/badges.less @@ -0,0 +1,64 @@ +@import "ui-variables"; + +.badge { + display: inline-block; + line-height: 1; + vertical-align: middle; + font-weight: normal; + text-align: center; + white-space: nowrap; + border-radius: 1em; + + &:empty { + display: none; // Hide when un-used + } + + + // Color ---------------------- + + .badge-color( @fg: @text-color-selected; @bg: @background-color-selected; ) { + color: @fg; + background-color: @bg; + } + .badge-color(); + &.badge-info { .badge-color(white, @background-color-info); } + &.badge-success { .badge-color(white, @background-color-success); } + &.badge-warning { .badge-color(white, @background-color-warning); } + &.badge-error { .badge-color(white, @background-color-error); } + + + // Size ---------------------- + + .badge-size( @size: @font-size; ) { + @padding: round(@size/4); + font-size: @size; + min-width: @size + @padding*2; + padding: @padding round(@padding*1.5); + } + .badge-size(); // default + + // Fixed size + &.badge-large { .badge-size(18px); } + &.badge-medium { .badge-size(14px); } + &.badge-small { .badge-size(10px); } + + // Flexible size + // The size changes depending on the parent element + // Best used for larger sizes, since em's can cause rounding errors + &.badge-flexible { + @size: .8em; + @padding: @size/2; + font-size: @size; + min-width: @size + @padding*2; + padding: @padding @padding*1.5; + } + + + // Icon ---------------------- + + &.icon { + font-size: round(@component-icon-size*0.8); + padding: @component-icon-padding @component-icon-padding*2; + } + +} diff --git a/static/atom-ui/styles/button-groups.less b/static/atom-ui/styles/button-groups.less new file mode 100644 index 00000000000..430522071fe --- /dev/null +++ b/static/atom-ui/styles/button-groups.less @@ -0,0 +1,187 @@ +@import "variables/variables"; +@import "ui-variables"; +@import "mixins/mixins"; + +// +// Button groups +// -------------------------------------------------- + +// Make the div behave like a button +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; // match .btn alignment given font-size hack above + > .btn { + position: relative; + float: left; + // Bring the "active" button to the front + &:hover, + &:focus, + &:active, + &.active { + z-index: 2; + } + } +} + + +// Borders +// --------------------------------------------------------- + +.btn-group > .btn { + border-left: 1px solid @button-border-color; + border-right: 1px solid @button-border-color; +} +.btn-group > .btn:first-child { + border-left: none; + border-top-left-radius: @component-border-radius; + border-bottom-left-radius: @component-border-radius; +} +.btn-group > .btn:last-child, +.btn-group > .btn.selected:last-child, +.btn-group > .dropdown-toggle { + border-right: none; + border-top-right-radius: @component-border-radius; + border-bottom-right-radius: @component-border-radius; +} + +// Prevent double borders when buttons are next to each other +.btn-group { + .btn + .btn, + .btn + .btn-group, + .btn-group + .btn, + .btn-group + .btn-group { + margin-left: -1px; + } +} + +// Optional: Group multiple button groups together for a toolbar +.btn-toolbar { + margin-left: -5px; // Offset the first child's margin + &:extend(.clearfix all); + + .btn, + .btn-group, + .input-group { + float: left; + } + > .btn, + > .btn-group, + > .input-group { + margin-left: 5px; + } +} + +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} + +// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match +.btn-group > .btn:first-child { + margin-left: 0; + &:not(:last-child):not(.dropdown-toggle) { + .border-right-radius(0); + } +} +// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + .border-left-radius(0); +} + +// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group) +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) { + > .btn:last-child, + > .dropdown-toggle { + .border-right-radius(0); + } +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + .border-left-radius(0); +} + +// On active and open, don't show outline +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} + + +// Sizing +// +// Remix the default button sizing classes into new ones for easier manipulation. + +.btn-group-xs > .btn { &:extend(.btn-xs); } +.btn-group-sm > .btn { &:extend(.btn-sm); } +.btn-group-lg > .btn { &:extend(.btn-lg); } + + +// Split button dropdowns +// ---------------------- + +// Give the line between buttons some depth +.btn-group > .btn + .dropdown-toggle { + padding-left: 8px; + padding-right: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-left: 12px; + padding-right: 12px; +} + +// The clickable button for toggling the menu +// Remove the gradient and set the same inset shadow as the :active state +.btn-group.open .dropdown-toggle { + box-shadow: inset 0 3px 5px rgba(0,0,0,.125); + + // Show no shadow for `.btn-link` since it has no other button styles. + &.btn-link { + box-shadow: none; + } +} + + +// Reposition the caret +.btn .caret { + margin-left: 0; +} +// Carets in other button sizes +.btn-lg .caret { + border-width: @caret-width-large @caret-width-large 0; + border-bottom-width: 0; +} +// Upside down carets for .dropup +.dropup .btn-lg .caret { + border-width: 0 @caret-width-large @caret-width-large; +} + + +// Justified button groups +// ---------------------- + +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; + > .btn, + > .btn-group { + float: none; + display: table-cell; + width: 1%; + } + > .btn-group .btn { + width: 100%; + } + + > .btn-group .dropdown-menu { + left: auto; + } +} diff --git a/static/atom-ui/styles/buttons.less b/static/atom-ui/styles/buttons.less new file mode 100644 index 00000000000..5f5c35d9b25 --- /dev/null +++ b/static/atom-ui/styles/buttons.less @@ -0,0 +1,274 @@ +@import "variables/variables"; +@import "ui-variables"; +@import "mixins/mixins"; + +// +// Buttons +// -------------------------------------------------- + + +// Base styles +// -------------------------------------------------- + +.btn { + display: inline-block; + margin-bottom: 0; // For input.btn + height: @component-line-height + 2px; + padding: 0 @component-padding; + font-size: @font-size; + font-weight: normal; + line-height: @component-line-height; + text-align: center; + vertical-align: middle; + border: none; + border-radius: @component-border-radius; + background-color: @btn-default-bg; + white-space: nowrap; + cursor: pointer; + z-index: 0; + -webkit-user-select: none; + + &, + &:active, + &.active { + &:focus, + &.focus { + .tab-focus(); + } + } + + &:hover, + &:focus, + &.focus { + color: @btn-default-color; + text-decoration: none; + background-color: @button-background-color-hover; + } + + &:active, + &.active { + outline: 0; + background-image: none; + box-shadow: inset 0 3px 5px rgba(0,0,0,.125); + } + + &.selected, + &.selected:hover { + // we want the selected button to behave like the :hover button; it's on top of the other buttons. + z-index: 1; + color: @text-color-selected; + background-color: @button-background-color-selected; + } + + &.disabled, + &[disabled], + fieldset[disabled] & { + cursor: @cursor-disabled; + opacity: .65; + box-shadow: none; + } + + a& { + &.disabled, + fieldset[disabled] & { + pointer-events: none; // Future-proof disabling of clicks on `` elements + } + } +} + + +// Button variants +// -------------------------------------------------- + +.button-variant(@color; @background;) { + color: @color; + background-color: @background; + + &:focus, + &.focus { + color: @color; + background-color: darken(@background, 10%); + } + &:hover { + color: @color; + background-color: darken(@background, 10%); + } + &:active, + &.active { + color: @color; + background-color: darken(@background, 10%); + + &:hover, + &:focus, + &.focus { + color: @color; + background-color: darken(@background, 17%); + } + } + &.selected, + &.selected:hover { + // we want the selected button to behave like the :hover button; it's on top of the other buttons. + z-index: 1; + background-color: darken(@background, 10%); + } + + &.disabled, + &[disabled], + fieldset[disabled] & { + &:hover, + &:focus, + &.focus { + background-color: @background; + } + } + + .badge { + color: @background; + background-color: @color; + } +} + +.btn-primary { + .button-variant(@btn-primary-color; @btn-primary-bg;); +} +// Success appears as green +.btn-success { + .button-variant(@btn-success-color; @btn-success-bg;); +} +// Info appears as blue-green +.btn-info { + .button-variant(@btn-info-color; @btn-info-bg;); +} +// Warning appears as orange +.btn-warning { + .button-variant(@btn-warning-color; @btn-warning-bg;); +} +// Danger and error appear as red +.btn-error { + .button-variant(@btn-error-color; @btn-error-bg;); +} + + +// Button Sizes +// -------------------------------------------------- + +.btn-xs, +.btn-group-xs > .btn { + padding: @component-padding/4 @component-padding/2; + font-size: @font-size - 2px; + height: auto; + line-height: 1.3em; + &.icon:before { + font-size: @font-size - 2px; + } +} +.btn-sm, +.btn-group-sm > .btn { + padding: @component-padding/4 @component-padding/2; + height: auto; + line-height: 1.3em; + &.icon:before { + font-size: @font-size + 1px; + } +} +.btn-lg, +.btn-group-lg > .btn { + font-size: @font-size + 2px; + padding: @component-padding - 2px @component-padding + 2px; + height: auto; + line-height: 1.3em; + &.icon:before { + font-size: @font-size + 6px; + } +} + + +// Link button +// ------------------------- + +// Make a button look and behave like a link +.btn-link { + color: @link-color; + font-weight: normal; + border-radius: 0; + &, + &:active, + &.active, + &[disabled], + fieldset[disabled] & { + background-color: transparent; + box-shadow: none; + } + &:hover, + &:focus { + color: @link-hover-color; + text-decoration: @link-hover-decoration; + background-color: transparent; + } + &[disabled], + fieldset[disabled] & { + &:hover, + &:focus { + color: @btn-link-disabled-color; + text-decoration: none; + } + } +} + + +// Block button +// -------------------------------------------------- + +.btn-block { + display: block; + width: 100%; +} + +// Vertically space out multiple block buttons +.btn-block + .btn-block { + margin-top: 5px; +} + +// Specificity overrides +input[type="submit"], +input[type="reset"], +input[type="button"] { + &.btn-block { + width: 100%; + } +} + + +// Icon buttons +// -------------------------------------------------- + +.btn.icon { + &:before { + width: initial; + height: initial; + margin-right: .3125em; + } + &:empty:before { + margin-right: 0; + } +} + + +// Button Toolbar +// -------------------------------------------------- + +.btn-toolbar { + > .btn-group + .btn-group, + > .btn-group + .btn, + > .btn + .btn { + float: none; + display: inline-block; + margin-left: 0; + } + > * { + margin-right: @component-padding / 2; + } + > *:last-child { + margin-right: 0; + } +} diff --git a/static/atom-ui/styles/git-status.less b/static/atom-ui/styles/git-status.less new file mode 100644 index 00000000000..ead7ed73dd3 --- /dev/null +++ b/static/atom-ui/styles/git-status.less @@ -0,0 +1,13 @@ +@import "ui-variables"; + +// +// Git Status +// -------------------------------------------------- + +.status { + &-ignored { color: @text-color-subtle; } + &-added { color: @text-color-success; } + &-modified { color: @text-color-warning; } + &-removed { color: @text-color-error; } + &-renamed { color: @text-color-info; } +} diff --git a/static/icons.less b/static/atom-ui/styles/icons.less similarity index 89% rename from static/icons.less rename to static/atom-ui/styles/icons.less index 4fe56a8c1b1..993ba28f8c3 100644 --- a/static/icons.less +++ b/static/atom-ui/styles/icons.less @@ -4,8 +4,7 @@ margin-right: @component-icon-padding; } -a.icon, -button.icon { +a.icon { text-decoration: none; color: @text-color; &:hover{ diff --git a/static/atom-ui/styles/inputs.less b/static/atom-ui/styles/inputs.less new file mode 100644 index 00000000000..591de55db0f --- /dev/null +++ b/static/atom-ui/styles/inputs.less @@ -0,0 +1,383 @@ +@import "./variables/ui-variables"; // Fallback for @use-custom-controls +@import "ui-variables"; + +@component-size: @component-icon-size; // use for text-less controls like radio, checkboxes etc. +@component-margin-side: .3em; +@text-component-height: 2em; +@component-background-color: mix(@text-color, @base-background-color, 20%); + + +// +// Overrides +// ------------------------- + +input.input-radio, +input.input-checkbox, +input.input-toggle { + margin-top: 0; // Override Bootstrap's 4px +} +.input-label { + margin-bottom: 0; +} + +// +// Mixins +// ------------------------- + +.input-field-mixin() { + padding: .25em .4em; + line-height: 1.5; // line-height + padding = @text-component-height + border-radius: @component-border-radius; + border: 1px solid @input-border-color; + background-color: @input-background-color; + &::-webkit-input-placeholder { + color: @text-color-subtle; + } + &:invalid { + color: @text-color-error; + border-color: @background-color-error; + } +} + +.input-block-mixin() { + display: block; + width: 100%; +} + + +// +// Checkbox +// ------------------------- + +.input-checkbox { + vertical-align: middle; + + & when (@use-custom-controls) { + -webkit-appearance: none; + display: inline-block; + position: relative; + width: @component-size; + height: @component-size; + font-size: inherit; + border-radius: @component-border-radius; + background-color: @component-background-color; + transition: background-color .16s cubic-bezier(0.5, 0.15, 0.2, 1); + + &&:focus { + outline: 0; // TODO: Add it back + } + &:active { + background-color: @background-color-info; + } + + &:before, + &:after { + content: ""; + position: absolute; + top: @component-size * .75; + left: @component-size * .4; + height: 2px; + border-radius: 1px; + background-color: @base-background-color; + transform-origin: 0 0; + opacity: 0; + transition: transform .1s cubic-bezier(0.5, 0.15, 0.2, 1), opacity .1s cubic-bezier(0.5, 0.15, 0.2, 1); + } + &:before { + width: @component-size * .33; + transform: translate3d(0,0,0) rotate(225deg) scale(0); + } + &:after { + width: @component-size * .66; + margin: -1px; + transform: translate3d(0,0,0) rotate(-45deg) scale(0); + transition-delay: .05s; + } + + &:checked { + background-color: @background-color-info; + &:active { + background-color: @component-background-color; + } + &:before { + opacity: 1; + transform: translate3d(0,0,0) rotate(225deg) scale(1); + transition-delay: .05s; + } + &:after { + opacity: 1; + transform: translate3d(0, 0, 0) rotate(-45deg) scale(1); + transition-delay: 0; + } + } + + &:indeterminate { + background-color: @background-color-info; + &:active { + background-color: @component-background-color; + } + &:after { + opacity: 1; + transform: translate3d(@component-size * -.14, @component-size * -.25, 0) rotate(0deg) scale(1); + transition-delay: 0; + } + } + } +} + + +// +// Color +// ------------------------- + + +.input-color { + vertical-align: middle; + + & when (@use-custom-controls) { + -webkit-appearance: none; + padding: 0; + width: @component-size * 2.5; + height: @component-size * 2.5; + border-radius: 50%; + border: 2px solid @input-border-color; + background-color: @input-background-color; + &::-webkit-color-swatch-wrapper { padding: 0; } + &::-webkit-color-swatch { + border: 1px solid hsla(0,0%,0%,.1); + border-radius: 50%; + transition: transform .16s cubic-bezier(0.5, 0.15, 0.2, 1); + &:active { + transition-duration: 0s; + transform: scale(.9); + } + } + } +} + + + +// +// Label +// ------------------------- + +.input-label { + .input-radio, + .input-checkbox, + .input-toggle { + margin-top: -.25em; // Vertical center (visually) - since most labels are upper case. + margin-right: @component-margin-side; + } +} + + +// +// Number +// ------------------------- + +.input-number { + vertical-align: middle; + + & when (@use-custom-controls) { + .input-field-mixin(); + position: relative; + width: auto; + .platform-darwin & { + padding-right: 1.2em; // space for the spin button + &::-webkit-inner-spin-button { + -webkit-appearance: menulist-button; + position: absolute; + top: 1px; + bottom: 1px; + right: 1px; + width: calc(.6em ~'+' 9px); // magic numbers, OMG! + outline: 1px solid @input-background-color; + outline-offset: -1px; // reduces border radius (that can't be changed) + border-right: .2em solid @background-color-highlight; // a bit more padding + background-color: @background-color-highlight; + transition: transform .16s cubic-bezier(0.5, 0.15, 0.2, 1); + &:active { + transform: scale(.9); + transition-duration: 0s; + } + } + } + } +} + + +// +// Radio +// ------------------------- + +.input-radio { + vertical-align: middle; + + & when (@use-custom-controls) { + -webkit-appearance: none; + display: inline-block; + position: relative; + width: @component-size; + height: @component-size; + font-size: inherit; + border-radius: 50%; + background-color: @component-background-color; + transition: background-color .16s cubic-bezier(0.5, 0.15, 0.2, 1); + + &:before { + content: ""; + position: absolute; + width: inherit; + height: inherit; + border-radius: inherit; + border: @component-size/3 solid transparent; + background-clip: content-box; + background-color: @base-background-color; + transform: scale(0); + transition: transform .1s cubic-bezier(0.5, 0.15, 0.2, 1); + } + &&:focus { + outline: none; + } + &:active { + background-color: @background-color-info; + } + &:checked { + background-color: @background-color-info; + &:before { + transform: scale(1); + } + } + } +} + + +// +// Range (Slider) +// ------------------------- + +.input-range { + & when (@use-custom-controls) { + -webkit-appearance: none; + margin: @component-padding 0; + height: 4px; + border-radius: @component-border-radius; + background-color: @component-background-color; + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: @component-size; + height: @component-size; + border-radius: 50%; + background-color: @background-color-info; + transition: transform .16s; + &:active { + transition-duration: 0s; + transform: scale(.9); + } + } + } +} + + +// +// Search +// ------------------------- + +.input-search { + .input-block-mixin(); + &&::-webkit-search-cancel-button { + -webkit-appearance: searchfield-cancel-button; + } + + & when (@use-custom-controls) { + .input-field-mixin(); + } +} + + +// +// Select +// ------------------------- + +.input-select { + vertical-align: middle; + + & when (@use-custom-controls) { + height: calc(@text-component-height ~'+' 2px); // + 2px? Magic! + border-radius: @component-border-radius; + border: 1px solid @button-border-color; + background-color: @button-background-color; + } +} + + +// +// Text +// ------------------------- + +.input-text { + .input-block-mixin(); + + & when (@use-custom-controls) { + .input-field-mixin(); + } +} + + +// +// Text Area +// ------------------------- + +.input-textarea { + .input-block-mixin(); + + & when (@use-custom-controls) { + .input-field-mixin(); + } +} + + +// +// Toggle +// ------------------------- + +.input-toggle { + & when (@use-custom-controls) { + -webkit-appearance: none; + display: inline-block; + position: relative; + font-size: inherit; + width: @component-size * 2; + height: @component-size; + vertical-align: middle; + border-radius: 2em; + background-color: @component-background-color; + transition: background-color .2s cubic-bezier(0.5, 0.15, 0.2, 1); + + &&:focus { + outline: 0; + } + &:checked { + background-color: @background-color-info; + } + + // Thumb + &:before { + content: ""; + position: absolute; + width: @component-size; + height: @component-size; + border-radius: inherit; + border: @component-size/4 solid transparent; + background-clip: content-box; + background-color: @base-background-color; + transition: transform .2s cubic-bezier(0.5, 0.15, 0.2, 1); + } + &:active:before { + opacity: .5; + } + &:checked:before { + transform: translate3d(100%, 0, 0); + } + } +} diff --git a/static/atom-ui/styles/layout.less b/static/atom-ui/styles/layout.less new file mode 100644 index 00000000000..83560fab966 --- /dev/null +++ b/static/atom-ui/styles/layout.less @@ -0,0 +1,85 @@ +@import "ui-variables"; +@import "mixins/mixins"; + +.padded { + padding: @component-padding; +} + +// Blocks + +.center-block { + display: block; + margin-left: auto; + margin-right: auto; +} + +// Must be div.block so as not to affect syntax highlighting. +ul.block, +div.block { + margin-bottom: @component-padding; +} +div > ul.block:last-child, +div > div.block:last-child { + margin-bottom: 0; +} + +// Inline Blocks + +.inline-block, +.inline-block-tight { + display: inline-block; + vertical-align: middle; +} +.inline-block { + margin-right: @component-padding; +} +.inline-block-tight { + margin-right: @component-padding/2; +} +div > .inline-block:last-child, +div > .inline-block-tight:last-child { + margin-right: 0; +} + +.inline-block .inline-block { + vertical-align: top; +} + +// Centering +// ------------------------- + +.pull-center { + margin-left: auto; + margin-right: auto; +} + +// Floats +// ------------------------- + +// Use left margin when it's in a float: right element. +// Sets the margin correctly when inline blocks are hidden and shown. +.pull-right { + float: right !important; + + .inline-block { + margin-right: 0; + margin-left: @component-padding; + } + .inline-block-tight { + margin-right: 0; + margin-left: @component-padding/2; + } + + > .inline-block:first-child, + > .inline-block-tight:first-child { + margin-left: 0; + } +} + +.pull-left { + float: left !important; +} + +.clearfix { + .clearfix(); +} diff --git a/static/atom-ui/styles/lists.less b/static/atom-ui/styles/lists.less new file mode 100644 index 00000000000..d8a32d9190a --- /dev/null +++ b/static/atom-ui/styles/lists.less @@ -0,0 +1,291 @@ +@import "variables/variables"; +@import "ui-variables"; +@import "mixins/mixins"; + +@import "octicon-mixins"; + +// +// List options +// -------------------------------------------------- + +// Unstyled keeps list items block level, just removes default browser padding and list-style +.list-unstyled { + padding-left: 0; + list-style: none; +} + +// Inline turns list items into inline-block +.list-inline { + .list-unstyled(); + margin-left: -5px; + + > li { + display: inline-block; + padding-left: 5px; + padding-right: 5px; + } +} + + +// +// List groups +// -------------------------------------------------- + +// Mixins + +.list-group-item-variant(@state; @background; @color) { + .list-group-item-@{state} { + color: @color; + background-color: @background; + + a&, + button& { + color: @color; + + .list-group-item-heading { + color: inherit; + } + + &:hover, + &:focus { + color: @color; + background-color: darken(@background, 5%); + } + &.active, + &.active:hover, + &.active:focus { + color: #fff; + background-color: @color; + border-color: @color; + } + } + } +} + + + +// Individual list items +// +// Use on `li`s or `div`s within the `.list-group` parent. + +.list-group-item { + position: relative; + display: block; + padding: 10px 15px; + // Place the border on the list items and negative margin up for better styling + margin-bottom: -1px; + background-color: @list-group-bg; + border: 1px solid @list-group-border; + + // Round the first and last items + &:first-child { + .border-top-radius(@list-group-border-radius); + } + &:last-child { + margin-bottom: 0; + .border-bottom-radius(@list-group-border-radius); + } +} + + +// Interactive list items +// +// Use anchor or button elements instead of `li`s or `div`s to create interactive items. +// Includes an extra `.active` modifier class for showing selected items. + +a.list-group-item, +button.list-group-item { + color: @list-group-link-color; + + .list-group-item-heading { + color: @list-group-link-heading-color; + } + + // Hover state + &:hover, + &:focus { + text-decoration: none; + color: @list-group-link-hover-color; + background-color: @list-group-hover-bg; + } +} + +button.list-group-item { + width: 100%; + text-align: left; +} + +.list-group-item { + // Disabled state + &.disabled, + &.disabled:hover, + &.disabled:focus { + background-color: @list-group-disabled-bg; + color: @list-group-disabled-color; + cursor: @cursor-disabled; + + // Force color to inherit for custom content + .list-group-item-heading { + color: inherit; + } + .list-group-item-text { + color: @list-group-disabled-text-color; + } + } + + // Active class on item itself, not parent + &.active, + &.active:hover, + &.active:focus { + z-index: 2; // Place active items above their siblings for proper border styling + color: @list-group-active-color; + background-color: @list-group-active-bg; + border-color: @list-group-active-border; + + // Force color to inherit for custom content + .list-group-item-heading, + .list-group-item-heading > small, + .list-group-item-heading > .small { + color: inherit; + } + .list-group-item-text { + color: @list-group-active-text-color; + } + } +} + + +// Contextual variants +// +// Add modifier classes to change text and background color on individual items. +// Organizationally, this must come after the `:hover` states. + +.list-group-item-variant(success; @state-success-bg; @state-success-text); +.list-group-item-variant(info; @state-info-bg; @state-info-text); +.list-group-item-variant(warning; @state-warning-bg; @state-warning-text); +.list-group-item-variant(danger; @state-danger-bg; @state-danger-text); + + +// Custom content options +// +// Extra classes for creating well-formatted content within `.list-group-item`s. + +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} + + + +// This is a bootstrap override +// --------------------------------------------- + +.list-group, +.list-group .list-group-item { + background-color: transparent; + border: none; + padding: 0; + margin: 0; + position: static; +} + +.list-group, +.list-tree { + margin: 0; + padding: 0; + list-style: none; + cursor: default; + + li:not(.list-nested-item), + li.list-nested-item > .list-item { + line-height: @component-line-height; + text-wrap: none; + white-space: nowrap; + } + + // The background highlight uses ::before rather than the item background so + // it can span the entire width of the parent container rather than the size + // of the list item. + .selected::before { + content: ''; + background-color: @background-color-selected; + position: absolute; + left: 0; + right: 0; + height: @component-line-height; + } + + // Make sure the background highlight is below the content. + .selected > * { + position: relative; + } + + .icon::before { + margin-right: @component-icon-padding; + position: relative; + top: 1px; + } + .no-icon { + padding-left: @component-icon-padding + @component-icon-size; + } +} + + + +// +// List Tree +// -------------------------------------------------- + +// Handle indentation of the tree. Assume disclosure arrows. + +.list-tree { + .list-nested-item > .list-tree > li, + .list-nested-item > .list-group > li { + padding-left: @component-icon-size + @component-icon-padding; + } + + &.has-collapsable-children { + @disclosure-arrow-padding: @disclosure-arrow-size + @component-icon-padding; + li.list-item { + margin-left: @disclosure-arrow-padding; + } + + .list-nested-item.collapsed > .list-group, + .list-nested-item.collapsed > .list-tree { + display: none; + } + + // Nested items always get disclosure arrows + .list-nested-item > .list-item { + .octicon(chevron-down, @disclosure-arrow-size); + &::before{ + position: relative; + top: -1px; + margin-right: @component-icon-padding; + } + } + .list-nested-item.collapsed > .list-item { + .octicon(chevron-right, @disclosure-arrow-size); + &::before{ + left: 1px; + } + } + + .list-nested-item > .list-tree > li, + .list-nested-item > .list-group > li { + padding-left: @disclosure-arrow-padding; + } + + // You want a subtree to be flat -- no collapsable children + .has-flat-children, + &.has-flat-children { + li.list-item { + margin-left: 0; + } + } + } +} diff --git a/static/atom-ui/styles/loading.less b/static/atom-ui/styles/loading.less new file mode 100644 index 00000000000..dff5b178228 --- /dev/null +++ b/static/atom-ui/styles/loading.less @@ -0,0 +1,21 @@ +// +// Loading +// -------------------------------------------------- + +.loading-spinner(@size) { + display: block; + width: @size; + height: @size; + background-image: url(images/octocat-spinner-128.gif); + background-repeat: no-repeat; + background-size: cover; + + &.inline-block { + display: inline-block; + } +} + +.loading-spinner-tiny { .loading-spinner(16px); } +.loading-spinner-small { .loading-spinner(32px); } +.loading-spinner-medium { .loading-spinner(48px); } +.loading-spinner-large { .loading-spinner(64px); } diff --git a/static/atom-ui/styles/messages.less b/static/atom-ui/styles/messages.less new file mode 100644 index 00000000000..a679c92cb4f --- /dev/null +++ b/static/atom-ui/styles/messages.less @@ -0,0 +1,41 @@ +@import "ui-variables"; + +.info-messages, +.error-messages { + margin: 0; + padding: 0; + list-style: none; +} + +.error-messages { + color: @text-color-error; +} + +ul.background-message { + font-size: @font-size * 3; + + margin: 0; + padding: 0; + + li { + margin: 0; + padding: 0; + list-style: none; + } + + &.centered { + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + align-items: center; + text-align: center; + + li { + width: 100%; + } + } +} diff --git a/static/atom-ui/styles/mixins/mixins.less b/static/atom-ui/styles/mixins/mixins.less new file mode 100644 index 00000000000..afd8d51369c --- /dev/null +++ b/static/atom-ui/styles/mixins/mixins.less @@ -0,0 +1,88 @@ +@import "ui-variables"; + +// Core mixins +// ---------------------------------------- + +// Focus +// +.tab-focus() { + outline: 2px auto @text-color-info; + outline-offset: -2px; +} + + +// Border-radius +// +.border-top-radius(@radius) { + border-top-right-radius: @radius; + border-top-left-radius: @radius; +} +.border-right-radius(@radius) { + border-bottom-right-radius: @radius; + border-top-right-radius: @radius; +} +.border-bottom-radius(@radius) { + border-bottom-right-radius: @radius; + border-bottom-left-radius: @radius; +} +.border-left-radius(@radius) { + border-bottom-left-radius: @radius; + border-top-left-radius: @radius; +} + + +// Clearfix +// +// For modern browsers +// 1. The space content is one way to avoid an Opera bug when the +// contenteditable attribute is included anywhere else in the document. +// Otherwise it causes space to appear at the top and bottom of elements +// that are clearfixed. +// 2. The use of `table` rather than `block` is only necessary if using +// `:before` to contain the top-margins of child elements. +// +// Source: http://nicolasgallagher.com/micro-clearfix-hack/ + +.clearfix() { + &:before, + &:after { + content: " "; // 1 + display: table; // 2 + } + &:after { + clear: both; + } +} + + +// CSS image replacement +// +// Heads up! v3 launched with only `.hide-text()`, but per our pattern for +// mixins being reused as classes with the same name, this doesn't hold up. As +// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`. +// +// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757 + +// Deprecated as of v3.0.1 (has been removed in v4) +.hide-text() { + font: ~"0/0" a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} + +// New mixin to use as of v3.0.1 +.text-hide() { + .hide-text(); +} + + +// Text overflow +// Requires inline-block or block for proper styling + +.text-overflow() { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/static/atom-ui/styles/modals.less b/static/atom-ui/styles/modals.less new file mode 100644 index 00000000000..8857f3a68c9 --- /dev/null +++ b/static/atom-ui/styles/modals.less @@ -0,0 +1,83 @@ +@import "ui-variables"; + +// +// Modals +// -------------------------------------------------- + +.overlay, // deprecated .overlay +atom-panel.modal { + position: absolute; + display: block; + top: 0; + left: 50%; + width: 500px; + margin-left: -250px; + z-index: 9999; + box-sizing: border-box; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + + color: @text-color; + background-color: @overlay-background-color; + + padding: 10px; + + // shrink modals when window gets narrow + @media (max-width: 500px) { + & { + width: 100%; + left: 0; + margin-left: 0; + } + } + + h1 { + margin-top: 0; + color: @text-color-highlight; + font-size: 1.6em; + font-weight: bold; + } + + h2 { + font-size: 1.3em; + } + + atom-text-editor[mini] { + margin-bottom: 10px; + } + + .message { + padding-top: 5px; + font-size: 11px; + } + + &.mini { + width: 200px; + margin-left: -100px; + font-size: 12px; + } +} + + +// Deprecated: overlay, from-top, from-bottom, floating +// -------------------------------------------------- +// TODO: Remove these! + +.overlay.from-top { + top: 0; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.overlay.from-bottom { + bottom: 0; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.overlay.floating { + left: auto; +} diff --git a/static/atom-ui/styles/panels.less b/static/atom-ui/styles/panels.less new file mode 100644 index 00000000000..72bfc4bb834 --- /dev/null +++ b/static/atom-ui/styles/panels.less @@ -0,0 +1,38 @@ +@import "ui-variables"; + +// +// Panels +// -------------------------------------------------- + +.tool-panel, // deprecated: .tool-panel +.panel, // deprecated: .panel +atom-panel { + background-color: @tool-panel-background-color; +} + +.inset-panel { + border-radius: @component-border-radius; + background-color: @inset-panel-background-color; +} + +.panel-heading { + margin: 0; + padding: @component-padding; + border-radius: 0; + font-size: @font-size; + line-height: 1; + background-color: @panel-heading-background-color; + + .inset-panel & { + border-radius: @component-border-radius @component-border-radius 0 0; + } + + .btn { + @btn-height: @component-line-height - 5px; + height: @btn-height; + line-height: @btn-height; + font-size: @font-size - 2px; + position: relative; + top: -5px; + } +} diff --git a/static/atom-ui/styles/private/README.md b/static/atom-ui/styles/private/README.md new file mode 100644 index 00000000000..a383db44881 --- /dev/null +++ b/static/atom-ui/styles/private/README.md @@ -0,0 +1,5 @@ +# Private components + +> Private! Don't use these in packages. + +If you need something, feel free to open an issue and it might can be made public. diff --git a/static/atom-ui/styles/private/alerts.less b/static/atom-ui/styles/private/alerts.less new file mode 100644 index 00000000000..cc32a8c8c8d --- /dev/null +++ b/static/atom-ui/styles/private/alerts.less @@ -0,0 +1,114 @@ +@import "../variables/variables"; +@import "ui-variables"; + +// +// Alerts +// -------------------------------------------------- + +//## Define alert colors, border radius, and padding. + +@alert-padding: 15px; +@alert-border-radius: @border-radius-base; +@alert-link-font-weight: bold; + +@alert-success-bg: @state-success-bg; +@alert-success-text: @state-success-text; +@alert-success-border: @state-success-border; + +@alert-info-bg: @state-info-bg; +@alert-info-text: @state-info-text; +@alert-info-border: @state-info-border; + +@alert-warning-bg: @state-warning-bg; +@alert-warning-text: @state-warning-text; +@alert-warning-border: @state-warning-border; + +@alert-danger-bg: @state-danger-bg; +@alert-danger-text: @state-danger-text; +@alert-danger-border: @state-danger-border; + + +//## variant mixin + +.alert-variant(@background; @border; @text-color) { + background-color: @background; + border-color: @border; + color: @text-color; + + hr { + border-top-color: darken(@border, 5%); + } + .alert-link { + color: darken(@text-color, 10%); + } +} + + +// Base styles +// ------------------------- + +.alert { + padding: @alert-padding; + margin-bottom: @line-height-computed; + border: 1px solid transparent; + border-radius: @alert-border-radius; + + // Headings for larger alerts + h4 { + margin-top: 0; + // Specified for the h4 to prevent conflicts of changing @headings-color + color: inherit; + } + + // Provide class for links that match alerts + .alert-link { + font-weight: @alert-link-font-weight; + } + + // Improve alignment and spacing of inner content + > p, + > ul { + margin-bottom: 0; + } + + > p + p { + margin-top: 5px; + } +} + +// Dismissible alerts +// +// Expand the right padding and account for the close button's positioning. + +.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0. +.alert-dismissible { + padding-right: (@alert-padding + 20); + + // Adjust close link position + .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; + } +} + +// Alternate styles +// +// Generate contextual modifier classes for colorizing the alert. + +.alert-success { + .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text); +} + +.alert-info { + .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text); +} + +.alert-warning { + .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text); +} + +.alert-danger { + .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text); +} diff --git a/static/atom-ui/styles/private/close.less b/static/atom-ui/styles/private/close.less new file mode 100644 index 00000000000..9befb2e9d7a --- /dev/null +++ b/static/atom-ui/styles/private/close.less @@ -0,0 +1,38 @@ +// +// Close icon (deprecated) +// -------------------------------------------------- + +.close { + @font-size-base: 14px; + @close-font-weight: bold; + @close-color: #000; + @close-text-shadow: 0 1px 0 #fff; + + float: right; + font-size: (@font-size-base * 1.5); + font-weight: @close-font-weight; + line-height: 1; + color: @close-color; + text-shadow: @close-text-shadow; + opacity: .2; + + &:hover, + &:focus { + color: @close-color; + text-decoration: none; + cursor: pointer; + opacity: .5; + } + + // Additional properties for button version + // iOS requires the button element instead of an anchor tag. + // If you want the anchor version, it requires `href="#"`. + // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile + button& { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; + } +} diff --git a/static/atom-ui/styles/private/code.less b/static/atom-ui/styles/private/code.less new file mode 100644 index 00000000000..a657ca70ee6 --- /dev/null +++ b/static/atom-ui/styles/private/code.less @@ -0,0 +1,77 @@ +@import "../variables/variables"; +@import "ui-variables"; + +// +// Code (inline and block) +// -------------------------------------------------- + +@code-color: @text-color-highlight; +@code-bg: @background-color-highlight; + +@pre-color: @code-color; +@pre-bg: @code-bg; +@pre-border-color: @base-border-color; +@pre-scrollable-max-height: 340px; + +// Inline and block code styles +code, +kbd, +pre, +samp { + font-family: @font-family-monospace; +} + +// Inline code +code { + padding: 2px 4px; + font-size: 90%; + color: @code-color; + background-color: @code-bg; + border-radius: @border-radius-base; +} + +// User input typically entered via keyboard +kbd { + padding: 2px 4px; + font-size: 90%; + color: @code-color; + background-color: @code-bg; + border-radius: @border-radius-small; + + kbd { + padding: 0; + font-size: 100%; + font-weight: bold; + } +} + +// Blocks of code +pre { + display: block; + padding: ((@line-height-computed - 1) / 2); + margin: 0 0 (@line-height-computed / 2); + font-size: (@font-size-base - 1); // 14px to 13px + line-height: @line-height-base; + word-break: break-all; + word-wrap: break-word; + color: @pre-color; + background-color: @pre-bg; + border: 1px solid @pre-border-color; + border-radius: @border-radius-base; + + // Account for some code outputs that place code tags in pre tags + code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; + } +} + +// Enable scrollable blocks of code +.pre-scrollable { + max-height: @pre-scrollable-max-height; + overflow-y: scroll; +} diff --git a/static/atom-ui/styles/private/forms.less b/static/atom-ui/styles/private/forms.less new file mode 100644 index 00000000000..c0172bb0d5c --- /dev/null +++ b/static/atom-ui/styles/private/forms.less @@ -0,0 +1,705 @@ +@import "../variables/variables"; +@import "ui-variables"; +@import "../mixins/mixins"; + +// +// Forms +// -------------------------------------------------- + + +@input-bg: #fff; //** `` background color +@input-bg-disabled: @gray-lighter; //** `` background color +@input-color: @gray; //** Text color for ``s +@input-border: #ccc; //** `` border color + +// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4 +//** Default `.form-control` border radius +// This has no effect on ``s in CSS. +@input-border-radius: @border-radius-base; //** Large `.form-control` border radius +@input-border-radius-large: @border-radius-large; //** Small `.form-control` border radius +@input-border-radius-small: @border-radius-small; +@input-border-focus: #66afe9; //** Border color for inputs on focus +@input-color-placeholder: #999; //** Placeholder text color +@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2); //** Default `.form-control` height +@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2); //** Large `.form-control` height +@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2); //** Small `.form-control` height + +@form-group-margin-bottom: 15px; //** `.form-group` margin + +@legend-color: @gray-dark; +@legend-border-color: #e5e5e5; + +@input-group-addon-bg: @gray-lighter; //** Background color for textual input addons +@input-group-addon-border-color: @input-border; //** Border color for textual input addons + +@cursor-disabled: not-allowed; //** Disabled cursor for form controls and buttons. + +@grid-gutter-width: 30px; //** Padding between columns. Gets divided in half for the left and right. + + +// Form validation states +// +// Used in forms.less to generate the form validation CSS for warnings, errors, +// and successes. + +.form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) { + // Color the label and help text + .help-block, + .control-label, + .radio, + .checkbox, + .radio-inline, + .checkbox-inline, + &.radio label, + &.checkbox label, + &.radio-inline label, + &.checkbox-inline label { + color: @text-color; + } + // Set the border and box shadow on specific inputs to match + .form-control { + border-color: @border-color; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075); // Redeclare so transitions work + &:focus { + border-color: darken(@border-color, 10%); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%); + } + } + // Set validation states also for addons + .input-group-addon { + color: @text-color; + border-color: @border-color; + background-color: @background-color; + } + // Optional feedback icon + .form-control-feedback { + color: @text-color; + } +} + + +// Form control focus state +// +// Generate a customized focus state and for any input with the specified color, +// which defaults to the `@input-border-focus` variable. +// +// We highly encourage you to not customize the default value, but instead use +// this to tweak colors on an as-needed basis. This aesthetic change is based on +// WebKit's default styles, but applicable to a wider range of browsers. Its +// usability and accessibility should be taken into account with any change. +// +// Example usage: change the default blue border and shadow to white for better +// contrast against a dark gray background. +.form-control-focus(@color: @input-border-focus) { + @color-rgba: rgba(red(@color), green(@color), blue(@color), .6); + &:focus { + border-color: @color; + outline: 0; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @color-rgba; + } +} + +// Form control sizing +// +// Relative text size, padding, and border-radii changes for form controls. For +// horizontal sizing, wrap controls in the predefined grid classes. `s in some browsers, due to the limited stylability of `s in IE10+. + &::-ms-expand { + border: 0; + background-color: transparent; + } + + // Disabled and read-only inputs + // + // HTML5 says that controls under a fieldset > legend:first-child won't be + // disabled if the fieldset is disabled. Due to implementation difficulty, we + // don't honor that edge case; we style them as disabled anyway. + &[disabled], + &[readonly], + fieldset[disabled] & { + background-color: @input-bg-disabled; + opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655 + } + + &[disabled], + fieldset[disabled] & { + cursor: @cursor-disabled; + } + + // Reset height for `textarea`s + textarea& { + height: auto; + } +} + + +// Form groups +// +// Designed to help with the organization and spacing of vertical forms. For +// horizontal forms, use the predefined grid classes. + +.form-group { + margin-bottom: @form-group-margin-bottom; +} + + +// Checkboxes and radios +// +// Indent the labels to position radios/checkboxes as hanging controls. + +.radio, +.checkbox { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; + + label { + min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + cursor: pointer; + } +} +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + position: absolute; + margin-left: -20px; + margin-top: 4px \9; +} + +.radio + .radio, +.checkbox + .checkbox { + margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing +} + +// Radios and checkboxes on same line +.radio-inline, +.checkbox-inline { + position: relative; + display: inline-block; + padding-left: 20px; + margin-bottom: 0; + vertical-align: middle; + font-weight: normal; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 10px; // space out consecutive inline controls +} + +// Apply same disabled cursor tweak as for inputs +// Some special care is needed because