mirror of
https://github.com/atom/atom.git
synced 2026-01-25 14:59:03 -05:00
Merge branch 'master' into b3-failing-seed
This commit is contained in:
16
.github/stale.yml
vendored
16
.github/stale.yml
vendored
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"url": "https://github.com/atom/atom.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"atom-package-manager": "1.18.8"
|
||||
"atom-package-manager": "1.18.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
102
package.json
102
package.json
@@ -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": {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
770
spec/atom-environment-spec.js
Normal file
770
spec/atom-environment-spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
'name': 'Test Ruby'
|
||||
'scopeName': 'test.rb'
|
||||
'firstLineMatch': '^\\#!.*(?:\\s|\\/)(?:testruby)(?:$|\\s)'
|
||||
'fileTypes': [
|
||||
'rb'
|
||||
]
|
||||
|
||||
5
spec/fixtures/packages/package-with-uri-handler/index.js
vendored
Normal file
5
spec/fixtures/packages/package-with-uri-handler/index.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
activate: () => null,
|
||||
deactivate: () => null,
|
||||
handleURI: () => null,
|
||||
}
|
||||
6
spec/fixtures/packages/package-with-uri-handler/package.json
vendored
Normal file
6
spec/fixtures/packages/package-with-uri-handler/package.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "package-with-uri-handler",
|
||||
"uriHandler": {
|
||||
"method": "handleURI"
|
||||
}
|
||||
}
|
||||
@@ -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
393
spec/git-repository-spec.js
Normal 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
|
||||
}
|
||||
@@ -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", ->
|
||||
|
||||
@@ -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 []
|
||||
77
spec/gutter-container-spec.js
Normal file
77
spec/gutter-container-spec.js
Normal 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([])
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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
82
spec/gutter-spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 () {
|
||||
|
||||
27
spec/main-process/parse-command-line.test.js
Normal file
27
spec/main-process/parse-command-line.test.js
Normal 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, [])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
955
spec/project-spec.js
Normal 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')
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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
157
spec/selection-spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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("B")).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, "B")).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
110
spec/text-utils-spec.js
Normal 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('B')).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, 'B')).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)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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
503
spec/theme-manager-spec.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
43
spec/token-iterator-spec.js
Normal file
43
spec/token-iterator-spec.js
Normal 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'])
|
||||
})
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
257
spec/tooltip-manager-spec.js
Normal file
257
spec/tooltip-manager-spec.js
Normal 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}))
|
||||
}
|
||||
75
spec/uri-handler-registry-spec.js
Normal file
75
spec/uri-handler-registry-spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
216
spec/view-registry-spec.js
Normal 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 })
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
228
spec/window-event-handler-spec.js
Normal file
228
spec/window-event-handler-spec.js
Normal 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()
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
1339
src/atom-environment.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
//
|
||||
|
||||
@@ -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
38
src/core-uri-handlers.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
205
src/decoration.js
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
171
src/grammar-registry.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
108
src/gutter-container.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
107
src/gutter.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -138,4 +138,4 @@ class AutoUpdateManager
|
||||
detail: message
|
||||
|
||||
getWindows: ->
|
||||
global.atomApplication.windows
|
||||
global.atomApplication.getAllWindows()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
713
src/project.js
Normal 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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
92
src/protocol-handler-installer.js
Normal file
92
src/protocol-handler-installer.js
Normal 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)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
977
src/selection.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
4603
src/text-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
130
src/text-utils.js
Normal 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
|
||||
}
|
||||
@@ -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
401
src/theme-manager.js
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
55
src/theme-package.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
79
src/token-iterator.js
Normal 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
Reference in New Issue
Block a user