Merge branch 'master' into b3-failing-seed

This commit is contained in:
Wliu
2017-11-12 13:27:28 +01:00
110 changed files with 21316 additions and 18185 deletions

16
.github/stale.yml vendored
View File

@@ -14,18 +14,18 @@ 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
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

View File

@@ -85,7 +85,7 @@ Here's a list of the big ones:
* [apm](https://github.com/atom/apm) - the `apm` command line tool (Atom Package Manager). You should use this repository for any contributions related to the `apm` tool and to publishing packages.
* [atom.io](https://github.com/atom/atom.io) - the repository for feedback on the [Atom.io website](https://atom.io) and the [Atom.io package API](https://github.com/atom/atom/blob/master/docs/apm-rest-api.md) used by [apm](https://github.com/atom/apm).
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](http://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/).
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, the [Atom FAQ](https://discuss.atom.io/c/faq) has instructions on how to [contact the maintainers of any Atom community package or theme.](https://discuss.atom.io/t/i-have-a-question-about-a-specific-atom-community-package-where-is-the-best-place-to-ask-it/25581)
@@ -199,16 +199,10 @@ If you want to read about using Atom or developing packages in Atom, the [Atom F
#### Local development
All packages can be developed locally, by checking out the corresponding repository and registering the package to Atom with `apm`:
Atom Core and all packages can be developed locally. For instructions on how to do this, see the following sections in the [Atom Flight Manual](http://flight-manual.atom.io):
```
$ git clone url-to-git-repository
$ cd path-to-package/
$ apm link -d
$ atom -d .
```
By running Atom with the `-d` flag, you signal it to run with development packages installed. `apm link` makes sure that your local repository is loaded by Atom.
* [Hacking on Atom Core][hacking-on-atom-core]
* [Contributing to Official Atom Packages][contributing-to-official-atom-packages]
### Pull Requests
@@ -500,3 +494,5 @@ Please open an issue on `atom/atom` if you have suggestions for new labels, and
[beginner]:https://github.com/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3Abeginner+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc
[help-wanted]:https://github.com/issues?q=is%3Aopen+is%3Aissue+label%3Ahelp-wanted+user%3Aatom+sort%3Acomments-desc+-label%3Abeginner
[contributing-to-official-atom-packages]:http://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/
[hacking-on-atom-core]: http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/

View File

@@ -81,10 +81,10 @@ repeat these steps to upgrade to future releases.
## Building
* [Linux](./docs/build-instructions/linux.md)
* [macOS](./docs/build-instructions/macOS.md)
* [FreeBSD](./docs/build-instructions/freebsd.md)
* [Windows](./docs/build-instructions/windows.md)
* [Linux](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux)
* [macOS](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac)
* [Windows](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows)
## License

View File

@@ -6,6 +6,6 @@
"url": "https://github.com/atom/atom.git"
},
"dependencies": {
"atom-package-manager": "1.18.8"
"atom-package-manager": "1.18.10"
}
}

View File

@@ -1,130 +1 @@
# Linux
Ubuntu LTS 12.04 64-bit is the recommended platform.
## Requirements
* OS with 64-bit or 32-bit architecture
* C++11 toolchain
* Git
* Node.js 6.x or later (we recommend installing it via [nvm](https://github.com/creationix/nvm))
* npm 3.10.x or later (run `npm install -g npm`)
* Ensure node-gyp uses python2 (run `npm config set python /usr/bin/python2 -g`, use `sudo` if you didn't install node via nvm)
* Development headers for [libsecret](https://wiki.gnome.org/Projects/Libsecret).
For more details, scroll down to find how to setup a specific Linux distro.
## Instructions
```sh
git clone https://github.com/atom/atom.git
cd atom
script/build
```
To also install the newly built application, use `--create-debian-package` or `--create-rpm-package` and then install the generated package via the system package manager.
### `script/build` Options
* `--compress-artifacts`: zips the generated application as `out/atom-{arch}.tar.gz`.
* `--create-debian-package`: creates a .deb package as `out/atom-{arch}.deb`
* `--create-rpm-package`: creates a .rpm package as `out/atom-{arch}.rpm`
* `--install[=dir]`: installs the application in `${dir}`; `${dir}` defaults to `/usr/local`.
### Ubuntu / Debian
* Install GNOME headers and other basic prerequisites:
```sh
sudo apt-get install build-essential git libsecret-1-dev fakeroot rpm libx11-dev libxkbfile-dev
```
* If `script/build` exits with an error, you may need to install a newer C++ compiler with C++11:
```sh
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt-get update
sudo apt-get install gcc-5 g++-5
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-5 80 --slave /usr/bin/g++ g++ /usr/bin/g++-5
sudo update-alternatives --config gcc # choose gcc-5 from the list
```
### Fedora 22+
* `sudo dnf --assumeyes install make gcc gcc-c++ glibc-devel git-core libsecret-devel rpmdevtools libX11-devel libxkbfile-devel`
### Fedora 21 / CentOS / RHEL
* `sudo yum install -y make gcc gcc-c++ glibc-devel git-core libsecret-devel rpmdevtools`
### Arch
* `sudo pacman -S --needed gconf base-devel git nodejs npm libsecret python2 libx11 libxkbfile`
* `export PYTHON=/usr/bin/python2` before building Atom.
### Slackware
* `sbopkg -k -i node -i atom`
### openSUSE
* `sudo zypper install nodejs nodejs-devel make gcc gcc-c++ glibc-devel git-core libsecret-devel rpmdevtools libX11-devel libxkbfile-devel`
## Troubleshooting
### TypeError: Unable to watch path
If you get following error with a big traceback right after Atom starts:
```
TypeError: Unable to watch path
```
you have to increase number of watched files by inotify. For testing if
this is the reason for this error you can issue
```sh
sudo sysctl fs.inotify.max_user_watches=32768
```
and restart Atom. If Atom now works fine, you can make this setting permanent:
```sh
echo 32768 | sudo tee -a /proc/sys/fs/inotify/max_user_watches
```
See also [#2082](https://github.com/atom/atom/issues/2082).
### /usr/bin/env: node: No such file or directory
If you get this notice when attempting to run any script, you either do not have
Node.js installed, or node isn't identified as Node.js on your machine. If it's
the latter, this might be caused by installing Node.js via the distro package
manager and not nvm, so entering `sudo ln -s /usr/bin/nodejs /usr/bin/node` into
your terminal may fix the issue. On some variants (mostly Debian based distros)
you can use `update-alternatives` too:
```sh
sudo update-alternatives --install /usr/bin/node node /usr/bin/nodejs 1 --slave /usr/bin/js js /usr/bin/nodejs
```
### AttributeError: 'module' object has no attribute 'script_main'
If you get following error with a big traceback while building Atom:
```
sys.exit(gyp.script_main()) AttributeError: 'module' object has no attribute 'script_main' gyp ERR!
```
you need to uninstall the system version of gyp.
On Fedora you would do the following:
```sh
sudo yum remove gyp
```
### Linux build error reports in atom/atom
* Use [this search](https://github.com/atom/atom/search?q=label%3Abuild-error+label%3Alinux&type=Issues)
to get a list of reports about build errors on Linux.
See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-linux) section in the [Atom Flight Manual](http://flight-manual.atom.io).

View File

@@ -1,29 +1 @@
# macOS
## Requirements
* macOS 10.8 or later
* Node.js 6.x or later (we recommend installing it via [nvm](https://github.com/creationix/nvm))
* npm 3.10.x or later (run `npm install -g npm`)
* Command Line Tools for [Xcode](https://developer.apple.com/xcode/downloads/) (run `xcode-select --install` to install)
## Instructions
```sh
git clone https://github.com/atom/atom.git
cd atom
script/build
```
To also install the newly built application, use `script/build --install`.
### `script/build` Options
* `--code-sign`: signs the application with the GitHub certificate specified in `$ATOM_MAC_CODE_SIGNING_CERT_DOWNLOAD_URL`.
* `--compress-artifacts`: zips the generated application as `out/atom-mac.zip`.
* `--install[=dir]`: installs the application at `${dir}/Atom.app` for dev and stable versions or at `${dir}/Atom-Beta.app` for beta versions; `${dir}` defaults to `/Applications`.
## Troubleshooting
### macOS build error reports in atom/atom
* Use [this search](https://github.com/atom/atom/search?q=label%3Abuild-error+label%3Amac&type=Issues) to get a list of reports about build errors on macOS.
See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-mac) section in the [Atom Flight Manual](http://flight-manual.atom.io).

View File

@@ -1,90 +1 @@
# Windows
## Requirements
* Node.js 6.9.4 or later (the architecture of node available to the build system will determine whether you build 32-bit or 64-bit Atom)
* Python v2.7.x
* The python.exe must be available at `%SystemDrive%\Python27\python.exe`. If it is installed elsewhere create a symbolic link to the directory containing the python.exe using: `mklink /d %SystemDrive%\Python27 D:\elsewhere\Python27`
* 7zip (7z.exe available from the command line) - for creating distribution zip files
* Visual Studio, either:
* [Visual C++ Build Tools 2015](http://landinghub.visualstudio.com/visual-cpp-build-tools)
* [Visual Studio 2013 Update 5](https://www.visualstudio.com/en-us/downloads/download-visual-studio-vs) (Express Edition or better)
* [Visual Studio 2015](https://www.visualstudio.com/en-us/downloads/download-visual-studio-vs) (Community Edition or better)
Also ensure that:
* The default installation folder is chosen so the build tools can find it
* If using Visual Studio make sure Visual C++ support is selected/installed
* If using Visual C++ Build Tools make sure Windows 8 SDK is selected/installed
* A `git` command is in your path
* Set the `GYP_MSVS_VERSION` environment variable to the Visual Studio/Build Tools version (`2013` or `2015`) e.g. ``[Environment]::SetEnvironmentVariable("GYP_MSVS_VERSION", "2015", "User")`` in PowerShell (or set it in Windows advanced system settings).
## Instructions
You can run these commands using Command Prompt, PowerShell, Git Shell, or any other terminal. These instructions will assume the use of Command Prompt.
```
cd C:\
git clone https://github.com/atom/atom.git
cd atom
script\build
```
To also install the newly built application, use `script\build --create-windows-installer` and launch the generated installers.
### `script\build` Options
* `--code-sign`: signs the application with the GitHub certificate specified in `$WIN_P12KEY_URL`.
* `--compress-artifacts`: zips the generated application as `out\atom-windows.zip` (requires [7-Zip](http://www.7-zip.org)).
* `--create-windows-installer`: creates an `.msi`, an `.exe` and two `.nupkg` packages in the `out` directory.
* `--install[=dir]`: installs the application in `${dir}\Atom\app-dev`; `${dir}` defaults to `%LOCALAPPDATA%`.
### Running tests
In order to run tests from command line you need `apm`, available after you install Atom or after you build from source. If you installed it, run the following commands (assuming `C:\atom` is the root of your Atom repository):
```bash
cd C:\atom
apm test
```
When building Atom from source, the `apm` command is not added to the system path by default. In this case, you can either add it yourself or explicitly list the complete path in previous commands. The default install location is `%LOCALAPPDATA%\Atom\app-dev\resources\cli\`.
**NOTE**: Please keep in mind that there are still some tests that don't pass on Windows.
## Troubleshooting
### Common Errors
* `node is not recognized`
* If you just installed Node.js, you'll need to restart Command Prompt before the `node` command is available on your path.
* `msbuild.exe failed with exit code: 1`
* If using **Visual Studio**, ensure you have the **Visual C++** component installed. Go into Add/Remove Programs, select Visual Studio, press Modify, and then check the Visual C++ box.
* If using **Visual C++ Build Tools**, ensure you have the **Windows 8 SDK** component installed. Go into Add/Remove Programs, select Visual C++ Build Tools, press Modify and then check the Windows 8 SDK box.
* `script\build` stops with no error or warning shortly after displaying the versions of node, npm and Python
* Make sure that the path where you have checked out Atom does not include a space. For example, use `C:\atom` instead of `C:\my stuff\atom`.
* Try moving the repository to `C:\atom`. Most likely, the path is too long. See [issue #2200](https://github.com/atom/atom/issues/2200).
* `error MSB4025: The project file could not be loaded. Invalid character in the given encoding.`
* This can occur because your home directory (`%USERPROFILE%`) has non-ASCII characters in it. This is a bug in [gyp](https://code.google.com/p/gyp/)
which is used to build native Node.js modules and there is no known workaround.
* https://github.com/TooTallNate/node-gyp/issues/297
* https://code.google.com/p/gyp/issues/detail?id=393
* `'node_modules\.bin\npm' is not recognized as an internal or external command, operable program or batch file.`
* This occurs if the previous build left things in a bad state. Run `script\clean` and then `script\build` again.
* `script\build` stops at installing runas with `Failed at the runas@x.y.z install script.`
* See the next item.
* `error MSB8020: The build tools for Visual Studio 201? (Platform Toolset = 'v1?0') cannot be found.`
* Try setting the `GYP_MSVS_VERSION` environment variable to **2013** or **2015** depending on what version of Visual Studio/Build Tools is installed and then `script\clean` followed by `script\build` (re-open the Command Prompt if you set the variable using the GUI).
* `'node-gyp' is not recognized as an internal or external command, operable program or batch file.`
* Try running `npm install -g node-gyp`, and run `script\build` again.
* Other `node-gyp` errors on first build attempt, even though the right Node.js and Python versions are installed.
* Do try the build command one more time as experience shows it often works on second try in many cases.
### Windows build error reports in atom/atom
* If all fails, use [this search](https://github.com/atom/atom/search?q=label%3Abuild-error+label%3Awindows&type=Issues) to get a list of reports about build errors on Windows, and see if yours has already been reported.
* If it hasn't, please open a new issue with your Windows version, architecture (x86 or x64), and a screenshot of your build output, including the Node.js and Python versions.
See the [Hacking on Atom Core](http://flight-manual.atom.io/hacking-atom/sections/hacking-on-atom-core/#platform-windows) section in the [Atom Flight Manual](http://flight-manual.atom.io).

View File

@@ -1,53 +1 @@
# Contributing to Official Atom Packages
If you think you know which package is causing the issue you are reporting, feel
free to open up the issue in that specific repository instead. When in doubt
just open the issue here but be aware that it may get closed here and reopened
in the proper package's repository.
## Hacking on Packages
### Cloning
The first step is creating your own clone.
For example, if you want to make changes to the `tree-view` package, fork the repo on your github account, then clone it:
```
> git clone git@github.com:your-username/tree-view.git
```
Next install all the dependencies:
```
> cd tree-view
> apm install
Installing modules ✓
```
Now you can link it to development mode so when you run an Atom window with `atom --dev`, you will use your fork instead of the built in package:
```
> apm link -d
```
### Running in Development Mode
Editing a package in Atom is a bit of a circular experience: you're using Atom
to modify itself. What happens if you temporarily break something? You don't
want the version of Atom you're using to edit to become useless in the process.
For this reason, you'll only want to load packages in **development mode** while
you are working on them. You'll perform your editing in **stable mode**, only
switching to development mode to test your changes.
To open a development mode window, use the "Application: Open Dev" command.
You can also run dev mode from the command line with `atom --dev`.
To load your package in development mode, create a symlink to it in
`~/.atom/dev/packages`. This occurs automatically when you clone the package
with `apm develop`. You can also run `apm link --dev` and `apm unlink --dev`
from the package directory to create and remove dev-mode symlinks.
### Installing Dependencies
You'll want to keep dependencies up to date by running `apm update` after pulling any upstream changes.
See http://flight-manual.atom.io/hacking-atom/sections/contributing-to-official-atom-packages/

View File

@@ -132,6 +132,7 @@
'ctrl-shift-w': 'editor:select-word'
'cmd-ctrl-left': 'editor:move-selection-left'
'cmd-ctrl-right': 'editor:move-selection-right'
'cmd-shift-V': 'editor:paste-without-reformatting'
# Emacs
'alt-f': 'editor:move-to-end-of-word'

View File

@@ -105,6 +105,7 @@
'alt-shift-right': 'editor:select-to-next-subword-boundary'
'alt-backspace': 'editor:delete-to-beginning-of-subword'
'alt-delete': 'editor:delete-to-end-of-subword'
'ctrl-shift-V': 'editor:paste-without-reformatting'
# Sublime Parity
'ctrl-a': 'core:select-all'

View File

@@ -110,6 +110,7 @@
'alt-shift-right': 'editor:select-to-next-subword-boundary'
'alt-backspace': 'editor:delete-to-beginning-of-subword'
'alt-delete': 'editor:delete-to-end-of-subword'
'ctrl-shift-V': 'editor:paste-without-reformatting'
# Sublime Parity
'ctrl-a': 'core:select-all'

View File

@@ -65,6 +65,7 @@
{ label: 'Copy', command: 'core:copy' }
{ label: 'Copy Path', command: 'editor:copy-path' }
{ label: 'Paste', command: 'core:paste' }
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
{ label: 'Select All', command: 'core:select-all' }
{ type: 'separator' }
{ label: 'Toggle Comments', command: 'editor:toggle-line-comments' }

View File

@@ -38,6 +38,7 @@
{ label: 'C&opy', command: 'core:copy' }
{ label: 'Copy Pat&h', command: 'editor:copy-path' }
{ label: '&Paste', command: 'core:paste' }
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
{ label: 'Select &All', command: 'core:select-all' }
{ type: 'separator' }
{ label: '&Toggle Comments', command: 'editor:toggle-line-comments' }

View File

@@ -46,6 +46,7 @@
{ label: '&Copy', command: 'core:copy' }
{ label: 'Copy Pat&h', command: 'editor:copy-path' }
{ label: '&Paste', command: 'core:paste' }
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
{ label: 'Select &All', command: 'core:select-all' }
{ type: 'separator' }
{ label: '&Toggle Comments', command: 'editor:toggle-line-comments' }

View File

@@ -1,7 +1,7 @@
{
"name": "atom",
"productName": "Atom",
"version": "1.23.0-dev",
"version": "1.24.0-dev",
"description": "A hackable text editor for the 21st Century.",
"main": "./src/main-process/main.js",
"repository": {
@@ -12,11 +12,12 @@
"url": "https://github.com/atom/atom/issues"
},
"license": "MIT",
"electronVersion": "1.6.14",
"electronVersion": "1.6.15",
"dependencies": {
"@atom/nsfw": "^1.0.18",
"@atom/source-map-support": "^0.3.4",
"async": "0.2.6",
"atom-keymap": "8.2.6",
"atom-keymap": "8.2.8",
"atom-select-list": "^0.1.0",
"atom-ui": "0.4.1",
"babel-core": "5.8.38",
@@ -24,14 +25,14 @@
"chai": "3.5.0",
"chart.js": "^2.3.0",
"clear-cut": "^2.0.2",
"coffee-script": "1.11.1",
"coffee-script": "1.12.7",
"color": "^0.7.3",
"dedent": "^0.6.0",
"dedent": "^0.7.0",
"devtron": "1.3.0",
"etch": "^0.12.6",
"event-kit": "^2.4.0",
"find-parent-dir": "^0.3.0",
"first-mate": "7.0.9",
"first-mate": "7.1.0",
"focus-trap": "^2.3.0",
"fs-admin": "^0.1.6",
"fs-plus": "^3.0.1",
@@ -53,7 +54,6 @@
"mocha-multi-reporters": "^1.1.4",
"mock-spawn": "^0.2.6",
"normalize-package-data": "^2.0.0",
"nsfw": "^1.0.15",
"nslog": "^3",
"oniguruma": "6.2.1",
"pathwatcher": "8.0.1",
@@ -65,12 +65,12 @@
"scandal": "^3.1.0",
"scoped-property-store": "^0.17.0",
"scrollbar-style": "^3.2",
"season": "^6.0.1",
"season": "^6.0.2",
"semver": "^4.3.3",
"service-hub": "^0.7.4",
"sinon": "1.17.4",
"temp": "^0.8.3",
"text-buffer": "13.5.3",
"text-buffer": "13.8.3",
"typescript-simple": "1.0.0",
"underscore-plus": "^1.6.6",
"winreg": "^1.2.1",
@@ -90,85 +90,85 @@
"solarized-dark-syntax": "1.1.2",
"solarized-light-syntax": "1.1.2",
"about": "1.7.8",
"archive-view": "0.63.4",
"autocomplete-atom-api": "0.10.3",
"autocomplete-css": "0.17.3",
"autocomplete-html": "0.8.2",
"autocomplete-plus": "2.36.2",
"autocomplete-snippets": "1.11.1",
"archive-view": "0.64.1",
"autocomplete-atom-api": "0.10.5",
"autocomplete-css": "0.17.4",
"autocomplete-html": "0.8.3",
"autocomplete-plus": "2.37.3",
"autocomplete-snippets": "1.11.2",
"autoflow": "0.29.0",
"autosave": "0.24.6",
"background-tips": "0.27.1",
"bookmarks": "0.44.4",
"bracket-matcher": "0.88.0",
"command-palette": "0.41.1",
"command-palette": "0.42.0",
"dalek": "0.2.1",
"deprecation-cop": "0.56.9",
"dev-live-reload": "0.47.1",
"encoding-selector": "0.23.7",
"exception-reporting": "0.41.4",
"find-and-replace": "0.212.3",
"fuzzy-finder": "1.6.1",
"github": "0.6.3",
"exception-reporting": "0.41.5",
"find-and-replace": "0.214.0",
"fuzzy-finder": "1.7.3",
"github": "0.8.2",
"git-diff": "1.3.6",
"go-to-line": "0.32.1",
"grammar-selector": "0.49.6",
"grammar-selector": "0.49.8",
"image-view": "0.62.4",
"incompatible-packages": "0.27.3",
"keybinding-resolver": "0.38.0",
"keybinding-resolver": "0.38.1",
"line-ending-selector": "0.7.4",
"link": "0.31.3",
"markdown-preview": "0.159.14",
"markdown-preview": "0.159.18",
"metrics": "1.2.6",
"notifications": "0.69.2",
"open-on-github": "1.2.1",
"open-on-github": "1.3.0",
"package-generator": "1.1.1",
"settings-view": "0.251.9",
"snippets": "1.1.4",
"spell-check": "0.72.2",
"status-bar": "1.8.13",
"styleguide": "0.49.7",
"settings-view": "0.253.0",
"snippets": "1.1.9",
"spell-check": "0.72.3",
"status-bar": "1.8.15",
"styleguide": "0.49.9",
"symbols-view": "0.118.1",
"tabs": "0.107.4",
"timecop": "0.36.0",
"tree-view": "0.218.0",
"update-package-dependencies": "0.12.0",
"tabs": "0.109.1",
"timecop": "0.36.2",
"tree-view": "0.221.3",
"update-package-dependencies": "0.13.0",
"welcome": "0.36.5",
"whitespace": "0.37.4",
"whitespace": "0.37.5",
"wrap-guide": "0.40.2",
"language-c": "0.58.1",
"language-clojure": "0.22.4",
"language-coffee-script": "0.49.1",
"language-coffee-script": "0.49.3",
"language-csharp": "0.14.3",
"language-css": "0.42.6",
"language-gfm": "0.90.1",
"language-css": "0.42.7",
"language-gfm": "0.90.2",
"language-git": "0.19.1",
"language-go": "0.44.2",
"language-html": "0.48.1",
"language-hyperlink": "0.16.2",
"language-java": "0.27.4",
"language-javascript": "0.127.5",
"language-go": "0.44.3",
"language-html": "0.48.2",
"language-hyperlink": "0.16.3",
"language-java": "0.27.6",
"language-javascript": "0.127.6",
"language-json": "0.19.1",
"language-less": "0.33.0",
"language-make": "0.22.3",
"language-mustache": "0.14.3",
"language-mustache": "0.14.4",
"language-objective-c": "0.15.1",
"language-perl": "0.37.0",
"language-php": "0.42.1",
"language-perl": "0.38.1",
"language-php": "0.42.2",
"language-property-list": "0.9.1",
"language-python": "0.45.4",
"language-ruby": "0.71.3",
"language-python": "0.45.5",
"language-ruby": "0.71.4",
"language-ruby-on-rails": "0.25.2",
"language-sass": "0.61.1",
"language-shellscript": "0.25.3",
"language-shellscript": "0.25.4",
"language-source": "0.9.0",
"language-sql": "0.25.8",
"language-text": "0.7.3",
"language-todo": "0.29.2",
"language-todo": "0.29.3",
"language-toml": "0.18.1",
"language-typescript": "0.2.1",
"language-typescript": "0.2.3",
"language-xml": "0.35.2",
"language-yaml": "0.31.0"
"language-yaml": "0.31.1"
},
"private": true,
"scripts": {

View File

@@ -27,47 +27,37 @@ module.exports = function (packagedAppPath) {
coreModules.has(modulePath) ||
(relativePath.startsWith(path.join('..', 'src')) && relativePath.endsWith('-element.js')) ||
relativePath.startsWith(path.join('..', 'node_modules', 'dugite')) ||
relativePath.endsWith(path.join('node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js')) ||
relativePath.endsWith(path.join('node_modules', 'fs-extra', 'lib', 'index.js')) ||
relativePath.endsWith(path.join('node_modules', 'graceful-fs', 'graceful-fs.js')) ||
relativePath.endsWith(path.join('node_modules', 'htmlparser2', 'lib', 'index.js')) ||
relativePath.endsWith(path.join('node_modules', 'minimatch', 'minimatch.js')) ||
relativePath === path.join('..', 'exports', 'atom.js') ||
relativePath === path.join('..', 'src', 'electron-shims.js') ||
relativePath === path.join('..', 'src', 'safe-clipboard.js') ||
relativePath === path.join('..', 'node_modules', 'atom-keymap', 'lib', 'command-event.js') ||
relativePath === path.join('..', 'node_modules', 'babel-core', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'cached-run-in-this-context', 'lib', 'main.js') ||
relativePath === path.join('..', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') ||
relativePath === path.join('..', 'node_modules', 'cson-parser', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') ||
relativePath === path.join('..', 'node_modules', 'decompress-zip', 'lib', 'decompress-zip.js') ||
relativePath === path.join('..', 'node_modules', 'debug', 'node.js') ||
relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'git-utils', 'src', 'git.js') ||
relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') ||
relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') ||
relativePath === path.join('..', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'markdown-preview', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'roaster', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'task-lists', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'iconv-lite', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'less', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less', 'fs.js') ||
relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less-node', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'less', 'node_modules', 'graceful-fs', 'graceful-fs.js') ||
relativePath === path.join('..', 'node_modules', 'minimatch', 'minimatch.js') ||
relativePath === path.join('..', 'node_modules', 'node-fetch', 'lib', 'fetch-error.js') ||
relativePath === path.join('..', 'node_modules', 'nsfw', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'superstring', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'oniguruma', 'src', 'oniguruma.js') ||
relativePath === path.join('..', 'node_modules', 'request', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'resolve', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'resolve', 'lib', 'core.js') ||
relativePath === path.join('..', 'node_modules', 'scandal', 'node_modules', 'minimatch', 'minimatch.js') ||
relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'glob', 'glob.js') ||
relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'minimatch', 'minimatch.js') ||
relativePath === path.join('..', 'node_modules', 'spellchecker', 'lib', 'spellchecker.js') ||
relativePath === path.join('..', 'node_modules', 'spelling-manager', 'node_modules', 'natural', 'lib', 'natural', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'tar', 'tar.js') ||
relativePath === path.join('..', 'node_modules', 'temp', 'lib', 'temp.js') ||
relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') ||
relativePath === path.join('..', 'node_modules', 'tree-view', 'node_modules', 'minimatch', 'minimatch.js')
relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js')
)
}
}).then((snapshotScript) => {

View File

@@ -71,7 +71,8 @@ const EXCLUDE_REGEXPS_SOURCES = [
'node_modules' + escapeRegExp(path.sep) + '.*' + escapeRegExp(path.sep) + 'examples?' + escapeRegExp(path.sep),
'node_modules' + escapeRegExp(path.sep) + '.*' + '\\.md$',
'node_modules' + escapeRegExp(path.sep) + '.*' + '\\.d\\.ts$',
'node_modules' + escapeRegExp(path.sep) + '.*' + '\\.js\\.map$'
'node_modules' + escapeRegExp(path.sep) + '.*' + '\\.js\\.map$',
'.*' + escapeRegExp(path.sep) + 'test.*\\.html$'
]
// Ignore spec directories in all bundled packages

View File

@@ -9,7 +9,7 @@
"csslint": "1.0.2",
"donna": "1.0.16",
"electron-chromedriver": "~1.6",
"electron-link": "0.1.1",
"electron-link": "0.1.2",
"electron-mksnapshot": "~1.6",
"electron-packager": "7.3.0",
"electron-winstaller": "2.6.3",

View File

@@ -34,7 +34,7 @@ export function afterEach (fn) {
}
})
export async function conditionPromise (condition) {
export async function conditionPromise (condition, description = 'anonymous condition') {
const startTime = Date.now()
while (true) {
@@ -45,7 +45,7 @@ export async function conditionPromise (condition) {
}
if (Date.now() - startTime > 5000) {
throw new Error('Timed out waiting on condition')
throw new Error('Timed out waiting on ' + description)
}
}
}

View File

@@ -1,711 +0,0 @@
_ = require 'underscore-plus'
path = require 'path'
temp = require('temp').track()
AtomEnvironment = require '../src/atom-environment'
StorageFolder = require '../src/storage-folder'
describe "AtomEnvironment", ->
afterEach ->
try
temp.cleanupSync()
describe 'window sizing methods', ->
describe '::getPosition and ::setPosition', ->
originalPosition = null
beforeEach ->
originalPosition = atom.getPosition()
afterEach ->
atom.setPosition(originalPosition.x, originalPosition.y)
it 'sets the position of the window, and can retrieve the position just set', ->
atom.setPosition(22, 45)
expect(atom.getPosition()).toEqual x: 22, y: 45
describe '::getSize and ::setSize', ->
originalSize = null
beforeEach ->
originalSize = atom.getSize()
afterEach ->
atom.setSize(originalSize.width, originalSize.height)
it 'sets the size of the window, and can retrieve the size just set', ->
newWidth = originalSize.width - 12
newHeight = originalSize.height - 23
waitsForPromise ->
atom.setSize(newWidth, newHeight)
runs ->
expect(atom.getSize()).toEqual width: newWidth, height: newHeight
describe ".isReleasedVersion()", ->
it "returns false if the version is a SHA and true otherwise", ->
version = '0.1.0'
spyOn(atom, 'getVersion').andCallFake -> version
expect(atom.isReleasedVersion()).toBe true
version = '36b5518'
expect(atom.isReleasedVersion()).toBe false
describe "loading default config", ->
it 'loads the default core config schema', ->
expect(atom.config.get('core.excludeVcsIgnoredPaths')).toBe true
expect(atom.config.get('core.followSymlinks')).toBe true
expect(atom.config.get('editor.showInvisibles')).toBe false
describe "window onerror handler", ->
devToolsPromise = null
beforeEach ->
devToolsPromise = Promise.resolve()
spyOn(atom, 'openDevTools').andReturn(devToolsPromise)
spyOn(atom, 'executeJavaScriptInDevTools')
it "will open the dev tools when an error is triggered", ->
try
a + 1
catch e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
waitsForPromise -> devToolsPromise
runs ->
expect(atom.openDevTools).toHaveBeenCalled()
expect(atom.executeJavaScriptInDevTools).toHaveBeenCalled()
describe "::onWillThrowError", ->
willThrowSpy = null
beforeEach ->
willThrowSpy = jasmine.createSpy()
it "is called when there is an error", ->
error = null
atom.onWillThrowError(willThrowSpy)
try
a + 1
catch e
error = e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
delete willThrowSpy.mostRecentCall.args[0].preventDefault
expect(willThrowSpy).toHaveBeenCalledWith
message: error.toString()
url: 'abc'
line: 2
column: 3
originalError: error
it "will not show the devtools when preventDefault() is called", ->
willThrowSpy.andCallFake (errorObject) -> errorObject.preventDefault()
atom.onWillThrowError(willThrowSpy)
try
a + 1
catch e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
expect(willThrowSpy).toHaveBeenCalled()
expect(atom.openDevTools).not.toHaveBeenCalled()
expect(atom.executeJavaScriptInDevTools).not.toHaveBeenCalled()
describe "::onDidThrowError", ->
didThrowSpy = null
beforeEach ->
didThrowSpy = jasmine.createSpy()
it "is called when there is an error", ->
error = null
atom.onDidThrowError(didThrowSpy)
try
a + 1
catch e
error = e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
expect(didThrowSpy).toHaveBeenCalledWith
message: error.toString()
url: 'abc'
line: 2
column: 3
originalError: error
describe ".assert(condition, message, callback)", ->
errors = null
beforeEach ->
errors = []
spyOn(atom, 'isReleasedVersion').andReturn(true)
atom.onDidFailAssertion (error) -> errors.push(error)
describe "if the condition is false", ->
it "notifies onDidFailAssertion handlers with an error object based on the call site of the assertion", ->
result = atom.assert(false, "a == b")
expect(result).toBe false
expect(errors.length).toBe 1
expect(errors[0].message).toBe "Assertion failed: a == b"
expect(errors[0].stack).toContain('atom-environment-spec')
describe "if passed a callback function", ->
it "calls the callback with the assertion failure's error object", ->
error = null
atom.assert(false, "a == b", (e) -> error = e)
expect(error).toBe errors[0]
describe "if passed metadata", ->
it "assigns the metadata on the assertion failure's error object", ->
atom.assert(false, "a == b", {foo: 'bar'})
expect(errors[0].metadata).toEqual {foo: 'bar'}
describe "when Atom has been built from source", ->
it "throws an error", ->
atom.isReleasedVersion.andReturn(false)
expect(-> atom.assert(false, 'testing')).toThrow('Assertion failed: testing')
describe "if the condition is true", ->
it "does nothing", ->
result = atom.assert(true, "a == b")
expect(result).toBe true
expect(errors).toEqual []
describe "saving and loading", ->
beforeEach ->
atom.enablePersistence = true
afterEach ->
atom.enablePersistence = false
it "selects the state based on the current project paths", ->
jasmine.useRealClock()
[dir1, dir2] = [temp.mkdirSync("dir1-"), temp.mkdirSync("dir2-")]
loadSettings = _.extend atom.getLoadSettings(),
initialPaths: [dir1]
windowState: null
spyOn(atom, 'getLoadSettings').andCallFake -> loadSettings
spyOn(atom, 'serialize').andReturn({stuff: 'cool'})
atom.project.setPaths([dir1, dir2])
# State persistence will fail if other Atom instances are running
waitsForPromise ->
atom.stateStore.connect().then (isConnected) ->
expect(isConnected).toBe true
waitsForPromise ->
atom.saveState().then ->
atom.loadState().then (state) ->
expect(state).toBeFalsy()
waitsForPromise ->
loadSettings.initialPaths = [dir2, dir1]
atom.loadState().then (state) ->
expect(state).toEqual({stuff: 'cool'})
it "loads state from the storage folder when it can't be found in atom.stateStore", ->
jasmine.useRealClock()
storageFolderState = {foo: 1, bar: 2}
serializedState = {someState: 42}
loadSettings = _.extend(atom.getLoadSettings(), {initialPaths: [temp.mkdirSync("project-directory")]})
spyOn(atom, 'getLoadSettings').andReturn(loadSettings)
spyOn(atom, 'serialize').andReturn(serializedState)
spyOn(atom, 'getStorageFolder').andReturn(new StorageFolder(temp.mkdirSync("config-directory")))
atom.project.setPaths(atom.getLoadSettings().initialPaths)
waitsForPromise ->
atom.stateStore.connect()
runs ->
atom.getStorageFolder().storeSync(atom.getStateKey(loadSettings.initialPaths), storageFolderState)
waitsForPromise ->
atom.loadState().then (state) -> expect(state).toEqual(storageFolderState)
waitsForPromise ->
atom.saveState()
waitsForPromise ->
atom.loadState().then (state) -> expect(state).toEqual(serializedState)
it "saves state when the CPU is idle after a keydown or mousedown event", ->
atomEnv = new AtomEnvironment({
applicationDelegate: global.atom.applicationDelegate,
})
idleCallbacks = []
atomEnv.initialize({
window: {
requestIdleCallback: (callback) -> idleCallbacks.push(callback),
addEventListener: ->
removeEventListener: ->
},
document: document.implementation.createHTMLDocument()
})
spyOn(atomEnv, 'saveState')
keydown = new KeyboardEvent('keydown')
atomEnv.document.dispatchEvent(keydown)
advanceClock atomEnv.saveStateDebounceInterval
idleCallbacks.shift()()
expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false})
expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true})
atomEnv.saveState.reset()
mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
advanceClock atomEnv.saveStateDebounceInterval
idleCallbacks.shift()()
expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false})
expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true})
atomEnv.destroy()
it "ignores mousedown/keydown events happening after calling unloadEditorWindow", ->
atomEnv = new AtomEnvironment({
applicationDelegate: global.atom.applicationDelegate,
})
idleCallbacks = []
atomEnv.initialize({
window: {
requestIdleCallback: (callback) -> idleCallbacks.push(callback),
addEventListener: ->
removeEventListener: ->
},
document: document.implementation.createHTMLDocument()
})
spyOn(atomEnv, 'saveState')
mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
atomEnv.unloadEditorWindow()
expect(atomEnv.saveState).not.toHaveBeenCalled()
advanceClock atomEnv.saveStateDebounceInterval
idleCallbacks.shift()()
expect(atomEnv.saveState).not.toHaveBeenCalled()
mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
advanceClock atomEnv.saveStateDebounceInterval
idleCallbacks.shift()()
expect(atomEnv.saveState).not.toHaveBeenCalled()
atomEnv.destroy()
it "serializes the project state with all the options supplied in saveState", ->
spyOn(atom.project, 'serialize').andReturn({foo: 42})
waitsForPromise -> atom.saveState({anyOption: 'any option'})
runs ->
expect(atom.project.serialize.calls.length).toBe(1)
expect(atom.project.serialize.mostRecentCall.args[0]).toEqual({anyOption: 'any option'})
it "serializes the text editor registry", ->
editor = null
waitsForPromise ->
atom.workspace.open('sample.js').then (e) -> editor = e
waitsForPromise ->
atom.textEditors.setGrammarOverride(editor, 'text.plain')
atom2 = new AtomEnvironment({
applicationDelegate: atom.applicationDelegate,
window: document.createElement('div'),
document: Object.assign(
document.createElement('div'),
{
body: document.createElement('div'),
head: document.createElement('div'),
}
)
})
atom2.initialize({document, window})
atom2.deserialize(atom.serialize()).then ->
expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain')
atom2.destroy()
describe "deserialization failures", ->
it "propagates project state restoration failures", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open project directory',
{description: 'Project directory `/foo` is no longer on disk.'}
it "accumulates and reports two errors with one notification", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 2 project directories',
{description: 'Project directories `/foo` and `/wat` are no longer on disk.'}
it "accumulates and reports three+ errors with one notification", ->
spyOn(atom.project, 'deserialize').andCallFake ->
err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat', '/stuff', '/things']
Promise.reject(err)
spyOn(atom.notifications, 'addError')
waitsForPromise -> atom.deserialize({project: 'should work'})
runs ->
expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 4 project directories',
{description: 'Project directories `/foo`, `/wat`, `/stuff`, and `/things` are no longer on disk.'}
describe "openInitialEmptyEditorIfNecessary", ->
describe "when there are no paths set", ->
beforeEach ->
spyOn(atom, 'getLoadSettings').andReturn(initialPaths: [])
it "opens an empty buffer", ->
spyOn(atom.workspace, 'open')
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).toHaveBeenCalledWith(null)
describe "when there is already a buffer open", ->
beforeEach ->
waitsForPromise -> atom.workspace.open()
it "does not open an empty buffer", ->
spyOn(atom.workspace, 'open')
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).not.toHaveBeenCalled()
describe "when the project has a path", ->
beforeEach ->
spyOn(atom, 'getLoadSettings').andReturn(initialPaths: ['something'])
spyOn(atom.workspace, 'open')
it "does not open an empty buffer", ->
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).not.toHaveBeenCalled()
describe "adding a project folder", ->
it "does nothing if the user dismisses the file picker", ->
initialPaths = atom.project.getPaths()
tempDirectory = temp.mkdirSync("a-new-directory")
spyOn(atom, "pickFolder").andCallFake (callback) -> callback(null)
atom.addProjectFolder()
expect(atom.project.getPaths()).toEqual(initialPaths)
describe "when there is no saved state for the added folders", ->
beforeEach ->
spyOn(atom, 'loadState').andReturn(Promise.resolve(null))
spyOn(atom, 'attemptRestoreProjectStateForPaths')
it "adds the selected folder to the project", ->
initialPaths = atom.project.setPaths([])
tempDirectory = temp.mkdirSync("a-new-directory")
spyOn(atom, "pickFolder").andCallFake (callback) ->
callback([tempDirectory])
waitsForPromise ->
atom.addProjectFolder()
runs ->
expect(atom.project.getPaths()).toEqual([tempDirectory])
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
describe "when there is saved state for the relevant directories", ->
state = Symbol('savedState')
beforeEach ->
spyOn(atom, "getStateKey").andCallFake (dirs) -> dirs.join(':')
spyOn(atom, "loadState").andCallFake (key) ->
if key is __dirname then Promise.resolve(state) else Promise.resolve(null)
spyOn(atom, "attemptRestoreProjectStateForPaths")
spyOn(atom, "pickFolder").andCallFake (callback) ->
callback([__dirname])
atom.project.setPaths([])
describe "when there are no project folders", ->
it "attempts to restore the project state", ->
waitsForPromise ->
atom.addProjectFolder()
runs ->
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [__dirname])
expect(atom.project.getPaths()).toEqual([])
describe "when there are already project folders", ->
openedPath = path.join(__dirname, 'fixtures')
beforeEach ->
atom.project.setPaths([openedPath])
it "does not attempt to restore the project state, instead adding the project paths", ->
waitsForPromise ->
atom.addProjectFolder()
runs ->
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
expect(atom.project.getPaths()).toEqual([openedPath, __dirname])
describe "attemptRestoreProjectStateForPaths(state, projectPaths, filesToOpen)", ->
describe "when the window is clean (empty or has only unnamed, unmodified buffers)", ->
beforeEach ->
# Unnamed, unmodified buffer doesn't count toward "clean"-ness
waitsForPromise -> atom.workspace.open()
it "automatically restores the saved state into the current environment", ->
state = Symbol()
spyOn(atom.workspace, 'open')
spyOn(atom, 'restoreStateIntoThisEnvironment')
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.restoreStateIntoThisEnvironment).toHaveBeenCalledWith(state)
expect(atom.workspace.open.callCount).toBe(1)
expect(atom.workspace.open).toHaveBeenCalledWith(__filename)
describe "when a dock has a non-text editor", ->
it "doesn't prompt the user to restore state", ->
dock = atom.workspace.getLeftDock()
dock.getActivePane().addItem
getTitle: -> 'title'
element: document.createElement 'div'
state = Symbol()
spyOn(atom, 'confirm')
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).not.toHaveBeenCalled()
describe "when the window is dirty", ->
editor = null
beforeEach ->
waitsForPromise -> atom.workspace.open().then (e) ->
editor = e
editor.setText('new editor')
describe "when a dock has a modified editor", ->
it "prompts the user to restore the state", ->
dock = atom.workspace.getLeftDock()
dock.getActivePane().addItem editor
spyOn(atom, "confirm").andReturn(1)
spyOn(atom.project, 'addPath')
spyOn(atom.workspace, 'open')
state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
it "prompts the user to restore the state in a new window, discarding it and adding folder to current window", ->
spyOn(atom, "confirm").andReturn(1)
spyOn(atom.project, 'addPath')
spyOn(atom.workspace, 'open')
state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
expect(atom.project.addPath.callCount).toBe(1)
expect(atom.project.addPath).toHaveBeenCalledWith(__dirname)
expect(atom.workspace.open.callCount).toBe(1)
expect(atom.workspace.open).toHaveBeenCalledWith(__filename)
it "prompts the user to restore the state in a new window, opening a new window", ->
spyOn(atom, "confirm").andReturn(0)
spyOn(atom, "open")
state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
expect(atom.open).toHaveBeenCalledWith
pathsToOpen: [__dirname, __filename]
newWindow: true
devMode: atom.inDevMode()
safeMode: atom.inSafeMode()
describe "::unloadEditorWindow()", ->
it "saves the BlobStore so it can be loaded after reload", ->
configDirPath = temp.mkdirSync('atom-spec-environment')
fakeBlobStore = jasmine.createSpyObj("blob store", ["save"])
atomEnvironment = new AtomEnvironment({applicationDelegate: atom.applicationDelegate, enablePersistence: true})
atomEnvironment.initialize({configDirPath, blobStore: fakeBlobStore, window, document})
atomEnvironment.unloadEditorWindow()
expect(fakeBlobStore.save).toHaveBeenCalled()
atomEnvironment.destroy()
describe "::destroy()", ->
it "does not throw exceptions when unsubscribing from ipc events (regression)", ->
configDirPath = temp.mkdirSync('atom-spec-environment')
fakeDocument = {
addEventListener: ->
removeEventListener: ->
head: document.createElement('head')
body: document.createElement('body')
}
atomEnvironment = new AtomEnvironment({applicationDelegate: atom.applicationDelegate})
atomEnvironment.initialize({window, document: fakeDocument})
spyOn(atomEnvironment.packages, 'loadPackages').andReturn(Promise.resolve())
spyOn(atomEnvironment.packages, 'activate').andReturn(Promise.resolve())
spyOn(atomEnvironment, 'displayWindow').andReturn(Promise.resolve())
waitsForPromise ->
atomEnvironment.startEditorWindow()
runs ->
atomEnvironment.unloadEditorWindow()
atomEnvironment.destroy()
describe "::whenShellEnvironmentLoaded()", ->
[atomEnvironment, envLoaded, spy] = []
beforeEach ->
resolve = null
promise = new Promise (r) -> resolve = r
envLoaded = ->
resolve()
waitsForPromise -> promise
atomEnvironment = new AtomEnvironment
applicationDelegate: atom.applicationDelegate
updateProcessEnv: -> promise
atomEnvironment.initialize({window, document})
spy = jasmine.createSpy()
afterEach ->
atomEnvironment.destroy()
it "is triggered once the shell environment is loaded", ->
atomEnvironment.whenShellEnvironmentLoaded spy
atomEnvironment.updateProcessEnvAndTriggerHooks()
envLoaded()
runs -> expect(spy).toHaveBeenCalled()
it "triggers the callback immediately if the shell environment is already loaded", ->
atomEnvironment.updateProcessEnvAndTriggerHooks()
envLoaded()
runs ->
atomEnvironment.whenShellEnvironmentLoaded spy
expect(spy).toHaveBeenCalled()
describe "::openLocations(locations) (called via IPC from browser process)", ->
beforeEach ->
spyOn(atom.workspace, 'open')
atom.project.setPaths([])
describe "when there is no saved state", ->
beforeEach ->
spyOn(atom, "loadState").andReturn(Promise.resolve(null))
describe "when the opened path exists", ->
it "adds it to the project's paths", ->
pathToOpen = __filename
waitsForPromise -> atom.openLocations([{pathToOpen}])
runs -> expect(atom.project.getPaths()[0]).toBe __dirname
describe "then a second path is opened with forceAddToWindow", ->
it "adds the second path to the project's paths", ->
firstPathToOpen = __dirname
secondPathToOpen = path.resolve(__dirname, './fixtures')
waitsForPromise -> atom.openLocations([{pathToOpen: firstPathToOpen}])
waitsForPromise -> atom.openLocations([{pathToOpen: secondPathToOpen, forceAddToWindow: true}])
runs -> expect(atom.project.getPaths()).toEqual([firstPathToOpen, secondPathToOpen])
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')
waitsForPromise -> atom.openLocations([{pathToOpen}])
runs -> expect(atom.project.getPaths()[0]).toBe __dirname
describe "when the opened path is a file", ->
it "opens it in the workspace", ->
pathToOpen = __filename
waitsForPromise -> atom.openLocations([{pathToOpen}])
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
waitsForPromise -> atom.openLocations([{pathToOpen}])
runs -> expect(atom.workspace.open.callCount).toBe 0
describe "when the opened path is a uri", ->
it "adds it to the project's paths as is", ->
pathToOpen = 'remote://server:7644/some/dir/path'
spyOn(atom.project, 'addPath')
waitsForPromise -> atom.openLocations([{pathToOpen}])
runs -> expect(atom.project.addPath).toHaveBeenCalledWith(pathToOpen)
describe "when there is saved state for the relevant directories", ->
state = Symbol('savedState')
beforeEach ->
spyOn(atom, "getStateKey").andCallFake (dirs) -> dirs.join(':')
spyOn(atom, "loadState").andCallFake (key) ->
if key is __dirname then Promise.resolve(state) else Promise.resolve(null)
spyOn(atom, "attemptRestoreProjectStateForPaths")
describe "when there are no project folders", ->
it "attempts to restore the project state", ->
pathToOpen = __dirname
waitsForPromise -> atom.openLocations([{pathToOpen}])
runs ->
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [pathToOpen], [])
expect(atom.project.getPaths()).toEqual([])
it "opens the specified files", ->
waitsForPromise -> atom.openLocations([{pathToOpen: __dirname}, {pathToOpen: __filename}])
runs ->
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [__dirname], [__filename])
expect(atom.project.getPaths()).toEqual([])
describe "when there are already project folders", ->
beforeEach ->
atom.project.setPaths([__dirname])
it "does not attempt to restore the project state, instead adding the project paths", ->
pathToOpen = path.join(__dirname, 'fixtures')
waitsForPromise -> atom.openLocations([{pathToOpen, forceAddToWindow: true}])
runs ->
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
expect(atom.project.getPaths()).toEqual([__dirname, pathToOpen])
it "opens the specified files", ->
pathToOpen = path.join(__dirname, 'fixtures')
fileToOpen = path.join(pathToOpen, 'michelle-is-awesome.txt')
waitsForPromise -> atom.openLocations([{pathToOpen}, {pathToOpen: fileToOpen}])
runs ->
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalledWith(state, [pathToOpen], [fileToOpen])
expect(atom.project.getPaths()).toEqual([__dirname])
describe "::updateAvailable(info) (called via IPC from browser process)", ->
subscription = null
afterEach ->
subscription?.dispose()
it "invokes onUpdateAvailable listeners", ->
return unless process.platform is 'darwin' # Test tied to electron autoUpdater, we use something else on Linux and Win32
atom.listenForUpdates()
updateAvailableHandler = jasmine.createSpy("update-available-handler")
subscription = atom.onUpdateAvailable updateAvailableHandler
autoUpdater = require('electron').remote.autoUpdater
autoUpdater.emit 'update-downloaded', null, "notes", "version"
waitsFor ->
updateAvailableHandler.callCount > 0
runs ->
{releaseVersion} = updateAvailableHandler.mostRecentCall.args[0]
expect(releaseVersion).toBe 'version'
describe "::getReleaseChannel()", ->
[version] = []
beforeEach ->
spyOn(atom, 'getVersion').andCallFake -> version
it "returns the correct channel based on the version number", ->
version = '1.5.6'
expect(atom.getReleaseChannel()).toBe 'stable'
version = '1.5.0-beta10'
expect(atom.getReleaseChannel()).toBe 'beta'
version = '1.7.0-dev-5340c91'
expect(atom.getReleaseChannel()).toBe 'dev'

View File

@@ -0,0 +1,770 @@
const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers')
const _ = require('underscore-plus')
const path = require('path')
const temp = require('temp').track()
const AtomEnvironment = require('../src/atom-environment')
const StorageFolder = require('../src/storage-folder')
describe('AtomEnvironment', () => {
afterEach(() => {
try {
temp.cleanupSync()
} catch (error) {}
})
describe('window sizing methods', () => {
describe('::getPosition and ::setPosition', () => {
let originalPosition = null
beforeEach(() => originalPosition = atom.getPosition())
afterEach(() => atom.setPosition(originalPosition.x, originalPosition.y))
it('sets the position of the window, and can retrieve the position just set', () => {
atom.setPosition(22, 45)
expect(atom.getPosition()).toEqual({x: 22, y: 45})
})
})
describe('::getSize and ::setSize', () => {
let originalSize = null
beforeEach(() => originalSize = atom.getSize())
afterEach(() => atom.setSize(originalSize.width, originalSize.height))
it('sets the size of the window, and can retrieve the size just set', async () => {
const newWidth = originalSize.width - 12
const newHeight = originalSize.height - 23
await atom.setSize(newWidth, newHeight)
expect(atom.getSize()).toEqual({width: newWidth, height: newHeight})
})
})
})
describe('.isReleasedVersion()', () => {
it('returns false if the version is a SHA and true otherwise', () => {
let version = '0.1.0'
spyOn(atom, 'getVersion').andCallFake(() => version)
expect(atom.isReleasedVersion()).toBe(true)
version = '36b5518'
expect(atom.isReleasedVersion()).toBe(false)
})
})
describe('loading default config', () => {
it('loads the default core config schema', () => {
expect(atom.config.get('core.excludeVcsIgnoredPaths')).toBe(true)
expect(atom.config.get('core.followSymlinks')).toBe(true)
expect(atom.config.get('editor.showInvisibles')).toBe(false)
})
})
describe('window onerror handler', () => {
let devToolsPromise = null
beforeEach(() => {
devToolsPromise = Promise.resolve()
spyOn(atom, 'openDevTools').andReturn(devToolsPromise)
spyOn(atom, 'executeJavaScriptInDevTools')
})
it('will open the dev tools when an error is triggered', async () => {
try {
a + 1
} catch (e) {
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
}
await devToolsPromise
expect(atom.openDevTools).toHaveBeenCalled()
expect(atom.executeJavaScriptInDevTools).toHaveBeenCalled()
})
describe('::onWillThrowError', () => {
let willThrowSpy = null
beforeEach(() => {
willThrowSpy = jasmine.createSpy()
})
it('is called when there is an error', () => {
let error = null
atom.onWillThrowError(willThrowSpy)
try {
a + 1
} catch (e) {
error = e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
}
delete willThrowSpy.mostRecentCall.args[0].preventDefault
expect(willThrowSpy).toHaveBeenCalledWith({
message: error.toString(),
url: 'abc',
line: 2,
column: 3,
originalError: error
})
})
it('will not show the devtools when preventDefault() is called', () => {
willThrowSpy.andCallFake(errorObject => errorObject.preventDefault())
atom.onWillThrowError(willThrowSpy)
try {
a + 1
} catch (e) {
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
}
expect(willThrowSpy).toHaveBeenCalled()
expect(atom.openDevTools).not.toHaveBeenCalled()
expect(atom.executeJavaScriptInDevTools).not.toHaveBeenCalled()
})
})
describe('::onDidThrowError', () => {
let didThrowSpy = null
beforeEach(() => didThrowSpy = jasmine.createSpy())
it('is called when there is an error', () => {
let error = null
atom.onDidThrowError(didThrowSpy)
try {
a + 1
} catch (e) {
error = e
window.onerror.call(window, e.toString(), 'abc', 2, 3, e)
}
expect(didThrowSpy).toHaveBeenCalledWith({
message: error.toString(),
url: 'abc',
line: 2,
column: 3,
originalError: error
})
})
})
})
describe('.assert(condition, message, callback)', () => {
let errors = null
beforeEach(() => {
errors = []
spyOn(atom, 'isReleasedVersion').andReturn(true)
atom.onDidFailAssertion(error => errors.push(error))
})
describe('if the condition is false', () => {
it('notifies onDidFailAssertion handlers with an error object based on the call site of the assertion', () => {
const result = atom.assert(false, 'a == b')
expect(result).toBe(false)
expect(errors.length).toBe(1)
expect(errors[0].message).toBe('Assertion failed: a == b')
expect(errors[0].stack).toContain('atom-environment-spec')
})
describe('if passed a callback function', () => {
it("calls the callback with the assertion failure's error object", () => {
let error = null
atom.assert(false, 'a == b', e => error = e)
expect(error).toBe(errors[0])
})
})
describe('if passed metadata', () => {
it("assigns the metadata on the assertion failure's error object", () => {
atom.assert(false, 'a == b', {foo: 'bar'})
expect(errors[0].metadata).toEqual({foo: 'bar'})
})
})
describe('when Atom has been built from source', () => {
it('throws an error', () => {
atom.isReleasedVersion.andReturn(false)
expect(() => atom.assert(false, 'testing')).toThrow('Assertion failed: testing')
})
})
})
describe('if the condition is true', () => {
it('does nothing', () => {
const result = atom.assert(true, 'a == b')
expect(result).toBe(true)
expect(errors).toEqual([])
})
})
})
describe('saving and loading', () => {
beforeEach(() => atom.enablePersistence = true)
afterEach(() => atom.enablePersistence = false)
it('selects the state based on the current project paths', async () => {
jasmine.useRealClock()
const [dir1, dir2] = [temp.mkdirSync('dir1-'), temp.mkdirSync('dir2-')]
const loadSettings = Object.assign(atom.getLoadSettings(), {
initialPaths: [dir1],
windowState: null
})
spyOn(atom, 'getLoadSettings').andCallFake(() => loadSettings)
spyOn(atom, 'serialize').andReturn({stuff: 'cool'})
atom.project.setPaths([dir1, dir2])
// State persistence will fail if other Atom instances are running
expect(await atom.stateStore.connect()).toBe(true)
await atom.saveState()
expect(await atom.loadState()).toBeFalsy()
loadSettings.initialPaths = [dir2, dir1]
expect(await atom.loadState()).toEqual({stuff: 'cool'})
})
it('saves state when the CPU is idle after a keydown or mousedown event', () => {
const atomEnv = new AtomEnvironment({
applicationDelegate: global.atom.applicationDelegate
})
const idleCallbacks = []
atomEnv.initialize({
window: {
requestIdleCallback (callback) { idleCallbacks.push(callback) },
addEventListener () {},
removeEventListener () {}
},
document: document.implementation.createHTMLDocument()
})
spyOn(atomEnv, 'saveState')
const keydown = new KeyboardEvent('keydown')
atomEnv.document.dispatchEvent(keydown)
advanceClock(atomEnv.saveStateDebounceInterval)
idleCallbacks.shift()()
expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false})
expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true})
atomEnv.saveState.reset()
const mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
advanceClock(atomEnv.saveStateDebounceInterval)
idleCallbacks.shift()()
expect(atomEnv.saveState).toHaveBeenCalledWith({isUnloading: false})
expect(atomEnv.saveState).not.toHaveBeenCalledWith({isUnloading: true})
atomEnv.destroy()
})
it('ignores mousedown/keydown events happening after calling unloadEditorWindow', () => {
const atomEnv = new AtomEnvironment({
applicationDelegate: global.atom.applicationDelegate
})
const idleCallbacks = []
atomEnv.initialize({
window: {
requestIdleCallback (callback) { idleCallbacks.push(callback) },
addEventListener () {},
removeEventListener () {}
},
document: document.implementation.createHTMLDocument()
})
spyOn(atomEnv, 'saveState')
let mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
atomEnv.unloadEditorWindow()
expect(atomEnv.saveState).not.toHaveBeenCalled()
advanceClock(atomEnv.saveStateDebounceInterval)
idleCallbacks.shift()()
expect(atomEnv.saveState).not.toHaveBeenCalled()
mousedown = new MouseEvent('mousedown')
atomEnv.document.dispatchEvent(mousedown)
advanceClock(atomEnv.saveStateDebounceInterval)
idleCallbacks.shift()()
expect(atomEnv.saveState).not.toHaveBeenCalled()
atomEnv.destroy()
})
it('serializes the project state with all the options supplied in saveState', async () => {
spyOn(atom.project, 'serialize').andReturn({foo: 42})
await atom.saveState({anyOption: 'any option'})
expect(atom.project.serialize.calls.length).toBe(1)
expect(atom.project.serialize.mostRecentCall.args[0]).toEqual({anyOption: 'any option'})
})
it('serializes the text editor registry', async () => {
const editor = await atom.workspace.open('sample.js')
atom.textEditors.setGrammarOverride(editor, 'text.plain')
const atom2 = new AtomEnvironment({
applicationDelegate: atom.applicationDelegate,
window: document.createElement('div'),
document: Object.assign(
document.createElement('div'),
{
body: document.createElement('div'),
head: document.createElement('div')
}
)
})
atom2.initialize({document, window})
await atom2.deserialize(atom.serialize())
expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain')
atom2.destroy()
})
describe('deserialization failures', () => {
it('propagates project state restoration failures', async () => {
spyOn(atom.project, 'deserialize').andCallFake(() => {
const err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo']
return Promise.reject(err)
})
spyOn(atom.notifications, 'addError')
await atom.deserialize({project: 'should work'})
expect(atom.notifications.addError).toHaveBeenCalledWith('Unable to open project directory', {
description: 'Project directory `/foo` is no longer on disk.'
})
})
it('accumulates and reports two errors with one notification', async () => {
spyOn(atom.project, 'deserialize').andCallFake(() => {
const err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat']
return Promise.reject(err)
})
spyOn(atom.notifications, 'addError')
await atom.deserialize({project: 'should work'})
expect(atom.notifications.addError).toHaveBeenCalledWith('Unable to open 2 project directories', {
description: 'Project directories `/foo` and `/wat` are no longer on disk.'
})
})
it('accumulates and reports three+ errors with one notification', async () => {
spyOn(atom.project, 'deserialize').andCallFake(() => {
const err = new Error('deserialization failure')
err.missingProjectPaths = ['/foo', '/wat', '/stuff', '/things']
return Promise.reject(err)
})
spyOn(atom.notifications, 'addError')
await atom.deserialize({project: 'should work'})
expect(atom.notifications.addError).toHaveBeenCalledWith('Unable to open 4 project directories', {
description: 'Project directories `/foo`, `/wat`, `/stuff`, and `/things` are no longer on disk.'
})
})
})
})
describe('openInitialEmptyEditorIfNecessary', () => {
describe('when there are no paths set', () => {
beforeEach(() => spyOn(atom, 'getLoadSettings').andReturn({initialPaths: []}))
it('opens an empty buffer', () => {
spyOn(atom.workspace, 'open')
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).toHaveBeenCalledWith(null)
})
describe('when there is already a buffer open', () => {
beforeEach(async () => {
await atom.workspace.open()
})
it('does not open an empty buffer', () => {
spyOn(atom.workspace, 'open')
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).not.toHaveBeenCalled()
})
})
})
describe('when the project has a path', () => {
beforeEach(() => {
spyOn(atom, 'getLoadSettings').andReturn({initialPaths: ['something']})
spyOn(atom.workspace, 'open')
})
it('does not open an empty buffer', () => {
atom.openInitialEmptyEditorIfNecessary()
expect(atom.workspace.open).not.toHaveBeenCalled()
})
})
})
describe('adding a project folder', () => {
it('does nothing if the user dismisses the file picker', () => {
const initialPaths = atom.project.getPaths()
const tempDirectory = temp.mkdirSync('a-new-directory')
spyOn(atom, 'pickFolder').andCallFake(callback => callback(null))
atom.addProjectFolder()
expect(atom.project.getPaths()).toEqual(initialPaths)
})
describe('when there is no saved state for the added folders', () => {
beforeEach(() => {
spyOn(atom, 'loadState').andReturn(Promise.resolve(null))
spyOn(atom, 'attemptRestoreProjectStateForPaths')
})
it('adds the selected folder to the project', async () => {
const initialPaths = atom.project.setPaths([])
const tempDirectory = temp.mkdirSync('a-new-directory')
spyOn(atom, 'pickFolder').andCallFake(callback => callback([tempDirectory]))
await atom.addProjectFolder()
expect(atom.project.getPaths()).toEqual([tempDirectory])
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
})
})
describe('when there is saved state for the relevant directories', () => {
const state = Symbol('savedState')
beforeEach(() => {
spyOn(atom, 'getStateKey').andCallFake(dirs => dirs.join(':'))
spyOn(atom, 'loadState').andCallFake(async (key) => key === __dirname ? state : null)
spyOn(atom, 'attemptRestoreProjectStateForPaths')
spyOn(atom, 'pickFolder').andCallFake(callback => callback([__dirname]))
atom.project.setPaths([])
})
describe('when there are no project folders', () => {
it('attempts to restore the project state', async () => {
await atom.addProjectFolder()
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [__dirname])
expect(atom.project.getPaths()).toEqual([])
})
})
describe('when there are already project folders', () => {
const openedPath = path.join(__dirname, 'fixtures')
beforeEach(() => atom.project.setPaths([openedPath]))
it('does not attempt to restore the project state, instead adding the project paths', async () => {
await atom.addProjectFolder()
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
expect(atom.project.getPaths()).toEqual([openedPath, __dirname])
})
})
})
})
describe('attemptRestoreProjectStateForPaths(state, projectPaths, filesToOpen)', () => {
describe('when the window is clean (empty or has only unnamed, unmodified buffers)', () => {
beforeEach(async () => {
// Unnamed, unmodified buffer doesn't count toward "clean"-ness
await atom.workspace.open()
})
it('automatically restores the saved state into the current environment', () => {
const state = {}
spyOn(atom.workspace, 'open')
spyOn(atom, 'restoreStateIntoThisEnvironment')
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.restoreStateIntoThisEnvironment).toHaveBeenCalledWith(state)
expect(atom.workspace.open.callCount).toBe(1)
expect(atom.workspace.open).toHaveBeenCalledWith(__filename)
})
describe('when a dock has a non-text editor', () => {
it("doesn't prompt the user to restore state", () => {
const dock = atom.workspace.getLeftDock()
dock.getActivePane().addItem({
getTitle () { return 'title' },
element: document.createElement('div')
})
const state = {}
spyOn(atom, 'confirm')
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).not.toHaveBeenCalled()
})
})
})
describe('when the window is dirty', () => {
let editor
beforeEach(async () => {
editor = await atom.workspace.open()
editor.setText('new editor')
})
describe('when a dock has a modified editor', () => {
it('prompts the user to restore the state', () => {
const dock = atom.workspace.getLeftDock()
dock.getActivePane().addItem(editor)
spyOn(atom, 'confirm').andReturn(1)
spyOn(atom.project, 'addPath')
spyOn(atom.workspace, 'open')
const state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
})
})
it('prompts the user to restore the state in a new window, discarding it and adding folder to current window', () => {
spyOn(atom, 'confirm').andReturn(1)
spyOn(atom.project, 'addPath')
spyOn(atom.workspace, 'open')
const state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
expect(atom.project.addPath.callCount).toBe(1)
expect(atom.project.addPath).toHaveBeenCalledWith(__dirname)
expect(atom.workspace.open.callCount).toBe(1)
expect(atom.workspace.open).toHaveBeenCalledWith(__filename)
})
it('prompts the user to restore the state in a new window, opening a new window', () => {
spyOn(atom, 'confirm').andReturn(0)
spyOn(atom, 'open')
const state = Symbol()
atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename])
expect(atom.confirm).toHaveBeenCalled()
expect(atom.open).toHaveBeenCalledWith({
pathsToOpen: [__dirname, __filename],
newWindow: true,
devMode: atom.inDevMode(),
safeMode: atom.inSafeMode()
})
})
})
})
describe('::unloadEditorWindow()', () => {
it('saves the BlobStore so it can be loaded after reload', () => {
const configDirPath = temp.mkdirSync('atom-spec-environment')
const fakeBlobStore = jasmine.createSpyObj('blob store', ['save'])
const atomEnvironment = new AtomEnvironment({applicationDelegate: atom.applicationDelegate, enablePersistence: true})
atomEnvironment.initialize({configDirPath, blobStore: fakeBlobStore, window, document})
atomEnvironment.unloadEditorWindow()
expect(fakeBlobStore.save).toHaveBeenCalled()
atomEnvironment.destroy()
})
})
describe('::destroy()', () => {
it('does not throw exceptions when unsubscribing from ipc events (regression)', async () => {
const configDirPath = temp.mkdirSync('atom-spec-environment')
const fakeDocument = {
addEventListener () {},
removeEventListener () {},
head: document.createElement('head'),
body: document.createElement('body')
}
const atomEnvironment = new AtomEnvironment({applicationDelegate: atom.applicationDelegate})
atomEnvironment.initialize({window, document: fakeDocument})
spyOn(atomEnvironment.packages, 'loadPackages').andReturn(Promise.resolve())
spyOn(atomEnvironment.packages, 'activate').andReturn(Promise.resolve())
spyOn(atomEnvironment, 'displayWindow').andReturn(Promise.resolve())
await atomEnvironment.startEditorWindow()
atomEnvironment.unloadEditorWindow()
atomEnvironment.destroy()
})
})
describe('::whenShellEnvironmentLoaded()', () => {
let atomEnvironment, envLoaded, spy
beforeEach(() => {
let resolve = null
const promise = new Promise((r) => { resolve = r })
envLoaded = () => {
resolve()
promise
}
atomEnvironment = new AtomEnvironment({
applicationDelegate: atom.applicationDelegate,
updateProcessEnv () { return promise }
})
atomEnvironment.initialize({window, document})
spy = jasmine.createSpy()
})
afterEach(() => atomEnvironment.destroy())
it('is triggered once the shell environment is loaded', async () => {
atomEnvironment.whenShellEnvironmentLoaded(spy)
atomEnvironment.updateProcessEnvAndTriggerHooks()
await envLoaded()
expect(spy).toHaveBeenCalled()
})
it('triggers the callback immediately if the shell environment is already loaded', async () => {
atomEnvironment.updateProcessEnvAndTriggerHooks()
await envLoaded()
atomEnvironment.whenShellEnvironmentLoaded(spy)
expect(spy).toHaveBeenCalled()
})
})
describe('::openLocations(locations) (called via IPC from browser process)', () => {
beforeEach(() => {
spyOn(atom.workspace, 'open')
atom.project.setPaths([])
})
describe('when there is no saved state', () => {
beforeEach(() => {
spyOn(atom, 'loadState').andReturn(Promise.resolve(null))
})
describe('when the opened path exists', () => {
it("adds it to the project's paths", async () => {
const pathToOpen = __filename
await atom.openLocations([{pathToOpen}])
expect(atom.project.getPaths()[0]).toBe(__dirname)
})
describe('then a second path is opened with forceAddToWindow', () => {
it("adds the second path to the project's paths", async () => {
const firstPathToOpen = __dirname
const secondPathToOpen = path.resolve(__dirname, './fixtures')
await atom.openLocations([{pathToOpen: firstPathToOpen}])
await atom.openLocations([{pathToOpen: secondPathToOpen, forceAddToWindow: true}])
expect(atom.project.getPaths()).toEqual([firstPathToOpen, secondPathToOpen])
})
})
})
describe('when the opened path does not exist but its parent directory does', () => {
it('adds the parent directory to the project paths', async () => {
const pathToOpen = path.join(__dirname, 'this-path-does-not-exist.txt')
await atom.openLocations([{pathToOpen}])
expect(atom.project.getPaths()[0]).toBe(__dirname)
})
})
describe('when the opened path is a file', () => {
it('opens it in the workspace', async () => {
const pathToOpen = __filename
await atom.openLocations([{pathToOpen}])
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', async () => {
const pathToOpen = __dirname
await atom.openLocations([{pathToOpen}])
expect(atom.workspace.open.callCount).toBe(0)
})
})
describe('when the opened path is a uri', () => {
it("adds it to the project's paths as is", async () => {
const pathToOpen = 'remote://server:7644/some/dir/path'
spyOn(atom.project, 'addPath')
await atom.openLocations([{pathToOpen}])
expect(atom.project.addPath).toHaveBeenCalledWith(pathToOpen)
})
})
})
describe('when there is saved state for the relevant directories', () => {
const state = Symbol('savedState')
beforeEach(() => {
spyOn(atom, 'getStateKey').andCallFake(dirs => dirs.join(':'))
spyOn(atom, 'loadState').andCallFake(function (key) {
if (key === __dirname) { return Promise.resolve(state) } else { return Promise.resolve(null) }
})
spyOn(atom, 'attemptRestoreProjectStateForPaths')
})
describe('when there are no project folders', () => {
it('attempts to restore the project state', async () => {
const pathToOpen = __dirname
await atom.openLocations([{pathToOpen}])
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [pathToOpen], [])
expect(atom.project.getPaths()).toEqual([])
})
it('opens the specified files', async () => {
await atom.openLocations([{pathToOpen: __dirname}, {pathToOpen: __filename}])
expect(atom.attemptRestoreProjectStateForPaths).toHaveBeenCalledWith(state, [__dirname], [__filename])
expect(atom.project.getPaths()).toEqual([])
})
})
describe('when there are already project folders', () => {
beforeEach(() => atom.project.setPaths([__dirname]))
it('does not attempt to restore the project state, instead adding the project paths', async () => {
const pathToOpen = path.join(__dirname, 'fixtures')
await atom.openLocations([{pathToOpen, forceAddToWindow: true}])
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalled()
expect(atom.project.getPaths()).toEqual([__dirname, pathToOpen])
})
it('opens the specified files', async () => {
const pathToOpen = path.join(__dirname, 'fixtures')
const fileToOpen = path.join(pathToOpen, 'michelle-is-awesome.txt')
await atom.openLocations([{pathToOpen}, {pathToOpen: fileToOpen}])
expect(atom.attemptRestoreProjectStateForPaths).not.toHaveBeenCalledWith(state, [pathToOpen], [fileToOpen])
expect(atom.project.getPaths()).toEqual([__dirname])
})
})
})
})
describe('::updateAvailable(info) (called via IPC from browser process)', () => {
let subscription
afterEach(() => {
if (subscription) subscription.dispose()
})
it('invokes onUpdateAvailable listeners', async () => {
if (process.platform !== 'darwin') return // Test tied to electron autoUpdater, we use something else on Linux and Win32
const updateAvailablePromise = new Promise(resolve => {
subscription = atom.onUpdateAvailable(resolve)
})
atom.listenForUpdates()
const {autoUpdater} = require('electron').remote
autoUpdater.emit('update-downloaded', null, 'notes', 'version')
const {releaseVersion} = await updateAvailablePromise
expect(releaseVersion).toBe('version')
})
})
describe('::getReleaseChannel()', () => {
let version
beforeEach(() => {
spyOn(atom, 'getVersion').andCallFake(() => version)
})
it('returns the correct channel based on the version number', () => {
version = '1.5.6'
expect(atom.getReleaseChannel()).toBe('stable')
version = '1.5.0-beta10'
expect(atom.getReleaseChannel()).toBe('beta')
version = '1.7.0-dev-5340c91'
expect(atom.getReleaseChannel()).toBe('dev')
})
})
})

View File

@@ -1,5 +1,6 @@
'name': 'Test Ruby'
'scopeName': 'test.rb'
'firstLineMatch': '^\\#!.*(?:\\s|\\/)(?:testruby)(?:$|\\s)'
'fileTypes': [
'rb'
]

View File

@@ -0,0 +1,5 @@
module.exports = {
activate: () => null,
deactivate: () => null,
handleURI: () => null,
}

View File

@@ -0,0 +1,6 @@
{
"name": "package-with-uri-handler",
"uriHandler": {
"method": "handleURI"
}
}

View File

@@ -1,371 +0,0 @@
temp = require('temp').track()
GitRepository = require '../src/git-repository'
fs = require 'fs-plus'
path = require 'path'
Project = require '../src/project'
copyRepository = ->
workingDirPath = temp.mkdirSync('atom-spec-git')
fs.copySync(path.join(__dirname, 'fixtures', 'git', 'working-dir'), workingDirPath)
fs.renameSync(path.join(workingDirPath, 'git.git'), path.join(workingDirPath, '.git'))
workingDirPath
describe "GitRepository", ->
repo = null
beforeEach ->
gitPath = path.join(temp.dir, '.git')
fs.removeSync(gitPath) if fs.isDirectorySync(gitPath)
afterEach ->
repo.destroy() if repo?.repo?
try
temp.cleanupSync() # These tests sometimes lag at shutting down resources
describe "@open(path)", ->
it "returns null when no repository is found", ->
expect(GitRepository.open(path.join(temp.dir, 'nogit.txt'))).toBeNull()
describe "new GitRepository(path)", ->
it "throws an exception when no repository is found", ->
expect(-> new GitRepository(path.join(temp.dir, 'nogit.txt'))).toThrow()
describe ".getPath()", ->
it "returns the repository path for a .git directory path with a directory", ->
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects'))
expect(repo.getPath()).toBe path.join(__dirname, 'fixtures', 'git', 'master.git')
it "returns the repository path for a repository path", ->
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git'))
expect(repo.getPath()).toBe path.join(__dirname, 'fixtures', 'git', 'master.git')
describe ".isPathIgnored(path)", ->
it "returns true for an ignored path", ->
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git'))
expect(repo.isPathIgnored('a.txt')).toBeTruthy()
it "returns false for a non-ignored path", ->
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git'))
expect(repo.isPathIgnored('b.txt')).toBeFalsy()
describe ".isPathModified(path)", ->
[repo, filePath, newPath] = []
beforeEach ->
workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
newPath = path.join(workingDirPath, 'new-path.txt')
describe "when the path is unstaged", ->
it "returns false if the path has not been modified", ->
expect(repo.isPathModified(filePath)).toBeFalsy()
it "returns true if the path is modified", ->
fs.writeFileSync(filePath, "change")
expect(repo.isPathModified(filePath)).toBeTruthy()
it "returns true if the path is deleted", ->
fs.removeSync(filePath)
expect(repo.isPathModified(filePath)).toBeTruthy()
it "returns false if the path is new", ->
expect(repo.isPathModified(newPath)).toBeFalsy()
describe ".isPathNew(path)", ->
[filePath, newPath] = []
beforeEach ->
workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
newPath = path.join(workingDirPath, 'new-path.txt')
fs.writeFileSync(newPath, "i'm new here")
describe "when the path is unstaged", ->
it "returns true if the path is new", ->
expect(repo.isPathNew(newPath)).toBeTruthy()
it "returns false if the path isn't new", ->
expect(repo.isPathNew(filePath)).toBeFalsy()
describe ".checkoutHead(path)", ->
[filePath] = []
beforeEach ->
workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
it "no longer reports a path as modified after checkout", ->
expect(repo.isPathModified(filePath)).toBeFalsy()
fs.writeFileSync(filePath, 'ch ch changes')
expect(repo.isPathModified(filePath)).toBeTruthy()
expect(repo.checkoutHead(filePath)).toBeTruthy()
expect(repo.isPathModified(filePath)).toBeFalsy()
it "restores the contents of the path to the original text", ->
fs.writeFileSync(filePath, 'ch ch changes')
expect(repo.checkoutHead(filePath)).toBeTruthy()
expect(fs.readFileSync(filePath, 'utf8')).toBe ''
it "fires a status-changed event if the checkout completes successfully", ->
fs.writeFileSync(filePath, 'ch ch changes')
repo.getPathStatus(filePath)
statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatus statusHandler
repo.checkoutHead(filePath)
expect(statusHandler.callCount).toBe 1
expect(statusHandler.argsForCall[0][0]).toEqual {path: filePath, pathStatus: 0}
repo.checkoutHead(filePath)
expect(statusHandler.callCount).toBe 1
describe ".checkoutHeadForEditor(editor)", ->
[filePath, editor] = []
beforeEach ->
spyOn(atom, "confirm")
workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath, {project: atom.project, config: atom.config, confirm: atom.confirm})
filePath = path.join(workingDirPath, 'a.txt')
fs.writeFileSync(filePath, 'ch ch changes')
waitsForPromise ->
atom.workspace.open(filePath)
runs ->
editor = atom.workspace.getActiveTextEditor()
it "displays a confirmation dialog by default", ->
return if process.platform is 'win32' # Permissions issues with this test on Windows
atom.confirm.andCallFake ({buttons}) -> buttons.OK()
atom.config.set('editor.confirmCheckoutHeadRevision', true)
repo.checkoutHeadForEditor(editor)
expect(fs.readFileSync(filePath, 'utf8')).toBe ''
it "does not display a dialog when confirmation is disabled", ->
return if process.platform is 'win32' # Flakey EPERM opening a.txt on Win32
atom.config.set('editor.confirmCheckoutHeadRevision', false)
repo.checkoutHeadForEditor(editor)
expect(fs.readFileSync(filePath, 'utf8')).toBe ''
expect(atom.confirm).not.toHaveBeenCalled()
describe ".destroy()", ->
it "throws an exception when any method is called after it is called", ->
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git'))
repo.destroy()
expect(-> repo.getShortHead()).toThrow()
describe ".getPathStatus(path)", ->
[filePath] = []
beforeEach ->
workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory)
filePath = path.join(workingDirectory, 'file.txt')
it "trigger a status-changed event when the new status differs from the last cached one", ->
statusHandler = jasmine.createSpy("statusHandler")
repo.onDidChangeStatus statusHandler
fs.writeFileSync(filePath, '')
status = repo.getPathStatus(filePath)
expect(statusHandler.callCount).toBe 1
expect(statusHandler.argsForCall[0][0]).toEqual {path: filePath, pathStatus: status}
fs.writeFileSync(filePath, 'abc')
status = repo.getPathStatus(filePath)
expect(statusHandler.callCount).toBe 1
describe ".getDirectoryStatus(path)", ->
[directoryPath, filePath] = []
beforeEach ->
workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory)
directoryPath = path.join(workingDirectory, 'dir')
filePath = path.join(directoryPath, 'b.txt')
it "gets the status based on the files inside the directory", ->
expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe false
fs.writeFileSync(filePath, 'abc')
repo.getPathStatus(filePath)
expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe true
describe ".refreshStatus()", ->
[newPath, modifiedPath, cleanPath, workingDirectory] = []
beforeEach ->
workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory, {project: atom.project, config: atom.config})
modifiedPath = path.join(workingDirectory, 'file.txt')
newPath = path.join(workingDirectory, 'untracked.txt')
cleanPath = path.join(workingDirectory, 'other.txt')
fs.writeFileSync(cleanPath, 'Full of text')
fs.writeFileSync(newPath, '')
newPath = fs.absolute newPath # specs could be running under symbol path.
it "returns status information for all new and modified files", ->
fs.writeFileSync(modifiedPath, 'making this path modified')
statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatuses statusHandler
repo.refreshStatus()
waitsFor ->
statusHandler.callCount > 0
runs ->
expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined()
expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy()
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy()
it 'caches the proper statuses when a subdir is open', ->
subDir = path.join(workingDirectory, 'dir')
fs.mkdirSync(subDir)
filePath = path.join(subDir, 'b.txt')
fs.writeFileSync(filePath, '')
atom.project.setPaths([subDir])
waitsForPromise ->
atom.workspace.open('b.txt')
statusHandler = null
runs ->
repo = atom.project.getRepositories()[0]
statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatuses statusHandler
repo.refreshStatus()
waitsFor ->
statusHandler.callCount > 0
runs ->
status = repo.getCachedPathStatus(filePath)
expect(repo.isStatusModified(status)).toBe false
expect(repo.isStatusNew(status)).toBe false
it "works correctly when the project has multiple folders (regression)", ->
atom.project.addPath(workingDirectory)
atom.project.addPath(path.join(__dirname, 'fixtures', 'dir'))
statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatuses statusHandler
repo.refreshStatus()
waitsFor ->
statusHandler.callCount > 0
runs ->
expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined()
expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy()
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy()
it 'caches statuses that were looked up synchronously', ->
originalContent = 'undefined'
fs.writeFileSync(modifiedPath, 'making this path modified')
repo.getPathStatus('file.txt')
fs.writeFileSync(modifiedPath, originalContent)
waitsForPromise -> repo.refreshStatus()
runs ->
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeFalsy()
describe "buffer events", ->
[editor] = []
beforeEach ->
statusRefreshed = false
atom.project.setPaths([copyRepository()])
atom.project.getRepositories()[0].onDidChangeStatuses -> statusRefreshed = true
waitsForPromise ->
atom.workspace.open('other.txt').then (o) -> editor = o
waitsFor 'repo to refresh', -> statusRefreshed
it "emits a status-changed event when a buffer is saved", ->
editor.insertNewline()
statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus statusHandler
waitsForPromise ->
editor.save()
runs ->
expect(statusHandler.callCount).toBe 1
expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256}
it "emits a status-changed event when a buffer is reloaded", ->
fs.writeFileSync(editor.getPath(), 'changed')
statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus statusHandler
waitsForPromise ->
editor.getBuffer().reload()
runs ->
expect(statusHandler.callCount).toBe 1
expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256}
waitsForPromise ->
editor.getBuffer().reload()
runs ->
expect(statusHandler.callCount).toBe 1
it "emits a status-changed event when a buffer's path changes", ->
fs.writeFileSync(editor.getPath(), 'changed')
statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus statusHandler
editor.getBuffer().emitter.emit 'did-change-path'
expect(statusHandler.callCount).toBe 1
expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256}
editor.getBuffer().emitter.emit 'did-change-path'
expect(statusHandler.callCount).toBe 1
it "stops listening to the buffer when the repository is destroyed (regression)", ->
atom.project.getRepositories()[0].destroy()
expect(-> editor.save()).not.toThrow()
describe "when a project is deserialized", ->
[buffer, project2, statusHandler] = []
afterEach ->
project2?.destroy()
it "subscribes to all the serialized buffers in the project", ->
atom.project.setPaths([copyRepository()])
waitsForPromise ->
atom.workspace.open('file.txt')
waitsForPromise ->
project2 = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm, applicationDelegate: atom.applicationDelegate})
project2.deserialize(atom.project.serialize({isUnloading: false}))
waitsFor ->
buffer = project2.getBuffers()[0]
waitsForPromise ->
originalContent = buffer.getText()
buffer.append('changes')
statusHandler = jasmine.createSpy('statusHandler')
project2.getRepositories()[0].onDidChangeStatus statusHandler
buffer.save()
runs ->
expect(statusHandler.callCount).toBe 1
expect(statusHandler).toHaveBeenCalledWith {path: buffer.getPath(), pathStatus: 256}

393
spec/git-repository-spec.js Normal file
View File

@@ -0,0 +1,393 @@
const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers')
const path = require('path')
const fs = require('fs-plus')
const temp = require('temp').track()
const GitRepository = require('../src/git-repository')
const Project = require('../src/project')
describe('GitRepository', () => {
let repo
beforeEach(() => {
const gitPath = path.join(temp.dir, '.git')
if (fs.isDirectorySync(gitPath)) fs.removeSync(gitPath)
})
afterEach(() => {
if (repo && !repo.isDestroyed()) repo.destroy()
// These tests sometimes lag at shutting down resources
try {
temp.cleanupSync()
} catch (error) {}
})
describe('@open(path)', () => {
it('returns null when no repository is found', () => {
expect(GitRepository.open(path.join(temp.dir, 'nogit.txt'))).toBeNull()
})
})
describe('new GitRepository(path)', () => {
it('throws an exception when no repository is found', () => {
expect(() => new GitRepository(path.join(temp.dir, 'nogit.txt'))).toThrow()
})
})
describe('.getPath()', () => {
it('returns the repository path for a .git directory path with a directory', () => {
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects'))
expect(repo.getPath()).toBe(path.join(__dirname, 'fixtures', 'git', 'master.git'))
})
it('returns the repository path for a repository path', () => {
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git'))
expect(repo.getPath()).toBe(path.join(__dirname, 'fixtures', 'git', 'master.git'))
})
})
describe('.isPathIgnored(path)', () => {
it('returns true for an ignored path', () => {
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git'))
expect(repo.isPathIgnored('a.txt')).toBeTruthy()
})
it('returns false for a non-ignored path', () => {
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git'))
expect(repo.isPathIgnored('b.txt')).toBeFalsy()
})
})
describe('.isPathModified(path)', () => {
let filePath, newPath
beforeEach(() => {
const workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
newPath = path.join(workingDirPath, 'new-path.txt')
})
describe('when the path is unstaged', () => {
it('returns false if the path has not been modified', () => {
expect(repo.isPathModified(filePath)).toBeFalsy()
})
it('returns true if the path is modified', () => {
fs.writeFileSync(filePath, 'change')
expect(repo.isPathModified(filePath)).toBeTruthy()
})
it('returns true if the path is deleted', () => {
fs.removeSync(filePath)
expect(repo.isPathModified(filePath)).toBeTruthy()
})
it('returns false if the path is new', () => {
expect(repo.isPathModified(newPath)).toBeFalsy()
})
})
})
describe('.isPathNew(path)', () => {
let filePath, newPath
beforeEach(() => {
const workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
newPath = path.join(workingDirPath, 'new-path.txt')
fs.writeFileSync(newPath, "i'm new here")
})
describe('when the path is unstaged', () => {
it('returns true if the path is new', () => {
expect(repo.isPathNew(newPath)).toBeTruthy()
})
it("returns false if the path isn't new", () => {
expect(repo.isPathNew(filePath)).toBeFalsy()
})
})
})
describe('.checkoutHead(path)', () => {
let filePath
beforeEach(() => {
const workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath)
filePath = path.join(workingDirPath, 'a.txt')
})
it('no longer reports a path as modified after checkout', () => {
expect(repo.isPathModified(filePath)).toBeFalsy()
fs.writeFileSync(filePath, 'ch ch changes')
expect(repo.isPathModified(filePath)).toBeTruthy()
expect(repo.checkoutHead(filePath)).toBeTruthy()
expect(repo.isPathModified(filePath)).toBeFalsy()
})
it('restores the contents of the path to the original text', () => {
fs.writeFileSync(filePath, 'ch ch changes')
expect(repo.checkoutHead(filePath)).toBeTruthy()
expect(fs.readFileSync(filePath, 'utf8')).toBe('')
})
it('fires a status-changed event if the checkout completes successfully', () => {
fs.writeFileSync(filePath, 'ch ch changes')
repo.getPathStatus(filePath)
const statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatus(statusHandler)
repo.checkoutHead(filePath)
expect(statusHandler.callCount).toBe(1)
expect(statusHandler.argsForCall[0][0]).toEqual({path: filePath, pathStatus: 0})
repo.checkoutHead(filePath)
expect(statusHandler.callCount).toBe(1)
})
})
describe('.checkoutHeadForEditor(editor)', () => {
let filePath, editor
beforeEach(async () => {
spyOn(atom, 'confirm')
const workingDirPath = copyRepository()
repo = new GitRepository(workingDirPath, {project: atom.project, config: atom.config, confirm: atom.confirm})
filePath = path.join(workingDirPath, 'a.txt')
fs.writeFileSync(filePath, 'ch ch changes')
editor = await atom.workspace.open(filePath)
})
it('displays a confirmation dialog by default', () => {
// Permissions issues with this test on Windows
if (process.platform === 'win32') return
atom.confirm.andCallFake(({buttons}) => buttons.OK())
atom.config.set('editor.confirmCheckoutHeadRevision', true)
repo.checkoutHeadForEditor(editor)
expect(fs.readFileSync(filePath, 'utf8')).toBe('')
})
it('does not display a dialog when confirmation is disabled', () => {
// Flakey EPERM opening a.txt on Win32
if (process.platform === 'win32') return
atom.config.set('editor.confirmCheckoutHeadRevision', false)
repo.checkoutHeadForEditor(editor)
expect(fs.readFileSync(filePath, 'utf8')).toBe('')
expect(atom.confirm).not.toHaveBeenCalled()
})
})
describe('.destroy()', () => {
it('throws an exception when any method is called after it is called', () => {
repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git'))
repo.destroy()
expect(() => repo.getShortHead()).toThrow()
})
})
describe('.getPathStatus(path)', () => {
let filePath
beforeEach(() => {
const workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory)
filePath = path.join(workingDirectory, 'file.txt')
})
it('trigger a status-changed event when the new status differs from the last cached one', () => {
const statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatus(statusHandler)
fs.writeFileSync(filePath, '')
let status = repo.getPathStatus(filePath)
expect(statusHandler.callCount).toBe(1)
expect(statusHandler.argsForCall[0][0]).toEqual({path: filePath, pathStatus: status})
fs.writeFileSync(filePath, 'abc')
status = repo.getPathStatus(filePath)
expect(statusHandler.callCount).toBe(1)
})
})
describe('.getDirectoryStatus(path)', () => {
let directoryPath, filePath
beforeEach(() => {
const workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory)
directoryPath = path.join(workingDirectory, 'dir')
filePath = path.join(directoryPath, 'b.txt')
})
it('gets the status based on the files inside the directory', () => {
expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe(false)
fs.writeFileSync(filePath, 'abc')
repo.getPathStatus(filePath)
expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe(true)
})
})
describe('.refreshStatus()', () => {
let newPath, modifiedPath, cleanPath, workingDirectory
beforeEach(() => {
workingDirectory = copyRepository()
repo = new GitRepository(workingDirectory, {project: atom.project, config: atom.config})
modifiedPath = path.join(workingDirectory, 'file.txt')
newPath = path.join(workingDirectory, 'untracked.txt')
cleanPath = path.join(workingDirectory, 'other.txt')
fs.writeFileSync(cleanPath, 'Full of text')
fs.writeFileSync(newPath, '')
newPath = fs.absolute(newPath)
})
it('returns status information for all new and modified files', async () => {
const statusHandler = jasmine.createSpy('statusHandler')
repo.onDidChangeStatuses(statusHandler)
fs.writeFileSync(modifiedPath, 'making this path modified')
await repo.refreshStatus()
expect(statusHandler.callCount).toBe(1)
expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined()
expect(repo.isStatusNew(repo.getCachedPathStatus(newPath) )).toBeTruthy()
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy()
})
it('caches the proper statuses when a subdir is open', async () => {
const subDir = path.join(workingDirectory, 'dir')
fs.mkdirSync(subDir)
const filePath = path.join(subDir, 'b.txt')
fs.writeFileSync(filePath, '')
atom.project.setPaths([subDir])
await atom.workspace.open('b.txt')
repo = atom.project.getRepositories()[0]
await repo.refreshStatus()
const status = repo.getCachedPathStatus(filePath)
expect(repo.isStatusModified(status)).toBe(false)
expect(repo.isStatusNew(status)).toBe(false)
})
it('works correctly when the project has multiple folders (regression)', async () => {
atom.project.addPath(workingDirectory)
atom.project.addPath(path.join(__dirname, 'fixtures', 'dir'))
await repo.refreshStatus()
expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined()
expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy()
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy()
})
it('caches statuses that were looked up synchronously', async () => {
const originalContent = 'undefined'
fs.writeFileSync(modifiedPath, 'making this path modified')
repo.getPathStatus('file.txt')
fs.writeFileSync(modifiedPath, originalContent)
await repo.refreshStatus()
expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeFalsy()
})
})
describe('buffer events', () => {
let editor
beforeEach(async () => {
atom.project.setPaths([copyRepository()])
const refreshPromise = new Promise(resolve => atom.project.getRepositories()[0].onDidChangeStatuses(resolve))
editor = await atom.workspace.open('other.txt')
await refreshPromise
})
it('emits a status-changed event when a buffer is saved', async () => {
editor.insertNewline()
const statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus(statusHandler)
await editor.save()
expect(statusHandler.callCount).toBe(1)
expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256})
})
it('emits a status-changed event when a buffer is reloaded', async () => {
fs.writeFileSync(editor.getPath(), 'changed')
const statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus(statusHandler)
await editor.getBuffer().reload()
expect(statusHandler.callCount).toBe(1)
expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256})
await editor.getBuffer().reload()
expect(statusHandler.callCount).toBe(1)
})
it("emits a status-changed event when a buffer's path changes", () => {
fs.writeFileSync(editor.getPath(), 'changed')
const statusHandler = jasmine.createSpy('statusHandler')
atom.project.getRepositories()[0].onDidChangeStatus(statusHandler)
editor.getBuffer().emitter.emit('did-change-path')
expect(statusHandler.callCount).toBe(1)
expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256})
editor.getBuffer().emitter.emit('did-change-path')
expect(statusHandler.callCount).toBe(1)
})
it('stops listening to the buffer when the repository is destroyed (regression)', () => {
atom.project.getRepositories()[0].destroy()
expect(() => editor.save()).not.toThrow()
})
})
describe('when a project is deserialized', () => {
let buffer, project2, statusHandler
afterEach(() => {
if (project2) project2.destroy()
})
it('subscribes to all the serialized buffers in the project', async () => {
atom.project.setPaths([copyRepository()])
await atom.workspace.open('file.txt')
project2 = new Project({
notificationManager: atom.notifications,
packageManager: atom.packages,
confirm: atom.confirm,
applicationDelegate: atom.applicationDelegate
})
await project2.deserialize(atom.project.serialize({isUnloading: false}))
buffer = project2.getBuffers()[0]
const originalContent = buffer.getText()
buffer.append('changes')
statusHandler = jasmine.createSpy('statusHandler')
project2.getRepositories()[0].onDidChangeStatus(statusHandler)
await buffer.save()
expect(statusHandler.callCount).toBe(1)
expect(statusHandler).toHaveBeenCalledWith({path: buffer.getPath(), pathStatus: 256})
})
})
})
function copyRepository () {
const workingDirPath = temp.mkdirSync('atom-spec-git')
fs.copySync(path.join(__dirname, 'fixtures', 'git', 'working-dir'), workingDirPath)
fs.renameSync(path.join(workingDirPath, 'git.git'), path.join(workingDirPath, '.git'))
return workingDirPath
}

View File

@@ -120,6 +120,8 @@ describe "the `grammars` global", ->
atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true
atom.grammars.grammarForScopeName('test.rb').bundledPackage = false
expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env ruby').scopeName).toBe 'source.ruby'
expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env testruby').scopeName).toBe 'test.rb'
expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe 'test.rb'
describe "when there is no file path", ->

View File

@@ -1,64 +0,0 @@
Gutter = require '../src/gutter'
GutterContainer = require '../src/gutter-container'
describe 'GutterContainer', ->
gutterContainer = null
fakeTextEditor = {
scheduleComponentUpdate: ->
}
beforeEach ->
gutterContainer = new GutterContainer fakeTextEditor
describe 'when initialized', ->
it 'it has no gutters', ->
expect(gutterContainer.getGutters().length).toBe 0
describe '::addGutter', ->
it 'creates a new gutter', ->
newGutter = gutterContainer.addGutter {'test-gutter', priority: 1}
expect(gutterContainer.getGutters()).toEqual [newGutter]
expect(newGutter.priority).toBe 1
it 'throws an error if the provided gutter name is already in use', ->
name = 'test-gutter'
gutterContainer.addGutter {name}
expect(gutterContainer.addGutter.bind(null, {name})).toThrow()
it 'keeps added gutters sorted by ascending priority', ->
gutter1 = gutterContainer.addGutter {name: 'first', priority: 1}
gutter3 = gutterContainer.addGutter {name: 'third', priority: 3}
gutter2 = gutterContainer.addGutter {name: 'second', priority: 2}
expect(gutterContainer.getGutters()).toEqual [gutter1, gutter2, gutter3]
describe '::removeGutter', ->
removedGutters = null
beforeEach ->
gutterContainer = new GutterContainer fakeTextEditor
removedGutters = []
gutterContainer.onDidRemoveGutter (gutterName) ->
removedGutters.push gutterName
it 'removes the gutter if it is contained by this GutterContainer', ->
gutter = gutterContainer.addGutter {'test-gutter'}
expect(gutterContainer.getGutters()).toEqual [gutter]
gutterContainer.removeGutter gutter
expect(gutterContainer.getGutters().length).toBe 0
expect(removedGutters).toEqual [gutter.name]
it 'throws an error if the gutter is not within this GutterContainer', ->
fakeOtherTextEditor = {}
otherGutterContainer = new GutterContainer fakeOtherTextEditor
gutter = new Gutter 'gutter-name', otherGutterContainer
expect(gutterContainer.removeGutter.bind(null, gutter)).toThrow()
describe '::destroy', ->
it 'clears its array of gutters and destroys custom gutters', ->
newGutter = gutterContainer.addGutter {'test-gutter', priority: 1}
newGutterSpy = jasmine.createSpy()
newGutter.onDidDestroy(newGutterSpy)
gutterContainer.destroy()
expect(newGutterSpy).toHaveBeenCalled()
expect(gutterContainer.getGutters()).toEqual []

View File

@@ -0,0 +1,77 @@
const Gutter = require('../src/gutter')
const GutterContainer = require('../src/gutter-container')
describe('GutterContainer', () => {
let gutterContainer = null
const fakeTextEditor = {
scheduleComponentUpdate () {}
}
beforeEach(() => {
gutterContainer = new GutterContainer(fakeTextEditor)
})
describe('when initialized', () =>
it('it has no gutters', () => {
expect(gutterContainer.getGutters().length).toBe(0)
})
)
describe('::addGutter', () => {
it('creates a new gutter', () => {
const newGutter = gutterContainer.addGutter({'test-gutter': 'test-gutter', priority: 1})
expect(gutterContainer.getGutters()).toEqual([newGutter])
expect(newGutter.priority).toBe(1)
})
it('throws an error if the provided gutter name is already in use', () => {
const name = 'test-gutter'
gutterContainer.addGutter({name})
expect(gutterContainer.addGutter.bind(null, {name})).toThrow()
})
it('keeps added gutters sorted by ascending priority', () => {
const gutter1 = gutterContainer.addGutter({name: 'first', priority: 1})
const gutter3 = gutterContainer.addGutter({name: 'third', priority: 3})
const gutter2 = gutterContainer.addGutter({name: 'second', priority: 2})
expect(gutterContainer.getGutters()).toEqual([gutter1, gutter2, gutter3])
})
})
describe('::removeGutter', () => {
let removedGutters
beforeEach(function () {
gutterContainer = new GutterContainer(fakeTextEditor)
removedGutters = []
gutterContainer.onDidRemoveGutter(gutterName => removedGutters.push(gutterName))
})
it('removes the gutter if it is contained by this GutterContainer', () => {
const gutter = gutterContainer.addGutter({'test-gutter': 'test-gutter'})
expect(gutterContainer.getGutters()).toEqual([gutter])
gutterContainer.removeGutter(gutter)
expect(gutterContainer.getGutters().length).toBe(0)
expect(removedGutters).toEqual([gutter.name])
})
it('throws an error if the gutter is not within this GutterContainer', () => {
const fakeOtherTextEditor = {}
const otherGutterContainer = new GutterContainer(fakeOtherTextEditor)
const gutter = new Gutter('gutter-name', otherGutterContainer)
expect(gutterContainer.removeGutter.bind(null, gutter)).toThrow()
})
})
describe('::destroy', () =>
it('clears its array of gutters and destroys custom gutters', () => {
const newGutter = gutterContainer.addGutter({'test-gutter': 'test-gutter', priority: 1})
const newGutterSpy = jasmine.createSpy()
newGutter.onDidDestroy(newGutterSpy)
gutterContainer.destroy()
expect(newGutterSpy).toHaveBeenCalled()
expect(gutterContainer.getGutters()).toEqual([])
})
)
})

View File

@@ -1,70 +0,0 @@
Gutter = require '../src/gutter'
describe 'Gutter', ->
fakeGutterContainer = {
scheduleComponentUpdate: ->
}
name = 'name'
describe '::hide', ->
it 'hides the gutter if it is visible.', ->
options =
name: name
visible: true
gutter = new Gutter fakeGutterContainer, options
events = []
gutter.onDidChangeVisible (gutter) ->
events.push gutter.isVisible()
expect(gutter.isVisible()).toBe true
gutter.hide()
expect(gutter.isVisible()).toBe false
expect(events).toEqual [false]
gutter.hide()
expect(gutter.isVisible()).toBe false
# An event should only be emitted when the visibility changes.
expect(events.length).toBe 1
describe '::show', ->
it 'shows the gutter if it is hidden.', ->
options =
name: name
visible: false
gutter = new Gutter fakeGutterContainer, options
events = []
gutter.onDidChangeVisible (gutter) ->
events.push gutter.isVisible()
expect(gutter.isVisible()).toBe false
gutter.show()
expect(gutter.isVisible()).toBe true
expect(events).toEqual [true]
gutter.show()
expect(gutter.isVisible()).toBe true
# An event should only be emitted when the visibility changes.
expect(events.length).toBe 1
describe '::destroy', ->
[mockGutterContainer, mockGutterContainerRemovedGutters] = []
beforeEach ->
mockGutterContainerRemovedGutters = []
mockGutterContainer = removeGutter: (destroyedGutter) ->
mockGutterContainerRemovedGutters.push destroyedGutter
it 'removes the gutter from its container.', ->
gutter = new Gutter mockGutterContainer, {name}
gutter.destroy()
expect(mockGutterContainerRemovedGutters).toEqual([gutter])
it 'calls all callbacks registered on ::onDidDestroy.', ->
gutter = new Gutter mockGutterContainer, {name}
didDestroy = false
gutter.onDidDestroy ->
didDestroy = true
gutter.destroy()
expect(didDestroy).toBe true
it 'does not allow destroying the line-number gutter', ->
gutter = new Gutter mockGutterContainer, {name: 'line-number'}
expect(gutter.destroy).toThrow()

82
spec/gutter-spec.js Normal file
View File

@@ -0,0 +1,82 @@
const Gutter = require('../src/gutter')
describe('Gutter', () => {
const fakeGutterContainer = {
scheduleComponentUpdate () {}
}
const name = 'name'
describe('::hide', () =>
it('hides the gutter if it is visible.', () => {
const options = {
name,
visible: true
}
const gutter = new Gutter(fakeGutterContainer, options)
const events = []
gutter.onDidChangeVisible(gutter => events.push(gutter.isVisible()))
expect(gutter.isVisible()).toBe(true)
gutter.hide()
expect(gutter.isVisible()).toBe(false)
expect(events).toEqual([false])
gutter.hide()
expect(gutter.isVisible()).toBe(false)
// An event should only be emitted when the visibility changes.
expect(events.length).toBe(1)
})
)
describe('::show', () =>
it('shows the gutter if it is hidden.', () => {
const options = {
name,
visible: false
}
const gutter = new Gutter(fakeGutterContainer, options)
const events = []
gutter.onDidChangeVisible(gutter => events.push(gutter.isVisible()))
expect(gutter.isVisible()).toBe(false)
gutter.show()
expect(gutter.isVisible()).toBe(true)
expect(events).toEqual([true])
gutter.show()
expect(gutter.isVisible()).toBe(true)
// An event should only be emitted when the visibility changes.
expect(events.length).toBe(1)
})
)
describe('::destroy', () => {
let mockGutterContainer, mockGutterContainerRemovedGutters
beforeEach(() => {
mockGutterContainerRemovedGutters = []
mockGutterContainer = {
removeGutter (destroyedGutter) {
mockGutterContainerRemovedGutters.push(destroyedGutter)
}
}
})
it('removes the gutter from its container.', () => {
const gutter = new Gutter(mockGutterContainer, {name})
gutter.destroy()
expect(mockGutterContainerRemovedGutters).toEqual([gutter])
})
it('calls all callbacks registered on ::onDidDestroy.', () => {
const gutter = new Gutter(mockGutterContainer, {name})
let didDestroy = false
gutter.onDidDestroy(() => { didDestroy = true })
gutter.destroy()
expect(didDestroy).toBe(true)
})
it('does not allow destroying the line-number gutter', () => {
const gutter = new Gutter(mockGutterContainer, {name: 'line-number'})
expect(gutter.destroy).toThrow()
})
})
})

View File

@@ -5,6 +5,7 @@ import dedent from 'dedent'
import electron from 'electron'
import fs from 'fs-plus'
import path from 'path'
import sinon from 'sinon'
import AtomApplication from '../../src/main-process/atom-application'
import parseCommandLine from '../../src/main-process/parse-command-line'
import {timeoutPromise, conditionPromise, emitterEventPromise} from '../async-spec-helpers'
@@ -137,7 +138,7 @@ describe('AtomApplication', function () {
// Does not change the project paths when doing so.
const reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath]))
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.windows, [window1])
assert.deepEqual(atomApplication.getAllWindows(), [window1])
activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) {
const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) {
sendBackToMainProcess(textEditor.getPath())
@@ -177,7 +178,7 @@ describe('AtomApplication', function () {
// parent directory to the project
let reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add']))
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.windows, [window1])
assert.deepEqual(atomApplication.getAllWindows(), [window1])
activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) {
const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) {
sendBackToMainProcess(textEditor.getPath())
@@ -191,7 +192,7 @@ describe('AtomApplication', function () {
// the directory to the project
reusedWindow = atomApplication.launch(parseCommandLine([dirBPath, '-a']))
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.windows, [window1])
assert.deepEqual(atomApplication.getAllWindows(), [window1])
await conditionPromise(async () => (await getTreeViewRootDirectories(reusedWindow)).length === 3)
assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirCPath, dirBPath])
@@ -276,7 +277,7 @@ describe('AtomApplication', function () {
})
assert.equal(window2EditorTitle, 'untitled')
assert.deepEqual(atomApplication.windows, [window1, window2])
assert.deepEqual(atomApplication.getAllWindows(), [window2, window1])
})
it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async function () {
@@ -461,6 +462,31 @@ describe('AtomApplication', function () {
assert.equal(reached, true);
windows[0].close();
})
it('triggers /core/open/file in the correct window', async function() {
const dirAPath = makeTempDir('a')
const dirBPath = makeTempDir('b')
const atomApplication = buildAtomApplication()
const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath)]))
await focusWindow(window1)
const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath)]))
await focusWindow(window2)
const fileA = path.join(dirAPath, 'file-a')
const uriA = `atom://core/open/file?filename=${fileA}`
const fileB = path.join(dirBPath, 'file-b')
const uriB = `atom://core/open/file?filename=${fileB}`
sinon.spy(window1, 'sendURIMessage')
sinon.spy(window2, 'sendURIMessage')
atomApplication.launch(parseCommandLine(['--uri-handler', uriA]))
await conditionPromise(() => window1.sendURIMessage.calledWith(uriA), `window1 to be focused from ${fileA}`)
atomApplication.launch(parseCommandLine(['--uri-handler', uriB]))
await conditionPromise(() => window2.sendURIMessage.calledWith(uriB), `window2 to be focused from ${fileB}`)
})
})
})
@@ -514,7 +540,7 @@ describe('AtomApplication', function () {
async function focusWindow (window) {
window.focus()
await window.loadedPromise
await conditionPromise(() => window.atomApplication.lastFocusedWindow === window)
await conditionPromise(() => window.atomApplication.getLastFocusedWindow() === window)
}
function mockElectronAppQuit () {

View File

@@ -0,0 +1,27 @@
/** @babel */
import parseCommandLine from '../../src/main-process/parse-command-line'
describe('parseCommandLine', function () {
describe('when --uri-handler is not passed', function () {
it('parses arguments as normal', function () {
const args = parseCommandLine(['-d', '--safe', '--test', '/some/path', 'atom://test/url', 'atom://other/url'])
assert.isTrue(args.devMode)
assert.isTrue(args.safeMode)
assert.isTrue(args.test)
assert.deepEqual(args.urlsToOpen, ['atom://test/url', 'atom://other/url'])
assert.deepEqual(args.pathsToOpen, ['/some/path'])
})
})
describe('when --uri-handler is passed', function () {
it('ignores other arguments and limits to one URL', function () {
const args = parseCommandLine(['-d', '--uri-handler', '--safe', '--test', '/some/path', 'atom://test/url', 'atom://other/url'])
assert.isUndefined(args.devMode)
assert.isUndefined(args.safeMode)
assert.isUndefined(args.test)
assert.deepEqual(args.urlsToOpen, ['atom://test/url'])
assert.deepEqual(args.pathsToOpen, [])
})
})
})

View File

@@ -6,6 +6,7 @@ describe "MenuManager", ->
beforeEach ->
menu = new MenuManager({keymapManager: atom.keymaps, packageManager: atom.packages})
spyOn(menu, 'sendToBrowserProcess') # Do not modify Atom's actual menus
menu.initialize({resourcePath: atom.getLoadSettings().resourcePath})
describe "::add(items)", ->
@@ -54,7 +55,6 @@ describe "MenuManager", ->
afterEach -> Object.defineProperty process, 'platform', value: originalPlatform
it "sends the current menu template and associated key bindings to the browser process", ->
spyOn(menu, 'sendToBrowserProcess')
menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}]
atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b'
menu.update()
@@ -66,7 +66,6 @@ describe "MenuManager", ->
it "omits key bindings that are mapped to unset! in any context", ->
# it would be nice to be smarter about omitting, but that would require a much
# more dynamic interaction between the currently focused element and the menu
spyOn(menu, 'sendToBrowserProcess')
menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}]
atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b'
atom.keymaps.add 'test', 'atom-text-editor': 'ctrl-b': 'unset!'
@@ -77,7 +76,6 @@ describe "MenuManager", ->
it "omits key bindings that could conflict with AltGraph characters on macOS", ->
Object.defineProperty process, 'platform', value: 'darwin'
spyOn(menu, 'sendToBrowserProcess')
menu.add [{label: "A", submenu: [
{label: "B", command: "b"},
{label: "C", command: "c"}
@@ -98,7 +96,6 @@ describe "MenuManager", ->
it "omits key bindings that could conflict with AltGraph characters on Windows", ->
Object.defineProperty process, 'platform', value: 'win32'
spyOn(menu, 'sendToBrowserProcess')
menu.add [{label: "A", submenu: [
{label: "B", command: "b"},
{label: "C", command: "c"}

View File

@@ -1,4 +1,5 @@
const path = require('path')
const url = require('url')
const Package = require('../src/package')
const PackageManager = require('../src/package-manager')
const temp = require('temp').track()
@@ -1038,6 +1039,20 @@ describe('PackageManager', () => {
})
})
describe("URI handler registration", () => {
it("registers the package's specified URI handler", async () => {
const uri = 'atom://package-with-uri-handler/some/url?with=args'
const mod = require('./fixtures/packages/package-with-uri-handler')
spyOn(mod, 'handleURI')
spyOn(atom.packages, 'hasLoadedInitialPackages').andReturn(true)
const activationPromise = atom.packages.activatePackage('package-with-uri-handler')
atom.dispatchURIMessage(uri)
await activationPromise
expect(mod.handleURI).toHaveBeenCalledWith(url.parse(uri, true), uri)
})
})
describe('service registration', () => {
it("registers the package's provided and consumed services", async () => {
const consumerModule = require('./fixtures/packages/package-with-consumed-services')

View File

@@ -1,802 +0,0 @@
temp = require('temp').track()
TextBuffer = require('text-buffer')
Project = require '../src/project'
fs = require 'fs-plus'
path = require 'path'
{Directory} = require 'pathwatcher'
{stopAllWatchers} = require '../src/path-watcher'
GitRepository = require '../src/git-repository'
describe "Project", ->
beforeEach ->
atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')])
# Wait for project's service consumers to be asynchronously added
waits(1)
describe "serialization", ->
deserializedProject = null
notQuittingProject = null
quittingProject = null
afterEach ->
deserializedProject?.destroy()
notQuittingProject?.destroy()
quittingProject?.destroy()
it "does not deserialize paths to directories that don't exist", ->
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
state = atom.project.serialize()
state.paths.push('/directory/that/does/not/exist')
err = null
waitsForPromise ->
deserializedProject.deserialize(state, atom.deserializers)
.catch (e) -> err = e
runs ->
expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths())
expect(err.missingProjectPaths).toEqual ['/directory/that/does/not/exist']
it "does not deserialize paths that are now files", ->
childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child')
fs.mkdirSync(childPath)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
atom.project.setPaths([childPath])
state = atom.project.serialize()
fs.rmdirSync(childPath)
fs.writeFileSync(childPath, 'surprise!\n')
err = null
waitsForPromise ->
deserializedProject.deserialize(state, atom.deserializers)
.catch (e) -> err = e
runs ->
expect(deserializedProject.getPaths()).toEqual([])
expect(err.missingProjectPaths).toEqual [childPath]
it "does not include unretained buffers in the serialized state", ->
waitsForPromise ->
atom.project.bufferForPath('a')
runs ->
expect(atom.project.getBuffers().length).toBe 1
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "listens for destroyed events on deserialized buffers and removes them when they are destroyed", ->
waitsForPromise ->
atom.workspace.open('a')
runs ->
expect(atom.project.getBuffers().length).toBe 1
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 1
deserializedProject.getBuffers()[0].destroy()
expect(deserializedProject.getBuffers().length).toBe 0
it "does not deserialize buffers when their path is now a directory", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise ->
atom.workspace.open(pathToOpen)
runs ->
expect(atom.project.getBuffers().length).toBe 1
fs.mkdirSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "does not deserialize buffers when their path is inaccessible", ->
return if process.platform is 'win32' # chmod not supported on win32
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
fs.writeFileSync(pathToOpen, '')
waitsForPromise ->
atom.workspace.open(pathToOpen)
runs ->
expect(atom.project.getBuffers().length).toBe 1
fs.chmodSync(pathToOpen, '000')
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "does not deserialize buffers with their path is no longer present", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
fs.writeFileSync(pathToOpen, '')
waitsForPromise ->
atom.workspace.open(pathToOpen)
runs ->
expect(atom.project.getBuffers().length).toBe 1
fs.unlinkSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 0
it "deserializes buffers that have never been saved before", ->
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise ->
atom.workspace.open(pathToOpen)
runs ->
atom.workspace.getActiveTextEditor().setText('unsaved\n')
expect(atom.project.getBuffers().length).toBe 1
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise ->
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(deserializedProject.getBuffers().length).toBe 1
expect(deserializedProject.getBuffers()[0].getPath()).toBe pathToOpen
expect(deserializedProject.getBuffers()[0].getText()).toBe 'unsaved\n'
it "serializes marker layers and history only if Atom is quitting", ->
waitsForPromise -> atom.workspace.open('a')
bufferA = null
layerA = null
markerA = null
runs ->
bufferA = atom.project.getBuffers()[0]
layerA = bufferA.addMarkerLayer(persistent: true)
markerA = layerA.markPosition([0, 3])
bufferA.append('!')
notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise -> notQuittingProject.deserialize(atom.project.serialize({isUnloading: false}))
runs ->
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined()
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
waitsForPromise -> quittingProject.deserialize(atom.project.serialize({isUnloading: true}))
runs ->
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined()
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
describe "when an editor is saved and the project has no path", ->
it "sets the project's path to the saved file's parent directory", ->
tempFile = temp.openSync().path
atom.project.setPaths([])
expect(atom.project.getPaths()[0]).toBeUndefined()
editor = null
waitsForPromise ->
atom.workspace.open().then (o) -> editor = o
waitsForPromise ->
editor.saveAs(tempFile)
runs ->
expect(atom.project.getPaths()[0]).toBe path.dirname(tempFile)
describe "before and after saving a buffer", ->
[buffer] = []
beforeEach ->
waitsForPromise ->
atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then (o) ->
buffer = o
buffer.retain()
afterEach ->
buffer.release()
it "emits save events on the main process", ->
spyOn(atom.project.applicationDelegate, 'emitDidSavePath')
spyOn(atom.project.applicationDelegate, 'emitWillSavePath')
waitsForPromise -> buffer.save()
runs ->
expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath())
expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath())
describe "when a watch error is thrown from the TextBuffer", ->
editor = null
beforeEach ->
waitsForPromise ->
atom.workspace.open(require.resolve('./fixtures/dir/a')).then (o) -> editor = o
it "creates a warning notification", ->
atom.notifications.onDidAddNotification noteSpy = jasmine.createSpy()
error = new Error('SomeError')
error.eventType = 'resurrect'
editor.buffer.emitter.emit 'will-throw-watch-error',
handle: jasmine.createSpy()
error: error
expect(noteSpy).toHaveBeenCalled()
notification = noteSpy.mostRecentCall.args[0]
expect(notification.getType()).toBe 'warning'
expect(notification.getDetail()).toBe 'SomeError'
expect(notification.getMessage()).toContain '`resurrect`'
expect(notification.getMessage()).toContain path.join('fixtures', 'dir', 'a')
describe "when a custom repository-provider service is provided", ->
[fakeRepositoryProvider, fakeRepository] = []
beforeEach ->
fakeRepository = {destroy: -> null}
fakeRepositoryProvider = {
repositoryForDirectory: (directory) -> Promise.resolve(fakeRepository)
repositoryForDirectorySync: (directory) -> fakeRepository
}
it "uses it to create repositories for any directories that need one", ->
projectPath = temp.mkdirSync('atom-project')
atom.project.setPaths([projectPath])
expect(atom.project.getRepositories()).toEqual [null]
atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
waitsFor -> atom.project.repositoryProviders.length > 1
runs -> atom.project.getRepositories()[0] is fakeRepository
it "does not create any new repositories if every directory has a repository", ->
repositories = atom.project.getRepositories()
expect(repositories.length).toEqual 1
expect(repositories[0]).toBeTruthy()
atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
waitsFor -> atom.project.repositoryProviders.length > 1
runs -> expect(atom.project.getRepositories()).toBe repositories
it "stops using it to create repositories when the service is removed", ->
atom.project.setPaths([])
disposable = atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
waitsFor -> atom.project.repositoryProviders.length > 1
runs ->
disposable.dispose()
atom.project.addPath(temp.mkdirSync('atom-project'))
expect(atom.project.getRepositories()).toEqual [null]
describe "when a custom directory-provider service is provided", ->
class DummyDirectory
constructor: (@path) ->
getPath: -> @path
getFile: -> {existsSync: -> false}
getSubdirectory: -> {existsSync: -> false}
isRoot: -> true
existsSync: -> @path.endsWith('does-exist')
contains: (filePath) -> filePath.startsWith(@path)
serviceDisposable = null
beforeEach ->
serviceDisposable = atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", {
directoryForURISync: (uri) ->
if uri.startsWith("ssh://")
new DummyDirectory(uri)
else
null
})
waitsFor ->
atom.project.directoryProviders.length > 0
it "uses the provider's custom directories for any paths that it handles", ->
localPath = temp.mkdirSync('local-path')
remotePath = "ssh://foreign-directory:8080/does-exist"
atom.project.setPaths([localPath, remotePath])
directories = atom.project.getDirectories()
expect(directories[0].getPath()).toBe localPath
expect(directories[0] instanceof Directory).toBe true
expect(directories[1].getPath()).toBe remotePath
expect(directories[1] instanceof DummyDirectory).toBe true
# It does not add new remote paths that do not exist
nonExistentRemotePath = "ssh://another-directory:8080/does-not-exist"
atom.project.addPath(nonExistentRemotePath)
expect(atom.project.getDirectories().length).toBe 2
# It adds new remote paths if their directories exist.
newRemotePath = "ssh://another-directory:8080/does-exist"
atom.project.addPath(newRemotePath)
directories = atom.project.getDirectories()
expect(directories[2].getPath()).toBe newRemotePath
expect(directories[2] instanceof DummyDirectory).toBe true
it "stops using the provider when the service is removed", ->
serviceDisposable.dispose()
atom.project.setPaths(["ssh://foreign-directory:8080/does-exist"])
expect(atom.project.getDirectories().length).toBe(0)
describe ".open(path)", ->
[absolutePath, newBufferHandler] = []
beforeEach ->
absolutePath = require.resolve('./fixtures/dir/a')
newBufferHandler = jasmine.createSpy('newBufferHandler')
atom.project.onDidAddBuffer(newBufferHandler)
describe "when given an absolute path that isn't currently open", ->
it "returns a new edit session for the given path and emits 'buffer-created'", ->
editor = null
waitsForPromise ->
atom.workspace.open(absolutePath).then (o) -> editor = o
runs ->
expect(editor.buffer.getPath()).toBe absolutePath
expect(newBufferHandler).toHaveBeenCalledWith editor.buffer
describe "when given a relative path that isn't currently opened", ->
it "returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", ->
editor = null
waitsForPromise ->
atom.workspace.open(absolutePath).then (o) -> editor = o
runs ->
expect(editor.buffer.getPath()).toBe absolutePath
expect(newBufferHandler).toHaveBeenCalledWith editor.buffer
describe "when passed the path to a buffer that is currently opened", ->
it "returns a new edit session containing currently opened buffer", ->
editor = null
waitsForPromise ->
atom.workspace.open(absolutePath).then (o) -> editor = o
runs ->
newBufferHandler.reset()
waitsForPromise ->
atom.workspace.open(absolutePath).then ({buffer}) ->
expect(buffer).toBe editor.buffer
waitsForPromise ->
atom.workspace.open('a').then ({buffer}) ->
expect(buffer).toBe editor.buffer
expect(newBufferHandler).not.toHaveBeenCalled()
describe "when not passed a path", ->
it "returns a new edit session and emits 'buffer-created'", ->
editor = null
waitsForPromise ->
atom.workspace.open().then (o) -> editor = o
runs ->
expect(editor.buffer.getPath()).toBeUndefined()
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
describe ".bufferForPath(path)", ->
buffer = null
beforeEach ->
waitsForPromise ->
atom.project.bufferForPath("a").then (o) ->
buffer = o
buffer.retain()
afterEach ->
buffer.release()
describe "when opening a previously opened path", ->
it "does not create a new buffer", ->
waitsForPromise ->
atom.project.bufferForPath("a").then (anotherBuffer) ->
expect(anotherBuffer).toBe buffer
waitsForPromise ->
atom.project.bufferForPath("b").then (anotherBuffer) ->
expect(anotherBuffer).not.toBe buffer
waitsForPromise ->
Promise.all([
atom.project.bufferForPath('c'),
atom.project.bufferForPath('c')
]).then ([buffer1, buffer2]) ->
expect(buffer1).toBe(buffer2)
it "retries loading the buffer if it previously failed", ->
waitsForPromise shouldReject: true, ->
spyOn(TextBuffer, 'load').andCallFake ->
Promise.reject(new Error('Could not open file'))
atom.project.bufferForPath('b')
waitsForPromise shouldReject: false, ->
TextBuffer.load.andCallThrough()
atom.project.bufferForPath('b')
it "creates a new buffer if the previous buffer was destroyed", ->
buffer.release()
waitsForPromise ->
atom.project.bufferForPath("b").then (anotherBuffer) ->
expect(anotherBuffer).not.toBe buffer
describe ".repositoryForDirectory(directory)", ->
it "resolves to null when the directory does not have a repository", ->
waitsForPromise ->
directory = new Directory("/tmp")
atom.project.repositoryForDirectory(directory).then (result) ->
expect(result).toBeNull()
expect(atom.project.repositoryProviders.length).toBeGreaterThan 0
expect(atom.project.repositoryPromisesByPath.size).toBe 0
it "resolves to a GitRepository and is cached when the given directory is a Git repo", ->
waitsForPromise ->
directory = new Directory(path.join(__dirname, '..'))
promise = atom.project.repositoryForDirectory(directory)
promise.then (result) ->
expect(result).toBeInstanceOf GitRepository
dirPath = directory.getRealPathSync()
expect(result.getPath()).toBe path.join(dirPath, '.git')
# Verify that the result is cached.
expect(atom.project.repositoryForDirectory(directory)).toBe(promise)
it "creates a new repository if a previous one with the same directory had been destroyed", ->
repository = null
directory = new Directory(path.join(__dirname, '..'))
waitsForPromise ->
atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo
runs ->
expect(repository.isDestroyed()).toBe(false)
repository.destroy()
expect(repository.isDestroyed()).toBe(true)
waitsForPromise ->
atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo
runs ->
expect(repository.isDestroyed()).toBe(false)
describe ".setPaths(paths, options)", ->
describe "when path is a file", ->
it "sets its path to the file's parent directory and updates the root directory", ->
filePath = require.resolve('./fixtures/dir/a')
atom.project.setPaths([filePath])
expect(atom.project.getPaths()[0]).toEqual path.dirname(filePath)
expect(atom.project.getDirectories()[0].path).toEqual path.dirname(filePath)
describe "when path is a directory", ->
it "assigns the directories and repositories", ->
directory1 = temp.mkdirSync("non-git-repo")
directory2 = temp.mkdirSync("git-repo1")
directory3 = temp.mkdirSync("git-repo2")
gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
fs.copySync(gitDirPath, path.join(directory2, ".git"))
fs.copySync(gitDirPath, path.join(directory3, ".git"))
atom.project.setPaths([directory1, directory2, directory3])
[repo1, repo2, repo3] = atom.project.getRepositories()
expect(repo1).toBeNull()
expect(repo2.getShortHead()).toBe "master"
expect(repo2.getPath()).toBe fs.realpathSync(path.join(directory2, ".git"))
expect(repo3.getShortHead()).toBe "master"
expect(repo3.getPath()).toBe fs.realpathSync(path.join(directory3, ".git"))
it "calls callbacks registered with ::onDidChangePaths", ->
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
paths = [ temp.mkdirSync("dir1"), temp.mkdirSync("dir2") ]
atom.project.setPaths(paths)
expect(onDidChangePathsSpy.callCount).toBe 1
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths)
it "optionally throws an error with any paths that did not exist", ->
paths = [temp.mkdirSync("exists0"), "/doesnt-exists/0", temp.mkdirSync("exists1"), "/doesnt-exists/1"]
try
atom.project.setPaths paths, mustExist: true
expect('no exception thrown').toBeUndefined()
catch e
expect(e.missingProjectPaths).toEqual [paths[1], paths[3]]
expect(atom.project.getPaths()).toEqual [paths[0], paths[2]]
describe "when no paths are given", ->
it "clears its path", ->
atom.project.setPaths([])
expect(atom.project.getPaths()).toEqual []
expect(atom.project.getDirectories()).toEqual []
it "normalizes the path to remove consecutive slashes, ., and .. segments", ->
atom.project.setPaths(["#{require.resolve('./fixtures/dir/a')}#{path.sep}b#{path.sep}#{path.sep}.."])
expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
describe ".addPath(path, options)", ->
it "calls callbacks registered with ::onDidChangePaths", ->
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
[oldPath] = atom.project.getPaths()
newPath = temp.mkdirSync("dir")
atom.project.addPath(newPath)
expect(onDidChangePathsSpy.callCount).toBe 1
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath])
it "doesn't add redundant paths", ->
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
[oldPath] = atom.project.getPaths()
# Doesn't re-add an existing root directory
atom.project.addPath(oldPath)
expect(atom.project.getPaths()).toEqual([oldPath])
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
# Doesn't add an entry for a file-path within an existing root directory
atom.project.addPath(path.join(oldPath, 'some-file.txt'))
expect(atom.project.getPaths()).toEqual([oldPath])
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
# Does add an entry for a directory within an existing directory
newPath = path.join(oldPath, "a-dir")
atom.project.addPath(newPath)
expect(atom.project.getPaths()).toEqual([oldPath, newPath])
expect(onDidChangePathsSpy).toHaveBeenCalled()
it "doesn't add non-existent directories", ->
previousPaths = atom.project.getPaths()
atom.project.addPath('/this-definitely/does-not-exist')
expect(atom.project.getPaths()).toEqual(previousPaths)
it "optionally throws on non-existent directories", ->
expect ->
atom.project.addPath '/this-definitely/does-not-exist', mustExist: true
.toThrow()
describe ".removePath(path)", ->
onDidChangePathsSpy = null
beforeEach ->
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener')
atom.project.onDidChangePaths(onDidChangePathsSpy)
it "removes the directory and repository for the path", ->
result = atom.project.removePath(atom.project.getPaths()[0])
expect(atom.project.getDirectories()).toEqual([])
expect(atom.project.getRepositories()).toEqual([])
expect(atom.project.getPaths()).toEqual([])
expect(result).toBe true
expect(onDidChangePathsSpy).toHaveBeenCalled()
it "does nothing if the path is not one of the project's root paths", ->
originalPaths = atom.project.getPaths()
result = atom.project.removePath(originalPaths[0] + "xyz")
expect(result).toBe false
expect(atom.project.getPaths()).toEqual(originalPaths)
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
it "doesn't destroy the repository if it is shared by another root directory", ->
atom.project.setPaths([__dirname, path.join(__dirname, "..", "src")])
atom.project.removePath(__dirname)
expect(atom.project.getPaths()).toEqual([path.join(__dirname, "..", "src")])
expect(atom.project.getRepositories()[0].isSubmodule("src")).toBe false
it "removes a path that is represented as a URI", ->
atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", {
directoryForURISync: (uri) ->
{
getPath: -> uri
getSubdirectory: -> {}
isRoot: -> true
existsSync: -> true
off: ->
}
})
ftpURI = "ftp://example.com/some/folder"
atom.project.setPaths([ftpURI])
expect(atom.project.getPaths()).toEqual [ftpURI]
atom.project.removePath(ftpURI)
expect(atom.project.getPaths()).toEqual []
describe ".onDidChangeFiles()", ->
sub = []
events = []
checkCallback = ->
beforeEach ->
sub = atom.project.onDidChangeFiles (incoming) ->
events.push incoming...
checkCallback()
afterEach ->
sub.dispose()
waitForEvents = (paths) ->
remaining = new Set(fs.realpathSync(p) for p in paths)
new Promise (resolve, reject) ->
checkCallback = ->
remaining.delete(event.path) for event in events
resolve() if remaining.size is 0
expire = ->
checkCallback = ->
console.error "Paths not seen:", Array.from(remaining)
reject(new Error('Expired before all expected events were delivered.'))
checkCallback()
setTimeout expire, 2000
it "reports filesystem changes within project paths", ->
dirOne = temp.mkdirSync('atom-spec-project-one')
fileOne = path.join(dirOne, 'file-one.txt')
fileTwo = path.join(dirOne, 'file-two.txt')
dirTwo = temp.mkdirSync('atom-spec-project-two')
fileThree = path.join(dirTwo, 'file-three.txt')
# Ensure that all preexisting watchers are stopped
waitsForPromise -> stopAllWatchers()
runs -> atom.project.setPaths([dirOne])
waitsForPromise -> atom.project.getWatcherPromise dirOne
runs ->
expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual undefined
fs.writeFileSync fileThree, "three\n"
fs.writeFileSync fileTwo, "two\n"
fs.writeFileSync fileOne, "one\n"
waitsForPromise -> waitForEvents [fileOne, fileTwo]
runs ->
expect(events.some (event) -> event.path is fileThree).toBeFalsy()
describe ".onDidAddBuffer()", ->
it "invokes the callback with added text buffers", ->
buffers = []
added = []
waitsForPromise ->
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
.then (o) -> buffers.push(o)
runs ->
expect(buffers.length).toBe 1
atom.project.onDidAddBuffer (buffer) -> added.push(buffer)
waitsForPromise ->
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then (o) -> buffers.push(o)
runs ->
expect(buffers.length).toBe 2
expect(added).toEqual [buffers[1]]
describe ".observeBuffers()", ->
it "invokes the observer with current and future text buffers", ->
buffers = []
observed = []
waitsForPromise ->
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
.then (o) -> buffers.push(o)
waitsForPromise ->
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then (o) -> buffers.push(o)
runs ->
expect(buffers.length).toBe 2
atom.project.observeBuffers (buffer) -> observed.push(buffer)
expect(observed).toEqual buffers
waitsForPromise ->
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then (o) -> buffers.push(o)
runs ->
expect(observed.length).toBe 3
expect(buffers.length).toBe 3
expect(observed).toEqual buffers
describe ".relativize(path)", ->
it "returns the path, relative to whichever root directory it is inside of", ->
atom.project.addPath(temp.mkdirSync("another-path"))
rootPath = atom.project.getPaths()[0]
childPath = path.join(rootPath, "some", "child", "directory")
expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory")
rootPath = atom.project.getPaths()[1]
childPath = path.join(rootPath, "some", "child", "directory")
expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory")
it "returns the given path if it is not in any of the root directories", ->
randomPath = path.join("some", "random", "path")
expect(atom.project.relativize(randomPath)).toBe randomPath
describe ".relativizePath(path)", ->
it "returns the root path that contains the given path, and the path relativized to that root path", ->
atom.project.addPath(temp.mkdirSync("another-path"))
rootPath = atom.project.getPaths()[0]
childPath = path.join(rootPath, "some", "child", "directory")
expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")]
rootPath = atom.project.getPaths()[1]
childPath = path.join(rootPath, "some", "child", "directory")
expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")]
describe "when the given path isn't inside of any of the project's path", ->
it "returns null for the root path, and the given path unchanged", ->
randomPath = path.join("some", "random", "path")
expect(atom.project.relativizePath(randomPath)).toEqual [null, randomPath]
describe "when the given path is a URL", ->
it "returns null for the root path, and the given path unchanged", ->
url = "http://the-path"
expect(atom.project.relativizePath(url)).toEqual [null, url]
describe "when the given path is inside more than one root folder", ->
it "uses the root folder that is closest to the given path", ->
atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir'))
inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt')
expect(atom.project.getDirectories()[0].contains(inputPath)).toBe true
expect(atom.project.getDirectories()[1].contains(inputPath)).toBe true
expect(atom.project.relativizePath(inputPath)).toEqual [
atom.project.getPaths()[1],
path.join('somewhere', 'something.txt')
]
describe ".contains(path)", ->
it "returns whether or not the given path is in one of the root directories", ->
rootPath = atom.project.getPaths()[0]
childPath = path.join(rootPath, "some", "child", "directory")
expect(atom.project.contains(childPath)).toBe true
randomPath = path.join("some", "random", "path")
expect(atom.project.contains(randomPath)).toBe false
describe ".resolvePath(uri)", ->
it "normalizes disk drive letter in passed path on #win32", ->
expect(atom.project.resolvePath("d:\\file.txt")).toEqual "D:\\file.txt"

955
spec/project-spec.js Normal file
View File

@@ -0,0 +1,955 @@
const temp = require('temp').track()
const TextBuffer = require('text-buffer')
const Project = require('../src/project')
const fs = require('fs-plus')
const path = require('path')
const {Directory} = require('pathwatcher')
const {stopAllWatchers} = require('../src/path-watcher')
const GitRepository = require('../src/git-repository')
describe('Project', () => {
beforeEach(() => {
const directory = atom.project.getDirectories()[0]
const paths = directory ? [directory.resolve('dir')] : [null]
atom.project.setPaths(paths)
// Wait for project's service consumers to be asynchronously added
waits(1)
})
describe('serialization', () => {
let deserializedProject = null
let notQuittingProject = null
let quittingProject = null
afterEach(() => {
if (deserializedProject != null) {
deserializedProject.destroy()
}
if (notQuittingProject != null) {
notQuittingProject.destroy()
}
if (quittingProject != null) {
quittingProject.destroy()
}
})
it("does not deserialize paths to directories that don't exist", () => {
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
const state = atom.project.serialize()
state.paths.push('/directory/that/does/not/exist')
let err = null
waitsForPromise(() =>
deserializedProject.deserialize(state, atom.deserializers)
.catch(e => { err = e })
)
runs(() => {
expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths())
expect(err.missingProjectPaths).toEqual(['/directory/that/does/not/exist'])
})
})
it('does not deserialize paths that are now files', () => {
const childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child')
fs.mkdirSync(childPath)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
atom.project.setPaths([childPath])
const state = atom.project.serialize()
fs.rmdirSync(childPath)
fs.writeFileSync(childPath, 'surprise!\n')
let err = null
waitsForPromise(() =>
deserializedProject.deserialize(state, atom.deserializers)
.catch(e => { err = e })
)
runs(() => {
expect(deserializedProject.getPaths()).toEqual([])
expect(err.missingProjectPaths).toEqual([childPath])
})
})
it('does not include unretained buffers in the serialized state', () => {
waitsForPromise(() => atom.project.bufferForPath('a'))
runs(() => {
expect(atom.project.getBuffers().length).toBe(1)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
})
it('listens for destroyed events on deserialized buffers and removes them when they are destroyed', () => {
waitsForPromise(() => atom.workspace.open('a'))
runs(() => {
expect(atom.project.getBuffers().length).toBe(1)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => {
expect(deserializedProject.getBuffers().length).toBe(1)
deserializedProject.getBuffers()[0].destroy()
expect(deserializedProject.getBuffers().length).toBe(0)
})
})
it('does not deserialize buffers when their path is now a directory', () => {
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise(() => atom.workspace.open(pathToOpen))
runs(() => {
expect(atom.project.getBuffers().length).toBe(1)
fs.mkdirSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
})
it('does not deserialize buffers when their path is inaccessible', () => {
if (process.platform === 'win32') { return } // chmod not supported on win32
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
fs.writeFileSync(pathToOpen, '')
waitsForPromise(() => atom.workspace.open(pathToOpen))
runs(() => {
expect(atom.project.getBuffers().length).toBe(1)
fs.chmodSync(pathToOpen, '000')
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
})
it('does not deserialize buffers with their path is no longer present', () => {
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
fs.writeFileSync(pathToOpen, '')
waitsForPromise(() => atom.workspace.open(pathToOpen))
runs(() => {
expect(atom.project.getBuffers().length).toBe(1)
fs.unlinkSync(pathToOpen)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
})
it('deserializes buffers that have never been saved before', () => {
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
waitsForPromise(() => atom.workspace.open(pathToOpen))
runs(() => {
atom.workspace.getActiveTextEditor().setText('unsaved\n')
expect(atom.project.getBuffers().length).toBe(1)
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => {
expect(deserializedProject.getBuffers().length).toBe(1)
expect(deserializedProject.getBuffers()[0].getPath()).toBe(pathToOpen)
expect(deserializedProject.getBuffers()[0].getText()).toBe('unsaved\n')
})
})
it('serializes marker layers and history only if Atom is quitting', () => {
waitsForPromise(() => atom.workspace.open('a'))
let bufferA = null
let layerA = null
let markerA = null
runs(() => {
bufferA = atom.project.getBuffers()[0]
layerA = bufferA.addMarkerLayer({persistent: true})
markerA = layerA.markPosition([0, 3])
bufferA.append('!')
notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})))
runs(() => {
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).toBeUndefined()
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
})
waitsForPromise(() => quittingProject.deserialize(atom.project.serialize({isUnloading: true})))
runs(() => {
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).not.toBeUndefined()
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
})
})
})
describe('when an editor is saved and the project has no path', () =>
it("sets the project's path to the saved file's parent directory", () => {
const tempFile = temp.openSync().path
atom.project.setPaths([])
expect(atom.project.getPaths()[0]).toBeUndefined()
let editor = null
waitsForPromise(() => atom.workspace.open().then(o => { editor = o }))
waitsForPromise(() => editor.saveAs(tempFile))
runs(() => expect(atom.project.getPaths()[0]).toBe(path.dirname(tempFile)))
})
)
describe('before and after saving a buffer', () => {
let buffer
beforeEach(() =>
waitsForPromise(() =>
atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then((o) => {
buffer = o
buffer.retain()
})
)
)
afterEach(() => buffer.release())
it('emits save events on the main process', () => {
spyOn(atom.project.applicationDelegate, 'emitDidSavePath')
spyOn(atom.project.applicationDelegate, 'emitWillSavePath')
waitsForPromise(() => buffer.save())
runs(() => {
expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath())
expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1)
expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath())
})
})
})
describe('when a watch error is thrown from the TextBuffer', () => {
let editor = null
beforeEach(() =>
waitsForPromise(() => atom.workspace.open(require.resolve('./fixtures/dir/a')).then(o => { editor = o }))
)
it('creates a warning notification', () => {
let noteSpy
atom.notifications.onDidAddNotification(noteSpy = jasmine.createSpy())
const error = new Error('SomeError')
error.eventType = 'resurrect'
editor.buffer.emitter.emit('will-throw-watch-error', {
handle: jasmine.createSpy(),
error
}
)
expect(noteSpy).toHaveBeenCalled()
const notification = noteSpy.mostRecentCall.args[0]
expect(notification.getType()).toBe('warning')
expect(notification.getDetail()).toBe('SomeError')
expect(notification.getMessage()).toContain('`resurrect`')
expect(notification.getMessage()).toContain(path.join('fixtures', 'dir', 'a'))
})
})
describe('when a custom repository-provider service is provided', () => {
let fakeRepositoryProvider, fakeRepository
beforeEach(() => {
fakeRepository = {destroy () { return null }}
fakeRepositoryProvider = {
repositoryForDirectory (directory) { return Promise.resolve(fakeRepository) },
repositoryForDirectorySync (directory) { return fakeRepository }
}
})
it('uses it to create repositories for any directories that need one', () => {
const projectPath = temp.mkdirSync('atom-project')
atom.project.setPaths([projectPath])
expect(atom.project.getRepositories()).toEqual([null])
atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
waitsFor(() => atom.project.repositoryProviders.length > 1)
runs(() => atom.project.getRepositories()[0] === fakeRepository)
})
it('does not create any new repositories if every directory has a repository', () => {
const repositories = atom.project.getRepositories()
expect(repositories.length).toEqual(1)
expect(repositories[0]).toBeTruthy()
atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
waitsFor(() => atom.project.repositoryProviders.length > 1)
runs(() => expect(atom.project.getRepositories()).toBe(repositories))
})
it('stops using it to create repositories when the service is removed', () => {
atom.project.setPaths([])
const disposable = atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
waitsFor(() => atom.project.repositoryProviders.length > 1)
runs(() => {
disposable.dispose()
atom.project.addPath(temp.mkdirSync('atom-project'))
expect(atom.project.getRepositories()).toEqual([null])
})
})
})
describe('when a custom directory-provider service is provided', () => {
class DummyDirectory {
constructor (aPath) {
this.path = aPath
}
getPath () { return this.path }
getFile () { return {existsSync () { return false }} }
getSubdirectory () { return {existsSync () { return false }} }
isRoot () { return true }
existsSync () { return this.path.endsWith('does-exist') }
contains (filePath) { return filePath.startsWith(this.path) }
onDidChangeFiles (callback) {
onDidChangeFilesCallback = callback
return {dispose: () => {}}
}
}
let serviceDisposable = null
let onDidChangeFilesCallback = null
beforeEach(() => {
serviceDisposable = atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', {
directoryForURISync (uri) {
if (uri.startsWith('ssh://')) {
return new DummyDirectory(uri)
} else {
return null
}
}
})
onDidChangeFilesCallback = null
waitsFor(() => atom.project.directoryProviders.length > 0)
})
it("uses the provider's custom directories for any paths that it handles", () => {
const localPath = temp.mkdirSync('local-path')
const remotePath = 'ssh://foreign-directory:8080/does-exist'
atom.project.setPaths([localPath, remotePath])
let directories = atom.project.getDirectories()
expect(directories[0].getPath()).toBe(localPath)
expect(directories[0] instanceof Directory).toBe(true)
expect(directories[1].getPath()).toBe(remotePath)
expect(directories[1] instanceof DummyDirectory).toBe(true)
// It does not add new remote paths that do not exist
const nonExistentRemotePath = 'ssh://another-directory:8080/does-not-exist'
atom.project.addPath(nonExistentRemotePath)
expect(atom.project.getDirectories().length).toBe(2)
// It adds new remote paths if their directories exist.
const newRemotePath = 'ssh://another-directory:8080/does-exist'
atom.project.addPath(newRemotePath)
directories = atom.project.getDirectories()
expect(directories[2].getPath()).toBe(newRemotePath)
expect(directories[2] instanceof DummyDirectory).toBe(true)
})
it('stops using the provider when the service is removed', () => {
serviceDisposable.dispose()
atom.project.setPaths(['ssh://foreign-directory:8080/does-exist'])
expect(atom.project.getDirectories().length).toBe(0)
})
it('uses the custom onDidChangeFiles as the watcher if available', () => {
// Ensure that all preexisting watchers are stopped
waitsForPromise(() => stopAllWatchers())
const remotePath = 'ssh://another-directory:8080/does-exist'
runs(() => atom.project.setPaths([remotePath]))
waitsForPromise(() => atom.project.getWatcherPromise(remotePath))
runs(() => {
expect(onDidChangeFilesCallback).not.toBeNull()
const changeSpy = jasmine.createSpy('atom.project.onDidChangeFiles')
const disposable = atom.project.onDidChangeFiles(changeSpy)
const events = [{action: 'created', path: remotePath + '/test.txt'}]
onDidChangeFilesCallback(events)
expect(changeSpy).toHaveBeenCalledWith(events)
disposable.dispose()
})
})
})
describe('.open(path)', () => {
let absolutePath, newBufferHandler
beforeEach(() => {
absolutePath = require.resolve('./fixtures/dir/a')
newBufferHandler = jasmine.createSpy('newBufferHandler')
atom.project.onDidAddBuffer(newBufferHandler)
})
describe("when given an absolute path that isn't currently open", () =>
it("returns a new edit session for the given path and emits 'buffer-created'", () => {
let editor = null
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
runs(() => {
expect(editor.buffer.getPath()).toBe(absolutePath)
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
})
})
)
describe("when given a relative path that isn't currently opened", () =>
it("returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", () => {
let editor = null
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
runs(() => {
expect(editor.buffer.getPath()).toBe(absolutePath)
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
})
})
)
describe('when passed the path to a buffer that is currently opened', () =>
it('returns a new edit session containing currently opened buffer', () => {
let editor = null
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
runs(() => newBufferHandler.reset())
waitsForPromise(() =>
atom.workspace.open(absolutePath).then(({buffer}) => expect(buffer).toBe(editor.buffer))
)
waitsForPromise(() =>
atom.workspace.open('a').then(({buffer}) => {
expect(buffer).toBe(editor.buffer)
expect(newBufferHandler).not.toHaveBeenCalled()
})
)
})
)
describe('when not passed a path', () =>
it("returns a new edit session and emits 'buffer-created'", () => {
let editor = null
waitsForPromise(() => atom.workspace.open().then(o => { editor = o }))
runs(() => {
expect(editor.buffer.getPath()).toBeUndefined()
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
})
})
)
})
describe('.bufferForPath(path)', () => {
let buffer = null
beforeEach(() =>
waitsForPromise(() =>
atom.project.bufferForPath('a').then((o) => {
buffer = o
buffer.retain()
})
)
)
afterEach(() => buffer.release())
describe('when opening a previously opened path', () => {
it('does not create a new buffer', () => {
waitsForPromise(() =>
atom.project.bufferForPath('a').then(anotherBuffer => expect(anotherBuffer).toBe(buffer))
)
waitsForPromise(() =>
atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer))
)
waitsForPromise(() =>
Promise.all([
atom.project.bufferForPath('c'),
atom.project.bufferForPath('c')
]).then(([buffer1, buffer2]) => {
expect(buffer1).toBe(buffer2)
})
)
})
it('retries loading the buffer if it previously failed', () => {
waitsForPromise({shouldReject: true}, () => {
spyOn(TextBuffer, 'load').andCallFake(() => Promise.reject(new Error('Could not open file')))
return atom.project.bufferForPath('b')
})
waitsForPromise({shouldReject: false}, () => {
TextBuffer.load.andCallThrough()
return atom.project.bufferForPath('b')
})
})
it('creates a new buffer if the previous buffer was destroyed', () => {
buffer.release()
waitsForPromise(() =>
atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer))
)
})
})
})
describe('.repositoryForDirectory(directory)', () => {
it('resolves to null when the directory does not have a repository', () =>
waitsForPromise(() => {
const directory = new Directory('/tmp')
return atom.project.repositoryForDirectory(directory).then((result) => {
expect(result).toBeNull()
expect(atom.project.repositoryProviders.length).toBeGreaterThan(0)
expect(atom.project.repositoryPromisesByPath.size).toBe(0)
})
})
)
it('resolves to a GitRepository and is cached when the given directory is a Git repo', () =>
waitsForPromise(() => {
const directory = new Directory(path.join(__dirname, '..'))
const promise = atom.project.repositoryForDirectory(directory)
return promise.then((result) => {
expect(result).toBeInstanceOf(GitRepository)
const dirPath = directory.getRealPathSync()
expect(result.getPath()).toBe(path.join(dirPath, '.git'))
// Verify that the result is cached.
expect(atom.project.repositoryForDirectory(directory)).toBe(promise)
})
})
)
it('creates a new repository if a previous one with the same directory had been destroyed', () => {
let repository = null
const directory = new Directory(path.join(__dirname, '..'))
waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo }))
runs(() => {
expect(repository.isDestroyed()).toBe(false)
repository.destroy()
expect(repository.isDestroyed()).toBe(true)
})
waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo }))
runs(() => expect(repository.isDestroyed()).toBe(false))
})
})
describe('.setPaths(paths, options)', () => {
describe('when path is a file', () =>
it("sets its path to the file's parent directory and updates the root directory", () => {
const filePath = require.resolve('./fixtures/dir/a')
atom.project.setPaths([filePath])
expect(atom.project.getPaths()[0]).toEqual(path.dirname(filePath))
expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(filePath))
})
)
describe('when path is a directory', () => {
it('assigns the directories and repositories', () => {
const directory1 = temp.mkdirSync('non-git-repo')
const directory2 = temp.mkdirSync('git-repo1')
const directory3 = temp.mkdirSync('git-repo2')
const gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
fs.copySync(gitDirPath, path.join(directory2, '.git'))
fs.copySync(gitDirPath, path.join(directory3, '.git'))
atom.project.setPaths([directory1, directory2, directory3])
const [repo1, repo2, repo3] = atom.project.getRepositories()
expect(repo1).toBeNull()
expect(repo2.getShortHead()).toBe('master')
expect(repo2.getPath()).toBe(fs.realpathSync(path.join(directory2, '.git')))
expect(repo3.getShortHead()).toBe('master')
expect(repo3.getPath()).toBe(fs.realpathSync(path.join(directory3, '.git')))
})
it('calls callbacks registered with ::onDidChangePaths', () => {
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
const paths = [ temp.mkdirSync('dir1'), temp.mkdirSync('dir2') ]
atom.project.setPaths(paths)
expect(onDidChangePathsSpy.callCount).toBe(1)
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths)
})
it('optionally throws an error with any paths that did not exist', () => {
const paths = [temp.mkdirSync('exists0'), '/doesnt-exists/0', temp.mkdirSync('exists1'), '/doesnt-exists/1']
try {
atom.project.setPaths(paths, {mustExist: true})
expect('no exception thrown').toBeUndefined()
} catch (e) {
expect(e.missingProjectPaths).toEqual([paths[1], paths[3]])
}
expect(atom.project.getPaths()).toEqual([paths[0], paths[2]])
})
})
describe('when no paths are given', () =>
it('clears its path', () => {
atom.project.setPaths([])
expect(atom.project.getPaths()).toEqual([])
expect(atom.project.getDirectories()).toEqual([])
})
)
it('normalizes the path to remove consecutive slashes, ., and .. segments', () => {
atom.project.setPaths([`${require.resolve('./fixtures/dir/a')}${path.sep}b${path.sep}${path.sep}..`])
expect(atom.project.getPaths()[0]).toEqual(path.dirname(require.resolve('./fixtures/dir/a')))
expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(require.resolve('./fixtures/dir/a')))
})
})
describe('.addPath(path, options)', () => {
it('calls callbacks registered with ::onDidChangePaths', () => {
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
const [oldPath] = atom.project.getPaths()
const newPath = temp.mkdirSync('dir')
atom.project.addPath(newPath)
expect(onDidChangePathsSpy.callCount).toBe(1)
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath])
})
it("doesn't add redundant paths", () => {
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
atom.project.onDidChangePaths(onDidChangePathsSpy)
const [oldPath] = atom.project.getPaths()
// Doesn't re-add an existing root directory
atom.project.addPath(oldPath)
expect(atom.project.getPaths()).toEqual([oldPath])
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
// Doesn't add an entry for a file-path within an existing root directory
atom.project.addPath(path.join(oldPath, 'some-file.txt'))
expect(atom.project.getPaths()).toEqual([oldPath])
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
// Does add an entry for a directory within an existing directory
const newPath = path.join(oldPath, 'a-dir')
atom.project.addPath(newPath)
expect(atom.project.getPaths()).toEqual([oldPath, newPath])
expect(onDidChangePathsSpy).toHaveBeenCalled()
})
it("doesn't add non-existent directories", () => {
const previousPaths = atom.project.getPaths()
atom.project.addPath('/this-definitely/does-not-exist')
expect(atom.project.getPaths()).toEqual(previousPaths)
})
it('optionally throws on non-existent directories', () =>
expect(() => atom.project.addPath('/this-definitely/does-not-exist', {mustExist: true})).toThrow()
)
})
describe('.removePath(path)', () => {
let onDidChangePathsSpy = null
beforeEach(() => {
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener')
atom.project.onDidChangePaths(onDidChangePathsSpy)
})
it('removes the directory and repository for the path', () => {
const result = atom.project.removePath(atom.project.getPaths()[0])
expect(atom.project.getDirectories()).toEqual([])
expect(atom.project.getRepositories()).toEqual([])
expect(atom.project.getPaths()).toEqual([])
expect(result).toBe(true)
expect(onDidChangePathsSpy).toHaveBeenCalled()
})
it("does nothing if the path is not one of the project's root paths", () => {
const originalPaths = atom.project.getPaths()
const result = atom.project.removePath(originalPaths[0] + 'xyz')
expect(result).toBe(false)
expect(atom.project.getPaths()).toEqual(originalPaths)
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
})
it("doesn't destroy the repository if it is shared by another root directory", () => {
atom.project.setPaths([__dirname, path.join(__dirname, '..', 'src')])
atom.project.removePath(__dirname)
expect(atom.project.getPaths()).toEqual([path.join(__dirname, '..', 'src')])
expect(atom.project.getRepositories()[0].isSubmodule('src')).toBe(false)
})
it('removes a path that is represented as a URI', () => {
atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', {
directoryForURISync (uri) {
return {
getPath () { return uri },
getSubdirectory () { return {} },
isRoot () { return true },
existsSync () { return true },
off () {}
}
}
})
const ftpURI = 'ftp://example.com/some/folder'
atom.project.setPaths([ftpURI])
expect(atom.project.getPaths()).toEqual([ftpURI])
atom.project.removePath(ftpURI)
expect(atom.project.getPaths()).toEqual([])
})
})
describe('.onDidChangeFiles()', () => {
let sub = []
const events = []
let checkCallback = () => {}
beforeEach(() => {
sub = atom.project.onDidChangeFiles((incoming) => {
events.push(...incoming)
checkCallback()
})
})
afterEach(() => sub.dispose())
const waitForEvents = (paths) => {
const remaining = new Set(paths.map((p) => fs.realpathSync(p)))
return new Promise((resolve, reject) => {
checkCallback = () => {
for (let event of events) { remaining.delete(event.path) }
if (remaining.size === 0) { resolve() }
}
const expire = () => {
checkCallback = () => {}
console.error('Paths not seen:', remaining)
reject(new Error('Expired before all expected events were delivered.'))
}
checkCallback()
setTimeout(expire, 2000)
})
}
it('reports filesystem changes within project paths', () => {
const dirOne = temp.mkdirSync('atom-spec-project-one')
const fileOne = path.join(dirOne, 'file-one.txt')
const fileTwo = path.join(dirOne, 'file-two.txt')
const dirTwo = temp.mkdirSync('atom-spec-project-two')
const fileThree = path.join(dirTwo, 'file-three.txt')
// Ensure that all preexisting watchers are stopped
waitsForPromise(() => stopAllWatchers())
runs(() => atom.project.setPaths([dirOne]))
waitsForPromise(() => atom.project.getWatcherPromise(dirOne))
runs(() => {
expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual(undefined)
fs.writeFileSync(fileThree, 'three\n')
fs.writeFileSync(fileTwo, 'two\n')
fs.writeFileSync(fileOne, 'one\n')
})
waitsForPromise(() => waitForEvents([fileOne, fileTwo]))
runs(() => expect(events.some(event => event.path === fileThree)).toBeFalsy())
})
})
describe('.onDidAddBuffer()', () =>
it('invokes the callback with added text buffers', () => {
const buffers = []
const added = []
waitsForPromise(() =>
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
.then(o => buffers.push(o))
)
runs(() => {
expect(buffers.length).toBe(1)
atom.project.onDidAddBuffer(buffer => added.push(buffer))
})
waitsForPromise(() =>
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then(o => buffers.push(o))
)
runs(() => {
expect(buffers.length).toBe(2)
expect(added).toEqual([buffers[1]])
})
})
)
describe('.observeBuffers()', () =>
it('invokes the observer with current and future text buffers', () => {
const buffers = []
const observed = []
waitsForPromise(() =>
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
.then(o => buffers.push(o))
)
waitsForPromise(() =>
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then(o => buffers.push(o))
)
runs(() => {
expect(buffers.length).toBe(2)
atom.project.observeBuffers(buffer => observed.push(buffer))
expect(observed).toEqual(buffers)
})
waitsForPromise(() =>
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
.then(o => buffers.push(o))
)
runs(() => {
expect(observed.length).toBe(3)
expect(buffers.length).toBe(3)
expect(observed).toEqual(buffers)
})
})
)
describe('.relativize(path)', () => {
it('returns the path, relative to whichever root directory it is inside of', () => {
atom.project.addPath(temp.mkdirSync('another-path'))
let rootPath = atom.project.getPaths()[0]
let childPath = path.join(rootPath, 'some', 'child', 'directory')
expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory'))
rootPath = atom.project.getPaths()[1]
childPath = path.join(rootPath, 'some', 'child', 'directory')
expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory'))
})
it('returns the given path if it is not in any of the root directories', () => {
const randomPath = path.join('some', 'random', 'path')
expect(atom.project.relativize(randomPath)).toBe(randomPath)
})
})
describe('.relativizePath(path)', () => {
it('returns the root path that contains the given path, and the path relativized to that root path', () => {
atom.project.addPath(temp.mkdirSync('another-path'))
let rootPath = atom.project.getPaths()[0]
let childPath = path.join(rootPath, 'some', 'child', 'directory')
expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')])
rootPath = atom.project.getPaths()[1]
childPath = path.join(rootPath, 'some', 'child', 'directory')
expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')])
})
describe("when the given path isn't inside of any of the project's path", () =>
it('returns null for the root path, and the given path unchanged', () => {
const randomPath = path.join('some', 'random', 'path')
expect(atom.project.relativizePath(randomPath)).toEqual([null, randomPath])
})
)
describe('when the given path is a URL', () =>
it('returns null for the root path, and the given path unchanged', () => {
const url = 'http://the-path'
expect(atom.project.relativizePath(url)).toEqual([null, url])
})
)
describe('when the given path is inside more than one root folder', () =>
it('uses the root folder that is closest to the given path', () => {
atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir'))
const inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt')
expect(atom.project.getDirectories()[0].contains(inputPath)).toBe(true)
expect(atom.project.getDirectories()[1].contains(inputPath)).toBe(true)
expect(atom.project.relativizePath(inputPath)).toEqual([
atom.project.getPaths()[1],
path.join('somewhere', 'something.txt')
])
})
)
})
describe('.contains(path)', () =>
it('returns whether or not the given path is in one of the root directories', () => {
const rootPath = atom.project.getPaths()[0]
const childPath = path.join(rootPath, 'some', 'child', 'directory')
expect(atom.project.contains(childPath)).toBe(true)
const randomPath = path.join('some', 'random', 'path')
expect(atom.project.contains(randomPath)).toBe(false)
})
)
describe('.resolvePath(uri)', () =>
it('normalizes disk drive letter in passed path on #win32', () => {
expect(atom.project.resolvePath('d:\\file.txt')).toEqual('D:\\file.txt')
})
)
})

View File

@@ -1,123 +0,0 @@
TextEditor = require '../src/text-editor'
describe "Selection", ->
[buffer, editor, selection] = []
beforeEach ->
buffer = atom.project.bufferForPathSync('sample.js')
editor = new TextEditor({buffer: buffer, tabLength: 2})
selection = editor.getLastSelection()
afterEach ->
buffer.destroy()
describe ".deleteSelectedText()", ->
describe "when nothing is selected", ->
it "deletes nothing", ->
selection.setBufferRange [[0, 3], [0, 3]]
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe "var quicksort = function () {"
describe "when one line is selected", ->
it "deletes selected text and clears the selection", ->
selection.setBufferRange [[0, 4], [0, 14]]
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe "var = function () {"
endOfLine = buffer.lineForRow(0).length
selection.setBufferRange [[0, 0], [0, endOfLine]]
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe ""
expect(selection.isEmpty()).toBeTruthy()
describe "when multiple lines are selected", ->
it "deletes selected text and clears the selection", ->
selection.setBufferRange [[0, 1], [2, 39]]
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe "v;"
expect(selection.isEmpty()).toBeTruthy()
describe "when the cursor precedes the tail", ->
it "deletes selected text and clears the selection", ->
selection.cursor.setScreenPosition [0, 13]
selection.selectToScreenPosition [0, 4]
selection.delete()
expect(buffer.lineForRow(0)).toBe "var = function () {"
expect(selection.isEmpty()).toBeTruthy()
describe ".isReversed()", ->
it "returns true if the cursor precedes the tail", ->
selection.cursor.setScreenPosition([0, 20])
selection.selectToScreenPosition([0, 10])
expect(selection.isReversed()).toBeTruthy()
selection.selectToScreenPosition([0, 25])
expect(selection.isReversed()).toBeFalsy()
describe ".selectLine(row)", ->
describe "when passed a row", ->
it "selects the specified row", ->
selection.setBufferRange([[2, 4], [3, 4]])
selection.selectLine(5)
expect(selection.getBufferRange()).toEqual [[5, 0], [6, 0]]
describe "when not passed a row", ->
it "selects all rows spanned by the selection", ->
selection.setBufferRange([[2, 4], [3, 4]])
selection.selectLine()
expect(selection.getBufferRange()).toEqual [[2, 0], [4, 0]]
describe "when only the selection's tail is moved (regression)", ->
it "notifies ::onDidChangeRange observers", ->
selection.setBufferRange([[2, 0], [2, 10]], reversed: true)
changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler')
selection.onDidChangeRange changeScreenRangeHandler
buffer.insert([2, 5], 'abc')
expect(changeScreenRangeHandler).toHaveBeenCalled()
describe "when the selection is destroyed", ->
it "destroys its marker", ->
selection.setBufferRange([[2, 0], [2, 10]])
marker = selection.marker
selection.destroy()
expect(marker.isDestroyed()).toBeTruthy()
describe ".insertText(text, options)", ->
it "allows pasting white space only lines when autoIndent is enabled", ->
selection.setBufferRange [[0, 0], [0, 0]]
selection.insertText(" \n \n\n", autoIndent: true)
expect(buffer.lineForRow(0)).toBe " "
expect(buffer.lineForRow(1)).toBe " "
expect(buffer.lineForRow(2)).toBe ""
it "auto-indents if only a newline is inserted", ->
selection.setBufferRange [[2, 0], [3, 0]]
selection.insertText("\n", autoIndent: true)
expect(buffer.lineForRow(2)).toBe " "
it "auto-indents if only a carriage return + newline is inserted", ->
selection.setBufferRange [[2, 0], [3, 0]]
selection.insertText("\r\n", autoIndent: true)
expect(buffer.lineForRow(2)).toBe " "
describe ".fold()", ->
it "folds the buffer range spanned by the selection", ->
selection.setBufferRange([[0, 3], [1, 6]])
selection.fold()
expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]])
expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]])
expect(editor.lineTextForScreenRow(0)).toBe "var#{editor.displayLayer.foldCharacter}sort = function(items) {"
expect(editor.isFoldedAtBufferRow(0)).toBe(true)
it "doesn't create a fold when the selection is empty", ->
selection.setBufferRange([[0, 3], [0, 3]])
selection.fold()
expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]])
expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]])
expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {"
expect(editor.isFoldedAtBufferRow(0)).toBe(false)

157
spec/selection-spec.js Normal file
View File

@@ -0,0 +1,157 @@
const TextEditor = require('../src/text-editor')
describe('Selection', () => {
let buffer, editor, selection
beforeEach(() => {
buffer = atom.project.bufferForPathSync('sample.js')
editor = new TextEditor({buffer, tabLength: 2})
selection = editor.getLastSelection()
})
afterEach(() => buffer.destroy())
describe('.deleteSelectedText()', () => {
describe('when nothing is selected', () => {
it('deletes nothing', () => {
selection.setBufferRange([[0, 3], [0, 3]])
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe('var quicksort = function () {')
})
})
describe('when one line is selected', () => {
it('deletes selected text and clears the selection', () => {
selection.setBufferRange([[0, 4], [0, 14]])
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe('var = function () {')
const endOfLine = buffer.lineForRow(0).length
selection.setBufferRange([[0, 0], [0, endOfLine]])
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe('')
expect(selection.isEmpty()).toBeTruthy()
})
})
describe('when multiple lines are selected', () => {
it('deletes selected text and clears the selection', () => {
selection.setBufferRange([[0, 1], [2, 39]])
selection.deleteSelectedText()
expect(buffer.lineForRow(0)).toBe('v;')
expect(selection.isEmpty()).toBeTruthy()
})
})
describe('when the cursor precedes the tail', () => {
it('deletes selected text and clears the selection', () => {
selection.cursor.setScreenPosition([0, 13])
selection.selectToScreenPosition([0, 4])
selection.delete()
expect(buffer.lineForRow(0)).toBe('var = function () {')
expect(selection.isEmpty()).toBeTruthy()
})
})
})
describe('.isReversed()', () => {
it('returns true if the cursor precedes the tail', () => {
selection.cursor.setScreenPosition([0, 20])
selection.selectToScreenPosition([0, 10])
expect(selection.isReversed()).toBeTruthy()
selection.selectToScreenPosition([0, 25])
expect(selection.isReversed()).toBeFalsy()
})
})
describe('.selectLine(row)', () => {
describe('when passed a row', () => {
it('selects the specified row', () => {
selection.setBufferRange([[2, 4], [3, 4]])
selection.selectLine(5)
expect(selection.getBufferRange()).toEqual([[5, 0], [6, 0]])
})
})
describe('when not passed a row', () => {
it('selects all rows spanned by the selection', () => {
selection.setBufferRange([[2, 4], [3, 4]])
selection.selectLine()
expect(selection.getBufferRange()).toEqual([[2, 0], [4, 0]])
})
})
})
describe("when only the selection's tail is moved (regression)", () => {
it('notifies ::onDidChangeRange observers', () => {
selection.setBufferRange([[2, 0], [2, 10]], {reversed: true})
const changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler')
selection.onDidChangeRange(changeScreenRangeHandler)
buffer.insert([2, 5], 'abc')
expect(changeScreenRangeHandler).toHaveBeenCalled()
})
})
describe('when the selection is destroyed', () => {
it('destroys its marker', () => {
selection.setBufferRange([[2, 0], [2, 10]])
const { marker } = selection
selection.destroy()
expect(marker.isDestroyed()).toBeTruthy()
})
})
describe('.insertText(text, options)', () => {
it('allows pasting white space only lines when autoIndent is enabled', () => {
selection.setBufferRange([[0, 0], [0, 0]])
selection.insertText(' \n \n\n', {autoIndent: true})
expect(buffer.lineForRow(0)).toBe(' ')
expect(buffer.lineForRow(1)).toBe(' ')
expect(buffer.lineForRow(2)).toBe('')
})
it('auto-indents if only a newline is inserted', () => {
selection.setBufferRange([[2, 0], [3, 0]])
selection.insertText('\n', {autoIndent: true})
expect(buffer.lineForRow(2)).toBe(' ')
})
it('auto-indents if only a carriage return + newline is inserted', () => {
selection.setBufferRange([[2, 0], [3, 0]])
selection.insertText('\r\n', {autoIndent: true})
expect(buffer.lineForRow(2)).toBe(' ')
})
it('does not adjust the indent of trailing lines if preserveTrailingLineIndentation is true', () => {
selection.setBufferRange([[5, 0], [5, 0]])
selection.insertText(' foo\n bar\n', {preserveTrailingLineIndentation: true, indentBasis: 1})
expect(buffer.lineForRow(6)).toBe(' bar')
})
})
describe('.fold()', () => {
it('folds the buffer range spanned by the selection', () => {
selection.setBufferRange([[0, 3], [1, 6]])
selection.fold()
expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]])
expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]])
expect(editor.lineTextForScreenRow(0)).toBe(`var${editor.displayLayer.foldCharacter}sort = function(items) {`)
expect(editor.isFoldedAtBufferRow(0)).toBe(true)
})
it("doesn't create a fold when the selection is empty", () => {
selection.setBufferRange([[0, 3], [0, 3]])
selection.fold()
expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]])
expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]])
expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {')
expect(editor.isFoldedAtBufferRow(0)).toBe(false)
})
})
})

View File

@@ -58,7 +58,7 @@ if specPackagePath = FindParentDir.sync(testPaths[0], 'package.json')
if specDirectory = FindParentDir.sync(testPaths[0], 'fixtures')
specProjectPath = path.join(specDirectory, 'fixtures')
else
specProjectPath = path.join(__dirname, 'fixtures')
specProjectPath = require('os').tmpdir()
beforeEach ->
atom.project.setPaths([specProjectPath])

View File

@@ -1930,6 +1930,8 @@ describe('TextEditorComponent', () => {
const decoration = editor.decorateMarker(marker, {type: 'overlay', item: overlayElement, class: 'a'})
await component.getNextUpdatePromise()
const overlayComponent = component.overlayComponents.values().next().value
const overlayWrapper = overlayElement.parentElement
expect(overlayWrapper.classList.contains('a')).toBe(true)
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
@@ -1960,12 +1962,12 @@ describe('TextEditorComponent', () => {
await setScrollTop(component, 20)
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
overlayElement.style.height = 60 + 'px'
await component.getNextUpdatePromise()
await overlayComponent.getNextUpdatePromise()
expect(overlayWrapper.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 4))
// Does not flip the overlay vertically if it would overflow the top of the window
overlayElement.style.height = 80 + 'px'
await component.getNextUpdatePromise()
await overlayComponent.getNextUpdatePromise()
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
// Can update overlay wrapper class
@@ -2575,6 +2577,24 @@ describe('TextEditorComponent', () => {
])
})
it('does not throw exceptions when destroying a block decoration inside a marker change event (regression)', async () => {
const {editor, component} = buildComponent({rowsPerTile: 3})
const marker = editor.markScreenPosition([2, 0])
marker.onDidChange(() => { marker.destroy() })
const item = document.createElement('div')
editor.decorateMarker(marker, {type: 'block', item})
await component.getNextUpdatePromise()
expect(item.nextSibling).toBe(lineNodeForScreenRow(component, 2))
marker.setBufferRange([[0, 0], [0, 0]])
expect(marker.isDestroyed()).toBe(true)
await component.getNextUpdatePromise()
expect(item.parentElement).toBeNull()
})
it('does not attempt to render block decorations located outside the visible range', async () => {
const {editor, component} = buildComponent({autoHeight: false, rowsPerTile: 2})
await setEditorHeightInLines(component, 2)
@@ -4438,24 +4458,44 @@ describe('TextEditorComponent', () => {
expect(dragEvents).toEqual([])
})
it('calls `didStopDragging` if the buffer changes while dragging', async () => {
it('calls `didStopDragging` if the user interacts with the keyboard while dragging', async () => {
const {component, editor} = buildComponent()
let dragging = false
component.handleMouseDragUntilMouseUp({
didDrag: (event) => { dragging = true },
didStopDragging: () => { dragging = false }
})
function startDragging () {
component.handleMouseDragUntilMouseUp({
didDrag: (event) => { dragging = true },
didStopDragging: () => { dragging = false }
})
}
startDragging()
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
editor.delete()
// Buffer changes don't cause dragging to be stopped.
editor.insertText('X')
expect(dragging).toBe(true)
// Keyboard interaction prevents users from dragging further.
component.didKeydown({code: 'KeyX'})
expect(dragging).toBe(false)
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(false)
// Pressing a modifier key does not terminate dragging, (to ensure we can add new selections with the mouse)
startDragging()
window.dispatchEvent(new MouseEvent('mousemove'))
await getNextAnimationFramePromise()
expect(dragging).toBe(true)
component.didKeydown({key: 'Control'})
component.didKeydown({key: 'Alt'})
component.didKeydown({key: 'Shift'})
component.didKeydown({key: 'Meta'})
expect(dragging).toBe(true)
})
function getNextAnimationFramePromise () {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +0,0 @@
textUtils = require '../src/text-utils'
describe 'text utilities', ->
describe '.hasPairedCharacter(string)', ->
it 'returns true when the string contains a surrogate pair, variation sequence, or combined character', ->
expect(textUtils.hasPairedCharacter('abc')).toBe false
expect(textUtils.hasPairedCharacter('a\uD835\uDF97b\uD835\uDF97c')).toBe true
expect(textUtils.hasPairedCharacter('\uD835\uDF97')).toBe true
expect(textUtils.hasPairedCharacter('\u2714\uFE0E')).toBe true
expect(textUtils.hasPairedCharacter('e\u0301')).toBe true
expect(textUtils.hasPairedCharacter('\uD835')).toBe false
expect(textUtils.hasPairedCharacter('\uDF97')).toBe false
expect(textUtils.hasPairedCharacter('\uFE0E')).toBe false
expect(textUtils.hasPairedCharacter('\u0301')).toBe false
expect(textUtils.hasPairedCharacter('\uFE0E\uFE0E')).toBe false
expect(textUtils.hasPairedCharacter('\u0301\u0301')).toBe false
describe '.isPairedCharacter(string, index)', ->
it 'returns true when the index is the start of a high/low surrogate pair, variation sequence, or combined character', ->
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 0)).toBe false
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 1)).toBe true
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 2)).toBe false
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 3)).toBe false
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 4)).toBe true
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 5)).toBe false
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 6)).toBe false
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 0)).toBe false
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 1)).toBe true
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 2)).toBe false
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 3)).toBe false
expect(textUtils.isPairedCharacter('\uD835')).toBe false
expect(textUtils.isPairedCharacter('\uDF97')).toBe false
expect(textUtils.isPairedCharacter('\uFE0E')).toBe false
expect(textUtils.isPairedCharacter('\uFE0E')).toBe false
expect(textUtils.isPairedCharacter('\uFE0E\uFE0E')).toBe false
expect(textUtils.isPairedCharacter('ae\u0301c', 0)).toBe false
expect(textUtils.isPairedCharacter('ae\u0301c', 1)).toBe true
expect(textUtils.isPairedCharacter('ae\u0301c', 2)).toBe false
expect(textUtils.isPairedCharacter('ae\u0301c', 3)).toBe false
expect(textUtils.isPairedCharacter('ae\u0301c', 4)).toBe false
describe ".isDoubleWidthCharacter(character)", ->
it "returns true when the character is either japanese, chinese or a full width form", ->
expect(textUtils.isDoubleWidthCharacter("")).toBe(true)
expect(textUtils.isDoubleWidthCharacter("")).toBe(true)
expect(textUtils.isDoubleWidthCharacter("")).toBe(true)
expect(textUtils.isDoubleWidthCharacter("")).toBe(true)
expect(textUtils.isDoubleWidthCharacter("")).toBe(true)
expect(textUtils.isDoubleWidthCharacter("a")).toBe(false)
describe ".isHalfWidthCharacter(character)", ->
it "returns true when the character is an half width form", ->
expect(textUtils.isHalfWidthCharacter("")).toBe(true)
expect(textUtils.isHalfWidthCharacter("")).toBe(true)
expect(textUtils.isHalfWidthCharacter("")).toBe(true)
expect(textUtils.isHalfWidthCharacter("")).toBe(true)
expect(textUtils.isHalfWidthCharacter("B")).toBe(false)
describe ".isKoreanCharacter(character)", ->
it "returns true when the character is a korean character", ->
expect(textUtils.isKoreanCharacter("")).toBe(true)
expect(textUtils.isKoreanCharacter("")).toBe(true)
expect(textUtils.isKoreanCharacter("")).toBe(true)
expect(textUtils.isKoreanCharacter("")).toBe(true)
expect(textUtils.isKoreanCharacter("O")).toBe(false)
describe ".isWrapBoundary(previousCharacter, character)", ->
it "returns true when the character is CJK or when the previous character is a space/tab", ->
anyCharacter = 'x'
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, "")).toBe(true)
expect(textUtils.isWrapBoundary(' ', 'h')).toBe(true)
expect(textUtils.isWrapBoundary('\t', 'h')).toBe(true)
expect(textUtils.isWrapBoundary('a', 'h')).toBe(false)

110
spec/text-utils-spec.js Normal file
View File

@@ -0,0 +1,110 @@
const textUtils = require('../src/text-utils')
describe('text utilities', () => {
describe('.hasPairedCharacter(string)', () =>
it('returns true when the string contains a surrogate pair, variation sequence, or combined character', () => {
expect(textUtils.hasPairedCharacter('abc')).toBe(false)
expect(textUtils.hasPairedCharacter('a\uD835\uDF97b\uD835\uDF97c')).toBe(true)
expect(textUtils.hasPairedCharacter('\uD835\uDF97')).toBe(true)
expect(textUtils.hasPairedCharacter('\u2714\uFE0E')).toBe(true)
expect(textUtils.hasPairedCharacter('e\u0301')).toBe(true)
expect(textUtils.hasPairedCharacter('\uD835')).toBe(false)
expect(textUtils.hasPairedCharacter('\uDF97')).toBe(false)
expect(textUtils.hasPairedCharacter('\uFE0E')).toBe(false)
expect(textUtils.hasPairedCharacter('\u0301')).toBe(false)
expect(textUtils.hasPairedCharacter('\uFE0E\uFE0E')).toBe(false)
expect(textUtils.hasPairedCharacter('\u0301\u0301')).toBe(false)
})
)
describe('.isPairedCharacter(string, index)', () =>
it('returns true when the index is the start of a high/low surrogate pair, variation sequence, or combined character', () => {
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 0)).toBe(false)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 1)).toBe(true)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 2)).toBe(false)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 3)).toBe(false)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 4)).toBe(true)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 5)).toBe(false)
expect(textUtils.isPairedCharacter('a\uD835\uDF97b\uD835\uDF97c', 6)).toBe(false)
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 0)).toBe(false)
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 1)).toBe(true)
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 2)).toBe(false)
expect(textUtils.isPairedCharacter('a\u2714\uFE0E', 3)).toBe(false)
expect(textUtils.isPairedCharacter('\uD835')).toBe(false)
expect(textUtils.isPairedCharacter('\uDF97')).toBe(false)
expect(textUtils.isPairedCharacter('\uFE0E')).toBe(false)
expect(textUtils.isPairedCharacter('\uFE0E')).toBe(false)
expect(textUtils.isPairedCharacter('\uFE0E\uFE0E')).toBe(false)
expect(textUtils.isPairedCharacter('ae\u0301c', 0)).toBe(false)
expect(textUtils.isPairedCharacter('ae\u0301c', 1)).toBe(true)
expect(textUtils.isPairedCharacter('ae\u0301c', 2)).toBe(false)
expect(textUtils.isPairedCharacter('ae\u0301c', 3)).toBe(false)
expect(textUtils.isPairedCharacter('ae\u0301c', 4)).toBe(false)
})
)
describe('.isDoubleWidthCharacter(character)', () =>
it('returns true when the character is either japanese, chinese or a full width form', () => {
expect(textUtils.isDoubleWidthCharacter('我')).toBe(true)
expect(textUtils.isDoubleWidthCharacter('私')).toBe(true)
expect(textUtils.isDoubleWidthCharacter('')).toBe(true)
expect(textUtils.isDoubleWidthCharacter('')).toBe(true)
expect(textUtils.isDoubleWidthCharacter('¢')).toBe(true)
expect(textUtils.isDoubleWidthCharacter('a')).toBe(false)
})
)
describe('.isHalfWidthCharacter(character)', () =>
it('returns true when the character is an half width form', () => {
expect(textUtils.isHalfWidthCharacter('ハ')).toBe(true)
expect(textUtils.isHalfWidthCharacter('ヒ')).toBe(true)
expect(textUtils.isHalfWidthCharacter('ᆲ')).toBe(true)
expect(textUtils.isHalfWidthCharacter('■')).toBe(true)
expect(textUtils.isHalfWidthCharacter('B')).toBe(false)
})
)
describe('.isKoreanCharacter(character)', () =>
it('returns true when the character is a korean character', () => {
expect(textUtils.isKoreanCharacter('우')).toBe(true)
expect(textUtils.isKoreanCharacter('가')).toBe(true)
expect(textUtils.isKoreanCharacter('ㅢ')).toBe(true)
expect(textUtils.isKoreanCharacter('ㄼ')).toBe(true)
expect(textUtils.isKoreanCharacter('O')).toBe(false)
})
)
describe('.isWrapBoundary(previousCharacter, character)', () =>
it('returns true when the character is CJK or when the previous character is a space/tab', () => {
const anyCharacter = 'x'
expect(textUtils.isWrapBoundary(anyCharacter, '我')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '私')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '¢')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, 'ハ')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, 'ヒ')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, 'ᆲ')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '■')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '우')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, '가')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, 'ㅢ')).toBe(true)
expect(textUtils.isWrapBoundary(anyCharacter, 'ㄼ')).toBe(true)
expect(textUtils.isWrapBoundary(' ', 'h')).toBe(true)
expect(textUtils.isWrapBoundary('\t', 'h')).toBe(true)
expect(textUtils.isWrapBoundary('a', 'h')).toBe(false)
})
)
})

View File

@@ -1,437 +0,0 @@
path = require 'path'
fs = require 'fs-plus'
temp = require('temp').track()
describe "atom.themes", ->
beforeEach ->
spyOn(atom, 'inSpecMode').andReturn(false)
spyOn(console, 'warn')
afterEach ->
waitsForPromise ->
atom.themes.deactivateThemes()
runs ->
try
temp.cleanupSync()
describe "theme getters and setters", ->
beforeEach ->
jasmine.snapshotDeprecations()
atom.packages.loadPackages()
afterEach ->
jasmine.restoreDeprecationsSnapshot()
describe 'getLoadedThemes', ->
it 'gets all the loaded themes', ->
themes = atom.themes.getLoadedThemes()
expect(themes.length).toBeGreaterThan(2)
describe "getActiveThemes", ->
it 'gets all the active themes', ->
waitsForPromise -> atom.themes.activateThemes()
runs ->
names = atom.config.get('core.themes')
expect(names.length).toBeGreaterThan(0)
themes = atom.themes.getActiveThemes()
expect(themes).toHaveLength(names.length)
describe "when the core.themes config value contains invalid entry", ->
it "ignores theme", ->
atom.config.set 'core.themes', [
'atom-light-ui'
null
undefined
''
false
4
{}
[]
'atom-dark-ui'
]
expect(atom.themes.getEnabledThemeNames()).toEqual ['atom-dark-ui', 'atom-light-ui']
describe "::getImportPaths()", ->
it "returns the theme directories before the themes are loaded", ->
atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui', 'atom-light-ui'])
paths = atom.themes.getImportPaths()
# syntax theme is not a dir at this time, so only two.
expect(paths.length).toBe 2
expect(paths[0]).toContain 'atom-light-ui'
expect(paths[1]).toContain 'atom-dark-ui'
it "ignores themes that cannot be resolved to a directory", ->
atom.config.set('core.themes', ['definitely-not-a-theme'])
expect(-> atom.themes.getImportPaths()).not.toThrow()
describe "when the core.themes config value changes", ->
it "add/removes stylesheets to reflect the new config value", ->
atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy()
spyOn(atom.styles, 'getUserStyleSheetPath').andCallFake -> null
waitsForPromise ->
atom.themes.activateThemes()
runs ->
didChangeActiveThemesHandler.reset()
atom.config.set('core.themes', [])
waitsFor 'a', ->
didChangeActiveThemesHandler.callCount is 1
runs ->
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style.theme')).toHaveLength 0
atom.config.set('core.themes', ['atom-dark-ui'])
waitsFor 'b', ->
didChangeActiveThemesHandler.callCount is 1
runs ->
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2
expect(document.querySelector('style[priority="1"]').getAttribute('source-path')).toMatch /atom-dark-ui/
atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-ui'])
waitsFor 'c', ->
didChangeActiveThemesHandler.callCount is 1
runs ->
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2
expect(document.querySelectorAll('style[priority="1"]')[0].getAttribute('source-path')).toMatch /atom-dark-ui/
expect(document.querySelectorAll('style[priority="1"]')[1].getAttribute('source-path')).toMatch /atom-light-ui/
atom.config.set('core.themes', [])
waitsFor ->
didChangeActiveThemesHandler.callCount is 1
runs ->
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2
# atom-dark-ui has an directory path, the syntax one doesn't
atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui'])
waitsFor ->
didChangeActiveThemesHandler.callCount is 1
runs ->
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2
importPaths = atom.themes.getImportPaths()
expect(importPaths.length).toBe 1
expect(importPaths[0]).toContain 'atom-dark-ui'
it 'adds theme-* classes to the workspace for each active theme', ->
atom.config.set('core.themes', ['atom-dark-ui', 'atom-dark-syntax'])
workspaceElement = atom.workspace.getElement()
atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy()
waitsForPromise ->
atom.themes.activateThemes()
runs ->
expect(workspaceElement).toHaveClass 'theme-atom-dark-ui'
atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy()
atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables'])
waitsFor ->
didChangeActiveThemesHandler.callCount > 0
runs ->
# `theme-` twice as it prefixes the name with `theme-`
expect(workspaceElement).toHaveClass 'theme-theme-with-ui-variables'
expect(workspaceElement).toHaveClass 'theme-theme-with-syntax-variables'
expect(workspaceElement).not.toHaveClass 'theme-atom-dark-ui'
expect(workspaceElement).not.toHaveClass 'theme-atom-dark-syntax'
describe "when a theme fails to load", ->
it "logs a warning", ->
console.warn.reset()
atom.packages.activatePackage('a-theme-that-will-not-be-found').then((->), (->))
expect(console.warn.callCount).toBe 1
expect(console.warn.argsForCall[0][0]).toContain "Could not resolve 'a-theme-that-will-not-be-found'"
describe "::requireStylesheet(path)", ->
beforeEach ->
jasmine.snapshotDeprecations()
afterEach ->
jasmine.restoreDeprecationsSnapshot()
it "synchronously loads css at the given path and installs a style tag for it in the head", ->
atom.styles.onDidAddStyleElement styleElementAddedHandler = jasmine.createSpy("styleElementAddedHandler")
cssPath = atom.project.getDirectories()[0]?.resolve('css.css')
lengthBefore = document.querySelectorAll('head style').length
atom.themes.requireStylesheet(cssPath)
expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1
expect(styleElementAddedHandler).toHaveBeenCalled()
element = document.querySelector('head style[source-path*="css.css"]')
expect(element.getAttribute('source-path')).toEqualPath cssPath
expect(element.textContent).toBe fs.readFileSync(cssPath, 'utf8')
# doesn't append twice
styleElementAddedHandler.reset()
atom.themes.requireStylesheet(cssPath)
expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1
expect(styleElementAddedHandler).not.toHaveBeenCalled()
for styleElement in document.querySelectorAll('head style[id*="css.css"]')
styleElement.remove()
it "synchronously loads and parses less files at the given path and installs a style tag for it in the head", ->
lessPath = atom.project.getDirectories()[0]?.resolve('sample.less')
lengthBefore = document.querySelectorAll('head style').length
atom.themes.requireStylesheet(lessPath)
expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1
element = document.querySelector('head style[source-path*="sample.less"]')
expect(element.getAttribute('source-path')).toEqualPath lessPath
expect(element.textContent.toLowerCase()).toBe """
#header {
color: #4d926f;
}
h2 {
color: #4d926f;
}
"""
# doesn't append twice
atom.themes.requireStylesheet(lessPath)
expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1
for styleElement in document.querySelectorAll('head style[id*="sample.less"]')
styleElement.remove()
it "supports requiring css and less stylesheets without an explicit extension", ->
atom.themes.requireStylesheet path.join(__dirname, 'fixtures', 'css')
expect(document.querySelector('head style[source-path*="css.css"]').getAttribute('source-path')).toEqualPath atom.project.getDirectories()[0]?.resolve('css.css')
atom.themes.requireStylesheet path.join(__dirname, 'fixtures', 'sample')
expect(document.querySelector('head style[source-path*="sample.less"]').getAttribute('source-path')).toEqualPath atom.project.getDirectories()[0]?.resolve('sample.less')
document.querySelector('head style[source-path*="css.css"]').remove()
document.querySelector('head style[source-path*="sample.less"]').remove()
it "returns a disposable allowing styles applied by the given path to be removed", ->
cssPath = require.resolve('./fixtures/css.css')
expect(getComputedStyle(document.body).fontWeight).not.toBe("bold")
disposable = atom.themes.requireStylesheet(cssPath)
expect(getComputedStyle(document.body).fontWeight).toBe("bold")
atom.styles.onDidRemoveStyleElement styleElementRemovedHandler = jasmine.createSpy("styleElementRemovedHandler")
disposable.dispose()
expect(getComputedStyle(document.body).fontWeight).not.toBe("bold")
expect(styleElementRemovedHandler).toHaveBeenCalled()
describe "base style sheet loading", ->
beforeEach ->
workspaceElement = atom.workspace.getElement()
jasmine.attachToDOM(atom.workspace.getElement())
workspaceElement.appendChild document.createElement('atom-text-editor')
waitsForPromise ->
atom.themes.activateThemes()
it "loads the correct values from the theme's ui-variables file", ->
atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy()
atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables'])
waitsFor ->
didChangeActiveThemesHandler.callCount > 0
runs ->
# an override loaded in the base css
expect(getComputedStyle(atom.workspace.getElement())["background-color"]).toBe "rgb(0, 0, 255)"
# from within the theme itself
expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingTop).toBe "150px"
expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingRight).toBe "150px"
expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingBottom).toBe "150px"
describe "when there is a theme with incomplete variables", ->
it "loads the correct values from the fallback ui-variables", ->
atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy()
atom.config.set('core.themes', ['theme-with-incomplete-ui-variables', 'theme-with-syntax-variables'])
waitsFor ->
didChangeActiveThemesHandler.callCount > 0
runs ->
# an override loaded in the base css
expect(getComputedStyle(atom.workspace.getElement())["background-color"]).toBe "rgb(0, 0, 255)"
# from within the theme itself
expect(getComputedStyle(document.querySelector("atom-text-editor")).backgroundColor).toBe "rgb(0, 152, 255)"
describe "user stylesheet", ->
userStylesheetPath = null
beforeEach ->
userStylesheetPath = path.join(temp.mkdirSync("atom"), 'styles.less')
fs.writeFileSync(userStylesheetPath, 'body {border-style: dotted !important;}')
spyOn(atom.styles, 'getUserStyleSheetPath').andReturn userStylesheetPath
describe "when the user stylesheet changes", ->
beforeEach ->
jasmine.snapshotDeprecations()
afterEach ->
jasmine.restoreDeprecationsSnapshot()
it "reloads it", ->
[styleElementAddedHandler, styleElementRemovedHandler] = []
waitsForPromise ->
atom.themes.activateThemes()
runs ->
atom.styles.onDidRemoveStyleElement styleElementRemovedHandler = jasmine.createSpy("styleElementRemovedHandler")
atom.styles.onDidAddStyleElement styleElementAddedHandler = jasmine.createSpy("styleElementAddedHandler")
spyOn(atom.themes, 'loadUserStylesheet').andCallThrough()
expect(getComputedStyle(document.body).borderStyle).toBe 'dotted'
fs.writeFileSync(userStylesheetPath, 'body {border-style: dashed}')
waitsFor ->
atom.themes.loadUserStylesheet.callCount is 1
runs ->
expect(getComputedStyle(document.body).borderStyle).toBe 'dashed'
expect(styleElementRemovedHandler).toHaveBeenCalled()
expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain 'dotted'
expect(styleElementAddedHandler).toHaveBeenCalled()
expect(styleElementAddedHandler.argsForCall[0][0].textContent).toContain 'dashed'
styleElementRemovedHandler.reset()
fs.removeSync(userStylesheetPath)
waitsFor ->
atom.themes.loadUserStylesheet.callCount is 2
runs ->
expect(styleElementRemovedHandler).toHaveBeenCalled()
expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain 'dashed'
expect(getComputedStyle(document.body).borderStyle).toBe 'none'
describe "when there is an error reading the stylesheet", ->
addErrorHandler = null
beforeEach ->
atom.themes.loadUserStylesheet()
spyOn(atom.themes.lessCache, 'cssForFile').andCallFake ->
throw new Error('EACCES permission denied "styles.less"')
atom.notifications.onDidAddNotification addErrorHandler = jasmine.createSpy()
it "creates an error notification and does not add the stylesheet", ->
atom.themes.loadUserStylesheet()
expect(addErrorHandler).toHaveBeenCalled()
note = addErrorHandler.mostRecentCall.args[0]
expect(note.getType()).toBe 'error'
expect(note.getMessage()).toContain 'Error loading'
expect(atom.styles.styleElementsBySourcePath[atom.styles.getUserStyleSheetPath()]).toBeUndefined()
describe "when there is an error watching the user stylesheet", ->
addErrorHandler = null
beforeEach ->
{File} = require 'pathwatcher'
spyOn(File::, 'on').andCallFake (event) ->
if event.indexOf('contents-changed') > -1
throw new Error('Unable to watch path')
spyOn(atom.themes, 'loadStylesheet').andReturn ''
atom.notifications.onDidAddNotification addErrorHandler = jasmine.createSpy()
it "creates an error notification", ->
atom.themes.loadUserStylesheet()
expect(addErrorHandler).toHaveBeenCalled()
note = addErrorHandler.mostRecentCall.args[0]
expect(note.getType()).toBe 'error'
expect(note.getMessage()).toContain 'Unable to watch path'
it "adds a notification when a theme's stylesheet is invalid", ->
addErrorHandler = jasmine.createSpy()
atom.notifications.onDidAddNotification(addErrorHandler)
expect(-> atom.packages.activatePackage('theme-with-invalid-styles').then((->), (->))).not.toThrow()
expect(addErrorHandler.callCount).toBe 2
expect(addErrorHandler.argsForCall[1][0].message).toContain("Failed to activate the theme-with-invalid-styles theme")
describe "when a non-existent theme is present in the config", ->
beforeEach ->
console.warn.reset()
atom.config.set('core.themes', ['non-existent-dark-ui', 'non-existent-dark-syntax'])
waitsForPromise ->
atom.themes.activateThemes()
it 'uses the default dark UI and syntax themes and logs a warning', ->
activeThemeNames = atom.themes.getActiveThemeNames()
expect(console.warn.callCount).toBe 2
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
describe "when in safe mode", ->
describe 'when the enabled UI and syntax themes are bundled with Atom', ->
beforeEach ->
atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-syntax'])
waitsForPromise ->
atom.themes.activateThemes()
it 'uses the enabled themes', ->
activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-light-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
describe 'when the enabled UI and syntax themes are not bundled with Atom', ->
beforeEach ->
atom.config.set('core.themes', ['installed-dark-ui', 'installed-dark-syntax'])
waitsForPromise ->
atom.themes.activateThemes()
it 'uses the default dark UI and syntax themes', ->
activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
describe 'when the enabled UI theme is not bundled with Atom', ->
beforeEach ->
atom.config.set('core.themes', ['installed-dark-ui', 'atom-light-syntax'])
waitsForPromise ->
atom.themes.activateThemes()
it 'uses the default dark UI theme', ->
activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-light-syntax')
describe 'when the enabled syntax theme is not bundled with Atom', ->
beforeEach ->
atom.config.set('core.themes', ['atom-light-ui', 'installed-dark-syntax'])
waitsForPromise ->
atom.themes.activateThemes()
it 'uses the default dark syntax theme', ->
activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-light-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')

503
spec/theme-manager-spec.js Normal file
View File

@@ -0,0 +1,503 @@
const path = require('path')
const fs = require('fs-plus')
const temp = require('temp').track()
describe('atom.themes', function () {
beforeEach(function () {
spyOn(atom, 'inSpecMode').andReturn(false)
spyOn(console, 'warn')
})
afterEach(function () {
waitsForPromise(() => atom.themes.deactivateThemes())
runs(function () {
try {
temp.cleanupSync()
} catch (error) {}
})
})
describe('theme getters and setters', function () {
beforeEach(function () {
jasmine.snapshotDeprecations()
atom.packages.loadPackages()
})
afterEach(() => jasmine.restoreDeprecationsSnapshot())
describe('getLoadedThemes', () =>
it('gets all the loaded themes', function () {
const themes = atom.themes.getLoadedThemes()
expect(themes.length).toBeGreaterThan(2)
})
)
describe('getActiveThemes', () =>
it('gets all the active themes', function () {
waitsForPromise(() => atom.themes.activateThemes())
runs(function () {
const names = atom.config.get('core.themes')
expect(names.length).toBeGreaterThan(0)
const themes = atom.themes.getActiveThemes()
expect(themes).toHaveLength(names.length)
})
})
)
})
describe('when the core.themes config value contains invalid entry', () =>
it('ignores theme', function () {
atom.config.set('core.themes', [
'atom-light-ui',
null,
undefined,
'',
false,
4,
{},
[],
'atom-dark-ui'
])
expect(atom.themes.getEnabledThemeNames()).toEqual(['atom-dark-ui', 'atom-light-ui'])
})
)
describe('::getImportPaths()', function () {
it('returns the theme directories before the themes are loaded', function () {
atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui', 'atom-light-ui'])
const paths = atom.themes.getImportPaths()
// syntax theme is not a dir at this time, so only two.
expect(paths.length).toBe(2)
expect(paths[0]).toContain('atom-light-ui')
expect(paths[1]).toContain('atom-dark-ui')
})
it('ignores themes that cannot be resolved to a directory', function () {
atom.config.set('core.themes', ['definitely-not-a-theme'])
expect(() => atom.themes.getImportPaths()).not.toThrow()
})
})
describe('when the core.themes config value changes', function () {
it('add/removes stylesheets to reflect the new config value', function () {
let didChangeActiveThemesHandler
atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy())
spyOn(atom.styles, 'getUserStyleSheetPath').andCallFake(() => null)
waitsForPromise(() => atom.themes.activateThemes())
runs(function () {
didChangeActiveThemesHandler.reset()
atom.config.set('core.themes', [])
})
waitsFor('a', () => didChangeActiveThemesHandler.callCount === 1)
runs(function () {
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style.theme')).toHaveLength(0)
atom.config.set('core.themes', ['atom-dark-ui'])
})
waitsFor('b', () => didChangeActiveThemesHandler.callCount === 1)
runs(function () {
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2)
expect(document.querySelector('style[priority="1"]').getAttribute('source-path')).toMatch(/atom-dark-ui/)
atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-ui'])
})
waitsFor('c', () => didChangeActiveThemesHandler.callCount === 1)
runs(function () {
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2)
expect(document.querySelectorAll('style[priority="1"]')[0].getAttribute('source-path')).toMatch(/atom-dark-ui/)
expect(document.querySelectorAll('style[priority="1"]')[1].getAttribute('source-path')).toMatch(/atom-light-ui/)
atom.config.set('core.themes', [])
})
waitsFor(() => didChangeActiveThemesHandler.callCount === 1)
runs(function () {
didChangeActiveThemesHandler.reset()
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2)
// atom-dark-ui has a directory path, the syntax one doesn't
atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui'])
})
waitsFor(() => didChangeActiveThemesHandler.callCount === 1)
runs(function () {
expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2)
const importPaths = atom.themes.getImportPaths()
expect(importPaths.length).toBe(1)
expect(importPaths[0]).toContain('atom-dark-ui')
})
})
it('adds theme-* classes to the workspace for each active theme', function () {
atom.config.set('core.themes', ['atom-dark-ui', 'atom-dark-syntax'])
let didChangeActiveThemesHandler
atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy())
waitsForPromise(() => atom.themes.activateThemes())
const workspaceElement = atom.workspace.getElement()
runs(function () {
expect(workspaceElement).toHaveClass('theme-atom-dark-ui')
atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy())
atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables'])
})
waitsFor(() => didChangeActiveThemesHandler.callCount > 0)
runs(function () {
// `theme-` twice as it prefixes the name with `theme-`
expect(workspaceElement).toHaveClass('theme-theme-with-ui-variables')
expect(workspaceElement).toHaveClass('theme-theme-with-syntax-variables')
expect(workspaceElement).not.toHaveClass('theme-atom-dark-ui')
expect(workspaceElement).not.toHaveClass('theme-atom-dark-syntax')
})
})
})
describe('when a theme fails to load', () =>
it('logs a warning', function () {
console.warn.reset()
atom.packages.activatePackage('a-theme-that-will-not-be-found').then(function () {}, function () {})
expect(console.warn.callCount).toBe(1)
expect(console.warn.argsForCall[0][0]).toContain("Could not resolve 'a-theme-that-will-not-be-found'")
})
)
describe('::requireStylesheet(path)', function () {
beforeEach(() => jasmine.snapshotDeprecations())
afterEach(() => jasmine.restoreDeprecationsSnapshot())
it('synchronously loads css at the given path and installs a style tag for it in the head', function () {
let styleElementAddedHandler
atom.styles.onDidAddStyleElement(styleElementAddedHandler = jasmine.createSpy('styleElementAddedHandler'))
const cssPath = getAbsolutePath(atom.project.getDirectories()[0], 'css.css')
const lengthBefore = document.querySelectorAll('head style').length
atom.themes.requireStylesheet(cssPath)
expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1)
expect(styleElementAddedHandler).toHaveBeenCalled()
const element = document.querySelector('head style[source-path*="css.css"]')
expect(element.getAttribute('source-path')).toEqualPath(cssPath)
expect(element.textContent).toBe(fs.readFileSync(cssPath, 'utf8'))
// doesn't append twice
styleElementAddedHandler.reset()
atom.themes.requireStylesheet(cssPath)
expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1)
expect(styleElementAddedHandler).not.toHaveBeenCalled()
document.querySelectorAll('head style[id*="css.css"]').forEach((styleElement) => {
styleElement.remove()
})
})
it('synchronously loads and parses less files at the given path and installs a style tag for it in the head', function () {
const lessPath = getAbsolutePath(atom.project.getDirectories()[0], 'sample.less')
const lengthBefore = document.querySelectorAll('head style').length
atom.themes.requireStylesheet(lessPath)
expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1)
const element = document.querySelector('head style[source-path*="sample.less"]')
expect(element.getAttribute('source-path')).toEqualPath(lessPath)
expect(element.textContent.toLowerCase()).toBe(`\
#header {
color: #4d926f;
}
h2 {
color: #4d926f;
}
\
`
)
// doesn't append twice
atom.themes.requireStylesheet(lessPath)
expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1)
document.querySelectorAll('head style[id*="sample.less"]').forEach((styleElement) => {
styleElement.remove()
})
})
it('supports requiring css and less stylesheets without an explicit extension', function () {
atom.themes.requireStylesheet(path.join(__dirname, 'fixtures', 'css'))
expect(document.querySelector('head style[source-path*="css.css"]').getAttribute('source-path'))
.toEqualPath(getAbsolutePath(atom.project.getDirectories()[0], 'css.css'))
atom.themes.requireStylesheet(path.join(__dirname, 'fixtures', 'sample'))
expect(document.querySelector('head style[source-path*="sample.less"]').getAttribute('source-path'))
.toEqualPath(getAbsolutePath(atom.project.getDirectories()[0], 'sample.less'))
document.querySelector('head style[source-path*="css.css"]').remove()
document.querySelector('head style[source-path*="sample.less"]').remove()
})
it('returns a disposable allowing styles applied by the given path to be removed', function () {
const cssPath = require.resolve('./fixtures/css.css')
expect(getComputedStyle(document.body).fontWeight).not.toBe('bold')
const disposable = atom.themes.requireStylesheet(cssPath)
expect(getComputedStyle(document.body).fontWeight).toBe('bold')
let styleElementRemovedHandler
atom.styles.onDidRemoveStyleElement(styleElementRemovedHandler = jasmine.createSpy('styleElementRemovedHandler'))
disposable.dispose()
expect(getComputedStyle(document.body).fontWeight).not.toBe('bold')
expect(styleElementRemovedHandler).toHaveBeenCalled()
})
})
describe('base style sheet loading', function () {
beforeEach(function () {
const workspaceElement = atom.workspace.getElement()
jasmine.attachToDOM(atom.workspace.getElement())
workspaceElement.appendChild(document.createElement('atom-text-editor'))
waitsForPromise(() => atom.themes.activateThemes())
})
it("loads the correct values from the theme's ui-variables file", function () {
let didChangeActiveThemesHandler
atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy())
atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables'])
waitsFor(() => didChangeActiveThemesHandler.callCount > 0)
runs(function () {
// an override loaded in the base css
expect(getComputedStyle(atom.workspace.getElement())['background-color']).toBe('rgb(0, 0, 255)')
// from within the theme itself
expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingTop).toBe('150px')
expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingRight).toBe('150px')
expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingBottom).toBe('150px')
})
})
describe('when there is a theme with incomplete variables', () =>
it('loads the correct values from the fallback ui-variables', function () {
let didChangeActiveThemesHandler
atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy())
atom.config.set('core.themes', ['theme-with-incomplete-ui-variables', 'theme-with-syntax-variables'])
waitsFor(() => didChangeActiveThemesHandler.callCount > 0)
runs(function () {
// an override loaded in the base css
expect(getComputedStyle(atom.workspace.getElement())['background-color']).toBe('rgb(0, 0, 255)')
// from within the theme itself
expect(getComputedStyle(document.querySelector('atom-text-editor')).backgroundColor).toBe('rgb(0, 152, 255)')
})
})
)
})
describe('user stylesheet', function () {
let userStylesheetPath
beforeEach(function () {
userStylesheetPath = path.join(temp.mkdirSync('atom'), 'styles.less')
fs.writeFileSync(userStylesheetPath, 'body {border-style: dotted !important;}')
spyOn(atom.styles, 'getUserStyleSheetPath').andReturn(userStylesheetPath)
})
describe('when the user stylesheet changes', function () {
beforeEach(() => jasmine.snapshotDeprecations())
afterEach(() => jasmine.restoreDeprecationsSnapshot())
it('reloads it', function () {
let styleElementAddedHandler, styleElementRemovedHandler
waitsForPromise(() => atom.themes.activateThemes())
runs(function () {
atom.styles.onDidRemoveStyleElement(styleElementRemovedHandler = jasmine.createSpy('styleElementRemovedHandler'))
atom.styles.onDidAddStyleElement(styleElementAddedHandler = jasmine.createSpy('styleElementAddedHandler'))
spyOn(atom.themes, 'loadUserStylesheet').andCallThrough()
expect(getComputedStyle(document.body).borderStyle).toBe('dotted')
fs.writeFileSync(userStylesheetPath, 'body {border-style: dashed}')
})
waitsFor(() => atom.themes.loadUserStylesheet.callCount === 1)
runs(function () {
expect(getComputedStyle(document.body).borderStyle).toBe('dashed')
expect(styleElementRemovedHandler).toHaveBeenCalled()
expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain('dotted')
expect(styleElementAddedHandler).toHaveBeenCalled()
expect(styleElementAddedHandler.argsForCall[0][0].textContent).toContain('dashed')
styleElementRemovedHandler.reset()
fs.removeSync(userStylesheetPath)
})
waitsFor(() => atom.themes.loadUserStylesheet.callCount === 2)
runs(function () {
expect(styleElementRemovedHandler).toHaveBeenCalled()
expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain('dashed')
expect(getComputedStyle(document.body).borderStyle).toBe('none')
})
})
})
describe('when there is an error reading the stylesheet', function () {
let addErrorHandler = null
beforeEach(function () {
atom.themes.loadUserStylesheet()
spyOn(atom.themes.lessCache, 'cssForFile').andCallFake(function () {
throw new Error('EACCES permission denied "styles.less"')
})
atom.notifications.onDidAddNotification(addErrorHandler = jasmine.createSpy())
})
it('creates an error notification and does not add the stylesheet', function () {
atom.themes.loadUserStylesheet()
expect(addErrorHandler).toHaveBeenCalled()
const note = addErrorHandler.mostRecentCall.args[0]
expect(note.getType()).toBe('error')
expect(note.getMessage()).toContain('Error loading')
expect(atom.styles.styleElementsBySourcePath[atom.styles.getUserStyleSheetPath()]).toBeUndefined()
})
})
describe('when there is an error watching the user stylesheet', function () {
let addErrorHandler = null
beforeEach(function () {
const {File} = require('pathwatcher')
spyOn(File.prototype, 'on').andCallFake(function (event) {
if (event.indexOf('contents-changed') > -1) {
throw new Error('Unable to watch path')
}
})
spyOn(atom.themes, 'loadStylesheet').andReturn('')
atom.notifications.onDidAddNotification(addErrorHandler = jasmine.createSpy())
})
it('creates an error notification', function () {
atom.themes.loadUserStylesheet()
expect(addErrorHandler).toHaveBeenCalled()
const note = addErrorHandler.mostRecentCall.args[0]
expect(note.getType()).toBe('error')
expect(note.getMessage()).toContain('Unable to watch path')
})
})
it("adds a notification when a theme's stylesheet is invalid", function () {
const addErrorHandler = jasmine.createSpy()
atom.notifications.onDidAddNotification(addErrorHandler)
expect(() => atom.packages.activatePackage('theme-with-invalid-styles').then(function () {}, function () {})).not.toThrow()
expect(addErrorHandler.callCount).toBe(2)
expect(addErrorHandler.argsForCall[1][0].message).toContain('Failed to activate the theme-with-invalid-styles theme')
})
})
describe('when a non-existent theme is present in the config', function () {
beforeEach(function () {
console.warn.reset()
atom.config.set('core.themes', ['non-existent-dark-ui', 'non-existent-dark-syntax'])
waitsForPromise(() => atom.themes.activateThemes())
})
it('uses the default dark UI and syntax themes and logs a warning', function () {
const activeThemeNames = atom.themes.getActiveThemeNames()
expect(console.warn.callCount).toBe(2)
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
})
})
describe('when in safe mode', function () {
describe('when the enabled UI and syntax themes are bundled with Atom', function () {
beforeEach(function () {
atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-syntax'])
waitsForPromise(() => atom.themes.activateThemes())
})
it('uses the enabled themes', function () {
const activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-light-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
})
})
describe('when the enabled UI and syntax themes are not bundled with Atom', function () {
beforeEach(function () {
atom.config.set('core.themes', ['installed-dark-ui', 'installed-dark-syntax'])
waitsForPromise(() => atom.themes.activateThemes())
})
it('uses the default dark UI and syntax themes', function () {
const activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
})
})
describe('when the enabled UI theme is not bundled with Atom', function () {
beforeEach(function () {
atom.config.set('core.themes', ['installed-dark-ui', 'atom-light-syntax'])
waitsForPromise(() => atom.themes.activateThemes())
})
it('uses the default dark UI theme', function () {
const activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-dark-ui')
expect(activeThemeNames).toContain('atom-light-syntax')
})
})
describe('when the enabled syntax theme is not bundled with Atom', function () {
beforeEach(function () {
atom.config.set('core.themes', ['atom-light-ui', 'installed-dark-syntax'])
waitsForPromise(() => atom.themes.activateThemes())
})
it('uses the default dark syntax theme', function () {
const activeThemeNames = atom.themes.getActiveThemeNames()
expect(activeThemeNames.length).toBe(2)
expect(activeThemeNames).toContain('atom-light-ui')
expect(activeThemeNames).toContain('atom-dark-syntax')
})
})
})
})
function getAbsolutePath (directory, relativePath) {
if (directory) {
return directory.resolve(relativePath)
}
}

View File

@@ -1,37 +0,0 @@
TextBuffer = require 'text-buffer'
TokenizedBuffer = require '../src/tokenized-buffer'
describe "TokenIterator", ->
it "correctly terminates scopes at the beginning of the line (regression)", ->
grammar = atom.grammars.createGrammar('test', {
'scopeName': 'text.broken'
'name': 'Broken grammar'
'patterns': [
{
'begin': 'start'
'end': '(?=end)'
'name': 'blue.broken'
}
{
'match': '.'
'name': 'yellow.broken'
}
]
})
buffer = new TextBuffer(text: """
start x
end x
x
""")
tokenizedBuffer = new TokenizedBuffer({
buffer, config: atom.config, grammarRegistry: atom.grammars, packageManager: atom.packages, assert: atom.assert
})
tokenizedBuffer.setGrammar(grammar)
tokenIterator = tokenizedBuffer.tokenizedLines[1].getTokenIterator()
tokenIterator.next()
expect(tokenIterator.getBufferStart()).toBe 0
expect(tokenIterator.getScopeEnds()).toEqual []
expect(tokenIterator.getScopeStarts()).toEqual ['text.broken', 'yellow.broken']

View File

@@ -0,0 +1,43 @@
const TextBuffer = require('text-buffer')
const TokenizedBuffer = require('../src/tokenized-buffer')
describe('TokenIterator', () =>
it('correctly terminates scopes at the beginning of the line (regression)', () => {
const grammar = atom.grammars.createGrammar('test', {
'scopeName': 'text.broken',
'name': 'Broken grammar',
'patterns': [
{
'begin': 'start',
'end': '(?=end)',
'name': 'blue.broken'
},
{
'match': '.',
'name': 'yellow.broken'
}
]
})
const buffer = new TextBuffer({text: `\
start x
end x
x\
`})
const tokenizedBuffer = new TokenizedBuffer({
buffer,
config: atom.config,
grammarRegistry: atom.grammars,
packageManager: atom.packages,
assert: atom.assert
})
tokenizedBuffer.setGrammar(grammar)
const tokenIterator = tokenizedBuffer.tokenizedLines[1].getTokenIterator()
tokenIterator.next()
expect(tokenIterator.getBufferStart()).toBe(0)
expect(tokenIterator.getScopeEnds()).toEqual([])
expect(tokenIterator.getScopeStarts()).toEqual(['text.broken', 'yellow.broken'])
})
)

View File

@@ -643,186 +643,6 @@ describe('TokenizedBuffer', () => {
})
})
describe('.toggleLineCommentsForBufferRows', () => {
describe('xml', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-xml')
buffer = new TextBuffer('<!-- test -->')
tokenizedBuffer = new TokenizedBuffer({
buffer,
grammar: atom.grammars.grammarForScopeName('text.xml'),
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
})
})
it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe('test')
})
})
describe('less', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-less')
await atom.packages.activatePackage('language-css')
buffer = await TextBuffer.load(require.resolve('./fixtures/sample.less'))
tokenizedBuffer = new TokenizedBuffer({
buffer,
grammar: atom.grammars.grammarForScopeName('source.css.less'),
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
})
})
it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe('// @color: #4D926F;')
})
})
describe('css', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-css')
buffer = await TextBuffer.load(require.resolve('./fixtures/css.css'))
tokenizedBuffer = new TokenizedBuffer({
buffer,
grammar: atom.grammars.grammarForScopeName('source.css'),
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
})
})
it('comments/uncomments lines in the given range', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
expect(buffer.lineForRow(0)).toBe('/*body {')
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/')
expect(buffer.lineForRow(2)).toBe(' width: 110%;')
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
expect(buffer.lineForRow(0)).toBe('/*body {')
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/')
expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/')
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
expect(buffer.lineForRow(0)).toBe('body {')
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;')
expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/')
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
})
it('uncomments lines with leading whitespace', () => {
buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/')
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
expect(buffer.lineForRow(2)).toBe(' width: 110%;')
})
it('uncomments lines with trailing whitespace', () => {
buffer.setTextInRange([[2, 0], [2, Infinity]], '/*width: 110%;*/ ')
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
expect(buffer.lineForRow(2)).toBe('width: 110%; ')
})
it('uncomments lines with leading and trailing whitespace', () => {
buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/ ')
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
expect(buffer.lineForRow(2)).toBe(' width: 110%; ')
})
})
describe('coffeescript', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-coffee-script')
buffer = await TextBuffer.load(require.resolve('./fixtures/coffee.coffee'))
tokenizedBuffer = new TokenizedBuffer({
buffer,
tabLength: 2,
grammar: atom.grammars.grammarForScopeName('source.coffee'),
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
})
})
it('comments/uncomments lines in the given range', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 6)
expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()')
expect(buffer.lineForRow(5)).toBe(' # left = []')
expect(buffer.lineForRow(6)).toBe(' # right = []')
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()')
expect(buffer.lineForRow(5)).toBe(' left = []')
expect(buffer.lineForRow(6)).toBe(' # right = []')
})
it('comments/uncomments empty lines', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7)
expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()')
expect(buffer.lineForRow(5)).toBe(' # left = []')
expect(buffer.lineForRow(6)).toBe(' # right = []')
expect(buffer.lineForRow(7)).toBe(' # ')
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()')
expect(buffer.lineForRow(5)).toBe(' left = []')
expect(buffer.lineForRow(6)).toBe(' # right = []')
expect(buffer.lineForRow(7)).toBe(' # ')
})
})
describe('javascript', () => {
beforeEach(async () => {
await atom.packages.activatePackage('language-javascript')
buffer = await TextBuffer.load(require.resolve('./fixtures/sample.js'))
tokenizedBuffer = new TokenizedBuffer({
buffer,
tabLength: 2,
grammar: atom.grammars.grammarForScopeName('source.js'),
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
})
})
it('comments/uncomments lines in the given range', () => {
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7)
expect(buffer.lineForRow(4)).toBe(' // while(items.length > 0) {')
expect(buffer.lineForRow(5)).toBe(' // current = items.shift();')
expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);')
expect(buffer.lineForRow(7)).toBe(' // }')
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
expect(buffer.lineForRow(4)).toBe(' while(items.length > 0) {')
expect(buffer.lineForRow(5)).toBe(' current = items.shift();')
expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);')
expect(buffer.lineForRow(7)).toBe(' // }')
buffer.setText('\tvar i;')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe('\t// var i;')
buffer.setText('var i;')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe('// var i;')
buffer.setText(' var i;')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe(' // var i;')
buffer.setText(' ')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
expect(buffer.lineForRow(0)).toBe(' // ')
buffer.setText(' a\n \n b')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 2)
expect(buffer.lineForRow(0)).toBe(' // a')
expect(buffer.lineForRow(1)).toBe(' // ')
expect(buffer.lineForRow(2)).toBe(' // b')
buffer.setText(' \n // var i;')
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
expect(buffer.lineForRow(0)).toBe(' ')
expect(buffer.lineForRow(1)).toBe(' var i;')
})
})
})
describe('.isFoldableAtRow(row)', () => {
beforeEach(() => {
buffer = atom.project.bufferForPathSync('sample.js')

View File

@@ -1,213 +0,0 @@
{CompositeDisposable} = require 'atom'
TooltipManager = require '../src/tooltip-manager'
Tooltip = require '../src/tooltip'
_ = require 'underscore-plus'
describe "TooltipManager", ->
[manager, element] = []
ctrlX = _.humanizeKeystroke("ctrl-x")
ctrlY = _.humanizeKeystroke("ctrl-y")
beforeEach ->
manager = new TooltipManager(keymapManager: atom.keymaps, viewRegistry: atom.views)
element = createElement 'foo'
createElement = (className) ->
el = document.createElement('div')
el.classList.add(className)
jasmine.attachToDOM(el)
el
mouseEnter = (element) ->
element.dispatchEvent(new CustomEvent('mouseenter', bubbles: false))
element.dispatchEvent(new CustomEvent('mouseover', bubbles: true))
mouseLeave = (element) ->
element.dispatchEvent(new CustomEvent('mouseleave', bubbles: false))
element.dispatchEvent(new CustomEvent('mouseout', bubbles: true))
hover = (element, fn) ->
mouseEnter(element)
advanceClock(manager.hoverDefaults.delay.show)
fn()
mouseLeave(element)
advanceClock(manager.hoverDefaults.delay.hide)
describe "::add(target, options)", ->
describe "when the trigger is 'hover' (the default)", ->
it "creates a tooltip when hovering over the target element", ->
manager.add element, title: "Title"
hover element, ->
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
it "displays tooltips immediately when hovering over new elements once a tooltip has been displayed once", ->
disposables = new CompositeDisposable
element1 = createElement('foo')
disposables.add(manager.add element1, title: 'Title')
element2 = createElement('bar')
disposables.add(manager.add element2, title: 'Title')
element3 = createElement('baz')
disposables.add(manager.add element3, title: 'Title')
hover element1, ->
expect(document.body.querySelector(".tooltip")).toBeNull()
mouseEnter(element2)
expect(document.body.querySelector(".tooltip")).not.toBeNull()
mouseLeave(element2)
advanceClock(manager.hoverDefaults.delay.hide)
expect(document.body.querySelector(".tooltip")).toBeNull()
advanceClock(Tooltip.FOLLOW_THROUGH_DURATION)
mouseEnter(element3)
expect(document.body.querySelector(".tooltip")).toBeNull()
advanceClock(manager.hoverDefaults.delay.show)
expect(document.body.querySelector(".tooltip")).not.toBeNull()
disposables.dispose()
describe "when the trigger is 'manual'", ->
it "creates a tooltip immediately and only hides it on dispose", ->
disposable = manager.add element, title: "Title", trigger: "manual"
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
disposable.dispose()
expect(document.body.querySelector(".tooltip")).toBeNull()
describe "when the trigger is 'click'", ->
it "shows and hides the tooltip when the target element is clicked", ->
disposable = manager.add element, title: "Title", trigger: "click"
expect(document.body.querySelector(".tooltip")).toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
# Hide the tooltip when clicking anywhere but inside the tooltip element
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.querySelector(".tooltip").click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.querySelector(".tooltip").firstChild.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
# Tooltip can show again after hiding due to clicking outside of the tooltip
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
it "allows a custom item to be specified for the content of the tooltip", ->
tooltipElement = document.createElement('div')
manager.add element, item: {element: tooltipElement}
hover element, ->
expect(tooltipElement.closest(".tooltip")).not.toBeNull()
it "allows a custom class to be specified for the tooltip", ->
tooltipElement = document.createElement('div')
manager.add element, title: 'Title', class: 'custom-tooltip-class'
hover element, ->
expect(document.body.querySelector(".tooltip").classList.contains('custom-tooltip-class')).toBe(true)
it "allows jQuery elements to be passed as the target", ->
element2 = document.createElement('div')
jasmine.attachToDOM(element2)
fakeJqueryWrapper = [element, element2]
fakeJqueryWrapper.jquery = 'any-version'
disposable = manager.add fakeJqueryWrapper, title: "Title"
hover element, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title")
expect(document.body.querySelector(".tooltip")).toBeNull()
hover element2, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title")
expect(document.body.querySelector(".tooltip")).toBeNull()
disposable.dispose()
hover element, -> expect(document.body.querySelector(".tooltip")).toBeNull()
hover element2, -> expect(document.body.querySelector(".tooltip")).toBeNull()
describe "when a keyBindingCommand is specified", ->
describe "when a title is specified", ->
it "appends the key binding corresponding to the command to the title", ->
atom.keymaps.add 'test',
'.foo': 'ctrl-x ctrl-y': 'test-command'
'.bar': 'ctrl-x ctrl-z': 'test-command'
manager.add element, title: "Title", keyBindingCommand: 'test-command'
hover element, ->
tooltipElement = document.body.querySelector(".tooltip")
expect(tooltipElement).toHaveText "Title #{ctrlX} #{ctrlY}"
describe "when no title is specified", ->
it "shows the key binding corresponding to the command alone", ->
atom.keymaps.add 'test', '.foo': 'ctrl-x ctrl-y': 'test-command'
manager.add element, keyBindingCommand: 'test-command'
hover element, ->
tooltipElement = document.body.querySelector(".tooltip")
expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}"
describe "when a keyBindingTarget is specified", ->
it "looks up the key binding relative to the target", ->
atom.keymaps.add 'test',
'.bar': 'ctrl-x ctrl-z': 'test-command'
'.foo': 'ctrl-x ctrl-y': 'test-command'
manager.add element, keyBindingCommand: 'test-command', keyBindingTarget: element
hover element, ->
tooltipElement = document.body.querySelector(".tooltip")
expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}"
it "does not display the keybinding if there is nothing mapped to the specified keyBindingCommand", ->
manager.add element, title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element
hover element, ->
tooltipElement = document.body.querySelector(".tooltip")
expect(tooltipElement.textContent).toBe "A Title"
describe "when .dispose() is called on the returned disposable", ->
it "no longer displays the tooltip on hover", ->
disposable = manager.add element, title: "Title"
hover element, ->
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
disposable.dispose()
hover element, ->
expect(document.body.querySelector(".tooltip")).toBeNull()
describe "when the window is resized", ->
it "hides the tooltips", ->
disposable = manager.add element, title: "Title"
hover element, ->
expect(document.body.querySelector(".tooltip")).not.toBeNull()
window.dispatchEvent(new CustomEvent('resize'))
expect(document.body.querySelector(".tooltip")).toBeNull()
disposable.dispose()
describe "findTooltips", ->
it "adds and remove tooltips correctly", ->
expect(manager.findTooltips(element).length).toBe(0)
disposable1 = manager.add element, title: "elem1"
expect(manager.findTooltips(element).length).toBe(1)
disposable2 = manager.add element, title: "elem2"
expect(manager.findTooltips(element).length).toBe(2)
disposable1.dispose()
expect(manager.findTooltips(element).length).toBe(1)
disposable2.dispose()
expect(manager.findTooltips(element).length).toBe(0)
it "lets us hide tooltips programmatically", ->
disposable = manager.add element, title: "Title"
hover element, ->
expect(document.body.querySelector(".tooltip")).not.toBeNull()
manager.findTooltips(element)[0].hide()
expect(document.body.querySelector(".tooltip")).toBeNull()
disposable.dispose()

View File

@@ -0,0 +1,257 @@
const {CompositeDisposable} = require('atom')
const TooltipManager = require('../src/tooltip-manager')
const Tooltip = require('../src/tooltip')
const _ = require('underscore-plus')
describe('TooltipManager', () => {
let manager, element
const ctrlX = _.humanizeKeystroke('ctrl-x')
const ctrlY = _.humanizeKeystroke('ctrl-y')
const hover = function (element, fn) {
mouseEnter(element)
advanceClock(manager.hoverDefaults.delay.show)
fn()
mouseLeave(element)
advanceClock(manager.hoverDefaults.delay.hide)
}
beforeEach(function () {
manager = new TooltipManager({keymapManager: atom.keymaps, viewRegistry: atom.views})
element = createElement('foo')
})
describe('::add(target, options)', () => {
describe("when the trigger is 'hover' (the default)", () => {
it('creates a tooltip when hovering over the target element', () => {
manager.add(element, {title: 'Title'})
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
})
it('displays tooltips immediately when hovering over new elements once a tooltip has been displayed once', () => {
const disposables = new CompositeDisposable()
const element1 = createElement('foo')
disposables.add(manager.add(element1, {title: 'Title'}))
const element2 = createElement('bar')
disposables.add(manager.add(element2, {title: 'Title'}))
const element3 = createElement('baz')
disposables.add(manager.add(element3, {title: 'Title'}))
hover(element1, () => {})
expect(document.body.querySelector('.tooltip')).toBeNull()
mouseEnter(element2)
expect(document.body.querySelector('.tooltip')).not.toBeNull()
mouseLeave(element2)
advanceClock(manager.hoverDefaults.delay.hide)
expect(document.body.querySelector('.tooltip')).toBeNull()
advanceClock(Tooltip.FOLLOW_THROUGH_DURATION)
mouseEnter(element3)
expect(document.body.querySelector('.tooltip')).toBeNull()
advanceClock(manager.hoverDefaults.delay.show)
expect(document.body.querySelector('.tooltip')).not.toBeNull()
disposables.dispose()
})
})
describe("when the trigger is 'manual'", () =>
it('creates a tooltip immediately and only hides it on dispose', () => {
const disposable = manager.add(element, {title: 'Title', trigger: 'manual'})
expect(document.body.querySelector('.tooltip')).toHaveText('Title')
disposable.dispose()
expect(document.body.querySelector('.tooltip')).toBeNull()
})
)
describe("when the trigger is 'click'", () =>
it('shows and hides the tooltip when the target element is clicked', () => {
manager.add(element, {title: 'Title', trigger: 'click'})
expect(document.body.querySelector('.tooltip')).toBeNull()
element.click()
expect(document.body.querySelector('.tooltip')).not.toBeNull()
element.click()
expect(document.body.querySelector('.tooltip')).toBeNull()
// Hide the tooltip when clicking anywhere but inside the tooltip element
element.click()
expect(document.body.querySelector('.tooltip')).not.toBeNull()
document.body.querySelector('.tooltip').click()
expect(document.body.querySelector('.tooltip')).not.toBeNull()
document.body.querySelector('.tooltip').firstChild.click()
expect(document.body.querySelector('.tooltip')).not.toBeNull()
document.body.click()
expect(document.body.querySelector('.tooltip')).toBeNull()
// Tooltip can show again after hiding due to clicking outside of the tooltip
element.click()
expect(document.body.querySelector('.tooltip')).not.toBeNull()
element.click()
expect(document.body.querySelector('.tooltip')).toBeNull()
})
)
it('allows a custom item to be specified for the content of the tooltip', () => {
const tooltipElement = document.createElement('div')
manager.add(element, {item: {element: tooltipElement}})
hover(element, () => expect(tooltipElement.closest('.tooltip')).not.toBeNull())
})
it('allows a custom class to be specified for the tooltip', () => {
manager.add(element, {title: 'Title', class: 'custom-tooltip-class'})
hover(element, () => expect(document.body.querySelector('.tooltip').classList.contains('custom-tooltip-class')).toBe(true))
})
it('allows jQuery elements to be passed as the target', () => {
const element2 = document.createElement('div')
jasmine.attachToDOM(element2)
const fakeJqueryWrapper = {
0: element,
1: element2,
length: 2,
jquery: 'any-version'
}
const disposable = manager.add(fakeJqueryWrapper, {title: 'Title'})
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
expect(document.body.querySelector('.tooltip')).toBeNull()
hover(element2, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
expect(document.body.querySelector('.tooltip')).toBeNull()
disposable.dispose()
hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull())
hover(element2, () => expect(document.body.querySelector('.tooltip')).toBeNull())
})
describe('when a keyBindingCommand is specified', () => {
describe('when a title is specified', () =>
it('appends the key binding corresponding to the command to the title', () => {
atom.keymaps.add('test', {
'.foo': { 'ctrl-x ctrl-y': 'test-command'
},
'.bar': { 'ctrl-x ctrl-z': 'test-command'
}
}
)
manager.add(element, {title: 'Title', keyBindingCommand: 'test-command'})
hover(element, function () {
const tooltipElement = document.body.querySelector('.tooltip')
expect(tooltipElement).toHaveText(`Title ${ctrlX} ${ctrlY}`)
})
})
)
describe('when no title is specified', () =>
it('shows the key binding corresponding to the command alone', () => {
atom.keymaps.add('test', {'.foo': {'ctrl-x ctrl-y': 'test-command'}})
manager.add(element, {keyBindingCommand: 'test-command'})
hover(element, function () {
const tooltipElement = document.body.querySelector('.tooltip')
expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`)
})
})
)
describe('when a keyBindingTarget is specified', () => {
it('looks up the key binding relative to the target', () => {
atom.keymaps.add('test', {
'.bar': { 'ctrl-x ctrl-z': 'test-command'
},
'.foo': { 'ctrl-x ctrl-y': 'test-command'
}
}
)
manager.add(element, {keyBindingCommand: 'test-command', keyBindingTarget: element})
hover(element, function () {
const tooltipElement = document.body.querySelector('.tooltip')
expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`)
})
})
it('does not display the keybinding if there is nothing mapped to the specified keyBindingCommand', () => {
manager.add(element, {title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element})
hover(element, function () {
const tooltipElement = document.body.querySelector('.tooltip')
expect(tooltipElement.textContent).toBe('A Title')
})
})
})
})
describe('when .dispose() is called on the returned disposable', () =>
it('no longer displays the tooltip on hover', () => {
const disposable = manager.add(element, {title: 'Title'})
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
disposable.dispose()
hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull())
})
)
describe('when the window is resized', () =>
it('hides the tooltips', () => {
const disposable = manager.add(element, {title: 'Title'})
hover(element, function () {
expect(document.body.querySelector('.tooltip')).not.toBeNull()
window.dispatchEvent(new CustomEvent('resize'))
expect(document.body.querySelector('.tooltip')).toBeNull()
disposable.dispose()
})
})
)
describe('findTooltips', () => {
it('adds and remove tooltips correctly', () => {
expect(manager.findTooltips(element).length).toBe(0)
const disposable1 = manager.add(element, {title: 'elem1'})
expect(manager.findTooltips(element).length).toBe(1)
const disposable2 = manager.add(element, {title: 'elem2'})
expect(manager.findTooltips(element).length).toBe(2)
disposable1.dispose()
expect(manager.findTooltips(element).length).toBe(1)
disposable2.dispose()
expect(manager.findTooltips(element).length).toBe(0)
})
it('lets us hide tooltips programmatically', () => {
const disposable = manager.add(element, {title: 'Title'})
hover(element, function () {
expect(document.body.querySelector('.tooltip')).not.toBeNull()
manager.findTooltips(element)[0].hide()
expect(document.body.querySelector('.tooltip')).toBeNull()
disposable.dispose()
})
})
})
})
})
function createElement (className) {
const el = document.createElement('div')
el.classList.add(className)
jasmine.attachToDOM(el)
return el
}
function mouseEnter (element) {
element.dispatchEvent(new CustomEvent('mouseenter', {bubbles: false}))
element.dispatchEvent(new CustomEvent('mouseover', {bubbles: true}))
}
function mouseLeave (element) {
element.dispatchEvent(new CustomEvent('mouseleave', {bubbles: false}))
element.dispatchEvent(new CustomEvent('mouseout', {bubbles: true}))
}

View File

@@ -0,0 +1,75 @@
/** @babel */
import url from 'url'
import {it} from './async-spec-helpers'
import URIHandlerRegistry from '../src/uri-handler-registry'
describe('URIHandlerRegistry', () => {
let registry
beforeEach(() => {
registry = new URIHandlerRegistry(5)
})
it('handles URIs on a per-host basis', () => {
const testPackageSpy = jasmine.createSpy()
const otherPackageSpy = jasmine.createSpy()
registry.registerHostHandler('test-package', testPackageSpy)
registry.registerHostHandler('other-package', otherPackageSpy)
registry.handleURI('atom://yet-another-package/path')
expect(testPackageSpy).not.toHaveBeenCalled()
expect(otherPackageSpy).not.toHaveBeenCalled()
registry.handleURI('atom://test-package/path')
expect(testPackageSpy).toHaveBeenCalledWith(url.parse('atom://test-package/path', true), 'atom://test-package/path')
expect(otherPackageSpy).not.toHaveBeenCalled()
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', () => {
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'
]
uris.forEach(u => 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())
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', () => {
[
'atom:package/path',
'atom:8080://package/path',
'user:pass@atom://package/path',
'smth://package/path'
].forEach(uri => {
expect(() => registry.handleURI(uri)).toThrow()
})
})
})

View File

@@ -1,163 +0,0 @@
ViewRegistry = require '../src/view-registry'
describe "ViewRegistry", ->
registry = null
beforeEach ->
registry = new ViewRegistry
afterEach ->
registry.clearDocumentRequests()
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 an object with an element property", ->
it "returns the element property if it's an instance of HTMLElement", ->
class TestComponent
constructor: -> @element = document.createElement('div')
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: ->
@myElement ?= document.createElement('div')
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 = 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 a view provider is registered generically, and works with the object", ->
it "constructs a view element and assigns the model on it", ->
model = {a: 'b'}
registry.addViewProvider (model) ->
if model.a is 'b'
element = document.createElement('div')
element.className = 'test-element'
element
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(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.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", ->
updateCalled = false
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

216
spec/view-registry-spec.js Normal file
View File

@@ -0,0 +1,216 @@
/*
* 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 })
})
})
)
})

View File

@@ -1,209 +0,0 @@
KeymapManager = require 'atom-keymap'
TextEditor = require '../src/text-editor'
WindowEventHandler = require '../src/window-event-handler'
{ipcRenderer} = require 'electron'
describe "WindowEventHandler", ->
[windowEventHandler] = []
beforeEach ->
atom.uninstallWindowEventHandler()
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({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", ->
return if process.platform is 'win32' #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 "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", ->
{shell} = require 'electron'
spyOn(shell, 'openExternal')
link = document.createElement('a')
linkChild = document.createElement('span')
link.appendChild(linkChild)
link.href = 'http://github.com'
jasmine.attachToDOM(link)
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()
describe "when a form is submitted", ->
it "prevents the default so that the window's URL isn't changed", ->
form = document.createElement('form')
jasmine.attachToDOM(form)
defaultPrevented = false
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", ->
wrapperDiv = document.createElement('div')
wrapperDiv.innerHTML = """
<div>
<button tabindex="2"></button>
<input tabindex="1">
</div>
"""
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", ->
wrapperDiv = document.createElement('div')
wrapperDiv.innerHTML = """
<div>
<input tabindex="1">
<button tabindex="2"></button>
<button tabindex="5"></button>
<input tabindex="-1">
<input tabindex="3">
<button tabindex="7"></button>
<input tabindex="9" disabled>
</div>
"""
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", ->
dispatchedCommands = []
atom.commands.onWillDispatch (command) -> dispatchedCommands.push(command)
atom.commands.add '*', 'foo-command': ->
atom.keymaps.add 'source-name', '*': {'x': 'foo-command'}
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", ->
webContentsSpy = jasmine.createSpyObj("webContents", ["copy", "paste"])
spyOn(atom.applicationDelegate, "getCurrentWindow").andReturn({
webContents: webContentsSpy
on: ->
})
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()
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()

View File

@@ -0,0 +1,228 @@
const KeymapManager = require('atom-keymap')
const WindowEventHandler = require('../src/window-event-handler')
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('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()
})
)
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 = `
<div>
<button tabindex="2"></button>
<input tabindex="1">
</div>
`.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 = `
<div>
<input tabindex="1">
<button tabindex="2"></button>
<button tabindex="5"></button>
<input tabindex="-1">
<input tabindex="3">
<button tabindex="7"></button>
<input tabindex="9" disabled>
</div>
`.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()
})
)
})

View File

@@ -1585,15 +1585,15 @@ i = /test/; #FIXME\
atom2.project.deserialize(atom.project.serialize())
atom2.workspace.deserialize(atom.workspace.serialize(), atom2.deserializers)
expect(atom2.grammars.getGrammars().map(grammar => grammar.name).sort()).toEqual([
'CoffeeScript',
'CoffeeScript (Literate)',
'JSDoc',
'JavaScript',
'Null Grammar',
'Regular Expression Replacement (JavaScript)',
'Regular Expressions (JavaScript)',
'TODO'
expect(atom2.grammars.getGrammars().map(grammar => grammar.scopeName).sort()).toEqual([
'source.coffee',
'source.js',
'source.js.regexp',
'source.js.regexp.replacement',
'source.jsdoc',
'source.litcoffee',
'text.plain.null-grammar',
'text.todo'
])
atom2.destroy()

View File

@@ -233,6 +233,14 @@ class ApplicationDelegate
new Disposable ->
ipcRenderer.removeListener('context-command', outerCallback)
onURIMessage: (callback) ->
outerCallback = (event, args...) ->
callback(args...)
ipcRenderer.on('uri-message', outerCallback)
new Disposable ->
ipcRenderer.removeListener('uri-message', outerCallback)
onDidRequestUnload: (callback) ->
outerCallback = (event, message) ->
callback(event).then (shouldUnload) ->

File diff suppressed because it is too large Load Diff

1339
src/atom-environment.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -89,7 +89,7 @@ module.exports = class CommandRegistry {
// 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
// * `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.
@@ -97,7 +97,7 @@ module.exports = class CommandRegistry {
// 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
// * `event`: A standard DOM event instance. Call `stopPropagation` or
// `stopImmediatePropagation` to terminate bubbling early.
//
// Additionally, `listener` may have additional properties which are returned
@@ -107,6 +107,13 @@ module.exports = class CommandRegistry {
// 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
//

View File

@@ -17,7 +17,7 @@ const configSchema = {
type: 'boolean',
default: true,
title: 'Exclude VCS Ignored Paths',
description: 'Files and directories ignored by the current project\'s VCS system 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.'
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',
@@ -55,6 +55,25 @@ const configSchema = {
}
}
},
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'],

38
src/core-uri-handlers.js Normal file
View File

@@ -0,0 +1,38 @@
function openFile (atom, {query}) {
const {filename, line, column} = query
atom.workspace.open(filename, {
initialLine: parseInt(line || 0, 10),
initialColumn: parseInt(column || 0, 10),
searchAllPanes: true
})
}
function windowShouldOpenFile ({query}) {
const {filename} = query
return (win) => win.containsPath(filename)
}
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 (win) => true
}
}
}

View File

@@ -454,23 +454,25 @@ class Cursor extends Model {
getPreviousWordBoundaryBufferPosition (options = {}) {
const currentBufferPosition = this.getBufferPosition()
const previousNonBlankRow = this.editor.buffer.previousNonBlankRow(currentBufferPosition.row)
const scanRange = [[previousNonBlankRow || 0, 0], currentBufferPosition]
const scanRange = Range(Point(previousNonBlankRow || 0, 0), currentBufferPosition)
let beginningOfWordPosition
this.editor.backwardsScanInBufferRange(options.wordRegex || this.wordRegExp(), scanRange, ({range, stop}) => {
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) {
// 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
return Point(currentBufferPosition.row, 0)
} else if (currentBufferPosition.isGreaterThan(range.end)) {
return Point.fromObject(range.end)
} else {
beginningOfWordPosition = range.start
return Point.fromObject(range.start)
}
if (!beginningOfWordPosition.isEqual(currentBufferPosition)) stop()
})
return beginningOfWordPosition || currentBufferPosition
} else {
return currentBufferPosition
}
}
// Public: Returns buffer position of the next word boundary. It might be on
@@ -481,23 +483,24 @@ class Cursor extends Model {
// (default: {::wordRegExp})
getNextWordBoundaryBufferPosition (options = {}) {
const currentBufferPosition = this.getBufferPosition()
const scanRange = [currentBufferPosition, this.editor.getEofBufferPosition()]
const scanRange = Range(currentBufferPosition, this.editor.getEofBufferPosition())
let endOfWordPosition
this.editor.scanInBufferRange((options.wordRegex != null ? options.wordRegex : this.wordRegExp()), scanRange, function ({range, stop}) {
const range = this.editor.buffer.findInRangeSync(
options.wordRegex || this.wordRegExp(),
scanRange
)
if (range) {
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
return Point(range.start.row, 0)
} else if (currentBufferPosition.isLessThan(range.start)) {
return Point.fromObject(range.start)
} else {
endOfWordPosition = range.end
return Point.fromObject(range.end)
}
if (!endOfWordPosition.isEqual(currentBufferPosition)) stop()
})
return endOfWordPosition || currentBufferPosition
} else {
return currentBufferPosition
}
}
// Public: Retrieves the buffer position of where the current word starts.
@@ -528,7 +531,7 @@ class Cursor extends Model {
let result
for (let range of ranges) {
if (position.isLessThanOrEqual(range.start)) break
if (allowPrevious || position.isLessThanOrEqual(range.end)) result = range.start
if (allowPrevious || position.isLessThanOrEqual(range.end)) result = Point.fromObject(range.start)
}
return result || (allowPrevious ? new Point(0, 0) : position)
@@ -559,7 +562,7 @@ class Cursor extends Model {
for (let range of ranges) {
if (position.isLessThan(range.start) && !allowNext) break
if (position.isLessThan(range.end)) return range.end
if (position.isLessThan(range.end)) return Point.fromObject(range.end)
}
return allowNext ? this.editor.getEofBufferPosition() : position
@@ -594,12 +597,13 @@ class Cursor extends Model {
getCurrentWordBufferRange (options = {}) {
const position = this.getBufferPosition()
const ranges = this.editor.buffer.findAllInRangeSync(
options.wordRegex || this.wordRegExp(),
options.wordRegex || this.wordRegExp(options),
new Range(new Point(position.row, 0), new Point(position.row, Infinity))
)
return ranges.find(range =>
const range = ranges.find(range =>
range.end.column >= position.column && range.start.column <= position.column
) || new Range(position, position)
)
return range ? Range.fromObject(range) : new Range(position, position)
}
// Public: Returns the buffer Range for the current line.

View File

@@ -1,178 +0,0 @@
_ = require 'underscore-plus'
{Emitter} = require 'event-kit'
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 {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'.
@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, @decorationManager, properties) ->
@emitter = new Emitter
@id = nextId()
@setProperties properties
@destroyed = false
@markerDestroyDisposable = @marker.onDidDestroy => @destroy()
# Essential: Destroy this marker decoration.
#
# You can also destroy the marker if you own it, which will destroy this
# decoration.
destroy: ->
return if @destroyed
@markerDestroyDisposable.dispose()
@markerDestroyDisposable = null
@destroyed = true
@decorationManager.didDestroyMarkerDecoration(this)
@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.once '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)
if newProperties.type?
@decorationManager.decorationDidChangeType(this)
@decorationManager.emitDidUpdateDecorations()
@emitter.emit 'did-change-properties', {oldProperties, newProperties}
###
Section: Utility
###
inspect: ->
"<Decoration #{@id}>"
###
Section: Private methods
###
matchesPattern: (decorationPattern) ->
return false unless decorationPattern?
for key, value of decorationPattern
return false if @properties[key] isnt value
true
flash: (klass, duration=500) ->
@properties.flashRequested = true
@properties.flashClass = klass
@properties.flashDuration = duration
@decorationManager.emitDidUpdateDecorations()
@emitter.emit 'did-flash'

205
src/decoration.js Normal file
View File

@@ -0,0 +1,205 @@
const _ = require('underscore-plus')
const {Emitter} = require('event-kit')
let idCounter = 0
const nextId = () => idCounter++
// Applies changes to a decorationsParam {Object} to make it possible to
// differentiate decorations on custom gutters versus the line-number gutter.
const translateDecorationParamsOldToNew = function (decorationParams) {
if (decorationParams.type === 'line-number') {
decorationParams.gutterName = 'line-number'
}
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 (_.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.update({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 = translateDecorationParamsOldToNew(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 `<Decoration ${this.id}>`
}
/*
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')
}
}

View File

@@ -1,15 +1,7 @@
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const {join} = require('path')
const path = require('path')
const fs = require('fs-plus')
const _ = require('underscore-plus')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const fs = require('fs-plus')
const path = require('path')
const GitUtils = require('git-utils')
let nextId = 0
@@ -241,15 +233,15 @@ class GitRepository {
// * `path` The {String} path to check.
//
// Returns a {Boolean}.
isSubmodule (path) {
if (!path) return false
isSubmodule (filePath) {
if (!filePath) return false
const repo = this.getRepo(path)
if (repo.isSubmodule(repo.relativize(path))) {
const repo = this.getRepo(filePath)
if (repo.isSubmodule(repo.relativize(filePath))) {
return true
} else {
// Check if the path is a working directory in a repo that isn't the root.
return repo !== this.getRepo() && repo.relativize(join(path, 'dir')) === 'dir'
// 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'
}
}

View File

@@ -1,130 +0,0 @@
_ = require 'underscore-plus'
FirstMate = require 'first-mate'
Token = require './token'
fs = require 'fs-plus'
Grim = require 'grim'
PathSplitRegex = new RegExp("[/.]")
# 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
constructor: ({@config}={}) ->
super(maxTokensPerLine: 100, maxLineLength: 1000)
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) ->
@selectGrammarWithScore(filePath, fileContents).grammar
selectGrammarWithScore: (filePath, fileContents) ->
bestMatch = null
highestScore = -Infinity
for grammar in @grammars
score = @getGrammarScore(grammar, filePath, fileContents)
if score > highestScore or not bestMatch?
bestMatch = grammar
highestScore = score
{grammar: bestMatch, score: highestScore}
# Extended: Returns a {Number} representing how well the grammar matches the
# `filePath` and `contents`.
getGrammarScore: (grammar, filePath, contents) ->
contents = fs.readFileSync(filePath, 'utf8') if not contents? and fs.isFileSync(filePath)
score = @getGrammarPathScore(grammar, filePath)
if score > 0 and not grammar.bundledPackage
score += 0.25
if @grammarMatchesContents(grammar, contents)
score += 0.125
score
getGrammarPathScore: (grammar, filePath) ->
return -1 unless filePath
filePath = filePath.replace(/\\/g, '/') if process.platform is 'win32'
pathComponents = filePath.toLowerCase().split(PathSplitRegex)
pathScore = -1
fileTypes = grammar.fileTypes
if customFileTypes = @config.get('core.customFileTypes')?[grammar.scopeName]
fileTypes = fileTypes.concat(customFileTypes)
for fileType, i in fileTypes
fileTypeComponents = fileType.toLowerCase().split(PathSplitRegex)
pathSuffix = pathComponents[-fileTypeComponents.length..-1]
if _.isEqual(pathSuffix, fileTypeComponents)
pathScore = Math.max(pathScore, fileType.length)
if i >= grammar.fileTypes.length
pathScore += 0.5
pathScore
grammarMatchesContents: (grammar, contents) ->
return false unless contents? and grammar.firstLineRegex?
escaped = false
numberOfNewlinesInRegex = 0
for character in grammar.firstLineRegex.source
switch character
when '\\'
escaped = not escaped
when 'n'
numberOfNewlinesInRegex++ if escaped
escaped = false
else
escaped = false
lines = contents.split('\n')
grammar.firstLineRegex.testSync(lines[0..numberOfNewlinesInRegex].join('\n'))
# 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 atom.textEditors.getGrammarOverride(editor) instead'
if editor = getEditorForPath(filePath)
atom.textEditors.getGrammarOverride(editor)
# Deprecated: Set the grammar override for the given file path.
#
# * `filePath` A non-empty {String} file path.
# * `scopeName` A {String} such as `"source.js"`.
#
# Returns undefined
setGrammarOverrideForPath: (filePath, scopeName) ->
Grim.deprecate 'Use atom.textEditors.setGrammarOverride(editor, scopeName) instead'
if editor = getEditorForPath(filePath)
atom.textEditors.setGrammarOverride(editor, scopeName)
return
# Deprecated: Remove the grammar override for the given file path.
#
# * `filePath` A {String} file path.
#
# Returns undefined.
clearGrammarOverrideForPath: (filePath) ->
Grim.deprecate 'Use atom.textEditors.clearGrammarOverride(editor) instead'
if editor = getEditorForPath(filePath)
atom.textEditors.clearGrammarOverride(editor)
return
getEditorForPath = (filePath) ->
if filePath?
atom.workspace.getTextEditors().find (editor) ->
editor.getPath() is filePath

171
src/grammar-registry.js Normal file
View File

@@ -0,0 +1,171 @@
const _ = require('underscore-plus')
const FirstMate = require('first-mate')
const Token = require('./token')
const fs = require('fs-plus')
const Grim = require('grim')
const PathSplitRegex = new RegExp('[/.]')
// 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 {
constructor ({config} = {}) {
super({maxTokensPerLine: 100, maxLineLength: 1000})
this.config = config
}
createToken (value, scopes) {
return 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) {
return this.selectGrammarWithScore(filePath, fileContents).grammar
}
selectGrammarWithScore (filePath, fileContents) {
let bestMatch = null
let highestScore = -Infinity
for (let grammar of this.grammars) {
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')
}
let score = this.getGrammarPathScore(grammar, filePath)
if ((score > 0) && !grammar.bundledPackage) {
score += 0.125
}
if (this.grammarMatchesContents(grammar, contents)) {
score += 0.25
}
return score
}
getGrammarPathScore (grammar, filePath) {
if (!filePath) { return -1 }
if (process.platform === 'win32') { filePath = filePath.replace(/\\/g, '/') }
const pathComponents = filePath.toLowerCase().split(PathSplitRegex)
let pathScore = -1
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(PathSplitRegex)
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
}
grammarMatchesContents (grammar, contents) {
if ((contents == null) || (grammar.firstLineRegex == null)) { return false }
let escaped = false
let numberOfNewlinesInRegex = 0
for (let character of grammar.firstLineRegex.source) {
switch (character) {
case '\\':
escaped = !escaped
break
case 'n':
if (escaped) { numberOfNewlinesInRegex++ }
escaped = false
break
default:
escaped = false
}
}
const lines = contents.split('\n')
return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n'))
}
// 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 atom.textEditors.getGrammarOverride(editor) instead')
const editor = getEditorForPath(filePath)
if (editor) {
return atom.textEditors.getGrammarOverride(editor)
}
}
// Deprecated: Set the grammar override for the given file path.
//
// * `filePath` A non-empty {String} file path.
// * `scopeName` A {String} such as `"source.js"`.
//
// Returns undefined.
setGrammarOverrideForPath (filePath, scopeName) {
Grim.deprecate('Use atom.textEditors.setGrammarOverride(editor, scopeName) instead')
const editor = getEditorForPath(filePath)
if (editor) {
atom.textEditors.setGrammarOverride(editor, scopeName)
}
}
// Deprecated: Remove the grammar override for the given file path.
//
// * `filePath` A {String} file path.
//
// Returns undefined.
clearGrammarOverrideForPath (filePath) {
Grim.deprecate('Use atom.textEditors.clearGrammarOverride(editor) instead')
const editor = getEditorForPath(filePath)
if (editor) {
atom.textEditors.clearGrammarOverride(editor)
}
}
}
function getEditorForPath (filePath) {
if (filePath != null) {
return atom.workspace.getTextEditors().find(editor => editor.getPath() === filePath)
}
}

View File

@@ -1,87 +0,0 @@
{Emitter} = require 'event-kit'
Gutter = require './gutter'
module.exports =
class GutterContainer
constructor: (textEditor) ->
@gutters = []
@textEditor = textEditor
@emitter = new Emitter
scheduleComponentUpdate: ->
@textEditor.scheduleComponentUpdate()
destroy: ->
# Create a copy, because `Gutter::destroy` removes the gutter from
# GutterContainer's @gutters.
guttersToDestroy = @gutters.slice(0)
for gutter in guttersToDestroy
gutter.destroy() if gutter.name isnt 'line-number'
@gutters = []
@emitter.dispose()
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
@scheduleComponentUpdate()
@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
observeGutters: (callback) ->
callback(gutter) for gutter in @getGutters()
@onDidAddGutter callback
onDidAddGutter: (callback) ->
@emitter.on 'did-add-gutter', callback
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)
@scheduleComponentUpdate()
@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)

108
src/gutter-container.js Normal file
View File

@@ -0,0 +1,108 @@
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.name === 'line-number') {
options.type = 'line-number'
} else {
options.type = 'gutter'
}
options.gutterName = gutter.name
return this.textEditor.decorateMarker(marker, options)
}
}

View File

@@ -1,95 +0,0 @@
{Emitter} = require 'event-kit'
CustomGutterComponent = null
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) ->
@gutterContainer = gutterContainer
@name = options?.name
@priority = options?.priority ? DefaultPriority
@visible = options?.visible ? true
@emitter = new Emitter
###
Section: Gutter Destruction
###
# Essential: Destroys the gutter.
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()
###
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) ->
@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) ->
@emitter.once 'did-destroy', callback
###
Section: Visibility
###
# Essential: Hide the gutter.
hide: ->
if @visible
@visible = false
@gutterContainer.scheduleComponentUpdate()
@emitter.emit 'did-change-visible', this
# Essential: Show the gutter.
show: ->
if not @visible
@visible = true
@gutterContainer.scheduleComponentUpdate()
@emitter.emit 'did-change-visible', this
# Essential: Determine whether the gutter is visible.
#
# Returns a {Boolean}.
isVisible: ->
@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) ->
@gutterContainer.addGutterDecoration(this, marker, options)
getElement: ->
@element ?= document.createElement('div')

107
src/gutter.js Normal file
View File

@@ -0,0 +1,107 @@
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.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
}
}

View File

@@ -128,7 +128,7 @@ class ApplicationMenu
]
focusedWindow: ->
_.find global.atomApplication.windows, (atomWindow) -> atomWindow.isFocused()
_.find global.atomApplication.getAllWindows(), (atomWindow) -> atomWindow.isFocused()
# Combines a menu template with the appropriate keystroke.
#

View File

@@ -67,7 +67,7 @@ class AtomApplication
{@resourcePath, @devResourcePath, @version, @devMode, @safeMode, @socketPath, @logFile, @userDataDir} = options
@socketPath = null if options.test or options.benchmark or options.benchmarkTest
@pidsToOpenWindows = {}
@windows = []
@windowStack = new WindowStack()
@config = new Config({enablePersistence: true})
@config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)}
@@ -114,7 +114,7 @@ class AtomApplication
@launch(options)
destroy: ->
windowsClosePromises = @windows.map (window) ->
windowsClosePromises = @getAllWindows().map (window) ->
window.close()
window.closedPromise
Promise.all(windowsClosePromises).then(=> @disposable.dispose())
@@ -162,8 +162,8 @@ class AtomApplication
# Public: Removes the {AtomWindow} from the global window list.
removeWindow: (window) ->
@windows.splice(@windows.indexOf(window), 1)
if @windows.length is 0
@windowStack.removeWindow(window)
if @getAllWindows().length is 0
@applicationMenu?.enableWindowSpecificItems(false)
if process.platform in ['win32', 'linux']
app.quit()
@@ -172,22 +172,28 @@ class AtomApplication
# Public: Adds the {AtomWindow} to the global window list.
addWindow: (window) ->
@windows.push window
@windowStack.addWindow(window)
@applicationMenu?.addWindow(window.browserWindow)
window.once 'window:loaded', =>
@autoUpdateManager?.emitUpdateAvailableEvent(window)
unless window.isSpec
focusHandler = => @lastFocusedWindow = window
focusHandler = => @windowStack.touch(window)
blurHandler = => @saveState(false)
window.browserWindow.on 'focus', focusHandler
window.browserWindow.on 'blur', blurHandler
window.browserWindow.once 'closed', =>
@lastFocusedWindow = null if window is @lastFocusedWindow
@windowStack.removeWindow(window)
window.browserWindow.removeListener 'focus', focusHandler
window.browserWindow.removeListener 'blur', blurHandler
window.browserWindow.webContents.once 'did-finish-load', => @saveState(false)
getAllWindows: =>
@windowStack.all().slice()
getLastFocusedWindow: (predicate) =>
@windowStack.getLastFocusedWindow(predicate)
# Creates server to listen for additional atom application launches.
#
# You can run the atom command multiple times, but after the first launch
@@ -276,7 +282,7 @@ class AtomApplication
else
event.preventDefault()
@quitting = true
windowUnloadPromises = @windows.map((window) -> window.prepareToUnload())
windowUnloadPromises = @getAllWindows().map((window) -> window.prepareToUnload())
Promise.all(windowUnloadPromises).then((windowUnloadedResults) ->
didUnloadAllWindows = windowUnloadedResults.every((didUnloadWindow) -> didUnloadWindow)
app.quit() if didUnloadAllWindows
@@ -309,7 +315,7 @@ class AtomApplication
event.sender.send('did-resolve-proxy', requestId, proxy)
@disposable.add ipcHelpers.on ipcMain, 'did-change-history-manager', (event) =>
for atomWindow in @windows
for atomWindow in @getAllWindows()
webContents = atomWindow.browserWindow.webContents
if webContents isnt event.sender
webContents.send('did-change-history-manager')
@@ -483,7 +489,7 @@ class AtomApplication
# Returns the {AtomWindow} for the given paths.
windowForPaths: (pathsToOpen, devMode) ->
_.find @windows, (atomWindow) ->
_.find @getAllWindows(), (atomWindow) ->
atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen)
# Returns the {AtomWindow} for the given ipcMain event.
@@ -491,11 +497,11 @@ class AtomApplication
@atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender))
atomWindowForBrowserWindow: (browserWindow) ->
@windows.find((atomWindow) -> atomWindow.browserWindow is browserWindow)
@getAllWindows().find((atomWindow) -> atomWindow.browserWindow is browserWindow)
# Public: Returns the currently focused {AtomWindow} or undefined if none.
focusedWindow: ->
_.find @windows, (atomWindow) -> atomWindow.isFocused()
_.find @getAllWindows(), (atomWindow) -> atomWindow.isFocused()
# Get the platform-specific window offset for new windows.
getWindowOffsetForCurrentPlatform: ->
@@ -507,8 +513,8 @@ class AtomApplication
# Get the dimensions for opening a new window by cascading as appropriate to
# the platform.
getDimensionsForNewWindow: ->
return if (@focusedWindow() ? @lastFocusedWindow)?.isMaximized()
dimensions = (@focusedWindow() ? @lastFocusedWindow)?.getDimensions()
return if (@focusedWindow() ? @getLastFocusedWindow())?.isMaximized()
dimensions = (@focusedWindow() ? @getLastFocusedWindow())?.getDimensions()
offset = @getWindowOffsetForCurrentPlatform()
if dimensions? and offset?
dimensions.x += offset
@@ -554,7 +560,7 @@ class AtomApplication
existingWindow = @windowForPaths(pathsToOpen, devMode)
stats = (fs.statSyncNoException(pathToOpen) for pathToOpen in pathsToOpen)
unless existingWindow?
if currentWindow = window ? @lastFocusedWindow
if currentWindow = window ? @getLastFocusedWindow()
existingWindow = currentWindow if (
addToLastWindow or
currentWindow.devMode is devMode and
@@ -583,7 +589,7 @@ class AtomApplication
windowDimensions ?= @getDimensionsForNewWindow()
openedWindow = new AtomWindow(this, @fileRecoveryService, {initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env})
openedWindow.focus()
@lastFocusedWindow = openedWindow
@windowStack.addWindow(openedWindow)
if pidToKillWhenClosed?
@pidsToOpenWindows[pidToKillWhenClosed] = openedWindow
@@ -617,9 +623,10 @@ class AtomApplication
saveState: (allowEmpty=false) ->
return if @quitting
states = []
for window in @windows
for window in @getAllWindows()
unless window.isSpec
states.push({initialPaths: window.representedDirectoryPaths})
states.reverse()
if states.length > 0 or allowEmpty
@storageFolder.storeSync('application.json', states)
@emit('application:did-save-state')
@@ -648,6 +655,50 @@ class AtomApplication
# :devMode - Boolean to control the opened window's dev mode.
# :safeMode - Boolean to control the opened window's safe mode.
openUrl: ({urlToOpen, devMode, safeMode, env}) ->
parsedUrl = url.parse(urlToOpen, true)
return unless parsedUrl.protocol is "atom:"
pack = @findPackageWithName(parsedUrl.host, devMode)
if pack?.urlMain
@openPackageUrlMain(parsedUrl.host, pack.urlMain, urlToOpen, devMode, safeMode, env)
else
@openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env)
openPackageUriHandler: (url, parsedUrl, devMode, safeMode, env) ->
bestWindow = null
if parsedUrl.host is 'core'
predicate = require('../core-uri-handlers').windowPredicate(parsedUrl)
bestWindow = @getLastFocusedWindow (win) ->
not win.isSpecWindow() and predicate(win)
bestWindow ?= @getLastFocusedWindow (win) -> not win.isSpecWindow()
if bestWindow?
bestWindow.sendURIMessage url
bestWindow.focus()
else
resourcePath = @resourcePath
if devMode
try
windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window'))
resourcePath = @devResourcePath
windowInitializationScript ?= require.resolve('../initialize-application-window')
windowDimensions = @getDimensionsForNewWindow()
win = new AtomWindow(this, @fileRecoveryService, {resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env})
@windowStack.addWindow(win)
win.on 'window:loaded', ->
win.sendURIMessage url
findPackageWithName: (packageName, devMode) ->
_.find @getPackageManager(devMode).getAvailablePackageMetadata(), ({name}) -> name is packageName
openPackageUrlMain: (packageName, packageUrlMain, urlToOpen, devMode, safeMode, env) ->
packagePath = @getPackageManager(devMode).resolvePackagePath(packageName)
windowInitializationScript = path.resolve(packagePath, packageUrlMain)
windowDimensions = @getDimensionsForNewWindow()
new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env})
getPackageManager: (devMode) ->
unless @packages?
PackageManager = require '../package-manager'
@packages = new PackageManager({})
@@ -656,18 +707,8 @@ class AtomApplication
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)
windowInitializationScript = path.resolve(packagePath, pack.urlMain)
windowDimensions = @getDimensionsForNewWindow()
new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env})
else
console.log "Package '#{pack.name}' does not have a url main: #{urlToOpen}"
else
console.log "Opening unknown url: #{urlToOpen}"
@packages
# Opens up a new {AtomWindow} to run specs within.
#
@@ -842,7 +883,7 @@ class AtomApplication
disableZoomOnDisplayChange: ->
outerCallback = =>
for window in @windows
for window in @getAllWindows()
window.disableZoom()
# Set the limits every time a display is added or removed, otherwise the
@@ -853,3 +894,24 @@ class AtomApplication
new Disposable ->
screen.removeListener('display-added', outerCallback)
screen.removeListener('display-removed', outerCallback)
class WindowStack
constructor: (@windows = []) ->
addWindow: (window) =>
@removeWindow(window)
@windows.unshift(window)
touch: (window) =>
@addWindow(window)
removeWindow: (window) =>
currentIndex = @windows.indexOf(window)
@windows.splice(currentIndex, 1) if currentIndex > -1
getLastFocusedWindow: (predicate) =>
predicate ?= (win) -> true
@windows.find(predicate)
all: =>
@windows

View File

@@ -232,6 +232,9 @@ class AtomWindow
unless @atomApplication.sendCommandToFirstResponder(command)
@sendCommandToBrowserWindow(command, args...)
sendURIMessage: (uri) ->
@browserWindow.webContents.send 'uri-message', uri
sendCommandToBrowserWindow: (command, args...) ->
action = if args[0]?.contextCommand then 'context-command' else 'command'
@browserWindow.webContents.send action, command, args...

View File

@@ -138,4 +138,4 @@ class AutoUpdateManager
detail: message
getWindows: ->
global.atomApplication.windows
global.atomApplication.getAllWindows()

View File

@@ -58,8 +58,18 @@ module.exports = function parseCommandLine (processArgs) {
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')
const args = options.argv
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)
}
}
if (args.help) {
process.stdout.write(options.help())
@@ -101,8 +111,8 @@ module.exports = function parseCommandLine (processArgs) {
const userDataDir = args['user-data-dir']
const profileStartup = args['profile-startup']
const clearWindowState = args['clear-window-state']
const pathsToOpen = []
const urlsToOpen = []
let pathsToOpen = []
let urlsToOpen = []
let devMode = args['dev']
let devResourcePath = process.env.ATOM_DEV_RESOURCE_PATH || path.join(app.getPath('home'), 'github', 'atom')
let resourcePath = null

View File

@@ -31,7 +31,8 @@ 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
commandRegistry: this.commandRegistry, grammarRegistry: this.grammarRegistry, deserializerManager: this.deserializerManager, viewRegistry: this.viewRegistry,
uriHandlerRegistry: this.uriHandlerRegistry
} = params)
this.emitter = new Emitter()
@@ -647,6 +648,10 @@ module.exports = class PackageManager {
})
}
registerURIHandlerForPackage (packageName, handler) {
return this.uriHandlerRegistry.registerHostHandler(packageName, handler)
}
// another type of package manager can handle other package types.
// See ThemeManager
registerPackageActivator (activator, types) {

View File

@@ -84,6 +84,7 @@ class Package
@loadMenus()
@registerDeserializerMethods()
@activateCoreStartupServices()
@registerURIHandler()
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
@requireMainModule()
@settingsPromise = @loadSettings()
@@ -114,6 +115,7 @@ class Package
@loadStylesheets()
@registerDeserializerMethods()
@activateCoreStartupServices()
@registerURIHandler()
@registerTranspilerConfig()
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
@settingsPromise = @loadSettings()
@@ -318,6 +320,19 @@ class Package
@activationDisposables.add @packageManager.serviceHub.consume(name, version, @mainModule[methodName].bind(@mainModule))
return
registerURIHandler: ->
handlerConfig = @getURIHandler()
if methodName = handlerConfig?.method
@uriHandlerSubscription = @packageManager.registerURIHandlerForPackage @name, (args...) =>
@handleURI(methodName, args)
unregisterURIHandler: ->
@uriHandlerSubscription?.dispose()
handleURI: (methodName, args) ->
@activate().then => @mainModule[methodName]?.apply(@mainModule, args)
@activateNow() unless @mainActivated
registerTranspilerConfig: ->
if @metadata.atomTranspilers
CompileCache.addTranspilerConfigForPath(@path, @name, @metadata, @metadata.atomTranspilers)
@@ -504,6 +519,7 @@ class Package
@activationCommandSubscriptions?.dispose()
@activationHookSubscriptions?.dispose()
@configSchemaRegisteredOnActivate = false
@unregisterURIHandler()
@deactivateResources()
@deactivateKeymaps()
@@ -595,7 +611,7 @@ class Package
@mainModulePath = fs.resolveExtension(mainModulePath, ["", CompileCache.supportedExtensions...])
activationShouldBeDeferred: ->
@hasActivationCommands() or @hasActivationHooks()
@hasActivationCommands() or @hasActivationHooks() or @hasDeferredURIHandler()
hasActivationHooks: ->
@getActivationHooks()?.length > 0
@@ -605,6 +621,9 @@ class Package
return true if commands.length > 0
false
hasDeferredURIHandler: ->
@getURIHandler() and @getURIHandler().deferActivation isnt false
subscribeToDeferredActivation: ->
@subscribeToActivationCommands()
@subscribeToActivationHooks()
@@ -673,6 +692,9 @@ class Package
@activationHooks = _.uniq(@activationHooks)
getURIHandler: ->
@metadata?.uriHandler
# Does the given module path contain native code?
isNativeModule: (modulePath) ->
try

View File

@@ -4,7 +4,7 @@ const fs = require('fs')
const path = require('path')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const nsfw = require('nsfw')
const nsfw = require('@atom/nsfw')
const {NativeWatcherRegistry} = require('./native-watcher-registry')
// Private: Associate native watcher action flags with descriptive String equivalents.

View File

@@ -1,565 +0,0 @@
path = require 'path'
_ = require 'underscore-plus'
fs = require 'fs-plus'
{Emitter, Disposable} = require 'event-kit'
TextBuffer = require 'text-buffer'
{watchPath} = require('./path-watcher')
DefaultDirectoryProvider = require './default-directory-provider'
Model = require './model'
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}) ->
@emitter = new Emitter
@buffers = []
@rootDirectories = []
@repositories = []
@directoryProviders = []
@defaultDirectoryProvider = new DefaultDirectoryProvider()
@repositoryPromisesByPath = new Map()
@repositoryProviders = [new GitRepositoryProvider(this, config)]
@loadPromisesByPath = {}
@watcherPromisesByPath = {}
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
@consumeServices(packageManager)
destroyed: ->
buffer.destroy() for buffer in @buffers.slice()
repository?.destroy() for repository in @repositories.slice()
watcher.dispose() for _, watcher in @watcherPromisesByPath
@rootDirectories = []
@repositories = []
reset: (packageManager) ->
@emitter.dispose()
@emitter = new Emitter
buffer?.destroy() for buffer in @buffers
@buffers = []
@setPaths([])
@loadPromisesByPath = {}
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
@consumeServices(packageManager)
destroyUnretainedBuffers: ->
buffer.destroy() for buffer in @getBuffers() when not buffer.isRetained()
return
###
Section: Serialization
###
deserialize: (state) ->
@retiredBufferIDs = new Set()
@retiredBufferPaths = new Set()
handleBufferState = (bufferState) =>
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 isnt false
TextBuffer.deserialize(bufferState).catch (err) =>
@retiredBufferIDs.add(bufferState.id)
@retiredBufferPaths.add(bufferState.filePath)
null
bufferPromises = (handleBufferState(bufferState) for bufferState in state.buffers)
Promise.all(bufferPromises).then (buffers) =>
@buffers = buffers.filter(Boolean)
@subscribeToBuffer(buffer) for buffer in @buffers
@setPaths(state.paths or [], mustExist: true, exact: true)
serialize: (options={}) ->
deserializer: 'Project'
paths: @getPaths()
buffers: _.compact(@buffers.map (buffer) ->
if buffer.isRetained()
isUnloading = options.isUnloading is true
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) ->
@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) ->
@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) ->
callback(buffer) for buffer in @getBuffers()
@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.type}`)
#
# // absolute path to the filesystem entry that was touched
# console.log(`Event path: ${event.path}`)
#
# if (event.type === '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) ->
@emitter.on 'did-change-files', 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(atom.project.getDirectories().map(
# atom.project.repositoryForDirectory.bind(atom.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?.onDidDestroy?(=> @repositoryPromisesByPath.delete(pathForDirectory))
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.
# * `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 = {}) ->
repository?.destroy() for repository in @repositories
@rootDirectories = []
@repositories = []
watcher.then((w) -> w.dispose()) for _, watcher in @watcherPromisesByPath
@watcherPromisesByPath = {}
missingProjectPaths = []
for projectPath in projectPaths
try
@addPath projectPath, emitEvent: false, mustExist: true, exact: options.exact is true
catch e
if e.missingProjectPaths?
missingProjectPaths.push e.missingProjectPaths...
else
throw e
@emitter.emit 'did-change-paths', projectPaths
if options.mustExist is true and missingProjectPaths.length > 0
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 = {}) ->
directory = @getDirectoryForProjectPath(projectPath)
ok = true
ok = ok and directory.getPath() is projectPath if options.exact is true
ok = ok and directory.existsSync()
unless ok
if options.mustExist is true
err = new Error "Project directory #{directory} does not exist"
err.missingProjectPaths = [projectPath]
throw err
else
return
for existingDirectory in @getDirectories()
return if existingDirectory.getPath() is directory.getPath()
@rootDirectories.push(directory)
@watcherPromisesByPath[directory.getPath()] = watchPath directory.getPath(), {}, (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 @rootDirectories.includes directory
@emitter.emit 'did-change-files', events
for root, watcherPromise in @watcherPromisesByPath
unless @rootDirectories.includes root
watcherPromise.then (watcher) -> watcher.dispose()
repo = null
for provider in @repositoryProviders
break if repo = provider.repositoryForDirectorySync?(directory)
@repositories.push(repo ? null)
unless options.emitEvent is false
@emitter.emit 'did-change-paths', @getPaths()
getDirectoryForProjectPath: (projectPath) ->
directory = null
for provider in @directoryProviders
break if directory = provider.directoryForURISync?(projectPath)
directory ?= @defaultDirectoryProvider.directoryForURISync(projectPath)
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) ->
@watcherPromisesByPath[projectPath] or
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.
unless projectPath in @getPaths()
projectPath = @defaultDirectoryProvider.normalizePath(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)
removedRepository?.destroy() unless removedRepository in @repositories
@watcherPromisesByPath[projectPath]?.then (w) -> w.dispose()
delete @watcherPromisesByPath[projectPath]
@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)
@defaultDirectoryProvider.normalizePath(fs.resolveHome(uri))
# TODO: what should we do here when there are multiple directories?
else if projectPath = @getPaths()[0]
@defaultDirectoryProvider.normalizePath(fs.resolveHome(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) ->
result = [null, fullPath]
if fullPath?
for rootDirectory in @rootDirectories
relativePath = rootDirectory.relativize(fullPath)
if relativePath?.length < result[1].length
result = [rootDirectory.getPath(), relativePath]
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) ->
@rootDirectories.some (dir) -> dir.contains(pathToCheck)
###
Section: Private
###
consumeServices: ({serviceHub}) ->
serviceHub.consume(
'atom.directory-provider',
'^0.1.0',
(provider) =>
@directoryProviders.unshift(provider)
new Disposable =>
@directoryProviders.splice(@directoryProviders.indexOf(provider), 1)
)
serviceHub.consume(
'atom.repository-provider',
'^0.1.0',
(provider) =>
@repositoryProviders.unshift(provider)
@setPaths(@getPaths()) if null in @repositories
new Disposable =>
@repositoryProviders.splice(@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: ->
@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
findBufferForId: (id) ->
_.find @buffers, (buffer) -> buffer.getId() is id
# Only to be used in specs
bufferForPathSync: (filePath) ->
absoluteFilePath = @resolvePath(filePath)
return null if @retiredBufferPaths.has absoluteFilePath
existingBuffer = @findBufferForPath(absoluteFilePath) if filePath
existingBuffer ? @buildBufferSync(absoluteFilePath)
# Only to be used when deserializing
bufferForIdSync: (id) ->
return null if @retiredBufferIDs.has id
existingBuffer = @findBufferForId(id) if id
existingBuffer ? @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) ->
existingBuffer = @findBufferForPath(absoluteFilePath) if absoluteFilePath?
if existingBuffer
Promise.resolve(existingBuffer)
else
@buildBuffer(absoluteFilePath)
shouldDestroyBufferOnFileDelete: ->
atom.config.get('core.closeDeletedFileTabs')
# Still needed when deserializing a tokenized buffer
buildBufferSync: (absoluteFilePath) ->
params = {shouldDestroyOnFileDelete: @shouldDestroyBufferOnFileDelete}
if absoluteFilePath?
buffer = TextBuffer.loadSync(absoluteFilePath, params)
else
buffer = new TextBuffer(params)
@addBuffer(buffer)
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) ->
params = {shouldDestroyOnFileDelete: @shouldDestroyBufferOnFileDelete}
if absoluteFilePath?
promise =
@loadPromisesByPath[absoluteFilePath] ?=
TextBuffer.load(absoluteFilePath, params).catch (error) =>
delete @loadPromisesByPath[absoluteFilePath]
throw error
else
promise = Promise.resolve(new TextBuffer(params))
promise.then (buffer) =>
delete @loadPromisesByPath[absoluteFilePath]
@addBuffer(buffer)
buffer
addBuffer: (buffer, options={}) ->
@addBufferAtIndex(buffer, @buffers.length, options)
addBufferAtIndex: (buffer, index, options={}) ->
@buffers.splice(index, 0, buffer)
@subscribeToBuffer(buffer)
@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()
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.onWillSave ({path}) => @applicationDelegate.emitWillSavePath(path)
buffer.onDidSave ({path}) => @applicationDelegate.emitDidSavePath(path)
buffer.onDidDestroy => @removeBuffer(buffer)
buffer.onDidChangePath =>
unless @getPaths().length > 0
@setPaths([path.dirname(buffer.getPath())])
buffer.onWillThrowWatchError ({error, handle}) =>
handle()
@notificationManager.addWarning """
Unable to read file after file `#{error.eventType}` event.
Make sure you have permission to access `#{buffer.getPath()}`.
""",
detail: error.message
dismissable: true

713
src/project.js Normal file
View File

@@ -0,0 +1,713 @@
const path = require('path')
const _ = require('underscore-plus')
const fs = require('fs-plus')
const {Emitter, Disposable} = 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}) {
super()
this.notificationManager = notificationManager
this.applicationDelegate = applicationDelegate
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.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()
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()
}
}
/*
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.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.type}`)
//
// // absolute path to the filesystem entry that was touched
// console.log(`Event path: ${event.path}`)
//
// if (event.type === '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)
}
/*
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(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 {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) {
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 () {
return this.rootDirectories.map((rootDirectory) => rootDirectory.getPath())
}
// 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 (options.emitEvent !== false) {
this.emitter.emit('did-change-paths', this.getPaths())
}
}
getDirectoryForProjectPath (projectPath) {
let directory = null
for (let provider of this.directoryProviders) {
if (typeof provider.directoryForURISync === 'function') {
directory = provider.directoryForURISync(projectPath)
if (directory) break
}
}
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}.
buildBuffer (absoluteFilePath) {
const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}
let promise
if (absoluteFilePath != null) {
if (this.loadPromisesByPath[absoluteFilePath] == null) {
this.loadPromisesByPath[absoluteFilePath] =
TextBuffer.load(absoluteFilePath, params).catch(error => {
delete this.loadPromisesByPath[absoluteFilePath]
throw error
})
}
promise = this.loadPromisesByPath[absoluteFilePath]
} else {
promise = Promise.resolve(new TextBuffer(params))
}
return promise.then(buffer => {
delete this.loadPromisesByPath[absoluteFilePath]
this.addBuffer(buffer)
return buffer
})
}
addBuffer (buffer, options = {}) {
return this.addBufferAtIndex(buffer, this.buffers.length, options)
}
addBufferAtIndex (buffer, index, options = {}) {
this.buffers.splice(index, 0, 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(({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
})
})
}
}

View File

@@ -0,0 +1,92 @@
const {remote} = 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)
}
isDefaultProtocolClient () {
return remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler'])
}
setAsDefaultProtocolClient () {
// This Electron API is only available on Windows and macOS. There might be some
// hacks to make it work on Linux; see https://github.com/electron/electron/issues/6440
return this.isSupported() && remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler'])
}
initialize (config, notifications) {
if (!this.isSupported()) {
return
}
if (!this.isDefaultProtocolClient()) {
const behaviorWhenNotProtocolClient = config.get(SETTING)
switch (behaviorWhenNotProtocolClient) {
case PROMPT:
this.promptToBecomeProtocolClient(config, notifications)
break
case ALWAYS:
this.setAsDefaultProtocolClient()
break
case NEVER:
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 defaut 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)
}
]
})
}
}

View File

@@ -174,6 +174,11 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
'core:cut': -> @cutSelectedText()
'core:copy': -> @copySelectedText()
'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()

View File

@@ -1,834 +0,0 @@
{Point, Range} = require 'text-buffer'
{pick} = require 'underscore-plus'
{Emitter} = require 'event-kit'
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) => @markerDidChange(e)
@marker.onDidDestroy => @markerDidDestroy()
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.once '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.
#
# * `bufferRange` 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.destroyFoldsIntersectingBufferRange(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) ->
@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, options) ->
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], reversed: false)
else
@cursor.setScreenPosition(position, options)
if @linewise
@expandOverLine(options)
else if @wordwise
@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) ->
@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 screen line.
selectToEndOfLine: ->
@modifySelection => @cursor.moveToEndOfScreenLine()
# Public: Selects all the text from the current cursor position to the end of
# the buffer line.
selectToEndOfBufferLine: ->
@modifySelection => @cursor.moveToEndOfLine()
# 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 text to the previous subword boundary.
selectToPreviousSubwordBoundary: ->
@modifySelection => @cursor.moveToPreviousSubwordBoundary()
# Public: Selects text to the next subword boundary.
selectToNextSubwordBoundary: ->
@modifySelection => @cursor.moveToNextSubwordBoundary()
# 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), options)
@wordwise = true
@initialScreenRange = @getScreenRange()
# Public: Expands the newest selection to include the entire word on which
# the cursors rests.
expandOverWord: (options) ->
@setBufferRange(@getBufferRange().union(@cursor.getCurrentWordBufferRange()), autoscroll: false)
@cursor.autoscroll() if options?.autoscroll ? true
# 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?
@setBufferRange(@editor.bufferRangeForBufferRow(row, includeNewline: true), options)
else
startRange = @editor.bufferRangeForBufferRow(@marker.getStartBufferPosition().row)
endRange = @editor.bufferRangeForBufferRow(@marker.getEndBufferPosition().row, includeNewline: true)
@setBufferRange(startRange.union(endRange), options)
@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: (options) ->
range = @getBufferRange().union(@cursor.getCurrentLineBufferRange(includeNewline: true))
@setBufferRange(range, autoscroll: false)
@cursor.autoscroll() if options?.autoscroll ? 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).
# * `normalizeLineEndings` (optional) {Boolean} (default: true)
# * `undo` if `skip`, skips the undo stack for this operation.
insertText: (text, options={}) ->
oldBufferRange = @getBufferRange()
wasReversed = @isReversed()
@clear(options)
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)
textIsAutoIndentable = text is '\n' or text is '\r\n' or NonWhitespaceRegExp.test(text)
if options.autoIndent and textIsAutoIndentable and not NonWhitespaceRegExp.test(precedingText) and remainingLines.length > 0
autoIndentFirstLine = true
firstLine = precedingText + firstInsertedLine
desiredIndentLevel = @editor.tokenizedBuffer.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) if wasReversed
if autoIndentFirstLine
@editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel)
if options.autoIndentNewline and text is '\n'
@editor.autoIndentBufferRow(newBufferRange.end.row, preserveLeadingWhitespace: true, skipBlankLines: false)
else if options.autoDecreaseIndent and NonWhitespaceRegExp.test(text)
@editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row)
@autoscroll() if options.autoscroll ? @isLastSelection()
newBufferRange
# Public: Removes the first character before the selection if the selection
# is empty otherwise it deletes the selection.
backspace: ->
@selectLeft() if @isEmpty()
@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: ->
@selectRight() if @isEmpty()
@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 the selection or all characters from the start of the
# selection to the end of the current word if nothing is selected.
deleteToBeginningOfSubword: ->
@selectToPreviousSubwordBoundary() 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.
deleteToEndOfSubword: ->
@selectToNextSubwordBoundary() if @isEmpty()
@deleteSelectedText()
# Public: Removes only the selected text.
deleteSelectedText: ->
bufferRange = @getBufferRange()
@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, invalidate: 'never')
rowCount = Math.max(1, selectedRange.getRowCount() - 1)
for [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 screen line.
cutToEndOfLine: (maintainClipboard) ->
@selectToEndOfLine() if @isEmpty()
@cut(maintainClipboard)
# Public: Cuts the selection until the end of the buffer line.
cutToEndOfBufferLine: (maintainClipboard) ->
@selectToEndOfBufferLine() 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} = @editor.constructor.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
})
@editor.constructor.clipboard.write([clipboardText, selectionText].join("\n"), metadata)
else
@editor.constructor.clipboard.write(selectionText, {
indentBasis: startLevel,
fullLine: fullLine
})
# Public: Creates a fold containing the current selection.
fold: ->
range = @getBufferRange()
unless range.isEmpty()
@editor.foldBufferRange(range)
@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 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} = @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().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()
selection = @editor.addSelectionForScreenRange(clippedRange)
selection.setGoalScreenRange(range)
break
return
# Public: Moves the selection up one row.
addSelectionAbove: ->
range = @getGoalScreenRange().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()
selection = @editor.addSelectionForScreenRange(clippedRange)
selection.setGoalScreenRange(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()), 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) ->
@marker.compare(otherSelection.marker)
###
Section: Private Utilities
###
setGoalScreenRange: (range) ->
@goalScreenRange = Range.fromObject(range)
getGoalScreenRange: ->
@goalScreenRange ? @getScreenRange()
markerDidChange: (e) ->
{oldHeadBufferPosition, oldTailBufferPosition, newHeadBufferPosition} = e
{oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e
{textChanged} = e
unless oldHeadScreenPosition.isEqual(newHeadScreenPosition)
@cursor.goalColumn = null
cursorMovedEvent = {
oldBufferPosition: oldHeadBufferPosition
oldScreenPosition: oldHeadScreenPosition
newBufferPosition: newHeadBufferPosition
newScreenPosition: newHeadScreenPosition
textChanged: textChanged
cursor: @cursor
}
@cursor.emitter.emit('did-change-position', cursorMovedEvent)
@editor.cursorMoved(cursorMovedEvent)
@emitter.emit 'did-change-range'
@editor.selectionRangeChanged(
oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition)
oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition)
newBufferRange: @getBufferRange()
newScreenRange: @getScreenRange()
selection: this
)
markerDidDestroy: ->
return if @editor.isDestroyed()
@destroyed = true
@cursor.destroyed = true
@editor.removeSelection(this)
@cursor.emitter.emit 'did-destroy'
@emitter.emit 'did-destroy'
@cursor.emitter.dispose()
@emitter.dispose()
finalize: ->
@initialScreenRange = null unless @initialScreenRange?.isEqual(@getScreenRange())
if @isEmpty()
@wordwise = false
@linewise = false
autoscroll: (options) ->
if @marker.hasTail()
@editor.scrollToScreenRange(@getScreenRange(), Object.assign({reversed: @isReversed()}, options))
else
@cursor.autoscroll(options)
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()

977
src/selection.js Normal file
View File

@@ -0,0 +1,977 @@
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:
// * `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()
}
/*
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` If `skip`, skips the undo stack for this operation.
insertText (text, options = {}) {
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
desiredIndentLevel = this.editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine)
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.
backspace () {
if (this.isEmpty()) this.selectLeft()
this.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 () {
if (this.isEmpty()) this.selectToPreviousWordBoundary()
this.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 () {
if (this.isEmpty()) this.selectToNextWordBoundary()
this.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 () {
if (this.isEmpty()) this.selectToBeginningOfWord()
this.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 (this.isEmpty() && this.cursor.isAtBeginningOfLine()) {
this.selectLeft()
} else {
this.selectToBeginningOfLine()
}
this.deleteSelectedText()
}
// Public: Removes the selection or the next character after the start of the
// selection if the selection is empty.
delete () {
if (this.isEmpty()) this.selectRight()
this.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 () {
if (this.isEmpty()) {
if (this.cursor.isAtEndOfLine()) {
this.delete()
return
}
this.selectToEndOfLine()
}
this.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 () {
if (this.isEmpty()) this.selectToEndOfWord()
this.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.
deleteToBeginningOfSubword () {
if (this.isEmpty()) this.selectToPreviousSubwordBoundary()
this.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.
deleteToEndOfSubword () {
if (this.isEmpty()) this.selectToNextSubwordBoundary()
this.deleteSelectedText()
}
// Public: Removes only the selected text.
deleteSelectedText () {
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.
deleteLine () {
if (this.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 range = this.getBufferRange()
const start = range.start.row
let end = range.end.row
if (end !== this.editor.buffer.getLastRow() && range.end.column === 0) end--
this.editor.buffer.deleteRows(start, end)
}
}
// 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 () {
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()
}
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(' ')
this.cursor.moveToEndOfLine()
// Remove leading whitespace from the line below
this.modifySelection(() => {
this.cursor.moveRight()
this.cursor.moveToFirstCharacterOfLine()
})
this.deleteSelectedText()
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.
outdentSelectedRows () {
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.
autoIndentSelectedRows () {
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.
toggleLineComments () {
this.editor.toggleLineCommentsForBufferRows(...(this.getBufferRowRange() || []))
}
// Public: Cuts the selection until the end of the screen line.
cutToEndOfLine (maintainClipboard) {
if (this.isEmpty()) this.selectToEndOfLine()
return this.cut(maintainClipboard)
}
// Public: Cuts the selection until the end of the buffer line.
cutToEndOfBufferLine (maintainClipboard) {
if (this.isEmpty()) this.selectToEndOfBufferLine()
this.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) {
this.copy(maintainClipboard, fullLine)
this.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) {
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.
indent ({autoIndent} = {}) {
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))
} else {
this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn()))
}
} else {
this.indentSelectedRows()
}
}
// Public: If the selection spans multiple rows, indent all of them.
indentSelectedRows () {
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 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 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)
}
this.emitter.emit('did-change-range')
this.editor.selectionRangeChanged({
oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition),
oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition),
newBufferRange: this.getBufferRange(),
newScreenRange: this.getScreenRange(),
selection: this
})
}
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()
}
}

View File

@@ -126,7 +126,6 @@ class TextEditorComponent {
this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this))
this.lineComponentsByScreenLineId = new Map()
this.overlayComponents = new Set()
this.overlayDimensionsByElement = new WeakMap()
this.shouldRenderDummyScrollbars = true
this.remeasureScrollbars = false
this.pendingAutoscroll = null
@@ -810,8 +809,10 @@ class TextEditorComponent {
{
key: overlayProps.element,
overlayComponents: this.overlayComponents,
measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element),
didResize: () => { this.updateSync() }
didResize: (overlayComponent) => {
this.updateOverlayToRender(overlayProps)
overlayComponent.update(overlayProps)
}
},
overlayProps
))
@@ -1346,42 +1347,46 @@ class TextEditorComponent {
})
}
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
const windowInnerHeight = this.getWindowInnerHeight()
const windowInnerWidth = this.getWindowInnerWidth()
const contentClientRect = this.refs.content.getBoundingClientRect()
for (let i = 0; i < overlayCount; i++) {
const decoration = this.decorationsToRender.overlays[i]
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()
this.overlayDimensionsByElement.set(element, clientRect)
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)
this.updateOverlayToRender(decoration)
}
}
@@ -1602,30 +1607,42 @@ class TextEditorComponent {
}
didTextInput (event) {
if (!this.isInputEnabled()) return
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 is impossible
// to test.
if (event.data !== ' ') event.preventDefault()
if (this.compositionCheckpoint) {
this.props.model.revertToCheckpoint(this.compositionCheckpoint)
this.compositionCheckpoint = null
}
// 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()
}
if (this.isInputEnabled()) {
event.stopPropagation()
this.props.model.insertText(event.data, {groupUndo: true})
// 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
@@ -1645,6 +1662,14 @@ class TextEditorComponent {
// keypress, meaning we're *holding* the _same_ key we intially 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
@@ -1760,7 +1785,7 @@ class TextEditorComponent {
if (target && target.matches('.fold-marker')) {
const bufferPosition = model.bufferPositionForScreenPosition(screenPosition)
model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition))
model.destroyFoldsContainingBufferPositions([bufferPosition], false)
return
}
@@ -1869,7 +1894,6 @@ class TextEditorComponent {
handleMouseDragUntilMouseUp ({didDrag, didStopDragging}) {
let dragging = false
let lastMousemoveEvent
let bufferWillChangeDisposable
const animationFrameLoop = () => {
window.requestAnimationFrame(() => {
@@ -1889,9 +1913,9 @@ class TextEditorComponent {
}
function didMouseUp () {
this.stopDragging = null
window.removeEventListener('mousemove', didMouseMove)
window.removeEventListener('mouseup', didMouseUp, {capture: true})
bufferWillChangeDisposable.dispose()
if (dragging) {
dragging = false
didStopDragging()
@@ -1900,10 +1924,7 @@ class TextEditorComponent {
window.addEventListener('mousemove', didMouseMove)
window.addEventListener('mouseup', didMouseUp, {capture: true})
// Simulate a mouse-up event if the buffer is about to change. This prevents
// unwanted selections when users perform edits while holding the left mouse
// button at the same time.
bufferWillChangeDisposable = this.props.model.getBuffer().onWillChange(didMouseUp)
this.stopDragging = didMouseUp
}
autoscrollOnMouseDrag ({clientX, clientY}, verticalOnly = false) {
@@ -2449,8 +2470,12 @@ class TextEditorComponent {
didChangeDisplayLayer (changes) {
for (let i = 0; i < changes.length; i++) {
const {start, oldExtent, newExtent} = changes[i]
this.spliceLineTopIndex(start.row, oldExtent.row, newExtent.row)
const {oldRange, newRange} = changes[i]
this.spliceLineTopIndex(
newRange.start.row,
oldRange.end.row - oldRange.start.row,
newRange.end.row - newRange.start.row
)
}
this.scheduleUpdate()
@@ -2530,6 +2555,7 @@ class TextEditorComponent {
didDestroyDisposable.dispose()
if (wasValid) {
wasValid = false
this.blockDecorationsToMeasure.delete(decoration)
this.heightsByBlockDecoration.delete(decoration)
this.blockDecorationsByElement.delete(element)
@@ -4199,17 +4225,26 @@ class OverlayComponent {
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 (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) {
if (
this.currentContentRect &&
(this.currentContentRect.width !== contentRect.width ||
this.currentContentRect.height !== contentRect.height)
) {
this.resizeObserver.disconnect()
this.props.didResize()
process.nextTick(() => { this.resizeObserver.observe(this.element) })
this.props.didResize(this)
process.nextTick(() => { this.resizeObserver.observe(this.props.element) })
}
this.currentContentRect = contentRect
})
this.didAttach()
this.props.overlayComponents.add(this)
@@ -4220,19 +4255,34 @@ class OverlayComponent {
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 = newProps
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.element)
this.resizeObserver.observe(this.props.element)
}
didDetach () {

View File

@@ -288,7 +288,7 @@ export default class TextEditorRegistry {
let currentScore = this.editorGrammarScores.get(editor)
if (currentScore == null || score > currentScore) {
editor.setGrammar(grammar, score)
editor.setGrammar(grammar)
this.editorGrammarScores.set(editor, score)
}
}

File diff suppressed because it is too large Load Diff

4603
src/text-editor.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,121 +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)
IsJapaneseKanaCharacter = (charCode) ->
0x3000 <= charCode <= 0x30FF
isCJKUnifiedIdeograph = (charCode) ->
0x4E00 <= charCode <= 0x9FFF
isFullWidthForm = (charCode) ->
0xFF01 <= charCode <= 0xFF5E or
0xFFE0 <= charCode <= 0xFFE6
isDoubleWidthCharacter = (character) ->
charCode = character.charCodeAt(0)
IsJapaneseKanaCharacter(charCode) or
isCJKUnifiedIdeograph(charCode) or
isFullWidthForm(charCode)
isHalfWidthCharacter = (character) ->
charCode = character.charCodeAt(0)
0xFF65 <= charCode <= 0xFFDC or
0xFFE8 <= charCode <= 0xFFEE
isKoreanCharacter = (character) ->
charCode = character.charCodeAt(0)
0xAC00 <= charCode <= 0xD7A3 or
0x1100 <= charCode <= 0x11FF or
0x3130 <= charCode <= 0x318F or
0xA960 <= charCode <= 0xA97F or
0xD7B0 <= charCode <= 0xD7FF
isCJKCharacter = (character) ->
isDoubleWidthCharacter(character) or
isHalfWidthCharacter(character) or
isKoreanCharacter(character)
isWordStart = (previousCharacter, character) ->
(previousCharacter is ' ' or previousCharacter is '\t') and
(character isnt ' ' and character isnt '\t')
isWrapBoundary = (previousCharacter, character) ->
isWordStart(previousCharacter, character) or 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}.
hasPairedCharacter = (string) ->
index = 0
while index < string.length
return true if isPairedCharacter(string, index)
index++
false
module.exports = {
isPairedCharacter, hasPairedCharacter,
isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter,
isWrapBoundary
}

130
src/text-utils.js Normal file
View File

@@ -0,0 +1,130 @@
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')) &&
((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
}

View File

@@ -1,322 +0,0 @@
path = require 'path'
_ = require 'underscore-plus'
{Emitter, CompositeDisposable} = require 'event-kit'
{File} = require 'pathwatcher'
fs = require 'fs-plus'
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}) ->
@emitter = new Emitter
@styleSheetDisposablesBySourcePath = {}
@lessCache = null
@initialLoadComplete = false
@packageManager.registerPackageActivator(this, ['theme'])
@packageManager.onDidActivateInitialPackages =>
@onDidChangeActiveThemes => @packageManager.reloadActivePackageStyleSheets()
initialize: ({@resourcePath, @configDirPath, @safeMode, devMode}) ->
@lessSourcesByRelativeFilePath = null
if devMode or typeof snapshotAuxiliaryData is 'undefined'
@lessSourcesByRelativeFilePath = {}
@importedFilePathsByRelativeImportPath = {}
else
@lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath
@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}
onDidChangeActiveThemes: (callback) ->
@emitter.on 'did-change-active-themes', callback
###
Section: Accessing Available Themes
###
getAvailableNames: ->
# TODO: Maybe should change to list all the available themes out there?
@getLoadedNames()
###
Section: Accessing Loaded Themes
###
# Public: Returns an {Array} of {String}s of all the loaded theme names.
getLoadedThemeNames: ->
theme.name for theme in @getLoadedThemes()
# Public: Returns an {Array} of all the loaded themes.
getLoadedThemes: ->
pack for pack in @packageManager.getLoadedPackages() when pack.isTheme()
###
Section: Accessing Active Themes
###
# Public: Returns an {Array} of {String}s all the active theme names.
getActiveThemeNames: ->
theme.name for theme in @getActiveThemes()
# Public: Returns an {Array} of all the active themes.
getActiveThemes: ->
pack for pack in @packageManager.getActivePackages() when pack.isTheme()
activatePackages: -> @activateThemes()
###
Section: Managing Enabled Themes
###
warnForNonExistentThemes: ->
themeNames = @config.get('core.themes') ? []
themeNames = [themeNames] unless _.isArray(themeNames)
for themeName in themeNames
unless themeName and typeof themeName is 'string' and @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: ->
themeNames = @config.get('core.themes') ? []
themeNames = [themeNames] unless _.isArray(themeNames)
themeNames = themeNames.filter (themeName) =>
if themeName and typeof themeName is 'string'
return true if @packageManager.resolvePackagePath(themeName)
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 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) ->
if fullPath = @resolveStylesheet(stylesheetPath)
content = @loadStylesheet(fullPath)
@applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation)
else
throw new Error("Could not find a file at path '#{stylesheetPath}'")
unwatchUserStylesheet: ->
@userStylesheetSubscriptions?.dispose()
@userStylesheetSubscriptions = null
@userStylesheetFile = null
@userStyleSheetDisposable?.dispose()
@userStyleSheetDisposable = null
loadUserStylesheet: ->
@unwatchUserStylesheet()
userStylesheetPath = @styleManager.getUserStyleSheetPath()
return unless fs.isFileSync(userStylesheetPath)
try
@userStylesheetFile = new File(userStylesheetPath)
@userStylesheetSubscriptions = new CompositeDisposable()
reloadStylesheet = => @loadUserStylesheet()
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet))
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet))
@userStylesheetSubscriptions.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
"""
@notificationManager.addError(message, dismissable: true)
try
userStylesheetContents = @loadStylesheet(userStylesheetPath, true)
catch
return
@userStyleSheetDisposable = @styleManager.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2)
loadBaseStylesheets: ->
@reloadBaseStylesheets()
reloadBaseStylesheets: ->
@requireStylesheet('../static/atom', -2, true)
stylesheetElementForId: (id) ->
escapedId = id.replace(/\\/g, '\\\\')
document.head.querySelector("atom-styles style[source-path=\"#{escapedId}\"]")
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) ->
@lessCache ?= new LessCompileCache({
@resourcePath,
@lessSourcesByRelativeFilePath,
@importedFilePathsByRelativeImportPath,
importPaths: @getImportPaths()
})
try
if importFallbackVariables
baseVarImports = """
@import "variables/ui-variables";
@import "variables/syntax-variables";
"""
relativeFilePath = path.relative(@resourcePath, lessStylesheetPath)
lessSource = @lessSourcesByRelativeFilePath[relativeFilePath]
if lessSource?
content = lessSource.content
digest = lessSource.digest
else
content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8')
digest = null
@lessCache.cssForFile(lessStylesheetPath, content, digest)
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
@notificationManager.addError(message, {detail, dismissable: true})
throw error
removeStylesheet: (stylesheetPath) ->
@styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose()
applyStylesheet: (path, text, priority, skipDeprecatedSelectorsTransformation) ->
@styleSheetDisposablesBySourcePath[path] = @styleManager.addStyleSheet(
text,
{
priority,
skipDeprecatedSelectorsTransformation,
sourcePath: path
}
)
activateThemes: ->
new Promise (resolve) =>
# @config.observe runs the callback once, then on subsequent changes.
@config.observe 'core.themes', =>
@deactivateThemes().then =>
@warnForNonExistentThemes()
@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.")
Promise.all(promises).then =>
@addActiveThemeClasses()
@refreshLessCache() # Update cache again now that @getActiveThemes() is populated
@loadUserStylesheet()
@reloadBaseStylesheets()
@initialLoadComplete = true
@emitter.emit 'did-change-active-themes'
resolve()
deactivateThemes: ->
@removeActiveThemeClasses()
@unwatchUserStylesheet()
results = @getActiveThemes().map((pack) => @packageManager.deactivatePackage(pack.name))
Promise.all(results.filter((r) -> typeof r?.then is 'function'))
isInitialLoadComplete: -> @initialLoadComplete
addActiveThemeClasses: ->
if workspaceElement = @viewRegistry.getView(@workspace)
for pack in @getActiveThemes()
workspaceElement.classList.add("theme-#{pack.name}")
return
removeActiveThemeClasses: ->
workspaceElement = @viewRegistry.getView(@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)

401
src/theme-manager.js Normal file
View File

@@ -0,0 +1,401 @@
/* 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}
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 (!_.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 (!_.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 = ['atom-dark-syntax', 'atom-dark-ui']
} else if (themeNames.length === 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.
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))
}
}

View File

@@ -1,37 +0,0 @@
path = require 'path'
Package = require './package'
module.exports =
class ThemePackage extends Package
getType: -> 'theme'
getStyleSheetPriority: -> 1
enable: ->
@config.unshiftAtKeyPath('core.themes', @name)
disable: ->
@config.removeAtKeyPath('core.themes', @name)
preload: ->
@loadTime = 0
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
finishLoading: ->
@path = path.join(@packageManager.resourcePath, @path)
load: ->
@loadTime = 0
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
this
activate: ->
@activationPromise ?= new Promise (resolve, reject) =>
@resolveActivationPromise = resolve
@rejectActivationPromise = reject
@measure 'activateTime', =>
try
@loadStylesheets()
@activateNow()
catch error
@handleError("Failed to activate the #{@name} theme", error)

55
src/theme-package.js Normal file
View File

@@ -0,0 +1,55 @@
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
}
}

View File

@@ -1,56 +0,0 @@
module.exports =
class TokenIterator
constructor: (@tokenizedBuffer) ->
reset: (@line) ->
@index = null
@startColumn = 0
@endColumn = 0
@scopes = @line.openScopes.map (id) => @tokenizedBuffer.grammar.scopeForId(id)
@scopeStarts = @scopes.slice()
@scopeEnds = []
this
next: ->
{tags} = @line
if @index?
@startColumn = @endColumn
@scopeEnds.length = 0
@scopeStarts.length = 0
@index++
else
@index = 0
while @index < tags.length
tag = tags[@index]
if tag < 0
scope = @tokenizedBuffer.grammar.scopeForId(tag)
if tag % 2 is 0
if @scopeStarts[@scopeStarts.length - 1] is scope
@scopeStarts.pop()
else
@scopeEnds.push(scope)
@scopes.pop()
else
@scopeStarts.push(scope)
@scopes.push(scope)
@index++
else
@endColumn += tag
@text = @line.text.substring(@startColumn, @endColumn)
return true
false
getScopes: -> @scopes
getScopeStarts: -> @scopeStarts
getScopeEnds: -> @scopeEnds
getText: -> @text
getBufferStart: -> @startColumn
getBufferEnd: -> @endColumn

79
src/token-iterator.js Normal file
View File

@@ -0,0 +1,79 @@
module.exports =
class TokenIterator {
constructor (tokenizedBuffer) {
this.tokenizedBuffer = tokenizedBuffer
}
reset (line) {
this.line = line
this.index = null
this.startColumn = 0
this.endColumn = 0
this.scopes = this.line.openScopes.map(id => this.tokenizedBuffer.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.tokenizedBuffer.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
}
}

Some files were not shown because too many files have changed in this diff Show More