diff --git a/.github/stale.yml b/.github/stale.yml index 2adc475b5..4888a3bb6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6ee13d47..0f0d2d5a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/ diff --git a/README.md b/README.md index ab6cd06a6..c29203ea0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apm/package.json b/apm/package.json index 5391c9972..336544d3e 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.18.8" + "atom-package-manager": "1.18.10" } } diff --git a/docs/build-instructions/linux.md b/docs/build-instructions/linux.md index dee67d726..3499f6ac9 100644 --- a/docs/build-instructions/linux.md +++ b/docs/build-instructions/linux.md @@ -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). diff --git a/docs/build-instructions/macOS.md b/docs/build-instructions/macOS.md index ae3ed9c84..3085d11f3 100644 --- a/docs/build-instructions/macOS.md +++ b/docs/build-instructions/macOS.md @@ -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). diff --git a/docs/build-instructions/windows.md b/docs/build-instructions/windows.md index a6c327ec8..f75a07530 100644 --- a/docs/build-instructions/windows.md +++ b/docs/build-instructions/windows.md @@ -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). diff --git a/docs/contributing-to-packages.md b/docs/contributing-to-packages.md index 4576635ff..67933dc26 100644 --- a/docs/contributing-to-packages.md +++ b/docs/contributing-to-packages.md @@ -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/ diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index fa942d97c..7161a8478 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -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' diff --git a/keymaps/linux.cson b/keymaps/linux.cson index d6ded1f90..9d3e4dbb1 100644 --- a/keymaps/linux.cson +++ b/keymaps/linux.cson @@ -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' diff --git a/keymaps/win32.cson b/keymaps/win32.cson index 14f5a4283..8a8e92249 100644 --- a/keymaps/win32.cson +++ b/keymaps/win32.cson @@ -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' diff --git a/menus/darwin.cson b/menus/darwin.cson index 055cd2405..2dffda1ef 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -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' } diff --git a/menus/linux.cson b/menus/linux.cson index 2a1ca47f8..b44900398 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -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' } diff --git a/menus/win32.cson b/menus/win32.cson index 553b6017e..a921bae74 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -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' } diff --git a/package.json b/package.json index f420deba9..3f94ad81e 100644 --- a/package.json +++ b/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": { diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 2905bca1b..333acdc0a 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -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) => { diff --git a/script/lib/include-path-in-packaged-app.js b/script/lib/include-path-in-packaged-app.js index 1705c3457..603f14da0 100644 --- a/script/lib/include-path-in-packaged-app.js +++ b/script/lib/include-path-in-packaged-app.js @@ -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 diff --git a/script/package.json b/script/package.json index c766806a1..4cf1bfb8c 100644 --- a/script/package.json +++ b/script/package.json @@ -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", diff --git a/spec/async-spec-helpers.js b/spec/async-spec-helpers.js index 56550cd9f..73002c049 100644 --- a/spec/async-spec-helpers.js +++ b/spec/async-spec-helpers.js @@ -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) } } } diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee deleted file mode 100644 index f178bbb6c..000000000 --- a/spec/atom-environment-spec.coffee +++ /dev/null @@ -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' diff --git a/spec/atom-environment-spec.js b/spec/atom-environment-spec.js new file mode 100644 index 000000000..84b415eab --- /dev/null +++ b/spec/atom-environment-spec.js @@ -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') + }) + }) +}) diff --git a/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson index 8b4d85412..37aac3d4d 100644 --- a/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson +++ b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson @@ -1,5 +1,6 @@ 'name': 'Test Ruby' 'scopeName': 'test.rb' +'firstLineMatch': '^\\#!.*(?:\\s|\\/)(?:testruby)(?:$|\\s)' 'fileTypes': [ 'rb' ] diff --git a/spec/fixtures/packages/package-with-uri-handler/index.js b/spec/fixtures/packages/package-with-uri-handler/index.js new file mode 100644 index 000000000..5d31dca98 --- /dev/null +++ b/spec/fixtures/packages/package-with-uri-handler/index.js @@ -0,0 +1,5 @@ +module.exports = { + activate: () => null, + deactivate: () => null, + handleURI: () => null, +} diff --git a/spec/fixtures/packages/package-with-uri-handler/package.json b/spec/fixtures/packages/package-with-uri-handler/package.json new file mode 100644 index 000000000..60160e36b --- /dev/null +++ b/spec/fixtures/packages/package-with-uri-handler/package.json @@ -0,0 +1,6 @@ +{ + "name": "package-with-uri-handler", + "uriHandler": { + "method": "handleURI" + } +} diff --git a/spec/git-repository-spec.coffee b/spec/git-repository-spec.coffee deleted file mode 100644 index e4d1e0c7f..000000000 --- a/spec/git-repository-spec.coffee +++ /dev/null @@ -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} diff --git a/spec/git-repository-spec.js b/spec/git-repository-spec.js new file mode 100644 index 000000000..e03a9788a --- /dev/null +++ b/spec/git-repository-spec.js @@ -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 +} diff --git a/spec/grammars-spec.coffee b/spec/grammars-spec.coffee index 7b70797ba..db716528d 100644 --- a/spec/grammars-spec.coffee +++ b/spec/grammars-spec.coffee @@ -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", -> diff --git a/spec/gutter-container-spec.coffee b/spec/gutter-container-spec.coffee deleted file mode 100644 index dc4af0b8c..000000000 --- a/spec/gutter-container-spec.coffee +++ /dev/null @@ -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 [] diff --git a/spec/gutter-container-spec.js b/spec/gutter-container-spec.js new file mode 100644 index 000000000..f41f1d220 --- /dev/null +++ b/spec/gutter-container-spec.js @@ -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([]) + }) +) +}) diff --git a/spec/gutter-spec.coffee b/spec/gutter-spec.coffee deleted file mode 100644 index 47c5983f6..000000000 --- a/spec/gutter-spec.coffee +++ /dev/null @@ -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() diff --git a/spec/gutter-spec.js b/spec/gutter-spec.js new file mode 100644 index 000000000..4ae23db3e --- /dev/null +++ b/spec/gutter-spec.js @@ -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() + }) + }) +}) diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js index 62fae82b3..01d052b96 100644 --- a/spec/main-process/atom-application.test.js +++ b/spec/main-process/atom-application.test.js @@ -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 () { diff --git a/spec/main-process/parse-command-line.test.js b/spec/main-process/parse-command-line.test.js new file mode 100644 index 000000000..0cd1f5b13 --- /dev/null +++ b/spec/main-process/parse-command-line.test.js @@ -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, []) + }) + }) +}) diff --git a/spec/menu-manager-spec.coffee b/spec/menu-manager-spec.coffee index 798aa3766..3bbd8b9da 100644 --- a/spec/menu-manager-spec.coffee +++ b/spec/menu-manager-spec.coffee @@ -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"} diff --git a/spec/package-manager-spec.js b/spec/package-manager-spec.js index 1d949859d..0b26bf839 100644 --- a/spec/package-manager-spec.js +++ b/spec/package-manager-spec.js @@ -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') diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee deleted file mode 100644 index 1f5eb54a4..000000000 --- a/spec/project-spec.coffee +++ /dev/null @@ -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" diff --git a/spec/project-spec.js b/spec/project-spec.js new file mode 100644 index 000000000..0f003b26b --- /dev/null +++ b/spec/project-spec.js @@ -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') + }) + ) +}) diff --git a/spec/selection-spec.coffee b/spec/selection-spec.coffee deleted file mode 100644 index cb070310a..000000000 --- a/spec/selection-spec.coffee +++ /dev/null @@ -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) diff --git a/spec/selection-spec.js b/spec/selection-spec.js new file mode 100644 index 000000000..cb586da26 --- /dev/null +++ b/spec/selection-spec.js @@ -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) + }) + }) +}) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index c20bfc827..7621f9cae 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -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]) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0bf1c849a..0738b291f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -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 () { diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee deleted file mode 100644 index 53011fdcc..000000000 --- a/spec/text-editor-spec.coffee +++ /dev/null @@ -1,5959 +0,0 @@ -path = require 'path' -clipboard = require '../src/safe-clipboard' -TextEditor = require '../src/text-editor' -TextBuffer = require 'text-buffer' - -describe "TextEditor", -> - [buffer, editor, lineLengths] = [] - - convertToHardTabs = (buffer) -> - buffer.setText(buffer.getText().replace(/[ ]{2}/g, "\t")) - - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js', {autoIndent: false}).then (o) -> editor = o - - runs -> - buffer = editor.buffer - editor.update({autoIndent: false}) - lineLengths = buffer.getLines().map (line) -> line.length - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - describe "when the editor is deserialized", -> - it "restores selections and folds based on markers in the buffer", -> - editor.setSelectedBufferRange([[1, 2], [3, 4]]) - editor.addSelectionForBufferRange([[5, 6], [7, 5]], reversed: true) - editor.foldBufferRow(4) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - - waitsForPromise -> - TextBuffer.deserialize(editor.buffer.serialize()).then (buffer2) -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> buffer2} - }) - - expect(editor2.id).toBe editor.id - expect(editor2.getBuffer().getPath()).toBe editor.getBuffer().getPath() - expect(editor2.getSelectedBufferRanges()).toEqual [[[1, 2], [3, 4]], [[5, 6], [7, 5]]] - expect(editor2.getSelections()[1].isReversed()).toBeTruthy() - expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() - editor2.destroy() - - it "restores the editor's layout configuration", -> - editor.update({ - softTabs: true - atomicSoftTabs: false - tabLength: 12 - softWrapped: true - softWrapAtPreferredLineLength: true - softWrapHangingIndentLength: 8 - invisibles: {space: 'S'} - showInvisibles: true - editorWidthInChars: 120 - }) - - # Force buffer and display layer to be deserialized as well, rather than - # reusing the same buffer instance - waitsForPromise -> - TextBuffer.deserialize(editor.buffer.serialize()).then (buffer2) -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> buffer2} - }) - - expect(editor2.getSoftTabs()).toBe(editor.getSoftTabs()) - expect(editor2.hasAtomicSoftTabs()).toBe(editor.hasAtomicSoftTabs()) - expect(editor2.getTabLength()).toBe(editor.getTabLength()) - expect(editor2.getSoftWrapColumn()).toBe(editor.getSoftWrapColumn()) - expect(editor2.getSoftWrapHangingIndentLength()).toBe(editor.getSoftWrapHangingIndentLength()) - expect(editor2.getInvisibles()).toEqual(editor.getInvisibles()) - expect(editor2.getEditorWidthInChars()).toBe(editor.getEditorWidthInChars()) - expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength()) - expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn()) - - it "ignores buffers with retired IDs", -> - editor2 = TextEditor.deserialize(editor.serialize(), { - assert: atom.assert, - textEditors: atom.textEditors, - project: {bufferForIdSync: -> null} - }) - - expect(editor2).toBeNull() - - describe "when the editor is constructed with the largeFileMode option set to true", -> - it "loads the editor but doesn't tokenize", -> - editor = null - - waitsForPromise -> - atom.workspace.openTextFile('sample.js', largeFileMode: true).then (o) -> editor = o - - runs -> - buffer = editor.getBuffer() - expect(editor.lineTextForScreenRow(0)).toBe buffer.lineForRow(0) - expect(editor.tokensForScreenRow(0).length).toBe 1 - expect(editor.tokensForScreenRow(1).length).toBe 2 # soft tab - expect(editor.lineTextForScreenRow(12)).toBe buffer.lineForRow(12) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - editor.insertText('hey"') - expect(editor.tokensForScreenRow(0).length).toBe 1 - expect(editor.tokensForScreenRow(1).length).toBe 2 # soft tab - - describe ".copy()", -> - it "returns a different editor with the same initial state", -> - expect(editor.getAutoHeight()).toBeFalsy() - expect(editor.getAutoWidth()).toBeFalsy() - expect(editor.getShowCursorOnSelection()).toBeTruthy() - - element = editor.getElement() - element.setHeight(100) - element.setWidth(100) - jasmine.attachToDOM(element) - - editor.update({showCursorOnSelection: false}) - editor.setSelectedBufferRange([[1, 2], [3, 4]]) - editor.addSelectionForBufferRange([[5, 6], [7, 8]], reversed: true) - editor.setScrollTopRow(3) - expect(editor.getScrollTopRow()).toBe(3) - editor.setScrollLeftColumn(4) - expect(editor.getScrollLeftColumn()).toBe(4) - editor.foldBufferRow(4) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - - editor2 = editor.copy() - element2 = editor2.getElement() - element2.setHeight(100) - element2.setWidth(100) - jasmine.attachToDOM(element2) - expect(editor2.id).not.toBe editor.id - expect(editor2.getSelectedBufferRanges()).toEqual editor.getSelectedBufferRanges() - expect(editor2.getSelections()[1].isReversed()).toBeTruthy() - expect(editor2.getScrollTopRow()).toBe(3) - expect(editor2.getScrollLeftColumn()).toBe(4) - expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor2.getAutoWidth()).toBe(false) - expect(editor2.getAutoHeight()).toBe(false) - expect(editor2.getShowCursorOnSelection()).toBeFalsy() - - # editor2 can now diverge from its origin edit session - editor2.getLastSelection().setBufferRange([[2, 1], [4, 3]]) - expect(editor2.getSelectedBufferRanges()).not.toEqual editor.getSelectedBufferRanges() - editor2.unfoldBufferRow(4) - expect(editor2.isFoldedAtBufferRow(4)).not.toBe editor.isFoldedAtBufferRow(4) - - describe ".update()", -> - it "updates the editor with the supplied config parameters", -> - element = editor.element # force element initialization - element.setUpdatedSynchronously(false) - editor.update({showInvisibles: true}) - editor.onDidChange(changeSpy = jasmine.createSpy('onDidChange')) - - returnedPromise = editor.update({ - tabLength: 6, softTabs: false, softWrapped: true, editorWidthInChars: 40, - showInvisibles: false, mini: false, lineNumberGutterVisible: false, scrollPastEnd: true, - autoHeight: false, maxScreenLineLength: 1000 - }) - - expect(returnedPromise).toBe(element.component.getNextUpdatePromise()) - expect(changeSpy.callCount).toBe(1) - expect(editor.getTabLength()).toBe(6) - expect(editor.getSoftTabs()).toBe(false) - expect(editor.isSoftWrapped()).toBe(true) - expect(editor.getEditorWidthInChars()).toBe(40) - expect(editor.getInvisibles()).toEqual({}) - expect(editor.isMini()).toBe(false) - expect(editor.isLineNumberGutterVisible()).toBe(false) - expect(editor.getScrollPastEnd()).toBe(true) - expect(editor.getAutoHeight()).toBe(false) - - describe "title", -> - describe ".getTitle()", -> - it "uses the basename of the buffer's path as its title, or 'untitled' if the path is undefined", -> - expect(editor.getTitle()).toBe 'sample.js' - buffer.setPath(undefined) - expect(editor.getTitle()).toBe 'untitled' - - describe ".getLongTitle()", -> - it "returns file name when there is no opened file with identical name", -> - expect(editor.getLongTitle()).toBe 'sample.js' - buffer.setPath(undefined) - expect(editor.getLongTitle()).toBe 'untitled' - - it "returns '' when opened files have identical file names", -> - editor1 = null - editor2 = null - waitsForPromise -> - atom.workspace.open(path.join('sample-theme-1', 'readme')).then (o) -> - editor1 = o - atom.workspace.open(path.join('sample-theme-2', 'readme')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "readme \u2014 sample-theme-1" - expect(editor2.getLongTitle()).toBe "readme \u2014 sample-theme-2" - - it "returns '' when opened files have identical file names in subdirectories", -> - editor1 = null - editor2 = null - path1 = path.join('sample-theme-1', 'src', 'js') - path2 = path.join('sample-theme-2', 'src', 'js') - waitsForPromise -> - atom.workspace.open(path.join(path1, 'main.js')).then (o) -> - editor1 = o - atom.workspace.open(path.join(path2, 'main.js')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "main.js \u2014 #{path1}" - expect(editor2.getLongTitle()).toBe "main.js \u2014 #{path2}" - - it "returns '' when opened files have identical file and same parent dir name", -> - editor1 = null - editor2 = null - waitsForPromise -> - atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'main.js')).then (o) -> - editor1 = o - atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'plugin', 'main.js')).then (o) -> - editor2 = o - runs -> - expect(editor1.getLongTitle()).toBe "main.js \u2014 js" - expect(editor2.getLongTitle()).toBe "main.js \u2014 " + path.join('js', 'plugin') - - it "notifies ::onDidChangeTitle observers when the underlying buffer path changes", -> - observed = [] - editor.onDidChangeTitle (title) -> observed.push(title) - - buffer.setPath('/foo/bar/baz.txt') - buffer.setPath(undefined) - - expect(observed).toEqual ['baz.txt', 'untitled'] - - describe "path", -> - it "notifies ::onDidChangePath observers when the underlying buffer path changes", -> - observed = [] - editor.onDidChangePath (filePath) -> observed.push(filePath) - - buffer.setPath(__filename) - buffer.setPath(undefined) - - expect(observed).toEqual [__filename, undefined] - - describe "encoding", -> - it "notifies ::onDidChangeEncoding observers when the editor encoding changes", -> - observed = [] - editor.onDidChangeEncoding (encoding) -> observed.push(encoding) - - editor.setEncoding('utf16le') - editor.setEncoding('utf16le') - editor.setEncoding('utf16be') - editor.setEncoding() - editor.setEncoding() - - expect(observed).toEqual ['utf16le', 'utf16be', 'utf8'] - - describe "cursor", -> - describe ".getLastCursor()", -> - it "returns the most recently created cursor", -> - editor.addCursorAtScreenPosition([1, 0]) - lastCursor = editor.addCursorAtScreenPosition([2, 0]) - expect(editor.getLastCursor()).toBe lastCursor - - it "creates a new cursor at (0, 0) if the last cursor has been destroyed", -> - editor.getLastCursor().destroy() - expect(editor.getLastCursor().getBufferPosition()).toEqual([0, 0]) - - describe ".getCursors()", -> - it "creates a new cursor at (0, 0) if the last cursor has been destroyed", -> - editor.getLastCursor().destroy() - expect(editor.getCursors()[0].getBufferPosition()).toEqual([0, 0]) - - describe "when the cursor moves", -> - it "clears a goal column established by vertical movement", -> - editor.setText('b') - editor.setCursorBufferPosition([0, 0]) - editor.insertNewline() - editor.moveUp() - editor.insertText('a') - editor.moveDown() - expect(editor.getCursorBufferPosition()).toEqual [1, 1] - - it "emits an event with the old position, new position, and the cursor that moved", -> - cursorCallback = jasmine.createSpy('cursor-changed-position') - editorCallback = jasmine.createSpy('editor-changed-cursor-position') - - editor.getLastCursor().onDidChangePosition(cursorCallback) - editor.onDidChangeCursorPosition(editorCallback) - - editor.setCursorBufferPosition([2, 4]) - - expect(editorCallback).toHaveBeenCalled() - expect(cursorCallback).toHaveBeenCalled() - eventObject = editorCallback.mostRecentCall.args[0] - expect(cursorCallback.mostRecentCall.args[0]).toEqual(eventObject) - - expect(eventObject.oldBufferPosition).toEqual [0, 0] - expect(eventObject.oldScreenPosition).toEqual [0, 0] - expect(eventObject.newBufferPosition).toEqual [2, 4] - expect(eventObject.newScreenPosition).toEqual [2, 4] - expect(eventObject.cursor).toBe editor.getLastCursor() - - describe ".setCursorScreenPosition(screenPosition)", -> - it "clears a goal column established by vertical movement", -> - # set a goal column by moving down - editor.setCursorScreenPosition(row: 3, column: lineLengths[3]) - editor.moveDown() - expect(editor.getCursorScreenPosition().column).not.toBe 6 - - # clear the goal column by explicitly setting the cursor position - editor.setCursorScreenPosition([4, 6]) - expect(editor.getCursorScreenPosition().column).toBe 6 - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe 6 - - it "merges multiple cursors", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 1]) - [cursor1, cursor2] = editor.getCursors() - editor.setCursorScreenPosition([4, 7]) - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursors()).toEqual [cursor1] - expect(editor.getCursorScreenPosition()).toEqual [4, 7] - - describe "when soft-wrap is enabled and code is folded", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - editor.foldBufferRowRange(2, 3) - - it "positions the cursor at the buffer position that corresponds to the given screen position", -> - editor.setCursorScreenPosition([9, 0]) - expect(editor.getCursorBufferPosition()).toEqual [8, 11] - - describe ".moveUp()", -> - it "moves the cursor up", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual [1, 2] - - it "retains the goal column across lines of differing length", -> - expect(lineLengths[6]).toBeGreaterThan(32) - editor.setCursorScreenPosition(row: 6, column: 32) - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[5] - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[4] - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe 32 - - describe "when the cursor is on the first line", -> - it "moves the cursor to the beginning of the line, but retains the goal column", -> - editor.setCursorScreenPosition([0, 4]) - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual([0, 0]) - - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual([1, 4]) - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[4, 9], [5, 10]]) - - it "moves above the selection", -> - cursor = editor.getLastCursor() - editor.moveUp() - expect(cursor.getBufferPosition()).toEqual [3, 9] - - it "merges cursors when they overlap", -> - editor.addCursorAtScreenPosition([1, 0]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveUp() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - - describe "when the cursor was moved down from the beginning of an indented soft-wrapped line", -> - it "moves to the beginning of the previous line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - editor.setCursorScreenPosition([3, 0]) - editor.moveDown() - editor.moveDown() - editor.moveUp() - expect(editor.getCursorScreenPosition()).toEqual [4, 4] - - describe ".moveDown()", -> - it "moves the cursor down", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual [3, 2] - - it "retains the goal column across lines of differing length", -> - editor.setCursorScreenPosition(row: 3, column: lineLengths[3]) - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[4] - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[5] - - editor.moveDown() - expect(editor.getCursorScreenPosition().column).toBe lineLengths[3] - - describe "when the cursor is on the last line", -> - it "moves the cursor to the end of line, but retains the goal column when moving back up", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - editor.setCursorScreenPosition(row: lastLineIndex, column: editor.getTabLength()) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual(row: lastLineIndex, column: lastLine.length) - - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe editor.getTabLength() - - it "retains a goal column of 0 when moving back up", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - editor.setCursorScreenPosition(row: lastLineIndex, column: 0) - editor.moveDown() - editor.moveUp() - expect(editor.getCursorScreenPosition().column).toBe 0 - - describe "when the cursor is at the beginning of an indented soft-wrapped line", -> - it "moves to the beginning of the line's continuation on the next screen row", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - editor.setCursorScreenPosition([3, 0]) - editor.moveDown() - expect(editor.getCursorScreenPosition()).toEqual [4, 4] - - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[4, 9], [5, 10]]) - - it "moves below the selection", -> - cursor = editor.getLastCursor() - editor.moveDown() - expect(cursor.getBufferPosition()).toEqual [6, 10] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([12, 2]) - editor.addCursorAtScreenPosition([11, 2]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveDown() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12, 2] - - describe ".moveLeft()", -> - it "moves the cursor by one column to the left", -> - editor.setCursorScreenPosition([1, 8]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [1, 7] - - it "moves the cursor by n columns to the left", -> - editor.setCursorScreenPosition([1, 8]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [1, 4] - - it "moves the cursor by two rows up when the columnCount is longer than an entire line", -> - editor.setCursorScreenPosition([2, 2]) - editor.moveLeft(34) - expect(editor.getCursorScreenPosition()).toEqual [0, 29] - - it "moves the cursor to the beginning columnCount is longer than the position in the buffer", -> - editor.setCursorScreenPosition([1, 0]) - editor.moveLeft(100) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - - describe "when the cursor is in the first column", -> - describe "when there is a previous line", -> - it "wraps to the end of the previous line", -> - editor.setCursorScreenPosition(row: 1, column: 0) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual(row: 0, column: buffer.lineForRow(0).length) - - it "moves the cursor by one row up and n columns to the left", -> - editor.setCursorScreenPosition([1, 0]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [0, 26] - - describe "when the next line is empty", -> - it "wraps to the beginning of the previous line", -> - editor.setCursorScreenPosition([11, 0]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [10, 0] - - describe "when line is wrapped and follow previous line indentation", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(50) - - it "wraps to the end of the previous line", -> - editor.setCursorScreenPosition([4, 4]) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual [3, 46] - - describe "when the cursor is on the first line", -> - it "remains in the same position (0,0)", -> - editor.setCursorScreenPosition(row: 0, column: 0) - editor.moveLeft() - expect(editor.getCursorScreenPosition()).toEqual(row: 0, column: 0) - - it "remains in the same position (0,0) when columnCount is specified", -> - editor.setCursorScreenPosition([0, 0]) - editor.moveLeft(4) - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - - describe "when softTabs is enabled and the cursor is preceded by leading whitespace", -> - it "skips tabLength worth of whitespace at a time", -> - editor.setCursorBufferPosition([5, 6]) - - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [5, 4] - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[5, 22], [5, 27]]) - - it "moves to the left of the selection", -> - cursor = editor.getLastCursor() - editor.moveLeft() - expect(cursor.getBufferPosition()).toEqual [5, 22] - - editor.moveLeft() - expect(cursor.getBufferPosition()).toEqual [5, 21] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 1]) - - [cursor1, cursor2] = editor.getCursors() - editor.moveLeft() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - - describe ".moveRight()", -> - it "moves the cursor by one column to the right", -> - editor.setCursorScreenPosition([3, 3]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [3, 4] - - it "moves the cursor by n columns to the right", -> - editor.setCursorScreenPosition([3, 7]) - editor.moveRight(4) - expect(editor.getCursorScreenPosition()).toEqual [3, 11] - - it "moves the cursor by two rows down when the columnCount is longer than an entire line", -> - editor.setCursorScreenPosition([0, 29]) - editor.moveRight(34) - expect(editor.getCursorScreenPosition()).toEqual [2, 2] - - it "moves the cursor to the end of the buffer when columnCount is longer than the number of characters following the cursor position", -> - editor.setCursorScreenPosition([11, 5]) - editor.moveRight(100) - expect(editor.getCursorScreenPosition()).toEqual [12, 2] - - describe "when the cursor is on the last column of a line", -> - describe "when there is a subsequent line", -> - it "wraps to the beginning of the next line", -> - editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [1, 0] - - it "moves the cursor by one row down and n columns to the right", -> - editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) - editor.moveRight(4) - expect(editor.getCursorScreenPosition()).toEqual [1, 3] - - describe "when the next line is empty", -> - it "wraps to the beginning of the next line", -> - editor.setCursorScreenPosition([9, 4]) - editor.moveRight() - expect(editor.getCursorScreenPosition()).toEqual [10, 0] - - describe "when the cursor is on the last line", -> - it "remains in the same position", -> - lastLineIndex = buffer.getLines().length - 1 - lastLine = buffer.lineForRow(lastLineIndex) - expect(lastLine.length).toBeGreaterThan(0) - - lastPosition = {row: lastLineIndex, column: lastLine.length} - editor.setCursorScreenPosition(lastPosition) - editor.moveRight() - - expect(editor.getCursorScreenPosition()).toEqual(lastPosition) - - describe "when there is a selection", -> - beforeEach -> - editor.setSelectedBufferRange([[5, 22], [5, 27]]) - - it "moves to the left of the selection", -> - cursor = editor.getLastCursor() - editor.moveRight() - expect(cursor.getBufferPosition()).toEqual [5, 27] - - editor.moveRight() - expect(cursor.getBufferPosition()).toEqual [5, 28] - - it "merges cursors when they overlap", -> - editor.setCursorScreenPosition([12, 2]) - editor.addCursorAtScreenPosition([12, 1]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveRight() - expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12, 2] - - describe ".moveToTop()", -> - it "moves the cursor to the top of the buffer", -> - editor.setCursorScreenPosition [11, 1] - editor.addCursorAtScreenPosition [12, 0] - editor.moveToTop() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".moveToBottom()", -> - it "moves the cursor to the bottom of the buffer", -> - editor.setCursorScreenPosition [0, 0] - editor.addCursorAtScreenPosition [1, 0] - editor.moveToBottom() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [12, 2] - - describe ".moveToBeginningOfScreenLine()", -> - describe "when soft wrap is on", -> - it "moves cursor to the beginning of the screen line", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToBeginningOfScreenLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [1, 0] - - describe "when soft wrap is off", -> - it "moves cursor to the beginning of the line", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - editor.moveToBeginningOfScreenLine() - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 0] - - describe ".moveToEndOfScreenLine()", -> - describe "when soft wrap is on", -> - it "moves cursor to the beginning of the screen line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToEndOfScreenLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [1, 9] - - describe "when soft wrap is off", -> - it "moves cursor to the end of line", -> - editor.setCursorScreenPosition [0, 0] - editor.addCursorAtScreenPosition [1, 0] - editor.moveToEndOfScreenLine() - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 29] - expect(cursor2.getBufferPosition()).toEqual [1, 30] - - describe ".moveToBeginningOfLine()", -> - it "moves cursor to the beginning of the buffer line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([1, 2]) - editor.moveToBeginningOfLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [0, 0] - - describe ".moveToEndOfLine()", -> - it "moves cursor to the end of the buffer line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition([0, 2]) - editor.moveToEndOfLine() - cursor = editor.getLastCursor() - expect(cursor.getScreenPosition()).toEqual [4, 4] - - describe ".moveToFirstCharacterOfLine()", -> - describe "when soft wrap is on", -> - it "moves to the first character of the current screen line or the beginning of the screen line if it's already on the first character", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(10) - editor.setCursorScreenPosition [2, 5] - editor.addCursorAtScreenPosition [8, 7] - - editor.moveToFirstCharacterOfLine() - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getScreenPosition()).toEqual [2, 0] - expect(cursor2.getScreenPosition()).toEqual [8, 2] - - editor.moveToFirstCharacterOfLine() - expect(cursor1.getScreenPosition()).toEqual [2, 0] - expect(cursor2.getScreenPosition()).toEqual [8, 2] - - describe "when soft wrap is off", -> - it "moves to the first character of the current line or the beginning of the line if it's already on the first character", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - - editor.moveToFirstCharacterOfLine() - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 2] - - editor.moveToFirstCharacterOfLine() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 0] - - it "moves to the beginning of the line if it only contains whitespace ", -> - editor.setText("first\n \nthird") - editor.setCursorScreenPosition [1, 2] - editor.moveToFirstCharacterOfLine() - cursor = editor.getLastCursor() - expect(cursor.getBufferPosition()).toEqual [1, 0] - - describe "when invisible characters are enabled with soft tabs", -> - it "moves to the first character of the current line without being confused by the invisible characters", -> - editor.update({showInvisibles: true}) - editor.setCursorScreenPosition [1, 7] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - - describe "when invisible characters are enabled with hard tabs", -> - it "moves to the first character of the current line without being confused by the invisible characters", -> - editor.update({showInvisibles: true}) - buffer.setTextInRange([[1, 0], [1, Infinity]], '\t\t\ta', normalizeLineEndings: false) - - editor.setCursorScreenPosition [1, 7] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 3] - editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - - describe ".moveToBeginningOfWord()", -> - it "moves the cursor to the beginning of the word", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [1, 12] - editor.addCursorAtBufferPosition [3, 0] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToBeginningOfWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [1, 11] - expect(cursor3.getBufferPosition()).toEqual [2, 39] - - it "does not fail at position [0, 0]", -> - editor.setCursorBufferPosition([0, 0]) - editor.moveToBeginningOfWord() - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([11, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "treats lines with only whitespace as a word (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([11, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [9, 2] - - it "works when the current line is blank (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfWord() - expect(editor.getCursorBufferPosition()).toEqual [9, 2] - editor.buffer.setText(buffer.getText().replace(/\r\n/g, "\n")) - - describe ".moveToPreviousWordBoundary()", -> - it "moves the cursor to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 0] - editor.addCursorAtBufferPosition [2, 4] - editor.addCursorAtBufferPosition [3, 14] - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.moveToPreviousWordBoundary() - - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [1, 30] - expect(cursor3.getBufferPosition()).toEqual [2, 0] - expect(cursor4.getBufferPosition()).toEqual [3, 13] - - describe ".moveToNextWordBoundary()", -> - it "moves the cursor to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 40] - editor.addCursorAtBufferPosition [3, 0] - editor.addCursorAtBufferPosition [3, 30] - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.moveToNextWordBoundary() - - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 0] - expect(cursor3.getBufferPosition()).toEqual [3, 4] - expect(cursor4.getBufferPosition()).toEqual [3, 31] - - describe ".moveToEndOfWord()", -> - it "moves the cursor to the end of the word", -> - editor.setCursorBufferPosition [0, 6] - editor.addCursorAtBufferPosition [1, 10] - editor.addCursorAtBufferPosition [2, 40] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToEndOfWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [1, 12] - expect(cursor3.getBufferPosition()).toEqual [3, 7] - - it "does not blow up when there is no next word", -> - editor.setCursorBufferPosition [Infinity, Infinity] - endPosition = editor.getCursorBufferPosition() - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual endPosition - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([9, 4]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "treats lines with only whitespace as a word (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([9, 4]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 8] - - it "works when the current line is blank (CRLF line ending)", -> - editor.buffer.setText(buffer.getText().replace(/\n/g, "\r\n")) - editor.setCursorBufferPosition([10, 0]) - editor.moveToEndOfWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 8] - - describe ".moveToBeginningOfNextWord()", -> - it "moves the cursor before the first character of the next word", -> - editor.setCursorBufferPosition [0, 6] - editor.addCursorAtBufferPosition [1, 11] - editor.addCursorAtBufferPosition [2, 0] - [cursor1, cursor2, cursor3] = editor.getCursors() - - editor.moveToBeginningOfNextWord() - - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [1, 13] - expect(cursor3.getBufferPosition()).toEqual [2, 4] - - # When the cursor is on whitespace - editor.setText("ab cde- ") - editor.setCursorBufferPosition [0, 2] - cursor = editor.getLastCursor() - editor.moveToBeginningOfNextWord() - - expect(cursor.getBufferPosition()).toEqual [0, 3] - - it "does not blow up when there is no next word", -> - editor.setCursorBufferPosition [Infinity, Infinity] - endPosition = editor.getCursorBufferPosition() - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual endPosition - - it "treats lines with only whitespace as a word", -> - editor.setCursorBufferPosition([9, 4]) - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - it "works when the current line is blank", -> - editor.setCursorBufferPosition([10, 0]) - editor.moveToBeginningOfNextWord() - expect(editor.getCursorBufferPosition()).toEqual [11, 9] - - describe ".moveToPreviousSubwordBoundary", -> - it "does not move the cursor when there is no previous subword boundary", -> - editor.setText('') - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - it "stops at word and underscore boundaries", -> - editor.setText("sub_word \n") - editor.setCursorBufferPosition([0, 9]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 8]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - editor.setText(" word\n") - editor.setCursorBufferPosition([0, 3]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "stops at camelCase boundaries", -> - editor.setText(" getPreviousWord\n") - editor.setCursorBufferPosition([0, 16]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 12]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "skips consecutive non-word characters", -> - editor.setText("e, => \n") - editor.setCursorBufferPosition([0, 6]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "skips consecutive uppercase characters", -> - editor.setText(" AAADF \n") - editor.setCursorBufferPosition([0, 7]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 6]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.setText("ALPhA\n") - editor.setCursorBufferPosition([0, 4]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 2]) - - it "skips consecutive numbers", -> - editor.setText(" 88 \n") - editor.setCursorBufferPosition([0, 4]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - it "works with multiple cursors", -> - editor.setText("curOp\ncursorOptions\n") - editor.setCursorBufferPosition([0, 8]) - editor.addCursorAtBufferPosition([1, 13]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveToPreviousSubwordBoundary() - - expect(cursor1.getBufferPosition()).toEqual([0, 3]) - expect(cursor2.getBufferPosition()).toEqual([1, 6]) - - it "works with non-English characters", -> - editor.setText("supåTøåst \n") - editor.setCursorBufferPosition([0, 9]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.setText("supaÖast \n") - editor.setCursorBufferPosition([0, 8]) - editor.moveToPreviousSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - describe ".moveToNextSubwordBoundary", -> - it "does not move the cursor when there is no next subword boundary", -> - editor.setText('') - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - it "stops at word and underscore boundaries", -> - editor.setText(" sub_word \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 9]) - - editor.setText("word \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - it "stops at camelCase boundaries", -> - editor.setText("getPreviousWord \n") - editor.setCursorBufferPosition([0, 0]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 11]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 15]) - - it "skips consecutive non-word characters", -> - editor.setText(", => \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - it "skips consecutive uppercase characters", -> - editor.setText(" AAADF \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 6]) - - editor.setText("ALPhA\n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 2]) - - it "skips consecutive numbers", -> - editor.setText(" 88 \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 1]) - - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 3]) - - it "works with multiple cursors", -> - editor.setText("curOp\ncursorOptions\n") - editor.setCursorBufferPosition([0, 0]) - editor.addCursorAtBufferPosition([1, 0]) - [cursor1, cursor2] = editor.getCursors() - - editor.moveToNextSubwordBoundary() - expect(cursor1.getBufferPosition()).toEqual([0, 3]) - expect(cursor2.getBufferPosition()).toEqual([1, 6]) - - it "works with non-English characters", -> - editor.setText("supåTøåst \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - editor.setText("supaÖast \n") - editor.setCursorBufferPosition([0, 0]) - editor.moveToNextSubwordBoundary() - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - describe ".moveToBeginningOfNextParagraph()", -> - it "moves the cursor before the first line of the next paragraph", -> - editor.setCursorBufferPosition [0, 6] - editor.foldBufferRow(4) - - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - it "moves the cursor before the first line of the next paragraph (CRLF line endings)", -> - editor.setText(editor.getText().replace(/\n/g, '\r\n')) - - editor.setCursorBufferPosition [0, 6] - editor.foldBufferRow(4) - - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfNextParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".moveToBeginningOfPreviousParagraph()", -> - it "moves the cursor before the first line of the previous paragraph", -> - editor.setCursorBufferPosition [10, 0] - editor.foldBufferRow(4) - - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - it "moves the cursor before the first line of the previous paragraph (CRLF line endings)", -> - editor.setText(editor.getText().replace(/\n/g, '\r\n')) - - editor.setCursorBufferPosition [10, 0] - editor.foldBufferRow(4) - - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - editor.setText("") - editor.setCursorBufferPosition [0, 0] - editor.moveToBeginningOfPreviousParagraph() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".getCurrentParagraphBufferRange()", -> - it "returns the buffer range of the current paragraph, delimited by blank lines or the beginning / end of the file", -> - buffer.setText """ - I am the first paragraph, - bordered by the beginning of - the file - #{' '} - - I am the second paragraph - with blank lines above and below - me. - - I am the last paragraph, - bordered by the end of the file. - """ - - # in a paragraph - editor.setCursorBufferPosition([1, 7]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[0, 0], [2, 8]] - - editor.setCursorBufferPosition([7, 1]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[5, 0], [7, 3]] - - editor.setCursorBufferPosition([9, 10]) - expect(editor.getCurrentParagraphBufferRange()).toEqual [[9, 0], [10, 32]] - - # between paragraphs - editor.setCursorBufferPosition([3, 1]) - expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() - - it 'will limit paragraph range to comments', -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) - editor.setText(""" - var quicksort = function () { - /* Single line comment block */ - var sort = function(items) {}; - - /* - A multiline - comment is here - */ - var sort = function(items) {}; - - // A comment - // - // Multiple comment - // lines - var sort = function(items) {}; - // comment line after fn - - var nosort = function(items) { - item; - } - - }; - """) - - paragraphBufferRangeForRow = (row) -> - editor.setCursorBufferPosition([row, 0]) - editor.getLastCursor().getCurrentParagraphBufferRange() - - expect(paragraphBufferRangeForRow(0)).toEqual([[0, 0], [0, 29]]) - expect(paragraphBufferRangeForRow(1)).toEqual([[1, 0], [1, 33]]) - expect(paragraphBufferRangeForRow(2)).toEqual([[2, 0], [2, 32]]) - expect(paragraphBufferRangeForRow(3)).toBeFalsy() - expect(paragraphBufferRangeForRow(4)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(5)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(6)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(7)).toEqual([[4, 0], [7, 4]]) - expect(paragraphBufferRangeForRow(8)).toEqual([[8, 0], [8, 32]]) - expect(paragraphBufferRangeForRow(9)).toBeFalsy() - expect(paragraphBufferRangeForRow(10)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(11)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(12)).toEqual([[10, 0], [13, 10]]) - expect(paragraphBufferRangeForRow(14)).toEqual([[14, 0], [14, 32]]) - expect(paragraphBufferRangeForRow(15)).toEqual([[15, 0], [15, 26]]) - expect(paragraphBufferRangeForRow(18)).toEqual([[17, 0], [19, 3]]) - - describe "getCursorAtScreenPosition(screenPosition)", -> - it "returns the cursor at the given screenPosition", -> - cursor1 = editor.addCursorAtScreenPosition([0, 2]) - cursor2 = editor.getCursorAtScreenPosition(cursor1.getScreenPosition()) - expect(cursor2).toBe cursor1 - - describe "::getCursorScreenPositions()", -> - it "returns the cursor positions in the order they were added", -> - editor.foldBufferRow(4) - cursor1 = editor.addCursorAtBufferPosition([8, 5]) - cursor2 = editor.addCursorAtBufferPosition([3, 5]) - expect(editor.getCursorScreenPositions()).toEqual [[0, 0], [5, 5], [3, 5]] - - describe "::getCursorsOrderedByBufferPosition()", -> - it "returns all cursors ordered by buffer positions", -> - originalCursor = editor.getLastCursor() - cursor1 = editor.addCursorAtBufferPosition([8, 5]) - cursor2 = editor.addCursorAtBufferPosition([4, 5]) - expect(editor.getCursorsOrderedByBufferPosition()).toEqual [originalCursor, cursor2, cursor1] - - describe "addCursorAtScreenPosition(screenPosition)", -> - describe "when a cursor already exists at the position", -> - it "returns the existing cursor", -> - cursor1 = editor.addCursorAtScreenPosition([0, 2]) - cursor2 = editor.addCursorAtScreenPosition([0, 2]) - expect(cursor2).toBe cursor1 - - describe "addCursorAtBufferPosition(bufferPosition)", -> - describe "when a cursor already exists at the position", -> - it "returns the existing cursor", -> - cursor1 = editor.addCursorAtBufferPosition([1, 4]) - cursor2 = editor.addCursorAtBufferPosition([1, 4]) - expect(cursor2.marker).toBe cursor1.marker - - describe '.getCursorScope()', -> - it 'returns the current scope', -> - descriptor = editor.getCursorScope() - expect(descriptor.scopes).toContain('source.js') - - describe "selection", -> - selection = null - - beforeEach -> - selection = editor.getLastSelection() - - describe ".getLastSelection()", -> - it "creates a new selection at (0, 0) if the last selection has been destroyed", -> - editor.getLastSelection().destroy() - expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) - - it "doesn't get stuck in a infinite loop when called from ::onDidAddCursor after the last selection has been destroyed (regression)", -> - callCount = 0 - editor.getLastSelection().destroy() - editor.onDidAddCursor (cursor) -> - callCount++ - editor.getLastSelection() - expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) - expect(callCount).toBe(1) - - describe ".getSelections()", -> - it "creates a new selection at (0, 0) if the last selection has been destroyed", -> - editor.getLastSelection().destroy() - expect(editor.getSelections()[0].getBufferRange()).toEqual([[0, 0], [0, 0]]) - - describe "when the selection range changes", -> - it "emits an event with the old range, new range, and the selection that moved", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - - editor.onDidChangeSelectionRange rangeChangedHandler = jasmine.createSpy() - editor.selectToBufferPosition([6, 2]) - - expect(rangeChangedHandler).toHaveBeenCalled() - eventObject = rangeChangedHandler.mostRecentCall.args[0] - - expect(eventObject.oldBufferRange).toEqual [[3, 0], [4, 5]] - expect(eventObject.oldScreenRange).toEqual [[3, 0], [4, 5]] - expect(eventObject.newBufferRange).toEqual [[3, 0], [6, 2]] - expect(eventObject.newScreenRange).toEqual [[3, 0], [6, 2]] - expect(eventObject.selection).toBe selection - - describe ".selectUp/Down/Left/Right()", -> - it "expands each selection to its cursor's new location", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 14]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 22]] - - editor.selectLeft() - editor.selectLeft() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - editor.selectDown() - expect(selection1.getBufferRange()).toEqual [[0, 9], [1, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [4, 20]] - - editor.selectUp() - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - it "merges selections when they intersect when moving down", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]], [[2, 15], [3, 25]]]) - [selection1, selection2, selection3] = editor.getSelections() - - editor.selectDown() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 9], [4, 25]]) - expect(selection1.isReversed()).toBeFalsy() - - it "merges selections when they intersect when moving up", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]]], reversed: true) - [selection1, selection2] = editor.getSelections() - - editor.selectUp() - expect(editor.getSelections().length).toBe 1 - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 0], [1, 20]]) - expect(selection1.isReversed()).toBeTruthy() - - it "merges selections when they intersect when moving left", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[0, 13], [1, 20]]], reversed: true) - [selection1, selection2] = editor.getSelections() - - editor.selectLeft() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 8], [1, 20]]) - expect(selection1.isReversed()).toBeTruthy() - - it "merges selections when they intersect when moving right", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 14]], [[0, 14], [1, 20]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.getScreenRange()).toEqual([[0, 9], [1, 21]]) - expect(selection1.isReversed()).toBeFalsy() - - describe "when counts are passed into the selection functions", -> - it "expands each selection to its cursor's new location", -> - editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) - [selection1, selection2] = editor.getSelections() - - editor.selectRight(2) - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 15]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 23]] - - editor.selectLeft(3) - expect(selection1.getBufferRange()).toEqual [[0, 9], [0, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [3, 20]] - - editor.selectDown(3) - expect(selection1.getBufferRange()).toEqual [[0, 9], [3, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [6, 20]] - - editor.selectUp(2) - expect(selection1.getBufferRange()).toEqual [[0, 9], [1, 12]] - expect(selection2.getBufferRange()).toEqual [[3, 16], [4, 20]] - - describe ".selectToBufferPosition(bufferPosition)", -> - it "expands the last selection to the given position", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtBufferPosition([5, 6]) - editor.selectToBufferPosition([6, 2]) - - selections = editor.getSelections() - expect(selections.length).toBe 2 - [selection1, selection2] = selections - expect(selection1.getBufferRange()).toEqual [[3, 0], [4, 5]] - expect(selection2.getBufferRange()).toEqual [[5, 6], [6, 2]] - - describe ".selectToScreenPosition(screenPosition)", -> - it "expands the last selection to the given position", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - selections = editor.getSelections() - expect(selections.length).toBe 2 - [selection1, selection2] = selections - expect(selection1.getScreenRange()).toEqual [[3, 0], [4, 5]] - expect(selection2.getScreenRange()).toEqual [[5, 6], [6, 2]] - - describe "when selecting with an initial screen range", -> - it "switches the direction of the selection when selecting to positions before/after the start of the initial range", -> - editor.setCursorScreenPosition([5, 10]) - editor.selectWordsContainingCursors() - editor.selectToScreenPosition([3, 0]) - expect(editor.getLastSelection().isReversed()).toBe true - editor.selectToScreenPosition([9, 0]) - expect(editor.getLastSelection().isReversed()).toBe false - - describe ".selectToBeginningOfNextParagraph()", -> - it "selects from the cursor to first line of the next paragraph", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - editor.selectToBeginningOfNextParagraph() - - selections = editor.getSelections() - expect(selections.length).toBe 1 - expect(selections[0].getScreenRange()).toEqual [[3, 0], [10, 0]] - - describe ".selectToBeginningOfPreviousParagraph()", -> - it "selects from the cursor to the first line of the previous paragraph", -> - editor.setSelectedBufferRange([[3, 0], [4, 5]]) - editor.addCursorAtScreenPosition([5, 6]) - editor.selectToScreenPosition([6, 2]) - - editor.selectToBeginningOfPreviousParagraph() - - selections = editor.getSelections() - expect(selections.length).toBe 1 - expect(selections[0].getScreenRange()).toEqual [[0, 0], [5, 6]] - - it "merges selections if they intersect, maintaining the directionality of the last selection", -> - editor.setCursorScreenPosition([4, 10]) - editor.selectToScreenPosition([5, 27]) - editor.addCursorAtScreenPosition([3, 10]) - editor.selectToScreenPosition([6, 27]) - - selections = editor.getSelections() - expect(selections.length).toBe 1 - [selection1] = selections - expect(selection1.getScreenRange()).toEqual [[3, 10], [6, 27]] - expect(selection1.isReversed()).toBeFalsy() - - editor.addCursorAtScreenPosition([7, 4]) - editor.selectToScreenPosition([4, 11]) - - selections = editor.getSelections() - expect(selections.length).toBe 1 - [selection1] = selections - expect(selection1.getScreenRange()).toEqual [[3, 10], [7, 4]] - expect(selection1.isReversed()).toBeTruthy() - - describe ".selectToTop()", -> - it "selects text from cursor position to the top of the buffer", -> - editor.setCursorScreenPosition [11, 2] - editor.addCursorAtScreenPosition [10, 0] - editor.selectToTop() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - expect(editor.getLastSelection().getBufferRange()).toEqual [[0, 0], [11, 2]] - expect(editor.getLastSelection().isReversed()).toBeTruthy() - - describe ".selectToBottom()", -> - it "selects text from cursor position to the bottom of the buffer", -> - editor.setCursorScreenPosition [10, 0] - editor.addCursorAtScreenPosition [9, 3] - editor.selectToBottom() - expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [12, 2] - expect(editor.getLastSelection().getBufferRange()).toEqual [[9, 3], [12, 2]] - expect(editor.getLastSelection().isReversed()).toBeFalsy() - - describe ".selectAll()", -> - it "selects the entire buffer", -> - editor.selectAll() - expect(editor.getLastSelection().getBufferRange()).toEqual buffer.getRange() - - describe ".selectToBeginningOfLine()", -> - it "selects text from cursor position to beginning of line", -> - editor.setCursorScreenPosition [12, 2] - editor.addCursorAtScreenPosition [11, 3] - - editor.selectToBeginningOfLine() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [12, 0] - expect(cursor2.getBufferPosition()).toEqual [11, 0] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[12, 0], [12, 2]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[11, 0], [11, 3]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".selectToEndOfLine()", -> - it "selects text from cursor position to end of line", -> - editor.setCursorScreenPosition [12, 0] - editor.addCursorAtScreenPosition [11, 3] - - editor.selectToEndOfLine() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [12, 2] - expect(cursor2.getBufferPosition()).toEqual [11, 44] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[12, 0], [12, 2]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[11, 3], [11, 44]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectLinesContainingCursors()", -> - it "selects to the entire line (including newlines) at given row", -> - editor.setCursorScreenPosition([1, 2]) - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[1, 0], [2, 0]] - expect(editor.getSelectedText()).toBe " var sort = function(items) {\n" - - editor.setCursorScreenPosition([12, 2]) - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[12, 0], [12, 2]] - - editor.setCursorBufferPosition([0, 2]) - editor.selectLinesContainingCursors() - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [2, 0]] - - describe "when the selection spans multiple row", -> - it "selects from the beginning of the first line to the last line", -> - selection = editor.getLastSelection() - selection.setBufferRange [[1, 10], [3, 20]] - editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[1, 0], [4, 0]] - - describe ".selectToBeginningOfWord()", -> - it "selects text from cursor position to beginning of word", -> - editor.setCursorScreenPosition [0, 13] - editor.addCursorAtScreenPosition [3, 49] - - editor.selectToBeginningOfWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 4] - expect(cursor2.getBufferPosition()).toEqual [3, 47] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 13]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[3, 47], [3, 49]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".selectToEndOfWord()", -> - it "selects text from cursor position to end of word", -> - editor.setCursorScreenPosition [0, 4] - editor.addCursorAtScreenPosition [3, 48] - - editor.selectToEndOfWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 50] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 13]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 50]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectToBeginningOfNextWord()", -> - it "selects text from cursor position to beginning of next word", -> - editor.setCursorScreenPosition [0, 4] - editor.addCursorAtScreenPosition [3, 48] - - editor.selectToBeginningOfNextWord() - - expect(editor.getCursors().length).toBe 2 - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [3, 51] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 4], [0, 14]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 51]] - expect(selection2.isReversed()).toBeFalsy() - - describe ".selectToPreviousWordBoundary()", -> - it "select to the previous word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 0] - editor.addCursorAtBufferPosition [3, 4] - editor.addCursorAtBufferPosition [3, 14] - - editor.selectToPreviousWordBoundary() - - expect(editor.getSelections().length).toBe 4 - [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 4]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[2, 0], [1, 30]] - expect(selection2.isReversed()).toBeTruthy() - expect(selection3.getBufferRange()).toEqual [[3, 4], [3, 0]] - expect(selection3.isReversed()).toBeTruthy() - expect(selection4.getBufferRange()).toEqual [[3, 14], [3, 13]] - expect(selection4.isReversed()).toBeTruthy() - - describe ".selectToNextWordBoundary()", -> - it "select to the next word boundary", -> - editor.setCursorBufferPosition [0, 8] - editor.addCursorAtBufferPosition [2, 40] - editor.addCursorAtBufferPosition [4, 0] - editor.addCursorAtBufferPosition [3, 30] - - editor.selectToNextWordBoundary() - - expect(editor.getSelections().length).toBe 4 - [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 13]] - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[2, 40], [3, 0]] - expect(selection2.isReversed()).toBeFalsy() - expect(selection3.getBufferRange()).toEqual [[4, 0], [4, 4]] - expect(selection3.isReversed()).toBeFalsy() - expect(selection4.getBufferRange()).toEqual [[3, 30], [3, 31]] - expect(selection4.isReversed()).toBeFalsy() - - describe ".selectToPreviousSubwordBoundary", -> - it "selects subwords", -> - editor.setText("") - editor.insertText("_word\n") - editor.insertText(" getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 5]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 5]) - editor.addCursorAtBufferPosition([3, 3]) - [selection1, selection2, selection3, selection4] = editor.getSelections() - - editor.selectToPreviousSubwordBoundary() - expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 5]]) - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual([[1, 4], [1, 7]]) - expect(selection2.isReversed()).toBeTruthy() - expect(selection3.getBufferRange()).toEqual([[2, 3], [2, 5]]) - expect(selection3.isReversed()).toBeTruthy() - expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) - expect(selection4.isReversed()).toBeTruthy() - - describe ".selectToNextSubwordBoundary", -> - it "selects subwords", -> - editor.setText("") - editor.insertText("word_\n") - editor.insertText("getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 1]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 2]) - editor.addCursorAtBufferPosition([3, 1]) - [selection1, selection2, selection3, selection4] = editor.getSelections() - - editor.selectToNextSubwordBoundary() - expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 4]]) - expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual([[1, 7], [1, 11]]) - expect(selection2.isReversed()).toBeFalsy() - expect(selection3.getBufferRange()).toEqual([[2, 2], [2, 5]]) - expect(selection3.isReversed()).toBeFalsy() - expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) - expect(selection4.isReversed()).toBeFalsy() - - describe ".deleteToBeginningOfSubword", -> - it "deletes subwords", -> - editor.setText("") - editor.insertText("_word\n") - editor.insertText(" getPreviousWord\n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 5]) - editor.addCursorAtBufferPosition([1, 7]) - editor.addCursorAtBufferPosition([2, 5]) - editor.addCursorAtBufferPosition([3, 3]) - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('_') - expect(buffer.lineForRow(1)).toBe(' getviousWord') - expect(buffer.lineForRow(2)).toBe('e, ') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 1]) - expect(cursor2.getBufferPosition()).toEqual([1, 4]) - expect(cursor3.getBufferPosition()).toEqual([2, 3]) - expect(cursor4.getBufferPosition()).toEqual([3, 1]) - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe(' viousWord') - expect(buffer.lineForRow(2)).toBe('e ') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 1]) - expect(cursor3.getBufferPosition()).toEqual([2, 1]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - editor.deleteToBeginningOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe('viousWord') - expect(buffer.lineForRow(2)).toBe(' ') - expect(buffer.lineForRow(3)).toBe('') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 0]) - expect(cursor4.getBufferPosition()).toEqual([2, 1]) - - describe ".deleteToEndOfSubword", -> - it "deletes subwords", -> - editor.setText("") - editor.insertText("word_\n") - editor.insertText("getPreviousWord \n") - editor.insertText("e, => \n") - editor.insertText(" 88 \n") - editor.setCursorBufferPosition([0, 0]) - editor.addCursorAtBufferPosition([1, 0]) - editor.addCursorAtBufferPosition([2, 2]) - editor.addCursorAtBufferPosition([3, 0]) - [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() - - editor.deleteToEndOfSubword() - expect(buffer.lineForRow(0)).toBe('_') - expect(buffer.lineForRow(1)).toBe('PreviousWord ') - expect(buffer.lineForRow(2)).toBe('e, ') - expect(buffer.lineForRow(3)).toBe('88 ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 2]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - editor.deleteToEndOfSubword() - expect(buffer.lineForRow(0)).toBe('') - expect(buffer.lineForRow(1)).toBe('Word ') - expect(buffer.lineForRow(2)).toBe('e,') - expect(buffer.lineForRow(3)).toBe(' ') - expect(cursor1.getBufferPosition()).toEqual([0, 0]) - expect(cursor2.getBufferPosition()).toEqual([1, 0]) - expect(cursor3.getBufferPosition()).toEqual([2, 2]) - expect(cursor4.getBufferPosition()).toEqual([3, 0]) - - describe ".selectWordsContainingCursors()", -> - describe "when the cursor is inside a word", -> - it "selects the entire word", -> - editor.setCursorScreenPosition([0, 8]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'quicksort' - - describe "when the cursor is between two words", -> - it "selects the word the cursor is on", -> - editor.setCursorScreenPosition([0, 4]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.setCursorScreenPosition([0, 3]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedText()).toBe 'var' - - describe "when the cursor is inside a region of whitespace", -> - it "selects the whitespace region", -> - editor.setCursorScreenPosition([5, 2]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[5, 0], [5, 6]] - - editor.setCursorScreenPosition([5, 0]) - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[5, 0], [5, 6]] - - describe "when the cursor is at the end of the text", -> - it "select the previous word", -> - editor.buffer.append 'word' - editor.moveToBottom() - editor.selectWordsContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[12, 2], [12, 6]] - - it "selects words based on the non-word characters configured at the cursor's current scope", -> - editor.setText("one-one; 'two-two'; three-three") - - editor.setCursorBufferPosition([0, 1]) - editor.addCursorAtBufferPosition([0, 12]) - - scopeDescriptors = editor.getCursors().map (c) -> c.getScopeDescriptor() - expect(scopeDescriptors[0].getScopesArray()).toEqual(['source.js']) - expect(scopeDescriptors[1].getScopesArray()).toEqual(['source.js', 'string.quoted.single.js']) - - editor.setScopedSettingsDelegate({ - getNonWordCharacters: (scopes) -> - result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' - if (scopes.some (scope) -> scope.startsWith('string')) - result - else - result + '-' - }) - - editor.selectWordsContainingCursors() - - expect(editor.getSelections()[0].getText()).toBe('one') - expect(editor.getSelections()[1].getText()).toBe('two-two') - - describe ".selectToFirstCharacterOfLine()", -> - it "moves to the first character of the current line or the beginning of the line if it's already on the first character", -> - editor.setCursorScreenPosition [0, 5] - editor.addCursorAtScreenPosition [1, 7] - - editor.selectToFirstCharacterOfLine() - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor2.getBufferPosition()).toEqual [1, 2] - - expect(editor.getSelections().length).toBe 2 - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 0], [0, 5]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1, 2], [1, 7]] - expect(selection2.isReversed()).toBeTruthy() - - editor.selectToFirstCharacterOfLine() - [selection1, selection2] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0, 0], [0, 5]] - expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1, 0], [1, 7]] - expect(selection2.isReversed()).toBeTruthy() - - describe ".setSelectedBufferRanges(ranges)", -> - it "clears existing selections and creates selections for each of the given ranges", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [3, 3]], [[4, 4], [5, 5]]] - - editor.setSelectedBufferRanges([[[5, 5], [6, 6]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[5, 5], [6, 6]]] - - it "merges intersecting selections", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [5, 5]]] - - it "does not merge non-empty adjacent selections", -> - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [3, 3]], [[3, 3], [5, 5]]] - - it "recycles existing selection instances", -> - selection = editor.getLastSelection() - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) - - [selection1, selection2] = editor.getSelections() - expect(selection1).toBe selection - expect(selection1.getBufferRange()).toEqual [[2, 2], [3, 3]] - - describe "when the 'preserveFolds' option is false (the default)", -> - it "removes folds that contain the selections", -> - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - editor.foldBufferRowRange(1, 4) - editor.foldBufferRowRange(2, 3) - editor.foldBufferRowRange(6, 8) - editor.foldBufferRowRange(10, 11) - - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 6], [7, 7]]]) - expect(editor.isFoldedAtScreenRow(1)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(6)).toBeFalsy() - expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() - - describe "when the 'preserveFolds' option is true", -> - it "does not remove folds that contain the selections", -> - editor.setSelectedBufferRange([[0, 0], [0, 0]]) - editor.foldBufferRowRange(1, 4) - editor.foldBufferRowRange(6, 8) - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 0], [6, 1]]], preserveFolds: true) - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - - describe ".setSelectedScreenRanges(ranges)", -> - beforeEach -> - editor.foldBufferRow(4) - - it "clears existing selections and creates selections for each of the given ranges", -> - editor.setSelectedScreenRanges([[[3, 4], [3, 7]], [[5, 4], [5, 7]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[3, 4], [3, 7]], [[8, 4], [8, 7]]] - - editor.setSelectedScreenRanges([[[6, 2], [6, 4]]]) - expect(editor.getSelectedScreenRanges()).toEqual [[[6, 2], [6, 4]]] - - it "merges intersecting selections and unfolds the fold which contain them", -> - editor.foldBufferRow(0) - - # Use buffer ranges because only the first line is on screen - editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [5, 5]]] - - it "recycles existing selection instances", -> - selection = editor.getLastSelection() - editor.setSelectedScreenRanges([[[2, 2], [3, 4]], [[4, 4], [5, 5]]]) - - [selection1, selection2] = editor.getSelections() - expect(selection1).toBe selection - expect(selection1.getScreenRange()).toEqual [[2, 2], [3, 4]] - - describe ".selectMarker(marker)", -> - describe "if the marker is valid", -> - it "selects the marker's range and returns the selected range", -> - marker = editor.markBufferRange([[0, 1], [3, 3]]) - expect(editor.selectMarker(marker)).toEqual [[0, 1], [3, 3]] - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 3]] - - describe "if the marker is invalid", -> - it "does not change the selection and returns a falsy value", -> - marker = editor.markBufferRange([[0, 1], [3, 3]]) - marker.destroy() - expect(editor.selectMarker(marker)).toBeFalsy() - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 0]] - - describe ".addSelectionForBufferRange(bufferRange)", -> - it "adds a selection for the specified buffer range", -> - editor.addSelectionForBufferRange([[3, 4], [5, 6]]) - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 0]], [[3, 4], [5, 6]]] - - describe ".addSelectionBelow()", -> - describe "when the selection is non-empty", -> - it "selects the same region of the line below current selections if possible", -> - editor.setSelectedBufferRange([[3, 16], [3, 21]]) - editor.addSelectionForBufferRange([[3, 25], [3, 34]]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 16], [3, 21]] - [[3, 25], [3, 34]] - [[4, 16], [4, 21]] - [[4, 25], [4, 29]] - ] - - it "skips lines that are too short to create a non-empty selection", -> - editor.setSelectedBufferRange([[3, 31], [3, 38]]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 31], [3, 38]] - [[6, 31], [6, 38]] - ] - - it "honors the original selection's range (goal range) when adding across shorter lines", -> - editor.setSelectedBufferRange([[3, 22], [3, 38]]) - editor.addSelectionBelow() - editor.addSelectionBelow() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 38]] - [[4, 22], [4, 29]] - [[5, 22], [5, 30]] - [[6, 22], [6, 38]] - ] - - it "clears selection goal ranges when the selection changes", -> - editor.setSelectedBufferRange([[3, 22], [3, 38]]) - editor.addSelectionBelow() - editor.selectLeft() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 37]] - [[4, 22], [4, 29]] - [[5, 22], [5, 28]] - ] - - # goal range from previous add selection is honored next time - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 22], [3, 37]] - [[4, 22], [4, 29]] - [[5, 22], [5, 30]] # select to end of line 5 because line 4's goal range was reset by line 3 previously - [[6, 22], [6, 28]] - ] - - it "can add selections to soft-wrapped line segments", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(40) - editor.setDefaultCharWidth(1) - - editor.setSelectedScreenRange([[3, 10], [3, 15]]) - editor.addSelectionBelow() - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 10], [3, 15]] - [[4, 10], [4, 15]] - ] - - it "takes atomic tokens into account", -> - waitsForPromise -> - atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', autoIndent: false).then (o) -> editor = o - - runs -> - editor.setSelectedBufferRange([[2, 1], [2, 3]]) - editor.addSelectionBelow() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 1], [2, 3]] - [[3, 1], [3, 2]] - ] - - describe "when the selection is empty", -> - describe "when lines are soft-wrapped", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - it "skips soft-wrap indentation tokens", -> - editor.setCursorScreenPosition([3, 0]) - editor.addSelectionBelow() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 0], [3, 0]] - [[4, 4], [4, 4]] - ] - - it "does not skip them if they're shorter than the current column", -> - editor.setCursorScreenPosition([3, 37]) - editor.addSelectionBelow() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[3, 37], [3, 37]] - [[4, 26], [4, 26]] - ] - - it "does not skip lines that are shorter than the current column", -> - editor.setCursorBufferPosition([3, 36]) - editor.addSelectionBelow() - editor.addSelectionBelow() - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 36], [3, 36]] - [[4, 29], [4, 29]] - [[5, 30], [5, 30]] - [[6, 36], [6, 36]] - ] - - it "skips empty lines when the column is non-zero", -> - editor.setCursorBufferPosition([9, 4]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[9, 4], [9, 4]] - [[11, 4], [11, 4]] - ] - - it "does not skip empty lines when the column is zero", -> - editor.setCursorBufferPosition([9, 0]) - editor.addSelectionBelow() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[9, 0], [9, 0]] - [[10, 0], [10, 0]] - ] - - describe ".addSelectionAbove()", -> - describe "when the selection is non-empty", -> - it "selects the same region of the line above current selections if possible", -> - editor.setSelectedBufferRange([[3, 16], [3, 21]]) - editor.addSelectionForBufferRange([[3, 37], [3, 44]]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 16], [3, 21]] - [[3, 37], [3, 44]] - [[2, 16], [2, 21]] - [[2, 37], [2, 40]] - ] - - it "skips lines that are too short to create a non-empty selection", -> - editor.setSelectedBufferRange([[6, 31], [6, 38]]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 31], [6, 38]] - [[3, 31], [3, 38]] - ] - - it "honors the original selection's range (goal range) when adding across shorter lines", -> - editor.setSelectedBufferRange([[6, 22], [6, 38]]) - editor.addSelectionAbove() - editor.addSelectionAbove() - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 22], [6, 38]] - [[5, 22], [5, 30]] - [[4, 22], [4, 29]] - [[3, 22], [3, 38]] - ] - - it "can add selections to soft-wrapped line segments", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - editor.setSelectedScreenRange([[4, 10], [4, 15]]) - editor.addSelectionAbove() - expect(editor.getSelectedScreenRanges()).toEqual [ - [[4, 10], [4, 15]] - [[3, 10], [3, 15]] - ] - - it "takes atomic tokens into account", -> - waitsForPromise -> - atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', autoIndent: false).then (o) -> editor = o - - runs -> - editor.setSelectedBufferRange([[3, 1], [3, 2]]) - editor.addSelectionAbove() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[3, 1], [3, 2]] - [[2, 1], [2, 3]] - ] - - describe "when the selection is empty", -> - describe "when lines are soft-wrapped", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(40) - - it "skips soft-wrap indentation tokens", -> - editor.setCursorScreenPosition([5, 0]) - editor.addSelectionAbove() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[5, 0], [5, 0]] - [[4, 4], [4, 4]] - ] - - it "does not skip them if they're shorter than the current column", -> - editor.setCursorScreenPosition([5, 29]) - editor.addSelectionAbove() - - expect(editor.getSelectedScreenRanges()).toEqual [ - [[5, 29], [5, 29]] - [[4, 26], [4, 26]] - ] - - it "does not skip lines that are shorter than the current column", -> - editor.setCursorBufferPosition([6, 36]) - editor.addSelectionAbove() - editor.addSelectionAbove() - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[6, 36], [6, 36]] - [[5, 30], [5, 30]] - [[4, 29], [4, 29]] - [[3, 36], [3, 36]] - ] - - it "skips empty lines when the column is non-zero", -> - editor.setCursorBufferPosition([11, 4]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[11, 4], [11, 4]] - [[9, 4], [9, 4]] - ] - - it "does not skip empty lines when the column is zero", -> - editor.setCursorBufferPosition([10, 0]) - editor.addSelectionAbove() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[10, 0], [10, 0]] - [[9, 0], [9, 0]] - ] - - describe ".splitSelectionsIntoLines()", -> - it "splits all multi-line selections into one selection per line", -> - editor.setSelectedBufferRange([[0, 3], [2, 4]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 29]] - [[1, 0], [1, 30]] - [[2, 0], [2, 4]] - ] - - editor.setSelectedBufferRange([[0, 3], [1, 10]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 29]] - [[1, 0], [1, 10]] - ] - - editor.setSelectedBufferRange([[0, 0], [0, 3]]) - editor.splitSelectionsIntoLines() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]]] - - describe "::consolidateSelections()", -> - makeMultipleSelections = -> - selection.setBufferRange [[3, 16], [3, 21]] - selection2 = editor.addSelectionForBufferRange([[3, 25], [3, 34]]) - selection3 = editor.addSelectionForBufferRange([[8, 4], [8, 10]]) - selection4 = editor.addSelectionForBufferRange([[1, 6], [1, 10]]) - expect(editor.getSelections()).toEqual [selection, selection2, selection3, selection4] - [selection, selection2, selection3, selection4] - - it "destroys all selections but the oldest selection and autoscrolls to it, returning true if any selections were destroyed", -> - [selection1] = makeMultipleSelections() - - autoscrollEvents = [] - editor.onDidRequestAutoscroll (event) -> autoscrollEvents.push(event) - - expect(editor.consolidateSelections()).toBeTruthy() - expect(editor.getSelections()).toEqual [selection1] - expect(selection1.isEmpty()).toBeFalsy() - expect(editor.consolidateSelections()).toBeFalsy() - expect(editor.getSelections()).toEqual [selection1] - - expect(autoscrollEvents).toEqual([ - {screenRange: selection1.getScreenRange(), options: {center: true, reversed: false}} - ]) - - describe "when the cursor is moved while there is a selection", -> - makeSelection = -> selection.setBufferRange [[1, 2], [1, 5]] - - it "clears the selection", -> - makeSelection() - editor.moveDown() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveUp() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveLeft() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.moveRight() - expect(selection.isEmpty()).toBeTruthy() - - makeSelection() - editor.setCursorScreenPosition([3, 3]) - expect(selection.isEmpty()).toBeTruthy() - - it "does not share selections between different edit sessions for the same buffer", -> - editor2 = null - waitsForPromise -> - atom.workspace.getActivePane().splitRight() - atom.workspace.open(editor.getPath()).then (o) -> editor2 = o - - runs -> - expect(editor2.getText()).toBe(editor.getText()) - editor.setSelectedBufferRanges([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) - editor2.setSelectedBufferRanges([[[8, 7], [6, 5]], [[4, 3], [2, 1]]]) - expect(editor2.getSelectedBufferRanges()).not.toEqual editor.getSelectedBufferRanges() - - describe "buffer manipulation", -> - describe ".moveLineUp", -> - it "moves the line under the cursor up", -> - editor.setCursorBufferPosition([1, 0]) - editor.moveLineUp() - expect(editor.getTextInBufferRange([[0, 0], [0, 30]])).toBe " var sort = function(items) {" - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 0 - - it "updates the line's indentation when the the autoIndent setting is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([1, 0]) - editor.moveLineUp() - expect(editor.indentationForBufferRow(0)).toBe 0 - expect(editor.indentationForBufferRow(1)).toBe 0 - - describe "when there is a single selection", -> - describe "when the selection spans a single line", -> - describe "when there is no fold in the preceeding row", -> - it "moves the line to the preceding row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.setSelectedBufferRange([[3, 2], [3, 9]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [2, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is at the beginning of a fold", -> - it "moves the line to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [4, 9]], preserveFolds: true) - expect(editor.getSelectedBufferRange()).toEqual [[4, 2], [4, 9]] - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [3, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - - describe "when the preceding row consists of folded code", -> - it "moves the line above the folded row and perseveres the correct folds", -> - expect(editor.lineTextForBufferRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[8, 0], [8, 4]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 0], [4, 4]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the selection spans multiple lines", -> - it "moves the lines spanned by the selection to the preceding row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[3, 2], [4, 9]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " if (items.length <= 1) return items;" - - describe "when the selection's end intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[3, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " if (items.length <= 1) return items;" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - describe "when the selection's start intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [8, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [7, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(8)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - - describe "when the selection spans multiple lines, but ends at column 0", -> - it "does not move the last line of the selection", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[3, 2], [4, 0]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[2, 2], [3, 0]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - describe "when the preceeding row is a folded row", -> - it "moves the lines spanned by the selection to the preceeding row, but preserves the folded code", -> - expect(editor.lineTextForBufferRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - editor.foldBufferRowRange(4, 7) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[8, 0], [9, 2]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 0], [5, 2]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " };" - expect(editor.lineTextForBufferRow(6)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(5)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - describe "when there are multiple selections", -> - describe "when all the selections span different lines", -> - describe "when there is no folds", -> - it "moves all lines that are spanned by a selection to the preceding row", -> - editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [0, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]] - expect(editor.lineTextForBufferRow(0)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(1)).toBe "var quicksort = function () {" - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " current = items.shift();" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - describe "when one selection intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRanges([ - [[2, 2], [2, 9]], - [[4, 2], [4, 9]] - ], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 2], [1, 9]], - [[3, 2], [3, 9]] - ]) - - expect(editor.lineTextForBufferRow(1)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - describe "when there is a fold", -> - it "moves all lines that spanned by a selection to preceding row, preserving all folds", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[8, 0], [8, 3]], [[11, 0], [11, 5]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[4, 0], [4, 3]], [[10, 0], [10, 5]]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(10)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe 'when there are many folds', -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample-with-many-folds.js', autoIndent: false).then (o) -> editor = o - - describe 'and many selections intersects folded rows', -> - it 'moves and preserves all the folds', -> - editor.foldBufferRowRange(2, 4) - editor.foldBufferRowRange(7, 9) - - editor.setSelectedBufferRanges([ - [[1, 0], [5, 4]], - [[7, 0], [7, 4]] - ], preserveFolds: true) - - editor.moveLineUp() - - expect(editor.lineTextForBufferRow(1)).toEqual "function f3() {" - expect(editor.lineTextForBufferRow(4)).toEqual "6;" - expect(editor.lineTextForBufferRow(5)).toEqual "1;" - expect(editor.lineTextForBufferRow(6)).toEqual "function f8() {" - expect(editor.lineTextForBufferRow(9)).toEqual "7;" - - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when some of the selections span the same lines", -> - it "moves lines that contain multiple selections correctly", -> - editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 2], [2, 9]], [[2, 12], [2, 13]]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when one of the selections spans line 0", -> - it "doesn't move any lines, since line 0 can't move", -> - editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]] - expect(buffer.isModified()).toBe false - - describe "when one of the selections spans the last line, and it is empty", -> - it "doesn't move any lines, since the last line can't move", -> - buffer.append('\n') - editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) - - editor.moveLineUp() - - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]] - - describe ".moveLineDown", -> - it "moves the line under the cursor down", -> - editor.setCursorBufferPosition([0, 0]) - editor.moveLineDown() - expect(editor.getTextInBufferRange([[1, 0], [1, 31]])).toBe "var quicksort = function () {" - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 0 - - it "updates the line's indentation when the editor.autoIndent setting is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([0, 0]) - editor.moveLineDown() - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 2 - - describe "when there is a single selection", -> - describe "when the selection spans a single line", -> - describe "when there is no fold in the following row", -> - it "moves the line to the following row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.setSelectedBufferRange([[2, 2], [2, 9]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [3, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is at the beginning of a fold", -> - it "moves the line to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[5, 2], [5, 9]] - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the following row is a folded row", -> - it "moves the line below the folded row and preserves the fold", -> - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[3, 0], [3, 4]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[7, 0], [7, 4]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - - - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when the selection spans multiple lines", -> - it "moves the lines spanned by the selection to the following row", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[2, 2], [3, 9]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [4, 9]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when the selection spans multiple lines, but ends at column 0", -> - it "does not move the last line of the selection", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.setSelectedBufferRange([[2, 2], [3, 0]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[3, 2], [4, 0]] - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - describe "when the selection's end intersects a fold", -> - it "moves the lines to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[3, 2], [4, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[4, 2], [5, 9]] - expect(editor.lineTextForBufferRow(3)).toBe " return sort(left).concat(pivot).concat(sort(right));" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when the selection's start intersects a fold", -> - it "moves the lines to the following row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRange([[4, 2], [8, 9]], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[5, 2], [9, 9]] - expect(editor.lineTextForBufferRow(4)).toBe " };" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(9)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - describe "when the following row is a folded row", -> - it "moves the lines spanned by the selection to the following row, but preserves the folded code", -> - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - editor.foldBufferRowRange(4, 7) - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRange([[2, 0], [3, 2]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRange()).toEqual [[6, 0], [7, 2]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - - describe "when the last line of selection does not end with a valid line ending", -> - it "appends line ending to last line and moves the lines spanned by the selection to the preceeding row", -> - expect(editor.lineTextForBufferRow(9)).toBe " };" - expect(editor.lineTextForBufferRow(10)).toBe "" - expect(editor.lineTextForBufferRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.lineTextForBufferRow(12)).toBe "};" - - editor.setSelectedBufferRange([[10, 0], [12, 2]]) - editor.moveLineUp() - - expect(editor.getSelectedBufferRange()).toEqual [[9, 0], [11, 2]] - expect(editor.lineTextForBufferRow(9)).toBe "" - expect(editor.lineTextForBufferRow(10)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.lineTextForBufferRow(11)).toBe "};" - expect(editor.lineTextForBufferRow(12)).toBe " };" - - describe "when there are multiple selections", -> - describe "when all the selections span different lines", -> - describe "when there is no folds", -> - it "moves all lines that are spanned by a selection to the following row", -> - editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[6, 2], [6, 9]], [[4, 2], [4, 9]], [[2, 2], [2, 9]]] - expect(editor.lineTextForBufferRow(1)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(6)).toBe " current = items.shift();" - - describe 'when there are many folds', -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample-with-many-folds.js', autoIndent: false).then (o) -> editor = o - - describe 'and many selections intersects folded rows', -> - it 'moves and preserves all the folds', -> - editor.foldBufferRowRange(2, 4) - editor.foldBufferRowRange(7, 9) - - editor.setSelectedBufferRanges([ - [[2, 0], [2, 4]], - [[6, 0], [10, 4]] - ], preserveFolds: true) - - editor.moveLineDown() - - expect(editor.lineTextForBufferRow(2)).toEqual "6;" - expect(editor.lineTextForBufferRow(3)).toEqual "function f3() {" - expect(editor.lineTextForBufferRow(6)).toEqual "12;" - expect(editor.lineTextForBufferRow(7)).toEqual "7;" - expect(editor.lineTextForBufferRow(8)).toEqual "function f8() {" - expect(editor.lineTextForBufferRow(11)).toEqual "11;" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(11)).toBeFalsy() - - describe "when there is a fold below one of the selected row", -> - it "moves all lines spanned by a selection to the following row, preserving the fold", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[1, 2], [1, 6]], [[3, 0], [3, 4]], [[8, 0], [8, 3]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[9, 0], [9, 3]], [[7, 0], [7, 4]], [[2, 2], [2, 6]]] - expect(editor.lineTextForBufferRow(2)).toBe " var sort = function(items) {" - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(9)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - describe "when there is a fold below a group of multiple selections without any lines with no selection in-between", -> - it "moves all the lines below the fold, preserving the fold", -> - editor.foldBufferRowRange(4, 7) - - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - - editor.setSelectedBufferRanges([[[2, 2], [2, 6]], [[3, 0], [3, 4]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[7, 0], [7, 4]], [[6, 2], [6, 6]]] - expect(editor.lineTextForBufferRow(2)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(7)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when one selection intersects a fold", -> - it "moves the lines to the previous row without breaking the fold", -> - expect(editor.lineTextForBufferRow(4)).toBe " while(items.length > 0) {" - - editor.foldBufferRowRange(4, 7) - editor.setSelectedBufferRanges([ - [[2, 2], [2, 9]], - [[4, 2], [4, 9]] - ], preserveFolds: true) - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual([ - [[5, 2], [5, 9]] - [[3, 2], [3, 9]], - ]) - - expect(editor.lineTextForBufferRow(2)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " if (items.length <= 1) return items;" - expect(editor.lineTextForBufferRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(9)).toBe " };" - - expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() - - describe "when some of the selections span the same lines", -> - it "moves lines that contain multiple selections correctly", -> - editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) - editor.moveLineDown() - - expect(editor.getSelectedBufferRanges()).toEqual [[[4, 12], [4, 13]], [[4, 2], [4, 9]]] - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - - describe "when the selections are above a wrapped line", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(80) - editor.setText(""" - 1 - 2 - Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. - 3 - 4 - """) - - it 'moves the lines past the soft wrapped line', -> - editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 0]]]) - - editor.moveLineDown() - - expect(editor.lineTextForBufferRow(0)).not.toBe "2" - expect(editor.lineTextForBufferRow(1)).toBe "1" - expect(editor.lineTextForBufferRow(2)).toBe "2" - - describe "when the line is the last buffer row", -> - it "doesn't move it", -> - editor.setText("abc\ndef") - editor.setCursorBufferPosition([1, 0]) - editor.moveLineDown() - expect(editor.getText()).toBe("abc\ndef") - - describe ".insertText(text)", -> - describe "when there is a single selection", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 0], [1, 2]]) - - it "replaces the selection with the given text", -> - range = editor.insertText('xxx') - expect(range).toEqual [ [[1, 0], [1, 3]] ] - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - describe "when there are multiple empty selections", -> - describe "when the cursors are on the same line", -> - it "inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", -> - editor.setCursorScreenPosition([1, 2]) - editor.addCursorAtScreenPosition([1, 5]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe ' xxxvarxxx sort = function(items) {' - [cursor1, cursor2] = editor.getCursors() - - expect(cursor1.getBufferPosition()).toEqual [1, 5] - expect(cursor2.getBufferPosition()).toEqual [1, 11] - - describe "when the cursors are on different lines", -> - it "inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", -> - editor.setCursorScreenPosition([1, 2]) - editor.addCursorAtScreenPosition([2, 4]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe ' xxxvar sort = function(items) {' - expect(buffer.lineForRow(2)).toBe ' xxxif (items.length <= 1) return items;' - [cursor1, cursor2] = editor.getCursors() - - expect(cursor1.getBufferPosition()).toEqual [1, 5] - expect(cursor2.getBufferPosition()).toEqual [2, 7] - - describe "when there are multiple non-empty selections", -> - describe "when the selections are on the same line", -> - it "replaces each selection range with the inserted characters", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 22], [0, 24]]]) - editor.insertText("x") - - [cursor1, cursor2] = editor.getCursors() - [selection1, selection2] = editor.getSelections() - - expect(cursor1.getScreenPosition()).toEqual [0, 5] - expect(cursor2.getScreenPosition()).toEqual [0, 15] - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - expect(editor.lineTextForBufferRow(0)).toBe "var x = functix () {" - - describe "when the selections are on different lines", -> - it "replaces each selection with the given text, clears the selections, and places the cursor at the end of each selection's inserted text", -> - editor.setSelectedBufferRanges([[[1, 0], [1, 2]], [[2, 0], [2, 4]]]) - - editor.insertText('xxx') - - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - expect(buffer.lineForRow(2)).toBe 'xxxif (items.length <= 1) return items;' - [selection1, selection2] = editor.getSelections() - - expect(selection1.isEmpty()).toBeTruthy() - expect(selection1.cursor.getBufferPosition()).toEqual [1, 3] - expect(selection2.isEmpty()).toBeTruthy() - expect(selection2.cursor.getBufferPosition()).toEqual [2, 3] - - describe "when there is a selection that ends on a folded line", -> - it "destroys the selection", -> - editor.foldBufferRowRange(2, 4) - editor.setSelectedBufferRange([[1, 0], [2, 0]]) - editor.insertText('holy cow') - expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() - - describe "when there are ::onWillInsertText and ::onDidInsertText observers", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 0], [1, 2]]) - - it "notifies the observers when inserting text", -> - willInsertSpy = jasmine.createSpy().andCallFake -> - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) {' - - didInsertSpy = jasmine.createSpy().andCallFake -> - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - editor.onWillInsertText(willInsertSpy) - editor.onDidInsertText(didInsertSpy) - - expect(editor.insertText('xxx')).toBeTruthy() - expect(buffer.lineForRow(1)).toBe 'xxxvar sort = function(items) {' - - expect(willInsertSpy).toHaveBeenCalled() - expect(didInsertSpy).toHaveBeenCalled() - - options = willInsertSpy.mostRecentCall.args[0] - expect(options.text).toBe 'xxx' - expect(options.cancel).toBeDefined() - - options = didInsertSpy.mostRecentCall.args[0] - expect(options.text).toBe 'xxx' - - it "cancels text insertion when an ::onWillInsertText observer calls cancel on an event", -> - willInsertSpy = jasmine.createSpy().andCallFake ({cancel}) -> - cancel() - - didInsertSpy = jasmine.createSpy() - - editor.onWillInsertText(willInsertSpy) - editor.onDidInsertText(didInsertSpy) - - expect(editor.insertText('xxx')).toBe false - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) {' - - expect(willInsertSpy).toHaveBeenCalled() - expect(didInsertSpy).not.toHaveBeenCalled() - - describe "when the undo option is set to 'skip'", -> - beforeEach -> - editor.setSelectedBufferRange([[1, 2], [1, 2]]) - - it "does not undo the skipped operation", -> - range = editor.insertText('x') - range = editor.insertText('y', undo: 'skip') - editor.undo() - expect(buffer.lineForRow(1)).toBe ' yvar sort = function(items) {' - - describe ".insertNewline()", -> - describe "when there is a single cursor", -> - describe "when the cursor is at the beginning of a line", -> - it "inserts an empty line before it", -> - editor.setCursorScreenPosition(row: 1, column: 0) - - editor.insertNewline() - - expect(buffer.lineForRow(1)).toBe '' - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when the cursor is in the middle of a line", -> - it "splits the current line to form a new line", -> - editor.setCursorScreenPosition(row: 1, column: 6) - originalLine = buffer.lineForRow(1) - lineBelowOriginalLine = buffer.lineForRow(2) - - editor.insertNewline() - - expect(buffer.lineForRow(1)).toBe originalLine[0...6] - expect(buffer.lineForRow(2)).toBe originalLine[6..] - expect(buffer.lineForRow(3)).toBe lineBelowOriginalLine - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when the cursor is on the end of a line", -> - it "inserts an empty line after it", -> - editor.setCursorScreenPosition(row: 1, column: buffer.lineForRow(1).length) - - editor.insertNewline() - - expect(buffer.lineForRow(2)).toBe '' - expect(editor.getCursorScreenPosition()).toEqual(row: 2, column: 0) - - describe "when there are multiple cursors", -> - describe "when the cursors are on the same line", -> - it "breaks the line at the cursor locations", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.insertNewline() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot" - expect(editor.lineTextForBufferRow(4)).toBe " = items.shift(), current" - expect(editor.lineTextForBufferRow(5)).toBe ", left = [], right = [];" - expect(editor.lineTextForBufferRow(6)).toBe " while(items.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [4, 0] - expect(cursor2.getBufferPosition()).toEqual [5, 0] - - describe "when the cursors are on different lines", -> - it "inserts newlines at each cursor location", -> - editor.setCursorScreenPosition([3, 0]) - editor.addCursorAtScreenPosition([6, 0]) - - editor.insertText("\n") - expect(editor.lineTextForBufferRow(3)).toBe "" - expect(editor.lineTextForBufferRow(4)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(5)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(6)).toBe " current = items.shift();" - expect(editor.lineTextForBufferRow(7)).toBe "" - expect(editor.lineTextForBufferRow(8)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(9)).toBe " }" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [4, 0] - expect(cursor2.getBufferPosition()).toEqual [8, 0] - - describe ".insertNewlineBelow()", -> - describe "when the operation is undone", -> - it "places the cursor back at the previous location", -> - editor.setCursorBufferPosition([0, 2]) - editor.insertNewlineBelow() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.undo() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - - it "inserts a newline below the cursor's current line, autoindents it, and moves the cursor to the end of the line", -> - editor.update({autoIndent: true}) - editor.insertNewlineBelow() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " " - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - - describe ".insertNewlineAbove()", -> - describe "when the cursor is on first line", -> - it "inserts a newline on the first line and moves the cursor to the first line", -> - editor.setCursorBufferPosition([0]) - editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - expect(editor.lineTextForBufferRow(0)).toBe '' - expect(editor.lineTextForBufferRow(1)).toBe 'var quicksort = function () {' - expect(editor.buffer.getLineCount()).toBe 14 - - describe "when the cursor is not on the first line", -> - it "inserts a newline above the current line and moves the cursor to the inserted line", -> - editor.setCursorBufferPosition([3, 4]) - editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [3, 0] - expect(editor.lineTextForBufferRow(3)).toBe '' - expect(editor.lineTextForBufferRow(4)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(editor.buffer.getLineCount()).toBe 14 - - editor.undo() - expect(editor.getCursorBufferPosition()).toEqual [3, 4] - - it "indents the new line to the correct level when editor.autoIndent is true", -> - editor.update({autoIndent: true}) - - editor.setText(' var test') - editor.setCursorBufferPosition([0, 2]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - expect(editor.lineTextForBufferRow(0)).toBe ' ' - expect(editor.lineTextForBufferRow(1)).toBe ' var test' - - editor.setText('\n var test') - editor.setCursorBufferPosition([1, 2]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - expect(editor.lineTextForBufferRow(0)).toBe '' - expect(editor.lineTextForBufferRow(1)).toBe ' ' - expect(editor.lineTextForBufferRow(2)).toBe ' var test' - - editor.setText('function() {\n}') - editor.setCursorBufferPosition([1, 1]) - editor.insertNewlineAbove() - - expect(editor.getCursorBufferPosition()).toEqual [1, 2] - expect(editor.lineTextForBufferRow(0)).toBe 'function() {' - expect(editor.lineTextForBufferRow(1)).toBe ' ' - expect(editor.lineTextForBufferRow(2)).toBe '}' - - describe ".insertNewLine()", -> - describe "when a new line is appended before a closing tag (e.g. by pressing enter before a selection)", -> - it "moves the line down and keeps the indentation level the same when editor.autoIndent is true", -> - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([9, 2]) - editor.insertNewline() - expect(editor.lineTextForBufferRow(10)).toBe ' };' - - describe "when a newline is appended with a trailing closing tag behind the cursor (e.g. by pressing enter in the middel of a line)", -> - it "indents the new line to the correct level when editor.autoIndent is true and using a curly-bracket language", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.js")) - editor.setText('var test = function () {\n return true;};') - editor.setCursorBufferPosition([1, 14]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - - it "indents the new line to the current level when editor.autoIndent is true and no increaseIndentPattern is specified", -> - runs -> - editor.setGrammar(atom.grammars.selectGrammar("file")) - editor.update({autoIndent: true}) - editor.setText(' if true') - editor.setCursorBufferPosition([0, 8]) - editor.insertNewline() - expect(editor.getGrammar()).toBe atom.grammars.nullGrammar - expect(editor.indentationForBufferRow(0)).toBe 1 - expect(editor.indentationForBufferRow(1)).toBe 1 - - it "indents the new line to the correct level when editor.autoIndent is true and using an off-side rule language", -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.coffee")) - editor.setText('if true\n return trueelse\n return false') - editor.setCursorBufferPosition([1, 13]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - expect(editor.indentationForBufferRow(3)).toBe 1 - - describe "when a newline is appended on a line that matches the decreaseNextIndentPattern", -> - it "indents the new line to the correct level when editor.autoIndent is true", -> - waitsForPromise -> - atom.packages.activatePackage('language-go') - - runs -> - editor.update({autoIndent: true}) - editor.setGrammar(atom.grammars.selectGrammar("file.go")) - editor.setText('fmt.Printf("some%s",\n "thing")') - editor.setCursorBufferPosition([1, 10]) - editor.insertNewline() - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 0 - - describe ".backspace()", -> - describe "when there is a single cursor", -> - changeScreenRangeHandler = null - - beforeEach -> - selection = editor.getLastSelection() - changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') - selection.onDidChangeRange changeScreenRangeHandler - - describe "when the cursor is on the middle of the line", -> - it "removes the character before the cursor", -> - editor.setCursorScreenPosition(row: 1, column: 7) - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - - editor.backspace() - - line = buffer.lineForRow(1) - expect(line).toBe " var ort = function(items) {" - expect(editor.getCursorScreenPosition()).toEqual {row: 1, column: 6} - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the cursor is at the beginning of a line", -> - it "joins it with the line above", -> - originalLine0 = buffer.lineForRow(0) - expect(originalLine0).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - - editor.setCursorScreenPosition(row: 1, column: 0) - editor.backspace() - - line0 = buffer.lineForRow(0) - line1 = buffer.lineForRow(1) - expect(line0).toBe "var quicksort = function () { var sort = function(items) {" - expect(line1).toBe " if (items.length <= 1) return items;" - expect(editor.getCursorScreenPosition()).toEqual [0, originalLine0.length] - - expect(changeScreenRangeHandler).toHaveBeenCalled() - - describe "when the cursor is at the first column of the first line", -> - it "does nothing, but doesn't raise an error", -> - editor.setCursorScreenPosition(row: 0, column: 0) - editor.backspace() - - describe "when the cursor is after a fold", -> - it "deletes the folded range", -> - editor.foldBufferRange([[4, 7], [5, 8]]) - editor.setCursorBufferPosition([5, 8]) - editor.backspace() - - expect(buffer.lineForRow(4)).toBe " whirrent = items.shift();" - expect(editor.isFoldedAtBufferRow(4)).toBe(false) - - describe "when the cursor is in the middle of a line below a fold", -> - it "backspaces as normal", -> - editor.setCursorScreenPosition([4, 0]) - editor.foldCurrentRow() - editor.setCursorScreenPosition([5, 5]) - editor.backspace() - - expect(buffer.lineForRow(7)).toBe " }" - expect(buffer.lineForRow(8)).toBe " eturn sort(left).concat(pivot).concat(sort(right));" - - describe "when the cursor is on a folded screen line", -> - it "deletes the contents of the fold before the cursor", -> - editor.setCursorBufferPosition([3, 0]) - editor.foldCurrentRow() - editor.backspace() - - expect(buffer.lineForRow(1)).toBe " var sort = function(items) var pivot = items.shift(), current, left = [], right = [];" - expect(editor.getCursorScreenPosition()).toEqual [1, 29] - - describe "when there are multiple cursors", -> - describe "when cursors are on the same line", -> - it "removes the characters preceding each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.backspace() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivo = items.shift(), curren, left = [], right = [];" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 12] - expect(cursor2.getBufferPosition()).toEqual [3, 36] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when cursors are on different lines", -> - describe "when the cursors are in the middle of their lines", -> - it "removes the characters preceding each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([4, 10]) - - editor.backspace() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivo = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " whileitems.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 12] - expect(cursor2.getBufferPosition()).toEqual [4, 9] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when the cursors are on the first column of their lines", -> - it "removes the newlines preceding each cursor", -> - editor.setCursorScreenPosition([3, 0]) - editor.addCursorAtScreenPosition([6, 0]) - - editor.backspace() - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items; var pivot = items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(3)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(4)).toBe " current = items.shift(); current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(5)).toBe " }" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [2, 40] - expect(cursor2.getBufferPosition()).toEqual [4, 30] - - describe "when there is a single selection", -> - it "deletes the selection, but not the character before it", -> - editor.setSelectedBufferRange([[0, 5], [0, 9]]) - editor.backspace() - expect(editor.buffer.lineForRow(0)).toBe 'var qsort = function () {' - - describe "when the selection ends on a folded line", -> - it "preserves the fold", -> - editor.setSelectedBufferRange([[3, 0], [4, 0]]) - editor.foldBufferRow(4) - editor.backspace() - - expect(buffer.lineForRow(3)).toBe " while(items.length > 0) {" - expect(editor.isFoldedAtScreenRow(3)).toBe(true) - - describe "when there are multiple selections", -> - it "removes all selected text", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - editor.backspace() - expect(editor.lineTextForBufferRow(0)).toBe 'var = () {' - - describe ".deleteToPreviousWordBoundary()", -> - describe "when no text is selected", -> - it "deletes to the previous word boundary", -> - editor.setCursorBufferPosition([0, 16]) - editor.addCursorAtBufferPosition([1, 21]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =function () {' - expect(buffer.lineForRow(1)).toBe ' var sort = (items) {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 13] - - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort function () {' - expect(buffer.lineForRow(1)).toBe ' var sort =(items) {' - expect(cursor1.getBufferPosition()).toEqual [0, 14] - expect(cursor2.getBufferPosition()).toEqual [1, 12] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToPreviousWordBoundary() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".deleteToNextWordBoundary()", -> - describe "when no text is selected", -> - it "deletes to the next word boundary", -> - editor.setCursorBufferPosition([0, 15]) - editor.addCursorAtBufferPosition([1, 24]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =function () {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort = () {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it {' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(0)).toBe 'var quicksort =() {' - expect(buffer.lineForRow(1)).toBe ' var sort = function(it{' - expect(cursor1.getBufferPosition()).toEqual [0, 15] - expect(cursor2.getBufferPosition()).toEqual [1, 24] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToNextWordBoundary() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".deleteToBeginningOfWord()", -> - describe "when no text is selected", -> - it "deletes all text between the cursor and the beginning of the word", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([3, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(ems) {' - expect(buffer.lineForRow(3)).toBe ' ar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 22] - expect(cursor2.getBufferPosition()).toEqual [3, 4] - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = functionems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return itemsar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 21] - expect(cursor2.getBufferPosition()).toEqual [2, 39] - - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = ems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return ar pivot = items.shift(), current, left = [], right = [];' - expect(cursor1.getBufferPosition()).toEqual [1, 13] - expect(cursor2.getBufferPosition()).toEqual [2, 34] - - editor.setText(' var sort') - editor.setCursorBufferPosition([0, 2]) - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(0)).toBe 'var sort' - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToBeginningOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - - describe '.deleteToEndOfLine()', -> - describe 'when no text is selected', -> - it 'deletes all text between the cursor and the end of the line', -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it' - expect(buffer.lineForRow(2)).toBe ' i' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - describe 'when at the end of the line', -> - it 'deletes the next newline', -> - editor.setCursorBufferPosition([1, 30]) - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe 'when text is selected', -> - it 'deletes only the text in the selection', -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToEndOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - - describe ".deleteToBeginningOfLine()", -> - describe "when no text is selected", -> - it "deletes all text between the cursor and the beginning of the line", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe 'ems) {' - expect(buffer.lineForRow(2)).toBe 'f (items.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 0] - expect(cursor2.getBufferPosition()).toEqual [2, 0] - - describe "when at the beginning of the line", -> - it "deletes the newline", -> - editor.setCursorBufferPosition([2]) - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe "when text is selected", -> - it "still deletes all text to beginning of the line", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.deleteToBeginningOfLine() - expect(buffer.lineForRow(1)).toBe 'ems) {' - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' - - describe ".delete()", -> - describe "when there is a single cursor", -> - describe "when the cursor is on the middle of a line", -> - it "deletes the character following the cursor", -> - editor.setCursorScreenPosition([1, 6]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var ort = function(items) {' - - describe "when the cursor is on the end of a line", -> - it "joins the line with the following line", -> - editor.setCursorScreenPosition([1, buffer.lineForRow(1).length]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var sort = function(items) { if (items.length <= 1) return items;' - - describe "when the cursor is on the last column of the last line", -> - it "does nothing, but doesn't raise an error", -> - editor.setCursorScreenPosition([12, buffer.lineForRow(12).length]) - editor.delete() - expect(buffer.lineForRow(12)).toBe '};' - - describe "when the cursor is before a fold", -> - it "only deletes the lines inside the fold", -> - editor.foldBufferRange([[3, 6], [4, 8]]) - editor.setCursorScreenPosition([3, 6]) - cursorPositionBefore = editor.getCursorScreenPosition() - - editor.delete() - - expect(buffer.lineForRow(3)).toBe " vae(items.length > 0) {" - expect(buffer.lineForRow(4)).toBe " current = items.shift();" - expect(editor.getCursorScreenPosition()).toEqual cursorPositionBefore - - describe "when the cursor is in the middle a line above a fold", -> - it "deletes as normal", -> - editor.foldBufferRow(4) - editor.setCursorScreenPosition([3, 4]) - cursorPositionBefore = editor.getCursorScreenPosition() - - editor.delete() - - expect(buffer.lineForRow(3)).toBe " ar pivot = items.shift(), current, left = [], right = [];" - expect(editor.isFoldedAtScreenRow(4)).toBe(true) - expect(editor.getCursorScreenPosition()).toEqual [3, 4] - - describe "when the cursor is inside a fold", -> - it "removes the folded content after the cursor", -> - editor.foldBufferRange([[2, 6], [6, 21]]) - editor.setCursorBufferPosition([4, 9]) - - editor.delete() - - expect(buffer.lineForRow(2)).toBe ' if (items.length <= 1) return items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - expect(buffer.lineForRow(4)).toBe ' while ? left.push(current) : right.push(current);' - expect(buffer.lineForRow(5)).toBe ' }' - expect(editor.getCursorBufferPosition()).toEqual [4, 9] - - describe "when there are multiple cursors", -> - describe "when cursors are on the same line", -> - it "removes the characters following each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([3, 38]) - - editor.delete() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot= items.shift(), current left = [], right = [];" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 13] - expect(cursor2.getBufferPosition()).toEqual [3, 37] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when cursors are on different lines", -> - describe "when the cursors are in the middle of the lines", -> - it "removes the characters following each cursor", -> - editor.setCursorScreenPosition([3, 13]) - editor.addCursorAtScreenPosition([4, 10]) - - editor.delete() - - expect(editor.lineTextForBufferRow(3)).toBe " var pivot= items.shift(), current, left = [], right = [];" - expect(editor.lineTextForBufferRow(4)).toBe " while(tems.length > 0) {" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [3, 13] - expect(cursor2.getBufferPosition()).toEqual [4, 10] - - [selection1, selection2] = editor.getSelections() - expect(selection1.isEmpty()).toBeTruthy() - expect(selection2.isEmpty()).toBeTruthy() - - describe "when the cursors are at the end of their lines", -> - it "removes the newlines following each cursor", -> - editor.setCursorScreenPosition([0, 29]) - editor.addCursorAtScreenPosition([1, 30]) - - editor.delete() - - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort = function () { var sort = function(items) { if (items.length <= 1) return items;" - - [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [0, 29] - expect(cursor2.getBufferPosition()).toEqual [0, 59] - - describe "when there is a single selection", -> - it "deletes the selection, but not the character following it", -> - editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) - editor.delete() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe 'if (items.length <= 1) return items;' - expect(editor.getLastSelection().isEmpty()).toBeTruthy() - - describe "when there are multiple selections", -> - describe "when selections are on the same line", -> - it "removes all selected text", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - editor.delete() - expect(editor.lineTextForBufferRow(0)).toBe 'var = () {' - - describe ".deleteToEndOfWord()", -> - describe "when no text is selected", -> - it "deletes to the end of the word", -> - editor.setCursorBufferPosition([1, 24]) - editor.addCursorAtBufferPosition([2, 5]) - [cursor1, cursor2] = editor.getCursors() - - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - expect(buffer.lineForRow(2)).toBe ' i (items.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it {' - expect(buffer.lineForRow(2)).toBe ' iitems.length <= 1) return items;' - expect(cursor1.getBufferPosition()).toEqual [1, 24] - expect(cursor2.getBufferPosition()).toEqual [2, 5] - - describe "when text is selected", -> - it "deletes only selected text", -> - editor.setSelectedBufferRange([[1, 24], [1, 27]]) - editor.deleteToEndOfWord() - expect(buffer.lineForRow(1)).toBe ' var sort = function(it) {' - - describe ".indent()", -> - describe "when the selection is empty", -> - describe "when autoIndent is disabled", -> - describe "if 'softTabs' is true (the default)", -> - it "inserts 'tabLength' spaces into the buffer", -> - tabRegex = new RegExp("^[ ]{#{editor.getTabLength()}}") - expect(buffer.lineForRow(0)).not.toMatch(tabRegex) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(tabRegex) - - it "respects the tab stops when cursor is in the middle of a tab", -> - editor.setTabLength(4) - buffer.insert([12, 2], "\n ") - editor.setCursorBufferPosition [13, 1] - editor.indent() - expect(buffer.lineForRow(13)).toMatch /^\s+$/ - expect(buffer.lineForRow(13).length).toBe 4 - expect(editor.getCursorBufferPosition()).toEqual [13, 4] - - buffer.insert([13, 0], " ") - editor.setCursorBufferPosition [13, 6] - editor.indent() - expect(buffer.lineForRow(13).length).toBe 8 - - describe "if 'softTabs' is false", -> - it "insert a \t into the buffer", -> - editor.setSoftTabs(false) - expect(buffer.lineForRow(0)).not.toMatch(/^\t/) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t/) - - describe "when autoIndent is enabled", -> - describe "when the cursor's column is less than the suggested level of indentation", -> - describe "when 'softTabs' is true (the default)", -> - it "moves the cursor to the end of the leading whitespace and inserts enough whitespace to bring the line to the suggested level of indentation", -> - buffer.insert([5, 0], " \n") - editor.setCursorBufferPosition [5, 0] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(5)).toMatch /^\s+$/ - expect(buffer.lineForRow(5).length).toBe 6 - expect(editor.getCursorBufferPosition()).toEqual [5, 6] - - it "respects the tab stops when cursor is in the middle of a tab", -> - editor.setTabLength(4) - buffer.insert([12, 2], "\n ") - editor.setCursorBufferPosition [13, 1] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(13)).toMatch /^\s+$/ - expect(buffer.lineForRow(13).length).toBe 4 - expect(editor.getCursorBufferPosition()).toEqual [13, 4] - - buffer.insert([13, 0], " ") - editor.setCursorBufferPosition [13, 6] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(13).length).toBe 8 - - describe "when 'softTabs' is false", -> - it "moves the cursor to the end of the leading whitespace and inserts enough tabs to bring the line to the suggested level of indentation", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - buffer.insert([5, 0], "\t\n") - editor.setCursorBufferPosition [5, 0] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(5)).toMatch /^\t\t\t$/ - expect(editor.getCursorBufferPosition()).toEqual [5, 3] - - describe "when the difference between the suggested level of indentation and the current level of indentation is greater than 0 but less than 1", -> - it "inserts one tab", -> - editor.setSoftTabs(false) - buffer.setText(" \ntest") - editor.setCursorBufferPosition [1, 0] - - editor.indent(autoIndent: true) - expect(buffer.lineForRow(1)).toBe '\ttest' - expect(editor.getCursorBufferPosition()).toEqual [1, 1] - - describe "when the line's indent level is greater than the suggested level of indentation", -> - describe "when 'softTabs' is true (the default)", -> - it "moves the cursor to the end of the leading whitespace and inserts 'tabLength' spaces into the buffer", -> - buffer.insert([7, 0], " \n") - editor.setCursorBufferPosition [7, 2] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(7)).toMatch /^\s+$/ - expect(buffer.lineForRow(7).length).toBe 8 - expect(editor.getCursorBufferPosition()).toEqual [7, 8] - - describe "when 'softTabs' is false", -> - it "moves the cursor to the end of the leading whitespace and inserts \t into the buffer", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - buffer.insert([7, 0], "\t\t\t\n") - editor.setCursorBufferPosition [7, 1] - editor.indent(autoIndent: true) - expect(buffer.lineForRow(7)).toMatch /^\t\t\t\t$/ - expect(editor.getCursorBufferPosition()).toEqual [7, 4] - - describe "when the selection is not empty", -> - it "indents the selected lines", -> - editor.setSelectedBufferRange([[0, 0], [10, 0]]) - selection = editor.getLastSelection() - spyOn(selection, "indentSelectedRows") - editor.indent() - expect(selection.indentSelectedRows).toHaveBeenCalled() - - describe "if editor.softTabs is false", -> - it "inserts a tab character into the buffer", -> - editor.setSoftTabs(false) - expect(buffer.lineForRow(0)).not.toMatch(/^\t/) - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t/) - expect(editor.getCursorBufferPosition()).toEqual [0, 1] - expect(editor.getCursorScreenPosition()).toEqual [0, editor.getTabLength()] - - editor.indent() - expect(buffer.lineForRow(0)).toMatch(/^\t\t/) - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - expect(editor.getCursorScreenPosition()).toEqual [0, editor.getTabLength() * 2] - - describe "clipboard operations", -> - describe ".cutSelectedText()", -> - it "removes the selected text from the buffer and places it on the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - editor.cutSelectedText() - expect(buffer.lineForRow(0)).toBe "var = function () {" - expect(buffer.lineForRow(1)).toBe " var = function(items) {" - expect(clipboard.readText()).toBe 'quicksort\nsort' - - describe "when no text is selected", -> - beforeEach -> - editor.setSelectedBufferRanges([ - [[0, 0], [0, 0]], - [[5, 0], [5, 0]], - ]) - - it "cuts the lines on which there are cursors", -> - editor.cutSelectedText() - expect(buffer.getLineCount()).toBe(11) - expect(buffer.lineForRow(1)).toBe(" if (items.length <= 1) return items;") - expect(buffer.lineForRow(4)).toBe(" current < pivot ? left.push(current) : right.push(current);") - expect(atom.clipboard.read()).toEqual """ - var quicksort = function () { - - current = items.shift(); - - """ - - describe "when many selections get added in shuffle order", -> - it "cuts them in order", -> - editor.setSelectedBufferRanges([ - [[2, 8], [2, 13]] - [[0, 4], [0, 13]], - [[1, 6], [1, 10]], - ]) - editor.cutSelectedText() - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe ".cutToEndOfLine()", -> - describe "when soft wrap is on", -> - it "cuts up to the end of the line", -> - editor.setSoftWrapped(true) - editor.setDefaultCharWidth(1) - editor.setEditorWidthInChars(25) - editor.setCursorScreenPosition([2, 6]) - editor.cutToEndOfLine() - expect(editor.lineTextForScreenRow(2)).toBe ' var function(items) {' - - describe "when soft wrap is off", -> - describe "when nothing is selected", -> - it "cuts up to the end of the line", -> - editor.setCursorBufferPosition([2, 20]) - editor.addCursorAtBufferPosition([3, 20]) - editor.cutToEndOfLine() - expect(buffer.lineForRow(2)).toBe ' if (items.length' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) return items;\ns.shift(), current, left = [], right = [];' - - describe "when text is selected", -> - it "only cuts the selected text, not to the end of the line", -> - editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) - - editor.cutToEndOfLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.lengthurn items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) ret\ns.shift(), current, left = [], right = [];' - - describe ".cutToEndOfBufferLine()", -> - beforeEach -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - - describe "when nothing is selected", -> - it "cuts up to the end of the buffer line", -> - editor.setCursorBufferPosition([2, 20]) - editor.addCursorAtBufferPosition([3, 20]) - - editor.cutToEndOfBufferLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.length' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) return items;\ns.shift(), current, left = [], right = [];' - - describe "when text is selected", -> - it "only cuts the selected text, not to the end of the buffer line", -> - editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) - - editor.cutToEndOfBufferLine() - - expect(buffer.lineForRow(2)).toBe ' if (items.lengthurn items;' - expect(buffer.lineForRow(3)).toBe ' var pivot = item' - expect(atom.clipboard.read()).toBe ' <= 1) ret\ns.shift(), current, left = [], right = [];' - - describe ".copySelectedText()", -> - it "copies selected text onto the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) - - editor.copySelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(clipboard.readText()).toBe 'quicksort\nsort\nitems' - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe "when no text is selected", -> - beforeEach -> - editor.setSelectedBufferRanges([ - [[1, 5], [1, 5]], - [[5, 8], [5, 8]] - ]) - - it "copies the lines on which there are cursors", -> - editor.copySelectedText() - expect(atom.clipboard.read()).toEqual([ - " var sort = function(items) {\n" - " current = items.shift();\n" - ].join("\n")) - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 5], [1, 5]], - [[5, 8], [5, 8]] - ]) - - describe "when many selections get added in shuffle order", -> - it "copies them in order", -> - editor.setSelectedBufferRanges([ - [[2, 8], [2, 13]] - [[0, 4], [0, 13]], - [[1, 6], [1, 10]], - ]) - editor.copySelectedText() - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe ".copyOnlySelectedText()", -> - describe "when thee are multiple selections", -> - it "copies selected text onto the clipboard", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) - - editor.copyOnlySelectedText() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(clipboard.readText()).toBe 'quicksort\nsort\nitems' - expect(atom.clipboard.read()).toEqual """ - quicksort - sort - items - """ - - describe "when no text is selected", -> - it "does not copy anything", -> - editor.setCursorBufferPosition([1, 5]) - editor.copyOnlySelectedText() - expect(atom.clipboard.read()).toEqual "initial clipboard content" - - describe ".pasteText()", -> - copyText = (text, {startColumn, textEditor}={}) -> - startColumn ?= 0 - textEditor ?= editor - textEditor.setCursorBufferPosition([0, 0]) - textEditor.insertText(text) - numberOfNewlines = text.match(/\n/g)?.length - endColumn = text.match(/[^\n]*$/)[0]?.length - textEditor.getLastSelection().setBufferRange([[0, startColumn], [numberOfNewlines, endColumn]]) - textEditor.cutSelectedText() - - it "pastes text into the buffer", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - atom.clipboard.write('first') - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var first = function () {" - expect(editor.lineTextForBufferRow(1)).toBe " var first = function(items) {" - - it "notifies ::onWillInsertText observers", -> - insertedStrings = [] - editor.onWillInsertText ({text, cancel}) -> - insertedStrings.push(text) - cancel() - - atom.clipboard.write("hello") - editor.pasteText() - - expect(insertedStrings).toEqual ["hello"] - - it "notifies ::onDidInsertText observers", -> - insertedStrings = [] - editor.onDidInsertText ({text, range}) -> - insertedStrings.push(text) - - atom.clipboard.write("hello") - editor.pasteText() - - expect(insertedStrings).toEqual ["hello"] - - describe "when `autoIndentOnPaste` is true", -> - beforeEach -> - editor.update({autoIndentOnPaste: true}) - - describe "when pasting multiple lines before any non-whitespace characters", -> - it "auto-indents the lines spanned by the pasted text, based on the first pasted line", -> - atom.clipboard.write("a(x);\n b(x);\n c(x);\n", indentBasis: 0) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - # Adjust the indentation of the pasted lines while preserving - # their indentation relative to each other. Also preserve the - # indentation of the following line. - expect(editor.lineTextForBufferRow(5)).toBe " a(x);" - expect(editor.lineTextForBufferRow(6)).toBe " b(x);" - expect(editor.lineTextForBufferRow(7)).toBe " c(x);" - expect(editor.lineTextForBufferRow(8)).toBe " current = items.shift();" - - it "auto-indents lines with a mix of hard tabs and spaces without removing spaces", -> - editor.setSoftTabs(false) - expect(editor.indentationForBufferRow(5)).toBe(3) - - atom.clipboard.write("/**\n\t * testing\n\t * indent\n\t **/\n", indentBasis: 1) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - # Do not lose the alignment spaces - expect(editor.lineTextForBufferRow(5)).toBe("\t\t\t/**") - expect(editor.lineTextForBufferRow(6)).toBe("\t\t\t * testing") - expect(editor.lineTextForBufferRow(7)).toBe("\t\t\t * indent") - expect(editor.lineTextForBufferRow(8)).toBe("\t\t\t **/") - - describe "when pasting line(s) above a line that matches the decreaseIndentPattern", -> - it "auto-indents based on the pasted line(s) only", -> - atom.clipboard.write("a(x);\n b(x);\n c(x);\n", indentBasis: 0) - editor.setCursorBufferPosition([7, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(7)).toBe " a(x);" - expect(editor.lineTextForBufferRow(8)).toBe " b(x);" - expect(editor.lineTextForBufferRow(9)).toBe " c(x);" - expect(editor.lineTextForBufferRow(10)).toBe " }" - - describe "when pasting a line of text without line ending", -> - it "does not auto-indent the text", -> - atom.clipboard.write("a(x);", indentBasis: 0) - editor.setCursorBufferPosition([5, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(5)).toBe "a(x); current = items.shift();" - expect(editor.lineTextForBufferRow(6)).toBe " current < pivot ? left.push(current) : right.push(current);" - - describe "when pasting on a line after non-whitespace characters", -> - it "does not auto-indent the affected line", -> - # Before the paste, the indentation is non-standard. - editor.setText """ - if (x) { - y(); - } - """ - - atom.clipboard.write(" z();\n h();") - editor.setCursorBufferPosition([1, Infinity]) - - # The indentation of the non-standard line is unchanged. - editor.pasteText() - expect(editor.lineTextForBufferRow(1)).toBe(" y(); z();") - expect(editor.lineTextForBufferRow(2)).toBe(" h();") - - describe "when `autoIndentOnPaste` is false", -> - beforeEach -> - editor.update({autoIndentOnPaste: false}) - - describe "when the cursor is indented further than the original copied text", -> - it "increases the indentation of the copied lines to match", -> - editor.setSelectedBufferRange([[1, 2], [3, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([5, 6]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(5)).toBe " var sort = function(items) {" - expect(editor.lineTextForBufferRow(6)).toBe " if (items.length <= 1) return items;" - - describe "when the cursor is indented less far than the original copied text", -> - it "decreases the indentation of the copied lines to match", -> - editor.setSelectedBufferRange([[6, 6], [8, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([1, 2]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(1)).toBe " current < pivot ? left.push(current) : right.push(current);" - expect(editor.lineTextForBufferRow(2)).toBe "}" - - describe "when the first copied line has leading whitespace", -> - it "preserves the line's leading whitespace", -> - editor.setSelectedBufferRange([[4, 0], [6, 0]]) - editor.copySelectedText() - - editor.setCursorBufferPosition([0, 0]) - editor.pasteText() - - expect(editor.lineTextForBufferRow(0)).toBe " while(items.length > 0) {" - expect(editor.lineTextForBufferRow(1)).toBe " current = items.shift();" - - describe 'when the clipboard has many selections', -> - beforeEach -> - editor.update({autoIndentOnPaste: false}) - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - editor.copySelectedText() - - it "pastes each selection in order separately into the buffer", -> - editor.setSelectedBufferRanges([ - [[1, 6], [1, 10]] - [[0, 4], [0, 13]], - ]) - - editor.moveRight() - editor.insertText("_") - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort_quicksort = function () {" - expect(editor.lineTextForBufferRow(1)).toBe " var sort_sort = function(items) {" - - describe 'and the selections count does not match', -> - beforeEach -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]]]) - - it "pastes the whole text into the buffer", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(0)).toBe "var quicksort" - expect(editor.lineTextForBufferRow(1)).toBe "sort = function () {" - - describe "when a full line was cut", -> - beforeEach -> - editor.setCursorBufferPosition([2, 13]) - editor.cutSelectedText() - editor.setCursorBufferPosition([2, 13]) - - it "pastes the line above the cursor and retains the cursor's column", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(2)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(3)).toBe(" var pivot = items.shift(), current, left = [], right = [];") - expect(editor.getCursorBufferPosition()).toEqual([3, 13]) - - describe "when a full line was copied", -> - beforeEach -> - editor.setCursorBufferPosition([2, 13]) - editor.copySelectedText() - - describe "when there is a selection", -> - it "overwrites the selection as with any copied text", -> - editor.setSelectedBufferRange([[1, 2], [1, Infinity]]) - editor.pasteText() - expect(editor.lineTextForBufferRow(1)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(2)).toBe("") - expect(editor.lineTextForBufferRow(3)).toBe(" if (items.length <= 1) return items;") - expect(editor.getCursorBufferPosition()).toEqual([2, 0]) - - describe "when there is no selection", -> - it "pastes the line above the cursor and retains the cursor's column", -> - editor.pasteText() - expect(editor.lineTextForBufferRow(2)).toBe(" if (items.length <= 1) return items;") - expect(editor.lineTextForBufferRow(3)).toBe(" if (items.length <= 1) return items;") - expect(editor.getCursorBufferPosition()).toEqual([3, 13]) - - describe ".indentSelectedRows()", -> - describe "when nothing is selected", -> - describe "when softTabs is enabled", -> - it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0, 3], [0, 3]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 3 + editor.getTabLength()], [0, 3 + editor.getTabLength()]] - - describe "when softTabs is disabled", -> - it "indents line and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0, 3], [0, 3]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 3 + 1], [0, 3 + 1]] - - describe "when one line is selected", -> - describe "when softTabs is enabled", -> - it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0, 4], [0, 14]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "#{editor.getTabText()}var quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 4 + editor.getTabLength()], [0, 14 + editor.getTabLength()]] - - describe "when softTabs is disabled", -> - it "indents line and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0, 4], [0, 14]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 4 + 1], [0, 14 + 1]] - - describe "when multiple lines are selected", -> - describe "when softTabs is enabled", -> - it "indents selected lines (that are not empty) and retains selection", -> - editor.setSelectedBufferRange([[9, 1], [11, 15]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe " };" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + editor.getTabLength()], [11, 15 + editor.getTabLength()]] - - it "does not indent the last row if the selection ends at column 0", -> - editor.setSelectedBufferRange([[9, 1], [11, 0]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe " };" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe " return sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + editor.getTabLength()], [11, 0]] - - describe "when softTabs is disabled", -> - it "indents selected lines (that are not empty) and retains selection", -> - convertToHardTabs(buffer) - editor.setSoftTabs(false) - editor.setSelectedBufferRange([[9, 1], [11, 15]]) - editor.indentSelectedRows() - expect(buffer.lineForRow(9)).toBe "\t\t};" - expect(buffer.lineForRow(10)).toBe "" - expect(buffer.lineForRow(11)).toBe "\t\treturn sort(Array.apply(this, arguments));" - expect(editor.getSelectedBufferRange()).toEqual [[9, 1 + 1], [11, 15 + 1]] - - describe ".outdentSelectedRows()", -> - describe "when nothing is selected", -> - it "outdents line and retains selection", -> - editor.setSelectedBufferRange([[1, 3], [1, 3]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(editor.getSelectedBufferRange()).toEqual [[1, 3 - editor.getTabLength()], [1, 3 - editor.getTabLength()]] - - it "outdents when indent is less than a tab length", -> - editor.insertText(' ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents a single hard tab when indent is multiple hard tabs and and the session is using soft tabs", -> - editor.insertText('\t\t') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tvar quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents when a mix of hard tabs and soft tabs are used", -> - editor.insertText('\t ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe " var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "outdents only up to the first non-space non-tab character", -> - editor.insertText(' \tfoo\t ') - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "\tfoo\t var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "foo\t var quicksort = function () {" - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "foo\t var quicksort = function () {" - - describe "when one line is selected", -> - it "outdents line and retains editor", -> - editor.setSelectedBufferRange([[1, 4], [1, 14]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(editor.getSelectedBufferRange()).toEqual [[1, 4 - editor.getTabLength()], [1, 14 - editor.getTabLength()]] - - describe "when multiple lines are selected", -> - it "outdents selected lines and retains editor", -> - editor.setSelectedBufferRange([[0, 1], [3, 15]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 15 - editor.getTabLength()]] - - it "does not outdent the last line of the selection if it ends at column 0", -> - editor.setSelectedBufferRange([[0, 1], [3, 0]]) - editor.outdentSelectedRows() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - expect(buffer.lineForRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [3, 0]] - - describe ".autoIndentSelectedRows", -> - it "auto-indents the selection", -> - editor.setCursorBufferPosition([2, 0]) - editor.insertText("function() {\ninside=true\n}\n i=1\n") - editor.getLastSelection().setBufferRange([[2, 0], [6, 0]]) - editor.autoIndentSelectedRows() - - expect(editor.lineTextForBufferRow(2)).toBe " function() {" - expect(editor.lineTextForBufferRow(3)).toBe " inside=true" - expect(editor.lineTextForBufferRow(4)).toBe " }" - expect(editor.lineTextForBufferRow(5)).toBe " i=1" - - describe ".toggleLineCommentsInSelection()", -> - it "toggles comments on the selected lines", -> - editor.setSelectedBufferRange([[4, 5], [7, 5]]) - editor.toggleLineCommentsInSelection() - - 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 " // }" - expect(editor.getSelectedBufferRange()).toEqual [[4, 8], [7, 8]] - - editor.toggleLineCommentsInSelection() - 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 " }" - - it "does not comment the last line of a non-empty selection if it ends at column 0", -> - editor.setSelectedBufferRange([[4, 5], [7, 0]]) - editor.toggleLineCommentsInSelection() - 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 " }" - - it "uncomments lines if all lines match the comment regex", -> - editor.setSelectedBufferRange([[0, 0], [0, 1]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// // var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "// var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe "// if (items.length <= 1) return items;" - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe " var sort = function(items) {" - expect(buffer.lineForRow(2)).toBe " if (items.length <= 1) return items;" - - editor.setSelectedBufferRange([[0, 0], [0, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - - it "uncomments commented lines separated by an empty line", -> - editor.setSelectedBufferRange([[0, 0], [1, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "// var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "// var sort = function(items) {" - - buffer.insert([0, Infinity], '\n') - - editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" - expect(buffer.lineForRow(1)).toBe "" - expect(buffer.lineForRow(2)).toBe " var sort = function(items) {" - - it "preserves selection emptiness", -> - editor.setCursorBufferPosition([4, 0]) - editor.toggleLineCommentsInSelection() - expect(editor.getLastSelection().isEmpty()).toBeTruthy() - - it "does not explode if the current language mode has no comment regex", -> - editor = new TextEditor(buffer: new TextBuffer(text: 'hello')) - editor.setSelectedBufferRange([[0, 0], [0, 5]]) - editor.toggleLineCommentsInSelection() - expect(editor.lineTextForBufferRow(0)).toBe "hello" - - it "does nothing for empty lines and null grammar", -> - runs -> - editor.setGrammar(atom.grammars.grammarForScopeName('text.plain.null-grammar')) - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - expect(editor.buffer.lineForRow(10)).toBe "" - - it "uncomments when the line lacks the trailing whitespace in the comment regex", -> - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - - expect(buffer.lineForRow(10)).toBe "// " - expect(editor.getSelectedBufferRange()).toEqual [[10, 3], [10, 3]] - editor.backspace() - expect(buffer.lineForRow(10)).toBe "//" - - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(10)).toBe "" - expect(editor.getSelectedBufferRange()).toEqual [[10, 0], [10, 0]] - - it "uncomments when the line has leading whitespace", -> - editor.setCursorBufferPosition([10, 0]) - editor.toggleLineCommentsInSelection() - - expect(buffer.lineForRow(10)).toBe "// " - editor.moveToBeginningOfLine() - editor.insertText(" ") - editor.setSelectedBufferRange([[10, 0], [10, 0]]) - editor.toggleLineCommentsInSelection() - expect(buffer.lineForRow(10)).toBe " " - - describe ".undo() and .redo()", -> - it "undoes/redoes the last change", -> - editor.insertText("foo") - editor.undo() - expect(buffer.lineForRow(0)).not.toContain "foo" - - editor.redo() - expect(buffer.lineForRow(0)).toContain "foo" - - it "batches the undo / redo of changes caused by multiple cursors", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([1, 0]) - - editor.insertText("foo") - editor.backspace() - - expect(buffer.lineForRow(0)).toContain "fovar" - expect(buffer.lineForRow(1)).toContain "fo " - - editor.undo() - - expect(buffer.lineForRow(0)).toContain "foo" - expect(buffer.lineForRow(1)).toContain "foo" - - editor.redo() - - expect(buffer.lineForRow(0)).not.toContain "foo" - expect(buffer.lineForRow(0)).toContain "fovar" - - it "restores cursors and selections to their states before and after undone and redone changes", -> - editor.setSelectedBufferRanges([ - [[0, 0], [0, 0]], - [[1, 0], [1, 3]], - ]) - editor.insertText("abc") - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 3]], - [[1, 3], [1, 3]] - ] - - editor.setCursorBufferPosition([0, 0]) - editor.setSelectedBufferRanges([ - [[2, 0], [2, 0]], - [[3, 0], [3, 0]], - [[4, 0], [4, 3]], - ]) - editor.insertText("def") - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 3], [2, 3]], - [[3, 3], [3, 3]] - [[4, 3], [4, 3]] - ] - - editor.setCursorBufferPosition([0, 0]) - editor.undo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 0], [2, 0]], - [[3, 0], [3, 0]], - [[4, 0], [4, 3]], - ] - - editor.undo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 0], [0, 0]], - [[1, 0], [1, 3]] - ] - - editor.redo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[0, 3], [0, 3]], - [[1, 3], [1, 3]] - ] - - editor.redo() - - expect(editor.getSelectedBufferRanges()).toEqual [ - [[2, 3], [2, 3]], - [[3, 3], [3, 3]] - [[4, 3], [4, 3]] - ] - - it "restores the selected ranges after undo and redo", -> - editor.setSelectedBufferRanges([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) - editor.delete() - editor.delete() - - selections = editor.getSelections() - expect(buffer.lineForRow(1)).toBe ' var = function( {' - - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 17], [1, 17]]] - - editor.undo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] - - editor.undo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 10]], [[1, 22], [1, 27]]] - - editor.redo() - expect(editor.getSelectedBufferRanges()).toEqual [[[1, 6], [1, 6]], [[1, 18], [1, 18]]] - - xit "restores folds after undo and redo", -> - editor.foldBufferRow(1) - editor.setSelectedBufferRange([[1, 0], [10, Infinity]], preserveFolds: true) - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - - editor.insertText """ - \ // testing - function foo() { - return 1 + 2; - } - """ - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - editor.foldBufferRow(2) - - editor.undo() - expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() - expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() - - editor.redo() - expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() - expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() - - describe "::transact", -> - it "restores the selection when the transaction is undone/redone", -> - buffer.setText('1234') - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - - editor.transact -> - editor.delete() - editor.moveToEndOfLine() - editor.insertText('5') - expect(buffer.getText()).toBe '145' - - editor.undo() - expect(buffer.getText()).toBe '1234' - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [0, 3]] - - editor.redo() - expect(buffer.getText()).toBe '145' - expect(editor.getSelectedBufferRange()).toEqual [[0, 3], [0, 3]] - - describe "when the buffer is changed (via its direct api, rather than via than edit session)", -> - it "moves the cursor so it is in the same relative position of the buffer", -> - expect(editor.getCursorScreenPosition()).toEqual [0, 0] - editor.addCursorAtScreenPosition([0, 5]) - editor.addCursorAtScreenPosition([1, 0]) - [cursor1, cursor2, cursor3] = editor.getCursors() - - buffer.insert([0, 1], 'abc') - - expect(cursor1.getScreenPosition()).toEqual [0, 0] - expect(cursor2.getScreenPosition()).toEqual [0, 8] - expect(cursor3.getScreenPosition()).toEqual [1, 0] - - it "does not destroy cursors or selections when a change encompasses them", -> - cursor = editor.getLastCursor() - cursor.setBufferPosition [3, 3] - editor.buffer.delete([[3, 1], [3, 5]]) - expect(cursor.getBufferPosition()).toEqual [3, 1] - expect(editor.getCursors().indexOf(cursor)).not.toBe -1 - - selection = editor.getLastSelection() - selection.setBufferRange [[3, 5], [3, 10]] - editor.buffer.delete [[3, 3], [3, 8]] - expect(selection.getBufferRange()).toEqual [[3, 3], [3, 5]] - expect(editor.getSelections().indexOf(selection)).not.toBe -1 - - it "merges cursors when the change causes them to overlap", -> - editor.setCursorScreenPosition([0, 0]) - editor.addCursorAtScreenPosition([0, 2]) - editor.addCursorAtScreenPosition([1, 2]) - - [cursor1, cursor2, cursor3] = editor.getCursors() - expect(editor.getCursors().length).toBe 3 - - buffer.delete([[0, 0], [0, 2]]) - - expect(editor.getCursors().length).toBe 2 - expect(editor.getCursors()).toEqual [cursor1, cursor3] - expect(cursor1.getBufferPosition()).toEqual [0, 0] - expect(cursor3.getBufferPosition()).toEqual [1, 2] - - describe ".moveSelectionLeft()", -> - it "moves one active selection on one line one column to the left", -> - editor.setSelectedBufferRange [[0, 4], [0, 13]] - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.moveSelectionLeft() - - expect(editor.getSelectedText()).toBe 'quicksort' - expect(editor.getSelectedBufferRange()).toEqual [[0, 3], [0, 12]] - - it "moves multiple active selections on one line one column to the left", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 3], [0, 12]], [[0, 15], [0, 23]]] - - it "moves multiple active selections on multiple lines one column to the left", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 3], [0, 12]], [[1, 5], [1, 9]]] - - describe "when a selection is at the first column of a line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe ' v' - - editor.moveSelectionLeft() - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe ' v' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]], [[1, 0], [1, 3]]] - - describe "when multiple selections are active on one line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe 'quicksort' - - editor.moveSelectionLeft() - - expect(selections[0].getText()).toBe 'var' - expect(selections[1].getText()).toBe 'quicksort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 3]], [[0, 4], [0, 13]]] - - describe ".moveSelectionRight()", -> - it "moves one active selection on one line one column to the right", -> - editor.setSelectedBufferRange [[0, 4], [0, 13]] - expect(editor.getSelectedText()).toBe 'quicksort' - - editor.moveSelectionRight() - - expect(editor.getSelectedText()).toBe 'quicksort' - expect(editor.getSelectedBufferRange()).toEqual [[0, 5], [0, 14]] - - it "moves multiple active selections on one line one column to the right", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'function' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 5], [0, 14]], [[0, 17], [0, 25]]] - - it "moves multiple active selections on multiple lines one column to the right", -> - editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'quicksort' - expect(selections[1].getText()).toBe 'sort' - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 5], [0, 14]], [[1, 7], [1, 11]]] - - describe "when a selection is at the last column of a line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'items;' - expect(selections[1].getText()).toBe 'shift();' - - editor.moveSelectionRight() - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'items;' - expect(selections[1].getText()).toBe 'shift();' - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 34], [2, 40]], [[5, 22], [5, 30]]] - - describe "when multiple selections are active on one line", -> - it "does not change the selection", -> - editor.setSelectedBufferRanges([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) - selections = editor.getSelections() - - expect(selections[0].getText()).toBe 'return' - expect(selections[1].getText()).toBe 'items;' - - editor.moveSelectionRight() - - expect(selections[0].getText()).toBe 'return' - expect(selections[1].getText()).toBe 'items;' - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 27], [2, 33]], [[2, 34], [2, 40]]] - - describe 'reading text', -> - it '.lineTextForScreenRow(row)', -> - editor.foldBufferRow(4) - expect(editor.lineTextForScreenRow(5)).toEqual ' return sort(left).concat(pivot).concat(sort(right));' - expect(editor.lineTextForScreenRow(9)).toEqual '};' - expect(editor.lineTextForScreenRow(10)).toBeUndefined() - - describe ".deleteLine()", -> - it "deletes the first line when the cursor is there", -> - editor.getLastCursor().moveToTop() - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line1) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "deletes the last line when the cursor is there", -> - count = buffer.getLineCount() - secondToLastLine = buffer.lineForRow(count - 2) - expect(buffer.lineForRow(count - 1)).not.toBe(secondToLastLine) - editor.getLastCursor().moveToBottom() - editor.deleteLine() - newCount = buffer.getLineCount() - expect(buffer.lineForRow(newCount - 1)).toBe(secondToLastLine) - expect(newCount).toBe(count - 1) - - it "deletes whole lines when partial lines are selected", -> - editor.setSelectedBufferRange([[0, 2], [1, 2]]) - line2 = buffer.lineForRow(2) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line2) - expect(buffer.lineForRow(1)).not.toBe(line2) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line2) - expect(buffer.getLineCount()).toBe(count - 2) - - it "deletes a line only once when multiple selections are on the same line", -> - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - editor.setSelectedBufferRanges([ - [[0, 1], [0, 2]], - [[0, 4], [0, 5]] - ]) - expect(buffer.lineForRow(0)).not.toBe(line1) - - editor.deleteLine() - - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "only deletes first line if only newline is selected on second line", -> - editor.setSelectedBufferRange([[0, 2], [1, 0]]) - line1 = buffer.lineForRow(1) - count = buffer.getLineCount() - expect(buffer.lineForRow(0)).not.toBe(line1) - editor.deleteLine() - expect(buffer.lineForRow(0)).toBe(line1) - expect(buffer.getLineCount()).toBe(count - 1) - - it "deletes the entire region when invoke on a folded region", -> - editor.foldBufferRow(1) - editor.getLastCursor().moveToTop() - editor.getLastCursor().moveDown() - expect(buffer.getLineCount()).toBe(13) - editor.deleteLine() - expect(buffer.getLineCount()).toBe(4) - - it "deletes the entire file from the bottom up", -> - count = buffer.getLineCount() - expect(count).toBeGreaterThan(0) - for [0...count] - editor.getLastCursor().moveToBottom() - editor.deleteLine() - expect(buffer.getLineCount()).toBe(1) - expect(buffer.getText()).toBe('') - - it "deletes the entire file from the top down", -> - count = buffer.getLineCount() - expect(count).toBeGreaterThan(0) - for [0...count] - editor.getLastCursor().moveToTop() - editor.deleteLine() - expect(buffer.getLineCount()).toBe(1) - expect(buffer.getText()).toBe('') - - describe "when soft wrap is enabled", -> - it "deletes the entire line that the cursor is on", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(10) - editor.setCursorBufferPosition([6]) - - line7 = buffer.lineForRow(7) - count = buffer.getLineCount() - expect(buffer.lineForRow(6)).not.toBe(line7) - editor.deleteLine() - expect(buffer.lineForRow(6)).toBe(line7) - expect(buffer.getLineCount()).toBe(count - 1) - - describe "when the line being deleted precedes a fold, and the command is undone", -> - it "restores the line and preserves the fold", -> - editor.setCursorBufferPosition([4]) - editor.foldCurrentRow() - expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() - editor.setCursorBufferPosition([3]) - editor.deleteLine() - expect(editor.isFoldedAtScreenRow(3)).toBeTruthy() - expect(buffer.lineForRow(3)).toBe ' while(items.length > 0) {' - editor.undo() - expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() - expect(buffer.lineForRow(3)).toBe ' var pivot = items.shift(), current, left = [], right = [];' - - describe ".replaceSelectedText(options, fn)", -> - describe "when no text is selected", -> - it "inserts the text returned from the function at the cursor position", -> - editor.replaceSelectedText {}, -> '123' - expect(buffer.lineForRow(0)).toBe '123var quicksort = function () {' - - editor.setCursorBufferPosition([0]) - editor.replaceSelectedText {selectWordIfEmpty: true}, -> 'var' - expect(buffer.lineForRow(0)).toBe 'var quicksort = function () {' - - editor.setCursorBufferPosition([10]) - editor.replaceSelectedText null, -> '' - expect(buffer.lineForRow(10)).toBe '' - - describe "when text is selected", -> - it "replaces the selected text with the text returned from the function", -> - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - editor.replaceSelectedText {}, -> 'ia' - expect(buffer.lineForRow(0)).toBe 'via quicksort = function () {' - - it "replaces the selected text and selects the replacement text", -> - editor.setSelectedBufferRange([[0, 4], [0, 9]]) - editor.replaceSelectedText {}, -> 'whatnot' - expect(buffer.lineForRow(0)).toBe 'var whatnotsort = function () {' - expect(editor.getSelectedBufferRange()).toEqual [[0, 4], [0, 11]] - - describe ".transpose()", -> - it "swaps two characters", -> - editor.buffer.setText("abc") - editor.setCursorScreenPosition([0, 1]) - editor.transpose() - expect(editor.lineTextForBufferRow(0)).toBe 'bac' - - it "reverses a selection", -> - editor.buffer.setText("xabcz") - editor.setSelectedBufferRange([[0, 1], [0, 4]]) - editor.transpose() - expect(editor.lineTextForBufferRow(0)).toBe 'xcbaz' - - describe ".upperCase()", -> - describe "when there is no selection", -> - it "upper cases the current word", -> - editor.buffer.setText("aBc") - editor.setCursorScreenPosition([0, 1]) - editor.upperCase() - expect(editor.lineTextForBufferRow(0)).toBe 'ABC' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 3]] - - describe "when there is a selection", -> - it "upper cases the current selection", -> - editor.buffer.setText("abc") - editor.setSelectedBufferRange([[0, 0], [0, 2]]) - editor.upperCase() - expect(editor.lineTextForBufferRow(0)).toBe 'ABc' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] - - describe ".lowerCase()", -> - describe "when there is no selection", -> - it "lower cases the current word", -> - editor.buffer.setText("aBC") - editor.setCursorScreenPosition([0, 1]) - editor.lowerCase() - expect(editor.lineTextForBufferRow(0)).toBe 'abc' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 3]] - - describe "when there is a selection", -> - it "lower cases the current selection", -> - editor.buffer.setText("ABC") - editor.setSelectedBufferRange([[0, 0], [0, 2]]) - editor.lowerCase() - expect(editor.lineTextForBufferRow(0)).toBe 'abC' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] - - describe '.setTabLength(tabLength)', -> - it 'clips atomic soft tabs to the given tab length', -> - expect(editor.getTabLength()).toBe 2 - expect(editor.clipScreenPosition([5, 1], clipDirection: 'forward')).toEqual([5, 2]) - - editor.setTabLength(6) - expect(editor.getTabLength()).toBe 6 - expect(editor.clipScreenPosition([5, 1], clipDirection: 'forward')).toEqual([5, 6]) - - changeHandler = jasmine.createSpy('changeHandler') - editor.onDidChange(changeHandler) - editor.setTabLength(6) - expect(changeHandler).not.toHaveBeenCalled() - - it 'does not change its tab length when the given tab length is null', -> - editor.setTabLength(4) - editor.setTabLength(null) - expect(editor.getTabLength()).toBe(4) - - describe ".indentLevelForLine(line)", -> - it "returns the indent level when the line has only leading whitespace", -> - expect(editor.indentLevelForLine(" hello")).toBe(2) - expect(editor.indentLevelForLine(" hello")).toBe(1.5) - - it "returns the indent level when the line has only leading tabs", -> - expect(editor.indentLevelForLine("\t\thello")).toBe(2) - - it "returns the indent level based on the character starting the line when the leading whitespace contains both spaces and tabs", -> - expect(editor.indentLevelForLine("\t hello")).toBe(2) - expect(editor.indentLevelForLine(" \thello")).toBe(2) - expect(editor.indentLevelForLine(" \t hello")).toBe(2.5) - expect(editor.indentLevelForLine(" \t \thello")).toBe(4) - expect(editor.indentLevelForLine(" \t \thello")).toBe(4) - expect(editor.indentLevelForLine(" \t \t hello")).toBe(4.5) - - describe "when a better-matched grammar is added to syntax", -> - it "switches to the better-matched grammar and re-tokenizes the buffer", -> - editor.destroy() - - jsGrammar = atom.grammars.selectGrammar('a.js') - atom.grammars.removeGrammar(jsGrammar) - - waitsForPromise -> - atom.workspace.open('sample.js', autoIndent: false).then (o) -> editor = o - - runs -> - expect(editor.getGrammar()).toBe atom.grammars.nullGrammar - expect(editor.tokensForScreenRow(0).length).toBe(1) - - atom.grammars.addGrammar(jsGrammar) - expect(editor.getGrammar()).toBe jsGrammar - expect(editor.tokensForScreenRow(0).length).toBeGreaterThan 1 - - describe "editor.autoIndent", -> - describe "when editor.autoIndent is false (default)", -> - describe "when `indent` is triggered", -> - it "does not auto-indent the line", -> - editor.setCursorBufferPosition([1, 30]) - editor.insertText("\n ") - expect(editor.lineTextForBufferRow(2)).toBe " " - - editor.update({autoIndent: false}) - editor.indent() - expect(editor.lineTextForBufferRow(2)).toBe " " - - describe "when editor.autoIndent is true", -> - beforeEach -> - editor.update({autoIndent: true}) - - describe "when `indent` is triggered", -> - it "auto-indents the line", -> - editor.setCursorBufferPosition([1, 30]) - editor.insertText("\n ") - expect(editor.lineTextForBufferRow(2)).toBe " " - - editor.update({autoIndent: true}) - editor.indent() - expect(editor.lineTextForBufferRow(2)).toBe " " - - describe "when a newline is added", -> - describe "when the line preceding the newline adds a new level of indentation", -> - it "indents the newline to one additional level of indentation beyond the preceding line", -> - editor.setCursorBufferPosition([1, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - - describe "when the line preceding the newline doesn't add a level of indentation", -> - it "indents the new line to the same level as the preceding line", -> - editor.setCursorBufferPosition([5, 14]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(6)).toBe editor.indentationForBufferRow(5) - - describe "when the line preceding the newline is a comment", -> - it "maintains the indent of the commented line", -> - editor.setCursorBufferPosition([0, 0]) - editor.insertText(' //') - editor.setCursorBufferPosition([0, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(1)).toBe 2 - - describe "when the line preceding the newline contains only whitespace", -> - it "bases the new line's indentation on only the preceding line", -> - editor.setCursorBufferPosition([6, Infinity]) - editor.insertText("\n ") - expect(editor.getCursorBufferPosition()).toEqual([7, 2]) - - editor.insertNewline() - expect(editor.lineTextForBufferRow(8)).toBe(" ") - - it "does not indent the line preceding the newline", -> - editor.setCursorBufferPosition([2, 0]) - editor.insertText(' var this-line-should-be-indented-more\n') - expect(editor.indentationForBufferRow(1)).toBe 1 - - editor.update({autoIndent: true}) - editor.setCursorBufferPosition([2, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(1)).toBe 1 - expect(editor.indentationForBufferRow(2)).toBe 1 - - describe "when the cursor is before whitespace", -> - it "retains the whitespace following the cursor on the new line", -> - editor.setText(" var sort = function() {}") - editor.setCursorScreenPosition([0, 12]) - editor.insertNewline() - - expect(buffer.lineForRow(0)).toBe ' var sort =' - expect(buffer.lineForRow(1)).toBe ' function() {}' - expect(editor.getCursorScreenPosition()).toEqual [1, 2] - - describe "when inserted text matches a decrease indent pattern", -> - describe "when the preceding line matches an increase indent pattern", -> - it "decreases the indentation to match that of the preceding line", -> - editor.setCursorBufferPosition([1, Infinity]) - editor.insertText('\n') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - editor.insertText('}') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) - - describe "when the preceding line doesn't match an increase indent pattern", -> - it "decreases the indentation to be one level below that of the preceding line", -> - editor.setCursorBufferPosition([3, Infinity]) - editor.insertText('\n ') - expect(editor.indentationForBufferRow(4)).toBe editor.indentationForBufferRow(3) - editor.insertText('}') - expect(editor.indentationForBufferRow(4)).toBe editor.indentationForBufferRow(3) - 1 - - it "doesn't break when decreasing the indentation on a row that has no indentation", -> - editor.setCursorBufferPosition([12, Infinity]) - editor.insertText("\n}; # too many closing brackets!") - expect(editor.lineTextForBufferRow(13)).toBe "}; # too many closing brackets!" - - describe "when inserted text does not match a decrease indent pattern", -> - it "does not decrease the indentation", -> - editor.setCursorBufferPosition([12, 0]) - editor.insertText(' ') - expect(editor.lineTextForBufferRow(12)).toBe ' };' - editor.insertText('\t\t') - expect(editor.lineTextForBufferRow(12)).toBe ' \t\t};' - - describe "when the current line does not match a decrease indent pattern", -> - it "leaves the line unchanged", -> - editor.setCursorBufferPosition([2, 4]) - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - editor.insertText('foo') - expect(editor.indentationForBufferRow(2)).toBe editor.indentationForBufferRow(1) + 1 - - describe "atomic soft tabs", -> - it "skips tab-length runs of leading whitespace when moving the cursor", -> - editor.update({tabLength: 4, atomicSoftTabs: true}) - - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 4] - - editor.update({atomicSoftTabs: false}) - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 3] - - editor.update({atomicSoftTabs: true}) - editor.setCursorScreenPosition([2, 3]) - expect(editor.getCursorScreenPosition()).toEqual [2, 4] - - describe ".destroy()", -> - it "destroys marker layers associated with the text editor", -> - buffer.retain() - selectionsMarkerLayerId = editor.selectionsMarkerLayer.id - foldsMarkerLayerId = editor.displayLayer.foldsMarkerLayer.id - editor.destroy() - expect(buffer.getMarkerLayer(selectionsMarkerLayerId)).toBeUndefined() - expect(buffer.getMarkerLayer(foldsMarkerLayerId)).toBeUndefined() - buffer.release() - - it "notifies ::onDidDestroy observers when the editor is destroyed", -> - destroyObserverCalled = false - editor.onDidDestroy -> destroyObserverCalled = true - - editor.destroy() - expect(destroyObserverCalled).toBe true - - it "does not blow up when query methods are called afterward", -> - editor.destroy() - editor.getGrammar() - editor.getLastCursor() - editor.lineTextForBufferRow(0) - - it "emits the destroy event after destroying the editor's buffer", -> - events = [] - editor.getBuffer().onDidDestroy -> - expect(editor.isDestroyed()).toBe(true) - events.push('buffer-destroyed') - editor.onDidDestroy -> - expect(buffer.isDestroyed()).toBe(true) - events.push('editor-destroyed') - editor.destroy() - expect(events).toEqual(['buffer-destroyed', 'editor-destroyed']) - - describe ".joinLines()", -> - describe "when no text is selected", -> - describe "when the line below isn't empty", -> - it "joins the line below with the current line separated by a space and moves the cursor to the start of line that was moved up", -> - editor.setCursorBufferPosition([0, Infinity]) - editor.insertText(' ') - editor.setCursorBufferPosition([0]) - editor.joinLines() - expect(editor.lineTextForBufferRow(0)).toBe 'var quicksort = function () { var sort = function(items) {' - expect(editor.getCursorBufferPosition()).toEqual [0, 29] - - describe "when the line below is empty", -> - it "deletes the line below and moves the cursor to the end of the line", -> - editor.setCursorBufferPosition([9]) - editor.joinLines() - expect(editor.lineTextForBufferRow(9)).toBe ' };' - expect(editor.lineTextForBufferRow(10)).toBe ' return sort(Array.apply(this, arguments));' - expect(editor.getCursorBufferPosition()).toEqual [9, 4] - - describe "when the cursor is on the last row", -> - it "does nothing", -> - editor.setCursorBufferPosition([Infinity, Infinity]) - editor.joinLines() - expect(editor.lineTextForBufferRow(12)).toBe '};' - - describe "when the line is empty", -> - it "joins the line below with the current line with no added space", -> - editor.setCursorBufferPosition([10]) - editor.joinLines() - expect(editor.lineTextForBufferRow(10)).toBe 'return sort(Array.apply(this, arguments));' - expect(editor.getCursorBufferPosition()).toEqual [10, 0] - - describe "when text is selected", -> - describe "when the selection does not span multiple lines", -> - it "joins the line below with the current line separated by a space and retains the selected text", -> - editor.setSelectedBufferRange([[0, 1], [0, 3]]) - editor.joinLines() - expect(editor.lineTextForBufferRow(0)).toBe 'var quicksort = function () { var sort = function(items) {' - expect(editor.getSelectedBufferRange()).toEqual [[0, 1], [0, 3]] - - describe "when the selection spans multiple lines", -> - it "joins all selected lines separated by a space and retains the selected text", -> - editor.setSelectedBufferRange([[9, 3], [12, 1]]) - editor.joinLines() - expect(editor.lineTextForBufferRow(9)).toBe ' }; return sort(Array.apply(this, arguments)); };' - expect(editor.getSelectedBufferRange()).toEqual [[9, 3], [9, 49]] - - describe ".duplicateLines()", -> - it "for each selection, duplicates all buffer lines intersected by the selection", -> - editor.foldBufferRow(4) - editor.setCursorBufferPosition([2, 5]) - editor.addSelectionForBufferRange([[3, 0], [8, 0]], preserveFolds: true) - - editor.duplicateLines() - - expect(editor.getTextInBufferRange([[2, 0], [13, 5]])).toBe """ - \ if (items.length <= 1) return items; - if (items.length <= 1) return items; - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - """ - expect(editor.getSelectedBufferRanges()).toEqual [[[3, 5], [3, 5]], [[9, 0], [14, 0]]] - - # folds are also duplicated - expect(editor.isFoldedAtScreenRow(5)).toBe(true) - expect(editor.isFoldedAtScreenRow(7)).toBe(true) - expect(editor.lineTextForScreenRow(7)).toBe " while(items.length > 0) {" + editor.displayLayer.foldCharacter - expect(editor.lineTextForScreenRow(8)).toBe " return sort(left).concat(pivot).concat(sort(right));" - - it "duplicates all folded lines for empty selections on lines containing folds", -> - editor.foldBufferRow(4) - editor.setCursorBufferPosition([4, 0]) - - editor.duplicateLines() - - expect(editor.getTextInBufferRange([[2, 0], [11, 5]])).toBe """ - \ if (items.length <= 1) return items; - var pivot = items.shift(), current, left = [], right = []; - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - while(items.length > 0) { - current = items.shift(); - current < pivot ? left.push(current) : right.push(current); - } - """ - expect(editor.getSelectedBufferRange()).toEqual [[8, 0], [8, 0]] - - it "can duplicate the last line of the buffer", -> - editor.setSelectedBufferRange([[11, 0], [12, 2]]) - editor.duplicateLines() - expect(editor.getTextInBufferRange([[11, 0], [14, 2]])).toBe """ - \ return sort(Array.apply(this, arguments)); - }; - return sort(Array.apply(this, arguments)); - }; - """ - expect(editor.getSelectedBufferRange()).toEqual [[13, 0], [14, 2]] - - it "only duplicates lines containing multiple selections once", -> - editor.setText(""" - aaaaaa - bbbbbb - cccccc - dddddd - """) - editor.setSelectedBufferRanges([ - [[0, 1], [0, 2]], - [[0, 3], [0, 4]], - [[2, 1], [2, 2]], - [[2, 3], [3, 1]], - [[3, 3], [3, 4]], - ]) - editor.duplicateLines() - expect(editor.getText()).toBe(""" - aaaaaa - aaaaaa - bbbbbb - cccccc - dddddd - cccccc - dddddd - """) - expect(editor.getSelectedBufferRanges()).toEqual([ - [[1, 1], [1, 2]], - [[1, 3], [1, 4]], - [[5, 1], [5, 2]], - [[5, 3], [6, 1]], - [[6, 3], [6, 4]], - ]) - - describe "when the editor contains surrogate pair characters", -> - it "correctly backspaces over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') - editor.moveToBottom() - editor.backspace() - expect(editor.getText()).toBe '\uD835\uDF97\uD835\uDF97' - editor.backspace() - expect(editor.getText()).toBe '\uD835\uDF97' - editor.backspace() - expect(editor.getText()).toBe '' - - it "correctly deletes over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') - editor.moveToTop() - editor.delete() - expect(editor.getText()).toBe '\uD835\uDF97\uD835\uDF97' - editor.delete() - expect(editor.getText()).toBe '\uD835\uDF97' - editor.delete() - expect(editor.getText()).toBe '' - - it "correctly moves over them", -> - editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97\n') - editor.moveToTop() - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe "when the editor contains variation sequence character pairs", -> - it "correctly backspaces over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') - editor.moveToBottom() - editor.backspace() - expect(editor.getText()).toBe '\u2714\uFE0E\u2714\uFE0E' - editor.backspace() - expect(editor.getText()).toBe '\u2714\uFE0E' - editor.backspace() - expect(editor.getText()).toBe '' - - it "correctly deletes over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') - editor.moveToTop() - editor.delete() - expect(editor.getText()).toBe '\u2714\uFE0E\u2714\uFE0E' - editor.delete() - expect(editor.getText()).toBe '\u2714\uFE0E' - editor.delete() - expect(editor.getText()).toBe '' - - it "correctly moves over them", -> - editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E\n') - editor.moveToTop() - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveRight() - expect(editor.getCursorBufferPosition()).toEqual [1, 0] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 6] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 4] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 2] - editor.moveLeft() - expect(editor.getCursorBufferPosition()).toEqual [0, 0] - - describe ".setIndentationForBufferRow", -> - describe "when the editor uses soft tabs but the row has hard tabs", -> - it "only replaces whitespace characters", -> - editor.setSoftWrapped(true) - editor.setText("\t1\n\t2") - editor.setCursorBufferPosition([0, 0]) - editor.setIndentationForBufferRow(0, 2) - expect(editor.getText()).toBe(" 1\n\t2") - - describe "when the indentation level is a non-integer", -> - it "does not throw an exception", -> - editor.setSoftWrapped(true) - editor.setText("\t1\n\t2") - editor.setCursorBufferPosition([0, 0]) - editor.setIndentationForBufferRow(0, 2.1) - expect(editor.getText()).toBe(" 1\n\t2") - - describe "when the editor's grammar has an injection selector", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-text') - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - it "includes the grammar's patterns when the selector matches the current scope in other grammars", -> - waitsForPromise -> - atom.packages.activatePackage('language-hyperlink') - - runs -> - grammar = atom.grammars.selectGrammar("text.js") - {line, tags} = grammar.tokenizeLine("var i; // http://github.com") - - tokens = atom.grammars.decodeTokens(line, tags) - expect(tokens[0].value).toBe "var" - expect(tokens[0].scopes).toEqual ["source.js", "storage.type.var.js"] - - expect(tokens[6].value).toBe "http://github.com" - expect(tokens[6].scopes).toEqual ["source.js", "comment.line.double-slash.js", "markup.underline.link.http.hyperlink"] - - describe "when the grammar is added", -> - it "retokenizes existing buffers that contain tokens that match the injection selector", -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - editor.setText("// http://github.com") - - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('language-hyperlink') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - {text: 'http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--markup syntax--underline syntax--link syntax--http syntax--hyperlink']} - ] - - describe "when the grammar is updated", -> - it "retokenizes existing buffers that contain tokens that match the injection selector", -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - editor.setText("// SELECT * FROM OCTOCATS") - - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('package-with-injection-selector') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - waitsForPromise -> - atom.packages.activatePackage('language-sql') - - runs -> - tokens = editor.tokensForScreenRow(0) - expect(tokens).toEqual [ - {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: 'SELECT', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: '*', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--operator syntax--star syntax--sql']}, - {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, - {text: 'FROM', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, - {text: ' OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} - ] - - describe ".normalizeTabsInBufferRange()", -> - it "normalizes tabs depending on the editor's soft tab/tab length settings", -> - editor.setTabLength(1) - editor.setSoftTabs(true) - editor.setText('\t\t\t') - editor.normalizeTabsInBufferRange([[0, 0], [0, 1]]) - expect(editor.getText()).toBe ' \t\t' - - editor.setTabLength(2) - editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) - expect(editor.getText()).toBe ' ' - - editor.setSoftTabs(false) - editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) - expect(editor.getText()).toBe ' ' - - describe ".pageUp/Down()", -> - it "moves the cursor down one page length", -> - editor.update(autoHeight: false) - element = editor.getElement() - jasmine.attachToDOM(element) - element.style.height = element.component.getLineHeight() * 5 + 'px' - element.measureDimensions() - - expect(editor.getCursorBufferPosition().row).toBe 0 - - editor.pageDown() - expect(editor.getCursorBufferPosition().row).toBe 5 - - editor.pageDown() - expect(editor.getCursorBufferPosition().row).toBe 10 - - editor.pageUp() - expect(editor.getCursorBufferPosition().row).toBe 5 - - editor.pageUp() - expect(editor.getCursorBufferPosition().row).toBe 0 - - describe ".selectPageUp/Down()", -> - it "selects one screen height of text up or down", -> - editor.update(autoHeight: false) - element = editor.getElement() - jasmine.attachToDOM(element) - element.style.height = element.component.getLineHeight() * 5 + 'px' - element.measureDimensions() - - expect(editor.getCursorBufferPosition().row).toBe 0 - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [5, 0]]] - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [10, 0]]] - - editor.selectPageDown() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] - - editor.moveToBottom() - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[7, 0], [12, 2]]] - - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[2, 0], [12, 2]]] - - editor.selectPageUp() - expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] - - describe "::scrollToScreenPosition(position, [options])", -> - it "triggers ::onDidRequestAutoscroll with the logical coordinates along with the options", -> - scrollSpy = jasmine.createSpy("::onDidRequestAutoscroll") - editor.onDidRequestAutoscroll(scrollSpy) - - editor.scrollToScreenPosition([8, 20]) - editor.scrollToScreenPosition([8, 20], center: true) - editor.scrollToScreenPosition([8, 20], center: false, reversed: true) - - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {}) - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {center: true}) - expect(scrollSpy).toHaveBeenCalledWith(screenRange: [[8, 20], [8, 20]], options: {center: false, reversed: true}) - - describe "scroll past end", -> - it "returns false by default but can be customized", -> - expect(editor.getScrollPastEnd()).toBe(false) - editor.update({scrollPastEnd: true}) - expect(editor.getScrollPastEnd()).toBe(true) - editor.update({scrollPastEnd: false}) - expect(editor.getScrollPastEnd()).toBe(false) - - it "always returns false when autoHeight is on", -> - editor.update({autoHeight: true, scrollPastEnd: true}) - expect(editor.getScrollPastEnd()).toBe(false) - editor.update({autoHeight: false}) - expect(editor.getScrollPastEnd()).toBe(true) - - describe "auto height", -> - it "returns true by default but can be customized", -> - editor = new TextEditor - expect(editor.getAutoHeight()).toBe(true) - editor.update({autoHeight: false}) - expect(editor.getAutoHeight()).toBe(false) - editor.update({autoHeight: true}) - expect(editor.getAutoHeight()).toBe(true) - editor.destroy() - - describe "auto width", -> - it "returns false by default but can be customized", -> - expect(editor.getAutoWidth()).toBe(false) - editor.update({autoWidth: true}) - expect(editor.getAutoWidth()).toBe(true) - editor.update({autoWidth: false}) - expect(editor.getAutoWidth()).toBe(false) - - describe '.get/setPlaceholderText()', -> - it 'can be created with placeholderText', -> - newEditor = new TextEditor({ - mini: true - placeholderText: 'yep' - }) - expect(newEditor.getPlaceholderText()).toBe 'yep' - - it 'models placeholderText and emits an event when changed', -> - editor.onDidChangePlaceholderText handler = jasmine.createSpy() - - expect(editor.getPlaceholderText()).toBeUndefined() - - editor.setPlaceholderText('OK') - expect(handler).toHaveBeenCalledWith 'OK' - expect(editor.getPlaceholderText()).toBe 'OK' - - describe 'gutters', -> - describe 'the TextEditor constructor', -> - it 'creates a line-number gutter', -> - expect(editor.getGutters().length).toBe 1 - lineNumberGutter = editor.gutterWithName('line-number') - expect(lineNumberGutter.name).toBe 'line-number' - expect(lineNumberGutter.priority).toBe 0 - - describe '::addGutter', -> - it 'can add a gutter', -> - expect(editor.getGutters().length).toBe 1 # line-number gutter - options = - name: 'test-gutter' - priority: 1 - gutter = editor.addGutter options - expect(editor.getGutters().length).toBe 2 - expect(editor.getGutters()[1]).toBe gutter - - it "does not allow a custom gutter with the 'line-number' name.", -> - expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow() - - describe '::decorateMarker', -> - [marker] = [] - - beforeEach -> - marker = editor.markBufferRange([[1, 0], [1, 0]]) - - it 'reflects an added decoration when one of its custom gutters is decorated.', -> - gutter = editor.addGutter {'name': 'custom-gutter'} - decoration = gutter.decorateMarker marker, {class: 'custom-class'} - gutterDecorations = editor.getDecorations - type: 'gutter' - gutterName: 'custom-gutter' - class: 'custom-class' - expect(gutterDecorations.length).toBe 1 - expect(gutterDecorations[0]).toBe decoration - - it 'reflects an added decoration when its line-number gutter is decorated.', -> - decoration = editor.gutterWithName('line-number').decorateMarker marker, {class: 'test-class'} - gutterDecorations = editor.getDecorations - type: 'line-number' - gutterName: 'line-number' - class: 'test-class' - expect(gutterDecorations.length).toBe 1 - expect(gutterDecorations[0]).toBe decoration - - describe '::observeGutters', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback immediately with each existing gutter, and with each added gutter after that.', -> - lineNumberGutter = editor.gutterWithName('line-number') - editor.observeGutters(callback) - expect(payloads).toEqual [lineNumberGutter] - gutter1 = editor.addGutter({name: 'test-gutter-1'}) - expect(payloads).toEqual [lineNumberGutter, gutter1] - gutter2 = editor.addGutter({name: 'test-gutter-2'}) - expect(payloads).toEqual [lineNumberGutter, gutter1, gutter2] - - it 'does not call the callback when a gutter is removed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - editor.observeGutters(callback) - payloads = [] - gutter.destroy() - expect(payloads).toEqual [] - - it 'does not call the callback after the subscription has been disposed.', -> - subscription = editor.observeGutters(callback) - payloads = [] - subscription.dispose() - editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [] - - describe '::onDidAddGutter', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback with each newly-added gutter, but not with existing gutters.', -> - editor.onDidAddGutter(callback) - expect(payloads).toEqual [] - gutter = editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [gutter] - - it 'does not call the callback after the subscription has been disposed.', -> - subscription = editor.onDidAddGutter(callback) - payloads = [] - subscription.dispose() - editor.addGutter({name: 'test-gutter'}) - expect(payloads).toEqual [] - - describe '::onDidRemoveGutter', -> - [payloads, callback] = [] - - beforeEach -> - payloads = [] - callback = (payload) -> - payloads.push(payload) - - it 'calls the callback when a gutter is removed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - editor.onDidRemoveGutter(callback) - expect(payloads).toEqual [] - gutter.destroy() - expect(payloads).toEqual ['test-gutter'] - - it 'does not call the callback after the subscription has been disposed.', -> - gutter = editor.addGutter({name: 'test-gutter'}) - subscription = editor.onDidRemoveGutter(callback) - subscription.dispose() - gutter.destroy() - expect(payloads).toEqual [] - - describe "decorations", -> - describe "::decorateMarker", -> - it "includes the decoration in the object returned from ::decorationsStateForScreenRowRange", -> - marker = editor.markBufferRange([[2, 4], [6, 8]]) - decoration = editor.decorateMarker(marker, type: 'highlight', class: 'foo') - expect(editor.decorationsStateForScreenRowRange(0, 5)[decoration.id]).toEqual { - properties: {type: 'highlight', class: 'foo'} - screenRange: marker.getScreenRange(), - bufferRange: marker.getBufferRange(), - rangeIsReversed: false - } - - it "does not throw errors after the marker's containing layer is destroyed", -> - layer = editor.addMarkerLayer() - marker = layer.markBufferRange([[2, 4], [6, 8]]) - decoration = editor.decorateMarker(marker, type: 'highlight', class: 'foo') - layer.destroy() - editor.decorationsStateForScreenRowRange(0, 5) - - describe "::decorateMarkerLayer", -> - it "based on the markers in the layer, includes multiple decoration objects with the same properties and different ranges in the object returned from ::decorationsStateForScreenRowRange", -> - layer1 = editor.getBuffer().addMarkerLayer() - marker1 = layer1.markRange([[2, 4], [6, 8]]) - marker2 = layer1.markRange([[11, 0], [11, 12]]) - layer2 = editor.getBuffer().addMarkerLayer() - marker3 = layer2.markRange([[8, 0], [9, 0]]) - - layer1Decoration1 = editor.decorateMarkerLayer(layer1, type: 'highlight', class: 'foo') - layer1Decoration2 = editor.decorateMarkerLayer(layer1, type: 'highlight', class: 'bar') - layer2Decoration = editor.decorateMarkerLayer(layer2, type: 'highlight', class: 'baz') - - decorationState = editor.decorationsStateForScreenRowRange(0, 13) - - expect(decorationState["#{layer1Decoration1.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'foo'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration1.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'foo'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer2Decoration.id}-#{marker3.id}"]).toEqual { - properties: {type: 'highlight', class: 'baz'}, - screenRange: marker3.getRange(), - bufferRange: marker3.getRange(), - rangeIsReversed: false - } - - layer1Decoration1.destroy() - - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration1.id}-#{marker1.id}"]).toBeUndefined() - expect(decorationState["#{layer1Decoration1.id}-#{marker2.id}"]).toBeUndefined() - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer1Decoration2.id}-#{marker2.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker2.getRange(), - bufferRange: marker2.getRange(), - rangeIsReversed: false - } - expect(decorationState["#{layer2Decoration.id}-#{marker3.id}"]).toEqual { - properties: {type: 'highlight', class: 'baz'}, - screenRange: marker3.getRange(), - bufferRange: marker3.getRange(), - rangeIsReversed: false - } - - layer1Decoration2.setPropertiesForMarker(marker1, {type: 'highlight', class: 'quux'}) - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'quux'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - - layer1Decoration2.setPropertiesForMarker(marker1, null) - decorationState = editor.decorationsStateForScreenRowRange(0, 12) - expect(decorationState["#{layer1Decoration2.id}-#{marker1.id}"]).toEqual { - properties: {type: 'highlight', class: 'bar'}, - screenRange: marker1.getRange(), - bufferRange: marker1.getRange(), - rangeIsReversed: false - } - - describe "invisibles", -> - beforeEach -> - editor.update({showInvisibles: true}) - - it "substitutes invisible characters according to the given rules", -> - previousLineText = editor.lineTextForScreenRow(0) - editor.update({invisibles: {eol: '?'}}) - expect(editor.lineTextForScreenRow(0)).not.toBe(previousLineText) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) - expect(editor.getInvisibles()).toEqual(eol: '?') - - it "does not use invisibles if showInvisibles is set to false", -> - editor.update({invisibles: {eol: '?'}}) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) - - editor.update({showInvisibles: false}) - expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(false) - - describe "indent guides", -> - it "shows indent guides when `editor.showIndentGuide` is set to true and the editor is not mini", -> - editor.setText(" foo") - editor.setTabLength(2) - - editor.update({showIndentGuide: false}) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - editor.update({showIndentGuide: true}) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace indent-guide']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - editor.setMini(true) - expect(editor.tokensForScreenRow(0)).toEqual [ - {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, - {text: 'foo', scopes: ['syntax--source syntax--js']} - ] - - describe "when the editor is constructed with the grammar option set", -> - beforeEach -> - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - it "sets the grammar", -> - editor = new TextEditor({grammar: atom.grammars.grammarForScopeName('source.coffee')}) - expect(editor.getGrammar().name).toBe 'CoffeeScript' - - describe "softWrapAtPreferredLineLength", -> - it "soft wraps the editor at the preferred line length unless the editor is narrower or the editor is mini", -> - editor.update({ - editorWidthInChars: 30 - softWrapped: true - softWrapAtPreferredLineLength: true - preferredLineLength: 20 - }) - - expect(editor.lineTextForScreenRow(0)).toBe 'var quicksort = ' - - editor.update({editorWidthInChars: 10}) - expect(editor.lineTextForScreenRow(0)).toBe 'var ' - - editor.update({mini: true}) - expect(editor.lineTextForScreenRow(0)).toBe 'var quicksort = function () {' - - describe "softWrapHangingIndentLength", -> - it "controls how much extra indentation is applied to soft-wrapped lines", -> - editor.setText('123456789') - editor.update({ - editorWidthInChars: 8 - softWrapped: true - softWrapHangingIndentLength: 2 - }) - expect(editor.lineTextForScreenRow(1)).toEqual ' 9' - - editor.update({softWrapHangingIndentLength: 4}) - expect(editor.lineTextForScreenRow(1)).toEqual ' 9' - - describe "::getElement", -> - it "returns an element", -> - expect(editor.getElement() instanceof HTMLElement).toBe(true) - - describe 'setMaxScreenLineLength', -> - it "sets the maximum line length in the editor before soft wrapping is forced", -> - expect(editor.getSoftWrapColumn()).toBe(500) - editor.update({ - maxScreenLineLength: 1500 - }) - expect(editor.getSoftWrapColumn()).toBe(1500) diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index c81df8089..fa8406731 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -1,7 +1,6694 @@ +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') + const fs = require('fs') +const path = require('path') const temp = require('temp').track() -const {Point, Range} = require('text-buffer') -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const dedent = require('dedent') +const clipboard = require('../src/safe-clipboard') +const TextEditor = require('../src/text-editor') +const TextBuffer = require('text-buffer') + +describe('TextEditor', () => { + let buffer, editor, lineLengths + + beforeEach(async () => { + editor = await atom.workspace.open('sample.js') + buffer = editor.buffer + editor.update({autoIndent: false}) + lineLengths = buffer.getLines().map(line => line.length) + await atom.packages.activatePackage('language-javascript') + }) + + describe('when the editor is deserialized', () => { + it('restores selections and folds based on markers in the buffer', async () => { + editor.setSelectedBufferRange([[1, 2], [3, 4]]) + editor.addSelectionForBufferRange([[5, 6], [7, 5]], {reversed: true}) + editor.foldBufferRow(4) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + + const buffer2 = await TextBuffer.deserialize(editor.buffer.serialize()) + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return buffer2 }} + }) + + expect(editor2.id).toBe(editor.id) + expect(editor2.getBuffer().getPath()).toBe(editor.getBuffer().getPath()) + expect(editor2.getSelectedBufferRanges()).toEqual([[[1, 2], [3, 4]], [[5, 6], [7, 5]]]) + expect(editor2.getSelections()[1].isReversed()).toBeTruthy() + expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() + editor2.destroy() + }) + + it("restores the editor's layout configuration", async () => { + editor.update({ + softTabs: true, + atomicSoftTabs: false, + tabLength: 12, + softWrapped: true, + softWrapAtPreferredLineLength: true, + softWrapHangingIndentLength: 8, + invisibles: {space: 'S'}, + showInvisibles: true, + editorWidthInChars: 120 + }) + + // Force buffer and display layer to be deserialized as well, rather than + // reusing the same buffer instance + const buffer2 = await TextBuffer.deserialize(editor.buffer.serialize()) + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return buffer2 }} + }) + + expect(editor2.getSoftTabs()).toBe(editor.getSoftTabs()) + expect(editor2.hasAtomicSoftTabs()).toBe(editor.hasAtomicSoftTabs()) + expect(editor2.getTabLength()).toBe(editor.getTabLength()) + expect(editor2.getSoftWrapColumn()).toBe(editor.getSoftWrapColumn()) + expect(editor2.getSoftWrapHangingIndentLength()).toBe(editor.getSoftWrapHangingIndentLength()) + expect(editor2.getInvisibles()).toEqual(editor.getInvisibles()) + expect(editor2.getEditorWidthInChars()).toBe(editor.getEditorWidthInChars()) + expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength()) + expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn()) + }) + + it('ignores buffers with retired IDs', () => { + const editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync () { return null }} + }) + + expect(editor2).toBeNull() + }) + }) + + describe('when the editor is constructed with the largeFileMode option set to true', () => { + it("loads the editor but doesn't tokenize", async () => { + editor = await atom.workspace.openTextFile('sample.js', {largeFileMode: true}) + buffer = editor.getBuffer() + expect(editor.lineTextForScreenRow(0)).toBe(buffer.lineForRow(0)) + expect(editor.tokensForScreenRow(0).length).toBe(1) + expect(editor.tokensForScreenRow(1).length).toBe(2) // soft tab + expect(editor.lineTextForScreenRow(12)).toBe(buffer.lineForRow(12)) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + editor.insertText('hey"') + expect(editor.tokensForScreenRow(0).length).toBe(1) + expect(editor.tokensForScreenRow(1).length).toBe(2) + }) + }) + + describe('.copy()', () => { + it('returns a different editor with the same initial state', () => { + expect(editor.getAutoHeight()).toBeFalsy() + expect(editor.getAutoWidth()).toBeFalsy() + expect(editor.getShowCursorOnSelection()).toBeTruthy() + + const element = editor.getElement() + element.setHeight(100) + element.setWidth(100) + jasmine.attachToDOM(element) + + editor.update({showCursorOnSelection: false}) + editor.setSelectedBufferRange([[1, 2], [3, 4]]) + editor.addSelectionForBufferRange([[5, 6], [7, 8]], {reversed: true}) + editor.foldBufferRow(4) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + editor.setScrollTopRow(3) + expect(editor.getScrollTopRow()).toBe(3) + editor.setScrollLeftColumn(4) + expect(editor.getScrollLeftColumn()).toBe(4) + + const editor2 = editor.copy() + const element2 = editor2.getElement() + element2.setHeight(100) + element2.setWidth(100) + jasmine.attachToDOM(element2) + expect(editor2.id).not.toBe(editor.id) + expect(editor2.getSelectedBufferRanges()).toEqual(editor.getSelectedBufferRanges()) + expect(editor2.getSelections()[1].isReversed()).toBeTruthy() + expect(editor2.getScrollTopRow()).toBe(3) + expect(editor2.getScrollLeftColumn()).toBe(4) + expect(editor2.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor2.getAutoWidth()).toBe(false) + expect(editor2.getAutoHeight()).toBe(false) + expect(editor2.getShowCursorOnSelection()).toBeFalsy() + + // editor2 can now diverge from its origin edit session + editor2.getLastSelection().setBufferRange([[2, 1], [4, 3]]) + expect(editor2.getSelectedBufferRanges()).not.toEqual(editor.getSelectedBufferRanges()) + editor2.unfoldBufferRow(4) + expect(editor2.isFoldedAtBufferRow(4)).not.toBe(editor.isFoldedAtBufferRow(4)) + }) + }) + + describe('.update()', () => { + it('updates the editor with the supplied config parameters', () => { + let changeSpy + const { element } = editor // force element initialization + element.setUpdatedSynchronously(false) + editor.update({showInvisibles: true}) + editor.onDidChange(changeSpy = jasmine.createSpy('onDidChange')) + + const returnedPromise = editor.update({ + tabLength: 6, + softTabs: false, + softWrapped: true, + editorWidthInChars: 40, + showInvisibles: false, + mini: false, + lineNumberGutterVisible: false, + scrollPastEnd: true, + autoHeight: false, + maxScreenLineLength: 1000 + }) + + expect(returnedPromise).toBe(element.component.getNextUpdatePromise()) + expect(changeSpy.callCount).toBe(1) + expect(editor.getTabLength()).toBe(6) + expect(editor.getSoftTabs()).toBe(false) + expect(editor.isSoftWrapped()).toBe(true) + expect(editor.getEditorWidthInChars()).toBe(40) + expect(editor.getInvisibles()).toEqual({}) + expect(editor.isMini()).toBe(false) + expect(editor.isLineNumberGutterVisible()).toBe(false) + expect(editor.getScrollPastEnd()).toBe(true) + expect(editor.getAutoHeight()).toBe(false) + }) + }) + + describe('title', () => { + describe('.getTitle()', () => { + it("uses the basename of the buffer's path as its title, or 'untitled' if the path is undefined", () => { + expect(editor.getTitle()).toBe('sample.js') + buffer.setPath(undefined) + expect(editor.getTitle()).toBe('untitled') + }) + }) + + describe('.getLongTitle()', () => { + it('returns file name when there is no opened file with identical name', () => { + expect(editor.getLongTitle()).toBe('sample.js') + buffer.setPath(undefined) + expect(editor.getLongTitle()).toBe('untitled') + }) + + it("returns '' when opened files have identical file names", async () => { + const editor1 = await atom.workspace.open(path.join('sample-theme-1', 'readme')) + const editor2 = await atom.workspace.open(path.join('sample-theme-2', 'readme')) + expect(editor1.getLongTitle()).toBe('readme \u2014 sample-theme-1') + expect(editor2.getLongTitle()).toBe('readme \u2014 sample-theme-2') + }) + + it("returns '' when opened files have identical file names in subdirectories", async () => { + const path1 = path.join('sample-theme-1', 'src', 'js') + const path2 = path.join('sample-theme-2', 'src', 'js') + const editor1 = await atom.workspace.open(path.join(path1, 'main.js')) + const editor2 = await atom.workspace.open(path.join(path2, 'main.js')) + expect(editor1.getLongTitle()).toBe(`main.js \u2014 ${path1}`) + expect(editor2.getLongTitle()).toBe(`main.js \u2014 ${path2}`) + }) + + it("returns '' when opened files have identical file and same parent dir name", async () => { + const editor1 = await atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'main.js')) + const editor2 = await atom.workspace.open(path.join('sample-theme-2', 'src', 'js', 'plugin', 'main.js')) + expect(editor1.getLongTitle()).toBe('main.js \u2014 js') + expect(editor2.getLongTitle()).toBe(`main.js \u2014 ${path.join('js', 'plugin')}`) + }) + + it('returns the filename when the editor is not in the workspace', async () => { + editor.onDidDestroy(() => { + expect(editor.getLongTitle()).toBe('sample.js') + }) + + await atom.workspace.getActivePane().close() + expect(editor.isDestroyed()).toBe(true) + }) + }) + + it('notifies ::onDidChangeTitle observers when the underlying buffer path changes', () => { + const observed = [] + editor.onDidChangeTitle(title => observed.push(title)) + + buffer.setPath('/foo/bar/baz.txt') + buffer.setPath(undefined) + + expect(observed).toEqual(['baz.txt', 'untitled']) + }) + }) + + describe('path', () => { + it('notifies ::onDidChangePath observers when the underlying buffer path changes', () => { + const observed = [] + editor.onDidChangePath(filePath => observed.push(filePath)) + + buffer.setPath(__filename) + buffer.setPath(undefined) + + expect(observed).toEqual([__filename, undefined]) + }) + }) + + describe('encoding', () => { + it('notifies ::onDidChangeEncoding observers when the editor encoding changes', () => { + const observed = [] + editor.onDidChangeEncoding(encoding => observed.push(encoding)) + + editor.setEncoding('utf16le') + editor.setEncoding('utf16le') + editor.setEncoding('utf16be') + editor.setEncoding() + editor.setEncoding() + + expect(observed).toEqual(['utf16le', 'utf16be', 'utf8']) + }) + }) + + describe('cursor', () => { + describe('.getLastCursor()', () => { + it('returns the most recently created cursor', () => { + editor.addCursorAtScreenPosition([1, 0]) + const lastCursor = editor.addCursorAtScreenPosition([2, 0]) + expect(editor.getLastCursor()).toBe(lastCursor) + }) + + it('creates a new cursor at (0, 0) if the last cursor has been destroyed', () => { + editor.getLastCursor().destroy() + expect(editor.getLastCursor().getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.getCursors()', () => { + it('creates a new cursor at (0, 0) if the last cursor has been destroyed', () => { + editor.getLastCursor().destroy() + expect(editor.getCursors()[0].getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('when the cursor moves', () => { + it('clears a goal column established by vertical movement', () => { + editor.setText('b') + editor.setCursorBufferPosition([0, 0]) + editor.insertNewline() + editor.moveUp() + editor.insertText('a') + editor.moveDown() + expect(editor.getCursorBufferPosition()).toEqual([1, 1]) + }) + + it('emits an event with the old position, new position, and the cursor that moved', () => { + const cursorCallback = jasmine.createSpy('cursor-changed-position') + const editorCallback = jasmine.createSpy('editor-changed-cursor-position') + + editor.getLastCursor().onDidChangePosition(cursorCallback) + editor.onDidChangeCursorPosition(editorCallback) + + editor.setCursorBufferPosition([2, 4]) + + expect(editorCallback).toHaveBeenCalled() + expect(cursorCallback).toHaveBeenCalled() + const eventObject = editorCallback.mostRecentCall.args[0] + expect(cursorCallback.mostRecentCall.args[0]).toEqual(eventObject) + + expect(eventObject.oldBufferPosition).toEqual([0, 0]) + expect(eventObject.oldScreenPosition).toEqual([0, 0]) + expect(eventObject.newBufferPosition).toEqual([2, 4]) + expect(eventObject.newScreenPosition).toEqual([2, 4]) + expect(eventObject.cursor).toBe(editor.getLastCursor()) + }) + }) + + describe('.setCursorScreenPosition(screenPosition)', () => { + it('clears a goal column established by vertical movement', () => { + // set a goal column by moving down + editor.setCursorScreenPosition({row: 3, column: lineLengths[3]}) + editor.moveDown() + expect(editor.getCursorScreenPosition().column).not.toBe(6) + + // clear the goal column by explicitly setting the cursor position + editor.setCursorScreenPosition([4, 6]) + expect(editor.getCursorScreenPosition().column).toBe(6) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(6) + }) + + it('merges multiple cursors', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 1]) + const [cursor1, cursor2] = editor.getCursors() + editor.setCursorScreenPosition([4, 7]) + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursors()).toEqual([cursor1]) + expect(editor.getCursorScreenPosition()).toEqual([4, 7]) + }) + + describe('when soft-wrap is enabled and code is folded', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + editor.foldBufferRowRange(2, 3) + }) + + it('positions the cursor at the buffer position that corresponds to the given screen position', () => { + editor.setCursorScreenPosition([9, 0]) + expect(editor.getCursorBufferPosition()).toEqual([8, 11]) + }) + }) + }) + + describe('.moveUp()', () => { + it('moves the cursor up', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([1, 2]) + }) + + it('retains the goal column across lines of differing length', () => { + expect(lineLengths[6]).toBeGreaterThan(32) + editor.setCursorScreenPosition({row: 6, column: 32}) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[5]) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[4]) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(32) + }) + + describe('when the cursor is on the first line', () => { + it('moves the cursor to the beginning of the line, but retains the goal column', () => { + editor.setCursorScreenPosition([0, 4]) + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([1, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[4, 9], [5, 10]])) + + it('moves above the selection', () => { + const cursor = editor.getLastCursor() + editor.moveUp() + expect(cursor.getBufferPosition()).toEqual([3, 9]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.addCursorAtScreenPosition([1, 0]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveUp() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + }) + + describe('when the cursor was moved down from the beginning of an indented soft-wrapped line', () => { + it('moves to the beginning of the previous line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + + editor.setCursorScreenPosition([3, 0]) + editor.moveDown() + editor.moveDown() + editor.moveUp() + expect(editor.getCursorScreenPosition()).toEqual([4, 4]) + }) + }) + }) + + describe('.moveDown()', () => { + it('moves the cursor down', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([3, 2]) + }) + + it('retains the goal column across lines of differing length', () => { + editor.setCursorScreenPosition({row: 3, column: lineLengths[3]}) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[4]) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[5]) + + editor.moveDown() + expect(editor.getCursorScreenPosition().column).toBe(lineLengths[3]) + }) + + describe('when the cursor is on the last line', () => { + it('moves the cursor to the end of line, but retains the goal column when moving back up', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + editor.setCursorScreenPosition({row: lastLineIndex, column: editor.getTabLength()}) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual({row: lastLineIndex, column: lastLine.length}) + + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(editor.getTabLength()) + }) + + it('retains a goal column of 0 when moving back up', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + editor.setCursorScreenPosition({row: lastLineIndex, column: 0}) + editor.moveDown() + editor.moveUp() + expect(editor.getCursorScreenPosition().column).toBe(0) + }) + }) + + describe('when the cursor is at the beginning of an indented soft-wrapped line', () => { + it("moves to the beginning of the line's continuation on the next screen row", () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + + editor.setCursorScreenPosition([3, 0]) + editor.moveDown() + expect(editor.getCursorScreenPosition()).toEqual([4, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[4, 9], [5, 10]])) + + it('moves below the selection', () => { + const cursor = editor.getLastCursor() + editor.moveDown() + expect(cursor.getBufferPosition()).toEqual([6, 10]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([11, 2]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveDown() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveLeft()', () => { + it('moves the cursor by one column to the left', () => { + editor.setCursorScreenPosition([1, 8]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([1, 7]) + }) + + it('moves the cursor by n columns to the left', () => { + editor.setCursorScreenPosition([1, 8]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([1, 4]) + }) + + it('moves the cursor by two rows up when the columnCount is longer than an entire line', () => { + editor.setCursorScreenPosition([2, 2]) + editor.moveLeft(34) + expect(editor.getCursorScreenPosition()).toEqual([0, 29]) + }) + + it('moves the cursor to the beginning columnCount is longer than the position in the buffer', () => { + editor.setCursorScreenPosition([1, 0]) + editor.moveLeft(100) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + + describe('when the cursor is in the first column', () => { + describe('when there is a previous line', () => { + it('wraps to the end of the previous line', () => { + editor.setCursorScreenPosition({row: 1, column: 0}) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual({row: 0, column: buffer.lineForRow(0).length}) + }) + + it('moves the cursor by one row up and n columns to the left', () => { + editor.setCursorScreenPosition([1, 0]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([0, 26]) + }) + }) + + describe('when the next line is empty', () => { + it('wraps to the beginning of the previous line', () => { + editor.setCursorScreenPosition([11, 0]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([10, 0]) + }) + }) + + describe('when line is wrapped and follow previous line indentation', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(50) + }) + + it('wraps to the end of the previous line', () => { + editor.setCursorScreenPosition([4, 4]) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual([3, 46]) + }) + }) + + describe('when the cursor is on the first line', () => { + it('remains in the same position (0,0)', () => { + editor.setCursorScreenPosition({row: 0, column: 0}) + editor.moveLeft() + expect(editor.getCursorScreenPosition()).toEqual({row: 0, column: 0}) + }) + + it('remains in the same position (0,0) when columnCount is specified', () => { + editor.setCursorScreenPosition([0, 0]) + editor.moveLeft(4) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + }) + }) + + describe('when softTabs is enabled and the cursor is preceded by leading whitespace', () => { + it('skips tabLength worth of whitespace at a time', () => { + editor.setCursorBufferPosition([5, 6]) + + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([5, 4]) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[5, 22], [5, 27]])) + + it('moves to the left of the selection', () => { + const cursor = editor.getLastCursor() + editor.moveLeft() + expect(cursor.getBufferPosition()).toEqual([5, 22]) + + editor.moveLeft() + expect(cursor.getBufferPosition()).toEqual([5, 21]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 1]) + + const [cursor1, cursor2] = editor.getCursors() + editor.moveLeft() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveRight()', () => { + it('moves the cursor by one column to the right', () => { + editor.setCursorScreenPosition([3, 3]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([3, 4]) + }) + + it('moves the cursor by n columns to the right', () => { + editor.setCursorScreenPosition([3, 7]) + editor.moveRight(4) + expect(editor.getCursorScreenPosition()).toEqual([3, 11]) + }) + + it('moves the cursor by two rows down when the columnCount is longer than an entire line', () => { + editor.setCursorScreenPosition([0, 29]) + editor.moveRight(34) + expect(editor.getCursorScreenPosition()).toEqual([2, 2]) + }) + + it('moves the cursor to the end of the buffer when columnCount is longer than the number of characters following the cursor position', () => { + editor.setCursorScreenPosition([11, 5]) + editor.moveRight(100) + expect(editor.getCursorScreenPosition()).toEqual([12, 2]) + }) + + describe('when the cursor is on the last column of a line', () => { + describe('when there is a subsequent line', () => { + it('wraps to the beginning of the next line', () => { + editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([1, 0]) + }) + + it('moves the cursor by one row down and n columns to the right', () => { + editor.setCursorScreenPosition([0, buffer.lineForRow(0).length]) + editor.moveRight(4) + expect(editor.getCursorScreenPosition()).toEqual([1, 3]) + }) + }) + + describe('when the next line is empty', () => { + it('wraps to the beginning of the next line', () => { + editor.setCursorScreenPosition([9, 4]) + editor.moveRight() + expect(editor.getCursorScreenPosition()).toEqual([10, 0]) + }) + }) + + describe('when the cursor is on the last line', () => { + it('remains in the same position', () => { + const lastLineIndex = buffer.getLines().length - 1 + const lastLine = buffer.lineForRow(lastLineIndex) + expect(lastLine.length).toBeGreaterThan(0) + + const lastPosition = {row: lastLineIndex, column: lastLine.length} + editor.setCursorScreenPosition(lastPosition) + editor.moveRight() + + expect(editor.getCursorScreenPosition()).toEqual(lastPosition) + }) + }) + }) + + describe('when there is a selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[5, 22], [5, 27]])) + + it('moves to the left of the selection', () => { + const cursor = editor.getLastCursor() + editor.moveRight() + expect(cursor.getBufferPosition()).toEqual([5, 27]) + + editor.moveRight() + expect(cursor.getBufferPosition()).toEqual([5, 28]) + }) + }) + + it('merges cursors when they overlap', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([12, 1]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveRight() + expect(editor.getCursors()).toEqual([cursor1]) + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveToTop()', () => { + it('moves the cursor to the top of the buffer', () => { + editor.setCursorScreenPosition([11, 1]) + editor.addCursorAtScreenPosition([12, 0]) + editor.moveToTop() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToBottom()', () => { + it('moves the cursor to the bottom of the buffer', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + editor.moveToBottom() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([12, 2]) + }) + }) + + describe('.moveToBeginningOfScreenLine()', () => { + describe('when soft wrap is on', () => { + it('moves cursor to the beginning of the screen line', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToBeginningOfScreenLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([1, 0]) + }) + }) + + describe('when soft wrap is off', () => { + it('moves cursor to the beginning of the line', () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + editor.moveToBeginningOfScreenLine() + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + }) + }) + }) + + describe('.moveToEndOfScreenLine()', () => { + describe('when soft wrap is on', () => { + it('moves cursor to the beginning of the screen line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToEndOfScreenLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([1, 9]) + }) + }) + + describe('when soft wrap is off', () => { + it('moves cursor to the end of line', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + editor.moveToEndOfScreenLine() + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 29]) + expect(cursor2.getBufferPosition()).toEqual([1, 30]) + }) + }) + }) + + describe('.moveToBeginningOfLine()', () => { + it('moves cursor to the beginning of the buffer line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([1, 2]) + editor.moveToBeginningOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToEndOfLine()', () => { + it('moves cursor to the end of the buffer line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([0, 2]) + editor.moveToEndOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getScreenPosition()).toEqual([4, 4]) + }) + }) + + describe('.moveToFirstCharacterOfLine()', () => { + describe('when soft wrap is on', () => { + it("moves to the first character of the current screen line or the beginning of the screen line if it's already on the first character", () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(10) + editor.setCursorScreenPosition([2, 5]) + editor.addCursorAtScreenPosition([8, 7]) + + editor.moveToFirstCharacterOfLine() + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getScreenPosition()).toEqual([2, 0]) + expect(cursor2.getScreenPosition()).toEqual([8, 2]) + + editor.moveToFirstCharacterOfLine() + expect(cursor1.getScreenPosition()).toEqual([2, 0]) + expect(cursor2.getScreenPosition()).toEqual([8, 2]) + }) + }) + + describe('when soft wrap is off', () => { + it("moves to the first character of the current line or the beginning of the line if it's already on the first character", () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + + editor.moveToFirstCharacterOfLine() + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 2]) + + editor.moveToFirstCharacterOfLine() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + }) + + it('moves to the beginning of the line if it only contains whitespace ', () => { + editor.setText('first\n \nthird') + editor.setCursorScreenPosition([1, 2]) + editor.moveToFirstCharacterOfLine() + const cursor = editor.getLastCursor() + expect(cursor.getBufferPosition()).toEqual([1, 0]) + }) + + describe('when invisible characters are enabled with soft tabs', () => { + it('moves to the first character of the current line without being confused by the invisible characters', () => { + editor.update({showInvisibles: true}) + editor.setCursorScreenPosition([1, 7]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + }) + }) + + describe('when invisible characters are enabled with hard tabs', () => { + it('moves to the first character of the current line without being confused by the invisible characters', () => { + editor.update({showInvisibles: true}) + buffer.setTextInRange([[1, 0], [1, Infinity]], '\t\t\ta', {normalizeLineEndings: false}) + + editor.setCursorScreenPosition([1, 7]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 3]) + editor.moveToFirstCharacterOfLine() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + }) + }) + }) + }) + + describe('.moveToBeginningOfWord()', () => { + it('moves the cursor to the beginning of the word', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([1, 12]) + editor.addCursorAtBufferPosition([3, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToBeginningOfWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([1, 11]) + expect(cursor3.getBufferPosition()).toEqual([2, 39]) + }) + + it('does not fail at position [0, 0]', () => { + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfWord() + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([11, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('treats lines with only whitespace as a word (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([11, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([9, 2]) + }) + + it('works when the current line is blank (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfWord() + expect(editor.getCursorBufferPosition()).toEqual([9, 2]) + editor.buffer.setText(buffer.getText().replace(/\r\n/g, '\n')) + }) + }) + + describe('.moveToPreviousWordBoundary()', () => { + it('moves the cursor to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 0]) + editor.addCursorAtBufferPosition([2, 4]) + editor.addCursorAtBufferPosition([3, 14]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.moveToPreviousWordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([1, 30]) + expect(cursor3.getBufferPosition()).toEqual([2, 0]) + expect(cursor4.getBufferPosition()).toEqual([3, 13]) + }) + }) + + describe('.moveToNextWordBoundary()', () => { + it('moves the cursor to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 40]) + editor.addCursorAtBufferPosition([3, 0]) + editor.addCursorAtBufferPosition([3, 30]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.moveToNextWordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 0]) + expect(cursor3.getBufferPosition()).toEqual([3, 4]) + expect(cursor4.getBufferPosition()).toEqual([3, 31]) + }) + }) + + describe('.moveToEndOfWord()', () => { + it('moves the cursor to the end of the word', () => { + editor.setCursorBufferPosition([0, 6]) + editor.addCursorAtBufferPosition([1, 10]) + editor.addCursorAtBufferPosition([2, 40]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToEndOfWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([1, 12]) + expect(cursor3.getBufferPosition()).toEqual([3, 7]) + }) + + it('does not blow up when there is no next word', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + const endPosition = editor.getCursorBufferPosition() + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual(endPosition) + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([9, 4]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('treats lines with only whitespace as a word (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([9, 4]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 8]) + }) + + it('works when the current line is blank (CRLF line ending)', () => { + editor.buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + editor.setCursorBufferPosition([10, 0]) + editor.moveToEndOfWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 8]) + }) + }) + + describe('.moveToBeginningOfNextWord()', () => { + it('moves the cursor before the first character of the next word', () => { + editor.setCursorBufferPosition([0, 6]) + editor.addCursorAtBufferPosition([1, 11]) + editor.addCursorAtBufferPosition([2, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + editor.moveToBeginningOfNextWord() + + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([1, 13]) + expect(cursor3.getBufferPosition()).toEqual([2, 4]) + + // When the cursor is on whitespace + editor.setText('ab cde- ') + editor.setCursorBufferPosition([0, 2]) + const cursor = editor.getLastCursor() + editor.moveToBeginningOfNextWord() + + expect(cursor.getBufferPosition()).toEqual([0, 3]) + }) + + it('does not blow up when there is no next word', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + const endPosition = editor.getCursorBufferPosition() + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual(endPosition) + }) + + it('treats lines with only whitespace as a word', () => { + editor.setCursorBufferPosition([9, 4]) + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + + it('works when the current line is blank', () => { + editor.setCursorBufferPosition([10, 0]) + editor.moveToBeginningOfNextWord() + expect(editor.getCursorBufferPosition()).toEqual([11, 9]) + }) + }) + + describe('.moveToPreviousSubwordBoundary', () => { + it('does not move the cursor when there is no previous subword boundary', () => { + editor.setText('') + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('stops at word and underscore boundaries', () => { + editor.setText('sub_word \n') + editor.setCursorBufferPosition([0, 9]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 8]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText(' word\n') + editor.setCursorBufferPosition([0, 3]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('stops at camelCase boundaries', () => { + editor.setText(' getPreviousWord\n') + editor.setCursorBufferPosition([0, 16]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 12]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('stops at camelCase boundaries with non-ascii characters', () => { + editor.setText(' gétÁrevìôüsWord\n') + editor.setCursorBufferPosition([0, 16]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 12]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('skips consecutive non-word characters', () => { + editor.setText('e, => \n') + editor.setCursorBufferPosition([0, 6]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('skips consecutive uppercase characters', () => { + editor.setText(' AAADF \n') + editor.setCursorBufferPosition([0, 7]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.setText('ALPhA\n') + editor.setCursorBufferPosition([0, 4]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + + it('skips consecutive uppercase non-ascii letters', () => { + editor.setText(' ÀÁÅDF \n') + editor.setCursorBufferPosition([0, 7]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.setText('ALPhA\n') + editor.setCursorBufferPosition([0, 4]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + + it('skips consecutive numbers', () => { + editor.setText(' 88 \n') + editor.setCursorBufferPosition([0, 4]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('works with multiple cursors', () => { + editor.setText('curOp\ncursorOptions\n') + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([1, 13]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveToPreviousSubwordBoundary() + + expect(cursor1.getBufferPosition()).toEqual([0, 3]) + expect(cursor2.getBufferPosition()).toEqual([1, 6]) + }) + + it('works with non-English characters', () => { + editor.setText('supåTøåst \n') + editor.setCursorBufferPosition([0, 9]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.setText('supaÖast \n') + editor.setCursorBufferPosition([0, 8]) + editor.moveToPreviousSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + }) + + describe('.moveToNextSubwordBoundary', () => { + it('does not move the cursor when there is no next subword boundary', () => { + editor.setText('') + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('stops at word and underscore boundaries', () => { + editor.setText(' sub_word \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 9]) + + editor.setText('word \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + + it('stops at camelCase boundaries', () => { + editor.setText('getPreviousWord \n') + editor.setCursorBufferPosition([0, 0]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 11]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 15]) + }) + + it('skips consecutive non-word characters', () => { + editor.setText(', => \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + + it('skips consecutive uppercase characters', () => { + editor.setText(' AAADF \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + editor.setText('ALPhA\n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + + it('skips consecutive numbers', () => { + editor.setText(' 88 \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + }) + + it('works with multiple cursors', () => { + editor.setText('curOp\ncursorOptions\n') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 0]) + const [cursor1, cursor2] = editor.getCursors() + + editor.moveToNextSubwordBoundary() + expect(cursor1.getBufferPosition()).toEqual([0, 3]) + expect(cursor2.getBufferPosition()).toEqual([1, 6]) + }) + + it('works with non-English characters', () => { + editor.setText('supåTøåst \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + + editor.setText('supaÖast \n') + editor.setCursorBufferPosition([0, 0]) + editor.moveToNextSubwordBoundary() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + }) + + describe('.moveToBeginningOfNextParagraph()', () => { + it('moves the cursor before the first line of the next paragraph', () => { + editor.setCursorBufferPosition([0, 6]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('moves the cursor before the first line of the next paragraph (CRLF line endings)', () => { + editor.setText(editor.getText().replace(/\n/g, '\r\n')) + + editor.setCursorBufferPosition([0, 6]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfNextParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.moveToBeginningOfPreviousParagraph()', () => { + it('moves the cursor before the first line of the previous paragraph', () => { + editor.setCursorBufferPosition([10, 0]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + + it('moves the cursor before the first line of the previous paragraph (CRLF line endings)', () => { + editor.setText(editor.getText().replace(/\n/g, '\r\n')) + + editor.setCursorBufferPosition([10, 0]) + editor.foldBufferRow(4) + + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + + editor.setText('') + editor.setCursorBufferPosition([0, 0]) + editor.moveToBeginningOfPreviousParagraph() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.getCurrentParagraphBufferRange()', () => { + it('returns the buffer range of the current paragraph, delimited by blank lines or the beginning / end of the file', () => { + buffer.setText(' ' + dedent` + I am the first paragraph, + bordered by the beginning of + the file + ${' '} + + I am the second paragraph + with blank lines above and below + me. + + I am the last paragraph, + bordered by the end of the file.\ + `) + + // in a paragraph + editor.setCursorBufferPosition([1, 7]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[0, 0], [2, 8]]) + + editor.setCursorBufferPosition([7, 1]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[5, 0], [7, 3]]) + + editor.setCursorBufferPosition([9, 10]) + expect(editor.getCurrentParagraphBufferRange()).toEqual([[9, 0], [10, 32]]) + + // between paragraphs + editor.setCursorBufferPosition([3, 1]) + expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() + }) + + it('will limit paragraph range to comments', () => { + editor.setGrammar(atom.grammars.grammarForScopeName('source.js')) + editor.setText(dedent` + var quicksort = function () { + /* Single line comment block */ + var sort = function(items) {}; + + /* + A multiline + comment is here + */ + var sort = function(items) {}; + + // A comment + // + // Multiple comment + // lines + var sort = function(items) {}; + // comment line after fn + + var nosort = function(items) { + item; + } + + };\ + `) + + function paragraphBufferRangeForRow (row) { + editor.setCursorBufferPosition([row, 0]) + return editor.getLastCursor().getCurrentParagraphBufferRange() + } + + expect(paragraphBufferRangeForRow(0)).toEqual([[0, 0], [0, 29]]) + expect(paragraphBufferRangeForRow(1)).toEqual([[1, 0], [1, 33]]) + expect(paragraphBufferRangeForRow(2)).toEqual([[2, 0], [2, 32]]) + expect(paragraphBufferRangeForRow(3)).toBeFalsy() + expect(paragraphBufferRangeForRow(4)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(5)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(6)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(7)).toEqual([[4, 0], [7, 4]]) + expect(paragraphBufferRangeForRow(8)).toEqual([[8, 0], [8, 32]]) + expect(paragraphBufferRangeForRow(9)).toBeFalsy() + expect(paragraphBufferRangeForRow(10)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(11)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(12)).toEqual([[10, 0], [13, 10]]) + expect(paragraphBufferRangeForRow(14)).toEqual([[14, 0], [14, 32]]) + expect(paragraphBufferRangeForRow(15)).toEqual([[15, 0], [15, 26]]) + expect(paragraphBufferRangeForRow(18)).toEqual([[17, 0], [19, 3]]) + }) + }) + + describe('getCursorAtScreenPosition(screenPosition)', () => { + it('returns the cursor at the given screenPosition', () => { + const cursor1 = editor.addCursorAtScreenPosition([0, 2]) + const cursor2 = editor.getCursorAtScreenPosition(cursor1.getScreenPosition()) + expect(cursor2).toBe(cursor1) + }) + }) + + describe('::getCursorScreenPositions()', () => { + it('returns the cursor positions in the order they were added', () => { + editor.foldBufferRow(4) + const cursor1 = editor.addCursorAtBufferPosition([8, 5]) + const cursor2 = editor.addCursorAtBufferPosition([3, 5]) + expect(editor.getCursorScreenPositions()).toEqual([[0, 0], [5, 5], [3, 5]]) + }) + }) + + describe('::getCursorsOrderedByBufferPosition()', () => { + it('returns all cursors ordered by buffer positions', () => { + const originalCursor = editor.getLastCursor() + const cursor1 = editor.addCursorAtBufferPosition([8, 5]) + const cursor2 = editor.addCursorAtBufferPosition([4, 5]) + expect(editor.getCursorsOrderedByBufferPosition()).toEqual([originalCursor, cursor2, cursor1]) + }) + }) + + describe('addCursorAtScreenPosition(screenPosition)', () => { + describe('when a cursor already exists at the position', () => { + it('returns the existing cursor', () => { + const cursor1 = editor.addCursorAtScreenPosition([0, 2]) + const cursor2 = editor.addCursorAtScreenPosition([0, 2]) + expect(cursor2).toBe(cursor1) + }) + }) + }) + + describe('addCursorAtBufferPosition(bufferPosition)', () => { + describe('when a cursor already exists at the position', () => { + it('returns the existing cursor', () => { + const cursor1 = editor.addCursorAtBufferPosition([1, 4]) + const cursor2 = editor.addCursorAtBufferPosition([1, 4]) + expect(cursor2.marker).toBe(cursor1.marker) + }) + }) + }) + + describe('.getCursorScope()', () => { + it('returns the current scope', () => { + const descriptor = editor.getCursorScope() + expect(descriptor.scopes).toContain('source.js') + }) + }) + }) + + describe('selection', () => { + let selection + + beforeEach(() => { + selection = editor.getLastSelection() + }) + + describe('.getLastSelection()', () => { + it('creates a new selection at (0, 0) if the last selection has been destroyed', () => { + editor.getLastSelection().destroy() + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + + it("doesn't get stuck in a infinite loop when called from ::onDidAddCursor after the last selection has been destroyed (regression)", () => { + let callCount = 0 + editor.getLastSelection().destroy() + editor.onDidAddCursor(function (cursor) { + callCount++ + editor.getLastSelection() + }) + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [0, 0]]) + expect(callCount).toBe(1) + }) + }) + + describe('.getSelections()', () => { + it('creates a new selection at (0, 0) if the last selection has been destroyed', () => { + editor.getLastSelection().destroy() + expect(editor.getSelections()[0].getBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + }) + + describe('when the selection range changes', () => { + it('emits an event with the old range, new range, and the selection that moved', () => { + let rangeChangedHandler + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + + editor.onDidChangeSelectionRange(rangeChangedHandler = jasmine.createSpy()) + editor.selectToBufferPosition([6, 2]) + + expect(rangeChangedHandler).toHaveBeenCalled() + const eventObject = rangeChangedHandler.mostRecentCall.args[0] + + expect(eventObject.oldBufferRange).toEqual([[3, 0], [4, 5]]) + expect(eventObject.oldScreenRange).toEqual([[3, 0], [4, 5]]) + expect(eventObject.newBufferRange).toEqual([[3, 0], [6, 2]]) + expect(eventObject.newScreenRange).toEqual([[3, 0], [6, 2]]) + expect(eventObject.selection).toBe(selection) + }) + }) + + describe('.selectUp/Down/Left/Right()', () => { + it("expands each selection to its cursor's new location", () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 14]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 22]]) + + editor.selectLeft() + editor.selectLeft() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + + editor.selectDown() + expect(selection1.getBufferRange()).toEqual([[0, 9], [1, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [4, 20]]) + + editor.selectUp() + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + }) + + it('merges selections when they intersect when moving down', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]], [[2, 15], [3, 25]]]) + const [selection1, selection2, selection3] = editor.getSelections() + + editor.selectDown() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 9], [4, 25]]) + expect(selection1.isReversed()).toBeFalsy() + }) + + it('merges selections when they intersect when moving up', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]]], {reversed: true}) + const [selection1, selection2] = editor.getSelections() + + editor.selectUp() + expect(editor.getSelections().length).toBe(1) + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 0], [1, 20]]) + expect(selection1.isReversed()).toBeTruthy() + }) + + it('merges selections when they intersect when moving left', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[0, 13], [1, 20]]], {reversed: true}) + const [selection1, selection2] = editor.getSelections() + + editor.selectLeft() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 8], [1, 20]]) + expect(selection1.isReversed()).toBeTruthy() + }) + + it('merges selections when they intersect when moving right', () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 14]], [[0, 14], [1, 20]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.getScreenRange()).toEqual([[0, 9], [1, 21]]) + expect(selection1.isReversed()).toBeFalsy() + }) + + describe('when counts are passed into the selection functions', () => { + it("expands each selection to its cursor's new location", () => { + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[3, 16], [3, 21]]]) + const [selection1, selection2] = editor.getSelections() + + editor.selectRight(2) + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 15]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 23]]) + + editor.selectLeft(3) + expect(selection1.getBufferRange()).toEqual([[0, 9], [0, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [3, 20]]) + + editor.selectDown(3) + expect(selection1.getBufferRange()).toEqual([[0, 9], [3, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [6, 20]]) + + editor.selectUp(2) + expect(selection1.getBufferRange()).toEqual([[0, 9], [1, 12]]) + expect(selection2.getBufferRange()).toEqual([[3, 16], [4, 20]]) + }) + }) + }) + + describe('.selectToBufferPosition(bufferPosition)', () => { + it('expands the last selection to the given position', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtBufferPosition([5, 6]) + editor.selectToBufferPosition([6, 2]) + + const selections = editor.getSelections() + expect(selections.length).toBe(2) + const [selection1, selection2] = selections + expect(selection1.getBufferRange()).toEqual([[3, 0], [4, 5]]) + expect(selection2.getBufferRange()).toEqual([[5, 6], [6, 2]]) + }) + }) + + describe('.selectToScreenPosition(screenPosition)', () => { + it('expands the last selection to the given position', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + const selections = editor.getSelections() + expect(selections.length).toBe(2) + const [selection1, selection2] = selections + expect(selection1.getScreenRange()).toEqual([[3, 0], [4, 5]]) + expect(selection2.getScreenRange()).toEqual([[5, 6], [6, 2]]) + }) + + describe('when selecting with an initial screen range', () => { + it('switches the direction of the selection when selecting to positions before/after the start of the initial range', () => { + editor.setCursorScreenPosition([5, 10]) + editor.selectWordsContainingCursors() + editor.selectToScreenPosition([3, 0]) + expect(editor.getLastSelection().isReversed()).toBe(true) + editor.selectToScreenPosition([9, 0]) + expect(editor.getLastSelection().isReversed()).toBe(false) + }) + }) + }) + + describe('.selectToBeginningOfNextParagraph()', () => { + it('selects from the cursor to first line of the next paragraph', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + editor.selectToBeginningOfNextParagraph() + + const selections = editor.getSelections() + expect(selections.length).toBe(1) + expect(selections[0].getScreenRange()).toEqual([[3, 0], [10, 0]]) + }) + }) + + describe('.selectToBeginningOfPreviousParagraph()', () => { + it('selects from the cursor to the first line of the previous paragraph', () => { + editor.setSelectedBufferRange([[3, 0], [4, 5]]) + editor.addCursorAtScreenPosition([5, 6]) + editor.selectToScreenPosition([6, 2]) + + editor.selectToBeginningOfPreviousParagraph() + + const selections = editor.getSelections() + expect(selections.length).toBe(1) + expect(selections[0].getScreenRange()).toEqual([[0, 0], [5, 6]]) + }) + + it('merges selections if they intersect, maintaining the directionality of the last selection', () => { + editor.setCursorScreenPosition([4, 10]) + editor.selectToScreenPosition([5, 27]) + editor.addCursorAtScreenPosition([3, 10]) + editor.selectToScreenPosition([6, 27]) + + let selections = editor.getSelections() + expect(selections.length).toBe(1) + let [selection1] = selections + expect(selection1.getScreenRange()).toEqual([[3, 10], [6, 27]]) + expect(selection1.isReversed()).toBeFalsy() + + editor.addCursorAtScreenPosition([7, 4]) + editor.selectToScreenPosition([4, 11]) + + selections = editor.getSelections() + expect(selections.length).toBe(1); + [selection1] = selections + expect(selection1.getScreenRange()).toEqual([[3, 10], [7, 4]]) + expect(selection1.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToTop()', () => { + it('selects text from cursor position to the top of the buffer', () => { + editor.setCursorScreenPosition([11, 2]) + editor.addCursorAtScreenPosition([10, 0]) + editor.selectToTop() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + expect(editor.getLastSelection().getBufferRange()).toEqual([[0, 0], [11, 2]]) + expect(editor.getLastSelection().isReversed()).toBeTruthy() + }) + }) + + describe('.selectToBottom()', () => { + it('selects text from cursor position to the bottom of the buffer', () => { + editor.setCursorScreenPosition([10, 0]) + editor.addCursorAtScreenPosition([9, 3]) + editor.selectToBottom() + expect(editor.getCursors().length).toBe(1) + expect(editor.getCursorBufferPosition()).toEqual([12, 2]) + expect(editor.getLastSelection().getBufferRange()).toEqual([[9, 3], [12, 2]]) + expect(editor.getLastSelection().isReversed()).toBeFalsy() + }) + }) + + describe('.selectAll()', () => { + it('selects the entire buffer', () => { + editor.selectAll() + expect(editor.getLastSelection().getBufferRange()).toEqual(buffer.getRange()) + }) + }) + + describe('.selectToBeginningOfLine()', () => { + it('selects text from cursor position to beginning of line', () => { + editor.setCursorScreenPosition([12, 2]) + editor.addCursorAtScreenPosition([11, 3]) + + editor.selectToBeginningOfLine() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([12, 0]) + expect(cursor2.getBufferPosition()).toEqual([11, 0]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[12, 0], [12, 2]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[11, 0], [11, 3]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToEndOfLine()', () => { + it('selects text from cursor position to end of line', () => { + editor.setCursorScreenPosition([12, 0]) + editor.addCursorAtScreenPosition([11, 3]) + + editor.selectToEndOfLine() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([12, 2]) + expect(cursor2.getBufferPosition()).toEqual([11, 44]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[12, 0], [12, 2]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[11, 3], [11, 44]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectLinesContainingCursors()', () => { + it('selects to the entire line (including newlines) at given row', () => { + editor.setCursorScreenPosition([1, 2]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[1, 0], [2, 0]]) + expect(editor.getSelectedText()).toBe(' var sort = function(items) {\n') + + editor.setCursorScreenPosition([12, 2]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[12, 0], [12, 2]]) + + editor.setCursorBufferPosition([0, 2]) + editor.selectLinesContainingCursors() + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [2, 0]]) + }) + + describe('when the selection spans multiple row', () => { + it('selects from the beginning of the first line to the last line', () => { + selection = editor.getLastSelection() + selection.setBufferRange([[1, 10], [3, 20]]) + editor.selectLinesContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[1, 0], [4, 0]]) + }) + }) + }) + + describe('.selectToBeginningOfWord()', () => { + it('selects text from cursor position to beginning of word', () => { + editor.setCursorScreenPosition([0, 13]) + editor.addCursorAtScreenPosition([3, 49]) + + editor.selectToBeginningOfWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 4]) + expect(cursor2.getBufferPosition()).toEqual([3, 47]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 13]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[3, 47], [3, 49]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToEndOfWord()', () => { + it('selects text from cursor position to end of word', () => { + editor.setCursorScreenPosition([0, 4]) + editor.addCursorAtScreenPosition([3, 48]) + + editor.selectToEndOfWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 50]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 13]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[3, 48], [3, 50]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToBeginningOfNextWord()', () => { + it('selects text from cursor position to beginning of next word', () => { + editor.setCursorScreenPosition([0, 4]) + editor.addCursorAtScreenPosition([3, 48]) + + editor.selectToBeginningOfNextWord() + + expect(editor.getCursors().length).toBe(2) + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([3, 51]) + + expect(editor.getSelections().length).toBe(2) + const [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 4], [0, 14]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[3, 48], [3, 51]]) + expect(selection2.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToPreviousWordBoundary()', () => { + it('select to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 0]) + editor.addCursorAtBufferPosition([3, 4]) + editor.addCursorAtBufferPosition([3, 14]) + + editor.selectToPreviousWordBoundary() + + expect(editor.getSelections().length).toBe(4) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 8], [0, 4]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[2, 0], [1, 30]]) + expect(selection2.isReversed()).toBeTruthy() + expect(selection3.getBufferRange()).toEqual([[3, 4], [3, 0]]) + expect(selection3.isReversed()).toBeTruthy() + expect(selection4.getBufferRange()).toEqual([[3, 14], [3, 13]]) + expect(selection4.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToNextWordBoundary()', () => { + it('select to the next word boundary', () => { + editor.setCursorBufferPosition([0, 8]) + editor.addCursorAtBufferPosition([2, 40]) + editor.addCursorAtBufferPosition([4, 0]) + editor.addCursorAtBufferPosition([3, 30]) + + editor.selectToNextWordBoundary() + + expect(editor.getSelections().length).toBe(4) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 8], [0, 13]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[2, 40], [3, 0]]) + expect(selection2.isReversed()).toBeFalsy() + expect(selection3.getBufferRange()).toEqual([[4, 0], [4, 4]]) + expect(selection3.isReversed()).toBeFalsy() + expect(selection4.getBufferRange()).toEqual([[3, 30], [3, 31]]) + expect(selection4.isReversed()).toBeFalsy() + }) + }) + + describe('.selectToPreviousSubwordBoundary', () => { + it('selects subwords', () => { + editor.setText('') + editor.insertText('_word\n') + editor.insertText(' getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 5]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 5]) + editor.addCursorAtBufferPosition([3, 3]) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + + editor.selectToPreviousSubwordBoundary() + expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 4], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + expect(selection3.getBufferRange()).toEqual([[2, 3], [2, 5]]) + expect(selection3.isReversed()).toBeTruthy() + expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) + expect(selection4.isReversed()).toBeTruthy() + }) + }) + + describe('.selectToNextSubwordBoundary', () => { + it('selects subwords', () => { + editor.setText('') + editor.insertText('word_\n') + editor.insertText('getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 1]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 2]) + editor.addCursorAtBufferPosition([3, 1]) + const [selection1, selection2, selection3, selection4] = editor.getSelections() + + editor.selectToNextSubwordBoundary() + expect(selection1.getBufferRange()).toEqual([[0, 1], [0, 4]]) + expect(selection1.isReversed()).toBeFalsy() + expect(selection2.getBufferRange()).toEqual([[1, 7], [1, 11]]) + expect(selection2.isReversed()).toBeFalsy() + expect(selection3.getBufferRange()).toEqual([[2, 2], [2, 5]]) + expect(selection3.isReversed()).toBeFalsy() + expect(selection4.getBufferRange()).toEqual([[3, 1], [3, 3]]) + expect(selection4.isReversed()).toBeFalsy() + }) + }) + + describe('.deleteToBeginningOfSubword', () => { + it('deletes subwords', () => { + editor.setText('') + editor.insertText('_word\n') + editor.insertText(' getPreviousWord\n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 5]) + editor.addCursorAtBufferPosition([1, 7]) + editor.addCursorAtBufferPosition([2, 5]) + editor.addCursorAtBufferPosition([3, 3]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('_') + expect(buffer.lineForRow(1)).toBe(' getviousWord') + expect(buffer.lineForRow(2)).toBe('e, ') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 1]) + expect(cursor2.getBufferPosition()).toEqual([1, 4]) + expect(cursor3.getBufferPosition()).toEqual([2, 3]) + expect(cursor4.getBufferPosition()).toEqual([3, 1]) + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe(' viousWord') + expect(buffer.lineForRow(2)).toBe('e ') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 1]) + expect(cursor3.getBufferPosition()).toEqual([2, 1]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + + editor.deleteToBeginningOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe('viousWord') + expect(buffer.lineForRow(2)).toBe(' ') + expect(buffer.lineForRow(3)).toBe('') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 0]) + expect(cursor4.getBufferPosition()).toEqual([2, 1]) + }) + }) + + describe('.deleteToEndOfSubword', () => { + it('deletes subwords', () => { + editor.setText('') + editor.insertText('word_\n') + editor.insertText('getPreviousWord \n') + editor.insertText('e, => \n') + editor.insertText(' 88 \n') + editor.setCursorBufferPosition([0, 0]) + editor.addCursorAtBufferPosition([1, 0]) + editor.addCursorAtBufferPosition([2, 2]) + editor.addCursorAtBufferPosition([3, 0]) + const [cursor1, cursor2, cursor3, cursor4] = editor.getCursors() + + editor.deleteToEndOfSubword() + expect(buffer.lineForRow(0)).toBe('_') + expect(buffer.lineForRow(1)).toBe('PreviousWord ') + expect(buffer.lineForRow(2)).toBe('e, ') + expect(buffer.lineForRow(3)).toBe('88 ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 2]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + + editor.deleteToEndOfSubword() + expect(buffer.lineForRow(0)).toBe('') + expect(buffer.lineForRow(1)).toBe('Word ') + expect(buffer.lineForRow(2)).toBe('e,') + expect(buffer.lineForRow(3)).toBe(' ') + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 0]) + expect(cursor3.getBufferPosition()).toEqual([2, 2]) + expect(cursor4.getBufferPosition()).toEqual([3, 0]) + }) + }) + + describe('.selectWordsContainingCursors()', () => { + describe('when the cursor is inside a word', () => { + it('selects the entire word', () => { + editor.setCursorScreenPosition([0, 8]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('quicksort') + }) + }) + + describe('when the cursor is between two words', () => { + it('selects the word the cursor is on', () => { + editor.setCursorBufferPosition([0, 4]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('quicksort') + + editor.setCursorBufferPosition([0, 3]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('var') + + editor.setCursorBufferPosition([1, 22]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('items') + }) + }) + + describe('when the cursor is inside a region of whitespace', () => { + it('selects the whitespace region', () => { + editor.setCursorScreenPosition([5, 2]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[5, 0], [5, 6]]) + + editor.setCursorScreenPosition([5, 0]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[5, 0], [5, 6]]) + }) + }) + + describe('when the cursor is at the end of the text', () => { + it('select the previous word', () => { + editor.buffer.append('word') + editor.moveToBottom() + editor.selectWordsContainingCursors() + expect(editor.getSelectedBufferRange()).toEqual([[12, 2], [12, 6]]) + }) + }) + + it("selects words based on the non-word characters configured at the cursor's current scope", () => { + editor.setText("one-one; 'two-two'; three-three") + + editor.setCursorBufferPosition([0, 1]) + editor.addCursorAtBufferPosition([0, 12]) + + const scopeDescriptors = editor.getCursors().map(c => c.getScopeDescriptor()) + expect(scopeDescriptors[0].getScopesArray()).toEqual(['source.js']) + expect(scopeDescriptors[1].getScopesArray()).toEqual(['source.js', 'string.quoted.single.js']) + + editor.setScopedSettingsDelegate({ + getNonWordCharacters (scopes) { + const result = '/\()"\':,.;<>~!@#$%^&*|+=[]{}`?' + if (scopes.some(scope => scope.startsWith('string'))) { + return result + } else { + return result + '-' + } + } + }) + + editor.selectWordsContainingCursors() + + expect(editor.getSelections()[0].getText()).toBe('one') + expect(editor.getSelections()[1].getText()).toBe('two-two') + }) + }) + + describe('.selectToFirstCharacterOfLine()', () => { + it("moves to the first character of the current line or the beginning of the line if it's already on the first character", () => { + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 7]) + + editor.selectToFirstCharacterOfLine() + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor2.getBufferPosition()).toEqual([1, 2]) + + expect(editor.getSelections().length).toBe(2) + let [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 0], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 2], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + + editor.selectToFirstCharacterOfLine(); + [selection1, selection2] = editor.getSelections() + expect(selection1.getBufferRange()).toEqual([[0, 0], [0, 5]]) + expect(selection1.isReversed()).toBeTruthy() + expect(selection2.getBufferRange()).toEqual([[1, 0], [1, 7]]) + expect(selection2.isReversed()).toBeTruthy() + }) + }) + + describe('.setSelectedBufferRanges(ranges)', () => { + it('clears existing selections and creates selections for each of the given ranges', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + + editor.setSelectedBufferRanges([[[5, 5], [6, 6]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[5, 5], [6, 6]]]) + }) + + it('merges intersecting selections', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [5, 5]]]) + }) + + it('does not merge non-empty adjacent selections', () => { + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [3, 3]], [[3, 3], [5, 5]]]) + }) + + it('recycles existing selection instances', () => { + selection = editor.getLastSelection() + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[4, 4], [5, 5]]]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1).toBe(selection) + expect(selection1.getBufferRange()).toEqual([[2, 2], [3, 3]]) + }) + + describe("when the 'preserveFolds' option is false (the default)", () => { + it("removes folds that contain one or both of the selection's end points", () => { + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + editor.foldBufferRowRange(1, 4) + editor.foldBufferRowRange(2, 3) + editor.foldBufferRowRange(6, 8) + editor.foldBufferRowRange(10, 11) + + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 6], [7, 7]]]) + expect(editor.isFoldedAtScreenRow(1)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(6)).toBeFalsy() + expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() + + editor.setSelectedBufferRange([[10, 0], [12, 0]]) + expect(editor.isFoldedAtScreenRow(10)).toBeTruthy() + }) + }) + + describe("when the 'preserveFolds' option is true", () => { + it('does not remove folds that contain the selections', () => { + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + editor.foldBufferRowRange(1, 4) + editor.foldBufferRowRange(6, 8) + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 0], [6, 1]]], {preserveFolds: true}) + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + }) + }) + }) + + describe('.setSelectedScreenRanges(ranges)', () => { + beforeEach(() => editor.foldBufferRow(4)) + + it('clears existing selections and creates selections for each of the given ranges', () => { + editor.setSelectedScreenRanges([[[3, 4], [3, 7]], [[5, 4], [5, 7]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[3, 4], [3, 7]], [[8, 4], [8, 7]]]) + + editor.setSelectedScreenRanges([[[6, 2], [6, 4]]]) + expect(editor.getSelectedScreenRanges()).toEqual([[[6, 2], [6, 4]]]) + }) + + it('merges intersecting selections and unfolds the fold which contain them', () => { + editor.foldBufferRow(0) + + // Use buffer ranges because only the first line is on screen + editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [5, 5]]]) + }) + + it('recycles existing selection instances', () => { + selection = editor.getLastSelection() + editor.setSelectedScreenRanges([[[2, 2], [3, 4]], [[4, 4], [5, 5]]]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1).toBe(selection) + expect(selection1.getScreenRange()).toEqual([[2, 2], [3, 4]]) + }) + }) + + describe('.selectMarker(marker)', () => { + describe('if the marker is valid', () => { + it("selects the marker's range and returns the selected range", () => { + const marker = editor.markBufferRange([[0, 1], [3, 3]]) + expect(editor.selectMarker(marker)).toEqual([[0, 1], [3, 3]]) + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 3]]) + }) + }) + + describe('if the marker is invalid', () => { + it('does not change the selection and returns a falsy value', () => { + const marker = editor.markBufferRange([[0, 1], [3, 3]]) + marker.destroy() + expect(editor.selectMarker(marker)).toBeFalsy() + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 0]]) + }) + }) + }) + + describe('.addSelectionForBufferRange(bufferRange)', () => { + it('adds a selection for the specified buffer range', () => { + editor.addSelectionForBufferRange([[3, 4], [5, 6]]) + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 0]], [[3, 4], [5, 6]]]) + }) + }) + + describe('.addSelectionBelow()', () => { + describe('when the selection is non-empty', () => { + it('selects the same region of the line below current selections if possible', () => { + editor.setSelectedBufferRange([[3, 16], [3, 21]]) + editor.addSelectionForBufferRange([[3, 25], [3, 34]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 16], [3, 21]], + [[3, 25], [3, 34]], + [[4, 16], [4, 21]], + [[4, 25], [4, 29]] + ]) + }) + + it('skips lines that are too short to create a non-empty selection', () => { + editor.setSelectedBufferRange([[3, 31], [3, 38]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 31], [3, 38]], + [[6, 31], [6, 38]] + ]) + }) + + it("honors the original selection's range (goal range) when adding across shorter lines", () => { + editor.setSelectedBufferRange([[3, 22], [3, 38]]) + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 38]], + [[4, 22], [4, 29]], + [[5, 22], [5, 30]], + [[6, 22], [6, 38]] + ]) + }) + + it('clears selection goal ranges when the selection changes', () => { + editor.setSelectedBufferRange([[3, 22], [3, 38]]) + editor.addSelectionBelow() + editor.selectLeft() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 37]], + [[4, 22], [4, 29]], + [[5, 22], [5, 28]] + ]) + + // goal range from previous add selection is honored next time + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 22], [3, 37]], + [[4, 22], [4, 29]], + [[5, 22], [5, 30]], // select to end of line 5 because line 4's goal range was reset by line 3 previously + [[6, 22], [6, 28]] + ]) + }) + + it('can add selections to soft-wrapped line segments', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(40) + editor.setDefaultCharWidth(1) + + editor.setSelectedScreenRange([[3, 10], [3, 15]]) + editor.addSelectionBelow() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 10], [3, 15]], + [[4, 10], [4, 15]] + ]) + }) + + it('takes atomic tokens into account', async () => { + editor = await atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', {autoIndent: false}) + editor.setSelectedBufferRange([[2, 1], [2, 3]]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 1], [2, 3]], + [[3, 1], [3, 2]] + ]) + }) + }) + + describe('when the selection is empty', () => { + describe('when lines are soft-wrapped', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + }) + + it('skips soft-wrap indentation tokens', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addSelectionBelow() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 0], [3, 0]], + [[4, 4], [4, 4]] + ]) + }) + + it("does not skip them if they're shorter than the current column", () => { + editor.setCursorScreenPosition([3, 37]) + editor.addSelectionBelow() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[3, 37], [3, 37]], + [[4, 26], [4, 26]] + ]) + }) + }) + + it('does not skip lines that are shorter than the current column', () => { + editor.setCursorBufferPosition([3, 36]) + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 36], [3, 36]], + [[4, 29], [4, 29]], + [[5, 30], [5, 30]], + [[6, 36], [6, 36]] + ]) + }) + + it('skips empty lines when the column is non-zero', () => { + editor.setCursorBufferPosition([9, 4]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[9, 4], [9, 4]], + [[11, 4], [11, 4]] + ]) + }) + + it('does not skip empty lines when the column is zero', () => { + editor.setCursorBufferPosition([9, 0]) + editor.addSelectionBelow() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[9, 0], [9, 0]], + [[10, 0], [10, 0]] + ]) + }) + }) + }) + + describe('.addSelectionAbove()', () => { + describe('when the selection is non-empty', () => { + it('selects the same region of the line above current selections if possible', () => { + editor.setSelectedBufferRange([[3, 16], [3, 21]]) + editor.addSelectionForBufferRange([[3, 37], [3, 44]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 16], [3, 21]], + [[3, 37], [3, 44]], + [[2, 16], [2, 21]], + [[2, 37], [2, 40]] + ]) + }) + + it('skips lines that are too short to create a non-empty selection', () => { + editor.setSelectedBufferRange([[6, 31], [6, 38]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 31], [6, 38]], + [[3, 31], [3, 38]] + ]) + }) + + it("honors the original selection's range (goal range) when adding across shorter lines", () => { + editor.setSelectedBufferRange([[6, 22], [6, 38]]) + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 22], [6, 38]], + [[5, 22], [5, 30]], + [[4, 22], [4, 29]], + [[3, 22], [3, 38]] + ]) + }) + + it('can add selections to soft-wrapped line segments', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + + editor.setSelectedScreenRange([[4, 10], [4, 15]]) + editor.addSelectionAbove() + expect(editor.getSelectedScreenRanges()).toEqual([ + [[4, 10], [4, 15]], + [[3, 10], [3, 15]] + ]) + }) + + it('takes atomic tokens into account', async () => { + editor = await atom.workspace.open('sample-with-tabs-and-leading-comment.coffee', {autoIndent: false}) + editor.setSelectedBufferRange([[3, 1], [3, 2]]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[3, 1], [3, 2]], + [[2, 1], [2, 3]] + ]) + }) + }) + + describe('when the selection is empty', () => { + describe('when lines are soft-wrapped', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(40) + }) + + it('skips soft-wrap indentation tokens', () => { + editor.setCursorScreenPosition([5, 0]) + editor.addSelectionAbove() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[5, 0], [5, 0]], + [[4, 4], [4, 4]] + ]) + }) + + it("does not skip them if they're shorter than the current column", () => { + editor.setCursorScreenPosition([5, 29]) + editor.addSelectionAbove() + + expect(editor.getSelectedScreenRanges()).toEqual([ + [[5, 29], [5, 29]], + [[4, 26], [4, 26]] + ]) + }) + }) + + it('does not skip lines that are shorter than the current column', () => { + editor.setCursorBufferPosition([6, 36]) + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[6, 36], [6, 36]], + [[5, 30], [5, 30]], + [[4, 29], [4, 29]], + [[3, 36], [3, 36]] + ]) + }) + + it('skips empty lines when the column is non-zero', () => { + editor.setCursorBufferPosition([11, 4]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[11, 4], [11, 4]], + [[9, 4], [9, 4]] + ]) + }) + + it('does not skip empty lines when the column is zero', () => { + editor.setCursorBufferPosition([10, 0]) + editor.addSelectionAbove() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[10, 0], [10, 0]], + [[9, 0], [9, 0]] + ]) + }) + }) + }) + + describe('.splitSelectionsIntoLines()', () => { + it('splits all multi-line selections into one selection per line', () => { + editor.setSelectedBufferRange([[0, 3], [2, 4]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 29]], + [[1, 0], [1, 30]], + [[2, 0], [2, 4]] + ]) + + editor.setSelectedBufferRange([[0, 3], [1, 10]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 29]], + [[1, 0], [1, 10]] + ]) + + editor.setSelectedBufferRange([[0, 0], [0, 3]]) + editor.splitSelectionsIntoLines() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]]]) + }) + }) + + describe('::consolidateSelections()', () => { + const makeMultipleSelections = () => { + selection.setBufferRange([[3, 16], [3, 21]]) + const selection2 = editor.addSelectionForBufferRange([[3, 25], [3, 34]]) + const selection3 = editor.addSelectionForBufferRange([[8, 4], [8, 10]]) + const selection4 = editor.addSelectionForBufferRange([[1, 6], [1, 10]]) + expect(editor.getSelections()).toEqual([selection, selection2, selection3, selection4]) + return [selection, selection2, selection3, selection4] + } + + it('destroys all selections but the oldest selection and autoscrolls to it, returning true if any selections were destroyed', () => { + const [selection1] = makeMultipleSelections() + + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) + + expect(editor.consolidateSelections()).toBeTruthy() + expect(editor.getSelections()).toEqual([selection1]) + expect(selection1.isEmpty()).toBeFalsy() + expect(editor.consolidateSelections()).toBeFalsy() + expect(editor.getSelections()).toEqual([selection1]) + + expect(autoscrollEvents).toEqual([ + {screenRange: selection1.getScreenRange(), options: {center: true, reversed: false}} + ]) + }) + }) + + describe('when the cursor is moved while there is a selection', () => { + const makeSelection = () => selection.setBufferRange([[1, 2], [1, 5]]) + + it('clears the selection', () => { + makeSelection() + editor.moveDown() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveUp() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveLeft() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.moveRight() + expect(selection.isEmpty()).toBeTruthy() + + makeSelection() + editor.setCursorScreenPosition([3, 3]) + expect(selection.isEmpty()).toBeTruthy() + }) + }) + + it('does not share selections between different edit sessions for the same buffer', async () => { + atom.workspace.getActivePane().splitRight() + const editor2 = await atom.workspace.open(editor.getPath()) + + expect(editor2.getText()).toBe(editor.getText()) + editor.setSelectedBufferRanges([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) + editor2.setSelectedBufferRanges([[[8, 7], [6, 5]], [[4, 3], [2, 1]]]) + expect(editor2.getSelectedBufferRanges()).not.toEqual(editor.getSelectedBufferRanges()) + }) + }) + + describe('buffer manipulation', () => { + describe('.moveLineUp', () => { + it('moves the line under the cursor up', () => { + editor.setCursorBufferPosition([1, 0]) + editor.moveLineUp() + expect(editor.getTextInBufferRange([[0, 0], [0, 30]])).toBe(' var sort = function(items) {') + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + it("updates the line's indentation when the the autoIndent setting is true", () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([1, 0]) + editor.moveLineUp() + expect(editor.indentationForBufferRow(0)).toBe(0) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + describe('when there is a single selection', () => { + describe('when the selection spans a single line', () => { + describe('when there is no fold in the preceeding row', () => + it('moves the line to the preceding row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.setSelectedBufferRange([[3, 2], [3, 9]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [2, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is at the beginning of a fold', () => + it('moves the line to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [4, 9]], {preserveFolds: true}) + expect(editor.getSelectedBufferRange()).toEqual([[4, 2], [4, 9]]) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + }) + ) + + describe('when the preceding row consists of folded code', () => + it('moves the line above the folded row and perseveres the correct folds', () => { + expect(editor.lineTextForBufferRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[8, 0], [8, 4]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [4, 4]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when the selection spans multiple lines', () => { + it('moves the lines spanned by the selection to the preceding row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[3, 2], [4, 9]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' if (items.length <= 1) return items;') + }) + + describe("when the selection's end intersects a fold", () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[3, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' if (items.length <= 1) return items;') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + }) + ) + + describe("when the selection's start intersects a fold", () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [8, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [7, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(8)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + }) + ) + }) + + describe('when the selection spans multiple lines, but ends at column 0', () => { + it('does not move the last line of the selection', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[3, 2], [4, 0]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[2, 2], [3, 0]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + }) + }) + + describe('when the preceeding row is a folded row', () => { + it('moves the lines spanned by the selection to the preceeding row, but preserves the folded code', () => { + expect(editor.lineTextForBufferRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + editor.foldBufferRowRange(4, 7) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[8, 0], [9, 2]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 0], [5, 2]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' };') + expect(editor.lineTextForBufferRow(6)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(5)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + }) + }) + }) + + describe('when there are multiple selections', () => { + describe('when all the selections span different lines', () => { + describe('when there is no folds', () => + it('moves all lines that are spanned by a selection to the preceding row', () => { + editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [0, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + expect(editor.lineTextForBufferRow(0)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(1)).toBe('var quicksort = function () {') + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + }) + ) + + describe('when one selection intersects a fold', () => + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRanges([ + [[2, 2], [2, 9]], + [[4, 2], [4, 9]] + ], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 2], [1, 9]], + [[3, 2], [3, 9]] + ]) + + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + }) + ) + + describe('when there is a fold', () => + it('moves all lines that spanned by a selection to preceding row, preserving all folds', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[8, 0], [8, 3]], [[11, 0], [11, 5]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[4, 0], [4, 3]], [[10, 0], [10, 5]]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when there are many folds', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample-with-many-folds.js', {autoIndent: false}) + }) + + describe('and many selections intersects folded rows', () => + it('moves and preserves all the folds', () => { + editor.foldBufferRowRange(2, 4) + editor.foldBufferRowRange(7, 9) + + editor.setSelectedBufferRanges([ + [[1, 0], [5, 4]], + [[7, 0], [7, 4]] + ], {preserveFolds: true}) + + editor.moveLineUp() + + expect(editor.lineTextForBufferRow(1)).toEqual('function f3() {') + expect(editor.lineTextForBufferRow(4)).toEqual('6;') + expect(editor.lineTextForBufferRow(5)).toEqual('1;') + expect(editor.lineTextForBufferRow(6)).toEqual('function f8() {') + expect(editor.lineTextForBufferRow(9)).toEqual('7;') + + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + }) + + describe('when some of the selections span the same lines', () => { + it('moves lines that contain multiple selections correctly', () => { + editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 2], [2, 9]], [[2, 12], [2, 13]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + + describe('when one of the selections spans line 0', () => { + it("doesn't move any lines, since line 0 can't move", () => { + editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[4, 2], [4, 9]]]) + expect(buffer.isModified()).toBe(false) + }) + }) + + describe('when one of the selections spans the last line, and it is empty', () => { + it("doesn't move any lines, since the last line can't move", () => { + buffer.append('\n') + editor.setSelectedBufferRanges([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) + + editor.moveLineUp() + + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 2], [1, 9]], [[2, 2], [2, 9]], [[13, 0], [13, 0]]]) + }) + }) + }) + }) + + describe('.moveLineDown', () => { + it('moves the line under the cursor down', () => { + editor.setCursorBufferPosition([0, 0]) + editor.moveLineDown() + expect(editor.getTextInBufferRange([[1, 0], [1, 31]])).toBe('var quicksort = function () {') + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(0) + }) + + it("updates the line's indentation when the editor.autoIndent setting is true", () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([0, 0]) + editor.moveLineDown() + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(2) + }) + + describe('when there is a single selection', () => { + describe('when the selection spans a single line', () => { + describe('when there is no fold in the following row', () => + it('moves the line to the following row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.setSelectedBufferRange([[2, 2], [2, 9]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [3, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is at the beginning of a fold', () => + it('moves the line to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[5, 2], [5, 9]]) + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + ) + + describe('when the following row is a folded row', () => + it('moves the line below the folded row and preserves the fold', () => { + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[3, 0], [3, 4]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[7, 0], [7, 4]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + ) + }) + + describe('when the selection spans multiple lines', () => { + it('moves the lines spanned by the selection to the following row', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[2, 2], [3, 9]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [4, 9]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + + describe('when the selection spans multiple lines, but ends at column 0', () => { + it('does not move the last line of the selection', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.setSelectedBufferRange([[2, 2], [3, 0]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[3, 2], [4, 0]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + }) + }) + + describe("when the selection's end intersects a fold", () => { + it('moves the lines to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[3, 2], [4, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[4, 2], [5, 9]]) + expect(editor.lineTextForBufferRow(3)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + }) + + describe("when the selection's start intersects a fold", () => { + it('moves the lines to the following row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRange([[4, 2], [8, 9]], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[5, 2], [9, 9]]) + expect(editor.lineTextForBufferRow(4)).toBe(' };') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(9)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + }) + }) + + describe('when the following row is a folded row', () => { + it('moves the lines spanned by the selection to the following row, but preserves the folded code', () => { + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + editor.foldBufferRowRange(4, 7) + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRange([[2, 0], [3, 2]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRange()).toEqual([[6, 0], [7, 2]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + }) + }) + + describe('when the last line of selection does not end with a valid line ending', () => { + it('appends line ending to last line and moves the lines spanned by the selection to the preceeding row', () => { + expect(editor.lineTextForBufferRow(9)).toBe(' };') + expect(editor.lineTextForBufferRow(10)).toBe('') + expect(editor.lineTextForBufferRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.lineTextForBufferRow(12)).toBe('};') + + editor.setSelectedBufferRange([[10, 0], [12, 2]]) + editor.moveLineUp() + + expect(editor.getSelectedBufferRange()).toEqual([[9, 0], [11, 2]]) + expect(editor.lineTextForBufferRow(9)).toBe('') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.lineTextForBufferRow(11)).toBe('};') + expect(editor.lineTextForBufferRow(12)).toBe(' };') + }) + }) + }) + + describe('when there are multiple selections', () => { + describe('when all the selections span different lines', () => { + describe('when there is no folds', () => + it('moves all lines that are spanned by a selection to the following row', () => { + editor.setSelectedBufferRanges([[[1, 2], [1, 9]], [[3, 2], [3, 9]], [[5, 2], [5, 9]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[6, 2], [6, 9]], [[4, 2], [4, 9]], [[2, 2], [2, 9]]]) + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(6)).toBe(' current = items.shift();') + }) + ) + + describe('when there are many folds', () => { + beforeEach(async () => { + editor = await atom.workspace.open('sample-with-many-folds.js', {autoIndent: false}) + }) + + describe('and many selections intersects folded rows', () => + it('moves and preserves all the folds', () => { + editor.foldBufferRowRange(2, 4) + editor.foldBufferRowRange(7, 9) + + editor.setSelectedBufferRanges([ + [[2, 0], [2, 4]], + [[6, 0], [10, 4]] + ], {preserveFolds: true}) + + editor.moveLineDown() + + expect(editor.lineTextForBufferRow(2)).toEqual('6;') + expect(editor.lineTextForBufferRow(3)).toEqual('function f3() {') + expect(editor.lineTextForBufferRow(6)).toEqual('12;') + expect(editor.lineTextForBufferRow(7)).toEqual('7;') + expect(editor.lineTextForBufferRow(8)).toEqual('function f8() {') + expect(editor.lineTextForBufferRow(11)).toEqual('11;') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(11)).toBeFalsy() + }) + ) + }) + + describe('when there is a fold below one of the selected row', () => + it('moves all lines spanned by a selection to the following row, preserving the fold', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[1, 2], [1, 6]], [[3, 0], [3, 4]], [[8, 0], [8, 3]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[9, 0], [9, 3]], [[7, 0], [7, 4]], [[2, 2], [2, 6]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeFalsy() + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(9)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + }) + ) + + describe('when there is a fold below a group of multiple selections without any lines with no selection in-between', () => + it('moves all the lines below the fold, preserving the fold', () => { + editor.foldBufferRowRange(4, 7) + + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + + editor.setSelectedBufferRanges([[[2, 2], [2, 6]], [[3, 0], [3, 4]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[7, 0], [7, 4]], [[6, 2], [6, 6]]]) + expect(editor.lineTextForBufferRow(2)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(3)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeFalsy() + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(7)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + ) + }) + + describe('when one selection intersects a fold', () => { + it('moves the lines to the previous row without breaking the fold', () => { + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + + editor.foldBufferRowRange(4, 7) + editor.setSelectedBufferRanges([ + [[2, 2], [2, 9]], + [[4, 2], [4, 9]] + ], {preserveFolds: true}) + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[5, 2], [5, 9]], + [[3, 2], [3, 9]] + ]) + + expect(editor.lineTextForBufferRow(2)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(4)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(9)).toBe(' };') + + expect(editor.isFoldedAtBufferRow(2)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(3)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(4)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(5)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(6)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(7)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(8)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeFalsy() + }) + }) + + describe('when some of the selections span the same lines', () => { + it('moves lines that contain multiple selections correctly', () => { + editor.setSelectedBufferRanges([[[3, 2], [3, 9]], [[3, 12], [3, 13]]]) + editor.moveLineDown() + + expect(editor.getSelectedBufferRanges()).toEqual([[[4, 12], [4, 13]], [[4, 2], [4, 9]]]) + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + }) + }) + + describe('when the selections are above a wrapped line', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(80) + editor.setText(dedent ` + 1 + 2 + Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. + 3 + 4 + `) + }) + + it('moves the lines past the soft wrapped line', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 0]], [[1, 0], [1, 0]]]) + + editor.moveLineDown() + + expect(editor.lineTextForBufferRow(0)).not.toBe('2') + expect(editor.lineTextForBufferRow(1)).toBe('1') + expect(editor.lineTextForBufferRow(2)).toBe('2') + }) + }) + }) + + describe('when the line is the last buffer row', () => { + it("doesn't move it", () => { + editor.setText('abc\ndef') + editor.setCursorBufferPosition([1, 0]) + editor.moveLineDown() + expect(editor.getText()).toBe('abc\ndef') + }) + }) + }) + + describe('.insertText(text)', () => { + describe('when there is a single selection', () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 0], [1, 2]])) + + it('replaces the selection with the given text', () => { + const range = editor.insertText('xxx') + expect(range).toEqual([ [[1, 0], [1, 3]] ]) + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + }) + }) + + describe('when there are multiple empty selections', () => { + describe('when the cursors are on the same line', () => { + it("inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", () => { + editor.setCursorScreenPosition([1, 2]) + editor.addCursorAtScreenPosition([1, 5]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe(' xxxvarxxx sort = function(items) {') + const [cursor1, cursor2] = editor.getCursors() + + expect(cursor1.getBufferPosition()).toEqual([1, 5]) + expect(cursor2.getBufferPosition()).toEqual([1, 11]) + }) + }) + + describe('when the cursors are on different lines', () => { + it("inserts the given text at the location of each cursor and moves the cursors to the end of each cursor's inserted text", () => { + editor.setCursorScreenPosition([1, 2]) + editor.addCursorAtScreenPosition([2, 4]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe(' xxxvar sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' xxxif (items.length <= 1) return items;') + const [cursor1, cursor2] = editor.getCursors() + + expect(cursor1.getBufferPosition()).toEqual([1, 5]) + expect(cursor2.getBufferPosition()).toEqual([2, 7]) + }) + }) + }) + + describe('when there are multiple non-empty selections', () => { + describe('when the selections are on the same line', () => { + it('replaces each selection range with the inserted characters', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 22], [0, 24]]]) + editor.insertText('x') + + const [cursor1, cursor2] = editor.getCursors() + const [selection1, selection2] = editor.getSelections() + + expect(cursor1.getScreenPosition()).toEqual([0, 5]) + expect(cursor2.getScreenPosition()).toEqual([0, 15]) + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + + expect(editor.lineTextForBufferRow(0)).toBe('var x = functix () {') + }) + }) + + describe('when the selections are on different lines', () => { + it("replaces each selection with the given text, clears the selections, and places the cursor at the end of each selection's inserted text", () => { + editor.setSelectedBufferRanges([[[1, 0], [1, 2]], [[2, 0], [2, 4]]]) + + editor.insertText('xxx') + + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + expect(buffer.lineForRow(2)).toBe('xxxif (items.length <= 1) return items;') + const [selection1, selection2] = editor.getSelections() + + expect(selection1.isEmpty()).toBeTruthy() + expect(selection1.cursor.getBufferPosition()).toEqual([1, 3]) + expect(selection2.isEmpty()).toBeTruthy() + expect(selection2.cursor.getBufferPosition()).toEqual([2, 3]) + }) + }) + }) + + describe('when there is a selection that ends on a folded line', () => { + it('destroys the selection', () => { + editor.foldBufferRowRange(2, 4) + editor.setSelectedBufferRange([[1, 0], [2, 0]]) + editor.insertText('holy cow') + expect(editor.isFoldedAtScreenRow(2)).toBeFalsy() + }) + }) + + describe('when there are ::onWillInsertText and ::onDidInsertText observers', () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 0], [1, 2]])) + + it('notifies the observers when inserting text', () => { + const willInsertSpy = jasmine.createSpy().andCallFake(() => expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {')) + + const didInsertSpy = jasmine.createSpy().andCallFake(() => expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {')) + + editor.onWillInsertText(willInsertSpy) + editor.onDidInsertText(didInsertSpy) + + expect(editor.insertText('xxx')).toBeTruthy() + expect(buffer.lineForRow(1)).toBe('xxxvar sort = function(items) {') + + expect(willInsertSpy).toHaveBeenCalled() + expect(didInsertSpy).toHaveBeenCalled() + + let options = willInsertSpy.mostRecentCall.args[0] + expect(options.text).toBe('xxx') + expect(options.cancel).toBeDefined() + + options = didInsertSpy.mostRecentCall.args[0] + expect(options.text).toBe('xxx') + }) + + it('cancels text insertion when an ::onWillInsertText observer calls cancel on an event', () => { + const willInsertSpy = jasmine.createSpy().andCallFake(({cancel}) => cancel()) + + const didInsertSpy = jasmine.createSpy() + + editor.onWillInsertText(willInsertSpy) + editor.onDidInsertText(didInsertSpy) + + expect(editor.insertText('xxx')).toBe(false) + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + expect(willInsertSpy).toHaveBeenCalled() + expect(didInsertSpy).not.toHaveBeenCalled() + }) + }) + + describe("when the undo option is set to 'skip'", () => { + beforeEach(() => editor.setSelectedBufferRange([[1, 2], [1, 2]])) + + it('does not undo the skipped operation', () => { + let range = editor.insertText('x') + range = editor.insertText('y', {undo: 'skip'}) + editor.undo() + expect(buffer.lineForRow(1)).toBe(' yvar sort = function(items) {') + }) + }) + }) + + describe('.insertNewline()', () => { + describe('when there is a single cursor', () => { + describe('when the cursor is at the beginning of a line', () => { + it('inserts an empty line before it', () => { + editor.setCursorScreenPosition({row: 1, column: 0}) + + editor.insertNewline() + + expect(buffer.lineForRow(1)).toBe('') + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + + describe('when the cursor is in the middle of a line', () => { + it('splits the current line to form a new line', () => { + editor.setCursorScreenPosition({row: 1, column: 6}) + const originalLine = buffer.lineForRow(1) + const lineBelowOriginalLine = buffer.lineForRow(2) + + editor.insertNewline() + + expect(buffer.lineForRow(1)).toBe(originalLine.slice(0, 6)) + expect(buffer.lineForRow(2)).toBe(originalLine.slice(6)) + expect(buffer.lineForRow(3)).toBe(lineBelowOriginalLine) + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + + describe('when the cursor is on the end of a line', () => { + it('inserts an empty line after it', () => { + editor.setCursorScreenPosition({row: 1, column: buffer.lineForRow(1).length}) + + editor.insertNewline() + + expect(buffer.lineForRow(2)).toBe('') + expect(editor.getCursorScreenPosition()).toEqual({row: 2, column: 0}) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when the cursors are on the same line', () => { + it('breaks the line at the cursor locations', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.insertNewline() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot') + expect(editor.lineTextForBufferRow(4)).toBe(' = items.shift(), current') + expect(editor.lineTextForBufferRow(5)).toBe(', left = [], right = [];') + expect(editor.lineTextForBufferRow(6)).toBe(' while(items.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([4, 0]) + expect(cursor2.getBufferPosition()).toEqual([5, 0]) + }) + }) + + describe('when the cursors are on different lines', () => { + it('inserts newlines at each cursor location', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addCursorAtScreenPosition([6, 0]) + + editor.insertText('\n') + expect(editor.lineTextForBufferRow(3)).toBe('') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(5)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(6)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(7)).toBe('') + expect(editor.lineTextForBufferRow(8)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(9)).toBe(' }') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([4, 0]) + expect(cursor2.getBufferPosition()).toEqual([8, 0]) + }) + }) + }) + }) + + describe('.insertNewlineBelow()', () => { + describe('when the operation is undone', () => { + it('places the cursor back at the previous location', () => { + editor.setCursorBufferPosition([0, 2]) + editor.insertNewlineBelow() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.undo() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + }) + }) + + it("inserts a newline below the cursor's current line, autoindents it, and moves the cursor to the end of the line", () => { + editor.update({autoIndent: true}) + editor.insertNewlineBelow() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' ') + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + }) + }) + + describe('.insertNewlineAbove()', () => { + describe('when the cursor is on first line', () => { + it('inserts a newline on the first line and moves the cursor to the first line', () => { + editor.setCursorBufferPosition([0]) + editor.insertNewlineAbove() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + expect(editor.lineTextForBufferRow(0)).toBe('') + expect(editor.lineTextForBufferRow(1)).toBe('var quicksort = function () {') + expect(editor.buffer.getLineCount()).toBe(14) + }) + }) + + describe('when the cursor is not on the first line', () => { + it('inserts a newline above the current line and moves the cursor to the inserted line', () => { + editor.setCursorBufferPosition([3, 4]) + editor.insertNewlineAbove() + expect(editor.getCursorBufferPosition()).toEqual([3, 0]) + expect(editor.lineTextForBufferRow(3)).toBe('') + expect(editor.lineTextForBufferRow(4)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.buffer.getLineCount()).toBe(14) + + editor.undo() + expect(editor.getCursorBufferPosition()).toEqual([3, 4]) + }) + }) + + it('indents the new line to the correct level when editor.autoIndent is true', () => { + editor.update({autoIndent: true}) + + editor.setText(' var test') + editor.setCursorBufferPosition([0, 2]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + expect(editor.lineTextForBufferRow(0)).toBe(' ') + expect(editor.lineTextForBufferRow(1)).toBe(' var test') + + editor.setText('\n var test') + editor.setCursorBufferPosition([1, 2]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + expect(editor.lineTextForBufferRow(0)).toBe('') + expect(editor.lineTextForBufferRow(1)).toBe(' ') + expect(editor.lineTextForBufferRow(2)).toBe(' var test') + + editor.setText('function() {\n}') + editor.setCursorBufferPosition([1, 1]) + editor.insertNewlineAbove() + + expect(editor.getCursorBufferPosition()).toEqual([1, 2]) + expect(editor.lineTextForBufferRow(0)).toBe('function() {') + expect(editor.lineTextForBufferRow(1)).toBe(' ') + expect(editor.lineTextForBufferRow(2)).toBe('}') + }) + }) + + describe('.insertNewLine()', () => { + describe('when a new line is appended before a closing tag (e.g. by pressing enter before a selection)', () => { + it('moves the line down and keeps the indentation level the same when editor.autoIndent is true', () => { + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([9, 2]) + editor.insertNewline() + expect(editor.lineTextForBufferRow(10)).toBe(' };') + }) + }) + + describe('when a newline is appended with a trailing closing tag behind the cursor (e.g. by pressing enter in the middel of a line)', () => { + it('indents the new line to the correct level when editor.autoIndent is true and using a curly-bracket language', () => { + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.js')) + editor.setText('var test = () => {\n return true;};') + editor.setCursorBufferPosition([1, 14]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + }) + + it('indents the new line to the current level when editor.autoIndent is true and no increaseIndentPattern is specified', () => { + editor.setGrammar(atom.grammars.selectGrammar('file')) + editor.update({autoIndent: true}) + editor.setText(' if true') + editor.setCursorBufferPosition([0, 8]) + editor.insertNewline() + expect(editor.getGrammar()).toBe(atom.grammars.nullGrammar) + expect(editor.indentationForBufferRow(0)).toBe(1) + expect(editor.indentationForBufferRow(1)).toBe(1) + }) + + it('indents the new line to the correct level when editor.autoIndent is true and using an off-side rule language', async () => { + await atom.packages.activatePackage('language-coffee-script') + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.coffee')) + editor.setText('if true\n return trueelse\n return false') + editor.setCursorBufferPosition([1, 13]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + expect(editor.indentationForBufferRow(3)).toBe(1) + }) + }) + + describe('when a newline is appended on a line that matches the decreaseNextIndentPattern', () => { + it('indents the new line to the correct level when editor.autoIndent is true', async () => { + await atom.packages.activatePackage('language-go') + editor.update({autoIndent: true}) + editor.setGrammar(atom.grammars.selectGrammar('file.go')) + editor.setText('fmt.Printf("some%s",\n "thing")') + editor.setCursorBufferPosition([1, 10]) + editor.insertNewline() + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(0) + }) + }) + }) + + describe('.backspace()', () => { + describe('when there is a single cursor', () => { + let changeScreenRangeHandler = null + + beforeEach(() => { + const selection = editor.getLastSelection() + changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler') + selection.onDidChangeRange(changeScreenRangeHandler) + }) + + describe('when the cursor is on the middle of the line', () => { + it('removes the character before the cursor', () => { + editor.setCursorScreenPosition({row: 1, column: 7}) + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + editor.backspace() + + const line = buffer.lineForRow(1) + expect(line).toBe(' var ort = function(items) {') + expect(editor.getCursorScreenPosition()).toEqual({row: 1, column: 6}) + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the cursor is at the beginning of a line', () => { + it('joins it with the line above', () => { + const originalLine0 = buffer.lineForRow(0) + expect(originalLine0).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + + editor.setCursorScreenPosition({row: 1, column: 0}) + editor.backspace() + + const line0 = buffer.lineForRow(0) + const line1 = buffer.lineForRow(1) + expect(line0).toBe('var quicksort = function () { var sort = function(items) {') + expect(line1).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorScreenPosition()).toEqual([0, originalLine0.length]) + + expect(changeScreenRangeHandler).toHaveBeenCalled() + }) + }) + + describe('when the cursor is at the first column of the first line', () => { + it("does nothing, but doesn't raise an error", () => { + editor.setCursorScreenPosition({row: 0, column: 0}) + editor.backspace() + }) + }) + + describe('when the cursor is after a fold', () => { + it('deletes the folded range', () => { + editor.foldBufferRange([[4, 7], [5, 8]]) + editor.setCursorBufferPosition([5, 8]) + editor.backspace() + + expect(buffer.lineForRow(4)).toBe(' whirrent = items.shift();') + expect(editor.isFoldedAtBufferRow(4)).toBe(false) + }) + }) + + describe('when the cursor is in the middle of a line below a fold', () => { + it('backspaces as normal', () => { + editor.setCursorScreenPosition([4, 0]) + editor.foldCurrentRow() + editor.setCursorScreenPosition([5, 5]) + editor.backspace() + + expect(buffer.lineForRow(7)).toBe(' }') + expect(buffer.lineForRow(8)).toBe(' eturn sort(left).concat(pivot).concat(sort(right));') + }) + }) + + describe('when the cursor is on a folded screen line', () => { + it('deletes the contents of the fold before the cursor', () => { + editor.setCursorBufferPosition([3, 0]) + editor.foldCurrentRow() + editor.backspace() + + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getCursorScreenPosition()).toEqual([1, 29]) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when cursors are on the same line', () => { + it('removes the characters preceding each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.backspace() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivo = items.shift(), curren, left = [], right = [];') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 12]) + expect(cursor2.getBufferPosition()).toEqual([3, 36]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + }) + + describe('when cursors are on different lines', () => { + describe('when the cursors are in the middle of their lines', () => + it('removes the characters preceding each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([4, 10]) + + editor.backspace() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivo = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' whileitems.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 12]) + expect(cursor2.getBufferPosition()).toEqual([4, 9]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + ) + + describe('when the cursors are on the first column of their lines', () => + it('removes the newlines preceding each cursor', () => { + editor.setCursorScreenPosition([3, 0]) + editor.addCursorAtScreenPosition([6, 0]) + + editor.backspace() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items; var pivot = items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(3)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(4)).toBe(' current = items.shift(); current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(5)).toBe(' }') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([2, 40]) + expect(cursor2.getBufferPosition()).toEqual([4, 30]) + }) + ) + }) + }) + + describe('when there is a single selection', () => { + it('deletes the selection, but not the character before it', () => { + editor.setSelectedBufferRange([[0, 5], [0, 9]]) + editor.backspace() + expect(editor.buffer.lineForRow(0)).toBe('var qsort = function () {') + }) + + describe('when the selection ends on a folded line', () => { + it('preserves the fold', () => { + editor.setSelectedBufferRange([[3, 0], [4, 0]]) + editor.foldBufferRow(4) + editor.backspace() + + expect(buffer.lineForRow(3)).toBe(' while(items.length > 0) {') + expect(editor.isFoldedAtScreenRow(3)).toBe(true) + }) + }) + }) + + describe('when there are multiple selections', () => { + it('removes all selected text', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + editor.backspace() + expect(editor.lineTextForBufferRow(0)).toBe('var = () {') + }) + }) + }) + + describe('.deleteToPreviousWordBoundary()', () => { + describe('when no text is selected', () => { + it('deletes to the previous word boundary', () => { + editor.setCursorBufferPosition([0, 16]) + editor.addCursorAtBufferPosition([1, 21]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = (items) {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 13]) + + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort function () {') + expect(buffer.lineForRow(1)).toBe(' var sort =(items) {') + expect(cursor1.getBufferPosition()).toEqual([0, 14]) + expect(cursor2.getBufferPosition()).toEqual([1, 12]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToPreviousWordBoundary() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.deleteToNextWordBoundary()', () => { + describe('when no text is selected', () => { + it('deletes to the next word boundary', () => { + editor.setCursorBufferPosition([0, 15]) + editor.addCursorAtBufferPosition([1, 24]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort = () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it {') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(0)).toBe('var quicksort =() {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(it{') + expect(cursor1.getBufferPosition()).toEqual([0, 15]) + expect(cursor2.getBufferPosition()).toEqual([1, 24]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToNextWordBoundary() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.deleteToBeginningOfWord()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the beginning of the word', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([3, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(ems) {') + expect(buffer.lineForRow(3)).toBe(' ar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 22]) + expect(cursor2.getBufferPosition()).toEqual([3, 4]) + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = functionems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return itemsar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 21]) + expect(cursor2.getBufferPosition()).toEqual([2, 39]) + + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = ems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return ar pivot = items.shift(), current, left = [], right = [];') + expect(cursor1.getBufferPosition()).toEqual([1, 13]) + expect(cursor2.getBufferPosition()).toEqual([2, 34]) + + editor.setText(' var sort') + editor.setCursorBufferPosition([0, 2]) + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(0)).toBe('var sort') + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToBeginningOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + }) + }) + }) + + describe('.deleteToEndOfLine()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the end of the line', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it') + expect(buffer.lineForRow(2)).toBe(' i') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + }) + + describe('when at the end of the line', () => { + it('deletes the next newline', () => { + editor.setCursorBufferPosition([1, 30]) + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + }) + + describe('when text is selected', () => { + it('deletes only the text in the selection', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToEndOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + }) + }) + }) + + describe('.deleteToBeginningOfLine()', () => { + describe('when no text is selected', () => { + it('deletes all text between the cursor and the beginning of the line', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe('ems) {') + expect(buffer.lineForRow(2)).toBe('f (items.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 0]) + expect(cursor2.getBufferPosition()).toEqual([2, 0]) + }) + + describe('when at the beginning of the line', () => { + it('deletes the newline', () => { + editor.setCursorBufferPosition([2]) + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + }) + + describe('when text is selected', () => { + it('still deletes all text to beginning of the line', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.deleteToBeginningOfLine() + expect(buffer.lineForRow(1)).toBe('ems) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + }) + }) + }) + + describe('.delete()', () => { + describe('when there is a single cursor', () => { + describe('when the cursor is on the middle of a line', () => { + it('deletes the character following the cursor', () => { + editor.setCursorScreenPosition([1, 6]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var ort = function(items) {') + }) + }) + + describe('when the cursor is on the end of a line', () => { + it('joins the line with the following line', () => { + editor.setCursorScreenPosition([1, buffer.lineForRow(1).length]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) { if (items.length <= 1) return items;') + }) + }) + + describe('when the cursor is on the last column of the last line', () => { + it("does nothing, but doesn't raise an error", () => { + editor.setCursorScreenPosition([12, buffer.lineForRow(12).length]) + editor.delete() + expect(buffer.lineForRow(12)).toBe('};') + }) + }) + + describe('when the cursor is before a fold', () => { + it('only deletes the lines inside the fold', () => { + editor.foldBufferRange([[3, 6], [4, 8]]) + editor.setCursorScreenPosition([3, 6]) + const cursorPositionBefore = editor.getCursorScreenPosition() + + editor.delete() + + expect(buffer.lineForRow(3)).toBe(' vae(items.length > 0) {') + expect(buffer.lineForRow(4)).toBe(' current = items.shift();') + expect(editor.getCursorScreenPosition()).toEqual(cursorPositionBefore) + }) + }) + + describe('when the cursor is in the middle a line above a fold', () => { + it('deletes as normal', () => { + editor.foldBufferRow(4) + editor.setCursorScreenPosition([3, 4]) + const cursorPositionBefore = editor.getCursorScreenPosition() + + editor.delete() + + expect(buffer.lineForRow(3)).toBe(' ar pivot = items.shift(), current, left = [], right = [];') + expect(editor.isFoldedAtScreenRow(4)).toBe(true) + expect(editor.getCursorScreenPosition()).toEqual([3, 4]) + }) + }) + + describe('when the cursor is inside a fold', () => { + it('removes the folded content after the cursor', () => { + editor.foldBufferRange([[2, 6], [6, 21]]) + editor.setCursorBufferPosition([4, 9]) + + editor.delete() + + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(buffer.lineForRow(4)).toBe(' while ? left.push(current) : right.push(current);') + expect(buffer.lineForRow(5)).toBe(' }') + expect(editor.getCursorBufferPosition()).toEqual([4, 9]) + }) + }) + }) + + describe('when there are multiple cursors', () => { + describe('when cursors are on the same line', () => { + it('removes the characters following each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([3, 38]) + + editor.delete() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot= items.shift(), current left = [], right = [];') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 13]) + expect(cursor2.getBufferPosition()).toEqual([3, 37]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + }) + + describe('when cursors are on different lines', () => { + describe('when the cursors are in the middle of the lines', () => + it('removes the characters following each cursor', () => { + editor.setCursorScreenPosition([3, 13]) + editor.addCursorAtScreenPosition([4, 10]) + + editor.delete() + + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot= items.shift(), current, left = [], right = [];') + expect(editor.lineTextForBufferRow(4)).toBe(' while(tems.length > 0) {') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([3, 13]) + expect(cursor2.getBufferPosition()).toEqual([4, 10]) + + const [selection1, selection2] = editor.getSelections() + expect(selection1.isEmpty()).toBeTruthy() + expect(selection2.isEmpty()).toBeTruthy() + }) + ) + + describe('when the cursors are at the end of their lines', () => + it('removes the newlines following each cursor', () => { + editor.setCursorScreenPosition([0, 29]) + editor.addCursorAtScreenPosition([1, 30]) + + editor.delete() + + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) { if (items.length <= 1) return items;') + + const [cursor1, cursor2] = editor.getCursors() + expect(cursor1.getBufferPosition()).toEqual([0, 29]) + expect(cursor2.getBufferPosition()).toEqual([0, 59]) + }) + ) + }) + }) + + describe('when there is a single selection', () => { + it('deletes the selection, but not the character following it', () => { + editor.setSelectedBufferRanges([[[1, 24], [1, 27]], [[2, 0], [2, 4]]]) + editor.delete() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe('if (items.length <= 1) return items;') + expect(editor.getLastSelection().isEmpty()).toBeTruthy() + }) + }) + + describe('when there are multiple selections', () => + describe('when selections are on the same line', () => { + it('removes all selected text', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + editor.delete() + expect(editor.lineTextForBufferRow(0)).toBe('var = () {') + }) + }) + ) + }) + + describe('.deleteToEndOfWord()', () => { + describe('when no text is selected', () => { + it('deletes to the end of the word', () => { + editor.setCursorBufferPosition([1, 24]) + editor.addCursorAtBufferPosition([2, 5]) + const [cursor1, cursor2] = editor.getCursors() + + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + expect(buffer.lineForRow(2)).toBe(' i (items.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it {') + expect(buffer.lineForRow(2)).toBe(' iitems.length <= 1) return items;') + expect(cursor1.getBufferPosition()).toEqual([1, 24]) + expect(cursor2.getBufferPosition()).toEqual([2, 5]) + }) + }) + + describe('when text is selected', () => { + it('deletes only selected text', () => { + editor.setSelectedBufferRange([[1, 24], [1, 27]]) + editor.deleteToEndOfWord() + expect(buffer.lineForRow(1)).toBe(' var sort = function(it) {') + }) + }) + }) + + describe('.indent()', () => { + describe('when the selection is empty', () => { + describe('when autoIndent is disabled', () => { + describe("if 'softTabs' is true (the default)", () => { + it("inserts 'tabLength' spaces into the buffer", () => { + const tabRegex = new RegExp(`^[ ]{${editor.getTabLength()}}`) + expect(buffer.lineForRow(0)).not.toMatch(tabRegex) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(tabRegex) + }) + + it('respects the tab stops when cursor is in the middle of a tab', () => { + editor.setTabLength(4) + buffer.insert([12, 2], '\n ') + editor.setCursorBufferPosition([13, 1]) + editor.indent() + expect(buffer.lineForRow(13)).toMatch(/^\s+$/) + expect(buffer.lineForRow(13).length).toBe(4) + expect(editor.getCursorBufferPosition()).toEqual([13, 4]) + + buffer.insert([13, 0], ' ') + editor.setCursorBufferPosition([13, 6]) + editor.indent() + expect(buffer.lineForRow(13).length).toBe(8) + }) + }) + + describe("if 'softTabs' is false", () => + it('insert a \t into the buffer', () => { + editor.setSoftTabs(false) + expect(buffer.lineForRow(0)).not.toMatch(/^\t/) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t/) + }) + ) + }) + + describe('when autoIndent is enabled', () => { + describe("when the cursor's column is less than the suggested level of indentation", () => { + describe("when 'softTabs' is true (the default)", () => { + it('moves the cursor to the end of the leading whitespace and inserts enough whitespace to bring the line to the suggested level of indentation', () => { + buffer.insert([5, 0], ' \n') + editor.setCursorBufferPosition([5, 0]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(5)).toMatch(/^\s+$/) + expect(buffer.lineForRow(5).length).toBe(6) + expect(editor.getCursorBufferPosition()).toEqual([5, 6]) + }) + + it('respects the tab stops when cursor is in the middle of a tab', () => { + editor.setTabLength(4) + buffer.insert([12, 2], '\n ') + editor.setCursorBufferPosition([13, 1]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(13)).toMatch(/^\s+$/) + expect(buffer.lineForRow(13).length).toBe(4) + expect(editor.getCursorBufferPosition()).toEqual([13, 4]) + + buffer.insert([13, 0], ' ') + editor.setCursorBufferPosition([13, 6]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(13).length).toBe(8) + }) + }) + + describe("when 'softTabs' is false", () => { + it('moves the cursor to the end of the leading whitespace and inserts enough tabs to bring the line to the suggested level of indentation', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + buffer.insert([5, 0], '\t\n') + editor.setCursorBufferPosition([5, 0]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(5)).toMatch(/^\t\t\t$/) + expect(editor.getCursorBufferPosition()).toEqual([5, 3]) + }) + + describe('when the difference between the suggested level of indentation and the current level of indentation is greater than 0 but less than 1', () => + it('inserts one tab', () => { + editor.setSoftTabs(false) + buffer.setText(' \ntest') + editor.setCursorBufferPosition([1, 0]) + + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(1)).toBe('\ttest') + expect(editor.getCursorBufferPosition()).toEqual([1, 1]) + }) + ) + }) + }) + + describe("when the line's indent level is greater than the suggested level of indentation", () => { + describe("when 'softTabs' is true (the default)", () => + it("moves the cursor to the end of the leading whitespace and inserts 'tabLength' spaces into the buffer", () => { + buffer.insert([7, 0], ' \n') + editor.setCursorBufferPosition([7, 2]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(7)).toMatch(/^\s+$/) + expect(buffer.lineForRow(7).length).toBe(8) + expect(editor.getCursorBufferPosition()).toEqual([7, 8]) + }) + ) + + describe("when 'softTabs' is false", () => + it('moves the cursor to the end of the leading whitespace and inserts \t into the buffer', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + buffer.insert([7, 0], '\t\t\t\n') + editor.setCursorBufferPosition([7, 1]) + editor.indent({autoIndent: true}) + expect(buffer.lineForRow(7)).toMatch(/^\t\t\t\t$/) + expect(editor.getCursorBufferPosition()).toEqual([7, 4]) + }) + ) + }) + }) + }) + + describe('when the selection is not empty', () => { + it('indents the selected lines', () => { + editor.setSelectedBufferRange([[0, 0], [10, 0]]) + const selection = editor.getLastSelection() + spyOn(selection, 'indentSelectedRows') + editor.indent() + expect(selection.indentSelectedRows).toHaveBeenCalled() + }) + }) + + describe('if editor.softTabs is false', () => { + it('inserts a tab character into the buffer', () => { + editor.setSoftTabs(false) + expect(buffer.lineForRow(0)).not.toMatch(/^\t/) + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t/) + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.getTabLength()]) + + editor.indent() + expect(buffer.lineForRow(0)).toMatch(/^\t\t/) + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + expect(editor.getCursorScreenPosition()).toEqual([0, editor.getTabLength() * 2]) + }) + }) + }) + + describe('clipboard operations', () => { + describe('.cutSelectedText()', () => { + it('removes the selected text from the buffer and places it on the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + editor.cutSelectedText() + expect(buffer.lineForRow(0)).toBe('var = function () {') + expect(buffer.lineForRow(1)).toBe(' var = function(items) {') + expect(clipboard.readText()).toBe('quicksort\nsort') + }) + + describe('when no text is selected', () => { + beforeEach(() => + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[5, 0], [5, 0]] + ]) + ) + + it('cuts the lines on which there are cursors', () => { + editor.cutSelectedText() + expect(buffer.getLineCount()).toBe(11) + expect(buffer.lineForRow(1)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(4)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(atom.clipboard.read()).toEqual([ + 'var quicksort = function () {', + '', + ' current = items.shift();', + '' + ].join('\n')) + }) + }) + + describe('when many selections get added in shuffle order', () => { + it('cuts them in order', () => { + editor.setSelectedBufferRanges([ + [[2, 8], [2, 13]], + [[0, 4], [0, 13]], + [[1, 6], [1, 10]] + ]) + editor.cutSelectedText() + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + }) + + describe('.cutToEndOfLine()', () => { + describe('when soft wrap is on', () => { + it('cuts up to the end of the line', () => { + editor.setSoftWrapped(true) + editor.setDefaultCharWidth(1) + editor.setEditorWidthInChars(25) + editor.setCursorScreenPosition([2, 6]) + editor.cutToEndOfLine() + expect(editor.lineTextForScreenRow(2)).toBe(' var function(items) {') + }) + }) + + describe('when soft wrap is off', () => { + describe('when nothing is selected', () => + it('cuts up to the end of the line', () => { + editor.setCursorBufferPosition([2, 20]) + editor.addCursorAtBufferPosition([3, 20]) + editor.cutToEndOfLine() + expect(buffer.lineForRow(2)).toBe(' if (items.length') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) return items;\ns.shift(), current, left = [], right = [];') + }) + ) + + describe('when text is selected', () => + it('only cuts the selected text, not to the end of the line', () => { + editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) + editor.cutToEndOfLine() + expect(buffer.lineForRow(2)).toBe(' if (items.lengthurn items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) ret\ns.shift(), current, left = [], right = [];') + }) + ) + }) + }) + + describe('.cutToEndOfBufferLine()', () => { + beforeEach(() => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + }) + + describe('when nothing is selected', () => { + it('cuts up to the end of the buffer line', () => { + editor.setCursorBufferPosition([2, 20]) + editor.addCursorAtBufferPosition([3, 20]) + editor.cutToEndOfBufferLine() + expect(buffer.lineForRow(2)).toBe(' if (items.length') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) return items;\ns.shift(), current, left = [], right = [];') + }) + }) + + describe('when text is selected', () => { + it('only cuts the selected text, not to the end of the buffer line', () => { + editor.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) + editor.cutToEndOfBufferLine() + expect(buffer.lineForRow(2)).toBe(' if (items.lengthurn items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = item') + expect(atom.clipboard.read()).toBe(' <= 1) ret\ns.shift(), current, left = [], right = [];') + }) + }) + }) + + describe('.copySelectedText()', () => { + it('copies selected text onto the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) + editor.copySelectedText() + + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(clipboard.readText()).toBe('quicksort\nsort\nitems') + expect(atom.clipboard.read()).toEqual('quicksort\nsort\nitems') + }) + + describe('when no text is selected', () => { + beforeEach(() => { + editor.setSelectedBufferRanges([ + [[1, 5], [1, 5]], + [[5, 8], [5, 8]] + ]) + }) + + it('copies the lines on which there are cursors', () => { + editor.copySelectedText() + expect(atom.clipboard.read()).toEqual([ + ' var sort = function(items) {\n', + ' current = items.shift();\n' + ].join('\n')) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 5], [1, 5]], + [[5, 8], [5, 8]] + ]) + }) + }) + + describe('when many selections get added in shuffle order', () => { + it('copies them in order', () => { + editor.setSelectedBufferRanges([ + [[2, 8], [2, 13]], + [[0, 4], [0, 13]], + [[1, 6], [1, 10]] + ]) + editor.copySelectedText() + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + }) + + describe('.copyOnlySelectedText()', () => { + describe('when thee are multiple selections', () => { + it('copies selected text onto the clipboard', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) + + editor.copyOnlySelectedText() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe(' var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(clipboard.readText()).toBe('quicksort\nsort\nitems') + expect(atom.clipboard.read()).toEqual(`quicksort\nsort\nitems`) + }) + }) + + describe('when no text is selected', () => { + it('does not copy anything', () => { + editor.setCursorBufferPosition([1, 5]) + editor.copyOnlySelectedText() + expect(atom.clipboard.read()).toEqual('initial clipboard content') + }) + }) + }) + + describe('.pasteText()', () => { + const copyText = function (text, {startColumn, textEditor} = {}) { + if (startColumn == null) startColumn = 0 + if (textEditor == null) textEditor = editor + textEditor.setCursorBufferPosition([0, 0]) + textEditor.insertText(text) + const numberOfNewlines = text.match(/\n/g).length + const endColumn = text.match(/[^\n]*$/)[0].length + textEditor.getLastSelection().setBufferRange([[0, startColumn], [numberOfNewlines, endColumn]]) + return textEditor.cutSelectedText() + } + + it('pastes text into the buffer', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + atom.clipboard.write('first') + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var first = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var first = function(items) {') + }) + + it('notifies ::onWillInsertText observers', () => { + const insertedStrings = [] + editor.onWillInsertText(function ({text, cancel}) { + insertedStrings.push(text) + cancel() + }) + + atom.clipboard.write('hello') + editor.pasteText() + + expect(insertedStrings).toEqual(['hello']) + }) + + it('notifies ::onDidInsertText observers', () => { + const insertedStrings = [] + editor.onDidInsertText(({text, range}) => insertedStrings.push(text)) + + atom.clipboard.write('hello') + editor.pasteText() + + expect(insertedStrings).toEqual(['hello']) + }) + + describe('when `autoIndentOnPaste` is true', () => { + beforeEach(() => editor.update({autoIndentOnPaste: true})) + + describe('when pasting multiple lines before any non-whitespace characters', () => { + it('auto-indents the lines spanned by the pasted text, based on the first pasted line', () => { + atom.clipboard.write('a(x);\n b(x);\n c(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + // Adjust the indentation of the pasted lines while preserving + // their indentation relative to each other. Also preserve the + // indentation of the following line. + expect(editor.lineTextForBufferRow(5)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(6)).toBe(' b(x);') + expect(editor.lineTextForBufferRow(7)).toBe(' c(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' current = items.shift();') + }) + + it('auto-indents lines with a mix of hard tabs and spaces without removing spaces', () => { + editor.setSoftTabs(false) + expect(editor.indentationForBufferRow(5)).toBe(3) + + atom.clipboard.write('/**\n\t * testing\n\t * indent\n\t **/\n', {indentBasis: 1}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + // Do not lose the alignment spaces + expect(editor.lineTextForBufferRow(5)).toBe('\t\t\t/**') + expect(editor.lineTextForBufferRow(6)).toBe('\t\t\t * testing') + expect(editor.lineTextForBufferRow(7)).toBe('\t\t\t * indent') + expect(editor.lineTextForBufferRow(8)).toBe('\t\t\t **/') + }) + }) + + describe('when pasting line(s) above a line that matches the decreaseIndentPattern', () => + it('auto-indents based on the pasted line(s) only', () => { + atom.clipboard.write('a(x);\n b(x);\n c(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([7, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(7)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' b(x);') + expect(editor.lineTextForBufferRow(9)).toBe(' c(x);') + expect(editor.lineTextForBufferRow(10)).toBe(' }') + }) + ) + + describe('when pasting a line of text without line ending', () => + it('does not auto-indent the text', () => { + atom.clipboard.write('a(x);', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(5)).toBe('a(x); current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' current < pivot ? left.push(current) : right.push(current);') + }) + ) + + describe('when pasting on a line after non-whitespace characters', () => + it('does not auto-indent the affected line', () => { + // Before the paste, the indentation is non-standard. + editor.setText(dedent`\ + if (x) { + y(); + }\ + `) + + atom.clipboard.write(' z();\n h();') + editor.setCursorBufferPosition([1, Infinity]) + + // The indentation of the non-standard line is unchanged. + editor.pasteText() + expect(editor.lineTextForBufferRow(1)).toBe(' y(); z();') + expect(editor.lineTextForBufferRow(2)).toBe(' h();') + }) + ) + }) + + describe('when `autoIndentOnPaste` is false', () => { + beforeEach(() => editor.update({autoIndentOnPaste: false})) + + describe('when the cursor is indented further than the original copied text', () => + it('increases the indentation of the copied lines to match', () => { + editor.setSelectedBufferRange([[1, 2], [3, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([5, 6]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(5)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(6)).toBe(' if (items.length <= 1) return items;') + }) + ) + + describe('when the cursor is indented less far than the original copied text', () => + it('decreases the indentation of the copied lines to match', () => { + editor.setSelectedBufferRange([[6, 6], [8, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([1, 2]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(1)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(2)).toBe('}') + }) + ) + + describe('when the first copied line has leading whitespace', () => + it("preserves the line's leading whitespace", () => { + editor.setSelectedBufferRange([[4, 0], [6, 0]]) + editor.copySelectedText() + + editor.setCursorBufferPosition([0, 0]) + editor.pasteText() + + expect(editor.lineTextForBufferRow(0)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(1)).toBe(' current = items.shift();') + }) + ) + }) + + describe('when the clipboard has many selections', () => { + beforeEach(() => { + editor.update({autoIndentOnPaste: false}) + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + editor.copySelectedText() + }) + + it('pastes each selection in order separately into the buffer', () => { + editor.setSelectedBufferRanges([ + [[1, 6], [1, 10]], + [[0, 4], [0, 13]] + ]) + + editor.moveRight() + editor.insertText('_') + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort_quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var sort_sort = function(items) {') + }) + + describe('and the selections count does not match', () => { + beforeEach(() => editor.setSelectedBufferRanges([[[0, 4], [0, 13]]])) + + it('pastes the whole text into the buffer', () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort') + expect(editor.lineTextForBufferRow(1)).toBe('sort = function () {') + }) + }) + }) + + describe('when a full line was cut', () => { + beforeEach(() => { + editor.setCursorBufferPosition([2, 13]) + editor.cutSelectedText() + editor.setCursorBufferPosition([2, 13]) + }) + + it("pastes the line above the cursor and retains the cursor's column", () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getCursorBufferPosition()).toEqual([3, 13]) + }) + }) + + describe('when a full line was copied', () => { + beforeEach(() => { + editor.setCursorBufferPosition([2, 13]) + editor.copySelectedText() + }) + + describe('when there is a selection', () => + it('overwrites the selection as with any copied text', () => { + editor.setSelectedBufferRange([[1, 2], [1, Infinity]]) + editor.pasteText() + expect(editor.lineTextForBufferRow(1)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(2)).toBe('') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorBufferPosition()).toEqual([2, 0]) + }) + ) + + describe('when there is no selection', () => + it("pastes the line above the cursor and retains the cursor's column", () => { + editor.pasteText() + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + expect(editor.lineTextForBufferRow(3)).toBe(' if (items.length <= 1) return items;') + expect(editor.getCursorBufferPosition()).toEqual([3, 13]) + }) + ) + }) + + it('respects options that preserve the formatting of the pasted text', () => { + editor.update({autoIndentOnPaste: true}) + atom.clipboard.write('a(x);\n b(x);\r\nc(x);\n', {indentBasis: 0}) + editor.setCursorBufferPosition([5, 0]) + editor.insertText(' ') + editor.pasteText({autoIndent: false, preserveTrailingLineIndentation: true, normalizeLineEndings: false}) + + expect(editor.lineTextForBufferRow(5)).toBe(' a(x);') + expect(editor.lineTextForBufferRow(6)).toBe(' b(x);') + expect(editor.buffer.lineEndingForRow(6)).toBe('\r\n') + expect(editor.lineTextForBufferRow(7)).toBe('c(x);') + expect(editor.lineTextForBufferRow(8)).toBe(' current = items.shift();') + }) + }) + }) + + describe('.indentSelectedRows()', () => { + describe('when nothing is selected', () => { + describe('when softTabs is enabled', () => { + it('indents line and retains selection', () => { + editor.setSelectedBufferRange([[0, 3], [0, 3]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3 + editor.getTabLength()], [0, 3 + editor.getTabLength()]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents line and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[0, 3], [0, 3]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3 + 1], [0, 3 + 1]]) + }) + }) + }) + + describe('when one line is selected', () => { + describe('when softTabs is enabled', () => { + it('indents line and retains selection', () => { + editor.setSelectedBufferRange([[0, 4], [0, 14]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe(`${editor.getTabText()}var quicksort = function () {`) + expect(editor.getSelectedBufferRange()).toEqual([[0, 4 + editor.getTabLength()], [0, 14 + editor.getTabLength()]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents line and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[0, 4], [0, 14]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 4 + 1], [0, 14 + 1]]) + }) + }) + }) + + describe('when multiple lines are selected', () => { + describe('when softTabs is enabled', () => { + it('indents selected lines (that are not empty) and retains selection', () => { + editor.setSelectedBufferRange([[9, 1], [11, 15]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe(' };') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + editor.getTabLength()], [11, 15 + editor.getTabLength()]]) + }) + + it('does not indent the last row if the selection ends at column 0', () => { + editor.setSelectedBufferRange([[9, 1], [11, 0]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe(' };') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + editor.getTabLength()], [11, 0]]) + }) + }) + + describe('when softTabs is disabled', () => { + it('indents selected lines (that are not empty) and retains selection', () => { + convertToHardTabs(buffer) + editor.setSoftTabs(false) + editor.setSelectedBufferRange([[9, 1], [11, 15]]) + editor.indentSelectedRows() + expect(buffer.lineForRow(9)).toBe('\t\t};') + expect(buffer.lineForRow(10)).toBe('') + expect(buffer.lineForRow(11)).toBe('\t\treturn sort(Array.apply(this, arguments));') + expect(editor.getSelectedBufferRange()).toEqual([[9, 1 + 1], [11, 15 + 1]]) + }) + }) + }) + }) + + describe('.outdentSelectedRows()', () => { + describe('when nothing is selected', () => { + it('outdents line and retains selection', () => { + editor.setSelectedBufferRange([[1, 3], [1, 3]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[1, 3 - editor.getTabLength()], [1, 3 - editor.getTabLength()]]) + }) + + it('outdents when indent is less than a tab length', () => { + editor.insertText(' ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents a single hard tab when indent is multiple hard tabs and and the session is using soft tabs', () => { + editor.insertText('\t\t') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tvar quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents when a mix of hard tabs and soft tabs are used', () => { + editor.insertText('\t ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe(' var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + }) + + it('outdents only up to the first non-space non-tab character', () => { + editor.insertText(' \tfoo\t ') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('\tfoo\t var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('foo\t var quicksort = function () {') + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('foo\t var quicksort = function () {') + }) + }) + + describe('when one line is selected', () => { + it('outdents line and retains editor', () => { + editor.setSelectedBufferRange([[1, 4], [1, 14]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[1, 4 - editor.getTabLength()], [1, 14 - editor.getTabLength()]]) + }) + }) + + describe('when multiple lines are selected', () => { + it('outdents selected lines and retains editor', () => { + editor.setSelectedBufferRange([[0, 1], [3, 15]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 15 - editor.getTabLength()]]) + }) + + it('does not outdent the last line of the selection if it ends at column 0', () => { + editor.setSelectedBufferRange([[0, 1], [3, 0]]) + editor.outdentSelectedRows() + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + expect(buffer.lineForRow(1)).toBe('var sort = function(items) {') + expect(buffer.lineForRow(2)).toBe(' if (items.length <= 1) return items;') + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [3, 0]]) + }) + }) + }) + + describe('.autoIndentSelectedRows', () => { + it('auto-indents the selection', () => { + editor.setCursorBufferPosition([2, 0]) + editor.insertText('function() {\ninside=true\n}\n i=1\n') + editor.getLastSelection().setBufferRange([[2, 0], [6, 0]]) + editor.autoIndentSelectedRows() + + expect(editor.lineTextForBufferRow(2)).toBe(' function() {') + expect(editor.lineTextForBufferRow(3)).toBe(' inside=true') + expect(editor.lineTextForBufferRow(4)).toBe(' }') + expect(editor.lineTextForBufferRow(5)).toBe(' i=1') + }) + }) + + describe('.undo() and .redo()', () => { + it('undoes/redoes the last change', () => { + editor.insertText('foo') + editor.undo() + expect(buffer.lineForRow(0)).not.toContain('foo') + + editor.redo() + expect(buffer.lineForRow(0)).toContain('foo') + }) + + it('batches the undo / redo of changes caused by multiple cursors', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([1, 0]) + + editor.insertText('foo') + editor.backspace() + + expect(buffer.lineForRow(0)).toContain('fovar') + expect(buffer.lineForRow(1)).toContain('fo ') + + editor.undo() + + expect(buffer.lineForRow(0)).toContain('foo') + expect(buffer.lineForRow(1)).toContain('foo') + + editor.redo() + + expect(buffer.lineForRow(0)).not.toContain('foo') + expect(buffer.lineForRow(0)).toContain('fovar') + }) + + it('restores cursors and selections to their states before and after undone and redone changes', () => { + editor.setSelectedBufferRanges([ + [[0, 0], [0, 0]], + [[1, 0], [1, 3]] + ]) + editor.insertText('abc') + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 3]], + [[1, 3], [1, 3]] + ]) + + editor.setCursorBufferPosition([0, 0]) + editor.setSelectedBufferRanges([ + [[2, 0], [2, 0]], + [[3, 0], [3, 0]], + [[4, 0], [4, 3]] + ]) + editor.insertText('def') + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 3], [2, 3]], + [[3, 3], [3, 3]], + [[4, 3], [4, 3]] + ]) + + editor.setCursorBufferPosition([0, 0]) + editor.undo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 0], [2, 0]], + [[3, 0], [3, 0]], + [[4, 0], [4, 3]] + ]) + + editor.undo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 0], [0, 0]], + [[1, 0], [1, 3]] + ]) + + editor.redo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[0, 3], [0, 3]], + [[1, 3], [1, 3]] + ]) + + editor.redo() + + expect(editor.getSelectedBufferRanges()).toEqual([ + [[2, 3], [2, 3]], + [[3, 3], [3, 3]], + [[4, 3], [4, 3]] + ]) + }) + + it('restores the selected ranges after undo and redo', () => { + editor.setSelectedBufferRanges([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) + editor.delete() + editor.delete() + + const selections = editor.getSelections() + expect(buffer.lineForRow(1)).toBe(' var = function( {') + + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 17], [1, 17]]]) + + editor.undo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 18], [1, 18]]]) + + editor.undo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 10]], [[1, 22], [1, 27]]]) + + editor.redo() + expect(editor.getSelectedBufferRanges()).toEqual([[[1, 6], [1, 6]], [[1, 18], [1, 18]]]) + }) + + xit('restores folds after undo and redo', () => { + editor.foldBufferRow(1) + editor.setSelectedBufferRange([[1, 0], [10, Infinity]], {preserveFolds: true}) + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + + editor.insertText(dedent`\ + // testing + function foo() { + return 1 + 2; + }\ + `) + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + editor.foldBufferRow(2) + + editor.undo() + expect(editor.isFoldedAtBufferRow(1)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(9)).toBeTruthy() + expect(editor.isFoldedAtBufferRow(10)).toBeFalsy() + + editor.redo() + expect(editor.isFoldedAtBufferRow(1)).toBeFalsy() + expect(editor.isFoldedAtBufferRow(2)).toBeTruthy() + }) + }) + + describe('::transact', () => { + it('restores the selection when the transaction is undone/redone', () => { + buffer.setText('1234') + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + + editor.transact(() => { + editor.delete() + editor.moveToEndOfLine() + editor.insertText('5') + expect(buffer.getText()).toBe('145') + }) + + editor.undo() + expect(buffer.getText()).toBe('1234') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [0, 3]]) + + editor.redo() + expect(buffer.getText()).toBe('145') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3], [0, 3]]) + }) + }) + + describe('when the buffer is changed (via its direct api, rather than via than edit session)', () => { + it('moves the cursor so it is in the same relative position of the buffer', () => { + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + editor.addCursorAtScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([1, 0]) + const [cursor1, cursor2, cursor3] = editor.getCursors() + + buffer.insert([0, 1], 'abc') + + expect(cursor1.getScreenPosition()).toEqual([0, 0]) + expect(cursor2.getScreenPosition()).toEqual([0, 8]) + expect(cursor3.getScreenPosition()).toEqual([1, 0]) + }) + + it('does not destroy cursors or selections when a change encompasses them', () => { + const cursor = editor.getLastCursor() + cursor.setBufferPosition([3, 3]) + editor.buffer.delete([[3, 1], [3, 5]]) + expect(cursor.getBufferPosition()).toEqual([3, 1]) + expect(editor.getCursors().indexOf(cursor)).not.toBe(-1) + + const selection = editor.getLastSelection() + selection.setBufferRange([[3, 5], [3, 10]]) + editor.buffer.delete([[3, 3], [3, 8]]) + expect(selection.getBufferRange()).toEqual([[3, 3], [3, 5]]) + expect(editor.getSelections().indexOf(selection)).not.toBe(-1) + }) + + it('merges cursors when the change causes them to overlap', () => { + editor.setCursorScreenPosition([0, 0]) + editor.addCursorAtScreenPosition([0, 2]) + editor.addCursorAtScreenPosition([1, 2]) + + const [cursor1, cursor2, cursor3] = editor.getCursors() + expect(editor.getCursors().length).toBe(3) + + buffer.delete([[0, 0], [0, 2]]) + + expect(editor.getCursors().length).toBe(2) + expect(editor.getCursors()).toEqual([cursor1, cursor3]) + expect(cursor1.getBufferPosition()).toEqual([0, 0]) + expect(cursor3.getBufferPosition()).toEqual([1, 2]) + }) + }) + + describe('.moveSelectionLeft()', () => { + it('moves one active selection on one line one column to the left', () => { + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + expect(editor.getSelectedText()).toBe('quicksort') + + editor.moveSelectionLeft() + + expect(editor.getSelectedText()).toBe('quicksort') + expect(editor.getSelectedBufferRange()).toEqual([[0, 3], [0, 12]]) + }) + + it('moves multiple active selections on one line one column to the left', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 3], [0, 12]], [[0, 15], [0, 23]]]) + }) + + it('moves multiple active selections on multiple lines one column to the left', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 3], [0, 12]], [[1, 5], [1, 9]]]) + }) + + describe('when a selection is at the first column of a line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe(' v') + + editor.moveSelectionLeft() + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe(' v') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]], [[1, 0], [1, 3]]]) + }) + + describe('when multiple selections are active on one line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe('quicksort') + + editor.moveSelectionLeft() + + expect(selections[0].getText()).toBe('var') + expect(selections[1].getText()).toBe('quicksort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [0, 3]], [[0, 4], [0, 13]]]) + }) + }) + }) + }) + + describe('.moveSelectionRight()', () => { + it('moves one active selection on one line one column to the right', () => { + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + expect(editor.getSelectedText()).toBe('quicksort') + + editor.moveSelectionRight() + + expect(editor.getSelectedText()).toBe('quicksort') + expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 14]]) + }) + + it('moves multiple active selections on one line one column to the right', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('function') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 5], [0, 14]], [[0, 17], [0, 25]]]) + }) + + it('moves multiple active selections on multiple lines one column to the right', () => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('quicksort') + expect(selections[1].getText()).toBe('sort') + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 5], [0, 14]], [[1, 7], [1, 11]]]) + }) + + describe('when a selection is at the last column of a line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('items;') + expect(selections[1].getText()).toBe('shift();') + + editor.moveSelectionRight() + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('items;') + expect(selections[1].getText()).toBe('shift();') + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 34], [2, 40]], [[5, 22], [5, 30]]]) + }) + + describe('when multiple selections are active on one line', () => { + it('does not change the selection', () => { + editor.setSelectedBufferRanges([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) + const selections = editor.getSelections() + + expect(selections[0].getText()).toBe('return') + expect(selections[1].getText()).toBe('items;') + + editor.moveSelectionRight() + + expect(selections[0].getText()).toBe('return') + expect(selections[1].getText()).toBe('items;') + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 27], [2, 33]], [[2, 34], [2, 40]]]) + }) + }) + }) + }) + }) + + describe('reading text', () => { + it('.lineTextForScreenRow(row)', () => { + editor.foldBufferRow(4) + expect(editor.lineTextForScreenRow(5)).toEqual(' return sort(left).concat(pivot).concat(sort(right));') + expect(editor.lineTextForScreenRow(9)).toEqual('};') + expect(editor.lineTextForScreenRow(10)).toBeUndefined() + }) + }) + + describe('.deleteLine()', () => { + it('deletes the first line when the cursor is there', () => { + editor.getLastCursor().moveToTop() + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line1) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('deletes the last line when the cursor is there', () => { + const count = buffer.getLineCount() + const secondToLastLine = buffer.lineForRow(count - 2) + expect(buffer.lineForRow(count - 1)).not.toBe(secondToLastLine) + editor.getLastCursor().moveToBottom() + editor.deleteLine() + const newCount = buffer.getLineCount() + expect(buffer.lineForRow(newCount - 1)).toBe(secondToLastLine) + expect(newCount).toBe(count - 1) + }) + + it('deletes whole lines when partial lines are selected', () => { + editor.setSelectedBufferRange([[0, 2], [1, 2]]) + const line2 = buffer.lineForRow(2) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line2) + expect(buffer.lineForRow(1)).not.toBe(line2) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line2) + expect(buffer.getLineCount()).toBe(count - 2) + }) + + it('deletes a line only once when multiple selections are on the same line', () => { + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + editor.setSelectedBufferRanges([ + [[0, 1], [0, 2]], + [[0, 4], [0, 5]] + ]) + expect(buffer.lineForRow(0)).not.toBe(line1) + + editor.deleteLine() + + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('only deletes first line if only newline is selected on second line', () => { + editor.setSelectedBufferRange([[0, 2], [1, 0]]) + const line1 = buffer.lineForRow(1) + const count = buffer.getLineCount() + expect(buffer.lineForRow(0)).not.toBe(line1) + editor.deleteLine() + expect(buffer.lineForRow(0)).toBe(line1) + expect(buffer.getLineCount()).toBe(count - 1) + }) + + it('deletes the entire region when invoke on a folded region', () => { + editor.foldBufferRow(1) + editor.getLastCursor().moveToTop() + editor.getLastCursor().moveDown() + expect(buffer.getLineCount()).toBe(13) + editor.deleteLine() + expect(buffer.getLineCount()).toBe(4) + }) + + it('deletes the entire file from the bottom up', () => { + const count = buffer.getLineCount() + expect(count).toBeGreaterThan(0) + for (let i = 0; i < count; i++) { + editor.getLastCursor().moveToBottom() + editor.deleteLine() + } + expect(buffer.getLineCount()).toBe(1) + expect(buffer.getText()).toBe('') + }) + + it('deletes the entire file from the top down', () => { + const count = buffer.getLineCount() + expect(count).toBeGreaterThan(0) + for (let i = 0; i < count; i++) { + editor.getLastCursor().moveToTop() + editor.deleteLine() + } + expect(buffer.getLineCount()).toBe(1) + expect(buffer.getText()).toBe('') + }) + + describe('when soft wrap is enabled', () => { + it('deletes the entire line that the cursor is on', () => { + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(10) + editor.setCursorBufferPosition([6]) + + const line7 = buffer.lineForRow(7) + const count = buffer.getLineCount() + expect(buffer.lineForRow(6)).not.toBe(line7) + editor.deleteLine() + expect(buffer.lineForRow(6)).toBe(line7) + expect(buffer.getLineCount()).toBe(count - 1) + }) + }) + + describe('when the line being deleted precedes a fold, and the command is undone', () => { + it('restores the line and preserves the fold', () => { + editor.setCursorBufferPosition([4]) + editor.foldCurrentRow() + expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() + editor.setCursorBufferPosition([3]) + editor.deleteLine() + expect(editor.isFoldedAtScreenRow(3)).toBeTruthy() + expect(buffer.lineForRow(3)).toBe(' while(items.length > 0) {') + editor.undo() + expect(editor.isFoldedAtScreenRow(4)).toBeTruthy() + expect(buffer.lineForRow(3)).toBe(' var pivot = items.shift(), current, left = [], right = [];') + }) + }) + }) + + describe('.replaceSelectedText(options, fn)', () => { + describe('when no text is selected', () => { + it('inserts the text returned from the function at the cursor position', () => { + editor.replaceSelectedText({}, () => '123') + expect(buffer.lineForRow(0)).toBe('123var quicksort = function () {') + + editor.setCursorBufferPosition([0]) + editor.replaceSelectedText({selectWordIfEmpty: true}, () => 'var') + expect(buffer.lineForRow(0)).toBe('var quicksort = function () {') + + editor.setCursorBufferPosition([10]) + editor.replaceSelectedText(null, () => '') + expect(buffer.lineForRow(10)).toBe('') + }) + }) + + describe('when text is selected', () => { + it('replaces the selected text with the text returned from the function', () => { + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + editor.replaceSelectedText({}, () => 'ia') + expect(buffer.lineForRow(0)).toBe('via quicksort = function () {') + }) + + it('replaces the selected text and selects the replacement text', () => { + editor.setSelectedBufferRange([[0, 4], [0, 9]]) + editor.replaceSelectedText({}, () => 'whatnot') + expect(buffer.lineForRow(0)).toBe('var whatnotsort = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 4], [0, 11]]) + }) + }) + }) + + describe('.transpose()', () => { + it('swaps two characters', () => { + editor.buffer.setText('abc') + editor.setCursorScreenPosition([0, 1]) + editor.transpose() + expect(editor.lineTextForBufferRow(0)).toBe('bac') + }) + + it('reverses a selection', () => { + editor.buffer.setText('xabcz') + editor.setSelectedBufferRange([[0, 1], [0, 4]]) + editor.transpose() + expect(editor.lineTextForBufferRow(0)).toBe('xcbaz') + }) + }) + + describe('.upperCase()', () => { + describe('when there is no selection', () => { + it('upper cases the current word', () => { + editor.buffer.setText('aBc') + editor.setCursorScreenPosition([0, 1]) + editor.upperCase() + expect(editor.lineTextForBufferRow(0)).toBe('ABC') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when there is a selection', () => { + it('upper cases the current selection', () => { + editor.buffer.setText('abc') + editor.setSelectedBufferRange([[0, 0], [0, 2]]) + editor.upperCase() + expect(editor.lineTextForBufferRow(0)).toBe('ABc') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 2]]) + }) + }) + }) + + describe('.lowerCase()', () => { + describe('when there is no selection', () => { + it('lower cases the current word', () => { + editor.buffer.setText('aBC') + editor.setCursorScreenPosition([0, 1]) + editor.lowerCase() + expect(editor.lineTextForBufferRow(0)).toBe('abc') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when there is a selection', () => { + it('lower cases the current selection', () => { + editor.buffer.setText('ABC') + editor.setSelectedBufferRange([[0, 0], [0, 2]]) + editor.lowerCase() + expect(editor.lineTextForBufferRow(0)).toBe('abC') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 2]]) + }) + }) + }) + + describe('.setTabLength(tabLength)', () => { + it('clips atomic soft tabs to the given tab length', () => { + expect(editor.getTabLength()).toBe(2) + expect(editor.clipScreenPosition([5, 1], {clipDirection: 'forward'})).toEqual([5, 2]) + + editor.setTabLength(6) + expect(editor.getTabLength()).toBe(6) + expect(editor.clipScreenPosition([5, 1], {clipDirection: 'forward'})).toEqual([5, 6]) + + const changeHandler = jasmine.createSpy('changeHandler') + editor.onDidChange(changeHandler) + editor.setTabLength(6) + expect(changeHandler).not.toHaveBeenCalled() + }) + + it('does not change its tab length when the given tab length is null', () => { + editor.setTabLength(4) + editor.setTabLength(null) + expect(editor.getTabLength()).toBe(4) + }) + }) + + describe('.indentLevelForLine(line)', () => { + it('returns the indent level when the line has only leading whitespace', () => { + expect(editor.indentLevelForLine(' hello')).toBe(2) + expect(editor.indentLevelForLine(' hello')).toBe(1.5) + }) + + it('returns the indent level when the line has only leading tabs', () => expect(editor.indentLevelForLine('\t\thello')).toBe(2)) + + it('returns the indent level based on the character starting the line when the leading whitespace contains both spaces and tabs', () => { + expect(editor.indentLevelForLine('\t hello')).toBe(2) + expect(editor.indentLevelForLine(' \thello')).toBe(2) + expect(editor.indentLevelForLine(' \t hello')).toBe(2.5) + expect(editor.indentLevelForLine(' \t \thello')).toBe(4) + expect(editor.indentLevelForLine(' \t \thello')).toBe(4) + expect(editor.indentLevelForLine(' \t \t hello')).toBe(4.5) + }) + }) + + describe('when a better-matched grammar is added to syntax', () => { + it('switches to the better-matched grammar and re-tokenizes the buffer', async () => { + editor.destroy() + + const jsGrammar = atom.grammars.selectGrammar('a.js') + atom.grammars.removeGrammar(jsGrammar) + + editor = await atom.workspace.open('sample.js', {autoIndent: false}) + + expect(editor.getGrammar()).toBe(atom.grammars.nullGrammar) + expect(editor.tokensForScreenRow(0).length).toBe(1) + + atom.grammars.addGrammar(jsGrammar) + expect(editor.getGrammar()).toBe(jsGrammar) + expect(editor.tokensForScreenRow(0).length).toBeGreaterThan(1) + }) + }) + + describe('editor.autoIndent', () => { + describe('when editor.autoIndent is false (default)', () => { + describe('when `indent` is triggered', () => { + it('does not auto-indent the line', () => { + editor.setCursorBufferPosition([1, 30]) + editor.insertText('\n ') + expect(editor.lineTextForBufferRow(2)).toBe(' ') + + editor.update({autoIndent: false}) + editor.indent() + expect(editor.lineTextForBufferRow(2)).toBe(' ') + }) + }) + }) + + describe('when editor.autoIndent is true', () => { + beforeEach(() => editor.update({autoIndent: true})) + + describe('when `indent` is triggered', () => { + it('auto-indents the line', () => { + editor.setCursorBufferPosition([1, 30]) + editor.insertText('\n ') + expect(editor.lineTextForBufferRow(2)).toBe(' ') + + editor.update({autoIndent: true}) + editor.indent() + expect(editor.lineTextForBufferRow(2)).toBe(' ') + }) + }) + + describe('when a newline is added', () => { + describe('when the line preceding the newline adds a new level of indentation', () => { + it('indents the newline to one additional level of indentation beyond the preceding line', () => { + editor.setCursorBufferPosition([1, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + }) + }) + + describe("when the line preceding the newline doesn't add a level of indentation", () => { + it('indents the new line to the same level as the preceding line', () => { + editor.setCursorBufferPosition([5, 14]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(6)).toBe(editor.indentationForBufferRow(5)) + }) + }) + + describe('when the line preceding the newline is a comment', () => { + it('maintains the indent of the commented line', () => { + editor.setCursorBufferPosition([0, 0]) + editor.insertText(' //') + editor.setCursorBufferPosition([0, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(1)).toBe(2) + }) + }) + + describe('when the line preceding the newline contains only whitespace', () => { + it("bases the new line's indentation on only the preceding line", () => { + editor.setCursorBufferPosition([6, Infinity]) + editor.insertText('\n ') + expect(editor.getCursorBufferPosition()).toEqual([7, 2]) + + editor.insertNewline() + expect(editor.lineTextForBufferRow(8)).toBe(' ') + }) + }) + + it('does not indent the line preceding the newline', () => { + editor.setCursorBufferPosition([2, 0]) + editor.insertText(' var this-line-should-be-indented-more\n') + expect(editor.indentationForBufferRow(1)).toBe(1) + + editor.update({autoIndent: true}) + editor.setCursorBufferPosition([2, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(1)).toBe(1) + expect(editor.indentationForBufferRow(2)).toBe(1) + }) + + describe('when the cursor is before whitespace', () => { + it('retains the whitespace following the cursor on the new line', () => { + editor.setText(' var sort = function() {}') + editor.setCursorScreenPosition([0, 12]) + editor.insertNewline() + + expect(buffer.lineForRow(0)).toBe(' var sort =') + expect(buffer.lineForRow(1)).toBe(' function() {}') + expect(editor.getCursorScreenPosition()).toEqual([1, 2]) + }) + }) + }) + + describe('when inserted text matches a decrease indent pattern', () => { + describe('when the preceding line matches an increase indent pattern', () => { + it('decreases the indentation to match that of the preceding line', () => { + editor.setCursorBufferPosition([1, Infinity]) + editor.insertText('\n') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + editor.insertText('}') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1)) + }) + }) + + describe("when the preceding line doesn't match an increase indent pattern", () => { + it('decreases the indentation to be one level below that of the preceding line', () => { + editor.setCursorBufferPosition([3, Infinity]) + editor.insertText('\n ') + expect(editor.indentationForBufferRow(4)).toBe(editor.indentationForBufferRow(3)) + editor.insertText('}') + expect(editor.indentationForBufferRow(4)).toBe(editor.indentationForBufferRow(3) - 1) + }) + + it("doesn't break when decreasing the indentation on a row that has no indentation", () => { + editor.setCursorBufferPosition([12, Infinity]) + editor.insertText('\n}; # too many closing brackets!') + expect(editor.lineTextForBufferRow(13)).toBe('}; # too many closing brackets!') + }) + }) + }) + + describe('when inserted text does not match a decrease indent pattern', () => { + it('does not decrease the indentation', () => { + editor.setCursorBufferPosition([12, 0]) + editor.insertText(' ') + expect(editor.lineTextForBufferRow(12)).toBe(' };') + editor.insertText('\t\t') + expect(editor.lineTextForBufferRow(12)).toBe(' \t\t};') + }) + }) + + describe('when the current line does not match a decrease indent pattern', () => { + it('leaves the line unchanged', () => { + editor.setCursorBufferPosition([2, 4]) + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + editor.insertText('foo') + expect(editor.indentationForBufferRow(2)).toBe(editor.indentationForBufferRow(1) + 1) + }) + }) + }) + }) + + describe('atomic soft tabs', () => { + it('skips tab-length runs of leading whitespace when moving the cursor', () => { + editor.update({tabLength: 4, atomicSoftTabs: true}) + + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 4]) + + editor.update({atomicSoftTabs: false}) + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 3]) + + editor.update({atomicSoftTabs: true}) + editor.setCursorScreenPosition([2, 3]) + expect(editor.getCursorScreenPosition()).toEqual([2, 4]) + }) + }) + + describe('.destroy()', () => { + it('destroys marker layers associated with the text editor', () => { + buffer.retain() + const selectionsMarkerLayerId = editor.selectionsMarkerLayer.id + const foldsMarkerLayerId = editor.displayLayer.foldsMarkerLayer.id + editor.destroy() + expect(buffer.getMarkerLayer(selectionsMarkerLayerId)).toBeUndefined() + expect(buffer.getMarkerLayer(foldsMarkerLayerId)).toBeUndefined() + buffer.release() + }) + + it('notifies ::onDidDestroy observers when the editor is destroyed', () => { + let destroyObserverCalled = false + editor.onDidDestroy(() => destroyObserverCalled = true) + + editor.destroy() + expect(destroyObserverCalled).toBe(true) + }) + + it('does not blow up when query methods are called afterward', () => { + editor.destroy() + editor.getGrammar() + editor.getLastCursor() + editor.lineTextForBufferRow(0) + }) + + it("emits the destroy event after destroying the editor's buffer", () => { + const events = [] + editor.getBuffer().onDidDestroy(() => { + expect(editor.isDestroyed()).toBe(true) + events.push('buffer-destroyed') + }) + editor.onDidDestroy(() => { + expect(buffer.isDestroyed()).toBe(true) + events.push('editor-destroyed') + }) + editor.destroy() + expect(events).toEqual(['buffer-destroyed', 'editor-destroyed']) + }) + }) + + describe('.joinLines()', () => { + describe('when no text is selected', () => { + describe("when the line below isn't empty", () => { + it('joins the line below with the current line separated by a space and moves the cursor to the start of line that was moved up', () => { + editor.setCursorBufferPosition([0, Infinity]) + editor.insertText(' ') + editor.setCursorBufferPosition([0]) + editor.joinLines() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) {') + expect(editor.getCursorBufferPosition()).toEqual([0, 29]) + }) + }) + + describe('when the line below is empty', () => { + it('deletes the line below and moves the cursor to the end of the line', () => { + editor.setCursorBufferPosition([9]) + editor.joinLines() + expect(editor.lineTextForBufferRow(9)).toBe(' };') + expect(editor.lineTextForBufferRow(10)).toBe(' return sort(Array.apply(this, arguments));') + expect(editor.getCursorBufferPosition()).toEqual([9, 4]) + }) + }) + + describe('when the cursor is on the last row', () => { + it('does nothing', () => { + editor.setCursorBufferPosition([Infinity, Infinity]) + editor.joinLines() + expect(editor.lineTextForBufferRow(12)).toBe('};') + }) + }) + + describe('when the line is empty', () => { + it('joins the line below with the current line with no added space', () => { + editor.setCursorBufferPosition([10]) + editor.joinLines() + expect(editor.lineTextForBufferRow(10)).toBe('return sort(Array.apply(this, arguments));') + expect(editor.getCursorBufferPosition()).toEqual([10, 0]) + }) + }) + }) + + describe('when text is selected', () => { + describe('when the selection does not span multiple lines', () => { + it('joins the line below with the current line separated by a space and retains the selected text', () => { + editor.setSelectedBufferRange([[0, 1], [0, 3]]) + editor.joinLines() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () { var sort = function(items) {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 1], [0, 3]]) + }) + }) + + describe('when the selection spans multiple lines', () => { + it('joins all selected lines separated by a space and retains the selected text', () => { + editor.setSelectedBufferRange([[9, 3], [12, 1]]) + editor.joinLines() + expect(editor.lineTextForBufferRow(9)).toBe(' }; return sort(Array.apply(this, arguments)); };') + expect(editor.getSelectedBufferRange()).toEqual([[9, 3], [9, 49]]) + }) + }) + }) + }) + + describe('.duplicateLines()', () => { + it('for each selection, duplicates all buffer lines intersected by the selection', () => { + editor.foldBufferRow(4) + editor.setCursorBufferPosition([2, 5]) + editor.addSelectionForBufferRange([[3, 0], [8, 0]], {preserveFolds: true}) + + editor.duplicateLines() + + expect(editor.getTextInBufferRange([[2, 0], [13, 5]])).toBe(dedent ` + if (items.length <= 1) return items; + if (items.length <= 1) return items; + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + } + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + }\ + `.split('\n').map(l => ` ${l}`).join('\n')) + expect(editor.getSelectedBufferRanges()).toEqual([[[3, 5], [3, 5]], [[9, 0], [14, 0]]]) + + // folds are also duplicated + expect(editor.isFoldedAtScreenRow(5)).toBe(true) + expect(editor.isFoldedAtScreenRow(7)).toBe(true) + expect(editor.lineTextForScreenRow(7)).toBe(` while(items.length > 0) {${editor.displayLayer.foldCharacter}`) + expect(editor.lineTextForScreenRow(8)).toBe(' return sort(left).concat(pivot).concat(sort(right));') + }) + + it('duplicates all folded lines for empty selections on lines containing folds', () => { + editor.foldBufferRow(4) + editor.setCursorBufferPosition([4, 0]) + + editor.duplicateLines() + + expect(editor.getTextInBufferRange([[2, 0], [11, 5]])).toBe(dedent` + if (items.length <= 1) return items; + var pivot = items.shift(), current, left = [], right = []; + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + } + while(items.length > 0) { + current = items.shift(); + current < pivot ? left.push(current) : right.push(current); + } + `.split('\n').map(l => ` ${l}`).join('\n')) + expect(editor.getSelectedBufferRange()).toEqual([[8, 0], [8, 0]]) + }) + + it('can duplicate the last line of the buffer', () => { + editor.setSelectedBufferRange([[11, 0], [12, 2]]) + editor.duplicateLines() + expect(editor.getTextInBufferRange([[11, 0], [14, 2]])).toBe(' ' + dedent ` + return sort(Array.apply(this, arguments)); + }; + return sort(Array.apply(this, arguments)); + }; + `.trim()) + expect(editor.getSelectedBufferRange()).toEqual([[13, 0], [14, 2]]) + }) + + it('only duplicates lines containing multiple selections once', () => { + editor.setText(dedent ` + aaaaaa + bbbbbb + cccccc + dddddd + `) + editor.setSelectedBufferRanges([ + [[0, 1], [0, 2]], + [[0, 3], [0, 4]], + [[2, 1], [2, 2]], + [[2, 3], [3, 1]], + [[3, 3], [3, 4]] + ]) + editor.duplicateLines() + expect(editor.getText()).toBe(dedent ` + aaaaaa + aaaaaa + bbbbbb + cccccc + dddddd + cccccc + dddddd + `) + expect(editor.getSelectedBufferRanges()).toEqual([ + [[1, 1], [1, 2]], + [[1, 3], [1, 4]], + [[5, 1], [5, 2]], + [[5, 3], [6, 1]], + [[6, 3], [6, 4]] + ]) + }) + }) + + describe('when the editor contains surrogate pair characters', () => { + it('correctly backspaces over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') + editor.moveToBottom() + editor.backspace() + expect(editor.getText()).toBe('\uD835\uDF97\uD835\uDF97') + editor.backspace() + expect(editor.getText()).toBe('\uD835\uDF97') + editor.backspace() + expect(editor.getText()).toBe('') + }) + + it('correctly deletes over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97') + editor.moveToTop() + editor.delete() + expect(editor.getText()).toBe('\uD835\uDF97\uD835\uDF97') + editor.delete() + expect(editor.getText()).toBe('\uD835\uDF97') + editor.delete() + expect(editor.getText()).toBe('') + }) + + it('correctly moves over them', () => { + editor.setText('\uD835\uDF97\uD835\uDF97\uD835\uDF97\n') + editor.moveToTop() + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('when the editor contains variation sequence character pairs', () => { + it('correctly backspaces over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') + editor.moveToBottom() + editor.backspace() + expect(editor.getText()).toBe('\u2714\uFE0E\u2714\uFE0E') + editor.backspace() + expect(editor.getText()).toBe('\u2714\uFE0E') + editor.backspace() + expect(editor.getText()).toBe('') + }) + + it('correctly deletes over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E') + editor.moveToTop() + editor.delete() + expect(editor.getText()).toBe('\u2714\uFE0E\u2714\uFE0E') + editor.delete() + expect(editor.getText()).toBe('\u2714\uFE0E') + editor.delete() + expect(editor.getText()).toBe('') + }) + + it('correctly moves over them', () => { + editor.setText('\u2714\uFE0E\u2714\uFE0E\u2714\uFE0E\n') + editor.moveToTop() + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveRight() + expect(editor.getCursorBufferPosition()).toEqual([1, 0]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 2]) + editor.moveLeft() + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('.setIndentationForBufferRow', () => { + describe('when the editor uses soft tabs but the row has hard tabs', () => { + it('only replaces whitespace characters', () => { + editor.setSoftWrapped(true) + editor.setText('\t1\n\t2') + editor.setCursorBufferPosition([0, 0]) + editor.setIndentationForBufferRow(0, 2) + expect(editor.getText()).toBe(' 1\n\t2') + }) + }) + + describe('when the indentation level is a non-integer', () => { + it('does not throw an exception', () => { + editor.setSoftWrapped(true) + editor.setText('\t1\n\t2') + editor.setCursorBufferPosition([0, 0]) + editor.setIndentationForBufferRow(0, 2.1) + expect(editor.getText()).toBe(' 1\n\t2') + }) + }) + }) + + describe("when the editor's grammar has an injection selector", () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-text') + await atom.packages.activatePackage('language-javascript') + }) + + it("includes the grammar's patterns when the selector matches the current scope in other grammars", async () => { + await atom.packages.activatePackage('language-hyperlink') + + const grammar = atom.grammars.selectGrammar('text.js') + const {line, tags} = grammar.tokenizeLine('var i; // http://github.com') + + const tokens = atom.grammars.decodeTokens(line, tags) + expect(tokens[0].value).toBe('var') + expect(tokens[0].scopes).toEqual(['source.js', 'storage.type.var.js']) + expect(tokens[6].value).toBe('http://github.com') + expect(tokens[6].scopes).toEqual(['source.js', 'comment.line.double-slash.js', 'markup.underline.link.http.hyperlink']) + }) + + describe('when the grammar is added', () => { + it('retokenizes existing buffers that contain tokens that match the injection selector', async () => { + editor = await atom.workspace.open('sample.js') + editor.setText('// http://github.com') + let tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('language-hyperlink') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'http://github.com', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--markup syntax--underline syntax--link syntax--http syntax--hyperlink']} + ]) + }) + + describe('when the grammar is updated', () => { + it('retokenizes existing buffers that contain tokens that match the injection selector', async () => { + editor = await atom.workspace.open('sample.js') + editor.setText('// SELECT * FROM OCTOCATS') + let tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('package-with-injection-selector') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' SELECT * FROM OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + + await atom.packages.activatePackage('language-sql') + tokens = editor.tokensForScreenRow(0) + expect(tokens).toEqual([ + {text: '//', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--punctuation syntax--definition syntax--comment syntax--js']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'SELECT', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: '*', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--operator syntax--star syntax--sql']}, + {text: ' ', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']}, + {text: 'FROM', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js', 'syntax--keyword syntax--other syntax--DML syntax--sql']}, + {text: ' OCTOCATS', scopes: ['syntax--source syntax--js', 'syntax--comment syntax--line syntax--double-slash syntax--js']} + ]) + }) + }) + }) + }) + + describe('.normalizeTabsInBufferRange()', () => { + it("normalizes tabs depending on the editor's soft tab/tab length settings", () => { + editor.setTabLength(1) + editor.setSoftTabs(true) + editor.setText('\t\t\t') + editor.normalizeTabsInBufferRange([[0, 0], [0, 1]]) + expect(editor.getText()).toBe(' \t\t') + + editor.setTabLength(2) + editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) + expect(editor.getText()).toBe(' ') + + editor.setSoftTabs(false) + editor.normalizeTabsInBufferRange([[0, 0], [Infinity, Infinity]]) + expect(editor.getText()).toBe(' ') + }) + }) + + describe('.pageUp/Down()', () => { + it('moves the cursor down one page length', () => { + editor.update({autoHeight: false}) + const element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = (element.component.getLineHeight() * 5) + 'px' + element.measureDimensions() + + expect(editor.getCursorBufferPosition().row).toBe(0) + + editor.pageDown() + expect(editor.getCursorBufferPosition().row).toBe(5) + + editor.pageDown() + expect(editor.getCursorBufferPosition().row).toBe(10) + + editor.pageUp() + expect(editor.getCursorBufferPosition().row).toBe(5) + + editor.pageUp() + expect(editor.getCursorBufferPosition().row).toBe(0) + }) + }) + + describe('.selectPageUp/Down()', () => { + it('selects one screen height of text up or down', () => { + editor.update({autoHeight: false}) + const element = editor.getElement() + jasmine.attachToDOM(element) + element.style.height = (element.component.getLineHeight() * 5) + 'px' + element.measureDimensions() + + expect(editor.getCursorBufferPosition().row).toBe(0) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [5, 0]]]) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [10, 0]]]) + + editor.selectPageDown() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [12, 2]]]) + + editor.moveToBottom() + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[7, 0], [12, 2]]]) + + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[2, 0], [12, 2]]]) + + editor.selectPageUp() + expect(editor.getSelectedBufferRanges()).toEqual([[[0, 0], [12, 2]]]) + }) + }) + + describe('::scrollToScreenPosition(position, [options])', () => { + it('triggers ::onDidRequestAutoscroll with the logical coordinates along with the options', () => { + const scrollSpy = jasmine.createSpy('::onDidRequestAutoscroll') + editor.onDidRequestAutoscroll(scrollSpy) + + editor.scrollToScreenPosition([8, 20]) + editor.scrollToScreenPosition([8, 20], {center: true}) + editor.scrollToScreenPosition([8, 20], {center: false, reversed: true}) + + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {}}) + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {center: true}}) + expect(scrollSpy).toHaveBeenCalledWith({screenRange: [[8, 20], [8, 20]], options: {center: false, reversed: true}}) + }) + }) + + describe('scroll past end', () => { + it('returns false by default but can be customized', () => { + expect(editor.getScrollPastEnd()).toBe(false) + editor.update({scrollPastEnd: true}) + expect(editor.getScrollPastEnd()).toBe(true) + editor.update({scrollPastEnd: false}) + expect(editor.getScrollPastEnd()).toBe(false) + }) + + it('always returns false when autoHeight is on', () => { + editor.update({autoHeight: true, scrollPastEnd: true}) + expect(editor.getScrollPastEnd()).toBe(false) + editor.update({autoHeight: false}) + expect(editor.getScrollPastEnd()).toBe(true) + }) + }) + + describe('auto height', () => { + it('returns true by default but can be customized', () => { + editor = new TextEditor() + expect(editor.getAutoHeight()).toBe(true) + editor.update({autoHeight: false}) + expect(editor.getAutoHeight()).toBe(false) + editor.update({autoHeight: true}) + expect(editor.getAutoHeight()).toBe(true) + editor.destroy() + }) + }) + + describe('auto width', () => { + it('returns false by default but can be customized', () => { + expect(editor.getAutoWidth()).toBe(false) + editor.update({autoWidth: true}) + expect(editor.getAutoWidth()).toBe(true) + editor.update({autoWidth: false}) + expect(editor.getAutoWidth()).toBe(false) + }) + }) + + describe('.get/setPlaceholderText()', () => { + it('can be created with placeholderText', () => { + const newEditor = new TextEditor({ + mini: true, + placeholderText: 'yep' + }) + expect(newEditor.getPlaceholderText()).toBe('yep') + }) + + it('models placeholderText and emits an event when changed', () => { + let handler + editor.onDidChangePlaceholderText(handler = jasmine.createSpy()) + + expect(editor.getPlaceholderText()).toBeUndefined() + + editor.setPlaceholderText('OK') + expect(handler).toHaveBeenCalledWith('OK') + expect(editor.getPlaceholderText()).toBe('OK') + }) + }) + + describe('gutters', () => { + describe('the TextEditor constructor', () => { + it('creates a line-number gutter', () => { + expect(editor.getGutters().length).toBe(1) + const lineNumberGutter = editor.gutterWithName('line-number') + expect(lineNumberGutter.name).toBe('line-number') + expect(lineNumberGutter.priority).toBe(0) + }) + }) + + describe('::addGutter', () => { + it('can add a gutter', () => { + expect(editor.getGutters().length).toBe(1) // line-number gutter + const options = { + name: 'test-gutter', + priority: 1 + } + const gutter = editor.addGutter(options) + expect(editor.getGutters().length).toBe(2) + expect(editor.getGutters()[1]).toBe(gutter) + }) + + it("does not allow a custom gutter with the 'line-number' name.", () => expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow()) + }) + + describe('::decorateMarker', () => { + let marker + + beforeEach(() => marker = editor.markBufferRange([[1, 0], [1, 0]])) + + it('reflects an added decoration when one of its custom gutters is decorated.', () => { + const gutter = editor.addGutter({'name': 'custom-gutter'}) + const decoration = gutter.decorateMarker(marker, {class: 'custom-class'}) + const gutterDecorations = editor.getDecorations({ + type: 'gutter', + gutterName: 'custom-gutter', + class: 'custom-class' + }) + expect(gutterDecorations.length).toBe(1) + expect(gutterDecorations[0]).toBe(decoration) + }) + + it('reflects an added decoration when its line-number gutter is decorated.', () => { + const decoration = editor.gutterWithName('line-number').decorateMarker(marker, {class: 'test-class'}) + const gutterDecorations = editor.getDecorations({ + type: 'line-number', + gutterName: 'line-number', + class: 'test-class' + }) + expect(gutterDecorations.length).toBe(1) + expect(gutterDecorations[0]).toBe(decoration) + }) + }) + + describe('::observeGutters', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback immediately with each existing gutter, and with each added gutter after that.', () => { + const lineNumberGutter = editor.gutterWithName('line-number') + editor.observeGutters(callback) + expect(payloads).toEqual([lineNumberGutter]) + const gutter1 = editor.addGutter({name: 'test-gutter-1'}) + expect(payloads).toEqual([lineNumberGutter, gutter1]) + const gutter2 = editor.addGutter({name: 'test-gutter-2'}) + expect(payloads).toEqual([lineNumberGutter, gutter1, gutter2]) + }) + + it('does not call the callback when a gutter is removed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + editor.observeGutters(callback) + payloads = [] + gutter.destroy() + expect(payloads).toEqual([]) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const subscription = editor.observeGutters(callback) + payloads = [] + subscription.dispose() + editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([]) + }) + }) + + describe('::onDidAddGutter', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback with each newly-added gutter, but not with existing gutters.', () => { + editor.onDidAddGutter(callback) + expect(payloads).toEqual([]) + const gutter = editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([gutter]) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const subscription = editor.onDidAddGutter(callback) + payloads = [] + subscription.dispose() + editor.addGutter({name: 'test-gutter'}) + expect(payloads).toEqual([]) + }) + }) + + describe('::onDidRemoveGutter', () => { + let payloads, callback + + beforeEach(() => { + payloads = [] + callback = payload => payloads.push(payload) + }) + + it('calls the callback when a gutter is removed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + editor.onDidRemoveGutter(callback) + expect(payloads).toEqual([]) + gutter.destroy() + expect(payloads).toEqual(['test-gutter']) + }) + + it('does not call the callback after the subscription has been disposed.', () => { + const gutter = editor.addGutter({name: 'test-gutter'}) + const subscription = editor.onDidRemoveGutter(callback) + subscription.dispose() + gutter.destroy() + expect(payloads).toEqual([]) + }) + }) + }) + + describe('decorations', () => { + describe('::decorateMarker', () => { + it('includes the decoration in the object returned from ::decorationsStateForScreenRowRange', () => { + const marker = editor.markBufferRange([[2, 4], [6, 8]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'foo'}) + expect(editor.decorationsStateForScreenRowRange(0, 5)[decoration.id]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker.getScreenRange(), + bufferRange: marker.getBufferRange(), + rangeIsReversed: false + }) + }) + + it("does not throw errors after the marker's containing layer is destroyed", () => { + const layer = editor.addMarkerLayer() + const marker = layer.markBufferRange([[2, 4], [6, 8]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'foo'}) + layer.destroy() + editor.decorationsStateForScreenRowRange(0, 5) + }) + }) + + describe('::decorateMarkerLayer', () => { + it('based on the markers in the layer, includes multiple decoration objects with the same properties and different ranges in the object returned from ::decorationsStateForScreenRowRange', () => { + const layer1 = editor.getBuffer().addMarkerLayer() + const marker1 = layer1.markRange([[2, 4], [6, 8]]) + const marker2 = layer1.markRange([[11, 0], [11, 12]]) + const layer2 = editor.getBuffer().addMarkerLayer() + const marker3 = layer2.markRange([[8, 0], [9, 0]]) + + const layer1Decoration1 = editor.decorateMarkerLayer(layer1, {type: 'highlight', class: 'foo'}) + const layer1Decoration2 = editor.decorateMarkerLayer(layer1, {type: 'highlight', class: 'bar'}) + const layer2Decoration = editor.decorateMarkerLayer(layer2, {type: 'highlight', class: 'baz'}) + + let decorationState = editor.decorationsStateForScreenRowRange(0, 13) + + expect(decorationState[`${layer1Decoration1.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration1.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'foo'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer2Decoration.id}-${marker3.id}`]).toEqual({ + properties: {type: 'highlight', class: 'baz'}, + screenRange: marker3.getRange(), + bufferRange: marker3.getRange(), + rangeIsReversed: false + }) + + layer1Decoration1.destroy() + + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration1.id}-${marker1.id}`]).toBeUndefined() + expect(decorationState[`${layer1Decoration1.id}-${marker2.id}`]).toBeUndefined() + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer1Decoration2.id}-${marker2.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker2.getRange(), + bufferRange: marker2.getRange(), + rangeIsReversed: false + }) + expect(decorationState[`${layer2Decoration.id}-${marker3.id}`]).toEqual({ + properties: {type: 'highlight', class: 'baz'}, + screenRange: marker3.getRange(), + bufferRange: marker3.getRange(), + rangeIsReversed: false + }) + + layer1Decoration2.setPropertiesForMarker(marker1, {type: 'highlight', class: 'quux'}) + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'quux'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + + layer1Decoration2.setPropertiesForMarker(marker1, null) + decorationState = editor.decorationsStateForScreenRowRange(0, 12) + expect(decorationState[`${layer1Decoration2.id}-${marker1.id}`]).toEqual({ + properties: {type: 'highlight', class: 'bar'}, + screenRange: marker1.getRange(), + bufferRange: marker1.getRange(), + rangeIsReversed: false + }) + }) + }) + }) + + describe('invisibles', () => { + beforeEach(() => { + editor.update({showInvisibles: true}) + }) + + it('substitutes invisible characters according to the given rules', () => { + const previousLineText = editor.lineTextForScreenRow(0) + editor.update({invisibles: {eol: '?'}}) + expect(editor.lineTextForScreenRow(0)).not.toBe(previousLineText) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) + expect(editor.getInvisibles()).toEqual({eol: '?'}) + }) + + it('does not use invisibles if showInvisibles is set to false', () => { + editor.update({invisibles: {eol: '?'}}) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(true) + + editor.update({showInvisibles: false}) + expect(editor.lineTextForScreenRow(0).endsWith('?')).toBe(false) + }) + }) + + describe('indent guides', () => { + it('shows indent guides when `editor.showIndentGuide` is set to true and the editor is not mini', () => { + editor.setText(' foo') + editor.setTabLength(2) + + editor.update({showIndentGuide: false}) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + + editor.update({showIndentGuide: true}) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace indent-guide']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + + editor.setMini(true) + expect(editor.tokensForScreenRow(0)).toEqual([ + {text: ' ', scopes: ['syntax--source syntax--js', 'leading-whitespace']}, + {text: 'foo', scopes: ['syntax--source syntax--js']} + ]) + }) + }) + + describe('when the editor is constructed with the grammar option set', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-coffee-script') + }) + + it('sets the grammar', () => { + editor = new TextEditor({grammar: atom.grammars.grammarForScopeName('source.coffee')}) + expect(editor.getGrammar().name).toBe('CoffeeScript') + }) + }) + + describe('softWrapAtPreferredLineLength', () => { + it('soft wraps the editor at the preferred line length unless the editor is narrower or the editor is mini', () => { + editor.update({ + editorWidthInChars: 30, + softWrapped: true, + softWrapAtPreferredLineLength: true, + preferredLineLength: 20 + }) + + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = ') + + editor.update({editorWidthInChars: 10}) + expect(editor.lineTextForScreenRow(0)).toBe('var ') + + editor.update({mini: true}) + expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') + }) + }) + + describe('softWrapHangingIndentLength', () => { + it('controls how much extra indentation is applied to soft-wrapped lines', () => { + editor.setText('123456789') + editor.update({ + editorWidthInChars: 8, + softWrapped: true, + softWrapHangingIndentLength: 2 + }) + expect(editor.lineTextForScreenRow(1)).toEqual(' 9') + + editor.update({softWrapHangingIndentLength: 4}) + expect(editor.lineTextForScreenRow(1)).toEqual(' 9') + }) + }) + + describe('::getElement', () => { + it('returns an element', () => expect(editor.getElement() instanceof HTMLElement).toBe(true)) + }) + + describe('setMaxScreenLineLength', () => { + it('sets the maximum line length in the editor before soft wrapping is forced', () => { + expect(editor.getSoftWrapColumn()).toBe(500) + editor.update({ + maxScreenLineLength: 1500 + }) + expect(editor.getSoftWrapColumn()).toBe(1500) + }) + }) +}) describe('TextEditor', () => { let editor @@ -58,6 +6745,276 @@ describe('TextEditor', () => { }) }) + describe('.toggleLineCommentsInSelection()', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + editor = await atom.workspace.open('sample.js') + }) + + it('toggles comments on the selected lines', () => { + editor.setSelectedBufferRange([[4, 5], [7, 5]]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + expect(editor.getSelectedBufferRange()).toEqual([[4, 8], [7, 8]]) + + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' }') + }) + + it('does not comment the last line of a non-empty selection if it ends at column 0', () => { + editor.setSelectedBufferRange([[4, 5], [7, 0]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' }') + }) + + it('uncomments lines if all lines match the comment regex', () => { + editor.setSelectedBufferRange([[0, 0], [0, 1]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// // var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('// var sort = function(items) {') + expect(editor.lineTextForBufferRow(2)).toBe('// if (items.length <= 1) return items;') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe(' var sort = function(items) {') + expect(editor.lineTextForBufferRow(2)).toBe(' if (items.length <= 1) return items;') + + editor.setSelectedBufferRange([[0, 0], [0, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + }) + + it('uncomments commented lines separated by an empty line', () => { + editor.setSelectedBufferRange([[0, 0], [1, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('// var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('// var sort = function(items) {') + + editor.getBuffer().insert([0, Infinity], '\n') + + editor.setSelectedBufferRange([[0, 0], [2, Infinity]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {') + expect(editor.lineTextForBufferRow(1)).toBe('') + expect(editor.lineTextForBufferRow(2)).toBe(' var sort = function(items) {') + }) + + it('preserves selection emptiness', () => { + editor.setCursorBufferPosition([4, 0]) + editor.toggleLineCommentsInSelection() + expect(editor.getLastSelection().isEmpty()).toBeTruthy() + }) + + it('does not explode if the current language mode has no comment regex', () => { + const editor = new TextEditor({buffer: new TextBuffer({text: 'hello'})}) + editor.setSelectedBufferRange([[0, 0], [0, 5]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(0)).toBe('hello') + }) + + it('does nothing for empty lines and null grammar', () => { + editor.setGrammar(atom.grammars.grammarForScopeName('text.plain.null-grammar')) + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe('') + }) + + it('uncomments when the line lacks the trailing whitespace in the comment regex', () => { + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(10)).toBe('// ') + expect(editor.getSelectedBufferRange()).toEqual([[10, 3], [10, 3]]) + editor.backspace() + expect(editor.lineTextForBufferRow(10)).toBe('//') + + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe('') + expect(editor.getSelectedBufferRange()).toEqual([[10, 0], [10, 0]]) + }) + + it('uncomments when the line has leading whitespace', () => { + editor.setCursorBufferPosition([10, 0]) + editor.toggleLineCommentsInSelection() + + expect(editor.lineTextForBufferRow(10)).toBe('// ') + editor.moveToBeginningOfLine() + editor.insertText(' ') + editor.setSelectedBufferRange([[10, 0], [10, 0]]) + editor.toggleLineCommentsInSelection() + expect(editor.lineTextForBufferRow(10)).toBe(' ') + }) + }) + + describe('.toggleLineCommentsForBufferRows', () => { + describe('xml', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-xml') + editor = await atom.workspace.open('test.xml') + editor.setText('') + }) + + it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => { + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('test') + }) + }) + + describe('less', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-less') + await atom.packages.activatePackage('language-css') + editor = await atom.workspace.open('sample.less') + }) + + it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => { + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('// @color: #4D926F;') + }) + }) + + describe('css', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-css') + editor = await atom.workspace.open('css.css') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe('/* body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px; */') + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%;') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(0)).toBe('/* body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px; */') + expect(editor.lineTextForBufferRow(2)).toBe(' /* width: 110%; */') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe('body {') + expect(editor.lineTextForBufferRow(1)).toBe(' font-size: 1234px;') + expect(editor.lineTextForBufferRow(2)).toBe(' /* width: 110%; */') + expect(editor.lineTextForBufferRow(3)).toBe(' font-weight: bold !important;') + }) + + it('uncomments lines with leading whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], ' /* width: 110%; */') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%;') + }) + + it('uncomments lines with trailing whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], '/* width: 110%; */ ') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe('width: 110%; ') + }) + + it('uncomments lines with leading and trailing whitespace', () => { + editor.setTextInBufferRange([[2, 0], [2, Infinity]], ' /* width: 110%; */ ') + editor.toggleLineCommentsForBufferRows(2, 2) + expect(editor.lineTextForBufferRow(2)).toBe(' width: 110%; ') + }) + }) + + describe('coffeescript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-coffee-script') + editor = await atom.workspace.open('coffee.coffee') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(4, 6) + expect(editor.lineTextForBufferRow(4)).toBe(' # pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' # left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + }) + + it('comments/uncomments empty lines', () => { + editor.toggleLineCommentsForBufferRows(4, 7) + expect(editor.lineTextForBufferRow(4)).toBe(' # pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' # left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + expect(editor.lineTextForBufferRow(7)).toBe(' # ') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' pivot = items.shift()') + expect(editor.lineTextForBufferRow(5)).toBe(' left = []') + expect(editor.lineTextForBufferRow(6)).toBe(' # right = []') + expect(editor.lineTextForBufferRow(7)).toBe(' # ') + }) + }) + + describe('javascript', () => { + beforeEach(async () => { + await atom.packages.activatePackage('language-javascript') + editor = await atom.workspace.open('sample.js') + }) + + it('comments/uncomments lines in the given range', () => { + editor.toggleLineCommentsForBufferRows(4, 7) + expect(editor.lineTextForBufferRow(4)).toBe(' // while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' // current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + + editor.toggleLineCommentsForBufferRows(4, 5) + expect(editor.lineTextForBufferRow(4)).toBe(' while(items.length > 0) {') + expect(editor.lineTextForBufferRow(5)).toBe(' current = items.shift();') + expect(editor.lineTextForBufferRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);') + expect(editor.lineTextForBufferRow(7)).toBe(' // }') + + editor.setText('\tvar i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('\t// var i;') + + editor.setText('var i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe('// var i;') + + editor.setText(' var i;') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe(' // var i;') + + editor.setText(' ') + editor.toggleLineCommentsForBufferRows(0, 0) + expect(editor.lineTextForBufferRow(0)).toBe(' // ') + + editor.setText(' a\n \n b') + editor.toggleLineCommentsForBufferRows(0, 2) + expect(editor.lineTextForBufferRow(0)).toBe(' // a') + expect(editor.lineTextForBufferRow(1)).toBe(' // ') + expect(editor.lineTextForBufferRow(2)).toBe(' // b') + + editor.setText(' \n // var i;') + editor.toggleLineCommentsForBufferRows(0, 1) + expect(editor.lineTextForBufferRow(0)).toBe(' ') + expect(editor.lineTextForBufferRow(1)).toBe(' var i;') + }) + }) + }) + describe('folding', () => { beforeEach(async () => { await atom.packages.activatePackage('language-javascript') @@ -71,15 +7028,19 @@ describe('TextEditor', () => { }) describe('.unfoldAll()', () => { - it('unfolds every folded line', async () => { + it('unfolds every folded line and autoscrolls', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) const initialScreenLineCount = editor.getScreenLineCount() editor.foldBufferRow(0) editor.foldBufferRow(1) expect(editor.getScreenLineCount()).toBeLessThan(initialScreenLineCount) + expect(autoscrollEvents.length).toBe(1) editor.unfoldAll() expect(editor.getScreenLineCount()).toBe(initialScreenLineCount) + expect(autoscrollEvents.length).toBe(2) }) it('unfolds every folded line with comments', async () => { @@ -97,8 +7058,11 @@ describe('TextEditor', () => { describe('.foldAll()', () => { it('folds every foldable line', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) editor.foldAll() + expect(autoscrollEvents.length).toBe(1) const [fold1, fold2, fold3] = editor.unfoldAll() expect([fold1.start.row, fold1.end.row]).toEqual([0, 12]) expect([fold2.start.row, fold2.end.row]).toEqual([1, 9]) @@ -129,7 +7093,11 @@ describe('TextEditor', () => { describe('when bufferRow can be folded', () => { it('creates a fold based on the syntactic region starting at the given row', () => { + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) + editor.foldBufferRow(1) + expect(autoscrollEvents.length).toBe(1) const [fold] = editor.unfoldAll() expect([fold.start.row, fold.end.row]).toEqual([1, 9]) }) @@ -173,24 +7141,53 @@ describe('TextEditor', () => { }) }) + describe('.foldCurrentRow()', () => { + it('creates a fold at the location of the last cursor', async () => { + editor = await atom.workspace.open() + + editor.setText('\nif (x) {\n y()\n}') + editor.setCursorBufferPosition([1, 0]) + expect(editor.getScreenLineCount()).toBe(4) + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) + editor.foldCurrentRow() + expect(autoscrollEvents.length).toBe(1) + expect(editor.getScreenLineCount()).toBe(3) + }) + + it('does nothing when the current row cannot be folded', async () => { + editor = await atom.workspace.open() + editor.setText('var x;\nx++\nx++') + editor.setCursorBufferPosition([0, 0]) + expect(editor.getScreenLineCount()).toBe(3) + editor.foldCurrentRow() + expect(editor.getScreenLineCount()).toBe(3) + }) + }) + describe('.foldAllAtIndentLevel(indentLevel)', () => { it('folds blocks of text at the given indentation level', async () => { editor = await atom.workspace.open('sample.js', {autoIndent: false}) + const autoscrollEvents = [] + editor.onDidRequestAutoscroll(event => autoscrollEvents.push(event)) editor.foldAllAtIndentLevel(0) expect(editor.lineTextForScreenRow(0)).toBe(`var quicksort = function () {${editor.displayLayer.foldCharacter}`) expect(editor.getLastScreenRow()).toBe(0) + expect(autoscrollEvents.length).toBe(1) editor.foldAllAtIndentLevel(1) expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') expect(editor.lineTextForScreenRow(1)).toBe(` var sort = function(items) {${editor.displayLayer.foldCharacter}`) expect(editor.getLastScreenRow()).toBe(4) + expect(autoscrollEvents.length).toBe(2) editor.foldAllAtIndentLevel(2) expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {') expect(editor.lineTextForScreenRow(1)).toBe(' var sort = function(items) {') expect(editor.lineTextForScreenRow(2)).toBe(' if (items.length <= 1) return items;') expect(editor.getLastScreenRow()).toBe(9) + expect(autoscrollEvents.length).toBe(3) }) it('folds every foldable range at a given indentLevel', async () => { @@ -247,3 +7244,7 @@ describe('TextEditor', () => { }) }) }) + +function convertToHardTabs (buffer) { + buffer.setText(buffer.getText().replace(/[ ]{2}/g, '\t')) +} diff --git a/spec/text-utils-spec.coffee b/spec/text-utils-spec.coffee deleted file mode 100644 index bae7f5997..000000000 --- a/spec/text-utils-spec.coffee +++ /dev/null @@ -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) diff --git a/spec/text-utils-spec.js b/spec/text-utils-spec.js new file mode 100644 index 000000000..3a4b29866 --- /dev/null +++ b/spec/text-utils-spec.js @@ -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) + }) + ) +}) diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee deleted file mode 100644 index 86237b71d..000000000 --- a/spec/theme-manager-spec.coffee +++ /dev/null @@ -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') diff --git a/spec/theme-manager-spec.js b/spec/theme-manager-spec.js new file mode 100644 index 000000000..f4ed3b9f5 --- /dev/null +++ b/spec/theme-manager-spec.js @@ -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) + } +} diff --git a/spec/token-iterator-spec.coffee b/spec/token-iterator-spec.coffee deleted file mode 100644 index 6ae01cd30..000000000 --- a/spec/token-iterator-spec.coffee +++ /dev/null @@ -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'] diff --git a/spec/token-iterator-spec.js b/spec/token-iterator-spec.js new file mode 100644 index 000000000..f6d43395c --- /dev/null +++ b/spec/token-iterator-spec.js @@ -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']) + }) +) diff --git a/spec/tokenized-buffer-spec.js b/spec/tokenized-buffer-spec.js index ba43f9ff3..b1574673a 100644 --- a/spec/tokenized-buffer-spec.js +++ b/spec/tokenized-buffer-spec.js @@ -643,186 +643,6 @@ describe('TokenizedBuffer', () => { }) }) - describe('.toggleLineCommentsForBufferRows', () => { - describe('xml', () => { - beforeEach(async () => { - await atom.packages.activatePackage('language-xml') - buffer = new TextBuffer('') - 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') diff --git a/spec/tooltip-manager-spec.coffee b/spec/tooltip-manager-spec.coffee deleted file mode 100644 index 95182853e..000000000 --- a/spec/tooltip-manager-spec.coffee +++ /dev/null @@ -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() diff --git a/spec/tooltip-manager-spec.js b/spec/tooltip-manager-spec.js new file mode 100644 index 000000000..3a6b56a1b --- /dev/null +++ b/spec/tooltip-manager-spec.js @@ -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})) +} diff --git a/spec/uri-handler-registry-spec.js b/spec/uri-handler-registry-spec.js new file mode 100644 index 000000000..d2da93087 --- /dev/null +++ b/spec/uri-handler-registry-spec.js @@ -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() + }) + }) +}) diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee deleted file mode 100644 index 4bae1d811..000000000 --- a/spec/view-registry-spec.coffee +++ /dev/null @@ -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 diff --git a/spec/view-registry-spec.js b/spec/view-registry-spec.js new file mode 100644 index 000000000..db8b077f1 --- /dev/null +++ b/spec/view-registry-spec.js @@ -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 }) + }) + }) + ) +}) diff --git a/spec/window-event-handler-spec.coffee b/spec/window-event-handler-spec.coffee deleted file mode 100644 index 9c9f4a098..000000000 --- a/spec/window-event-handler-spec.coffee +++ /dev/null @@ -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 = """ -
- - -
- """ - 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 = """ -
- - - - - - - -
- """ - 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() diff --git a/spec/window-event-handler-spec.js b/spec/window-event-handler-spec.js new file mode 100644 index 000000000..a03e168fa --- /dev/null +++ b/spec/window-event-handler-spec.js @@ -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 = ` +
+ + +
+ `.trim() + const elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + document.body.focus() + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + }) + ) + + describe('when a tabindex is set on the currently focused element', () => + it('focuses the element with the next highest/lowest tabindex, skipping disabled elements', () => { + const wrapperDiv = document.createElement('div') + wrapperDiv.innerHTML = ` +
+ + + + + + + +
+ `.trim() + const elements = wrapperDiv.firstChild + jasmine.attachToDOM(elements) + + elements.querySelector('[tabindex="1"]').focus() + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(3) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(5) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + + elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(5) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(3) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(2) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(1) + + elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true})) + expect(document.activeElement.tabIndex).toBe(7) + }) + ) + }) + + describe('when keydown events occur on the document', () => + it('dispatches the event via the KeymapManager and CommandRegistry', () => { + const dispatchedCommands = [] + atom.commands.onWillDispatch(command => dispatchedCommands.push(command)) + atom.commands.add('*', {'foo-command': () => {}}) + atom.keymaps.add('source-name', {'*': {'x': 'foo-command'}}) + + const event = KeymapManager.buildKeydownEvent('x', {target: document.createElement('div')}) + document.dispatchEvent(event) + + expect(dispatchedCommands.length).toBe(1) + expect(dispatchedCommands[0].type).toBe('foo-command') + }) + ) + + describe('native key bindings', () => + it("correctly dispatches them to active elements with the '.native-key-bindings' class", () => { + const webContentsSpy = jasmine.createSpyObj('webContents', ['copy', 'paste']) + spyOn(atom.applicationDelegate, 'getCurrentWindow').andReturn({ + webContents: webContentsSpy, + on: () => {} + }) + + const nativeKeyBindingsInput = document.createElement('input') + nativeKeyBindingsInput.classList.add('native-key-bindings') + jasmine.attachToDOM(nativeKeyBindingsInput) + nativeKeyBindingsInput.focus() + + atom.dispatchApplicationMenuCommand('core:copy') + atom.dispatchApplicationMenuCommand('core:paste') + + expect(webContentsSpy.copy).toHaveBeenCalled() + expect(webContentsSpy.paste).toHaveBeenCalled() + + webContentsSpy.copy.reset() + webContentsSpy.paste.reset() + + const normalInput = document.createElement('input') + jasmine.attachToDOM(normalInput) + normalInput.focus() + + atom.dispatchApplicationMenuCommand('core:copy') + atom.dispatchApplicationMenuCommand('core:paste') + + expect(webContentsSpy.copy).not.toHaveBeenCalled() + expect(webContentsSpy.paste).not.toHaveBeenCalled() + }) + ) +}) diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index 43a04eba9..1bde0e6fe 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -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() diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee index 78ea42087..55c27eb61 100644 --- a/src/application-delegate.coffee +++ b/src/application-delegate.coffee @@ -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) -> diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee deleted file mode 100644 index 8ebad4c4a..000000000 --- a/src/atom-environment.coffee +++ /dev/null @@ -1,1151 +0,0 @@ -crypto = require 'crypto' -path = require 'path' -{ipcRenderer} = require 'electron' - -_ = require 'underscore-plus' -{deprecate} = require 'grim' -{CompositeDisposable, Disposable, Emitter} = require 'event-kit' -fs = require 'fs-plus' -{mapSourcePosition} = require '@atom/source-map-support' -Model = require './model' -WindowEventHandler = require './window-event-handler' -StateStore = require './state-store' -StorageFolder = require './storage-folder' -registerDefaultCommands = require './register-default-commands' -{updateProcessEnv} = require './update-process-env' -ConfigSchema = require './config-schema' - -DeserializerManager = require './deserializer-manager' -ViewRegistry = require './view-registry' -NotificationManager = require './notification-manager' -Config = require './config' -KeymapManager = require './keymap-extensions' -TooltipManager = require './tooltip-manager' -CommandRegistry = require './command-registry' -GrammarRegistry = require './grammar-registry' -{HistoryManager, HistoryProject} = require './history-manager' -ReopenProjectMenuManager = require './reopen-project-menu-manager' -StyleManager = require './style-manager' -PackageManager = require './package-manager' -ThemeManager = require './theme-manager' -MenuManager = require './menu-manager' -ContextMenuManager = require './context-menu-manager' -CommandInstaller = require './command-installer' -Project = require './project' -TitleBar = require './title-bar' -Workspace = require './workspace' -PanelContainer = require './panel-container' -Panel = require './panel' -PaneContainer = require './pane-container' -PaneAxis = require './pane-axis' -Pane = require './pane' -Dock = require './dock' -Project = require './project' -TextEditor = require './text-editor' -TextBuffer = require 'text-buffer' -Gutter = require './gutter' -TextEditorRegistry = require './text-editor-registry' -AutoUpdateManager = require './auto-update-manager' - -# Essential: Atom global for dealing with packages, themes, menus, and the window. -# -# An instance of this class is always available as the `atom` global. -module.exports = -class AtomEnvironment extends Model - @version: 1 # Increment this when the serialization format changes - - lastUncaughtError: null - - ### - Section: Properties - ### - - # Public: A {CommandRegistry} instance - commands: null - - # Public: A {Config} instance - config: null - - # Public: A {Clipboard} instance - clipboard: null - - # Public: A {ContextMenuManager} instance - contextMenu: null - - # Public: A {MenuManager} instance - menu: null - - # Public: A {KeymapManager} instance - keymaps: null - - # Public: A {TooltipManager} instance - tooltips: null - - # Public: A {NotificationManager} instance - notifications: null - - # Public: A {Project} instance - project: null - - # Public: A {GrammarRegistry} instance - grammars: null - - # Public: A {HistoryManager} instance - history: null - - # Public: A {PackageManager} instance - packages: null - - # Public: A {ThemeManager} instance - themes: null - - # Public: A {StyleManager} instance - styles: null - - # Public: A {DeserializerManager} instance - deserializers: null - - # Public: A {ViewRegistry} instance - views: null - - # Public: A {Workspace} instance - workspace: null - - # Public: A {TextEditorRegistry} instance - textEditors: null - - # Private: An {AutoUpdateManager} instance - autoUpdater: null - - saveStateDebounceInterval: 1000 - - ### - Section: Construction and Destruction - ### - - # Call .loadOrCreate instead - constructor: (params={}) -> - {@applicationDelegate, @clipboard, @enablePersistence, onlyLoadBaseStyleSheets, @updateProcessEnv} = params - - @nextProxyRequestId = 0 - @unloaded = false - @loadTime = null - @emitter = new Emitter - @disposables = new CompositeDisposable - @deserializers = new DeserializerManager(this) - @deserializeTimings = {} - @views = new ViewRegistry(this) - TextEditor.setScheduler(@views) - @notifications = new NotificationManager - @updateProcessEnv ?= updateProcessEnv # For testing - - @stateStore = new StateStore('AtomEnvironments', 1) - - @config = new Config({notificationManager: @notifications, @enablePersistence}) - @config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)} - - @keymaps = new KeymapManager({notificationManager: @notifications}) - @tooltips = new TooltipManager(keymapManager: @keymaps, viewRegistry: @views) - @commands = new CommandRegistry - @grammars = new GrammarRegistry({@config}) - @styles = new StyleManager() - @packages = new PackageManager({ - @config, styleManager: @styles, - commandRegistry: @commands, keymapManager: @keymaps, notificationManager: @notifications, - grammarRegistry: @grammars, deserializerManager: @deserializers, viewRegistry: @views - }) - @themes = new ThemeManager({ - packageManager: @packages, @config, styleManager: @styles, - notificationManager: @notifications, viewRegistry: @views - }) - @menu = new MenuManager({keymapManager: @keymaps, packageManager: @packages}) - @contextMenu = new ContextMenuManager({keymapManager: @keymaps}) - @packages.setMenuManager(@menu) - @packages.setContextMenuManager(@contextMenu) - @packages.setThemeManager(@themes) - - @project = new Project({notificationManager: @notifications, packageManager: @packages, @config, @applicationDelegate}) - @commandInstaller = new CommandInstaller(@applicationDelegate) - - @textEditors = new TextEditorRegistry({ - @config, grammarRegistry: @grammars, assert: @assert.bind(this), - packageManager: @packages - }) - - @workspace = new Workspace({ - @config, @project, packageManager: @packages, grammarRegistry: @grammars, deserializerManager: @deserializers, - notificationManager: @notifications, @applicationDelegate, viewRegistry: @views, assert: @assert.bind(this), - textEditorRegistry: @textEditors, styleManager: @styles, @enablePersistence - }) - - @themes.workspace = @workspace - - @autoUpdater = new AutoUpdateManager({@applicationDelegate}) - - if @keymaps.canLoadBundledKeymapsFromMemory() - @keymaps.loadBundledKeymaps() - - @registerDefaultCommands() - @registerDefaultOpeners() - @registerDefaultDeserializers() - - @windowEventHandler = new WindowEventHandler({atomEnvironment: this, @applicationDelegate}) - - @history = new HistoryManager({@project, @commands, @stateStore}) - # Keep instances of HistoryManager in sync - @disposables.add @history.onDidChangeProjects (e) => - @applicationDelegate.didChangeHistoryManager() unless e.reloaded - - initialize: (params={}) -> - # This will force TextEditorElement to register the custom element, so that - # using `document.createElement('atom-text-editor')` works if it's called - # before opening a buffer. - require './text-editor-element' - - {@window, @document, @blobStore, @configDirPath, onlyLoadBaseStyleSheets} = params - {devMode, safeMode, resourcePath, clearWindowState} = @getLoadSettings() - - if clearWindowState - @getStorageFolder().clear() - @stateStore.clear() - - ConfigSchema.projectHome = { - type: 'string', - default: path.join(fs.getHomeDirectory(), 'github'), - description: 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' - } - @config.initialize({@configDirPath, resourcePath, projectHomeSchema: ConfigSchema.projectHome}) - - @menu.initialize({resourcePath}) - @contextMenu.initialize({resourcePath, devMode}) - - @keymaps.configDirPath = @configDirPath - @keymaps.resourcePath = resourcePath - @keymaps.devMode = devMode - unless @keymaps.canLoadBundledKeymapsFromMemory() - @keymaps.loadBundledKeymaps() - - @commands.attach(@window) - - @styles.initialize({@configDirPath}) - @packages.initialize({devMode, @configDirPath, resourcePath, safeMode}) - @themes.initialize({@configDirPath, resourcePath, safeMode, devMode}) - - @commandInstaller.initialize(@getVersion()) - @autoUpdater.initialize() - - @config.load() - - @themes.loadBaseStylesheets() - @initialStyleElements = @styles.getSnapshot() - @themes.initialLoadComplete = true if onlyLoadBaseStyleSheets - @setBodyPlatformClass() - - @stylesElement = @styles.buildStylesElement() - @document.head.appendChild(@stylesElement) - - @keymaps.subscribeToFileReadFailure() - - @installUncaughtErrorHandler() - @attachSaveStateListeners() - @windowEventHandler.initialize(@window, @document) - - didChangeStyles = @didChangeStyles.bind(this) - @disposables.add(@styles.onDidAddStyleElement(didChangeStyles)) - @disposables.add(@styles.onDidUpdateStyleElement(didChangeStyles)) - @disposables.add(@styles.onDidRemoveStyleElement(didChangeStyles)) - - @observeAutoHideMenuBar() - - @disposables.add @applicationDelegate.onDidChangeHistoryManager(=> @history.loadState()) - - preloadPackages: -> - @packages.preloadPackages() - - attachSaveStateListeners: -> - saveState = _.debounce((=> - @window.requestIdleCallback => @saveState({isUnloading: false}) unless @unloaded - ), @saveStateDebounceInterval) - @document.addEventListener('mousedown', saveState, true) - @document.addEventListener('keydown', saveState, true) - @disposables.add new Disposable => - @document.removeEventListener('mousedown', saveState, true) - @document.removeEventListener('keydown', saveState, true) - - registerDefaultDeserializers: -> - @deserializers.add(Workspace) - @deserializers.add(PaneContainer) - @deserializers.add(PaneAxis) - @deserializers.add(Pane) - @deserializers.add(Dock) - @deserializers.add(Project) - @deserializers.add(TextEditor) - @deserializers.add(TextBuffer) - - registerDefaultCommands: -> - registerDefaultCommands({commandRegistry: @commands, @config, @commandInstaller, notificationManager: @notifications, @project, @clipboard}) - - registerDefaultOpeners: -> - @workspace.addOpener (uri) => - switch uri - when 'atom://.atom/stylesheet' - @workspace.openTextFile(@styles.getUserStyleSheetPath()) - when 'atom://.atom/keymap' - @workspace.openTextFile(@keymaps.getUserKeymapPath()) - when 'atom://.atom/config' - @workspace.openTextFile(@config.getUserConfigPath()) - when 'atom://.atom/init-script' - @workspace.openTextFile(@getUserInitScriptPath()) - - registerDefaultTargetForKeymaps: -> - @keymaps.defaultTarget = @workspace.getElement() - - observeAutoHideMenuBar: -> - @disposables.add @config.onDidChange 'core.autoHideMenuBar', ({newValue}) => - @setAutoHideMenuBar(newValue) - @setAutoHideMenuBar(true) if @config.get('core.autoHideMenuBar') - - reset: -> - @deserializers.clear() - @registerDefaultDeserializers() - - @config.clear() - @config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)} - - @keymaps.clear() - @keymaps.loadBundledKeymaps() - - @commands.clear() - @registerDefaultCommands() - - @styles.restoreSnapshot(@initialStyleElements) - - @menu.clear() - - @clipboard.reset() - - @notifications.clear() - - @contextMenu.clear() - - @packages.reset().then => - @workspace.reset(@packages) - @registerDefaultOpeners() - @project.reset(@packages) - @workspace.subscribeToEvents() - @grammars.clear() - @textEditors.clear() - @views.clear() - - destroy: -> - return if not @project - - @disposables.dispose() - @workspace?.destroy() - @workspace = null - @themes.workspace = null - @project?.destroy() - @project = null - @commands.clear() - @stylesElement.remove() - @config.unobserveUserConfig() - @autoUpdater.destroy() - - @uninstallWindowEventHandler() - - ### - Section: Event Subscription - ### - - # Extended: Invoke the given callback whenever {::beep} is called. - # - # * `callback` {Function} to be called whenever {::beep} is called. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidBeep: (callback) -> - @emitter.on 'did-beep', callback - - # Extended: Invoke the given callback when there is an unhandled error, but - # before the devtools pop open - # - # * `callback` {Function} to be called whenever there is an unhandled error - # * `event` {Object} - # * `originalError` {Object} the original error object - # * `message` {String} the original error object - # * `url` {String} Url to the file where the error originated. - # * `line` {Number} - # * `column` {Number} - # * `preventDefault` {Function} call this to avoid popping up the dev tools. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillThrowError: (callback) -> - @emitter.on 'will-throw-error', callback - - # Extended: Invoke the given callback whenever there is an unhandled error. - # - # * `callback` {Function} to be called whenever there is an unhandled error - # * `event` {Object} - # * `originalError` {Object} the original error object - # * `message` {String} the original error object - # * `url` {String} Url to the file where the error originated. - # * `line` {Number} - # * `column` {Number} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidThrowError: (callback) -> - @emitter.on 'did-throw-error', callback - - # TODO: Make this part of the public API. We should make onDidThrowError - # match the interface by only yielding an exception object to the handler - # and deprecating the old behavior. - onDidFailAssertion: (callback) -> - @emitter.on 'did-fail-assertion', callback - - # Extended: Invoke the given callback as soon as the shell environment is - # loaded (or immediately if it was already loaded). - # - # * `callback` {Function} to be called whenever there is an unhandled error - whenShellEnvironmentLoaded: (callback) -> - if @shellEnvironmentLoaded - callback() - new Disposable() - else - @emitter.once 'loaded-shell-environment', callback - - ### - Section: Atom Details - ### - - # Public: Returns a {Boolean} that is `true` if the current window is in development mode. - inDevMode: -> - @devMode ?= @getLoadSettings().devMode - - # Public: Returns a {Boolean} that is `true` if the current window is in safe mode. - inSafeMode: -> - @safeMode ?= @getLoadSettings().safeMode - - # Public: Returns a {Boolean} that is `true` if the current window is running specs. - inSpecMode: -> - @specMode ?= @getLoadSettings().isSpec - - # Returns a {Boolean} indicating whether this the first time the window's been - # loaded. - isFirstLoad: -> - @firstLoad ?= @getLoadSettings().firstLoad - - # Public: Get the version of the Atom application. - # - # Returns the version text {String}. - getVersion: -> - @appVersion ?= @getLoadSettings().appVersion - - # Public: Gets the release channel of the Atom application. - # - # Returns the release channel as a {String}. Will return one of `dev`, `beta`, or `stable`. - getReleaseChannel: -> - version = @getVersion() - if version.indexOf('beta') > -1 - 'beta' - else if version.indexOf('dev') > -1 - 'dev' - else - 'stable' - - # Public: Returns a {Boolean} that is `true` if the current version is an official release. - isReleasedVersion: -> - not /\w{7}/.test(@getVersion()) # Check if the release is a 7-character SHA prefix - - # Public: Get the time taken to completely load the current window. - # - # This time include things like loading and activating packages, creating - # DOM elements for the editor, and reading the config. - # - # Returns the {Number} of milliseconds taken to load the window or null - # if the window hasn't finished loading yet. - getWindowLoadTime: -> - @loadTime - - # Public: Get the load settings for the current window. - # - # Returns an {Object} containing all the load setting key/value pairs. - getLoadSettings: -> - @applicationDelegate.getWindowLoadSettings() - - ### - Section: Managing The Atom Window - ### - - # Essential: Open a new Atom window using the given options. - # - # Calling this method without an options parameter will open a prompt to pick - # a file/folder to open in the new window. - # - # * `params` An {Object} with the following keys: - # * `pathsToOpen` An {Array} of {String} paths to open. - # * `newWindow` A {Boolean}, true to always open a new window instead of - # reusing existing windows depending on the paths to open. - # * `devMode` A {Boolean}, true to open the window in development mode. - # Development mode loads the Atom source from the locally cloned - # repository and also loads all the packages in ~/.atom/dev/packages - # * `safeMode` A {Boolean}, true to open the window in safe mode. Safe - # mode prevents all packages installed to ~/.atom/packages from loading. - open: (params) -> - @applicationDelegate.open(params) - - # Extended: Prompt the user to select one or more folders. - # - # * `callback` A {Function} to call once the user has confirmed the selection. - # * `paths` An {Array} of {String} paths that the user selected, or `null` - # if the user dismissed the dialog. - pickFolder: (callback) -> - @applicationDelegate.pickFolder(callback) - - # Essential: Close the current window. - close: -> - @applicationDelegate.closeWindow() - - # Essential: Get the size of current window. - # - # Returns an {Object} in the format `{width: 1000, height: 700}` - getSize: -> - @applicationDelegate.getWindowSize() - - # Essential: Set the size of current window. - # - # * `width` The {Number} of pixels. - # * `height` The {Number} of pixels. - setSize: (width, height) -> - @applicationDelegate.setWindowSize(width, height) - - # Essential: Get the position of current window. - # - # Returns an {Object} in the format `{x: 10, y: 20}` - getPosition: -> - @applicationDelegate.getWindowPosition() - - # Essential: Set the position of current window. - # - # * `x` The {Number} of pixels. - # * `y` The {Number} of pixels. - setPosition: (x, y) -> - @applicationDelegate.setWindowPosition(x, y) - - # Extended: Get the current window - getCurrentWindow: -> - @applicationDelegate.getCurrentWindow() - - # Extended: Move current window to the center of the screen. - center: -> - @applicationDelegate.centerWindow() - - # Extended: Focus the current window. - focus: -> - @applicationDelegate.focusWindow() - @window.focus() - - # Extended: Show the current window. - show: -> - @applicationDelegate.showWindow() - - # Extended: Hide the current window. - hide: -> - @applicationDelegate.hideWindow() - - # Extended: Reload the current window. - reload: -> - @applicationDelegate.reloadWindow() - - # Extended: Relaunch the entire application. - restartApplication: -> - @applicationDelegate.restartApplication() - - # Extended: Returns a {Boolean} that is `true` if the current window is maximized. - isMaximized: -> - @applicationDelegate.isWindowMaximized() - - maximize: -> - @applicationDelegate.maximizeWindow() - - # Extended: Returns a {Boolean} that is `true` if the current window is in full screen mode. - isFullScreen: -> - @applicationDelegate.isWindowFullScreen() - - # Extended: Set the full screen state of the current window. - setFullScreen: (fullScreen=false) -> - @applicationDelegate.setWindowFullScreen(fullScreen) - - # Extended: Toggle the full screen state of the current window. - toggleFullScreen: -> - @setFullScreen(not @isFullScreen()) - - # Restore the window to its previous dimensions and show it. - # - # Restores the full screen and maximized state after the window has resized to - # prevent resize glitches. - displayWindow: -> - @restoreWindowDimensions().then => - steps = [ - @restoreWindowBackground(), - @show(), - @focus() - ] - steps.push(@setFullScreen(true)) if @windowDimensions?.fullScreen - steps.push(@maximize()) if @windowDimensions?.maximized and process.platform isnt 'darwin' - Promise.all(steps) - - # Get the dimensions of this window. - # - # Returns an {Object} with the following keys: - # * `x` The window's x-position {Number}. - # * `y` The window's y-position {Number}. - # * `width` The window's width {Number}. - # * `height` The window's height {Number}. - getWindowDimensions: -> - browserWindow = @getCurrentWindow() - [x, y] = browserWindow.getPosition() - [width, height] = browserWindow.getSize() - maximized = browserWindow.isMaximized() - {x, y, width, height, maximized} - - # Set the dimensions of the window. - # - # The window will be centered if either the x or y coordinate is not set - # in the dimensions parameter. If x or y are omitted the window will be - # centered. If height or width are omitted only the position will be changed. - # - # * `dimensions` An {Object} with the following keys: - # * `x` The new x coordinate. - # * `y` The new y coordinate. - # * `width` The new width. - # * `height` The new height. - setWindowDimensions: ({x, y, width, height}) -> - steps = [] - if width? and height? - steps.push(@setSize(width, height)) - if x? and y? - steps.push(@setPosition(x, y)) - else - steps.push(@center()) - Promise.all(steps) - - # Returns true if the dimensions are useable, false if they should be ignored. - # Work around for https://github.com/atom/atom-shell/issues/473 - isValidDimensions: ({x, y, width, height}={}) -> - width > 0 and height > 0 and x + width > 0 and y + height > 0 - - storeWindowDimensions: -> - @windowDimensions = @getWindowDimensions() - if @isValidDimensions(@windowDimensions) - localStorage.setItem("defaultWindowDimensions", JSON.stringify(@windowDimensions)) - - getDefaultWindowDimensions: -> - {windowDimensions} = @getLoadSettings() - return windowDimensions if windowDimensions? - - dimensions = null - try - dimensions = JSON.parse(localStorage.getItem("defaultWindowDimensions")) - catch error - console.warn "Error parsing default window dimensions", error - localStorage.removeItem("defaultWindowDimensions") - - if @isValidDimensions(dimensions) - dimensions - else - {width, height} = @applicationDelegate.getPrimaryDisplayWorkAreaSize() - {x: 0, y: 0, width: Math.min(1024, width), height} - - restoreWindowDimensions: -> - unless @windowDimensions? and @isValidDimensions(@windowDimensions) - @windowDimensions = @getDefaultWindowDimensions() - @setWindowDimensions(@windowDimensions).then => @windowDimensions - - restoreWindowBackground: -> - if backgroundColor = window.localStorage.getItem('atom:window-background-color') - @backgroundStylesheet = document.createElement('style') - @backgroundStylesheet.type = 'text/css' - @backgroundStylesheet.innerText = 'html, body { background: ' + backgroundColor + ' !important; }' - document.head.appendChild(@backgroundStylesheet) - - storeWindowBackground: -> - return if @inSpecMode() - - backgroundColor = @window.getComputedStyle(@workspace.getElement())['background-color'] - @window.localStorage.setItem('atom:window-background-color', backgroundColor) - - # Call this method when establishing a real application window. - startEditorWindow: -> - @unloaded = false - - updateProcessEnvPromise = @updateProcessEnvAndTriggerHooks() - - loadStatePromise = @loadState().then (state) => - @windowDimensions = state?.windowDimensions - @displayWindow().then => - @commandInstaller.installAtomCommand false, (error) -> - console.warn error.message if error? - @commandInstaller.installApmCommand false, (error) -> - console.warn error.message if error? - - @disposables.add(@applicationDelegate.onDidOpenLocations(@openLocations.bind(this))) - @disposables.add(@applicationDelegate.onApplicationMenuCommand(@dispatchApplicationMenuCommand.bind(this))) - @disposables.add(@applicationDelegate.onContextMenuCommand(@dispatchContextMenuCommand.bind(this))) - @disposables.add @applicationDelegate.onDidRequestUnload => - @saveState({isUnloading: true}) - .catch(console.error) - .then => - @workspace?.confirmClose({ - windowCloseRequested: true, - projectHasPaths: @project.getPaths().length > 0 - }) - .then (closing) => - if closing - @packages.deactivatePackages().then -> closing - else - closing - - @listenForUpdates() - - @registerDefaultTargetForKeymaps() - - @packages.loadPackages() - - startTime = Date.now() - @deserialize(state).then => - @deserializeTimings.atom = Date.now() - startTime - - if process.platform is 'darwin' and @config.get('core.titleBar') is 'custom' - @workspace.addHeaderPanel({item: new TitleBar({@workspace, @themes, @applicationDelegate})}) - @document.body.classList.add('custom-title-bar') - if process.platform is 'darwin' and @config.get('core.titleBar') is 'custom-inset' - @workspace.addHeaderPanel({item: new TitleBar({@workspace, @themes, @applicationDelegate})}) - @document.body.classList.add('custom-inset-title-bar') - if process.platform is 'darwin' and @config.get('core.titleBar') is 'hidden' - @document.body.classList.add('hidden-title-bar') - - @document.body.appendChild(@workspace.getElement()) - @backgroundStylesheet?.remove() - - @watchProjectPaths() - - @packages.activate() - @keymaps.loadUserKeymap() - @requireUserInitScript() unless @getLoadSettings().safeMode - - @menu.update() - - @openInitialEmptyEditorIfNecessary() - - loadHistoryPromise = @history.loadState().then => - @reopenProjectMenuManager = new ReopenProjectMenuManager({ - @menu, @commands, @history, @config, - open: (paths) => @open(pathsToOpen: paths) - }) - @reopenProjectMenuManager.update() - - Promise.all([loadStatePromise, loadHistoryPromise, updateProcessEnvPromise]) - - serialize: (options) -> - version: @constructor.version - project: @project.serialize(options) - workspace: @workspace.serialize() - packageStates: @packages.serialize() - grammars: {grammarOverridesByPath: @grammars.grammarOverridesByPath} - fullScreen: @isFullScreen() - windowDimensions: @windowDimensions - textEditors: @textEditors.serialize() - - unloadEditorWindow: -> - return if not @project - - @storeWindowBackground() - @saveBlobStoreSync() - @unloaded = true - - saveBlobStoreSync: -> - if @enablePersistence - @blobStore.save() - - openInitialEmptyEditorIfNecessary: -> - return unless @config.get('core.openEmptyEditorOnStart') - if @getLoadSettings().initialPaths?.length is 0 and @workspace.getPaneItems().length is 0 - @workspace.open(null) - - installUncaughtErrorHandler: -> - @previousWindowErrorHandler = @window.onerror - @window.onerror = => - @lastUncaughtError = Array::slice.call(arguments) - [message, url, line, column, originalError] = @lastUncaughtError - - {line, column, source} = mapSourcePosition({source: url, line, column}) - - if url is '' - url = source - - eventObject = {message, url, line, column, originalError} - - openDevTools = true - eventObject.preventDefault = -> openDevTools = false - - @emitter.emit 'will-throw-error', eventObject - - if openDevTools - @openDevTools().then => @executeJavaScriptInDevTools('DevToolsAPI.showPanel("console")') - - @emitter.emit 'did-throw-error', {message, url, line, column, originalError} - - uninstallUncaughtErrorHandler: -> - @window.onerror = @previousWindowErrorHandler - - installWindowEventHandler: -> - @windowEventHandler = new WindowEventHandler({atomEnvironment: this, @applicationDelegate}) - @windowEventHandler.initialize(@window, @document) - - uninstallWindowEventHandler: -> - @windowEventHandler?.unsubscribe() - @windowEventHandler = null - - didChangeStyles: (styleElement) -> - TextEditor.didUpdateStyles() - if styleElement.textContent.indexOf('scrollbar') >= 0 - TextEditor.didUpdateScrollbarStyles() - - updateProcessEnvAndTriggerHooks: -> - @updateProcessEnv(@getLoadSettings().env).then => - @shellEnvironmentLoaded = true - @emitter.emit('loaded-shell-environment') - @packages.triggerActivationHook('core:loaded-shell-environment') - - ### - Section: Messaging the User - ### - - # Essential: Visually and audibly trigger a beep. - beep: -> - @applicationDelegate.playBeepSound() if @config.get('core.audioBeep') - @emitter.emit 'did-beep' - - # Essential: A flexible way to open a dialog akin to an alert dialog. - # - # If the dialog is closed (via `Esc` key or `X` in the top corner) without selecting a button - # the first button will be clicked unless a "Cancel" or "No" button is provided. - # - # ## Examples - # - # ```coffee - # atom.confirm - # message: 'How you feeling?' - # detailedMessage: 'Be honest.' - # buttons: - # Good: -> window.alert('good to hear') - # Bad: -> window.alert('bummer') - # ``` - # - # * `options` An {Object} with the following keys: - # * `message` The {String} message to display. - # * `detailedMessage` (optional) The {String} detailed message to display. - # * `buttons` (optional) Either an array of strings or an object where keys are - # button names and the values are callbacks to invoke when clicked. - # - # Returns the chosen button index {Number} if the buttons option is an array or the return value of the callback if the buttons option is an object. - confirm: (params={}) -> - @applicationDelegate.confirm(params) - - ### - Section: Managing the Dev Tools - ### - - # Extended: Open the dev tools for the current window. - # - # Returns a {Promise} that resolves when the DevTools have been opened. - openDevTools: -> - @applicationDelegate.openWindowDevTools() - - # Extended: Toggle the visibility of the dev tools for the current window. - # - # Returns a {Promise} that resolves when the DevTools have been opened or - # closed. - toggleDevTools: -> - @applicationDelegate.toggleWindowDevTools() - - # Extended: Execute code in dev tools. - executeJavaScriptInDevTools: (code) -> - @applicationDelegate.executeJavaScriptInWindowDevTools(code) - - ### - Section: Private - ### - - assert: (condition, message, callbackOrMetadata) -> - return true if condition - - error = new Error("Assertion failed: #{message}") - Error.captureStackTrace(error, @assert) - - if callbackOrMetadata? - if typeof callbackOrMetadata is 'function' - callbackOrMetadata?(error) - else - error.metadata = callbackOrMetadata - - @emitter.emit 'did-fail-assertion', error - unless @isReleasedVersion() - throw error - - false - - loadThemes: -> - @themes.load() - - # Notify the browser project of the window's current project path - watchProjectPaths: -> - @disposables.add @project.onDidChangePaths => - @applicationDelegate.setRepresentedDirectoryPaths(@project.getPaths()) - - setDocumentEdited: (edited) -> - @applicationDelegate.setWindowDocumentEdited?(edited) - - setRepresentedFilename: (filename) -> - @applicationDelegate.setWindowRepresentedFilename?(filename) - - addProjectFolder: -> - @pickFolder (selectedPaths = []) => - @addToProject(selectedPaths) - - addToProject: (projectPaths) -> - @loadState(@getStateKey(projectPaths)).then (state) => - if state and @project.getPaths().length is 0 - @attemptRestoreProjectStateForPaths(state, projectPaths) - else - @project.addPath(folder) for folder in projectPaths - - attemptRestoreProjectStateForPaths: (state, projectPaths, filesToOpen = []) -> - center = @workspace.getCenter() - windowIsUnused = => - for container in @workspace.getPaneContainers() - for item in container.getPaneItems() - if item instanceof TextEditor - return false if item.getPath() or item.isModified() - else - return false if container is center - true - - if windowIsUnused() - @restoreStateIntoThisEnvironment(state) - Promise.all (@workspace.open(file) for file in filesToOpen) - else - nouns = if projectPaths.length is 1 then 'folder' else 'folders' - btn = @confirm - message: 'Previous automatically-saved project state detected' - detailedMessage: "There is previously saved state for the selected #{nouns}. " + - "Would you like to add the #{nouns} to this window, permanently discarding the saved state, " + - "or open the #{nouns} in a new window, restoring the saved state?" - buttons: [ - '&Open in new window and recover state' - '&Add to this window and discard state' - ] - if btn is 0 - @open - pathsToOpen: projectPaths.concat(filesToOpen) - newWindow: true - devMode: @inDevMode() - safeMode: @inSafeMode() - Promise.resolve(null) - else if btn is 1 - @project.addPath(selectedPath) for selectedPath in projectPaths - Promise.all (@workspace.open(file) for file in filesToOpen) - - restoreStateIntoThisEnvironment: (state) -> - state.fullScreen = @isFullScreen() - pane.destroy() for pane in @workspace.getPanes() - @deserialize(state) - - showSaveDialog: (callback) -> - callback(@showSaveDialogSync()) - - showSaveDialogSync: (options={}) -> - @applicationDelegate.showSaveDialog(options) - - saveState: (options, storageKey) -> - new Promise (resolve, reject) => - if @enablePersistence and @project - state = @serialize(options) - savePromise = - if storageKey ?= @getStateKey(@project?.getPaths()) - @stateStore.save(storageKey, state) - else - @applicationDelegate.setTemporaryWindowState(state) - savePromise.catch(reject).then(resolve) - else - resolve() - - loadState: (stateKey) -> - if @enablePersistence - if stateKey ?= @getStateKey(@getLoadSettings().initialPaths) - @stateStore.load(stateKey).then (state) => - if state - state - else - # TODO: remove this when every user has migrated to the IndexedDb state store. - @getStorageFolder().load(stateKey) - else - @applicationDelegate.getTemporaryWindowState() - else - Promise.resolve(null) - - deserialize: (state) -> - return Promise.resolve() unless state? - - if grammarOverridesByPath = state.grammars?.grammarOverridesByPath - @grammars.grammarOverridesByPath = grammarOverridesByPath - - @setFullScreen(state.fullScreen) - - missingProjectPaths = [] - - @packages.packageStates = state.packageStates ? {} - - startTime = Date.now() - if state.project? - projectPromise = @project.deserialize(state.project, @deserializers) - .catch (err) => - if err.missingProjectPaths? - missingProjectPaths.push(err.missingProjectPaths...) - else - @notifications.addError "Unable to deserialize project", description: err.message, stack: err.stack - else - projectPromise = Promise.resolve() - - projectPromise.then => - @deserializeTimings.project = Date.now() - startTime - - @textEditors.deserialize(state.textEditors) if state.textEditors - - startTime = Date.now() - @workspace.deserialize(state.workspace, @deserializers) if state.workspace? - @deserializeTimings.workspace = Date.now() - startTime - - if missingProjectPaths.length > 0 - count = if missingProjectPaths.length is 1 then '' else missingProjectPaths.length + ' ' - noun = if missingProjectPaths.length is 1 then 'directory' else 'directories' - toBe = if missingProjectPaths.length is 1 then 'is' else 'are' - escaped = missingProjectPaths.map (projectPath) -> "`#{projectPath}`" - group = switch escaped.length - when 1 then escaped[0] - when 2 then "#{escaped[0]} and #{escaped[1]}" - else escaped[..-2].join(", ") + ", and #{escaped[escaped.length - 1]}" - - @notifications.addError "Unable to open #{count}project #{noun}", - description: "Project #{noun} #{group} #{toBe} no longer on disk." - - getStateKey: (paths) -> - if paths?.length > 0 - sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex') - "editor-#{sha1}" - else - null - - getStorageFolder: -> - @storageFolder ?= new StorageFolder(@getConfigDirPath()) - - getConfigDirPath: -> - @configDirPath ?= process.env.ATOM_HOME - - getUserInitScriptPath: -> - initScriptPath = fs.resolve(@getConfigDirPath(), 'init', ['js', 'coffee']) - initScriptPath ? path.join(@getConfigDirPath(), 'init.coffee') - - requireUserInitScript: -> - if userInitScriptPath = @getUserInitScriptPath() - try - require(userInitScriptPath) if fs.isFileSync(userInitScriptPath) - catch error - @notifications.addError "Failed to load `#{userInitScriptPath}`", - detail: error.message - dismissable: true - - # TODO: We should deprecate the update events here, and use `atom.autoUpdater` instead - onUpdateAvailable: (callback) -> - @emitter.on 'update-available', callback - - updateAvailable: (details) -> - @emitter.emit 'update-available', details - - listenForUpdates: -> - # listen for updates available locally (that have been successfully downloaded) - @disposables.add(@autoUpdater.onDidCompleteDownloadingUpdate(@updateAvailable.bind(this))) - - setBodyPlatformClass: -> - @document.body.classList.add("platform-#{process.platform}") - - setAutoHideMenuBar: (autoHide) -> - @applicationDelegate.setAutoHideWindowMenuBar(autoHide) - @applicationDelegate.setWindowMenuBarVisibility(not autoHide) - - dispatchApplicationMenuCommand: (command, arg) -> - activeElement = @document.activeElement - # Use the workspace element if body has focus - if activeElement is @document.body - activeElement = @workspace.getElement() - @commands.dispatch(activeElement, command, arg) - - dispatchContextMenuCommand: (command, args...) -> - @commands.dispatch(@contextMenu.activeElement, command, args) - - openLocations: (locations) -> - needsProjectPaths = @project?.getPaths().length is 0 - - foldersToAddToProject = [] - fileLocationsToOpen = [] - - pushFolderToOpen = (folder) -> - if folder not in foldersToAddToProject - foldersToAddToProject.push(folder) - - for {pathToOpen, initialLine, initialColumn, forceAddToWindow} in locations - if pathToOpen? and (needsProjectPaths or forceAddToWindow) - if fs.existsSync(pathToOpen) - pushFolderToOpen @project.getDirectoryForProjectPath(pathToOpen).getPath() - else if fs.existsSync(path.dirname(pathToOpen)) - pushFolderToOpen @project.getDirectoryForProjectPath(path.dirname(pathToOpen)).getPath() - else - pushFolderToOpen @project.getDirectoryForProjectPath(pathToOpen).getPath() - - unless fs.isDirectorySync(pathToOpen) - fileLocationsToOpen.push({pathToOpen, initialLine, initialColumn}) - - promise = Promise.resolve(null) - if foldersToAddToProject.length > 0 - promise = @loadState(@getStateKey(foldersToAddToProject)).then (state) => - if state and needsProjectPaths # only load state if this is the first path added to the project - files = (location.pathToOpen for location in fileLocationsToOpen) - @attemptRestoreProjectStateForPaths(state, foldersToAddToProject, files) - else - promises = [] - @project.addPath(folder) for folder in foldersToAddToProject - for {pathToOpen, initialLine, initialColumn} in fileLocationsToOpen - promises.push @workspace?.open(pathToOpen, {initialLine, initialColumn}) - Promise.all(promises) - else - promises = [] - for {pathToOpen, initialLine, initialColumn} in fileLocationsToOpen - promises.push @workspace?.open(pathToOpen, {initialLine, initialColumn}) - promise = Promise.all(promises) - - promise.then -> - ipcRenderer.send 'window-command', 'window:locations-opened' - - resolveProxy: (url) -> - return new Promise (resolve, reject) => - requestId = @nextProxyRequestId++ - disposable = @applicationDelegate.onDidResolveProxy (id, proxy) -> - if id is requestId - disposable.dispose() - resolve(proxy) - - @applicationDelegate.resolveProxy(requestId, url) - -# Preserve this deprecation until 2.0. Sorry. Should have removed Q sooner. -Promise.prototype.done = (callback) -> - deprecate("Atom now uses ES6 Promises instead of Q. Call promise.then instead of promise.done") - @then(callback) diff --git a/src/atom-environment.js b/src/atom-environment.js new file mode 100644 index 000000000..663bb6c00 --- /dev/null +++ b/src/atom-environment.js @@ -0,0 +1,1339 @@ +const crypto = require('crypto') +const path = require('path') +const {ipcRenderer} = require('electron') + +const _ = require('underscore-plus') +const {deprecate} = require('grim') +const {CompositeDisposable, Disposable, Emitter} = require('event-kit') +const fs = require('fs-plus') +const {mapSourcePosition} = require('@atom/source-map-support') +const WindowEventHandler = require('./window-event-handler') +const StateStore = require('./state-store') +const StorageFolder = require('./storage-folder') +const registerDefaultCommands = require('./register-default-commands') +const {updateProcessEnv} = require('./update-process-env') +const ConfigSchema = require('./config-schema') + +const DeserializerManager = require('./deserializer-manager') +const ViewRegistry = require('./view-registry') +const NotificationManager = require('./notification-manager') +const Config = require('./config') +const KeymapManager = require('./keymap-extensions') +const TooltipManager = require('./tooltip-manager') +const CommandRegistry = require('./command-registry') +const URIHandlerRegistry = require('./uri-handler-registry') +const GrammarRegistry = require('./grammar-registry') +const {HistoryManager} = require('./history-manager') +const ReopenProjectMenuManager = require('./reopen-project-menu-manager') +const StyleManager = require('./style-manager') +const PackageManager = require('./package-manager') +const ThemeManager = require('./theme-manager') +const MenuManager = require('./menu-manager') +const ContextMenuManager = require('./context-menu-manager') +const CommandInstaller = require('./command-installer') +const CoreURIHandlers = require('./core-uri-handlers') +const ProtocolHandlerInstaller = require('./protocol-handler-installer') +const Project = require('./project') +const TitleBar = require('./title-bar') +const Workspace = require('./workspace') +const PaneContainer = require('./pane-container') +const PaneAxis = require('./pane-axis') +const Pane = require('./pane') +const Dock = require('./dock') +const TextEditor = require('./text-editor') +const TextBuffer = require('text-buffer') +const TextEditorRegistry = require('./text-editor-registry') +const AutoUpdateManager = require('./auto-update-manager') + +let nextId = 0 + +// Essential: Atom global for dealing with packages, themes, menus, and the window. +// +// An instance of this class is always available as the `atom` global. +class AtomEnvironment { + /* + Section: Construction and Destruction + */ + + // Call .loadOrCreate instead + constructor (params = {}) { + this.id = (params.id != null) ? params.id : nextId++ + this.clipboard = params.clipboard + this.updateProcessEnv = params.updateProcessEnv || updateProcessEnv + this.enablePersistence = params.enablePersistence + this.applicationDelegate = params.applicationDelegate + + this.nextProxyRequestId = 0 + this.unloaded = false + this.loadTime = null + this.emitter = new Emitter() + this.disposables = new CompositeDisposable() + this.deserializers = new DeserializerManager(this) + this.deserializeTimings = {} + this.views = new ViewRegistry(this) + TextEditor.setScheduler(this.views) + this.notifications = new NotificationManager() + + this.stateStore = new StateStore('AtomEnvironments', 1) + + this.config = new Config({ + notificationManager: this.notifications, + enablePersistence: this.enablePersistence + }) + this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)}) + + this.keymaps = new KeymapManager({notificationManager: this.notifications}) + this.tooltips = new TooltipManager({keymapManager: this.keymaps, viewRegistry: this.views}) + this.commands = new CommandRegistry() + this.uriHandlerRegistry = new URIHandlerRegistry() + this.grammars = new GrammarRegistry({config: this.config}) + this.styles = new StyleManager() + this.packages = new PackageManager({ + config: this.config, + styleManager: this.styles, + commandRegistry: this.commands, + keymapManager: this.keymaps, + notificationManager: this.notifications, + grammarRegistry: this.grammars, + deserializerManager: this.deserializers, + viewRegistry: this.views, + uriHandlerRegistry: this.uriHandlerRegistry + }) + this.themes = new ThemeManager({ + packageManager: this.packages, + config: this.config, + styleManager: this.styles, + notificationManager: this.notifications, + viewRegistry: this.views + }) + this.menu = new MenuManager({keymapManager: this.keymaps, packageManager: this.packages}) + this.contextMenu = new ContextMenuManager({keymapManager: this.keymaps}) + this.packages.setMenuManager(this.menu) + this.packages.setContextMenuManager(this.contextMenu) + this.packages.setThemeManager(this.themes) + + this.project = new Project({notificationManager: this.notifications, packageManager: this.packages, config: this.config, applicationDelegate: this.applicationDelegate}) + this.commandInstaller = new CommandInstaller(this.applicationDelegate) + this.protocolHandlerInstaller = new ProtocolHandlerInstaller() + + this.textEditors = new TextEditorRegistry({ + config: this.config, + grammarRegistry: this.grammars, + assert: this.assert.bind(this), + packageManager: this.packages + }) + + this.workspace = new Workspace({ + config: this.config, + project: this.project, + packageManager: this.packages, + grammarRegistry: this.grammars, + deserializerManager: this.deserializers, + notificationManager: this.notifications, + applicationDelegate: this.applicationDelegate, + viewRegistry: this.views, + assert: this.assert.bind(this), + textEditorRegistry: this.textEditors, + styleManager: this.styles, + enablePersistence: this.enablePersistence + }) + + this.themes.workspace = this.workspace + + this.autoUpdater = new AutoUpdateManager({applicationDelegate: this.applicationDelegate}) + + if (this.keymaps.canLoadBundledKeymapsFromMemory()) { + this.keymaps.loadBundledKeymaps() + } + + this.registerDefaultCommands() + this.registerDefaultOpeners() + this.registerDefaultDeserializers() + + this.windowEventHandler = new WindowEventHandler({atomEnvironment: this, applicationDelegate: this.applicationDelegate}) + + this.history = new HistoryManager({project: this.project, commands: this.commands, stateStore: this.stateStore}) + // Keep instances of HistoryManager in sync + this.disposables.add(this.history.onDidChangeProjects(event => { + if (!event.reloaded) this.applicationDelegate.didChangeHistoryManager() + })) + } + + initialize (params = {}) { + // This will force TextEditorElement to register the custom element, so that + // using `document.createElement('atom-text-editor')` works if it's called + // before opening a buffer. + require('./text-editor-element') + + this.window = params.window + this.document = params.document + this.blobStore = params.blobStore + this.configDirPath = params.configDirPath + + const {devMode, safeMode, resourcePath, clearWindowState} = this.getLoadSettings() + + if (clearWindowState) { + this.getStorageFolder().clear() + this.stateStore.clear() + } + + ConfigSchema.projectHome = { + type: 'string', + default: path.join(fs.getHomeDirectory(), 'github'), + description: 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' + } + this.config.initialize({configDirPath: this.configDirPath, resourcePath, projectHomeSchema: ConfigSchema.projectHome}) + + this.menu.initialize({resourcePath}) + this.contextMenu.initialize({resourcePath, devMode}) + + this.keymaps.configDirPath = this.configDirPath + this.keymaps.resourcePath = resourcePath + this.keymaps.devMode = devMode + if (!this.keymaps.canLoadBundledKeymapsFromMemory()) { + this.keymaps.loadBundledKeymaps() + } + + this.commands.attach(this.window) + + this.styles.initialize({configDirPath: this.configDirPath}) + this.packages.initialize({devMode, configDirPath: this.configDirPath, resourcePath, safeMode}) + this.themes.initialize({configDirPath: this.configDirPath, resourcePath, safeMode, devMode}) + + this.commandInstaller.initialize(this.getVersion()) + this.protocolHandlerInstaller.initialize(this.config, this.notifications) + this.uriHandlerRegistry.registerHostHandler('core', CoreURIHandlers.create(this)) + this.autoUpdater.initialize() + + this.config.load() + + this.themes.loadBaseStylesheets() + this.initialStyleElements = this.styles.getSnapshot() + if (params.onlyLoadBaseStyleSheets) this.themes.initialLoadComplete = true + this.setBodyPlatformClass() + + this.stylesElement = this.styles.buildStylesElement() + this.document.head.appendChild(this.stylesElement) + + this.keymaps.subscribeToFileReadFailure() + + this.installUncaughtErrorHandler() + this.attachSaveStateListeners() + this.windowEventHandler.initialize(this.window, this.document) + + const didChangeStyles = this.didChangeStyles.bind(this) + this.disposables.add(this.styles.onDidAddStyleElement(didChangeStyles)) + this.disposables.add(this.styles.onDidUpdateStyleElement(didChangeStyles)) + this.disposables.add(this.styles.onDidRemoveStyleElement(didChangeStyles)) + + this.observeAutoHideMenuBar() + + this.disposables.add(this.applicationDelegate.onDidChangeHistoryManager(() => this.history.loadState())) + } + + preloadPackages () { + return this.packages.preloadPackages() + } + + attachSaveStateListeners () { + const saveState = _.debounce(() => { + this.window.requestIdleCallback(() => { + if (!this.unloaded) this.saveState({isUnloading: false}) + }) + }, this.saveStateDebounceInterval) + this.document.addEventListener('mousedown', saveState, true) + this.document.addEventListener('keydown', saveState, true) + this.disposables.add(new Disposable(() => { + this.document.removeEventListener('mousedown', saveState, true) + this.document.removeEventListener('keydown', saveState, true) + })) + } + + registerDefaultDeserializers () { + this.deserializers.add(Workspace) + this.deserializers.add(PaneContainer) + this.deserializers.add(PaneAxis) + this.deserializers.add(Pane) + this.deserializers.add(Dock) + this.deserializers.add(Project) + this.deserializers.add(TextEditor) + this.deserializers.add(TextBuffer) + } + + registerDefaultCommands () { + registerDefaultCommands({commandRegistry: this.commands, config: this.config, commandInstaller: this.commandInstaller, notificationManager: this.notifications, project: this.project, clipboard: this.clipboard}) + } + + registerDefaultOpeners () { + this.workspace.addOpener(uri => { + switch (uri) { + case 'atom://.atom/stylesheet': + return this.workspace.openTextFile(this.styles.getUserStyleSheetPath()) + case 'atom://.atom/keymap': + return this.workspace.openTextFile(this.keymaps.getUserKeymapPath()) + case 'atom://.atom/config': + return this.workspace.openTextFile(this.config.getUserConfigPath()) + case 'atom://.atom/init-script': + return this.workspace.openTextFile(this.getUserInitScriptPath()) + } + }) + } + + registerDefaultTargetForKeymaps () { + this.keymaps.defaultTarget = this.workspace.getElement() + } + + observeAutoHideMenuBar () { + this.disposables.add(this.config.onDidChange('core.autoHideMenuBar', ({newValue}) => { + this.setAutoHideMenuBar(newValue) + })) + if (this.config.get('core.autoHideMenuBar')) this.setAutoHideMenuBar(true) + } + + async reset () { + this.deserializers.clear() + this.registerDefaultDeserializers() + + this.config.clear() + this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)}) + + this.keymaps.clear() + this.keymaps.loadBundledKeymaps() + + this.commands.clear() + this.registerDefaultCommands() + + this.styles.restoreSnapshot(this.initialStyleElements) + + this.menu.clear() + + this.clipboard.reset() + + this.notifications.clear() + + this.contextMenu.clear() + + await this.packages.reset() + this.workspace.reset(this.packages) + this.registerDefaultOpeners() + this.project.reset(this.packages) + this.workspace.subscribeToEvents() + this.grammars.clear() + this.textEditors.clear() + this.views.clear() + } + + destroy () { + if (!this.project) return + + this.disposables.dispose() + if (this.workspace) this.workspace.destroy() + this.workspace = null + this.themes.workspace = null + if (this.project) this.project.destroy() + this.project = null + this.commands.clear() + this.stylesElement.remove() + this.config.unobserveUserConfig() + this.autoUpdater.destroy() + this.uriHandlerRegistry.destroy() + + this.uninstallWindowEventHandler() + } + + /* + Section: Event Subscription + */ + + // Extended: Invoke the given callback whenever {::beep} is called. + // + // * `callback` {Function} to be called whenever {::beep} is called. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidBeep (callback) { + return this.emitter.on('did-beep', callback) + } + + // Extended: Invoke the given callback when there is an unhandled error, but + // before the devtools pop open + // + // * `callback` {Function} to be called whenever there is an unhandled error + // * `event` {Object} + // * `originalError` {Object} the original error object + // * `message` {String} the original error object + // * `url` {String} Url to the file where the error originated. + // * `line` {Number} + // * `column` {Number} + // * `preventDefault` {Function} call this to avoid popping up the dev tools. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillThrowError (callback) { + return this.emitter.on('will-throw-error', callback) + } + + // Extended: Invoke the given callback whenever there is an unhandled error. + // + // * `callback` {Function} to be called whenever there is an unhandled error + // * `event` {Object} + // * `originalError` {Object} the original error object + // * `message` {String} the original error object + // * `url` {String} Url to the file where the error originated. + // * `line` {Number} + // * `column` {Number} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidThrowError (callback) { + return this.emitter.on('did-throw-error', callback) + } + + // TODO: Make this part of the public API. We should make onDidThrowError + // match the interface by only yielding an exception object to the handler + // and deprecating the old behavior. + onDidFailAssertion (callback) { + return this.emitter.on('did-fail-assertion', callback) + } + + // Extended: Invoke the given callback as soon as the shell environment is + // loaded (or immediately if it was already loaded). + // + // * `callback` {Function} to be called whenever there is an unhandled error + whenShellEnvironmentLoaded (callback) { + if (this.shellEnvironmentLoaded) { + callback() + return new Disposable() + } else { + return this.emitter.once('loaded-shell-environment', callback) + } + } + + /* + Section: Atom Details + */ + + // Public: Returns a {Boolean} that is `true` if the current window is in development mode. + inDevMode () { + if (this.devMode == null) this.devMode = this.getLoadSettings().devMode + return this.devMode + } + + // Public: Returns a {Boolean} that is `true` if the current window is in safe mode. + inSafeMode () { + if (this.safeMode == null) this.safeMode = this.getLoadSettings().safeMode + return this.safeMode + } + + // Public: Returns a {Boolean} that is `true` if the current window is running specs. + inSpecMode () { + if (this.specMode == null) this.specMode = this.getLoadSettings().isSpec + return this.specMode + } + + // Returns a {Boolean} indicating whether this the first time the window's been + // loaded. + isFirstLoad () { + if (this.firstLoad == null) this.firstLoad = this.getLoadSettings().firstLoad + return this.firstLoad + } + + // Public: Get the version of the Atom application. + // + // Returns the version text {String}. + getVersion () { + if (this.appVersion == null) this.appVersion = this.getLoadSettings().appVersion + return this.appVersion + } + + // Public: Gets the release channel of the Atom application. + // + // Returns the release channel as a {String}. Will return one of `dev`, `beta`, or `stable`. + getReleaseChannel () { + const version = this.getVersion() + if (version.includes('beta')) { + return 'beta' + } else if (version.includes('dev')) { + return 'dev' + } else { + return 'stable' + } + } + + // Public: Returns a {Boolean} that is `true` if the current version is an official release. + isReleasedVersion () { + return !/\w{7}/.test(this.getVersion()) // Check if the release is a 7-character SHA prefix + } + + // Public: Get the time taken to completely load the current window. + // + // This time include things like loading and activating packages, creating + // DOM elements for the editor, and reading the config. + // + // Returns the {Number} of milliseconds taken to load the window or null + // if the window hasn't finished loading yet. + getWindowLoadTime () { + return this.loadTime + } + + // Public: Get the load settings for the current window. + // + // Returns an {Object} containing all the load setting key/value pairs. + getLoadSettings () { + return this.applicationDelegate.getWindowLoadSettings() + } + + /* + Section: Managing The Atom Window + */ + + // Essential: Open a new Atom window using the given options. + // + // Calling this method without an options parameter will open a prompt to pick + // a file/folder to open in the new window. + // + // * `params` An {Object} with the following keys: + // * `pathsToOpen` An {Array} of {String} paths to open. + // * `newWindow` A {Boolean}, true to always open a new window instead of + // reusing existing windows depending on the paths to open. + // * `devMode` A {Boolean}, true to open the window in development mode. + // Development mode loads the Atom source from the locally cloned + // repository and also loads all the packages in ~/.atom/dev/packages + // * `safeMode` A {Boolean}, true to open the window in safe mode. Safe + // mode prevents all packages installed to ~/.atom/packages from loading. + open (params) { + return this.applicationDelegate.open(params) + } + + // Extended: Prompt the user to select one or more folders. + // + // * `callback` A {Function} to call once the user has confirmed the selection. + // * `paths` An {Array} of {String} paths that the user selected, or `null` + // if the user dismissed the dialog. + pickFolder (callback) { + return this.applicationDelegate.pickFolder(callback) + } + + // Essential: Close the current window. + close () { + return this.applicationDelegate.closeWindow() + } + + // Essential: Get the size of current window. + // + // Returns an {Object} in the format `{width: 1000, height: 700}` + getSize () { + return this.applicationDelegate.getWindowSize() + } + + // Essential: Set the size of current window. + // + // * `width` The {Number} of pixels. + // * `height` The {Number} of pixels. + setSize (width, height) { + return this.applicationDelegate.setWindowSize(width, height) + } + + // Essential: Get the position of current window. + // + // Returns an {Object} in the format `{x: 10, y: 20}` + getPosition () { + return this.applicationDelegate.getWindowPosition() + } + + // Essential: Set the position of current window. + // + // * `x` The {Number} of pixels. + // * `y` The {Number} of pixels. + setPosition (x, y) { + return this.applicationDelegate.setWindowPosition(x, y) + } + + // Extended: Get the current window + getCurrentWindow () { + return this.applicationDelegate.getCurrentWindow() + } + + // Extended: Move current window to the center of the screen. + center () { + return this.applicationDelegate.centerWindow() + } + + // Extended: Focus the current window. + focus () { + this.applicationDelegate.focusWindow() + return this.window.focus() + } + + // Extended: Show the current window. + show () { + return this.applicationDelegate.showWindow() + } + + // Extended: Hide the current window. + hide () { + return this.applicationDelegate.hideWindow() + } + + // Extended: Reload the current window. + reload () { + return this.applicationDelegate.reloadWindow() + } + + // Extended: Relaunch the entire application. + restartApplication () { + return this.applicationDelegate.restartApplication() + } + + // Extended: Returns a {Boolean} that is `true` if the current window is maximized. + isMaximized () { + return this.applicationDelegate.isWindowMaximized() + } + + maximize () { + return this.applicationDelegate.maximizeWindow() + } + + // Extended: Returns a {Boolean} that is `true` if the current window is in full screen mode. + isFullScreen () { + return this.applicationDelegate.isWindowFullScreen() + } + + // Extended: Set the full screen state of the current window. + setFullScreen (fullScreen = false) { + return this.applicationDelegate.setWindowFullScreen(fullScreen) + } + + // Extended: Toggle the full screen state of the current window. + toggleFullScreen () { + return this.setFullScreen(!this.isFullScreen()) + } + + // Restore the window to its previous dimensions and show it. + // + // Restores the full screen and maximized state after the window has resized to + // prevent resize glitches. + async displayWindow () { + await this.restoreWindowDimensions() + const steps = [ + this.restoreWindowBackground(), + this.show(), + this.focus() + ] + if (this.windowDimensions && this.windowDimensions.fullScreen) { + steps.push(this.setFullScreen(true)) + } + if (this.windowDimensions && this.windowDimensions.maximized && process.platform !== 'darwin') { + steps.push(this.maximize()) + } + await Promise.all(steps) + } + + // Get the dimensions of this window. + // + // Returns an {Object} with the following keys: + // * `x` The window's x-position {Number}. + // * `y` The window's y-position {Number}. + // * `width` The window's width {Number}. + // * `height` The window's height {Number}. + getWindowDimensions () { + const browserWindow = this.getCurrentWindow() + const [x, y] = browserWindow.getPosition() + const [width, height] = browserWindow.getSize() + const maximized = browserWindow.isMaximized() + return {x, y, width, height, maximized} + } + + // Set the dimensions of the window. + // + // The window will be centered if either the x or y coordinate is not set + // in the dimensions parameter. If x or y are omitted the window will be + // centered. If height or width are omitted only the position will be changed. + // + // * `dimensions` An {Object} with the following keys: + // * `x` The new x coordinate. + // * `y` The new y coordinate. + // * `width` The new width. + // * `height` The new height. + setWindowDimensions ({x, y, width, height}) { + const steps = [] + if (width != null && height != null) { + steps.push(this.setSize(width, height)) + } + if (x != null && y != null) { + steps.push(this.setPosition(x, y)) + } else { + steps.push(this.center()) + } + return Promise.all(steps) + } + + // Returns true if the dimensions are useable, false if they should be ignored. + // Work around for https://github.com/atom/atom-shell/issues/473 + isValidDimensions ({x, y, width, height} = {}) { + return (width > 0) && (height > 0) && ((x + width) > 0) && ((y + height) > 0) + } + + storeWindowDimensions () { + this.windowDimensions = this.getWindowDimensions() + if (this.isValidDimensions(this.windowDimensions)) { + localStorage.setItem('defaultWindowDimensions', JSON.stringify(this.windowDimensions)) + } + } + + getDefaultWindowDimensions () { + const {windowDimensions} = this.getLoadSettings() + if (windowDimensions) return windowDimensions + + let dimensions + try { + dimensions = JSON.parse(localStorage.getItem('defaultWindowDimensions')) + } catch (error) { + console.warn('Error parsing default window dimensions', error) + localStorage.removeItem('defaultWindowDimensions') + } + + if (dimensions && this.isValidDimensions(dimensions)) { + return dimensions + } else { + const {width, height} = this.applicationDelegate.getPrimaryDisplayWorkAreaSize() + return {x: 0, y: 0, width: Math.min(1024, width), height} + } + } + + async restoreWindowDimensions () { + if (!this.windowDimensions || !this.isValidDimensions(this.windowDimensions)) { + this.windowDimensions = this.getDefaultWindowDimensions() + } + await this.setWindowDimensions(this.windowDimensions) + return this.windowDimensions + } + + restoreWindowBackground () { + const backgroundColor = window.localStorage.getItem('atom:window-background-color') + if (backgroundColor) { + this.backgroundStylesheet = document.createElement('style') + this.backgroundStylesheet.type = 'text/css' + this.backgroundStylesheet.innerText = `html, body { background: ${backgroundColor} !important; }` + document.head.appendChild(this.backgroundStylesheet) + } + } + + storeWindowBackground () { + if (this.inSpecMode()) return + + const backgroundColor = this.window.getComputedStyle(this.workspace.getElement())['background-color'] + this.window.localStorage.setItem('atom:window-background-color', backgroundColor) + } + + // Call this method when establishing a real application window. + startEditorWindow () { + this.unloaded = false + + const updateProcessEnvPromise = this.updateProcessEnvAndTriggerHooks() + + const loadStatePromise = this.loadState().then(async state => { + this.windowDimensions = state && state.windowDimensions + await this.displayWindow() + this.commandInstaller.installAtomCommand(false, (error) => { + if (error) console.warn(error.message) + }) + this.commandInstaller.installApmCommand(false, (error) => { + if (error) console.warn(error.message) + }) + + this.disposables.add(this.applicationDelegate.onDidOpenLocations(this.openLocations.bind(this))) + this.disposables.add(this.applicationDelegate.onApplicationMenuCommand(this.dispatchApplicationMenuCommand.bind(this))) + this.disposables.add(this.applicationDelegate.onContextMenuCommand(this.dispatchContextMenuCommand.bind(this))) + this.disposables.add(this.applicationDelegate.onURIMessage(this.dispatchURIMessage.bind(this))) + this.disposables.add(this.applicationDelegate.onDidRequestUnload(async () => { + try { + await this.saveState({isUnloading: true}) + } catch (error) { + console.error(error) + } + + const closing = !this.workspace || await this.workspace.confirmClose({ + windowCloseRequested: true, + projectHasPaths: this.project.getPaths().length > 0 + }) + + if (closing) await this.packages.deactivatePackages() + return closing + })) + + this.listenForUpdates() + + this.registerDefaultTargetForKeymaps() + + this.packages.loadPackages() + + const startTime = Date.now() + await this.deserialize(state) + this.deserializeTimings.atom = Date.now() - startTime + + if (process.platform === 'darwin' && this.config.get('core.titleBar') === 'custom') { + this.workspace.addHeaderPanel({item: new TitleBar({workspace: this.workspace, themes: this.themes, applicationDelegate: this.applicationDelegate})}) + this.document.body.classList.add('custom-title-bar') + } + if (process.platform === 'darwin' && this.config.get('core.titleBar') === 'custom-inset') { + this.workspace.addHeaderPanel({item: new TitleBar({workspace: this.workspace, themes: this.themes, applicationDelegate: this.applicationDelegate})}) + this.document.body.classList.add('custom-inset-title-bar') + } + if (process.platform === 'darwin' && this.config.get('core.titleBar') === 'hidden') { + this.document.body.classList.add('hidden-title-bar') + } + + this.document.body.appendChild(this.workspace.getElement()) + if (this.backgroundStylesheet) this.backgroundStylesheet.remove() + + this.watchProjectPaths() + + this.packages.activate() + this.keymaps.loadUserKeymap() + if (!this.getLoadSettings().safeMode) this.requireUserInitScript() + + this.menu.update() + + await this.openInitialEmptyEditorIfNecessary() + }) + + const loadHistoryPromise = this.history.loadState().then(() => { + this.reopenProjectMenuManager = new ReopenProjectMenuManager({ + menu: this.menu, + commands: this.commands, + history: this.history, + config: this.config, + open: paths => this.open({pathsToOpen: paths}) + }) + this.reopenProjectMenuManager.update() + }) + + return Promise.all([loadStatePromise, loadHistoryPromise, updateProcessEnvPromise]) + } + + serialize (options) { + return { + version: this.constructor.version, + project: this.project.serialize(options), + workspace: this.workspace.serialize(), + packageStates: this.packages.serialize(), + grammars: {grammarOverridesByPath: this.grammars.grammarOverridesByPath}, + fullScreen: this.isFullScreen(), + windowDimensions: this.windowDimensions, + textEditors: this.textEditors.serialize() + } + } + + unloadEditorWindow () { + if (!this.project) return + + this.storeWindowBackground() + this.saveBlobStoreSync() + this.unloaded = true + } + + saveBlobStoreSync () { + if (this.enablePersistence) { + this.blobStore.save() + } + } + + openInitialEmptyEditorIfNecessary () { + if (!this.config.get('core.openEmptyEditorOnStart')) return + const {initialPaths} = this.getLoadSettings() + if (initialPaths && initialPaths.length === 0 && this.workspace.getPaneItems().length === 0) { + return this.workspace.open(null) + } + } + + installUncaughtErrorHandler () { + this.previousWindowErrorHandler = this.window.onerror + this.window.onerror = (message, url, line, column, originalError) => { + const mapping = mapSourcePosition({source: url, line, column}) + line = mapping.line + column = mapping.column + if (url === '') url = mapping.source + + const eventObject = {message, url, line, column, originalError} + + let openDevTools = true + eventObject.preventDefault = () => { openDevTools = false } + + this.emitter.emit('will-throw-error', eventObject) + + if (openDevTools) { + this.openDevTools().then(() => + this.executeJavaScriptInDevTools('DevToolsAPI.showPanel("console")') + ) + } + + this.emitter.emit('did-throw-error', {message, url, line, column, originalError}) + } + } + + uninstallUncaughtErrorHandler () { + this.window.onerror = this.previousWindowErrorHandler + } + + installWindowEventHandler () { + this.windowEventHandler = new WindowEventHandler({atomEnvironment: this, applicationDelegate: this.applicationDelegate}) + this.windowEventHandler.initialize(this.window, this.document) + } + + uninstallWindowEventHandler () { + if (this.windowEventHandler) { + this.windowEventHandler.unsubscribe() + } + this.windowEventHandler = null + } + + didChangeStyles (styleElement) { + TextEditor.didUpdateStyles() + if (styleElement.textContent.indexOf('scrollbar') >= 0) { + TextEditor.didUpdateScrollbarStyles() + } + } + + async updateProcessEnvAndTriggerHooks () { + await this.updateProcessEnv(this.getLoadSettings().env) + this.shellEnvironmentLoaded = true + this.emitter.emit('loaded-shell-environment') + this.packages.triggerActivationHook('core:loaded-shell-environment') + } + + /* + Section: Messaging the User + */ + + // Essential: Visually and audibly trigger a beep. + beep () { + if (this.config.get('core.audioBeep')) this.applicationDelegate.playBeepSound() + this.emitter.emit('did-beep') + } + + // Essential: A flexible way to open a dialog akin to an alert dialog. + // + // If the dialog is closed (via `Esc` key or `X` in the top corner) without selecting a button + // the first button will be clicked unless a "Cancel" or "No" button is provided. + // + // ## Examples + // + // ```coffee + // atom.confirm + // message: 'How you feeling?' + // detailedMessage: 'Be honest.' + // buttons: + // Good: -> window.alert('good to hear') + // Bad: -> window.alert('bummer') + // ``` + // + // * `options` An {Object} with the following keys: + // * `message` The {String} message to display. + // * `detailedMessage` (optional) The {String} detailed message to display. + // * `buttons` (optional) Either an array of strings or an object where keys are + // button names and the values are callbacks to invoke when clicked. + // + // Returns the chosen button index {Number} if the buttons option is an array or the return value of the callback if the buttons option is an object. + confirm (params = {}) { + return this.applicationDelegate.confirm(params) + } + + /* + Section: Managing the Dev Tools + */ + + // Extended: Open the dev tools for the current window. + // + // Returns a {Promise} that resolves when the DevTools have been opened. + openDevTools () { + return this.applicationDelegate.openWindowDevTools() + } + + // Extended: Toggle the visibility of the dev tools for the current window. + // + // Returns a {Promise} that resolves when the DevTools have been opened or + // closed. + toggleDevTools () { + return this.applicationDelegate.toggleWindowDevTools() + } + + // Extended: Execute code in dev tools. + executeJavaScriptInDevTools (code) { + return this.applicationDelegate.executeJavaScriptInWindowDevTools(code) + } + + /* + Section: Private + */ + + assert (condition, message, callbackOrMetadata) { + if (condition) return true + + const error = new Error(`Assertion failed: ${message}`) + Error.captureStackTrace(error, this.assert) + + if (callbackOrMetadata) { + if (typeof callbackOrMetadata === 'function') { + callbackOrMetadata(error) + } else { + error.metadata = callbackOrMetadata + } + } + + this.emitter.emit('did-fail-assertion', error) + if (!this.isReleasedVersion()) throw error + + return false + } + + loadThemes () { + return this.themes.load() + } + + // Notify the browser project of the window's current project path + watchProjectPaths () { + this.disposables.add(this.project.onDidChangePaths(() => { + this.applicationDelegate.setRepresentedDirectoryPaths(this.project.getPaths()) + })) + } + + setDocumentEdited (edited) { + if (typeof this.applicationDelegate.setWindowDocumentEdited === 'function') { + this.applicationDelegate.setWindowDocumentEdited(edited) + } + } + + setRepresentedFilename (filename) { + if (typeof this.applicationDelegate.setWindowRepresentedFilename === 'function') { + this.applicationDelegate.setWindowRepresentedFilename(filename) + } + } + + addProjectFolder () { + this.pickFolder((selectedPaths = []) => { + this.addToProject(selectedPaths) + }) + } + + async addToProject (projectPaths) { + const state = await this.loadState(this.getStateKey(projectPaths)) + if (state && (this.project.getPaths().length === 0)) { + this.attemptRestoreProjectStateForPaths(state, projectPaths) + } else { + projectPaths.map((folder) => this.project.addPath(folder)) + } + } + + attemptRestoreProjectStateForPaths (state, projectPaths, filesToOpen = []) { + const center = this.workspace.getCenter() + const windowIsUnused = () => { + for (let container of this.workspace.getPaneContainers()) { + for (let item of container.getPaneItems()) { + if (item instanceof TextEditor) { + if (item.getPath() || item.isModified()) return false + } else { + if (container === center) return false + } + } + } + return true + } + + if (windowIsUnused()) { + this.restoreStateIntoThisEnvironment(state) + return Promise.all(filesToOpen.map(file => this.workspace.open(file))) + } else { + const nouns = projectPaths.length === 1 ? 'folder' : 'folders' + const choice = this.confirm({ + message: 'Previous automatically-saved project state detected', + detailedMessage: `There is previously saved state for the selected ${nouns}. ` + + `Would you like to add the ${nouns} to this window, permanently discarding the saved state, ` + + `or open the ${nouns} in a new window, restoring the saved state?`, + buttons: [ + '&Open in new window and recover state', + '&Add to this window and discard state' + ]}) + if (choice === 0) { + this.open({ + pathsToOpen: projectPaths.concat(filesToOpen), + newWindow: true, + devMode: this.inDevMode(), + safeMode: this.inSafeMode() + }) + return Promise.resolve(null) + } else if (choice === 1) { + for (let selectedPath of projectPaths) { + this.project.addPath(selectedPath) + } + return Promise.all(filesToOpen.map(file => this.workspace.open(file))) + } + } + } + + restoreStateIntoThisEnvironment (state) { + state.fullScreen = this.isFullScreen() + for (let pane of this.workspace.getPanes()) { + pane.destroy() + } + return this.deserialize(state) + } + + showSaveDialog (callback) { + callback(this.showSaveDialogSync()) + } + + showSaveDialogSync (options = {}) { + this.applicationDelegate.showSaveDialog(options) + } + + async saveState (options, storageKey) { + if (this.enablePersistence && this.project) { + const state = this.serialize(options) + if (!storageKey) storageKey = this.getStateKey(this.project && this.project.getPaths()) + if (storageKey) { + await this.stateStore.save(storageKey, state) + } else { + await this.applicationDelegate.setTemporaryWindowState(state) + } + } + } + + loadState (stateKey) { + if (this.enablePersistence) { + if (!stateKey) stateKey = this.getStateKey(this.getLoadSettings().initialPaths) + if (stateKey) { + return this.stateStore.load(stateKey) + } else { + return this.applicationDelegate.getTemporaryWindowState() + } + } else { + return Promise.resolve(null) + } + } + + async deserialize (state) { + if (!state) return Promise.resolve() + + const grammarOverridesByPath = state.grammars && state.grammars.grammarOverridesByPath + if (grammarOverridesByPath) { + this.grammars.grammarOverridesByPath = grammarOverridesByPath + } + + this.setFullScreen(state.fullScreen) + + const missingProjectPaths = [] + + this.packages.packageStates = state.packageStates || {} + + let startTime = Date.now() + if (state.project) { + try { + await this.project.deserialize(state.project, this.deserializers) + } catch (error) { + if (error.missingProjectPaths) { + missingProjectPaths.push(...error.missingProjectPaths) + } else { + this.notifications.addError('Unable to deserialize project', { + description: error.message, + stack: error.stack + }) + } + } + } + + this.deserializeTimings.project = Date.now() - startTime + + if (state.textEditors) this.textEditors.deserialize(state.textEditors) + + startTime = Date.now() + if (state.workspace) this.workspace.deserialize(state.workspace, this.deserializers) + this.deserializeTimings.workspace = Date.now() - startTime + + if (missingProjectPaths.length > 0) { + const count = missingProjectPaths.length === 1 ? '' : missingProjectPaths.length + ' ' + const noun = missingProjectPaths.length === 1 ? 'directory' : 'directories' + const toBe = missingProjectPaths.length === 1 ? 'is' : 'are' + const escaped = missingProjectPaths.map(projectPath => `\`${projectPath}\``) + let group + switch (escaped.length) { + case 1: + group = escaped[0] + break + case 2: + group = `${escaped[0]} and ${escaped[1]}` + break + default: + group = escaped.slice(0, -1).join(', ') + `, and ${escaped[escaped.length - 1]}` + } + + this.notifications.addError(`Unable to open ${count}project ${noun}`, { + description: `Project ${noun} ${group} ${toBe} no longer on disk.` + }) + } + } + + getStateKey (paths) { + if (paths && paths.length > 0) { + const sha1 = crypto.createHash('sha1').update(paths.slice().sort().join('\n')).digest('hex') + return `editor-${sha1}` + } else { + return null + } + } + + getStorageFolder () { + if (!this.storageFolder) this.storageFolder = new StorageFolder(this.getConfigDirPath()) + return this.storageFolder + } + + getConfigDirPath () { + if (!this.configDirPath) this.configDirPath = process.env.ATOM_HOME + return this.configDirPath + } + + getUserInitScriptPath () { + const initScriptPath = fs.resolve(this.getConfigDirPath(), 'init', ['js', 'coffee']) + return initScriptPath || path.join(this.getConfigDirPath(), 'init.coffee') + } + + requireUserInitScript () { + const userInitScriptPath = this.getUserInitScriptPath() + if (userInitScriptPath) { + try { + if (fs.isFileSync(userInitScriptPath)) require(userInitScriptPath) + } catch (error) { + this.notifications.addError(`Failed to load \`${userInitScriptPath}\``, { + detail: error.message, + dismissable: true + }) + } + } + } + + // TODO: We should deprecate the update events here, and use `atom.autoUpdater` instead + onUpdateAvailable (callback) { + return this.emitter.on('update-available', callback) + } + + updateAvailable (details) { + return this.emitter.emit('update-available', details) + } + + listenForUpdates () { + // listen for updates available locally (that have been successfully downloaded) + this.disposables.add(this.autoUpdater.onDidCompleteDownloadingUpdate(this.updateAvailable.bind(this))) + } + + setBodyPlatformClass () { + this.document.body.classList.add(`platform-${process.platform}`) + } + + setAutoHideMenuBar (autoHide) { + this.applicationDelegate.setAutoHideWindowMenuBar(autoHide) + this.applicationDelegate.setWindowMenuBarVisibility(!autoHide) + } + + dispatchApplicationMenuCommand (command, arg) { + let {activeElement} = this.document + // Use the workspace element if body has focus + if (activeElement === this.document.body) { + activeElement = this.workspace.getElement() + } + this.commands.dispatch(activeElement, command, arg) + } + + dispatchContextMenuCommand (command, ...args) { + this.commands.dispatch(this.contextMenu.activeElement, command, args) + } + + dispatchURIMessage (uri) { + if (this.packages.hasLoadedInitialPackages()) { + this.uriHandlerRegistry.handleURI(uri) + } else { + let subscription = this.packages.onDidLoadInitialPackages(() => { + subscription.dispose() + this.uriHandlerRegistry.handleURI(uri) + }) + } + } + + async openLocations (locations) { + const needsProjectPaths = this.project && this.project.getPaths().length === 0 + const foldersToAddToProject = [] + const fileLocationsToOpen = [] + + function pushFolderToOpen (folder) { + if (!foldersToAddToProject.includes(folder)) { + foldersToAddToProject.push(folder) + } + } + + for (var {pathToOpen, initialLine, initialColumn, forceAddToWindow} of locations) { + if (pathToOpen && (needsProjectPaths || forceAddToWindow)) { + if (fs.existsSync(pathToOpen)) { + pushFolderToOpen(this.project.getDirectoryForProjectPath(pathToOpen).getPath()) + } else if (fs.existsSync(path.dirname(pathToOpen))) { + pushFolderToOpen(this.project.getDirectoryForProjectPath(path.dirname(pathToOpen)).getPath()) + } else { + pushFolderToOpen(this.project.getDirectoryForProjectPath(pathToOpen).getPath()) + } + } + + if (!fs.isDirectorySync(pathToOpen)) { + fileLocationsToOpen.push({pathToOpen, initialLine, initialColumn}) + } + } + + let restoredState = false + if (foldersToAddToProject.length > 0) { + const state = await this.loadState(this.getStateKey(foldersToAddToProject)) + + // only restore state if this is the first path added to the project + if (state && needsProjectPaths) { + const files = fileLocationsToOpen.map((location) => location.pathToOpen) + await this.attemptRestoreProjectStateForPaths(state, foldersToAddToProject, files) + restoredState = true + } else { + for (let folder of foldersToAddToProject) { + this.project.addPath(folder) + } + } + } + + if (!restoredState) { + const fileOpenPromises = [] + for ({pathToOpen, initialLine, initialColumn} of fileLocationsToOpen) { + fileOpenPromises.push(this.workspace && this.workspace.open(pathToOpen, {initialLine, initialColumn})) + } + await Promise.all(fileOpenPromises) + } + + ipcRenderer.send('window-command', 'window:locations-opened') + } + + resolveProxy (url) { + return new Promise((resolve, reject) => { + const requestId = this.nextProxyRequestId++ + const disposable = this.applicationDelegate.onDidResolveProxy((id, proxy) => { + if (id === requestId) { + disposable.dispose() + resolve(proxy) + } + }) + + return this.applicationDelegate.resolveProxy(requestId, url) + }) + } +} + +AtomEnvironment.version = 1 +AtomEnvironment.prototype.saveStateDebounceInterval = 1000 +module.exports = AtomEnvironment + +/* eslint-disable */ + +// Preserve this deprecation until 2.0. Sorry. Should have removed Q sooner. +Promise.prototype.done = function (callback) { + deprecate('Atom now uses ES6 Promises instead of Q. Call promise.then instead of promise.done') + return this.then(callback) +} + +/* eslint-enable */ diff --git a/src/command-registry.js b/src/command-registry.js index 30089b7f1..9e6d8c2e1 100644 --- a/src/command-registry.js +++ b/src/command-registry.js @@ -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 // diff --git a/src/config-schema.js b/src/config-schema.js index 00fb8bbe3..2ff68be86 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -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'], diff --git a/src/core-uri-handlers.js b/src/core-uri-handlers.js new file mode 100644 index 000000000..2af00f610 --- /dev/null +++ b/src/core-uri-handlers.js @@ -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 + } + } +} diff --git a/src/cursor.js b/src/cursor.js index 1425f5b49..181eeb971 100644 --- a/src/cursor.js +++ b/src/cursor.js @@ -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. diff --git a/src/decoration.coffee b/src/decoration.coffee deleted file mode 100644 index f18733f6e..000000000 --- a/src/decoration.coffee +++ /dev/null @@ -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: -> - "" - - ### - 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' diff --git a/src/decoration.js b/src/decoration.js new file mode 100644 index 000000000..731935506 --- /dev/null +++ b/src/decoration.js @@ -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 `` + } + + /* + Section: Private methods + */ + + matchesPattern (decorationPattern) { + if (decorationPattern == null) { return false } + for (let key in decorationPattern) { + const value = decorationPattern[key] + if (this.properties[key] !== value) { return false } + } + return true + } + + flash (klass, duration) { + if (duration == null) { duration = 500 } + this.properties.flashRequested = true + this.properties.flashClass = klass + this.properties.flashDuration = duration + this.decorationManager.emitDidUpdateDecorations() + return this.emitter.emit('did-flash') + } +} diff --git a/src/git-repository.js b/src/git-repository.js index 057c5fcb7..55d70c12c 100644 --- a/src/git-repository.js +++ b/src/git-repository.js @@ -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' } } diff --git a/src/grammar-registry.coffee b/src/grammar-registry.coffee deleted file mode 100644 index a2341c967..000000000 --- a/src/grammar-registry.coffee +++ /dev/null @@ -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 diff --git a/src/grammar-registry.js b/src/grammar-registry.js new file mode 100644 index 000000000..f2994acf1 --- /dev/null +++ b/src/grammar-registry.js @@ -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) + } +} diff --git a/src/gutter-container.coffee b/src/gutter-container.coffee deleted file mode 100644 index 677fa4521..000000000 --- a/src/gutter-container.coffee +++ /dev/null @@ -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) diff --git a/src/gutter-container.js b/src/gutter-container.js new file mode 100644 index 000000000..3faece073 --- /dev/null +++ b/src/gutter-container.js @@ -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) + } +} diff --git a/src/gutter.coffee b/src/gutter.coffee deleted file mode 100644 index 4521eeeb2..000000000 --- a/src/gutter.coffee +++ /dev/null @@ -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') diff --git a/src/gutter.js b/src/gutter.js new file mode 100644 index 000000000..3bf7a72ea --- /dev/null +++ b/src/gutter.js @@ -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 + } +} diff --git a/src/main-process/application-menu.coffee b/src/main-process/application-menu.coffee index 681677603..35bc7d66c 100644 --- a/src/main-process/application-menu.coffee +++ b/src/main-process/application-menu.coffee @@ -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. # diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index dcc7c6513..f6802705e 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -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 diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index 9bbdcfc25..ca3995c05 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -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... diff --git a/src/main-process/auto-update-manager.coffee b/src/main-process/auto-update-manager.coffee index 2ff2852cb..0e4144c1a 100644 --- a/src/main-process/auto-update-manager.coffee +++ b/src/main-process/auto-update-manager.coffee @@ -138,4 +138,4 @@ class AutoUpdateManager detail: message getWindows: -> - global.atomApplication.windows + global.atomApplication.getAllWindows() diff --git a/src/main-process/parse-command-line.js b/src/main-process/parse-command-line.js index 6c5349437..3b0654962 100644 --- a/src/main-process/parse-command-line.js +++ b/src/main-process/parse-command-line.js @@ -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 diff --git a/src/package-manager.js b/src/package-manager.js index b52e29cad..17a5f2214 100644 --- a/src/package-manager.js +++ b/src/package-manager.js @@ -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) { diff --git a/src/package.coffee b/src/package.coffee index e0db21ccc..1635c75dc 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -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 diff --git a/src/path-watcher.js b/src/path-watcher.js index 2dfece46e..5a2d10bde 100644 --- a/src/path-watcher.js +++ b/src/path-watcher.js @@ -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. diff --git a/src/project.coffee b/src/project.coffee deleted file mode 100644 index ab41f9eb3..000000000 --- a/src/project.coffee +++ /dev/null @@ -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 diff --git a/src/project.js b/src/project.js new file mode 100644 index 000000000..92a11ec7a --- /dev/null +++ b/src/project.js @@ -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 + }) + }) + } +} diff --git a/src/protocol-handler-installer.js b/src/protocol-handler-installer.js new file mode 100644 index 000000000..0a55bff41 --- /dev/null +++ b/src/protocol-handler-installer.js @@ -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) + } + ] + }) + } +} diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index d5b741c40..7dc0d3298 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -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() diff --git a/src/selection.coffee b/src/selection.coffee deleted file mode 100644 index 4d3fe8882..000000000 --- a/src/selection.coffee +++ /dev/null @@ -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() diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 000000000..a54ba68b8 --- /dev/null +++ b/src/selection.js @@ -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() + } +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index a6505d760..70a324cd5 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -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 () { diff --git a/src/text-editor-registry.js b/src/text-editor-registry.js index 2cbf3093c..d891a5868 100644 --- a/src/text-editor-registry.js +++ b/src/text-editor-registry.js @@ -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) } } diff --git a/src/text-editor.coffee b/src/text-editor.coffee deleted file mode 100644 index c00508f09..000000000 --- a/src/text-editor.coffee +++ /dev/null @@ -1,3909 +0,0 @@ -_ = require 'underscore-plus' -path = require 'path' -fs = require 'fs-plus' -Grim = require 'grim' -{CompositeDisposable, Disposable, Emitter} = require 'event-kit' -{Point, Range} = TextBuffer = require 'text-buffer' -DecorationManager = require './decoration-manager' -TokenizedBuffer = require './tokenized-buffer' -Cursor = require './cursor' -Model = require './model' -Selection = require './selection' -TextMateScopeSelector = require('first-mate').ScopeSelector -GutterContainer = require './gutter-container' -TextEditorComponent = null -TextEditorElement = null -{isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require './text-utils' - -NON_WHITESPACE_REGEXP = /\S/ -ZERO_WIDTH_NBSP = '\ufeff' - -# Essential: This class represents all essential editing state for a single -# {TextBuffer}, including cursor and selection positions, folds, and soft wraps. -# If you're manipulating the state of an editor, use this class. -# -# A single {TextBuffer} can belong to multiple editors. For example, if the -# same file is open in two different panes, Atom creates a separate editor for -# each pane. If the buffer is manipulated the changes are reflected in both -# editors, but each maintains its own cursor position, folded lines, etc. -# -# ## Accessing TextEditor Instances -# -# The easiest way to get hold of `TextEditor` objects is by registering a callback -# with `::observeTextEditors` on the `atom.workspace` global. Your callback will -# then be called with all current editor instances and also when any editor is -# created in the future. -# -# ```coffee -# atom.workspace.observeTextEditors (editor) -> -# editor.insertText('Hello World') -# ``` -# -# ## Buffer vs. Screen Coordinates -# -# Because editors support folds and soft-wrapping, the lines on screen don't -# always match the lines in the buffer. For example, a long line that soft wraps -# twice renders as three lines on screen, but only represents one line in the -# buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds -# to row 11 in the buffer. -# -# Your choice of coordinates systems will depend on what you're trying to -# achieve. For example, if you're writing a command that jumps the cursor up or -# down by 10 lines, you'll want to use screen coordinates because the user -# probably wants to skip lines *on screen*. However, if you're writing a package -# that jumps between method definitions, you'll want to work in buffer -# coordinates. -# -# **When in doubt, just default to buffer coordinates**, then experiment with -# soft wraps and folds to ensure your code interacts with them correctly. -module.exports = -class TextEditor extends Model - @setClipboard: (clipboard) -> - @clipboard = clipboard - - @setScheduler: (scheduler) -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.setScheduler(scheduler) - - @didUpdateStyles: -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.didUpdateStyles() - - @didUpdateScrollbarStyles: -> - TextEditorComponent ?= require './text-editor-component' - TextEditorComponent.didUpdateScrollbarStyles() - - @viewForItem: (item) -> item.element ? item - - serializationVersion: 1 - - buffer: null - cursors: null - showCursorOnSelection: null - selections: null - suppressSelectionMerging: false - selectionFlashDuration: 500 - gutterContainer: null - editorElement: null - verticalScrollMargin: 2 - horizontalScrollMargin: 6 - softWrapped: null - editorWidthInChars: null - lineHeightInPixels: null - defaultCharWidth: null - height: null - width: null - registered: false - atomicSoftTabs: true - invisibles: null - - Object.defineProperty @prototype, "element", - get: -> @getElement() - - Object.defineProperty @prototype, "editorElement", - get: -> - Grim.deprecate(""" - `TextEditor.prototype.editorElement` has always been private, but now - it is gone. Reading the `editorElement` property still returns a - reference to the editor element but this field will be removed in a - later version of Atom, so we recommend using the `element` property instead. - """) - - @getElement() - - Object.defineProperty(@prototype, 'displayBuffer', get: -> - Grim.deprecate(""" - `TextEditor.prototype.displayBuffer` has always been private, but now - it is gone. Reading the `displayBuffer` property now returns a reference - to the containing `TextEditor`, which now provides *some* of the API of - the defunct `DisplayBuffer` class. - """) - this - ) - - Object.defineProperty(@prototype, 'languageMode', get: -> @tokenizedBuffer) - - @deserialize: (state, atomEnvironment) -> - # TODO: Return null on version mismatch when 1.8.0 has been out for a while - if state.version isnt @prototype.serializationVersion and state.displayBuffer? - state.tokenizedBuffer = state.displayBuffer.tokenizedBuffer - - try - tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) - return null unless tokenizedBuffer? - - state.tokenizedBuffer = tokenizedBuffer - state.tabLength = state.tokenizedBuffer.getTabLength() - catch error - if error.syscall is 'read' - return # Error reading the file, don't deserialize an editor for it - else - throw error - - state.buffer = state.tokenizedBuffer.buffer - state.assert = atomEnvironment.assert.bind(atomEnvironment) - editor = new this(state) - if state.registered - disposable = atomEnvironment.textEditors.add(editor) - editor.onDidDestroy -> disposable.dispose() - editor - - constructor: (params={}) -> - unless @constructor.clipboard? - throw new Error("Must call TextEditor.setClipboard at least once before creating TextEditor instances") - - super - - { - @softTabs, @initialScrollTopRow, @initialScrollLeftColumn, initialLine, initialColumn, tabLength, - @decorationManager, @selectionsMarkerLayer, @buffer, suppressCursorCreation, - @mini, @placeholderText, lineNumberGutterVisible, @showLineNumbers, @largeFileMode, - @assert, grammar, @showInvisibles, @autoHeight, @autoWidth, @scrollPastEnd, @scrollSensitivity, @editorWidthInChars, - @tokenizedBuffer, @displayLayer, @invisibles, @showIndentGuide, - @softWrapped, @softWrapAtPreferredLineLength, @preferredLineLength, - @showCursorOnSelection, @maxScreenLineLength - } = params - - @assert ?= (condition) -> condition - @emitter = new Emitter - @disposables = new CompositeDisposable - @cursors = [] - @cursorsByMarkerId = new Map - @selections = [] - @hasTerminatedPendingState = false - - @mini ?= false - @scrollPastEnd ?= false - @scrollSensitivity ?= 40 - @showInvisibles ?= true - @softTabs ?= true - tabLength ?= 2 - @autoIndent ?= true - @autoIndentOnPaste ?= true - @showCursorOnSelection ?= true - @undoGroupingInterval ?= 300 - @nonWordCharacters ?= "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" - @softWrapped ?= false - @softWrapAtPreferredLineLength ?= false - @preferredLineLength ?= 80 - @maxScreenLineLength ?= 500 - @showLineNumbers ?= true - - @buffer ?= new TextBuffer({ - shouldDestroyOnFileDelete: -> atom.config.get('core.closeDeletedFileTabs') - }) - @tokenizedBuffer ?= new TokenizedBuffer({ - grammar, tabLength, @buffer, @largeFileMode, @assert - }) - - unless @displayLayer? - displayLayerParams = { - invisibles: @getInvisibles(), - softWrapColumn: @getSoftWrapColumn(), - showIndentGuides: @doesShowIndentGuide(), - atomicSoftTabs: params.atomicSoftTabs ? true, - tabLength: tabLength, - ratioForCharacter: @ratioForCharacter.bind(this), - isWrapBoundary: isWrapBoundary, - foldCharacter: ZERO_WIDTH_NBSP, - softWrapHangingIndent: params.softWrapHangingIndentLength ? 0 - } - - if @displayLayer = @buffer.getDisplayLayer(params.displayLayerId) - @displayLayer.reset(displayLayerParams) - @selectionsMarkerLayer = @displayLayer.getMarkerLayer(params.selectionsMarkerLayerId) - else - @displayLayer = @buffer.addDisplayLayer(displayLayerParams) - - @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) - @disposables.add new Disposable => - cancelIdleCallback(@backgroundWorkHandle) if @backgroundWorkHandle? - - @displayLayer.setTextDecorationLayer(@tokenizedBuffer) - @defaultMarkerLayer = @displayLayer.addMarkerLayer() - @disposables.add(@defaultMarkerLayer.onDidDestroy => - @assert(false, "defaultMarkerLayer destroyed at an unexpected time") - ) - @selectionsMarkerLayer ?= @addMarkerLayer(maintainHistory: true, persistent: true) - @selectionsMarkerLayer.trackDestructionInOnDidCreateMarkerCallbacks = true - - @decorationManager = new DecorationManager(this) - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'cursor') - @decorateCursorLine() unless @isMini() - - @decorateMarkerLayer(@displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) - - for marker in @selectionsMarkerLayer.getMarkers() - @addSelection(marker) - - @subscribeToBuffer() - @subscribeToDisplayLayer() - - if @cursors.length is 0 and not suppressCursorCreation - initialLine = Math.max(parseInt(initialLine) or 0, 0) - initialColumn = Math.max(parseInt(initialColumn) or 0, 0) - @addCursorAtBufferPosition([initialLine, initialColumn]) - - @gutterContainer = new GutterContainer(this) - @lineNumberGutter = @gutterContainer.addGutter - name: 'line-number' - priority: 0 - visible: lineNumberGutterVisible - - decorateCursorLine: -> - @cursorLineDecorations = [ - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line', class: 'cursor-line', onlyEmpty: true), - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line'), - @decorateMarkerLayer(@selectionsMarkerLayer, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) - ] - - doBackgroundWork: (deadline) => - previousLongestRow = @getApproximateLongestScreenRow() - if @displayLayer.doBackgroundWork(deadline) - @backgroundWorkHandle = requestIdleCallback(@doBackgroundWork) - else - @backgroundWorkHandle = null - - if @getApproximateLongestScreenRow() isnt previousLongestRow - @component?.scheduleUpdate() - - update: (params) -> - displayLayerParams = {} - - for param in Object.keys(params) - value = params[param] - - switch param - when 'autoIndent' - @autoIndent = value - - when 'autoIndentOnPaste' - @autoIndentOnPaste = value - - when 'undoGroupingInterval' - @undoGroupingInterval = value - - when 'nonWordCharacters' - @nonWordCharacters = value - - when 'scrollSensitivity' - @scrollSensitivity = value - - when 'encoding' - @buffer.setEncoding(value) - - when 'softTabs' - if value isnt @softTabs - @softTabs = value - - when 'atomicSoftTabs' - if value isnt @displayLayer.atomicSoftTabs - displayLayerParams.atomicSoftTabs = value - - when 'tabLength' - if value? and value isnt @tokenizedBuffer.getTabLength() - @tokenizedBuffer.setTabLength(value) - displayLayerParams.tabLength = value - - when 'softWrapped' - if value isnt @softWrapped - @softWrapped = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - @emitter.emit 'did-change-soft-wrapped', @isSoftWrapped() - - when 'softWrapHangingIndentLength' - if value isnt @displayLayer.softWrapHangingIndent - displayLayerParams.softWrapHangingIndent = value - - when 'softWrapAtPreferredLineLength' - if value isnt @softWrapAtPreferredLineLength - @softWrapAtPreferredLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'preferredLineLength' - if value isnt @preferredLineLength - @preferredLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'maxScreenLineLength' - if value isnt @maxScreenLineLength - @maxScreenLineLength = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'mini' - if value isnt @mini - @mini = value - @emitter.emit 'did-change-mini', value - displayLayerParams.invisibles = @getInvisibles() - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - displayLayerParams.showIndentGuides = @doesShowIndentGuide() - if @mini - decoration.destroy() for decoration in @cursorLineDecorations - @cursorLineDecorations = null - else - @decorateCursorLine() - @component?.scheduleUpdate() - - when 'placeholderText' - if value isnt @placeholderText - @placeholderText = value - @emitter.emit 'did-change-placeholder-text', value - - when 'lineNumberGutterVisible' - if value isnt @lineNumberGutterVisible - if value - @lineNumberGutter.show() - else - @lineNumberGutter.hide() - @emitter.emit 'did-change-line-number-gutter-visible', @lineNumberGutter.isVisible() - - when 'showIndentGuide' - if value isnt @showIndentGuide - @showIndentGuide = value - displayLayerParams.showIndentGuides = @doesShowIndentGuide() - - when 'showLineNumbers' - if value isnt @showLineNumbers - @showLineNumbers = value - @component?.scheduleUpdate() - - when 'showInvisibles' - if value isnt @showInvisibles - @showInvisibles = value - displayLayerParams.invisibles = @getInvisibles() - - when 'invisibles' - if not _.isEqual(value, @invisibles) - @invisibles = value - displayLayerParams.invisibles = @getInvisibles() - - when 'editorWidthInChars' - if value > 0 and value isnt @editorWidthInChars - @editorWidthInChars = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'width' - if value isnt @width - @width = value - displayLayerParams.softWrapColumn = @getSoftWrapColumn() - - when 'scrollPastEnd' - if value isnt @scrollPastEnd - @scrollPastEnd = value - @component?.scheduleUpdate() - - when 'autoHeight' - if value isnt @autoHeight - @autoHeight = value - - when 'autoWidth' - if value isnt @autoWidth - @autoWidth = value - - when 'showCursorOnSelection' - if value isnt @showCursorOnSelection - @showCursorOnSelection = value - @component?.scheduleUpdate() - - else - if param isnt 'ref' and param isnt 'key' - throw new TypeError("Invalid TextEditor parameter: '#{param}'") - - @displayLayer.reset(displayLayerParams) - - if @component? - @component.getNextUpdatePromise() - else - Promise.resolve() - - scheduleComponentUpdate: -> - @component?.scheduleUpdate() - - serialize: -> - tokenizedBufferState = @tokenizedBuffer.serialize() - - { - deserializer: 'TextEditor' - version: @serializationVersion - - # TODO: Remove this forward-compatible fallback once 1.8 reaches stable. - displayBuffer: {tokenizedBuffer: tokenizedBufferState} - - tokenizedBuffer: tokenizedBufferState - displayLayerId: @displayLayer.id - selectionsMarkerLayerId: @selectionsMarkerLayer.id - - initialScrollTopRow: @getScrollTopRow() - initialScrollLeftColumn: @getScrollLeftColumn() - - atomicSoftTabs: @displayLayer.atomicSoftTabs - softWrapHangingIndentLength: @displayLayer.softWrapHangingIndent - - @id, @softTabs, @softWrapped, @softWrapAtPreferredLineLength, - @preferredLineLength, @mini, @editorWidthInChars, @width, @largeFileMode, @maxScreenLineLength, - @registered, @invisibles, @showInvisibles, @showIndentGuide, @autoHeight, @autoWidth - } - - subscribeToBuffer: -> - @buffer.retain() - @disposables.add @buffer.onDidChangePath => - @emitter.emit 'did-change-title', @getTitle() - @emitter.emit 'did-change-path', @getPath() - @disposables.add @buffer.onDidChangeEncoding => - @emitter.emit 'did-change-encoding', @getEncoding() - @disposables.add @buffer.onDidDestroy => @destroy() - @disposables.add @buffer.onDidChangeModified => - @terminatePendingState() if not @hasTerminatedPendingState and @buffer.isModified() - - terminatePendingState: -> - @emitter.emit 'did-terminate-pending-state' if not @hasTerminatedPendingState - @hasTerminatedPendingState = true - - onDidTerminatePendingState: (callback) -> - @emitter.on 'did-terminate-pending-state', callback - - subscribeToDisplayLayer: -> - @disposables.add @tokenizedBuffer.onDidChangeGrammar @handleGrammarChange.bind(this) - @disposables.add @displayLayer.onDidChangeSync (e) => - @mergeIntersectingSelections() - @component?.didChangeDisplayLayer(e) - @emitter.emit 'did-change', e - @disposables.add @displayLayer.onDidReset => - @mergeIntersectingSelections() - @component?.didResetDisplayLayer() - @emitter.emit 'did-change', {} - @disposables.add @selectionsMarkerLayer.onDidCreateMarker @addSelection.bind(this) - @disposables.add @selectionsMarkerLayer.onDidUpdate => @component?.didUpdateSelections() - - destroyed: -> - @disposables.dispose() - @displayLayer.destroy() - @tokenizedBuffer.destroy() - selection.destroy() for selection in @selections.slice() - @buffer.release() - @gutterContainer.destroy() - @emitter.emit 'did-destroy' - @emitter.clear() - @component?.element.component = null - @component = null - @lineNumberGutter.element = null - - ### - Section: Event Subscription - ### - - # Essential: Calls your `callback` when the buffer's title has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeTitle: (callback) -> - @emitter.on 'did-change-title', callback - - # Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePath: (callback) -> - @emitter.on 'did-change-path', callback - - # Essential: Invoke the given callback synchronously when the content of the - # buffer changes. - # - # Because observers are invoked synchronously, it's important not to perform - # any expensive operations via this method. Consider {::onDidStopChanging} to - # delay expensive operations until after changes stop occurring. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - # Essential: Invoke `callback` when the buffer's contents change. It is - # emit asynchronously 300ms after the last buffer change. This is a good place - # to handle changes to the buffer without compromising typing performance. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidStopChanging: (callback) -> - @getBuffer().onDidStopChanging(callback) - - # Essential: Calls your `callback` when a {Cursor} is moved. If there are - # multiple cursors, your callback will be called for each cursor. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferPosition` {Point} - # * `oldScreenPosition` {Point} - # * `newBufferPosition` {Point} - # * `newScreenPosition` {Point} - # * `textChanged` {Boolean} - # * `cursor` {Cursor} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeCursorPosition: (callback) -> - @emitter.on 'did-change-cursor-position', callback - - # Essential: Calls your `callback` when a selection's screen range changes. - # - # * `callback` {Function} - # * `event` {Object} - # * `oldBufferRange` {Range} - # * `oldScreenRange` {Range} - # * `newBufferRange` {Range} - # * `newScreenRange` {Range} - # * `selection` {Selection} that triggered the event - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeSelectionRange: (callback) -> - @emitter.on 'did-change-selection-range', callback - - # Extended: Calls your `callback` when soft wrap was enabled or disabled. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeSoftWrapped: (callback) -> - @emitter.on 'did-change-soft-wrapped', callback - - # Extended: Calls your `callback` when the buffer's encoding has changed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeEncoding: (callback) -> - @emitter.on 'did-change-encoding', callback - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. Immediately calls your callback with - # the current grammar. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGrammar: (callback) -> - callback(@getGrammar()) - @onDidChangeGrammar(callback) - - # Extended: Calls your `callback` when the grammar that interprets and - # colorizes the text has been changed. - # - # * `callback` {Function} - # * `grammar` {Grammar} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeGrammar: (callback) -> - @emitter.on 'did-change-grammar', callback - - # Extended: Calls your `callback` when the result of {::isModified} changes. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeModified: (callback) -> - @getBuffer().onDidChangeModified(callback) - - # Extended: Calls your `callback` when the buffer's underlying file changes on - # disk at a moment when the result of {::isModified} is true. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidConflict: (callback) -> - @getBuffer().onDidConflict(callback) - - # Extended: Calls your `callback` before text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # * `cancel` {Function} Call to prevent the text from being inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillInsertText: (callback) -> - @emitter.on 'will-insert-text', callback - - # Extended: Calls your `callback` after text has been inserted. - # - # * `callback` {Function} - # * `event` event {Object} - # * `text` {String} text to be inserted - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidInsertText: (callback) -> - @emitter.on 'did-insert-text', callback - - # Essential: Invoke the given callback after the buffer is saved to disk. - # - # * `callback` {Function} to be called after the buffer is saved. - # * `event` {Object} with the following keys: - # * `path` The path to which the buffer was saved. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidSave: (callback) -> - @getBuffer().onDidSave(callback) - - # Essential: Invoke the given callback when the editor is destroyed. - # - # * `callback` {Function} to be called when the editor is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # Immediately calls your callback for each existing cursor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeCursors: (callback) -> - callback(cursor) for cursor in @getCursors() - @onDidAddCursor(callback) - - # Extended: Calls your `callback` when a {Cursor} is added to the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddCursor: (callback) -> - @emitter.on 'did-add-cursor', callback - - # Extended: Calls your `callback` when a {Cursor} is removed from the editor. - # - # * `callback` {Function} - # * `cursor` {Cursor} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveCursor: (callback) -> - @emitter.on 'did-remove-cursor', callback - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # Immediately calls your callback for each existing selection. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeSelections: (callback) -> - callback(selection) for selection in @getSelections() - @onDidAddSelection(callback) - - # Extended: Calls your `callback` when a {Selection} is added to the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddSelection: (callback) -> - @emitter.on 'did-add-selection', callback - - # Extended: Calls your `callback` when a {Selection} is removed from the editor. - # - # * `callback` {Function} - # * `selection` {Selection} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveSelection: (callback) -> - @emitter.on 'did-remove-selection', callback - - # Extended: Calls your `callback` with each {Decoration} added to the editor. - # Calls your `callback` immediately for any existing decorations. - # - # * `callback` {Function} - # * `decoration` {Decoration} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeDecorations: (callback) -> - @decorationManager.observeDecorations(callback) - - # Extended: Calls your `callback` when a {Decoration} is added to the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was added - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddDecoration: (callback) -> - @decorationManager.onDidAddDecoration(callback) - - # Extended: Calls your `callback` when a {Decoration} is removed from the editor. - # - # * `callback` {Function} - # * `decoration` {Decoration} that was removed - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveDecoration: (callback) -> - @decorationManager.onDidRemoveDecoration(callback) - - # Called by DecorationManager when a decoration is added. - didAddDecoration: (decoration) -> - if decoration.isType('block') - @component?.addBlockDecoration(decoration) - - # Extended: Calls your `callback` when the placeholder text is changed. - # - # * `callback` {Function} - # * `placeholderText` {String} new text - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangePlaceholderText: (callback) -> - @emitter.on 'did-change-placeholder-text', callback - - onDidChangeScrollTop: (callback) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.") - - @getElement().onDidChangeScrollTop(callback) - - onDidChangeScrollLeft: (callback) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::onDidChangeScrollLeft instead.") - - @getElement().onDidChangeScrollLeft(callback) - - onDidRequestAutoscroll: (callback) -> - @emitter.on 'did-request-autoscroll', callback - - # TODO Remove once the tabs package no longer uses .on subscriptions - onDidChangeIcon: (callback) -> - @emitter.on 'did-change-icon', callback - - onDidUpdateDecorations: (callback) -> - @decorationManager.onDidUpdateDecorations(callback) - - # Essential: Retrieves the current {TextBuffer}. - getBuffer: -> @buffer - - # Retrieves the current buffer's URI. - getURI: -> @buffer.getUri() - - # Create an {TextEditor} with its initial state based on this object - copy: -> - displayLayer = @displayLayer.copy() - selectionsMarkerLayer = displayLayer.getMarkerLayer(@buffer.getMarkerLayer(@selectionsMarkerLayer.id).copy().id) - softTabs = @getSoftTabs() - new TextEditor({ - @buffer, selectionsMarkerLayer, softTabs, - suppressCursorCreation: true, - tabLength: @tokenizedBuffer.getTabLength(), - initialScrollTopRow: @getScrollTopRow(), - initialScrollLeftColumn: @getScrollLeftColumn(), - @assert, displayLayer, grammar: @getGrammar(), - @autoWidth, @autoHeight, @showCursorOnSelection - }) - - # Controls visibility based on the given {Boolean}. - setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) - - setMini: (mini) -> - @update({mini}) - @mini - - isMini: -> @mini - - onDidChangeMini: (callback) -> - @emitter.on 'did-change-mini', callback - - setLineNumberGutterVisible: (lineNumberGutterVisible) -> @update({lineNumberGutterVisible}) - - isLineNumberGutterVisible: -> @lineNumberGutter.isVisible() - - onDidChangeLineNumberGutterVisible: (callback) -> - @emitter.on 'did-change-line-number-gutter-visible', callback - - # Essential: Calls your `callback` when a {Gutter} is added to the editor. - # Immediately calls your callback for each existing gutter. - # - # * `callback` {Function} - # * `gutter` {Gutter} that currently exists/was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeGutters: (callback) -> - @gutterContainer.observeGutters callback - - # Essential: Calls your `callback` when a {Gutter} is added to the editor. - # - # * `callback` {Function} - # * `gutter` {Gutter} that was added. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddGutter: (callback) -> - @gutterContainer.onDidAddGutter callback - - # Essential: Calls your `callback` when a {Gutter} is removed from the editor. - # - # * `callback` {Function} - # * `name` The name of the {Gutter} that was removed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveGutter: (callback) -> - @gutterContainer.onDidRemoveGutter callback - - # Set the number of characters that can be displayed horizontally in the - # editor. - # - # * `editorWidthInChars` A {Number} representing the width of the - # {TextEditorElement} in characters. - setEditorWidthInChars: (editorWidthInChars) -> @update({editorWidthInChars}) - - # Returns the editor width in characters. - getEditorWidthInChars: -> - if @width? and @defaultCharWidth > 0 - Math.max(0, Math.floor(@width / @defaultCharWidth)) - else - @editorWidthInChars - - ### - Section: File Details - ### - - # Essential: Get the editor's title for display in other parts of the - # UI such as the tabs. - # - # If the editor's buffer is saved, its title is the file name. If it is - # unsaved, its title is "untitled". - # - # Returns a {String}. - getTitle: -> - @getFileName() ? 'untitled' - - # Essential: Get unique title for display in other parts of the UI, such as - # the window title. - # - # If the editor's buffer is unsaved, its title is "untitled" - # If the editor's buffer is saved, its unique title is formatted as one - # of the following, - # * "" when it is the only editing buffer with this file name. - # * "" when other buffers have this file name. - # - # Returns a {String} - getLongTitle: -> - if @getPath() - fileName = @getFileName() - - allPathSegments = [] - for textEditor in atom.workspace.getTextEditors() when textEditor isnt this - if textEditor.getFileName() is fileName - directoryPath = fs.tildify(textEditor.getDirectoryPath()) - allPathSegments.push(directoryPath.split(path.sep)) - - if allPathSegments.length is 0 - return fileName - - ourPathSegments = fs.tildify(@getDirectoryPath()).split(path.sep) - allPathSegments.push ourPathSegments - - loop - firstSegment = ourPathSegments[0] - - commonBase = _.all(allPathSegments, (pathSegments) -> pathSegments.length > 1 and pathSegments[0] is firstSegment) - if commonBase - pathSegments.shift() for pathSegments in allPathSegments - else - break - - "#{fileName} \u2014 #{path.join(pathSegments...)}" - else - 'untitled' - - # Essential: Returns the {String} path of this editor's text buffer. - getPath: -> @buffer.getPath() - - getFileName: -> - if fullPath = @getPath() - path.basename(fullPath) - else - null - - getDirectoryPath: -> - if fullPath = @getPath() - path.dirname(fullPath) - else - null - - # Extended: Returns the {String} character set encoding of this editor's text - # buffer. - getEncoding: -> @buffer.getEncoding() - - # Extended: Set the character set encoding to use in this editor's text - # buffer. - # - # * `encoding` The {String} character set encoding name such as 'utf8' - setEncoding: (encoding) -> @buffer.setEncoding(encoding) - - # Essential: Returns {Boolean} `true` if this editor has been modified. - isModified: -> @buffer.isModified() - - # Essential: Returns {Boolean} `true` if this editor has no content. - isEmpty: -> @buffer.isEmpty() - - ### - Section: File Operations - ### - - # Essential: Saves the editor's text buffer. - # - # See {TextBuffer::save} for more details. - save: -> @buffer.save() - - # Essential: Saves the editor's text buffer as the given path. - # - # See {TextBuffer::saveAs} for more details. - # - # * `filePath` A {String} path. - saveAs: (filePath) -> @buffer.saveAs(filePath) - - # Determine whether the user should be prompted to save before closing - # this editor. - shouldPromptToSave: ({windowCloseRequested, projectHasPaths}={}) -> - if windowCloseRequested and projectHasPaths and atom.stateStore.isConnected() - @buffer.isInConflict() - else - @isModified() and not @buffer.hasMultipleEditors() - - # Returns an {Object} to configure dialog shown when this editor is saved - # via {Pane::saveItemAs}. - getSaveDialogOptions: -> {} - - ### - Section: Reading Text - ### - - # Essential: Returns a {String} representing the entire contents of the editor. - getText: -> @buffer.getText() - - # Essential: Get the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # - # Returns a {String}. - getTextInBufferRange: (range) -> - @buffer.getTextInRange(range) - - # Essential: Returns a {Number} representing the number of lines in the buffer. - getLineCount: -> @buffer.getLineCount() - - # Essential: Returns a {Number} representing the number of screen lines in the - # editor. This accounts for folds. - getScreenLineCount: -> @displayLayer.getScreenLineCount() - - getApproximateScreenLineCount: -> @displayLayer.getApproximateScreenLineCount() - - # Essential: Returns a {Number} representing the last zero-indexed buffer row - # number of the editor. - getLastBufferRow: -> @buffer.getLastRow() - - # Essential: Returns a {Number} representing the last zero-indexed screen row - # number of the editor. - getLastScreenRow: -> @getScreenLineCount() - 1 - - # Essential: Returns a {String} representing the contents of the line at the - # given buffer row. - # - # * `bufferRow` A {Number} representing a zero-indexed buffer row. - lineTextForBufferRow: (bufferRow) -> @buffer.lineForRow(bufferRow) - - # Essential: Returns a {String} representing the contents of the line at the - # given screen row. - # - # * `screenRow` A {Number} representing a zero-indexed screen row. - lineTextForScreenRow: (screenRow) -> - @screenLineForScreenRow(screenRow)?.lineText - - logScreenLines: (start=0, end=@getLastScreenRow()) -> - for row in [start..end] - line = @lineTextForScreenRow(row) - console.log row, @bufferRowForScreenRow(row), line, line.length - return - - tokensForScreenRow: (screenRow) -> - tokens = [] - lineTextIndex = 0 - currentTokenScopes = [] - {lineText, tags} = @screenLineForScreenRow(screenRow) - for tag in tags - if @displayLayer.isOpenTag(tag) - currentTokenScopes.push(@displayLayer.classNameForTag(tag)) - else if @displayLayer.isCloseTag(tag) - currentTokenScopes.pop() - else - tokens.push({ - text: lineText.substr(lineTextIndex, tag) - scopes: currentTokenScopes.slice() - }) - lineTextIndex += tag - tokens - - screenLineForScreenRow: (screenRow) -> - @displayLayer.getScreenLine(screenRow) - - bufferRowForScreenRow: (screenRow) -> - @displayLayer.translateScreenPosition(Point(screenRow, 0)).row - - bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - @displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow + 1) - - screenRowForBufferRow: (row) -> - @displayLayer.translateBufferPosition(Point(row, 0)).row - - getRightmostScreenPosition: -> @displayLayer.getRightmostScreenPosition() - - getApproximateRightmostScreenPosition: -> @displayLayer.getApproximateRightmostScreenPosition() - - getMaxScreenLineLength: -> @getRightmostScreenPosition().column - - getLongestScreenRow: -> @getRightmostScreenPosition().row - - getApproximateLongestScreenRow: -> @getApproximateRightmostScreenPosition().row - - lineLengthForScreenRow: (screenRow) -> @displayLayer.lineLengthForScreenRow(screenRow) - - # Returns the range for the given buffer row. - # - # * `row` A row {Number}. - # * `options` (optional) An options hash with an `includeNewline` key. - # - # Returns a {Range}. - bufferRangeForBufferRow: (row, {includeNewline}={}) -> @buffer.rangeForRow(row, includeNewline) - - # Get the text in the given {Range}. - # - # Returns a {String}. - getTextInRange: (range) -> @buffer.getTextInRange(range) - - # {Delegates to: TextBuffer.isRowBlank} - isBufferRowBlank: (bufferRow) -> @buffer.isRowBlank(bufferRow) - - # {Delegates to: TextBuffer.nextNonBlankRow} - nextNonBlankBufferRow: (bufferRow) -> @buffer.nextNonBlankRow(bufferRow) - - # {Delegates to: TextBuffer.getEndPosition} - getEofBufferPosition: -> @buffer.getEndPosition() - - # Essential: Get the {Range} of the paragraph surrounding the most recently added - # cursor. - # - # Returns a {Range}. - getCurrentParagraphBufferRange: -> - @getLastCursor().getCurrentParagraphBufferRange() - - - ### - Section: Mutating Text - ### - - # Essential: Replaces the entire contents of the buffer with the given {String}. - # - # * `text` A {String} to replace with - setText: (text) -> @buffer.setText(text) - - # Essential: Set the text in the given {Range} in buffer coordinates. - # - # * `range` A {Range} or range-compatible {Array}. - # * `text` A {String} - # * `options` (optional) {Object} - # * `normalizeLineEndings` (optional) {Boolean} (default: true) - # * `undo` (optional) {String} 'skip' will skip the undo system - # - # Returns the {Range} of the newly-inserted text. - setTextInBufferRange: (range, text, options) -> @getBuffer().setTextInRange(range, text, options) - - # Essential: For each selection, replace the selected text with the given text. - # - # * `text` A {String} representing the text to insert. - # * `options` (optional) See {Selection::insertText}. - # - # Returns a {Range} when the text has been inserted - # Returns a {Boolean} false when the text has not been inserted - insertText: (text, options={}) -> - return false unless @emitWillInsertTextEvent(text) - - groupingInterval = if options.groupUndo - @undoGroupingInterval - else - 0 - - options.autoIndentNewline ?= @shouldAutoIndent() - options.autoDecreaseIndent ?= @shouldAutoIndent() - @mutateSelectedText( - (selection) => - range = selection.insertText(text, options) - didInsertEvent = {text, range} - @emitter.emit 'did-insert-text', didInsertEvent - range - , groupingInterval - ) - - # Essential: For each selection, replace the selected text with a newline. - insertNewline: (options) -> - @insertText('\n', options) - - # Essential: For each selection, if the selection is empty, delete the character - # following the cursor. Otherwise delete the selected text. - delete: -> - @mutateSelectedText (selection) -> selection.delete() - - # Essential: For each selection, if the selection is empty, delete the character - # preceding the cursor. Otherwise delete the selected text. - backspace: -> - @mutateSelectedText (selection) -> selection.backspace() - - # Extended: Mutate the text of all the selections in a single transaction. - # - # All the changes made inside the given {Function} can be reverted with a - # single call to {::undo}. - # - # * `fn` A {Function} that will be called once for each {Selection}. The first - # argument will be a {Selection} and the second argument will be the - # {Number} index of that selection. - mutateSelectedText: (fn, groupingInterval=0) -> - @mergeIntersectingSelections => - @transact groupingInterval, => - fn(selection, index) for selection, index in @getSelectionsOrderedByBufferPosition() - - # Move lines intersecting the most recent selection or multiple selections - # up by one row in screen coordinates. - moveLineUp: -> - selections = @getSelectedBufferRanges().sort((a, b) -> a.compare(b)) - - if selections[0].start.row is 0 - return - - if selections[selections.length - 1].start.row is @getLastBufferRow() and @buffer.getLastLine() is '' - return - - @transact => - newSelectionRanges = [] - - while selections.length > 0 - # Find selections spanning a contiguous set of lines - selection = selections.shift() - selectionsToMove = [selection] - - while selection.end.row is selections[0]?.start.row - selectionsToMove.push(selections[0]) - selection.end.row = selections[0].end.row - selections.shift() - - # Compute the buffer range spanned by all these selections, expanding it - # so that it includes any folded region that intersects them. - startRow = selection.start.row - endRow = selection.end.row - if selection.end.row > selection.start.row and selection.end.column is 0 - # Don't move the last line of a multi-line selection if the selection ends at column 0 - endRow-- - - startRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow) - endRow = @displayLayer.findBoundaryFollowingBufferRow(endRow + 1) - linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) - - # If selected line range is preceded by a fold, one line above on screen - # could be multiple lines in the buffer. - precedingRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow - 1) - insertDelta = linesRange.start.row - precedingRow - - # Any folds in the text that is moved will need to be re-created. - # It includes the folds that were intersecting with the selection. - rangesToRefold = @displayLayer - .destroyFoldsIntersectingBufferRange(linesRange) - .map((range) -> range.translate([-insertDelta, 0])) - - # Delete lines spanned by selection and insert them on the preceding buffer row - lines = @buffer.getTextInRange(linesRange) - lines += @buffer.lineEndingForRow(linesRange.end.row - 2) unless lines[lines.length - 1] is '\n' - @buffer.delete(linesRange) - @buffer.insert([precedingRow, 0], lines) - - # Restore folds that existed before the lines were moved - for rangeToRefold in rangesToRefold - @displayLayer.foldBufferRange(rangeToRefold) - - for selection in selectionsToMove - newSelectionRanges.push(selection.translate([-insertDelta, 0])) - - @setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) - @autoIndentSelectedRows() if @shouldAutoIndent() - @scrollToBufferPosition([newSelectionRanges[0].start.row, 0]) - - # Move lines intersecting the most recent selection or multiple selections - # down by one row in screen coordinates. - moveLineDown: -> - selections = @getSelectedBufferRanges() - selections.sort (a, b) -> a.compare(b) - selections = selections.reverse() - - @transact => - @consolidateSelections() - newSelectionRanges = [] - - while selections.length > 0 - # Find selections spanning a contiguous set of lines - selection = selections.shift() - selectionsToMove = [selection] - - # if the current selection start row matches the next selections' end row - make them one selection - while selection.start.row is selections[0]?.end.row - selectionsToMove.push(selections[0]) - selection.start.row = selections[0].start.row - selections.shift() - - # Compute the buffer range spanned by all these selections, expanding it - # so that it includes any folded region that intersects them. - startRow = selection.start.row - endRow = selection.end.row - if selection.end.row > selection.start.row and selection.end.column is 0 - # Don't move the last line of a multi-line selection if the selection ends at column 0 - endRow-- - - startRow = @displayLayer.findBoundaryPrecedingBufferRow(startRow) - endRow = @displayLayer.findBoundaryFollowingBufferRow(endRow + 1) - linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) - - # If selected line range is followed by a fold, one line below on screen - # could be multiple lines in the buffer. But at the same time, if the - # next buffer row is wrapped, one line in the buffer can represent many - # screen rows. - followingRow = Math.min(@buffer.getLineCount(), @displayLayer.findBoundaryFollowingBufferRow(endRow + 1)) - insertDelta = followingRow - linesRange.end.row - - # Any folds in the text that is moved will need to be re-created. - # It includes the folds that were intersecting with the selection. - rangesToRefold = @displayLayer - .destroyFoldsIntersectingBufferRange(linesRange) - .map((range) -> range.translate([insertDelta, 0])) - - # Delete lines spanned by selection and insert them on the following correct buffer row - lines = @buffer.getTextInRange(linesRange) - if followingRow - 1 is @buffer.getLastRow() - lines = "\n#{lines}" - - @buffer.insert([followingRow, 0], lines) - @buffer.delete(linesRange) - - # Restore folds that existed before the lines were moved - for rangeToRefold in rangesToRefold - @displayLayer.foldBufferRange(rangeToRefold) - - for selection in selectionsToMove - newSelectionRanges.push(selection.translate([insertDelta, 0])) - - @setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) - @autoIndentSelectedRows() if @shouldAutoIndent() - @scrollToBufferPosition([newSelectionRanges[0].start.row - 1, 0]) - - # Move any active selections one column to the left. - moveSelectionLeft: -> - selections = @getSelectedBufferRanges() - noSelectionAtStartOfLine = selections.every((selection) -> - selection.start.column isnt 0 - ) - - translationDelta = [0, -1] - translatedRanges = [] - - if noSelectionAtStartOfLine - @transact => - for selection in selections - charToLeftOfSelection = new Range(selection.start.translate(translationDelta), selection.start) - charTextToLeftOfSelection = @buffer.getTextInRange(charToLeftOfSelection) - - @buffer.insert(selection.end, charTextToLeftOfSelection) - @buffer.delete(charToLeftOfSelection) - translatedRanges.push(selection.translate(translationDelta)) - - @setSelectedBufferRanges(translatedRanges) - - # Move any active selections one column to the right. - moveSelectionRight: -> - selections = @getSelectedBufferRanges() - noSelectionAtEndOfLine = selections.every((selection) => - selection.end.column isnt @buffer.lineLengthForRow(selection.end.row) - ) - - translationDelta = [0, 1] - translatedRanges = [] - - if noSelectionAtEndOfLine - @transact => - for selection in selections - charToRightOfSelection = new Range(selection.end, selection.end.translate(translationDelta)) - charTextToRightOfSelection = @buffer.getTextInRange(charToRightOfSelection) - - @buffer.delete(charToRightOfSelection) - @buffer.insert(selection.start, charTextToRightOfSelection) - translatedRanges.push(selection.translate(translationDelta)) - - @setSelectedBufferRanges(translatedRanges) - - duplicateLines: -> - @transact => - selections = @getSelectionsOrderedByBufferPosition() - previousSelectionRanges = [] - - i = selections.length - 1 - while i >= 0 - j = i - previousSelectionRanges[i] = selections[i].getBufferRange() - if selections[i].isEmpty() - {start} = selections[i].getScreenRange() - selections[i].setScreenRange([[start.row, 0], [start.row + 1, 0]], preserveFolds: true) - [startRow, endRow] = selections[i].getBufferRowRange() - endRow++ - while i > 0 - [previousSelectionStartRow, previousSelectionEndRow] = selections[i - 1].getBufferRowRange() - if previousSelectionEndRow is startRow - startRow = previousSelectionStartRow - previousSelectionRanges[i - 1] = selections[i - 1].getBufferRange() - i-- - else - break - - intersectingFolds = @displayLayer.foldsIntersectingBufferRange([[startRow, 0], [endRow, 0]]) - textToDuplicate = @getTextInBufferRange([[startRow, 0], [endRow, 0]]) - textToDuplicate = '\n' + textToDuplicate if endRow > @getLastBufferRow() - @buffer.insert([endRow, 0], textToDuplicate) - - insertedRowCount = endRow - startRow - - for k in [i..j] by 1 - selections[k].setBufferRange(previousSelectionRanges[k].translate([insertedRowCount, 0])) - - for fold in intersectingFolds - foldRange = @displayLayer.bufferRangeForFold(fold) - @displayLayer.foldBufferRange(foldRange.translate([insertedRowCount, 0])) - - i-- - - replaceSelectedText: (options={}, fn) -> - {selectWordIfEmpty} = options - @mutateSelectedText (selection) -> - selection.getBufferRange() - if selectWordIfEmpty and selection.isEmpty() - selection.selectWord() - text = selection.getText() - selection.deleteSelectedText() - range = selection.insertText(fn(text)) - selection.setBufferRange(range) - - # Split multi-line selections into one selection per line. - # - # Operates on all selections. This method breaks apart all multi-line - # selections to create multiple single-line selections that cumulatively cover - # the same original area. - splitSelectionsIntoLines: -> - @mergeIntersectingSelections => - for selection in @getSelections() - range = selection.getBufferRange() - continue if range.isSingleLine() - - {start, end} = range - @addSelectionForBufferRange([start, [start.row, Infinity]]) - {row} = start - while ++row < end.row - @addSelectionForBufferRange([[row, 0], [row, Infinity]]) - @addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) unless end.column is 0 - selection.destroy() - return - - # Extended: For each selection, transpose the selected text. - # - # If the selection is empty, the characters preceding and following the cursor - # are swapped. Otherwise, the selected characters are reversed. - transpose: -> - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectRight() - text = selection.getText() - selection.delete() - selection.cursor.moveLeft() - selection.insertText text - else - selection.insertText selection.getText().split('').reverse().join('') - - # Extended: Convert the selected text to upper case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - upperCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toUpperCase() - - # Extended: Convert the selected text to lower case. - # - # For each selection, if the selection is empty, converts the containing word - # to upper case. Otherwise convert the selected text to upper case. - lowerCase: -> - @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toLowerCase() - - # Extended: Toggle line comments for rows intersecting selections. - # - # If the current grammar doesn't support comments, does nothing. - toggleLineCommentsInSelection: -> - @mutateSelectedText (selection) -> selection.toggleLineComments() - - # Convert multiple lines to a single line. - # - # Operates on all selections. If the selection is empty, joins the current - # line with the next line. Otherwise it joins all lines that intersect the - # selection. - # - # Joining a line means that multiple lines are converted to a single line with - # the contents of each of the original non-empty lines separated by a space. - joinLines: -> - @mutateSelectedText (selection) -> selection.joinLines() - - # Extended: For each cursor, insert a newline at beginning the following line. - insertNewlineBelow: -> - @transact => - @moveToEndOfLine() - @insertNewline() - - # Extended: For each cursor, insert a newline at the end of the preceding line. - insertNewlineAbove: -> - @transact => - bufferRow = @getCursorBufferPosition().row - indentLevel = @indentationForBufferRow(bufferRow) - onFirstLine = bufferRow is 0 - - @moveToBeginningOfLine() - @moveLeft() - @insertNewline() - - if @shouldAutoIndent() and @indentationForBufferRow(bufferRow) < indentLevel - @setIndentationForBufferRow(bufferRow, indentLevel) - - if onFirstLine - @moveUp() - @moveToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfWord() - - # Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the - # previous word boundary. - deleteToPreviousWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToPreviousWordBoundary() - - # Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the - # next word boundary. - deleteToNextWordBoundary: -> - @mutateSelectedText (selection) -> selection.deleteToNextWordBoundary() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing subword following the cursor. Otherwise delete the selected - # text. - deleteToBeginningOfSubword: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfSubword() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing subword following the cursor. Otherwise delete the selected - # text. - deleteToEndOfSubword: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfSubword() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing line that precede the cursor. Otherwise delete the - # selected text. - deleteToBeginningOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToBeginningOfLine() - - # Extended: For each selection, if the selection is not empty, deletes the - # selection; otherwise, deletes all characters of the containing line - # following the cursor. If the cursor is already at the end of the line, - # deletes the following newline. - deleteToEndOfLine: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfLine() - - # Extended: For each selection, if the selection is empty, delete all characters - # of the containing word following the cursor. Otherwise delete the selected - # text. - deleteToEndOfWord: -> - @mutateSelectedText (selection) -> selection.deleteToEndOfWord() - - # Extended: Delete all lines intersecting selections. - deleteLine: -> - @mergeSelectionsOnSameRows() - @mutateSelectedText (selection) -> selection.deleteLine() - - ### - Section: History - ### - - # Essential: Undo the last change. - undo: -> - @avoidMergingSelections => @buffer.undo() - @getLastSelection().autoscroll() - - # Essential: Redo the last change. - redo: -> - @avoidMergingSelections => @buffer.redo() - @getLastSelection().autoscroll() - - # Extended: Batch multiple operations as a single undo/redo step. - # - # Any group of operations that are logically grouped from the perspective of - # undoing and redoing should be performed in a transaction. If you want to - # abort the transaction, call {::abortTransaction} to terminate the function's - # execution and revert any changes performed up to the abortion. - # - # * `groupingInterval` (optional) The {Number} of milliseconds for which this - # transaction should be considered 'groupable' after it begins. If a transaction - # with a positive `groupingInterval` is committed while the previous transaction is - # still 'groupable', the two transactions are merged with respect to undo and redo. - # * `fn` A {Function} to call inside the transaction. - transact: (groupingInterval, fn) -> - @buffer.transact(groupingInterval, fn) - - # Deprecated: Start an open-ended transaction. - beginTransaction: (groupingInterval) -> - Grim.deprecate('Transactions should be performed via TextEditor::transact only') - @buffer.beginTransaction(groupingInterval) - - # Deprecated: Commit an open-ended transaction started with {::beginTransaction}. - commitTransaction: -> - Grim.deprecate('Transactions should be performed via TextEditor::transact only') - @buffer.commitTransaction() - - # Extended: Abort an open transaction, undoing any operations performed so far - # within the transaction. - abortTransaction: -> @buffer.abortTransaction() - - # Extended: Create a pointer to the current state of the buffer for use - # with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. - # - # Returns a checkpoint value. - createCheckpoint: -> @buffer.createCheckpoint() - - # Extended: Revert the buffer to the state it was in when the given - # checkpoint was created. - # - # The redo stack will be empty following this operation, so changes since the - # checkpoint will be lost. If the given checkpoint is no longer present in the - # undo history, no changes will be made to the buffer and this method will - # return `false`. - # - # * `checkpoint` The checkpoint to revert to. - # - # Returns a {Boolean} indicating whether the operation succeeded. - revertToCheckpoint: (checkpoint) -> @buffer.revertToCheckpoint(checkpoint) - - # Extended: Group all changes since the given checkpoint into a single - # transaction for purposes of undo/redo. - # - # If the given checkpoint is no longer present in the undo history, no - # grouping will be performed and this method will return `false`. - # - # * `checkpoint` The checkpoint from which to group changes. - # - # Returns a {Boolean} indicating whether the operation succeeded. - groupChangesSinceCheckpoint: (checkpoint) -> @buffer.groupChangesSinceCheckpoint(checkpoint) - - ### - Section: TextEditor Coordinates - ### - - # Essential: Convert a position in buffer-coordinates to screen-coordinates. - # - # The position is clipped via {::clipBufferPosition} prior to the conversion. - # The position is also clipped via {::clipScreenPosition} following the - # conversion, which only makes a difference when `options` are supplied. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - screenPositionForBufferPosition: (bufferPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.translateBufferPosition(bufferPosition, options) - - # Essential: Convert a position in screen-coordinates to buffer-coordinates. - # - # The position is clipped via {::clipScreenPosition} prior to the conversion. - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # * `options` (optional) An options hash for {::clipScreenPosition}. - # - # Returns a {Point}. - bufferPositionForScreenPosition: (screenPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.translateScreenPosition(screenPosition, options) - - # Essential: Convert a range in buffer-coordinates to screen-coordinates. - # - # * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. - # - # Returns a {Range}. - screenRangeForBufferRange: (bufferRange, options) -> - bufferRange = Range.fromObject(bufferRange) - start = @screenPositionForBufferPosition(bufferRange.start, options) - end = @screenPositionForBufferPosition(bufferRange.end, options) - new Range(start, end) - - # Essential: Convert a range in screen-coordinates to buffer-coordinates. - # - # * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. - # - # Returns a {Range}. - bufferRangeForScreenRange: (screenRange) -> - screenRange = Range.fromObject(screenRange) - start = @bufferPositionForScreenPosition(screenRange.start) - end = @bufferPositionForScreenPosition(screenRange.end) - new Range(start, end) - - # Extended: Clip the given {Point} to a valid position in the buffer. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the buffer, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at buffer row 2 is 10 characters long - # editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `bufferPosition` The {Point} representing the position to clip. - # - # Returns a {Point}. - clipBufferPosition: (bufferPosition) -> @buffer.clipPosition(bufferPosition) - - # Extended: Clip the start and end of the given range to valid positions in the - # buffer. See {::clipBufferPosition} for more information. - # - # * `range` The {Range} to clip. - # - # Returns a {Range}. - clipBufferRange: (range) -> @buffer.clipRange(range) - - # Extended: Clip the given {Point} to a valid position on screen. - # - # If the given {Point} describes a position that is actually reachable by the - # cursor based on the current contents of the screen, it is returned - # unchanged. If the {Point} does not describe a valid position, the closest - # valid position is returned instead. - # - # ## Examples - # - # ```coffee - # editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` - # - # # When the line at screen row 2 is 10 characters long - # editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` - # ``` - # - # * `screenPosition` The {Point} representing the position to clip. - # * `options` (optional) {Object} - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. - # - # Returns a {Point}. - clipScreenPosition: (screenPosition, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @displayLayer.clipScreenPosition(screenPosition, options) - - # Extended: Clip the start and end of the given range to valid positions on screen. - # See {::clipScreenPosition} for more information. - # - # * `range` The {Range} to clip. - # * `options` (optional) See {::clipScreenPosition} `options`. - # - # Returns a {Range}. - clipScreenRange: (screenRange, options) -> - screenRange = Range.fromObject(screenRange) - start = @displayLayer.clipScreenPosition(screenRange.start, options) - end = @displayLayer.clipScreenPosition(screenRange.end, options) - Range(start, end) - - ### - Section: Decorations - ### - - # Essential: Add a decoration that tracks a {DisplayMarker}. When the - # marker moves, is invalidated, or is destroyed, the decoration will be - # updated to reflect the marker's state. - # - # The following are the supported decorations types: - # - # * __line__: Adds your CSS `class` to the line nodes within the range - # marked by the marker - # * __line-number__: Adds your CSS `class` to the line number nodes within the - # range marked by the marker - # * __highlight__: Adds a new highlight div to the editor surrounding the - # range marked by the marker. When the user selects text, the selection is - # visualized with a highlight decoration internally. The structure of this - # highlight will be - # ```html - #
- # - #
- #
- # ``` - # * __overlay__: Positions the view associated with the given item at the head - # or tail of the given `DisplayMarker`. - # * __gutter__: A decoration that tracks a {DisplayMarker} in a {Gutter}. Gutter - # decorations are created by calling {Gutter::decorateMarker} on the - # desired `Gutter` instance. - # * __block__: Positions the view associated with the given item before or - # after the row of the given `TextEditorMarker`. - # - # ## Arguments - # - # * `marker` A {DisplayMarker} you want this decoration to follow. - # * `decorationParams` An {Object} representing the decoration e.g. - # `{type: 'line-number', class: 'linter-error'}` - # * `type` There are several supported decoration types. The behavior of the - # types are as follows: - # * `line` Adds the given `class` to the lines overlapping the rows - # spanned by the `DisplayMarker`. - # * `line-number` Adds the given `class` to the line numbers overlapping - # the rows spanned by the `DisplayMarker`. - # * `text` Injects spans into all text overlapping the marked range, - # then adds the given `class` or `style` properties to these spans. - # Use this to manipulate the foreground color or styling of text in - # a given range. - # * `highlight` Creates an absolutely-positioned `.highlight` div - # containing nested divs to cover the marked region. For example, this - # is used to implement selections. - # * `overlay` Positions the view associated with the given item at the - # head or tail of the given `DisplayMarker`, depending on the `position` - # property. - # * `gutter` Tracks a {DisplayMarker} in a {Gutter}. Created by calling - # {Gutter::decorateMarker} on the desired `Gutter` instance. - # * `block` Positions the view associated with the given item before or - # after the row of the given `TextEditorMarker`, depending on the `position` - # property. - # * `cursor` Renders a cursor at the head of the given marker. If multiple - # decorations are created for the same marker, their class strings and - # style objects are combined into a single cursor. You can use this - # decoration type to style existing cursors by passing in their markers - # or render artificial cursors that don't actually exist in the model - # by passing a marker that isn't actually associated with a cursor. - # * `class` This CSS class will be applied to the decorated line number, - # line, text spans, highlight regions, cursors, or overlay. - # * `style` An {Object} containing CSS style properties to apply to the - # relevant DOM node. Currently this only works with a `type` of `cursor` - # or `text`. - # * `item` (optional) An {HTMLElement} or a model {Object} with a - # corresponding view registered. Only applicable to the `gutter`, - # `overlay` and `block` decoration types. - # * `onlyHead` (optional) If `true`, the decoration will only be applied to - # the head of the `DisplayMarker`. Only applicable to the `line` and - # `line-number` decoration types. - # * `onlyEmpty` (optional) If `true`, the decoration will only be applied if - # the associated `DisplayMarker` is empty. Only applicable to the `gutter`, - # `line`, and `line-number` decoration types. - # * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied - # if the associated `DisplayMarker` is non-empty. Only applicable to the - # `gutter`, `line`, and `line-number` decoration types. - # * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied - # to the last row of a non-empty range, even if it ends at column 0. - # Defaults to `true`. Only applicable to the `gutter`, `line`, and - # `line-number` decoration types. - # * `position` (optional) Only applicable to decorations of type `overlay` and `block`. - # Controls where the view is positioned relative to the `TextEditorMarker`. - # Values can be `'head'` (the default) or `'tail'` for overlay decorations, and - # `'before'` (the default) or `'after'` for block decorations. - # * `avoidOverflow` (optional) Only applicable to decorations of type - # `overlay`. Determines whether the decoration adjusts its horizontal or - # vertical position to remain fully visible when it would otherwise - # overflow the editor. Defaults to `true`. - # - # Returns a {Decoration} object - decorateMarker: (marker, decorationParams) -> - @decorationManager.decorateMarker(marker, decorationParams) - - # Essential: Add a decoration to every marker in the given marker layer. Can - # be used to decorate a large number of markers without having to create and - # manage many individual decorations. - # - # * `markerLayer` A {DisplayMarkerLayer} or {MarkerLayer} to decorate. - # * `decorationParams` The same parameters that are passed to - # {TextEditor::decorateMarker}, except the `type` cannot be `overlay` or `gutter`. - # - # Returns a {LayerDecoration}. - decorateMarkerLayer: (markerLayer, decorationParams) -> - @decorationManager.decorateMarkerLayer(markerLayer, decorationParams) - - # Deprecated: Get all the decorations within a screen row range on the default - # layer. - # - # * `startScreenRow` the {Number} beginning screen row - # * `endScreenRow` the {Number} end screen row (inclusive) - # - # Returns an {Object} of decorations in the form - # `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` - # where the keys are {DisplayMarker} IDs, and the values are an array of decoration - # params objects attached to the marker. - # Returns an empty object when no decorations are found - decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> - @decorationManager.decorationsForScreenRowRange(startScreenRow, endScreenRow) - - decorationsStateForScreenRowRange: (startScreenRow, endScreenRow) -> - @decorationManager.decorationsStateForScreenRowRange(startScreenRow, endScreenRow) - - # Extended: Get all decorations. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getDecorations: (propertyFilter) -> - @decorationManager.getDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineDecorations: (propertyFilter) -> - @decorationManager.getLineDecorations(propertyFilter) - - # Extended: Get all decorations of type 'line-number'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getLineNumberDecorations: (propertyFilter) -> - @decorationManager.getLineNumberDecorations(propertyFilter) - - # Extended: Get all decorations of type 'highlight'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getHighlightDecorations: (propertyFilter) -> - @decorationManager.getHighlightDecorations(propertyFilter) - - # Extended: Get all decorations of type 'overlay'. - # - # * `propertyFilter` (optional) An {Object} containing key value pairs that - # the returned decorations' properties must match. - # - # Returns an {Array} of {Decoration}s. - getOverlayDecorations: (propertyFilter) -> - @decorationManager.getOverlayDecorations(propertyFilter) - - ### - Section: Markers - ### - - # Essential: Create a marker on the default marker layer with the given range - # in buffer coordinates. This marker will maintain its logical location as the - # buffer is changed, so if you mark a particular word, the marker will remain - # over that word even if the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `maintainHistory` (optional) {Boolean} Whether to store this marker's - # range before and after each change in the undo history. This allows the - # marker's position to be restored more accurately for certain undo/redo - # operations, but uses more time and memory. (default: false) - # * `reversed` (optional) {Boolean} Creates the marker in a reversed - # orientation. (default: false) - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markBufferRange: (bufferRange, options) -> - @defaultMarkerLayer.markBufferRange(bufferRange, options) - - # Essential: Create a marker on the default marker layer with the given range - # in screen coordinates. This marker will maintain its logical location as the - # buffer is changed, so if you mark a particular word, the marker will remain - # over that word even if the word's location in the buffer changes. - # - # * `range` A {Range} or range-compatible {Array} - # * `properties` A hash of key-value pairs to associate with the marker. There - # are also reserved property names that have marker-specific meaning. - # * `maintainHistory` (optional) {Boolean} Whether to store this marker's - # range before and after each change in the undo history. This allows the - # marker's position to be restored more accurately for certain undo/redo - # operations, but uses more time and memory. (default: false) - # * `reversed` (optional) {Boolean} Creates the marker in a reversed - # orientation. (default: false) - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markScreenRange: (screenRange, options) -> - @defaultMarkerLayer.markScreenRange(screenRange, options) - - # Essential: Create a marker on the default marker layer with the given buffer - # position and no tail. To group multiple markers together in their own - # private layer, see {::addMarkerLayer}. - # - # * `bufferPosition` A {Point} or point-compatible {Array} - # * `options` (optional) An {Object} with the following keys: - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # - # Returns a {DisplayMarker}. - markBufferPosition: (bufferPosition, options) -> - @defaultMarkerLayer.markBufferPosition(bufferPosition, options) - - # Essential: Create a marker on the default marker layer with the given screen - # position and no tail. To group multiple markers together in their own - # private layer, see {::addMarkerLayer}. - # - # * `screenPosition` A {Point} or point-compatible {Array} - # * `options` (optional) An {Object} with the following keys: - # * `invalidate` (optional) {String} Determines the rules by which changes - # to the buffer *invalidate* the marker. (default: 'overlap') It can be - # any of the following strategies, in order of fragility: - # * __never__: The marker is never marked as invalid. This is a good choice for - # markers representing selections in an editor. - # * __surround__: The marker is invalidated by changes that completely surround it. - # * __overlap__: The marker is invalidated by changes that surround the - # start or end of the marker. This is the default. - # * __inside__: The marker is invalidated by changes that extend into the - # inside of the marker. Changes that end at the marker's start or - # start at the marker's end do not invalidate the marker. - # * __touch__: The marker is invalidated by a change that touches the marked - # region in any way, including changes that end at the marker's - # start or start at the marker's end. This is the most fragile strategy. - # * `clipDirection` {String} If `'backward'`, returns the first valid - # position preceding an invalid position. If `'forward'`, returns the - # first valid position following an invalid position. If `'closest'`, - # returns the first valid position closest to an invalid position. - # Defaults to `'closest'`. - # - # Returns a {DisplayMarker}. - markScreenPosition: (screenPosition, options) -> - @defaultMarkerLayer.markScreenPosition(screenPosition, options) - - # Essential: Find all {DisplayMarker}s on the default marker layer that - # match the given properties. - # - # This method finds markers based on the given properties. Markers can be - # associated with custom properties that will be compared with basic equality. - # In addition, there are several special properties that will be compared - # with the range of the markers rather than their properties. - # - # * `properties` An {Object} containing properties that each returned marker - # must satisfy. Markers can be associated with custom properties, which are - # compared with basic equality. In addition, several reserved properties - # can be used to filter markers based on their current range: - # * `startBufferRow` Only include markers starting at this row in buffer - # coordinates. - # * `endBufferRow` Only include markers ending at this row in buffer - # coordinates. - # * `containsBufferRange` Only include markers containing this {Range} or - # in range-compatible {Array} in buffer coordinates. - # * `containsBufferPosition` Only include markers containing this {Point} - # or {Array} of `[row, column]` in buffer coordinates. - # - # Returns an {Array} of {DisplayMarker}s - findMarkers: (params) -> - @defaultMarkerLayer.findMarkers(params) - - # Extended: Get the {DisplayMarker} on the default layer for the given - # marker id. - # - # * `id` {Number} id of the marker - getMarker: (id) -> - @defaultMarkerLayer.getMarker(id) - - # Extended: Get all {DisplayMarker}s on the default marker layer. Consider - # using {::findMarkers} - getMarkers: -> - @defaultMarkerLayer.getMarkers() - - # Extended: Get the number of markers in the default marker layer. - # - # Returns a {Number}. - getMarkerCount: -> - @defaultMarkerLayer.getMarkerCount() - - destroyMarker: (id) -> - @getMarker(id)?.destroy() - - # Essential: Create a marker layer to group related markers. - # - # * `options` An {Object} containing the following keys: - # * `maintainHistory` A {Boolean} indicating whether marker state should be - # restored on undo/redo. Defaults to `false`. - # * `persistent` A {Boolean} indicating whether or not this marker layer - # should be serialized and deserialized along with the rest of the - # buffer. Defaults to `false`. If `true`, the marker layer's id will be - # maintained across the serialization boundary, allowing you to retrieve - # it via {::getMarkerLayer}. - # - # Returns a {DisplayMarkerLayer}. - addMarkerLayer: (options) -> - @displayLayer.addMarkerLayer(options) - - # Essential: Get a {DisplayMarkerLayer} by id. - # - # * `id` The id of the marker layer to retrieve. - # - # Returns a {DisplayMarkerLayer} or `undefined` if no layer exists with the - # given id. - getMarkerLayer: (id) -> - @displayLayer.getMarkerLayer(id) - - # Essential: Get the default {DisplayMarkerLayer}. - # - # All marker APIs not tied to an explicit layer interact with this default - # layer. - # - # Returns a {DisplayMarkerLayer}. - getDefaultMarkerLayer: -> - @defaultMarkerLayer - - ### - Section: Cursors - ### - - # Essential: Get the position of the most recently added cursor in buffer - # coordinates. - # - # Returns a {Point} - getCursorBufferPosition: -> - @getLastCursor().getBufferPosition() - - # Essential: Get the position of all the cursor positions in buffer coordinates. - # - # Returns {Array} of {Point}s in the order they were added - getCursorBufferPositions: -> - cursor.getBufferPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in buffer coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} containing the following keys: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorBufferPosition: (position, options) -> - @moveCursors (cursor) -> cursor.setBufferPosition(position, options) - - # Essential: Get a {Cursor} at given screen coordinates {Point} - # - # * `position` A {Point} or {Array} of `[row, column]` - # - # Returns the first matched {Cursor} or undefined - getCursorAtScreenPosition: (position) -> - if selection = @getSelectionAtScreenPosition(position) - if selection.getHeadScreenPosition().isEqual(position) - selection.cursor - - # Essential: Get the position of the most recently added cursor in screen - # coordinates. - # - # Returns a {Point}. - getCursorScreenPosition: -> - @getLastCursor().getScreenPosition() - - # Essential: Get the position of all the cursor positions in screen coordinates. - # - # Returns {Array} of {Point}s in the order the cursors were added - getCursorScreenPositions: -> - cursor.getScreenPosition() for cursor in @getCursors() - - # Essential: Move the cursor to the given position in screen coordinates. - # - # If there are multiple cursors, they will be consolidated to a single cursor. - # - # * `position` A {Point} or {Array} of `[row, column]` - # * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: - # * `autoscroll` Determines whether the editor scrolls to the new cursor's - # position. Defaults to true. - setCursorScreenPosition: (position, options) -> - if options?.clip? - Grim.deprecate("The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.") - options.clipDirection ?= options.clip - if options?.wrapAtSoftNewlines? - Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapAtSoftNewlines then 'forward' else 'backward' - if options?.wrapBeyondNewlines? - Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") - options.clipDirection ?= if options.wrapBeyondNewlines then 'forward' else 'backward' - - @moveCursors (cursor) -> cursor.setScreenPosition(position, options) - - # Essential: Add a cursor at the given position in buffer coordinates. - # - # * `bufferPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtBufferPosition: (bufferPosition, options) -> - @selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Add a cursor at the position in screen coordinates. - # - # * `screenPosition` A {Point} or {Array} of `[row, column]` - # - # Returns a {Cursor}. - addCursorAtScreenPosition: (screenPosition, options) -> - @selectionsMarkerLayer.markScreenPosition(screenPosition, {invalidate: 'never'}) - @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false - @getLastSelection().cursor - - # Essential: Returns {Boolean} indicating whether or not there are multiple cursors. - hasMultipleCursors: -> - @getCursors().length > 1 - - # Essential: Move every cursor up one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveUp: (lineCount) -> - @moveCursors (cursor) -> cursor.moveUp(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor down one row in screen coordinates. - # - # * `lineCount` (optional) {Number} number of lines to move - moveDown: (lineCount) -> - @moveCursors (cursor) -> cursor.moveDown(lineCount, moveToEndOfSelection: true) - - # Essential: Move every cursor left one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveLeft: (columnCount) -> - @moveCursors (cursor) -> cursor.moveLeft(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor right one column. - # - # * `columnCount` (optional) {Number} number of columns to move (default: 1) - moveRight: (columnCount) -> - @moveCursors (cursor) -> cursor.moveRight(columnCount, moveToEndOfSelection: true) - - # Essential: Move every cursor to the beginning of its line in buffer coordinates. - moveToBeginningOfLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfLine() - - # Essential: Move every cursor to the beginning of its line in screen coordinates. - moveToBeginningOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfScreenLine() - - # Essential: Move every cursor to the first non-whitespace character of its line. - moveToFirstCharacterOfLine: -> - @moveCursors (cursor) -> cursor.moveToFirstCharacterOfLine() - - # Essential: Move every cursor to the end of its line in buffer coordinates. - moveToEndOfLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfLine() - - # Essential: Move every cursor to the end of its line in screen coordinates. - moveToEndOfScreenLine: -> - @moveCursors (cursor) -> cursor.moveToEndOfScreenLine() - - # Essential: Move every cursor to the beginning of its surrounding word. - moveToBeginningOfWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfWord() - - # Essential: Move every cursor to the end of its surrounding word. - moveToEndOfWord: -> - @moveCursors (cursor) -> cursor.moveToEndOfWord() - - # Cursor Extended - - # Extended: Move every cursor to the top of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToTop: -> - @moveCursors (cursor) -> cursor.moveToTop() - - # Extended: Move every cursor to the bottom of the buffer. - # - # If there are multiple cursors, they will be merged into a single cursor. - moveToBottom: -> - @moveCursors (cursor) -> cursor.moveToBottom() - - # Extended: Move every cursor to the beginning of the next word. - moveToBeginningOfNextWord: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextWord() - - # Extended: Move every cursor to the previous word boundary. - moveToPreviousWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToPreviousWordBoundary() - - # Extended: Move every cursor to the next word boundary. - moveToNextWordBoundary: -> - @moveCursors (cursor) -> cursor.moveToNextWordBoundary() - - # Extended: Move every cursor to the previous subword boundary. - moveToPreviousSubwordBoundary: -> - @moveCursors (cursor) -> cursor.moveToPreviousSubwordBoundary() - - # Extended: Move every cursor to the next subword boundary. - moveToNextSubwordBoundary: -> - @moveCursors (cursor) -> cursor.moveToNextSubwordBoundary() - - # Extended: Move every cursor to the beginning of the next paragraph. - moveToBeginningOfNextParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfNextParagraph() - - # Extended: Move every cursor to the beginning of the previous paragraph. - moveToBeginningOfPreviousParagraph: -> - @moveCursors (cursor) -> cursor.moveToBeginningOfPreviousParagraph() - - # Extended: Returns the most recently added {Cursor} - getLastCursor: -> - @createLastSelectionIfNeeded() - _.last(@cursors) - - # Extended: Returns the word surrounding the most recently added cursor. - # - # * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. - getWordUnderCursor: (options) -> - @getTextInBufferRange(@getLastCursor().getCurrentWordBufferRange(options)) - - # Extended: Get an Array of all {Cursor}s. - getCursors: -> - @createLastSelectionIfNeeded() - @cursors.slice() - - # Extended: Get all {Cursors}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getCursorsOrderedByBufferPosition: -> - @getCursors().sort (a, b) -> a.compare(b) - - cursorsForScreenRowRange: (startScreenRow, endScreenRow) -> - cursors = [] - for marker in @selectionsMarkerLayer.findMarkers(intersectsScreenRowRange: [startScreenRow, endScreenRow]) - if cursor = @cursorsByMarkerId.get(marker.id) - cursors.push(cursor) - cursors - - # Add a cursor based on the given {DisplayMarker}. - addCursor: (marker) -> - cursor = new Cursor(editor: this, marker: marker, showCursorOnSelection: @showCursorOnSelection) - @cursors.push(cursor) - @cursorsByMarkerId.set(marker.id, cursor) - cursor - - moveCursors: (fn) -> - @transact => - fn(cursor) for cursor in @getCursors() - @mergeCursors() - - cursorMoved: (event) -> - @emitter.emit 'did-change-cursor-position', event - - # Merge cursors that have the same screen position - mergeCursors: -> - positions = {} - for cursor in @getCursors() - position = cursor.getBufferPosition().toString() - if positions.hasOwnProperty(position) - cursor.destroy() - else - positions[position] = true - return - - ### - Section: Selections - ### - - # Essential: Get the selected text of the most recently added selection. - # - # Returns a {String}. - getSelectedText: -> - @getLastSelection().getText() - - # Essential: Get the {Range} of the most recently added selection in buffer - # coordinates. - # - # Returns a {Range}. - getSelectedBufferRange: -> - @getLastSelection().getBufferRange() - - # Essential: Get the {Range}s of all selections in buffer coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedBufferRanges: -> - selection.getBufferRange() for selection in @getSelections() - - # Essential: Set the selected range in buffer coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - setSelectedBufferRange: (bufferRange, options) -> - @setSelectedBufferRanges([bufferRange], options) - - # Essential: Set the selected ranges in buffer coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - setSelectedBufferRanges: (bufferRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedBufferRanges") unless bufferRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[bufferRanges.length...] - - @mergeIntersectingSelections options, => - for bufferRange, i in bufferRanges - bufferRange = Range.fromObject(bufferRange) - if selections[i] - selections[i].setBufferRange(bufferRange, options) - else - @addSelectionForBufferRange(bufferRange, options) - return - - # Essential: Get the {Range} of the most recently added selection in screen - # coordinates. - # - # Returns a {Range}. - getSelectedScreenRange: -> - @getLastSelection().getScreenRange() - - # Essential: Get the {Range}s of all selections in screen coordinates. - # - # The ranges are sorted by when the selections were added. Most recent at the end. - # - # Returns an {Array} of {Range}s. - getSelectedScreenRanges: -> - selection.getScreenRange() for selection in @getSelections() - - # Essential: Set the selected range in screen coordinates. If there are multiple - # selections, they are reduced to a single selection with the given range. - # - # * `screenRange` A {Range} or range-compatible {Array}. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRange: (screenRange, options) -> - @setSelectedBufferRange(@bufferRangeForScreenRange(screenRange, options), options) - - # Essential: Set the selected ranges in screen coordinates. If there are multiple - # selections, they are replaced by new selections with the given ranges. - # - # * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - setSelectedScreenRanges: (screenRanges, options={}) -> - throw new Error("Passed an empty array to setSelectedScreenRanges") unless screenRanges.length - - selections = @getSelections() - selection.destroy() for selection in selections[screenRanges.length...] - - @mergeIntersectingSelections options, => - for screenRange, i in screenRanges - screenRange = Range.fromObject(screenRange) - if selections[i] - selections[i].setScreenRange(screenRange, options) - else - @addSelectionForScreenRange(screenRange, options) - return - - # Essential: Add a selection for the given range in buffer coordinates. - # - # * `bufferRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - # - # Returns the added {Selection}. - addSelectionForBufferRange: (bufferRange, options={}) -> - unless options.preserveFolds - @destroyFoldsIntersectingBufferRange(bufferRange) - @selectionsMarkerLayer.markBufferRange(bufferRange, {invalidate: 'never', reversed: options.reversed ? false}) - @getLastSelection().autoscroll() unless options.autoscroll is false - @getLastSelection() - - # Essential: Add a selection for the given range in screen coordinates. - # - # * `screenRange` A {Range} - # * `options` (optional) An options {Object}: - # * `reversed` A {Boolean} indicating whether to create the selection in a - # reversed orientation. - # * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the - # selection is set. - # Returns the added {Selection}. - addSelectionForScreenRange: (screenRange, options={}) -> - @addSelectionForBufferRange(@bufferRangeForScreenRange(screenRange), options) - - # Essential: Select from the current cursor position to the given position in - # buffer coordinates. - # - # This method may merge selections that end up intersecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToBufferPosition: (position) -> - lastSelection = @getLastSelection() - lastSelection.selectToBufferPosition(position) - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Select from the current cursor position to the given position in - # screen coordinates. - # - # This method may merge selections that end up intersecting. - # - # * `position` An instance of {Point}, with a given `row` and `column`. - selectToScreenPosition: (position, options) -> - lastSelection = @getLastSelection() - lastSelection.selectToScreenPosition(position, options) - unless options?.suppressSelectionMerge - @mergeIntersectingSelections(reversed: lastSelection.isReversed()) - - # Essential: Move the cursor of each selection one character upward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectUp: (rowCount) -> - @expandSelectionsBackward (selection) -> selection.selectUp(rowCount) - - # Essential: Move the cursor of each selection one character downward while - # preserving the selection's tail position. - # - # * `rowCount` (optional) {Number} number of rows to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectDown: (rowCount) -> - @expandSelectionsForward (selection) -> selection.selectDown(rowCount) - - # Essential: Move the cursor of each selection one character leftward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectLeft: (columnCount) -> - @expandSelectionsBackward (selection) -> selection.selectLeft(columnCount) - - # Essential: Move the cursor of each selection one character rightward while - # preserving the selection's tail position. - # - # * `columnCount` (optional) {Number} number of columns to select (default: 1) - # - # This method may merge selections that end up intersecting. - selectRight: (columnCount) -> - @expandSelectionsForward (selection) -> selection.selectRight(columnCount) - - # Essential: Select from the top of the buffer to the end of the last selection - # in the buffer. - # - # This method merges multiple selections into a single selection. - selectToTop: -> - @expandSelectionsBackward (selection) -> selection.selectToTop() - - # Essential: Selects from the top of the first selection in the buffer to the end - # of the buffer. - # - # This method merges multiple selections into a single selection. - selectToBottom: -> - @expandSelectionsForward (selection) -> selection.selectToBottom() - - # Essential: Select all text in the buffer. - # - # This method merges multiple selections into a single selection. - selectAll: -> - @expandSelectionsForward (selection) -> selection.selectAll() - - # Essential: Move the cursor of each selection to the beginning of its line - # while preserving the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToBeginningOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfLine() - - # Essential: Move the cursor of each selection to the first non-whitespace - # character of its line while preserving the selection's tail position. If the - # cursor is already on the first character of the line, move it to the - # beginning of the line. - # - # This method may merge selections that end up intersecting. - selectToFirstCharacterOfLine: -> - @expandSelectionsBackward (selection) -> selection.selectToFirstCharacterOfLine() - - # Essential: Move the cursor of each selection to the end of its line while - # preserving the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToEndOfLine: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfLine() - - # Essential: Expand selections to the beginning of their containing word. - # - # Operates on all selections. Moves the cursor to the beginning of the - # containing word while preserving the selection's tail position. - selectToBeginningOfWord: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfWord() - - # Essential: Expand selections to the end of their containing word. - # - # Operates on all selections. Moves the cursor to the end of the containing - # word while preserving the selection's tail position. - selectToEndOfWord: -> - @expandSelectionsForward (selection) -> selection.selectToEndOfWord() - - # Extended: For each selection, move its cursor to the preceding subword - # boundary while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToPreviousSubwordBoundary: -> - @expandSelectionsBackward (selection) -> selection.selectToPreviousSubwordBoundary() - - # Extended: For each selection, move its cursor to the next subword boundary - # while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToNextSubwordBoundary: -> - @expandSelectionsForward (selection) -> selection.selectToNextSubwordBoundary() - - # Essential: For each cursor, select the containing line. - # - # This method merges selections on successive lines. - selectLinesContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectLine() - - # Essential: Select the word surrounding each cursor. - selectWordsContainingCursors: -> - @expandSelectionsForward (selection) -> selection.selectWord() - - # Selection Extended - - # Extended: For each selection, move its cursor to the preceding word boundary - # while maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToPreviousWordBoundary: -> - @expandSelectionsBackward (selection) -> selection.selectToPreviousWordBoundary() - - # Extended: For each selection, move its cursor to the next word boundary while - # maintaining the selection's tail position. - # - # This method may merge selections that end up intersecting. - selectToNextWordBoundary: -> - @expandSelectionsForward (selection) -> selection.selectToNextWordBoundary() - - # Extended: Expand selections to the beginning of the next word. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # word while preserving the selection's tail position. - selectToBeginningOfNextWord: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextWord() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfNextParagraph: -> - @expandSelectionsForward (selection) -> selection.selectToBeginningOfNextParagraph() - - # Extended: Expand selections to the beginning of the next paragraph. - # - # Operates on all selections. Moves the cursor to the beginning of the next - # paragraph while preserving the selection's tail position. - selectToBeginningOfPreviousParagraph: -> - @expandSelectionsBackward (selection) -> selection.selectToBeginningOfPreviousParagraph() - - # Extended: Select the range of the given marker if it is valid. - # - # * `marker` A {DisplayMarker} - # - # Returns the selected {Range} or `undefined` if the marker is invalid. - selectMarker: (marker) -> - if marker.isValid() - range = marker.getBufferRange() - @setSelectedBufferRange(range) - range - - # Extended: Get the most recently added {Selection}. - # - # Returns a {Selection}. - getLastSelection: -> - @createLastSelectionIfNeeded() - _.last(@selections) - - getSelectionAtScreenPosition: (position) -> - markers = @selectionsMarkerLayer.findMarkers(containsScreenPosition: position) - if markers.length > 0 - @cursorsByMarkerId.get(markers[0].id).selection - - # Extended: Get current {Selection}s. - # - # Returns: An {Array} of {Selection}s. - getSelections: -> - @createLastSelectionIfNeeded() - @selections.slice() - - # Extended: Get all {Selection}s, ordered by their position in the buffer - # instead of the order in which they were added. - # - # Returns an {Array} of {Selection}s. - getSelectionsOrderedByBufferPosition: -> - @getSelections().sort (a, b) -> a.compare(b) - - # Extended: Determine if a given range in buffer coordinates intersects a - # selection. - # - # * `bufferRange` A {Range} or range-compatible {Array}. - # - # Returns a {Boolean}. - selectionIntersectsBufferRange: (bufferRange) -> - _.any @getSelections(), (selection) -> - selection.intersectsBufferRange(bufferRange) - - # Selections Private - - # Add a similarly-shaped selection to the next eligible line below - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next following non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionBelow: -> - @expandSelectionsForward (selection) -> selection.addSelectionBelow() - - # Add a similarly-shaped selection to the next eligible line above - # each selection. - # - # Operates on all selections. If the selection is empty, adds an empty - # selection to the next preceding non-empty line as close to the current - # selection's column as possible. If the selection is non-empty, adds a - # selection to the next line that is long enough for a non-empty selection - # starting at the same column as the current selection to be added to it. - addSelectionAbove: -> - @expandSelectionsBackward (selection) -> selection.addSelectionAbove() - - # Calls the given function with each selection, then merges selections - expandSelectionsForward: (fn) -> - @mergeIntersectingSelections => - fn(selection) for selection in @getSelections() - return - - # Calls the given function with each selection, then merges selections in the - # reversed orientation - expandSelectionsBackward: (fn) -> - @mergeIntersectingSelections reversed: true, => - fn(selection) for selection in @getSelections() - return - - finalizeSelections: -> - selection.finalize() for selection in @getSelections() - return - - selectionsForScreenRows: (startRow, endRow) -> - @getSelections().filter (selection) -> selection.intersectsScreenRowRange(startRow, endRow) - - # Merges intersecting selections. If passed a function, it executes - # the function with merging suppressed, then merges intersecting selections - # afterward. - mergeIntersectingSelections: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - exclusive = not currentSelection.isEmpty() and not previousSelection.isEmpty() - - previousSelection.intersectsWith(currentSelection, exclusive) - - mergeSelectionsOnSameRows: (args...) -> - @mergeSelections args..., (previousSelection, currentSelection) -> - screenRange = currentSelection.getScreenRange() - - previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) - - avoidMergingSelections: (args...) -> - @mergeSelections args..., -> false - - mergeSelections: (args...) -> - mergePredicate = args.pop() - fn = args.pop() if _.isFunction(_.last(args)) - options = args.pop() ? {} - - return fn?() if @suppressSelectionMerging - - if fn? - @suppressSelectionMerging = true - result = fn() - @suppressSelectionMerging = false - - reducer = (disjointSelections, selection) -> - adjacentSelection = _.last(disjointSelections) - if mergePredicate(adjacentSelection, selection) - adjacentSelection.merge(selection, options) - disjointSelections - else - disjointSelections.concat([selection]) - - [head, tail...] = @getSelectionsOrderedByBufferPosition() - _.reduce(tail, reducer, [head]) - return result if fn? - - # Add a {Selection} based on the given {DisplayMarker}. - # - # * `marker` The {DisplayMarker} to highlight - # * `options` (optional) An {Object} that pertains to the {Selection} constructor. - # - # Returns the new {Selection}. - addSelection: (marker, options={}) -> - cursor = @addCursor(marker) - selection = new Selection(Object.assign({editor: this, marker, cursor}, options)) - @selections.push(selection) - selectionBufferRange = selection.getBufferRange() - @mergeIntersectingSelections(preserveFolds: options.preserveFolds) - - if selection.destroyed - for selection in @getSelections() - if selection.intersectsBufferRange(selectionBufferRange) - return selection - else - @emitter.emit 'did-add-cursor', cursor - @emitter.emit 'did-add-selection', selection - selection - - # Remove the given selection. - removeSelection: (selection) -> - _.remove(@cursors, selection.cursor) - _.remove(@selections, selection) - @cursorsByMarkerId.delete(selection.cursor.marker.id) - @emitter.emit 'did-remove-cursor', selection.cursor - @emitter.emit 'did-remove-selection', selection - - # Reduce one or more selections to a single empty selection based on the most - # recently added cursor. - clearSelections: (options) -> - @consolidateSelections() - @getLastSelection().clear(options) - - # Reduce multiple selections to the least recently added selection. - consolidateSelections: -> - selections = @getSelections() - if selections.length > 1 - selection.destroy() for selection in selections[1...(selections.length)] - selections[0].autoscroll(center: true) - true - else - false - - # Called by the selection - selectionRangeChanged: (event) -> - @component?.didChangeSelectionRange() - @emitter.emit 'did-change-selection-range', event - - createLastSelectionIfNeeded: -> - if @selections.length is 0 - @addSelectionForBufferRange([[0, 0], [0, 0]], autoscroll: false, preserveFolds: true) - - ### - Section: Searching and Replacing - ### - - # Essential: Scan regular expression matches in the entire buffer, calling the - # given iterator function on each match. - # - # `::scan` functions as the replace method as well via the `replace` - # - # If you're programmatically modifying the results, you may want to try - # {::backwardsScanInBufferRange} to avoid tripping over your own changes. - # - # * `regex` A {RegExp} to search for. - # * `options` (optional) {Object} - # * `leadingContextLineCount` {Number} default `0`; The number of lines - # before the matched line to include in the results object. - # * `trailingContextLineCount` {Number} default `0`; The number of lines - # after the matched line to include in the results object. - # * `iterator` A {Function} that's called on each match - # * `object` {Object} - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scan: (regex, options={}, iterator) -> - if _.isFunction(options) - iterator = options - options = {} - - @buffer.scan(regex, options, iterator) - - # Essential: Scan regular expression matches in a given range, calling the given - # iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - scanInBufferRange: (regex, range, iterator) -> @buffer.scanInRange(regex, range, iterator) - - # Essential: Scan regular expression matches in a given range in reverse order, - # calling the given iterator function on each match. - # - # * `regex` A {RegExp} to search for. - # * `range` A {Range} in which to search. - # * `iterator` A {Function} that's called on each match with an {Object} - # containing the following keys: - # * `match` The current regular expression match. - # * `matchText` A {String} with the text of the match. - # * `range` The {Range} of the match. - # * `stop` Call this {Function} to terminate the scan. - # * `replace` Call this {Function} with a {String} to replace the match. - backwardsScanInBufferRange: (regex, range, iterator) -> @buffer.backwardsScanInRange(regex, range, iterator) - - ### - Section: Tab Behavior - ### - - # Essential: Returns a {Boolean} indicating whether softTabs are enabled for this - # editor. - getSoftTabs: -> @softTabs - - # Essential: Enable or disable soft tabs for this editor. - # - # * `softTabs` A {Boolean} - setSoftTabs: (@softTabs) -> @update({@softTabs}) - - # Returns a {Boolean} indicating whether atomic soft tabs are enabled for this editor. - hasAtomicSoftTabs: -> @displayLayer.atomicSoftTabs - - # Essential: Toggle soft tabs for this editor - toggleSoftTabs: -> @setSoftTabs(not @getSoftTabs()) - - # Essential: Get the on-screen length of tab characters. - # - # Returns a {Number}. - getTabLength: -> @tokenizedBuffer.getTabLength() - - # Essential: Set the on-screen length of tab characters. Setting this to a - # {Number} This will override the `editor.tabLength` setting. - # - # * `tabLength` {Number} length of a single tab. Setting to `null` will - # fallback to using the `editor.tabLength` config setting - setTabLength: (tabLength) -> @update({tabLength}) - - # Returns an {Object} representing the current invisible character - # substitutions for this editor. See {::setInvisibles}. - getInvisibles: -> - if not @mini and @showInvisibles and @invisibles? - @invisibles - else - {} - - doesShowIndentGuide: -> @showIndentGuide and not @mini - - getSoftWrapHangingIndentLength: -> @displayLayer.softWrapHangingIndent - - # Extended: Determine if the buffer uses hard or soft tabs. - # - # Returns `true` if the first non-comment line with leading whitespace starts - # with a space character. Returns `false` if it starts with a hard tab (`\t`). - # - # Returns a {Boolean} or undefined if no non-comment lines had leading - # whitespace. - usesSoftTabs: -> - for bufferRow in [0..Math.min(1000, @buffer.getLastRow())] - continue if @tokenizedBuffer.tokenizedLines[bufferRow]?.isComment() - - line = @buffer.lineForRow(bufferRow) - return true if line[0] is ' ' - return false if line[0] is '\t' - - undefined - - # Extended: Get the text representing a single level of indent. - # - # If soft tabs are enabled, the text is composed of N spaces, where N is the - # tab length. Otherwise the text is a tab character (`\t`). - # - # Returns a {String}. - getTabText: -> @buildIndentString(1) - - # If soft tabs are enabled, convert all hard tabs to soft tabs in the given - # {Range}. - normalizeTabsInBufferRange: (bufferRange) -> - return unless @getSoftTabs() - @scanInBufferRange /\t/g, bufferRange, ({replace}) => replace(@getTabText()) - - ### - Section: Soft Wrap Behavior - ### - - # Essential: Determine whether lines in this editor are soft-wrapped. - # - # Returns a {Boolean}. - isSoftWrapped: -> @softWrapped - - # Essential: Enable or disable soft wrapping for this editor. - # - # * `softWrapped` A {Boolean} - # - # Returns a {Boolean}. - setSoftWrapped: (softWrapped) -> - @update({softWrapped}) - @isSoftWrapped() - - getPreferredLineLength: -> @preferredLineLength - - # Essential: Toggle soft wrapping for this editor - # - # Returns a {Boolean}. - toggleSoftWrapped: -> @setSoftWrapped(not @isSoftWrapped()) - - # Essential: Gets the column at which column will soft wrap - getSoftWrapColumn: -> - if @isSoftWrapped() and not @mini - if @softWrapAtPreferredLineLength - Math.min(@getEditorWidthInChars(), @preferredLineLength) - else - @getEditorWidthInChars() - else - @maxScreenLineLength - - ### - Section: Indentation - ### - - # Essential: Get the indentation level of the given buffer row. - # - # Determines how deeply the given row is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # - # Returns a {Number}. - indentationForBufferRow: (bufferRow) -> - @indentLevelForLine(@lineTextForBufferRow(bufferRow)) - - # Essential: Set the indentation level for the given buffer row. - # - # Inserts or removes hard tabs or spaces based on the soft tabs and tab length - # settings of this editor in order to bring it to the given indentation level. - # Note that if soft tabs are enabled and the tab length is 2, a row with 4 - # leading spaces would have an indentation level of 2. - # - # * `bufferRow` A {Number} indicating the buffer row. - # * `newLevel` A {Number} indicating the new indentation level. - # * `options` (optional) An {Object} with the following keys: - # * `preserveLeadingWhitespace` `true` to preserve any whitespace already at - # the beginning of the line (default: false). - setIndentationForBufferRow: (bufferRow, newLevel, {preserveLeadingWhitespace}={}) -> - if preserveLeadingWhitespace - endColumn = 0 - else - endColumn = @lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length - newIndentString = @buildIndentString(newLevel) - @buffer.setTextInRange([[bufferRow, 0], [bufferRow, endColumn]], newIndentString) - - # Extended: Indent rows intersecting selections by one level. - indentSelectedRows: -> - @mutateSelectedText (selection) -> selection.indentSelectedRows() - - # Extended: Outdent rows intersecting selections by one level. - outdentSelectedRows: -> - @mutateSelectedText (selection) -> selection.outdentSelectedRows() - - # Extended: Get the indentation level of the given line of text. - # - # Determines how deeply the given line is indented based on the soft tabs and - # tab length settings of this editor. Note that if soft tabs are enabled and - # the tab length is 2, a row with 4 leading spaces would have an indentation - # level of 2. - # - # * `line` A {String} representing a line of text. - # - # Returns a {Number}. - indentLevelForLine: (line) -> - @tokenizedBuffer.indentLevelForLine(line) - - # Extended: Indent rows intersecting selections based on the grammar's suggested - # indent level. - autoIndentSelectedRows: -> - @mutateSelectedText (selection) -> selection.autoIndentSelectedRows() - - # Indent all lines intersecting selections. See {Selection::indent} for more - # information. - indent: (options={}) -> - options.autoIndent ?= @shouldAutoIndent() - @mutateSelectedText (selection) -> selection.indent(options) - - # Constructs the string used for indents. - buildIndentString: (level, column=0) -> - if @getSoftTabs() - tabStopViolation = column % @getTabLength() - _.multiplyString(" ", Math.floor(level * @getTabLength()) - tabStopViolation) - else - excessWhitespace = _.multiplyString(' ', Math.round((level - Math.floor(level)) * @getTabLength())) - _.multiplyString("\t", Math.floor(level)) + excessWhitespace - - ### - Section: Grammars - ### - - # Essential: Get the current {Grammar} of this editor. - getGrammar: -> - @tokenizedBuffer.grammar - - # Essential: Set the current {Grammar} of this editor. - # - # Assigning a grammar will cause the editor to re-tokenize based on the new - # grammar. - # - # * `grammar` {Grammar} - setGrammar: (grammar) -> - @tokenizedBuffer.setGrammar(grammar) - - # Reload the grammar based on the file name. - reloadGrammar: -> - @tokenizedBuffer.reloadGrammar() - - # Experimental: Get a notification when async tokenization is completed. - onDidTokenize: (callback) -> - @tokenizedBuffer.onDidTokenize(callback) - - ### - Section: Managing Syntax Scopes - ### - - # Essential: Returns a {ScopeDescriptor} that includes this editor's language. - # e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with - # {Config::get} to get language specific config values. - getRootScopeDescriptor: -> - @tokenizedBuffer.rootScopeDescriptor - - # Essential: Get the syntactic scopeDescriptor for the given position in buffer - # coordinates. Useful with {Config::get}. - # - # For example, if called with a position inside the parameter list of an - # anonymous CoffeeScript function, the method returns the following array: - # `["source.coffee", "meta.inline.function.coffee", "variable.parameter.function.coffee"]` - # - # * `bufferPosition` A {Point} or {Array} of [row, column]. - # - # Returns a {ScopeDescriptor}. - scopeDescriptorForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) - - # Extended: Get the range in buffer coordinates of all tokens surrounding the - # cursor that match the given scope selector. - # - # For example, if you wanted to find the string surrounding the cursor, you - # could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. - # - # * `scopeSelector` {String} selector. e.g. `'.source.ruby'` - # - # Returns a {Range}. - bufferRangeForScopeAtCursor: (scopeSelector) -> - @bufferRangeForScopeAtPosition(scopeSelector, @getCursorBufferPosition()) - - bufferRangeForScopeAtPosition: (scopeSelector, position) -> - @tokenizedBuffer.bufferRangeForScopeAtPosition(scopeSelector, position) - - # Extended: Determine if the given row is entirely a comment - isBufferRowCommented: (bufferRow) -> - if match = @lineTextForBufferRow(bufferRow).match(/\S/) - @commentScopeSelector ?= new TextMateScopeSelector('comment.*') - @commentScopeSelector.matches(@scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) - - # Get the scope descriptor at the cursor. - getCursorScope: -> - @getLastCursor().getScopeDescriptor() - - tokenForBufferPosition: (bufferPosition) -> - @tokenizedBuffer.tokenForPosition(bufferPosition) - - ### - Section: Clipboard Operations - ### - - # Essential: For each selection, copy the selected text. - copySelectedText: -> - maintainClipboard = false - for selection in @getSelectionsOrderedByBufferPosition() - if selection.isEmpty() - previousRange = selection.getBufferRange() - selection.selectLine() - selection.copy(maintainClipboard, true) - selection.setBufferRange(previousRange) - else - selection.copy(maintainClipboard, false) - maintainClipboard = true - return - - # Private: For each selection, only copy highlighted text. - copyOnlySelectedText: -> - maintainClipboard = false - for selection in @getSelectionsOrderedByBufferPosition() - if not selection.isEmpty() - selection.copy(maintainClipboard, false) - maintainClipboard = true - return - - # Essential: For each selection, cut the selected text. - cutSelectedText: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - if selection.isEmpty() - selection.selectLine() - selection.cut(maintainClipboard, true) - else - selection.cut(maintainClipboard, false) - maintainClipboard = true - - # Essential: For each selection, replace the selected text with the contents of - # the clipboard. - # - # If the clipboard contains the same number of selections as the current - # editor, each selection will be replaced with the content of the - # corresponding clipboard selection text. - # - # * `options` (optional) See {Selection::insertText}. - pasteText: (options={}) -> - {text: clipboardText, metadata} = @constructor.clipboard.readWithMetadata() - return false unless @emitWillInsertTextEvent(clipboardText) - - metadata ?= {} - options.autoIndent = @shouldAutoIndentOnPaste() - - @mutateSelectedText (selection, index) => - if metadata.selections?.length is @getSelections().length - {text, indentBasis, fullLine} = metadata.selections[index] - else - {indentBasis, fullLine} = metadata - text = clipboardText - - delete options.indentBasis - {cursor} = selection - if indentBasis? - containsNewlines = text.indexOf('\n') isnt -1 - if containsNewlines or not cursor.hasPrecedingCharactersOnLine() - options.indentBasis ?= indentBasis - - range = null - if fullLine and selection.isEmpty() - oldPosition = selection.getBufferRange().start - selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]) - range = selection.insertText(text, options) - newPosition = oldPosition.translate([1, 0]) - selection.setBufferRange([newPosition, newPosition]) - else - range = selection.insertText(text, options) - - didInsertEvent = {text, range} - @emitter.emit 'did-insert-text', didInsertEvent - - # Essential: For each selection, if the selection is empty, cut all characters - # of the containing screen line following the cursor. Otherwise cut the selected - # text. - cutToEndOfLine: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - selection.cutToEndOfLine(maintainClipboard) - maintainClipboard = true - - # Essential: For each selection, if the selection is empty, cut all characters - # of the containing buffer line following the cursor. Otherwise cut the - # selected text. - cutToEndOfBufferLine: -> - maintainClipboard = false - @mutateSelectedText (selection) -> - selection.cutToEndOfBufferLine(maintainClipboard) - maintainClipboard = true - - ### - Section: Folds - ### - - # Essential: Fold the most recent cursor's row based on its indentation level. - # - # The fold will extend from the nearest preceding line with a lower - # indentation level up to the nearest following row with a lower indentation - # level. - foldCurrentRow: -> - {row} = @getCursorBufferPosition() - range = @tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) - @displayLayer.foldBufferRange(range) - - # Essential: Unfold the most recent cursor's row by one level. - unfoldCurrentRow: -> - {row} = @getCursorBufferPosition() - position = Point(row, Infinity) - @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) - - # Essential: Fold the given row in buffer coordinates based on its indentation - # level. - # - # If the given row is foldable, the fold will begin there. Otherwise, it will - # begin at the first foldable row preceding the given row. - # - # * `bufferRow` A {Number}. - foldBufferRow: (bufferRow) -> - position = Point(bufferRow, Infinity) - loop - foldableRange = @tokenizedBuffer.getFoldableRangeContainingPoint(position, @getTabLength()) - if foldableRange - existingFolds = @displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) - if existingFolds.length is 0 - @displayLayer.foldBufferRange(foldableRange) - else - firstExistingFoldRange = @displayLayer.bufferRangeForFold(existingFolds[0]) - if firstExistingFoldRange.start.isLessThan(position) - position = Point(firstExistingFoldRange.start.row, 0) - continue - return - - # Essential: Unfold all folds containing the given row in buffer coordinates. - # - # * `bufferRow` A {Number} - unfoldBufferRow: (bufferRow) -> - position = Point(bufferRow, Infinity) - @displayLayer.destroyFoldsIntersectingBufferRange(Range(position, position)) - - # Extended: For each selection, fold the rows it intersects. - foldSelectedLines: -> - selection.fold() for selection in @getSelections() - return - - # Extended: Fold all foldable lines. - foldAll: -> - @displayLayer.destroyAllFolds() - for range in @tokenizedBuffer.getFoldableRanges(@getTabLength()) - @displayLayer.foldBufferRange(range) - return - - # Extended: Unfold all existing folds. - unfoldAll: -> - result = @displayLayer.destroyAllFolds() - @scrollToCursorPosition() - result - - # Extended: Fold all foldable lines at the given indent level. - # - # * `level` A {Number}. - foldAllAtIndentLevel: (level) -> - @displayLayer.destroyAllFolds() - for range in @tokenizedBuffer.getFoldableRangesAtIndentLevel(level, @getTabLength()) - @displayLayer.foldBufferRange(range) - return - - # Extended: Determine whether the given row in buffer coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtBufferRow: (bufferRow) -> - @tokenizedBuffer.isFoldableAtRow(bufferRow) - - # Extended: Determine whether the given row in screen coordinates is foldable. - # - # A *foldable* row is a row that *starts* a row range that can be folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldableAtScreenRow: (screenRow) -> - @isFoldableAtBufferRow(@bufferRowForScreenRow(screenRow)) - - # Extended: Fold the given buffer row if it isn't currently folded, and unfold - # it otherwise. - toggleFoldAtBufferRow: (bufferRow) -> - if @isFoldedAtBufferRow(bufferRow) - @unfoldBufferRow(bufferRow) - else - @foldBufferRow(bufferRow) - - # Extended: Determine whether the most recently added cursor's row is folded. - # - # Returns a {Boolean}. - isFoldedAtCursorRow: -> - @isFoldedAtBufferRow(@getCursorBufferPosition().row) - - # Extended: Determine whether the given row in buffer coordinates is folded. - # - # * `bufferRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtBufferRow: (bufferRow) -> - range = Range( - Point(bufferRow, 0), - Point(bufferRow, @buffer.lineLengthForRow(bufferRow)) - ) - @displayLayer.foldsIntersectingBufferRange(range).length > 0 - - # Extended: Determine whether the given row in screen coordinates is folded. - # - # * `screenRow` A {Number} - # - # Returns a {Boolean}. - isFoldedAtScreenRow: (screenRow) -> - @isFoldedAtBufferRow(@bufferRowForScreenRow(screenRow)) - - # Creates a new fold between two row numbers. - # - # startRow - The row {Number} to start folding at - # endRow - The row {Number} to end the fold - # - # Returns the new {Fold}. - foldBufferRowRange: (startRow, endRow) -> - @foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) - - foldBufferRange: (range) -> - @displayLayer.foldBufferRange(range) - - # Remove any {Fold}s found that intersect the given buffer range. - destroyFoldsIntersectingBufferRange: (bufferRange) -> - @displayLayer.destroyFoldsIntersectingBufferRange(bufferRange) - - ### - Section: Gutters - ### - - # Essential: Add a custom {Gutter}. - # - # * `options` An {Object} with the following fields: - # * `name` (required) A unique {String} to identify this gutter. - # * `priority` (optional) A {Number} that determines stacking order between - # gutters. Lower priority items are forced closer to the edges of the - # window. (default: -100) - # * `visible` (optional) {Boolean} specifying whether the gutter is visible - # initially after being created. (default: true) - # - # Returns the newly-created {Gutter}. - addGutter: (options) -> - @gutterContainer.addGutter(options) - - # Essential: Get this editor's gutters. - # - # Returns an {Array} of {Gutter}s. - getGutters: -> - @gutterContainer.getGutters() - - getLineNumberGutter: -> - @lineNumberGutter - - # Essential: Get the gutter with the given name. - # - # Returns a {Gutter}, or `null` if no gutter exists for the given name. - gutterWithName: (name) -> - @gutterContainer.gutterWithName(name) - - ### - Section: Scrolling the TextEditor - ### - - # Essential: Scroll the editor to reveal the most recently added cursor if it is - # off-screen. - # - # * `options` (optional) {Object} - # * `center` Center the editor around the cursor if possible. (default: true) - scrollToCursorPosition: (options) -> - @getLastCursor().autoscroll(center: options?.center ? true) - - # Essential: Scrolls the editor to the given buffer position. - # - # * `bufferPosition` An object that represents a buffer position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToBufferPosition: (bufferPosition, options) -> - @scrollToScreenPosition(@screenPositionForBufferPosition(bufferPosition), options) - - # Essential: Scrolls the editor to the given screen position. - # - # * `screenPosition` An object that represents a screen position. It can be either - # an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} - # * `options` (optional) {Object} - # * `center` Center the editor around the position if possible. (default: false) - scrollToScreenPosition: (screenPosition, options) -> - @scrollToScreenRange(new Range(screenPosition, screenPosition), options) - - scrollToTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::scrollToTop instead.") - - @getElement().scrollToTop() - - scrollToBottom: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::scrollToTop instead.") - - @getElement().scrollToBottom() - - scrollToScreenRange: (screenRange, options = {}) -> - screenRange = @clipScreenRange(screenRange) if options.clip isnt false - scrollEvent = {screenRange, options} - @component?.didRequestAutoscroll(scrollEvent) - @emitter.emit "did-request-autoscroll", scrollEvent - - getHorizontalScrollbarHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getHorizontalScrollbarHeight instead.") - - @getElement().getHorizontalScrollbarHeight() - - getVerticalScrollbarWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getVerticalScrollbarWidth instead.") - - @getElement().getVerticalScrollbarWidth() - - pageUp: -> - @moveUp(@getRowsPerPage()) - - pageDown: -> - @moveDown(@getRowsPerPage()) - - selectPageUp: -> - @selectUp(@getRowsPerPage()) - - selectPageDown: -> - @selectDown(@getRowsPerPage()) - - # Returns the number of rows per page - getRowsPerPage: -> - if @component? - clientHeight = @component.getScrollContainerClientHeight() - lineHeight = @component.getLineHeight() - Math.max(1, Math.ceil(clientHeight / lineHeight)) - else - 1 - - Object.defineProperty(@prototype, 'rowsPerPage', { - get: -> @getRowsPerPage() - }) - - ### - Section: Config - ### - - # Experimental: Supply an object that will provide the editor with settings - # for specific syntactic scopes. See the `ScopedSettingsDelegate` in - # `text-editor-registry.js` for an example implementation. - setScopedSettingsDelegate: (@scopedSettingsDelegate) -> - @tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate - - # Experimental: Retrieve the {Object} that provides the editor with settings - # for specific syntactic scopes. - getScopedSettingsDelegate: -> @scopedSettingsDelegate - - # Experimental: Is auto-indentation enabled for this editor? - # - # Returns a {Boolean}. - shouldAutoIndent: -> @autoIndent - - # Experimental: Is auto-indentation on paste enabled for this editor? - # - # Returns a {Boolean}. - shouldAutoIndentOnPaste: -> @autoIndentOnPaste - - # Experimental: Does this editor allow scrolling past the last line? - # - # Returns a {Boolean}. - getScrollPastEnd: -> - if @getAutoHeight() - false - else - @scrollPastEnd - - # Experimental: How fast does the editor scroll in response to mouse wheel - # movements? - # - # Returns a positive {Number}. - getScrollSensitivity: -> @scrollSensitivity - - # Experimental: Does this editor show cursors while there is a selection? - # - # Returns a positive {Boolean}. - getShowCursorOnSelection: -> @showCursorOnSelection - - # Experimental: Are line numbers enabled for this editor? - # - # Returns a {Boolean} - doesShowLineNumbers: -> @showLineNumbers - - # Experimental: Get the time interval within which text editing operations - # are grouped together in the editor's undo history. - # - # Returns the time interval {Number} in milliseconds. - getUndoGroupingInterval: -> @undoGroupingInterval - - # Experimental: Get the characters that are *not* considered part of words, - # for the purpose of word-based cursor movements. - # - # Returns a {String} containing the non-word characters. - getNonWordCharacters: (scopes) -> - @scopedSettingsDelegate?.getNonWordCharacters?(scopes) ? @nonWordCharacters - - getCommentStrings: (scopes) -> - @scopedSettingsDelegate?.getCommentStrings?(scopes) - - ### - Section: Event Handlers - ### - - handleGrammarChange: -> - @unfoldAll() - @emitter.emit 'did-change-grammar', @getGrammar() - - ### - Section: TextEditor Rendering - ### - - # Get the Element for the editor. - getElement: -> - if @component? - @component.element - else - TextEditorComponent ?= require('./text-editor-component') - TextEditorElement ?= require('./text-editor-element') - new TextEditorComponent({ - model: this, - updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, - @initialScrollTopRow, @initialScrollLeftColumn - }) - @component.element - - getAllowedLocations: -> - ['center'] - - # Essential: Retrieves the greyed out placeholder of a mini editor. - # - # Returns a {String}. - getPlaceholderText: -> @placeholderText - - # Essential: Set the greyed out placeholder of a mini editor. Placeholder text - # will be displayed when the editor has no content. - # - # * `placeholderText` {String} text that is displayed when the editor has no content. - setPlaceholderText: (placeholderText) -> @update({placeholderText}) - - pixelPositionForBufferPosition: (bufferPosition) -> - Grim.deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead") - @getElement().pixelPositionForBufferPosition(bufferPosition) - - pixelPositionForScreenPosition: (screenPosition) -> - Grim.deprecate("This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead") - @getElement().pixelPositionForScreenPosition(screenPosition) - - getVerticalScrollMargin: -> - maxScrollMargin = Math.floor(((@height / @getLineHeightInPixels()) - 1) / 2) - Math.min(@verticalScrollMargin, maxScrollMargin) - - setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin - - getHorizontalScrollMargin: -> Math.min(@horizontalScrollMargin, Math.floor(((@width / @getDefaultCharWidth()) - 1) / 2)) - setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin - - getLineHeightInPixels: -> @lineHeightInPixels - setLineHeightInPixels: (@lineHeightInPixels) -> @lineHeightInPixels - - getKoreanCharWidth: -> @koreanCharWidth - getHalfWidthCharWidth: -> @halfWidthCharWidth - getDoubleWidthCharWidth: -> @doubleWidthCharWidth - getDefaultCharWidth: -> @defaultCharWidth - - ratioForCharacter: (character) -> - if isKoreanCharacter(character) - @getKoreanCharWidth() / @getDefaultCharWidth() - else if isHalfWidthCharacter(character) - @getHalfWidthCharWidth() / @getDefaultCharWidth() - else if isDoubleWidthCharacter(character) - @getDoubleWidthCharWidth() / @getDefaultCharWidth() - else - 1 - - setDefaultCharWidth: (defaultCharWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) -> - doubleWidthCharWidth ?= defaultCharWidth - halfWidthCharWidth ?= defaultCharWidth - koreanCharWidth ?= defaultCharWidth - if defaultCharWidth isnt @defaultCharWidth or doubleWidthCharWidth isnt @doubleWidthCharWidth and halfWidthCharWidth isnt @halfWidthCharWidth and koreanCharWidth isnt @koreanCharWidth - @defaultCharWidth = defaultCharWidth - @doubleWidthCharWidth = doubleWidthCharWidth - @halfWidthCharWidth = halfWidthCharWidth - @koreanCharWidth = koreanCharWidth - if @isSoftWrapped() - @displayLayer.reset({ - softWrapColumn: @getSoftWrapColumn() - }) - defaultCharWidth - - setHeight: (height) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setHeight instead.") - @getElement().setHeight(height) - - getHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getHeight instead.") - @getElement().getHeight() - - getAutoHeight: -> @autoHeight ? true - - getAutoWidth: -> @autoWidth ? false - - setWidth: (width) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setWidth instead.") - @getElement().setWidth(width) - - getWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getWidth instead.") - @getElement().getWidth() - - # Use setScrollTopRow instead of this method - setFirstVisibleScreenRow: (screenRow) -> - @setScrollTopRow(screenRow) - - getFirstVisibleScreenRow: -> - @getElement().component.getFirstVisibleRow() - - getLastVisibleScreenRow: -> - @getElement().component.getLastVisibleRow() - - getVisibleRowRange: -> - [@getFirstVisibleScreenRow(), @getLastVisibleScreenRow()] - - # Use setScrollLeftColumn instead of this method - setFirstVisibleScreenColumn: (column) -> - @setScrollLeftColumn(column) - - getFirstVisibleScreenColumn: -> - @getElement().component.getFirstVisibleColumn() - - getScrollTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollTop instead.") - - @getElement().getScrollTop() - - setScrollTop: (scrollTop) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollTop instead.") - - @getElement().setScrollTop(scrollTop) - - getScrollBottom: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollBottom instead.") - - @getElement().getScrollBottom() - - setScrollBottom: (scrollBottom) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollBottom instead.") - - @getElement().setScrollBottom(scrollBottom) - - getScrollLeft: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollLeft instead.") - - @getElement().getScrollLeft() - - setScrollLeft: (scrollLeft) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollLeft instead.") - - @getElement().setScrollLeft(scrollLeft) - - getScrollRight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollRight instead.") - - @getElement().getScrollRight() - - setScrollRight: (scrollRight) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::setScrollRight instead.") - - @getElement().setScrollRight(scrollRight) - - getScrollHeight: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollHeight instead.") - - @getElement().getScrollHeight() - - getScrollWidth: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getScrollWidth instead.") - - @getElement().getScrollWidth() - - getMaxScrollTop: -> - Grim.deprecate("This is now a view method. Call TextEditorElement::getMaxScrollTop instead.") - - @getElement().getMaxScrollTop() - - getScrollTopRow: -> - @getElement().component.getScrollTopRow() - - setScrollTopRow: (scrollTopRow) -> - @getElement().component.setScrollTopRow(scrollTopRow) - - getScrollLeftColumn: -> - @getElement().component.getScrollLeftColumn() - - setScrollLeftColumn: (scrollLeftColumn) -> - @getElement().component.setScrollLeftColumn(scrollLeftColumn) - - intersectsVisibleRowRange: (startRow, endRow) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.") - - @getElement().intersectsVisibleRowRange(startRow, endRow) - - selectionIntersectsVisibleRowRange: (selection) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::selectionIntersectsVisibleRowRange instead.") - - @getElement().selectionIntersectsVisibleRowRange(selection) - - screenPositionForPixelPosition: (pixelPosition) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::screenPositionForPixelPosition instead.") - - @getElement().screenPositionForPixelPosition(pixelPosition) - - pixelRectForScreenRange: (screenRange) -> - Grim.deprecate("This is now a view method. Call TextEditorElement::pixelRectForScreenRange instead.") - - @getElement().pixelRectForScreenRange(screenRange) - - ### - Section: Utility - ### - - inspect: -> - "" - - emitWillInsertTextEvent: (text) -> - result = true - cancel = -> result = false - willInsertEvent = {cancel, text} - @emitter.emit 'will-insert-text', willInsertEvent - result - - ### - Section: Language Mode Delegated Methods - ### - - suggestedIndentForBufferRow: (bufferRow, options) -> @tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) - - # Given a buffer row, indent it. - # - # * bufferRow - The row {Number}. - # * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. - autoIndentBufferRow: (bufferRow, options) -> - indentLevel = @suggestedIndentForBufferRow(bufferRow, options) - @setIndentationForBufferRow(bufferRow, indentLevel, options) - - # Indents all the rows between two buffer row numbers. - # - # * startRow - The row {Number} to start at - # * endRow - The row {Number} to end at - autoIndentBufferRows: (startRow, endRow) -> - row = startRow - while row <= endRow - @autoIndentBufferRow(row) - row++ - return - - autoDecreaseIndentForBufferRow: (bufferRow) -> - indentLevel = @tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) - @setIndentationForBufferRow(bufferRow, indentLevel) if indentLevel? - - toggleLineCommentForBufferRow: (row) -> @toggleLineCommentsForBufferRows(row, row) - - toggleLineCommentsForBufferRows: (start, end) -> @tokenizedBuffer.toggleLineCommentsForBufferRows(start, end) - - rowRangeForParagraphAtBufferRow: (bufferRow) -> - return unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(bufferRow)) - - isCommented = @tokenizedBuffer.isRowCommented(bufferRow) - - startRow = bufferRow - while startRow > 0 - break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(startRow - 1)) - break if @tokenizedBuffer.isRowCommented(startRow - 1) isnt isCommented - startRow-- - - endRow = bufferRow - rowCount = @getLineCount() - while endRow < rowCount - break unless NON_WHITESPACE_REGEXP.test(@lineTextForBufferRow(endRow + 1)) - break if @tokenizedBuffer.isRowCommented(endRow + 1) isnt isCommented - endRow++ - - new Range(new Point(startRow, 0), new Point(endRow, @buffer.lineLengthForRow(endRow))) diff --git a/src/text-editor.js b/src/text-editor.js new file mode 100644 index 000000000..8eee5c140 --- /dev/null +++ b/src/text-editor.js @@ -0,0 +1,4603 @@ +const _ = require('underscore-plus') +const path = require('path') +const fs = require('fs-plus') +const Grim = require('grim') +const dedent = require('dedent') +const {CompositeDisposable, Disposable, Emitter} = require('event-kit') +const TextBuffer = require('text-buffer') +const {Point, Range} = TextBuffer +const DecorationManager = require('./decoration-manager') +const TokenizedBuffer = require('./tokenized-buffer') +const Cursor = require('./cursor') +const Selection = require('./selection') + +const TextMateScopeSelector = require('first-mate').ScopeSelector +const GutterContainer = require('./gutter-container') +let TextEditorComponent = null +let TextEditorElement = null +const {isDoubleWidthCharacter, isHalfWidthCharacter, isKoreanCharacter, isWrapBoundary} = require('./text-utils') + +const SERIALIZATION_VERSION = 1 +const NON_WHITESPACE_REGEXP = /\S/ +const ZERO_WIDTH_NBSP = '\ufeff' +let nextId = 0 + +// Essential: This class represents all essential editing state for a single +// {TextBuffer}, including cursor and selection positions, folds, and soft wraps. +// If you're manipulating the state of an editor, use this class. +// +// A single {TextBuffer} can belong to multiple editors. For example, if the +// same file is open in two different panes, Atom creates a separate editor for +// each pane. If the buffer is manipulated the changes are reflected in both +// editors, but each maintains its own cursor position, folded lines, etc. +// +// ## Accessing TextEditor Instances +// +// The easiest way to get hold of `TextEditor` objects is by registering a callback +// with `::observeTextEditors` on the `atom.workspace` global. Your callback will +// then be called with all current editor instances and also when any editor is +// created in the future. +// +// ```coffee +// atom.workspace.observeTextEditors (editor) -> +// editor.insertText('Hello World') +// ``` +// +// ## Buffer vs. Screen Coordinates +// +// Because editors support folds and soft-wrapping, the lines on screen don't +// always match the lines in the buffer. For example, a long line that soft wraps +// twice renders as three lines on screen, but only represents one line in the +// buffer. Similarly, if rows 5-10 are folded, then row 6 on screen corresponds +// to row 11 in the buffer. +// +// Your choice of coordinates systems will depend on what you're trying to +// achieve. For example, if you're writing a command that jumps the cursor up or +// down by 10 lines, you'll want to use screen coordinates because the user +// probably wants to skip lines *on screen*. However, if you're writing a package +// that jumps between method definitions, you'll want to work in buffer +// coordinates. +// +// **When in doubt, just default to buffer coordinates**, then experiment with +// soft wraps and folds to ensure your code interacts with them correctly. +module.exports = +class TextEditor { + static setClipboard (clipboard) { + this.clipboard = clipboard + } + + static setScheduler (scheduler) { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.setScheduler(scheduler) + } + + static didUpdateStyles () { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.didUpdateStyles() + } + + static didUpdateScrollbarStyles () { + if (TextEditorComponent == null) { TextEditorComponent = require('./text-editor-component') } + return TextEditorComponent.didUpdateScrollbarStyles() + } + + static viewForItem (item) { return item.element || item } + + static deserialize (state, atomEnvironment) { + if (state.version !== SERIALIZATION_VERSION) return null + + try { + const tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) + if (!tokenizedBuffer) return null + + state.tokenizedBuffer = tokenizedBuffer + state.tabLength = state.tokenizedBuffer.getTabLength() + } catch (error) { + if (error.syscall === 'read') { + return // Error reading the file, don't deserialize an editor for it + } else { + throw error + } + } + + state.buffer = state.tokenizedBuffer.buffer + state.assert = atomEnvironment.assert.bind(atomEnvironment) + const editor = new TextEditor(state) + if (state.registered) { + const disposable = atomEnvironment.textEditors.add(editor) + editor.onDidDestroy(() => disposable.dispose()) + } + return editor + } + + constructor (params = {}) { + if (this.constructor.clipboard == null) { + throw new Error('Must call TextEditor.setClipboard at least once before creating TextEditor instances') + } + + this.id = params.id != null ? params.id : nextId++ + this.initialScrollTopRow = params.initialScrollTopRow + this.initialScrollLeftColumn = params.initialScrollLeftColumn + this.decorationManager = params.decorationManager + this.selectionsMarkerLayer = params.selectionsMarkerLayer + this.mini = (params.mini != null) ? params.mini : false + this.placeholderText = params.placeholderText + this.showLineNumbers = params.showLineNumbers + this.largeFileMode = params.largeFileMode + this.assert = params.assert || (condition => condition) + this.showInvisibles = (params.showInvisibles != null) ? params.showInvisibles : true + this.autoHeight = params.autoHeight + this.autoWidth = params.autoWidth + this.scrollPastEnd = (params.scrollPastEnd != null) ? params.scrollPastEnd : false + this.scrollSensitivity = (params.scrollSensitivity != null) ? params.scrollSensitivity : 40 + this.editorWidthInChars = params.editorWidthInChars + this.invisibles = params.invisibles + this.showIndentGuide = params.showIndentGuide + this.softWrapped = params.softWrapped + this.softWrapAtPreferredLineLength = params.softWrapAtPreferredLineLength + this.preferredLineLength = params.preferredLineLength + this.showCursorOnSelection = (params.showCursorOnSelection != null) ? params.showCursorOnSelection : true + this.maxScreenLineLength = params.maxScreenLineLength + this.softTabs = (params.softTabs != null) ? params.softTabs : true + this.autoIndent = (params.autoIndent != null) ? params.autoIndent : true + this.autoIndentOnPaste = (params.autoIndentOnPaste != null) ? params.autoIndentOnPaste : true + this.undoGroupingInterval = (params.undoGroupingInterval != null) ? params.undoGroupingInterval : 300 + this.nonWordCharacters = (params.nonWordCharacters != null) ? params.nonWordCharacters : "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" + this.softWrapped = (params.softWrapped != null) ? params.softWrapped : false + this.softWrapAtPreferredLineLength = (params.softWrapAtPreferredLineLength != null) ? params.softWrapAtPreferredLineLength : false + this.preferredLineLength = (params.preferredLineLength != null) ? params.preferredLineLength : 80 + this.maxScreenLineLength = (params.maxScreenLineLength != null) ? params.maxScreenLineLength : 500 + this.showLineNumbers = (params.showLineNumbers != null) ? params.showLineNumbers : true + const {tabLength = 2} = params + + this.alive = true + this.doBackgroundWork = this.doBackgroundWork.bind(this) + this.serializationVersion = 1 + this.suppressSelectionMerging = false + this.selectionFlashDuration = 500 + this.gutterContainer = null + this.verticalScrollMargin = 2 + this.horizontalScrollMargin = 6 + this.lineHeightInPixels = null + this.defaultCharWidth = null + this.height = null + this.width = null + this.registered = false + this.atomicSoftTabs = true + this.emitter = new Emitter() + this.disposables = new CompositeDisposable() + this.cursors = [] + this.cursorsByMarkerId = new Map() + this.selections = [] + this.hasTerminatedPendingState = false + + this.buffer = params.buffer || new TextBuffer({ + shouldDestroyOnFileDelete () { return atom.config.get('core.closeDeletedFileTabs') } + }) + + this.tokenizedBuffer = params.tokenizedBuffer || new TokenizedBuffer({ + grammar: params.grammar, + tabLength, + buffer: this.buffer, + largeFileMode: this.largeFileMode, + assert: this.assert + }) + + if (params.displayLayer) { + this.displayLayer = params.displayLayer + } else { + const displayLayerParams = { + invisibles: this.getInvisibles(), + softWrapColumn: this.getSoftWrapColumn(), + showIndentGuides: this.doesShowIndentGuide(), + atomicSoftTabs: params.atomicSoftTabs != null ? params.atomicSoftTabs : true, + tabLength, + ratioForCharacter: this.ratioForCharacter.bind(this), + isWrapBoundary, + foldCharacter: ZERO_WIDTH_NBSP, + softWrapHangingIndent: params.softWrapHangingIndentLength != null ? params.softWrapHangingIndentLength : 0 + } + + this.displayLayer = this.buffer.getDisplayLayer(params.displayLayerId) + if (this.displayLayer) { + this.displayLayer.reset(displayLayerParams) + this.selectionsMarkerLayer = this.displayLayer.getMarkerLayer(params.selectionsMarkerLayerId) + } else { + this.displayLayer = this.buffer.addDisplayLayer(displayLayerParams) + } + } + + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork) + this.disposables.add(new Disposable(() => { + if (this.backgroundWorkHandle != null) return cancelIdleCallback(this.backgroundWorkHandle) + })) + + this.defaultMarkerLayer = this.displayLayer.addMarkerLayer() + if (!this.selectionsMarkerLayer) { + this.selectionsMarkerLayer = this.addMarkerLayer({maintainHistory: true, persistent: true}) + } + + this.displayLayer.setTextDecorationLayer(this.tokenizedBuffer) + + this.decorationManager = new DecorationManager(this) + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'cursor'}) + if (!this.isMini()) this.decorateCursorLine() + + this.decorateMarkerLayer(this.displayLayer.foldsMarkerLayer, {type: 'line-number', class: 'folded'}) + + for (let marker of this.selectionsMarkerLayer.getMarkers()) { + this.addSelection(marker) + } + + this.subscribeToBuffer() + this.subscribeToDisplayLayer() + + if (this.cursors.length === 0 && !params.suppressCursorCreation) { + const initialLine = Math.max(parseInt(params.initialLine) || 0, 0) + const initialColumn = Math.max(parseInt(params.initialColumn) || 0, 0) + this.addCursorAtBufferPosition([initialLine, initialColumn]) + } + + this.gutterContainer = new GutterContainer(this) + this.lineNumberGutter = this.gutterContainer.addGutter({ + name: 'line-number', + priority: 0, + visible: params.lineNumberGutterVisible + }) + } + + get element () { + return this.getElement() + } + + get editorElement () { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.editorElement\` has always been private, but now + it is gone. Reading the \`editorElement\` property still returns a + reference to the editor element but this field will be removed in a + later version of Atom, so we recommend using the \`element\` property instead.\ + `) + + return this.getElement() + } + + get displayBuffer () { + Grim.deprecate(dedent`\ + \`TextEditor.prototype.displayBuffer\` has always been private, but now + it is gone. Reading the \`displayBuffer\` property now returns a reference + to the containing \`TextEditor\`, which now provides *some* of the API of + the defunct \`DisplayBuffer\` class.\ + `) + return this + } + + get languageMode () { + return this.tokenizedBuffer + } + + get rowsPerPage () { + return this.getRowsPerPage() + } + + decorateCursorLine () { + this.cursorLineDecorations = [ + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line', class: 'cursor-line', onlyEmpty: true}), + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line-number', class: 'cursor-line'}), + this.decorateMarkerLayer(this.selectionsMarkerLayer, {type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true}) + ] + } + + doBackgroundWork (deadline) { + const previousLongestRow = this.getApproximateLongestScreenRow() + if (this.displayLayer.doBackgroundWork(deadline)) { + this.backgroundWorkHandle = requestIdleCallback(this.doBackgroundWork) + } else { + this.backgroundWorkHandle = null + } + + if (this.component && this.getApproximateLongestScreenRow() !== previousLongestRow) { + this.component.scheduleUpdate() + } + } + + update (params) { + const displayLayerParams = {} + + for (let param of Object.keys(params)) { + const value = params[param] + + switch (param) { + case 'autoIndent': + this.autoIndent = value + break + + case 'autoIndentOnPaste': + this.autoIndentOnPaste = value + break + + case 'undoGroupingInterval': + this.undoGroupingInterval = value + break + + case 'nonWordCharacters': + this.nonWordCharacters = value + break + + case 'scrollSensitivity': + this.scrollSensitivity = value + break + + case 'encoding': + this.buffer.setEncoding(value) + break + + case 'softTabs': + if (value !== this.softTabs) { + this.softTabs = value + } + break + + case 'atomicSoftTabs': + if (value !== this.displayLayer.atomicSoftTabs) { + displayLayerParams.atomicSoftTabs = value + } + break + + case 'tabLength': + if (value > 0 && value !== this.tokenizedBuffer.getTabLength()) { + this.tokenizedBuffer.setTabLength(value) + displayLayerParams.tabLength = value + } + break + + case 'softWrapped': + if (value !== this.softWrapped) { + this.softWrapped = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + this.emitter.emit('did-change-soft-wrapped', this.isSoftWrapped()) + } + break + + case 'softWrapHangingIndentLength': + if (value !== this.displayLayer.softWrapHangingIndent) { + displayLayerParams.softWrapHangingIndent = value + } + break + + case 'softWrapAtPreferredLineLength': + if (value !== this.softWrapAtPreferredLineLength) { + this.softWrapAtPreferredLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'preferredLineLength': + if (value !== this.preferredLineLength) { + this.preferredLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'maxScreenLineLength': + if (value !== this.maxScreenLineLength) { + this.maxScreenLineLength = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'mini': + if (value !== this.mini) { + this.mini = value + this.emitter.emit('did-change-mini', value) + displayLayerParams.invisibles = this.getInvisibles() + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + displayLayerParams.showIndentGuides = this.doesShowIndentGuide() + if (this.mini) { + for (let decoration of this.cursorLineDecorations) { decoration.destroy() } + this.cursorLineDecorations = null + } else { + this.decorateCursorLine() + } + if (this.component != null) { + this.component.scheduleUpdate() + } + } + break + + case 'placeholderText': + if (value !== this.placeholderText) { + this.placeholderText = value + this.emitter.emit('did-change-placeholder-text', value) + } + break + + case 'lineNumberGutterVisible': + if (value !== this.lineNumberGutterVisible) { + if (value) { + this.lineNumberGutter.show() + } else { + this.lineNumberGutter.hide() + } + this.emitter.emit('did-change-line-number-gutter-visible', this.lineNumberGutter.isVisible()) + } + break + + case 'showIndentGuide': + if (value !== this.showIndentGuide) { + this.showIndentGuide = value + displayLayerParams.showIndentGuides = this.doesShowIndentGuide() + } + break + + case 'showLineNumbers': + if (value !== this.showLineNumbers) { + this.showLineNumbers = value + if (this.component != null) { + this.component.scheduleUpdate() + } + } + break + + case 'showInvisibles': + if (value !== this.showInvisibles) { + this.showInvisibles = value + displayLayerParams.invisibles = this.getInvisibles() + } + break + + case 'invisibles': + if (!_.isEqual(value, this.invisibles)) { + this.invisibles = value + displayLayerParams.invisibles = this.getInvisibles() + } + break + + case 'editorWidthInChars': + if (value > 0 && value !== this.editorWidthInChars) { + this.editorWidthInChars = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'width': + if (value !== this.width) { + this.width = value + displayLayerParams.softWrapColumn = this.getSoftWrapColumn() + } + break + + case 'scrollPastEnd': + if (value !== this.scrollPastEnd) { + this.scrollPastEnd = value + if (this.component) this.component.scheduleUpdate() + } + break + + case 'autoHeight': + if (value !== this.autoHeight) { + this.autoHeight = value + } + break + + case 'autoWidth': + if (value !== this.autoWidth) { + this.autoWidth = value + } + break + + case 'showCursorOnSelection': + if (value !== this.showCursorOnSelection) { + this.showCursorOnSelection = value + if (this.component) this.component.scheduleUpdate() + } + break + + default: + if (param !== 'ref' && param !== 'key') { + throw new TypeError(`Invalid TextEditor parameter: '${param}'`) + } + } + } + + this.displayLayer.reset(displayLayerParams) + + if (this.component) { + return this.component.getNextUpdatePromise() + } else { + return Promise.resolve() + } + } + + scheduleComponentUpdate () { + if (this.component) this.component.scheduleUpdate() + } + + serialize () { + const tokenizedBufferState = this.tokenizedBuffer.serialize() + + return { + deserializer: 'TextEditor', + version: SERIALIZATION_VERSION, + + // TODO: Remove this forward-compatible fallback once 1.8 reaches stable. + displayBuffer: {tokenizedBuffer: tokenizedBufferState}, + + tokenizedBuffer: tokenizedBufferState, + displayLayerId: this.displayLayer.id, + selectionsMarkerLayerId: this.selectionsMarkerLayer.id, + + initialScrollTopRow: this.getScrollTopRow(), + initialScrollLeftColumn: this.getScrollLeftColumn(), + + atomicSoftTabs: this.displayLayer.atomicSoftTabs, + softWrapHangingIndentLength: this.displayLayer.softWrapHangingIndent, + + id: this.id, + softTabs: this.softTabs, + softWrapped: this.softWrapped, + softWrapAtPreferredLineLength: this.softWrapAtPreferredLineLength, + preferredLineLength: this.preferredLineLength, + mini: this.mini, + editorWidthInChars: this.editorWidthInChars, + width: this.width, + largeFileMode: this.largeFileMode, + maxScreenLineLength: this.maxScreenLineLength, + registered: this.registered, + invisibles: this.invisibles, + showInvisibles: this.showInvisibles, + showIndentGuide: this.showIndentGuide, + autoHeight: this.autoHeight, + autoWidth: this.autoWidth + } + } + + subscribeToBuffer () { + this.buffer.retain() + this.disposables.add(this.buffer.onDidChangePath(() => { + this.emitter.emit('did-change-title', this.getTitle()) + this.emitter.emit('did-change-path', this.getPath()) + })) + this.disposables.add(this.buffer.onDidChangeEncoding(() => { + this.emitter.emit('did-change-encoding', this.getEncoding()) + })) + this.disposables.add(this.buffer.onDidDestroy(() => this.destroy())) + this.disposables.add(this.buffer.onDidChangeModified(() => { + if (!this.hasTerminatedPendingState && this.buffer.isModified()) this.terminatePendingState() + })) + } + + terminatePendingState () { + if (!this.hasTerminatedPendingState) this.emitter.emit('did-terminate-pending-state') + this.hasTerminatedPendingState = true + } + + onDidTerminatePendingState (callback) { + return this.emitter.on('did-terminate-pending-state', callback) + } + + subscribeToDisplayLayer () { + this.disposables.add(this.tokenizedBuffer.onDidChangeGrammar(this.handleGrammarChange.bind(this))) + this.disposables.add(this.displayLayer.onDidChange(changes => { + this.mergeIntersectingSelections() + if (this.component) this.component.didChangeDisplayLayer(changes) + this.emitter.emit('did-change', changes.map(change => new ChangeEvent(change))) + })) + this.disposables.add(this.displayLayer.onDidReset(() => { + this.mergeIntersectingSelections() + if (this.component) this.component.didResetDisplayLayer() + this.emitter.emit('did-change', {}) + })) + this.disposables.add(this.selectionsMarkerLayer.onDidCreateMarker(this.addSelection.bind(this))) + return this.disposables.add(this.selectionsMarkerLayer.onDidUpdate(() => (this.component != null ? this.component.didUpdateSelections() : undefined))) + } + + destroy () { + if (!this.alive) return + this.alive = false + this.disposables.dispose() + this.displayLayer.destroy() + this.tokenizedBuffer.destroy() + for (let selection of this.selections.slice()) { + selection.destroy() + } + this.buffer.release() + this.gutterContainer.destroy() + this.emitter.emit('did-destroy') + this.emitter.clear() + if (this.component) this.component.element.component = null + this.component = null + this.lineNumberGutter.element = null + } + + isAlive () { return this.alive } + + isDestroyed () { return !this.alive } + + /* + Section: Event Subscription + */ + + // Essential: Calls your `callback` when the buffer's title has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeTitle (callback) { + return this.emitter.on('did-change-title', callback) + } + + // Essential: Calls your `callback` when the buffer's path, and therefore title, has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePath (callback) { + return this.emitter.on('did-change-path', callback) + } + + // Essential: Invoke the given callback synchronously when the content of the + // buffer changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider {::onDidStopChanging} to + // delay expensive operations until after changes stop occurring. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChange (callback) { + return this.emitter.on('did-change', callback) + } + + // Essential: Invoke `callback` when the buffer's contents change. It is + // emit asynchronously 300ms after the last buffer change. This is a good place + // to handle changes to the buffer without compromising typing performance. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChanging (callback) { + return this.getBuffer().onDidStopChanging(callback) + } + + // Essential: Calls your `callback` when a {Cursor} is moved. If there are + // multiple cursors, your callback will be called for each cursor. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferPosition` {Point} + // * `oldScreenPosition` {Point} + // * `newBufferPosition` {Point} + // * `newScreenPosition` {Point} + // * `textChanged` {Boolean} + // * `cursor` {Cursor} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeCursorPosition (callback) { + return this.emitter.on('did-change-cursor-position', callback) + } + + // Essential: Calls your `callback` when a selection's screen range changes. + // + // * `callback` {Function} + // * `event` {Object} + // * `oldBufferRange` {Range} + // * `oldScreenRange` {Range} + // * `newBufferRange` {Range} + // * `newScreenRange` {Range} + // * `selection` {Selection} that triggered the event + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeSelectionRange (callback) { + return this.emitter.on('did-change-selection-range', callback) + } + + // Extended: Calls your `callback` when soft wrap was enabled or disabled. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeSoftWrapped (callback) { + return this.emitter.on('did-change-soft-wrapped', callback) + } + + // Extended: Calls your `callback` when the buffer's encoding has changed. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeEncoding (callback) { + return this.emitter.on('did-change-encoding', callback) + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. Immediately calls your callback with + // the current grammar. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGrammar (callback) { + callback(this.getGrammar()) + return this.onDidChangeGrammar(callback) + } + + // Extended: Calls your `callback` when the grammar that interprets and + // colorizes the text has been changed. + // + // * `callback` {Function} + // * `grammar` {Grammar} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeGrammar (callback) { + return this.emitter.on('did-change-grammar', callback) + } + + // Extended: Calls your `callback` when the result of {::isModified} changes. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeModified (callback) { + return this.getBuffer().onDidChangeModified(callback) + } + + // Extended: Calls your `callback` when the buffer's underlying file changes on + // disk at a moment when the result of {::isModified} is true. + // + // * `callback` {Function} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidConflict (callback) { + return this.getBuffer().onDidConflict(callback) + } + + // Extended: Calls your `callback` before text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // * `cancel` {Function} Call to prevent the text from being inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillInsertText (callback) { + return this.emitter.on('will-insert-text', callback) + } + + // Extended: Calls your `callback` after text has been inserted. + // + // * `callback` {Function} + // * `event` event {Object} + // * `text` {String} text to be inserted + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidInsertText (callback) { + return this.emitter.on('did-insert-text', callback) + } + + // Essential: Invoke the given callback after the buffer is saved to disk. + // + // * `callback` {Function} to be called after the buffer is saved. + // * `event` {Object} with the following keys: + // * `path` The path to which the buffer was saved. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidSave (callback) { + return this.getBuffer().onDidSave(callback) + } + + // Essential: Invoke the given callback when the editor is destroyed. + // + // * `callback` {Function} to be called when the editor is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy (callback) { + return this.emitter.once('did-destroy', callback) + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // Immediately calls your callback for each existing cursor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeCursors (callback) { + this.getCursors().forEach(callback) + return this.onDidAddCursor(callback) + } + + // Extended: Calls your `callback` when a {Cursor} is added to the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddCursor (callback) { + return this.emitter.on('did-add-cursor', callback) + } + + // Extended: Calls your `callback` when a {Cursor} is removed from the editor. + // + // * `callback` {Function} + // * `cursor` {Cursor} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveCursor (callback) { + return this.emitter.on('did-remove-cursor', callback) + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // Immediately calls your callback for each existing selection. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeSelections (callback) { + this.getSelections().forEach(callback) + return this.onDidAddSelection(callback) + } + + // Extended: Calls your `callback` when a {Selection} is added to the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddSelection (callback) { + return this.emitter.on('did-add-selection', callback) + } + + // Extended: Calls your `callback` when a {Selection} is removed from the editor. + // + // * `callback` {Function} + // * `selection` {Selection} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveSelection (callback) { + return this.emitter.on('did-remove-selection', callback) + } + + // Extended: Calls your `callback` with each {Decoration} added to the editor. + // Calls your `callback` immediately for any existing decorations. + // + // * `callback` {Function} + // * `decoration` {Decoration} + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeDecorations (callback) { + return this.decorationManager.observeDecorations(callback) + } + + // Extended: Calls your `callback` when a {Decoration} is added to the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was added + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddDecoration (callback) { + return this.decorationManager.onDidAddDecoration(callback) + } + + // Extended: Calls your `callback` when a {Decoration} is removed from the editor. + // + // * `callback` {Function} + // * `decoration` {Decoration} that was removed + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveDecoration (callback) { + return this.decorationManager.onDidRemoveDecoration(callback) + } + + // Called by DecorationManager when a decoration is added. + didAddDecoration (decoration) { + if (this.component && decoration.isType('block')) { + this.component.addBlockDecoration(decoration) + } + } + + // Extended: Calls your `callback` when the placeholder text is changed. + // + // * `callback` {Function} + // * `placeholderText` {String} new text + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangePlaceholderText (callback) { + return this.emitter.on('did-change-placeholder-text', callback) + } + + onDidChangeScrollTop (callback) { + Grim.deprecate('This is now a view method. Call TextEditorElement::onDidChangeScrollTop instead.') + return this.getElement().onDidChangeScrollTop(callback) + } + + onDidChangeScrollLeft (callback) { + Grim.deprecate('This is now a view method. Call TextEditorElement::onDidChangeScrollLeft instead.') + return this.getElement().onDidChangeScrollLeft(callback) + } + + onDidRequestAutoscroll (callback) { + return this.emitter.on('did-request-autoscroll', callback) + } + + // TODO Remove once the tabs package no longer uses .on subscriptions + onDidChangeIcon (callback) { + return this.emitter.on('did-change-icon', callback) + } + + onDidUpdateDecorations (callback) { + return this.decorationManager.onDidUpdateDecorations(callback) + } + + // Essential: Retrieves the current {TextBuffer}. + getBuffer () { return this.buffer } + + // Retrieves the current buffer's URI. + getURI () { return this.buffer.getUri() } + + // Create an {TextEditor} with its initial state based on this object + copy () { + const displayLayer = this.displayLayer.copy() + const selectionsMarkerLayer = displayLayer.getMarkerLayer(this.buffer.getMarkerLayer(this.selectionsMarkerLayer.id).copy().id) + const softTabs = this.getSoftTabs() + return new TextEditor({ + buffer: this.buffer, + selectionsMarkerLayer, + softTabs, + suppressCursorCreation: true, + tabLength: this.tokenizedBuffer.getTabLength(), + initialScrollTopRow: this.getScrollTopRow(), + initialScrollLeftColumn: this.getScrollLeftColumn(), + assert: this.assert, + displayLayer, + grammar: this.getGrammar(), + autoWidth: this.autoWidth, + autoHeight: this.autoHeight, + showCursorOnSelection: this.showCursorOnSelection + }) + } + + // Controls visibility based on the given {Boolean}. + setVisible (visible) { this.tokenizedBuffer.setVisible(visible) } + + setMini (mini) { + this.update({mini}) + } + + isMini () { return this.mini } + + onDidChangeMini (callback) { + return this.emitter.on('did-change-mini', callback) + } + + setLineNumberGutterVisible (lineNumberGutterVisible) { this.update({lineNumberGutterVisible}) } + + isLineNumberGutterVisible () { return this.lineNumberGutter.isVisible() } + + onDidChangeLineNumberGutterVisible (callback) { + return this.emitter.on('did-change-line-number-gutter-visible', callback) + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // Immediately calls your callback for each existing gutter. + // + // * `callback` {Function} + // * `gutter` {Gutter} that currently exists/was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGutters (callback) { + return this.gutterContainer.observeGutters(callback) + } + + // Essential: Calls your `callback` when a {Gutter} is added to the editor. + // + // * `callback` {Function} + // * `gutter` {Gutter} that was added. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddGutter (callback) { + return this.gutterContainer.onDidAddGutter(callback) + } + + // Essential: Calls your `callback` when a {Gutter} is removed from the editor. + // + // * `callback` {Function} + // * `name` The name of the {Gutter} that was removed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveGutter (callback) { + return this.gutterContainer.onDidRemoveGutter(callback) + } + + // Set the number of characters that can be displayed horizontally in the + // editor. + // + // * `editorWidthInChars` A {Number} representing the width of the + // {TextEditorElement} in characters. + setEditorWidthInChars (editorWidthInChars) { this.update({editorWidthInChars}) } + + // Returns the editor width in characters. + getEditorWidthInChars () { + if (this.width != null && this.defaultCharWidth > 0) { + return Math.max(0, Math.floor(this.width / this.defaultCharWidth)) + } else { + return this.editorWidthInChars + } + } + + /* + Section: File Details + */ + + // Essential: Get the editor's title for display in other parts of the + // UI such as the tabs. + // + // If the editor's buffer is saved, its title is the file name. If it is + // unsaved, its title is "untitled". + // + // Returns a {String}. + getTitle () { + return this.getFileName() || 'untitled' + } + + // Essential: Get unique title for display in other parts of the UI, such as + // the window title. + // + // If the editor's buffer is unsaved, its title is "untitled" + // If the editor's buffer is saved, its unique title is formatted as one + // of the following, + // * "" when it is the only editing buffer with this file name. + // * "" when other buffers have this file name. + // + // Returns a {String} + getLongTitle () { + if (this.getPath()) { + const fileName = this.getFileName() + + let myPathSegments + const openEditorPathSegmentsWithSameFilename = [] + for (const textEditor of atom.workspace.getTextEditors()) { + if (textEditor.getFileName() === fileName) { + const pathSegments = fs.tildify(textEditor.getDirectoryPath()).split(path.sep) + openEditorPathSegmentsWithSameFilename.push(pathSegments) + if (textEditor === this) myPathSegments = pathSegments + } + } + + if (!myPathSegments || openEditorPathSegmentsWithSameFilename.length === 1) return fileName + + let commonPathSegmentCount + for (let i = 0, {length} = myPathSegments; i < length; i++) { + const myPathSegment = myPathSegments[i] + if (openEditorPathSegmentsWithSameFilename.some(segments => (segments.length === i + 1) || (segments[i] !== myPathSegment))) { + commonPathSegmentCount = i + break + } + } + + return `${fileName} \u2014 ${path.join(...myPathSegments.slice(commonPathSegmentCount))}` + } else { + return 'untitled' + } + } + + // Essential: Returns the {String} path of this editor's text buffer. + getPath () { + return this.buffer.getPath() + } + + getFileName () { + const fullPath = this.getPath() + if (fullPath) return path.basename(fullPath) + } + + getDirectoryPath () { + const fullPath = this.getPath() + if (fullPath) return path.dirname(fullPath) + } + + // Extended: Returns the {String} character set encoding of this editor's text + // buffer. + getEncoding () { return this.buffer.getEncoding() } + + // Extended: Set the character set encoding to use in this editor's text + // buffer. + // + // * `encoding` The {String} character set encoding name such as 'utf8' + setEncoding (encoding) { this.buffer.setEncoding(encoding) } + + // Essential: Returns {Boolean} `true` if this editor has been modified. + isModified () { return this.buffer.isModified() } + + // Essential: Returns {Boolean} `true` if this editor has no content. + isEmpty () { return this.buffer.isEmpty() } + + /* + Section: File Operations + */ + + // Essential: Saves the editor's text buffer. + // + // See {TextBuffer::save} for more details. + save () { return this.buffer.save() } + + // Essential: Saves the editor's text buffer as the given path. + // + // See {TextBuffer::saveAs} for more details. + // + // * `filePath` A {String} path. + saveAs (filePath) { return this.buffer.saveAs(filePath) } + + // Determine whether the user should be prompted to save before closing + // this editor. + shouldPromptToSave ({windowCloseRequested, projectHasPaths} = {}) { + if (windowCloseRequested && projectHasPaths && atom.stateStore.isConnected()) { + return this.buffer.isInConflict() + } else { + return this.isModified() && !this.buffer.hasMultipleEditors() + } + } + + // Returns an {Object} to configure dialog shown when this editor is saved + // via {Pane::saveItemAs}. + getSaveDialogOptions () { return {} } + + /* + Section: Reading Text + */ + + // Essential: Returns a {String} representing the entire contents of the editor. + getText () { return this.buffer.getText() } + + // Essential: Get the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // + // Returns a {String}. + getTextInBufferRange (range) { + return this.buffer.getTextInRange(range) + } + + // Essential: Returns a {Number} representing the number of lines in the buffer. + getLineCount () { return this.buffer.getLineCount() } + + // Essential: Returns a {Number} representing the number of screen lines in the + // editor. This accounts for folds. + getScreenLineCount () { return this.displayLayer.getScreenLineCount() } + + getApproximateScreenLineCount () { return this.displayLayer.getApproximateScreenLineCount() } + + // Essential: Returns a {Number} representing the last zero-indexed buffer row + // number of the editor. + getLastBufferRow () { return this.buffer.getLastRow() } + + // Essential: Returns a {Number} representing the last zero-indexed screen row + // number of the editor. + getLastScreenRow () { return this.getScreenLineCount() - 1 } + + // Essential: Returns a {String} representing the contents of the line at the + // given buffer row. + // + // * `bufferRow` A {Number} representing a zero-indexed buffer row. + lineTextForBufferRow (bufferRow) { return this.buffer.lineForRow(bufferRow) } + + // Essential: Returns a {String} representing the contents of the line at the + // given screen row. + // + // * `screenRow` A {Number} representing a zero-indexed screen row. + lineTextForScreenRow (screenRow) { + const screenLine = this.screenLineForScreenRow(screenRow) + if (screenLine) return screenLine.lineText + } + + logScreenLines (start = 0, end = this.getLastScreenRow()) { + for (let row = start; row <= end; row++) { + const line = this.lineTextForScreenRow(row) + console.log(row, this.bufferRowForScreenRow(row), line, line.length) + } + } + + tokensForScreenRow (screenRow) { + const tokens = [] + let lineTextIndex = 0 + const currentTokenScopes = [] + const {lineText, tags} = this.screenLineForScreenRow(screenRow) + for (const tag of tags) { + if (this.displayLayer.isOpenTag(tag)) { + currentTokenScopes.push(this.displayLayer.classNameForTag(tag)) + } else if (this.displayLayer.isCloseTag(tag)) { + currentTokenScopes.pop() + } else { + tokens.push({ + text: lineText.substr(lineTextIndex, tag), + scopes: currentTokenScopes.slice() + }) + lineTextIndex += tag + } + } + return tokens + } + + screenLineForScreenRow (screenRow) { + return this.displayLayer.getScreenLine(screenRow) + } + + bufferRowForScreenRow (screenRow) { + return this.displayLayer.translateScreenPosition(Point(screenRow, 0)).row + } + + bufferRowsForScreenRows (startScreenRow, endScreenRow) { + return this.displayLayer.bufferRowsForScreenRows(startScreenRow, endScreenRow + 1) + } + + screenRowForBufferRow (row) { + return this.displayLayer.translateBufferPosition(Point(row, 0)).row + } + + getRightmostScreenPosition () { return this.displayLayer.getRightmostScreenPosition() } + + getApproximateRightmostScreenPosition () { return this.displayLayer.getApproximateRightmostScreenPosition() } + + getMaxScreenLineLength () { return this.getRightmostScreenPosition().column } + + getLongestScreenRow () { return this.getRightmostScreenPosition().row } + + getApproximateLongestScreenRow () { return this.getApproximateRightmostScreenPosition().row } + + lineLengthForScreenRow (screenRow) { return this.displayLayer.lineLengthForScreenRow(screenRow) } + + // Returns the range for the given buffer row. + // + // * `row` A row {Number}. + // * `options` (optional) An options hash with an `includeNewline` key. + // + // Returns a {Range}. + bufferRangeForBufferRow (row, options) { + return this.buffer.rangeForRow(row, options && options.includeNewline) + } + + // Get the text in the given {Range}. + // + // Returns a {String}. + getTextInRange (range) { return this.buffer.getTextInRange(range) } + + // {Delegates to: TextBuffer.isRowBlank} + isBufferRowBlank (bufferRow) { return this.buffer.isRowBlank(bufferRow) } + + // {Delegates to: TextBuffer.nextNonBlankRow} + nextNonBlankBufferRow (bufferRow) { return this.buffer.nextNonBlankRow(bufferRow) } + + // {Delegates to: TextBuffer.getEndPosition} + getEofBufferPosition () { return this.buffer.getEndPosition() } + + // Essential: Get the {Range} of the paragraph surrounding the most recently added + // cursor. + // + // Returns a {Range}. + getCurrentParagraphBufferRange () { + return this.getLastCursor().getCurrentParagraphBufferRange() + } + + /* + Section: Mutating Text + */ + + // Essential: Replaces the entire contents of the buffer with the given {String}. + // + // * `text` A {String} to replace with + setText (text) { return this.buffer.setText(text) } + + // Essential: Set the text in the given {Range} in buffer coordinates. + // + // * `range` A {Range} or range-compatible {Array}. + // * `text` A {String} + // * `options` (optional) {Object} + // * `normalizeLineEndings` (optional) {Boolean} (default: true) + // * `undo` (optional) {String} 'skip' will skip the undo system + // + // Returns the {Range} of the newly-inserted text. + setTextInBufferRange (range, text, options) { + return this.getBuffer().setTextInRange(range, text, options) + } + + // Essential: For each selection, replace the selected text with the given text. + // + // * `text` A {String} representing the text to insert. + // * `options` (optional) See {Selection::insertText}. + // + // Returns a {Range} when the text has been inserted + // Returns a {Boolean} false when the text has not been inserted + insertText (text, options = {}) { + if (!this.emitWillInsertTextEvent(text)) return false + + const groupingInterval = options.groupUndo ? this.undoGroupingInterval : 0 + if (options.autoIndentNewline == null) options.autoIndentNewline = this.shouldAutoIndent() + if (options.autoDecreaseIndent == null) options.autoDecreaseIndent = this.shouldAutoIndent() + return this.mutateSelectedText(selection => { + const range = selection.insertText(text, options) + const didInsertEvent = {text, range} + this.emitter.emit('did-insert-text', didInsertEvent) + return range + }, groupingInterval) + } + + // Essential: For each selection, replace the selected text with a newline. + insertNewline (options) { + return this.insertText('\n', options) + } + + // Essential: For each selection, if the selection is empty, delete the character + // following the cursor. Otherwise delete the selected text. + delete () { + return this.mutateSelectedText(selection => selection.delete()) + } + + // Essential: For each selection, if the selection is empty, delete the character + // preceding the cursor. Otherwise delete the selected text. + backspace () { + return this.mutateSelectedText(selection => selection.backspace()) + } + + // Extended: Mutate the text of all the selections in a single transaction. + // + // All the changes made inside the given {Function} can be reverted with a + // single call to {::undo}. + // + // * `fn` A {Function} that will be called once for each {Selection}. The first + // argument will be a {Selection} and the second argument will be the + // {Number} index of that selection. + mutateSelectedText (fn, groupingInterval = 0) { + return this.mergeIntersectingSelections(() => { + return this.transact(groupingInterval, () => { + return this.getSelectionsOrderedByBufferPosition().map((selection, index) => fn(selection, index)) + }) + }) + } + + // Move lines intersecting the most recent selection or multiple selections + // up by one row in screen coordinates. + moveLineUp () { + const selections = this.getSelectedBufferRanges().sort((a, b) => a.compare(b)) + + if (selections[0].start.row === 0) return + if (selections[selections.length - 1].start.row === this.getLastBufferRow() && this.buffer.getLastLine() === '') return + + this.transact(() => { + const newSelectionRanges = [] + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift() + const selectionsToMove = [selection] + + while (selection.end.row === (selections[0] != null ? selections[0].start.row : undefined)) { + selectionsToMove.push(selections[0]) + selection.end.row = selections[0].end.row + selections.shift() + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row + let endRow = selection.end.row + if (selection.end.row > selection.start.row && selection.end.column === 0) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow-- + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow) + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1) + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) + + // If selected line range is preceded by a fold, one line above on screen + // could be multiple lines in the buffer. + const precedingRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow - 1) + const insertDelta = linesRange.start.row - precedingRow + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([-insertDelta, 0])) + + // Delete lines spanned by selection and insert them on the preceding buffer row + let lines = this.buffer.getTextInRange(linesRange) + if (lines[lines.length - 1] !== '\n') { lines += this.buffer.lineEndingForRow(linesRange.end.row - 2) } + this.buffer.delete(linesRange) + this.buffer.insert([precedingRow, 0], lines) + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold) + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([-insertDelta, 0])) + } + } + + this.setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) + if (this.shouldAutoIndent()) this.autoIndentSelectedRows() + this.scrollToBufferPosition([newSelectionRanges[0].start.row, 0]) + }) + } + + // Move lines intersecting the most recent selection or multiple selections + // down by one row in screen coordinates. + moveLineDown () { + const selections = this.getSelectedBufferRanges() + selections.sort((a, b) => b.compare(a)) + + this.transact(() => { + this.consolidateSelections() + const newSelectionRanges = [] + + while (selections.length > 0) { + // Find selections spanning a contiguous set of lines + const selection = selections.shift() + const selectionsToMove = [selection] + + // if the current selection start row matches the next selections' end row - make them one selection + while (selection.start.row === (selections[0] != null ? selections[0].end.row : undefined)) { + selectionsToMove.push(selections[0]) + selection.start.row = selections[0].start.row + selections.shift() + } + + // Compute the buffer range spanned by all these selections, expanding it + // so that it includes any folded region that intersects them. + let startRow = selection.start.row + let endRow = selection.end.row + if (selection.end.row > selection.start.row && selection.end.column === 0) { + // Don't move the last line of a multi-line selection if the selection ends at column 0 + endRow-- + } + + startRow = this.displayLayer.findBoundaryPrecedingBufferRow(startRow) + endRow = this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1) + const linesRange = new Range(Point(startRow, 0), Point(endRow, 0)) + + // If selected line range is followed by a fold, one line below on screen + // could be multiple lines in the buffer. But at the same time, if the + // next buffer row is wrapped, one line in the buffer can represent many + // screen rows. + const followingRow = Math.min(this.buffer.getLineCount(), this.displayLayer.findBoundaryFollowingBufferRow(endRow + 1)) + const insertDelta = followingRow - linesRange.end.row + + // Any folds in the text that is moved will need to be re-created. + // It includes the folds that were intersecting with the selection. + const rangesToRefold = this.displayLayer + .destroyFoldsIntersectingBufferRange(linesRange) + .map(range => range.translate([insertDelta, 0])) + + // Delete lines spanned by selection and insert them on the following correct buffer row + let lines = this.buffer.getTextInRange(linesRange) + if (followingRow - 1 === this.buffer.getLastRow()) { + lines = `\n${lines}` + } + + this.buffer.insert([followingRow, 0], lines) + this.buffer.delete(linesRange) + + // Restore folds that existed before the lines were moved + for (let rangeToRefold of rangesToRefold) { + this.displayLayer.foldBufferRange(rangeToRefold) + } + + for (const selectionToMove of selectionsToMove) { + newSelectionRanges.push(selectionToMove.translate([insertDelta, 0])) + } + } + + this.setSelectedBufferRanges(newSelectionRanges, {autoscroll: false, preserveFolds: true}) + if (this.shouldAutoIndent()) this.autoIndentSelectedRows() + this.scrollToBufferPosition([newSelectionRanges[0].start.row - 1, 0]) + }) + } + + // Move any active selections one column to the left. + moveSelectionLeft () { + const selections = this.getSelectedBufferRanges() + const noSelectionAtStartOfLine = selections.every(selection => selection.start.column !== 0) + + const translationDelta = [0, -1] + const translatedRanges = [] + + if (noSelectionAtStartOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToLeftOfSelection = new Range(selection.start.translate(translationDelta), selection.start) + const charTextToLeftOfSelection = this.buffer.getTextInRange(charToLeftOfSelection) + + this.buffer.insert(selection.end, charTextToLeftOfSelection) + this.buffer.delete(charToLeftOfSelection) + translatedRanges.push(selection.translate(translationDelta)) + } + + this.setSelectedBufferRanges(translatedRanges) + }) + } + } + + // Move any active selections one column to the right. + moveSelectionRight () { + const selections = this.getSelectedBufferRanges() + const noSelectionAtEndOfLine = selections.every(selection => { + return selection.end.column !== this.buffer.lineLengthForRow(selection.end.row) + }) + + const translationDelta = [0, 1] + const translatedRanges = [] + + if (noSelectionAtEndOfLine) { + this.transact(() => { + for (let selection of selections) { + const charToRightOfSelection = new Range(selection.end, selection.end.translate(translationDelta)) + const charTextToRightOfSelection = this.buffer.getTextInRange(charToRightOfSelection) + + this.buffer.delete(charToRightOfSelection) + this.buffer.insert(selection.start, charTextToRightOfSelection) + translatedRanges.push(selection.translate(translationDelta)) + } + + this.setSelectedBufferRanges(translatedRanges) + }) + } + } + + duplicateLines () { + this.transact(() => { + const selections = this.getSelectionsOrderedByBufferPosition() + const previousSelectionRanges = [] + + let i = selections.length - 1 + while (i >= 0) { + const j = i + previousSelectionRanges[i] = selections[i].getBufferRange() + if (selections[i].isEmpty()) { + const {start} = selections[i].getScreenRange() + selections[i].setScreenRange([[start.row, 0], [start.row + 1, 0]], {preserveFolds: true}) + } + let [startRow, endRow] = selections[i].getBufferRowRange() + endRow++ + while (i > 0) { + const [previousSelectionStartRow, previousSelectionEndRow] = selections[i - 1].getBufferRowRange() + if (previousSelectionEndRow === startRow) { + startRow = previousSelectionStartRow + previousSelectionRanges[i - 1] = selections[i - 1].getBufferRange() + i-- + } else { + break + } + } + + const intersectingFolds = this.displayLayer.foldsIntersectingBufferRange([[startRow, 0], [endRow, 0]]) + let textToDuplicate = this.getTextInBufferRange([[startRow, 0], [endRow, 0]]) + if (endRow > this.getLastBufferRow()) textToDuplicate = `\n${textToDuplicate}` + this.buffer.insert([endRow, 0], textToDuplicate) + + const insertedRowCount = endRow - startRow + + for (let k = i; k <= j; k++) { + selections[k].setBufferRange(previousSelectionRanges[k].translate([insertedRowCount, 0])) + } + + for (const fold of intersectingFolds) { + const foldRange = this.displayLayer.bufferRangeForFold(fold) + this.displayLayer.foldBufferRange(foldRange.translate([insertedRowCount, 0])) + } + + i-- + } + }) + } + + replaceSelectedText (options, fn) { + this.mutateSelectedText((selection) => { + selection.getBufferRange() + if (options && options.selectWordIfEmpty && selection.isEmpty()) { + selection.selectWord() + } + const text = selection.getText() + selection.deleteSelectedText() + const range = selection.insertText(fn(text)) + selection.setBufferRange(range) + }) + } + + // Split multi-line selections into one selection per line. + // + // Operates on all selections. This method breaks apart all multi-line + // selections to create multiple single-line selections that cumulatively cover + // the same original area. + splitSelectionsIntoLines () { + this.mergeIntersectingSelections(() => { + for (const selection of this.getSelections()) { + const range = selection.getBufferRange() + if (range.isSingleLine()) continue + + const {start, end} = range + this.addSelectionForBufferRange([start, [start.row, Infinity]]) + let {row} = start + while (++row < end.row) { + this.addSelectionForBufferRange([[row, 0], [row, Infinity]]) + } + if (end.column !== 0) this.addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) + selection.destroy() + } + }) + } + + // Extended: For each selection, transpose the selected text. + // + // If the selection is empty, the characters preceding and following the cursor + // are swapped. Otherwise, the selected characters are reversed. + transpose () { + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectRight() + const text = selection.getText() + selection.delete() + selection.cursor.moveLeft() + selection.insertText(text) + } else { + selection.insertText(selection.getText().split('').reverse().join('')) + } + }) + } + + // Extended: Convert the selected text to upper case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + upperCase () { + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase()) + } + + // Extended: Convert the selected text to lower case. + // + // For each selection, if the selection is empty, converts the containing word + // to upper case. Otherwise convert the selected text to upper case. + lowerCase () { + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase()) + } + + // Extended: Toggle line comments for rows intersecting selections. + // + // If the current grammar doesn't support comments, does nothing. + toggleLineCommentsInSelection () { + this.mutateSelectedText(selection => selection.toggleLineComments()) + } + + // Convert multiple lines to a single line. + // + // Operates on all selections. If the selection is empty, joins the current + // line with the next line. Otherwise it joins all lines that intersect the + // selection. + // + // Joining a line means that multiple lines are converted to a single line with + // the contents of each of the original non-empty lines separated by a space. + joinLines () { + this.mutateSelectedText(selection => selection.joinLines()) + } + + // Extended: For each cursor, insert a newline at beginning the following line. + insertNewlineBelow () { + this.transact(() => { + this.moveToEndOfLine() + this.insertNewline() + }) + } + + // Extended: For each cursor, insert a newline at the end of the preceding line. + insertNewlineAbove () { + this.transact(() => { + const bufferRow = this.getCursorBufferPosition().row + const indentLevel = this.indentationForBufferRow(bufferRow) + const onFirstLine = bufferRow === 0 + + this.moveToBeginningOfLine() + this.moveLeft() + this.insertNewline() + + if (this.shouldAutoIndent() && (this.indentationForBufferRow(bufferRow) < indentLevel)) { + this.setIndentationForBufferRow(bufferRow, indentLevel) + } + + if (onFirstLine) { + this.moveUp() + this.moveToEndOfLine() + } + }) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word that precede the cursor. Otherwise delete the + // selected text. + deleteToBeginningOfWord () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfWord()) + } + + // Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the + // previous word boundary. + deleteToPreviousWordBoundary () { + this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary()) + } + + // Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the + // next word boundary. + deleteToNextWordBoundary () { + this.mutateSelectedText(selection => selection.deleteToNextWordBoundary()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + deleteToBeginningOfSubword () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing subword following the cursor. Otherwise delete the selected + // text. + deleteToEndOfSubword () { + this.mutateSelectedText(selection => selection.deleteToEndOfSubword()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing line that precede the cursor. Otherwise delete the + // selected text. + deleteToBeginningOfLine () { + this.mutateSelectedText(selection => selection.deleteToBeginningOfLine()) + } + + // Extended: For each selection, if the selection is not empty, deletes the + // selection; otherwise, deletes all characters of the containing line + // following the cursor. If the cursor is already at the end of the line, + // deletes the following newline. + deleteToEndOfLine () { + this.mutateSelectedText(selection => selection.deleteToEndOfLine()) + } + + // Extended: For each selection, if the selection is empty, delete all characters + // of the containing word following the cursor. Otherwise delete the selected + // text. + deleteToEndOfWord () { + this.mutateSelectedText(selection => selection.deleteToEndOfWord()) + } + + // Extended: Delete all lines intersecting selections. + deleteLine () { + this.mergeSelectionsOnSameRows() + this.mutateSelectedText(selection => selection.deleteLine()) + } + + /* + Section: History + */ + + // Essential: Undo the last change. + undo () { + this.avoidMergingSelections(() => this.buffer.undo()) + this.getLastSelection().autoscroll() + } + + // Essential: Redo the last change. + redo () { + this.avoidMergingSelections(() => this.buffer.redo()) + this.getLastSelection().autoscroll() + } + + // Extended: Batch multiple operations as a single undo/redo step. + // + // Any group of operations that are logically grouped from the perspective of + // undoing and redoing should be performed in a transaction. If you want to + // abort the transaction, call {::abortTransaction} to terminate the function's + // execution and revert any changes performed up to the abortion. + // + // * `groupingInterval` (optional) The {Number} of milliseconds for which this + // transaction should be considered 'groupable' after it begins. If a transaction + // with a positive `groupingInterval` is committed while the previous transaction is + // still 'groupable', the two transactions are merged with respect to undo and redo. + // * `fn` A {Function} to call inside the transaction. + transact (groupingInterval, fn) { + return this.buffer.transact(groupingInterval, fn) + } + + // Extended: Abort an open transaction, undoing any operations performed so far + // within the transaction. + abortTransaction () { return this.buffer.abortTransaction() } + + // Extended: Create a pointer to the current state of the buffer for use + // with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}. + // + // Returns a checkpoint value. + createCheckpoint () { return this.buffer.createCheckpoint() } + + // Extended: Revert the buffer to the state it was in when the given + // checkpoint was created. + // + // The redo stack will be empty following this operation, so changes since the + // checkpoint will be lost. If the given checkpoint is no longer present in the + // undo history, no changes will be made to the buffer and this method will + // return `false`. + // + // * `checkpoint` The checkpoint to revert to. + // + // Returns a {Boolean} indicating whether the operation succeeded. + revertToCheckpoint (checkpoint) { return this.buffer.revertToCheckpoint(checkpoint) } + + // Extended: Group all changes since the given checkpoint into a single + // transaction for purposes of undo/redo. + // + // If the given checkpoint is no longer present in the undo history, no + // grouping will be performed and this method will return `false`. + // + // * `checkpoint` The checkpoint from which to group changes. + // + // Returns a {Boolean} indicating whether the operation succeeded. + groupChangesSinceCheckpoint (checkpoint) { return this.buffer.groupChangesSinceCheckpoint(checkpoint) } + + /* + Section: TextEditor Coordinates + */ + + // Essential: Convert a position in buffer-coordinates to screen-coordinates. + // + // The position is clipped via {::clipBufferPosition} prior to the conversion. + // The position is also clipped via {::clipScreenPosition} following the + // conversion, which only makes a difference when `options` are supplied. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + screenPositionForBufferPosition (bufferPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.translateBufferPosition(bufferPosition, options) + } + + // Essential: Convert a position in screen-coordinates to buffer-coordinates. + // + // The position is clipped via {::clipScreenPosition} prior to the conversion. + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // * `options` (optional) An options hash for {::clipScreenPosition}. + // + // Returns a {Point}. + bufferPositionForScreenPosition (screenPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.translateScreenPosition(screenPosition, options) + } + + // Essential: Convert a range in buffer-coordinates to screen-coordinates. + // + // * `bufferRange` {Range} in buffer coordinates to translate into screen coordinates. + // + // Returns a {Range}. + screenRangeForBufferRange (bufferRange, options) { + bufferRange = Range.fromObject(bufferRange) + const start = this.screenPositionForBufferPosition(bufferRange.start, options) + const end = this.screenPositionForBufferPosition(bufferRange.end, options) + return new Range(start, end) + } + + // Essential: Convert a range in screen-coordinates to buffer-coordinates. + // + // * `screenRange` {Range} in screen coordinates to translate into buffer coordinates. + // + // Returns a {Range}. + bufferRangeForScreenRange (screenRange) { + screenRange = Range.fromObject(screenRange) + const start = this.bufferPositionForScreenPosition(screenRange.start) + const end = this.bufferPositionForScreenPosition(screenRange.end) + return new Range(start, end) + } + + // Extended: Clip the given {Point} to a valid position in the buffer. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the buffer, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```coffee + // editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` + // + // # When the line at buffer row 2 is 10 characters long + // editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` + // ``` + // + // * `bufferPosition` The {Point} representing the position to clip. + // + // Returns a {Point}. + clipBufferPosition (bufferPosition) { return this.buffer.clipPosition(bufferPosition) } + + // Extended: Clip the start and end of the given range to valid positions in the + // buffer. See {::clipBufferPosition} for more information. + // + // * `range` The {Range} to clip. + // + // Returns a {Range}. + clipBufferRange (range) { return this.buffer.clipRange(range) } + + // Extended: Clip the given {Point} to a valid position on screen. + // + // If the given {Point} describes a position that is actually reachable by the + // cursor based on the current contents of the screen, it is returned + // unchanged. If the {Point} does not describe a valid position, the closest + // valid position is returned instead. + // + // ## Examples + // + // ```coffee + // editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` + // + // # When the line at screen row 2 is 10 characters long + // editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` + // ``` + // + // * `screenPosition` The {Point} representing the position to clip. + // * `options` (optional) {Object} + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {Point}. + clipScreenPosition (screenPosition, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.displayLayer.clipScreenPosition(screenPosition, options) + } + + // Extended: Clip the start and end of the given range to valid positions on screen. + // See {::clipScreenPosition} for more information. + // + // * `range` The {Range} to clip. + // * `options` (optional) See {::clipScreenPosition} `options`. + // + // Returns a {Range}. + clipScreenRange (screenRange, options) { + screenRange = Range.fromObject(screenRange) + const start = this.displayLayer.clipScreenPosition(screenRange.start, options) + const end = this.displayLayer.clipScreenPosition(screenRange.end, options) + return Range(start, end) + } + + /* + Section: Decorations + */ + + // Essential: Add a decoration that tracks a {DisplayMarker}. When the + // marker moves, is invalidated, or is destroyed, the decoration will be + // updated to reflect the marker's state. + // + // The following are the supported decorations types: + // + // * __line__: Adds your CSS `class` to the line nodes within the range + // marked by the marker + // * __line-number__: Adds your CSS `class` to the line number nodes within the + // range marked by the marker + // * __highlight__: Adds a new highlight div to the editor surrounding the + // range marked by the marker. When the user selects text, the selection is + // visualized with a highlight decoration internally. The structure of this + // highlight will be + // ```html + //
+ // + //
+ //
+ // ``` + // * __overlay__: Positions the view associated with the given item at the head + // or tail of the given `DisplayMarker`. + // * __gutter__: A decoration that tracks a {DisplayMarker} in a {Gutter}. Gutter + // decorations are created by calling {Gutter::decorateMarker} on the + // desired `Gutter` instance. + // * __block__: Positions the view associated with the given item before or + // after the row of the given `TextEditorMarker`. + // + // ## Arguments + // + // * `marker` A {DisplayMarker} you want this decoration to follow. + // * `decorationParams` An {Object} representing the decoration e.g. + // `{type: 'line-number', class: 'linter-error'}` + // * `type` There are several supported decoration types. The behavior of the + // types are as follows: + // * `line` Adds the given `class` to the lines overlapping the rows + // spanned by the `DisplayMarker`. + // * `line-number` Adds the given `class` to the line numbers overlapping + // the rows spanned by the `DisplayMarker`. + // * `text` Injects spans into all text overlapping the marked range, + // then adds the given `class` or `style` properties to these spans. + // Use this to manipulate the foreground color or styling of text in + // a given range. + // * `highlight` Creates an absolutely-positioned `.highlight` div + // containing nested divs to cover the marked region. For example, this + // is used to implement selections. + // * `overlay` Positions the view associated with the given item at the + // head or tail of the given `DisplayMarker`, depending on the `position` + // property. + // * `gutter` Tracks a {DisplayMarker} in a {Gutter}. Created by calling + // {Gutter::decorateMarker} on the desired `Gutter` instance. + // * `block` Positions the view associated with the given item before or + // after the row of the given `TextEditorMarker`, depending on the `position` + // property. + // * `cursor` Renders a cursor at the head of the given marker. If multiple + // decorations are created for the same marker, their class strings and + // style objects are combined into a single cursor. You can use this + // decoration type to style existing cursors by passing in their markers + // or render artificial cursors that don't actually exist in the model + // by passing a marker that isn't actually associated with a cursor. + // * `class` This CSS class will be applied to the decorated line number, + // line, text spans, highlight regions, cursors, or overlay. + // * `style` An {Object} containing CSS style properties to apply to the + // relevant DOM node. Currently this only works with a `type` of `cursor` + // or `text`. + // * `item` (optional) An {HTMLElement} or a model {Object} with a + // corresponding view registered. Only applicable to the `gutter`, + // `overlay` and `block` decoration types. + // * `onlyHead` (optional) If `true`, the decoration will only be applied to + // the head of the `DisplayMarker`. Only applicable to the `line` and + // `line-number` decoration types. + // * `onlyEmpty` (optional) If `true`, the decoration will only be applied if + // the associated `DisplayMarker` is empty. Only applicable to the `gutter`, + // `line`, and `line-number` decoration types. + // * `onlyNonEmpty` (optional) If `true`, the decoration will only be applied + // if the associated `DisplayMarker` is non-empty. Only applicable to the + // `gutter`, `line`, and `line-number` decoration types. + // * `omitEmptyLastRow` (optional) If `false`, the decoration will be applied + // to the last row of a non-empty range, even if it ends at column 0. + // Defaults to `true`. Only applicable to the `gutter`, `line`, and + // `line-number` decoration types. + // * `position` (optional) Only applicable to decorations of type `overlay` and `block`. + // Controls where the view is positioned relative to the `TextEditorMarker`. + // Values can be `'head'` (the default) or `'tail'` for overlay decorations, and + // `'before'` (the default) or `'after'` for block decorations. + // * `avoidOverflow` (optional) Only applicable to decorations of type + // `overlay`. Determines whether the decoration adjusts its horizontal or + // vertical position to remain fully visible when it would otherwise + // overflow the editor. Defaults to `true`. + // + // Returns a {Decoration} object + decorateMarker (marker, decorationParams) { + return this.decorationManager.decorateMarker(marker, decorationParams) + } + + // Essential: Add a decoration to every marker in the given marker layer. Can + // be used to decorate a large number of markers without having to create and + // manage many individual decorations. + // + // * `markerLayer` A {DisplayMarkerLayer} or {MarkerLayer} to decorate. + // * `decorationParams` The same parameters that are passed to + // {TextEditor::decorateMarker}, except the `type` cannot be `overlay` or `gutter`. + // + // Returns a {LayerDecoration}. + decorateMarkerLayer (markerLayer, decorationParams) { + return this.decorationManager.decorateMarkerLayer(markerLayer, decorationParams) + } + + // Deprecated: Get all the decorations within a screen row range on the default + // layer. + // + // * `startScreenRow` the {Number} beginning screen row + // * `endScreenRow` the {Number} end screen row (inclusive) + // + // Returns an {Object} of decorations in the form + // `{1: [{id: 10, type: 'line-number', class: 'someclass'}], 2: ...}` + // where the keys are {DisplayMarker} IDs, and the values are an array of decoration + // params objects attached to the marker. + // Returns an empty object when no decorations are found + decorationsForScreenRowRange (startScreenRow, endScreenRow) { + return this.decorationManager.decorationsForScreenRowRange(startScreenRow, endScreenRow) + } + + decorationsStateForScreenRowRange (startScreenRow, endScreenRow) { + return this.decorationManager.decorationsStateForScreenRowRange(startScreenRow, endScreenRow) + } + + // Extended: Get all decorations. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getDecorations (propertyFilter) { + return this.decorationManager.getDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'line'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineDecorations (propertyFilter) { + return this.decorationManager.getLineDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'line-number'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getLineNumberDecorations (propertyFilter) { + return this.decorationManager.getLineNumberDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'highlight'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getHighlightDecorations (propertyFilter) { + return this.decorationManager.getHighlightDecorations(propertyFilter) + } + + // Extended: Get all decorations of type 'overlay'. + // + // * `propertyFilter` (optional) An {Object} containing key value pairs that + // the returned decorations' properties must match. + // + // Returns an {Array} of {Decoration}s. + getOverlayDecorations (propertyFilter) { + return this.decorationManager.getOverlayDecorations(propertyFilter) + } + + /* + Section: Markers + */ + + // Essential: Create a marker on the default marker layer with the given range + // in buffer coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferRange (bufferRange, options) { + return this.defaultMarkerLayer.markBufferRange(bufferRange, options) + } + + // Essential: Create a marker on the default marker layer with the given range + // in screen coordinates. This marker will maintain its logical location as the + // buffer is changed, so if you mark a particular word, the marker will remain + // over that word even if the word's location in the buffer changes. + // + // * `range` A {Range} or range-compatible {Array} + // * `properties` A hash of key-value pairs to associate with the marker. There + // are also reserved property names that have marker-specific meaning. + // * `maintainHistory` (optional) {Boolean} Whether to store this marker's + // range before and after each change in the undo history. This allows the + // marker's position to be restored more accurately for certain undo/redo + // operations, but uses more time and memory. (default: false) + // * `reversed` (optional) {Boolean} Creates the marker in a reversed + // orientation. (default: false) + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markScreenRange (screenRange, options) { + return this.defaultMarkerLayer.markScreenRange(screenRange, options) + } + + // Essential: Create a marker on the default marker layer with the given buffer + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `bufferPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // + // Returns a {DisplayMarker}. + markBufferPosition (bufferPosition, options) { + return this.defaultMarkerLayer.markBufferPosition(bufferPosition, options) + } + + // Essential: Create a marker on the default marker layer with the given screen + // position and no tail. To group multiple markers together in their own + // private layer, see {::addMarkerLayer}. + // + // * `screenPosition` A {Point} or point-compatible {Array} + // * `options` (optional) An {Object} with the following keys: + // * `invalidate` (optional) {String} Determines the rules by which changes + // to the buffer *invalidate* the marker. (default: 'overlap') It can be + // any of the following strategies, in order of fragility: + // * __never__: The marker is never marked as invalid. This is a good choice for + // markers representing selections in an editor. + // * __surround__: The marker is invalidated by changes that completely surround it. + // * __overlap__: The marker is invalidated by changes that surround the + // start or end of the marker. This is the default. + // * __inside__: The marker is invalidated by changes that extend into the + // inside of the marker. Changes that end at the marker's start or + // start at the marker's end do not invalidate the marker. + // * __touch__: The marker is invalidated by a change that touches the marked + // region in any way, including changes that end at the marker's + // start or start at the marker's end. This is the most fragile strategy. + // * `clipDirection` {String} If `'backward'`, returns the first valid + // position preceding an invalid position. If `'forward'`, returns the + // first valid position following an invalid position. If `'closest'`, + // returns the first valid position closest to an invalid position. + // Defaults to `'closest'`. + // + // Returns a {DisplayMarker}. + markScreenPosition (screenPosition, options) { + return this.defaultMarkerLayer.markScreenPosition(screenPosition, options) + } + + // Essential: Find all {DisplayMarker}s on the default marker layer that + // match the given properties. + // + // This method finds markers based on the given properties. Markers can be + // associated with custom properties that will be compared with basic equality. + // In addition, there are several special properties that will be compared + // with the range of the markers rather than their properties. + // + // * `properties` An {Object} containing properties that each returned marker + // must satisfy. Markers can be associated with custom properties, which are + // compared with basic equality. In addition, several reserved properties + // can be used to filter markers based on their current range: + // * `startBufferRow` Only include markers starting at this row in buffer + // coordinates. + // * `endBufferRow` Only include markers ending at this row in buffer + // coordinates. + // * `containsBufferRange` Only include markers containing this {Range} or + // in range-compatible {Array} in buffer coordinates. + // * `containsBufferPosition` Only include markers containing this {Point} + // or {Array} of `[row, column]` in buffer coordinates. + // + // Returns an {Array} of {DisplayMarker}s + findMarkers (params) { + return this.defaultMarkerLayer.findMarkers(params) + } + + // Extended: Get the {DisplayMarker} on the default layer for the given + // marker id. + // + // * `id` {Number} id of the marker + getMarker (id) { + return this.defaultMarkerLayer.getMarker(id) + } + + // Extended: Get all {DisplayMarker}s on the default marker layer. Consider + // using {::findMarkers} + getMarkers () { + return this.defaultMarkerLayer.getMarkers() + } + + // Extended: Get the number of markers in the default marker layer. + // + // Returns a {Number}. + getMarkerCount () { + return this.defaultMarkerLayer.getMarkerCount() + } + + destroyMarker (id) { + const marker = this.getMarker(id) + if (marker) marker.destroy() + } + + // Essential: Create a marker layer to group related markers. + // + // * `options` An {Object} containing the following keys: + // * `maintainHistory` A {Boolean} indicating whether marker state should be + // restored on undo/redo. Defaults to `false`. + // * `persistent` A {Boolean} indicating whether or not this marker layer + // should be serialized and deserialized along with the rest of the + // buffer. Defaults to `false`. If `true`, the marker layer's id will be + // maintained across the serialization boundary, allowing you to retrieve + // it via {::getMarkerLayer}. + // + // Returns a {DisplayMarkerLayer}. + addMarkerLayer (options) { + return this.displayLayer.addMarkerLayer(options) + } + + // Essential: Get a {DisplayMarkerLayer} by id. + // + // * `id` The id of the marker layer to retrieve. + // + // Returns a {DisplayMarkerLayer} or `undefined` if no layer exists with the + // given id. + getMarkerLayer (id) { + return this.displayLayer.getMarkerLayer(id) + } + + // Essential: Get the default {DisplayMarkerLayer}. + // + // All marker APIs not tied to an explicit layer interact with this default + // layer. + // + // Returns a {DisplayMarkerLayer}. + getDefaultMarkerLayer () { + return this.defaultMarkerLayer + } + + /* + Section: Cursors + */ + + // Essential: Get the position of the most recently added cursor in buffer + // coordinates. + // + // Returns a {Point} + getCursorBufferPosition () { + return this.getLastCursor().getBufferPosition() + } + + // Essential: Get the position of all the cursor positions in buffer coordinates. + // + // Returns {Array} of {Point}s in the order they were added + getCursorBufferPositions () { + return this.getCursors().map((cursor) => cursor.getBufferPosition()) + } + + // Essential: Move the cursor to the given position in buffer coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} containing the following keys: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorBufferPosition (position, options) { + return this.moveCursors(cursor => cursor.setBufferPosition(position, options)) + } + + // Essential: Get a {Cursor} at given screen coordinates {Point} + // + // * `position` A {Point} or {Array} of `[row, column]` + // + // Returns the first matched {Cursor} or undefined + getCursorAtScreenPosition (position) { + const selection = this.getSelectionAtScreenPosition(position) + if (selection && selection.getHeadScreenPosition().isEqual(position)) { + return selection.cursor + } + } + + // Essential: Get the position of the most recently added cursor in screen + // coordinates. + // + // Returns a {Point}. + getCursorScreenPosition () { + return this.getLastCursor().getScreenPosition() + } + + // Essential: Get the position of all the cursor positions in screen coordinates. + // + // Returns {Array} of {Point}s in the order the cursors were added + getCursorScreenPositions () { + return this.getCursors().map((cursor) => cursor.getScreenPosition()) + } + + // Essential: Move the cursor to the given position in screen coordinates. + // + // If there are multiple cursors, they will be consolidated to a single cursor. + // + // * `position` A {Point} or {Array} of `[row, column]` + // * `options` (optional) An {Object} combining options for {::clipScreenPosition} with: + // * `autoscroll` Determines whether the editor scrolls to the new cursor's + // position. Defaults to true. + setCursorScreenPosition (position, options) { + if (options && options.clip) { + Grim.deprecate('The `clip` parameter has been deprecated and will be removed soon. Please, use `clipDirection` instead.') + if (options.clipDirection) options.clipDirection = options.clip + } + if (options && options.wrapAtSoftNewlines != null) { + Grim.deprecate("The `wrapAtSoftNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapAtSoftNewlines ? 'forward' : 'backward' + } + if (options && options.wrapBeyondNewlines != null) { + Grim.deprecate("The `wrapBeyondNewlines` parameter has been deprecated and will be removed soon. Please, use `clipDirection: 'forward'` instead.") + if (options.clipDirection) options.clipDirection = options.wrapBeyondNewlines ? 'forward' : 'backward' + } + + return this.moveCursors(cursor => cursor.setScreenPosition(position, options)) + } + + // Essential: Add a cursor at the given position in buffer coordinates. + // + // * `bufferPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtBufferPosition (bufferPosition, options) { + this.selectionsMarkerLayer.markBufferPosition(bufferPosition, {invalidate: 'never'}) + if (!options || options.autoscroll !== false) this.getLastSelection().cursor.autoscroll() + return this.getLastSelection().cursor + } + + // Essential: Add a cursor at the position in screen coordinates. + // + // * `screenPosition` A {Point} or {Array} of `[row, column]` + // + // Returns a {Cursor}. + addCursorAtScreenPosition (screenPosition, options) { + this.selectionsMarkerLayer.markScreenPosition(screenPosition, {invalidate: 'never'}) + if (!options || options.autoscroll !== false) this.getLastSelection().cursor.autoscroll() + return this.getLastSelection().cursor + } + + // Essential: Returns {Boolean} indicating whether or not there are multiple cursors. + hasMultipleCursors () { + return this.getCursors().length > 1 + } + + // Essential: Move every cursor up one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveUp (lineCount) { + return this.moveCursors(cursor => cursor.moveUp(lineCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor down one row in screen coordinates. + // + // * `lineCount` (optional) {Number} number of lines to move + moveDown (lineCount) { + return this.moveCursors(cursor => cursor.moveDown(lineCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor left one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveLeft (columnCount) { + return this.moveCursors(cursor => cursor.moveLeft(columnCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor right one column. + // + // * `columnCount` (optional) {Number} number of columns to move (default: 1) + moveRight (columnCount) { + return this.moveCursors(cursor => cursor.moveRight(columnCount, {moveToEndOfSelection: true})) + } + + // Essential: Move every cursor to the beginning of its line in buffer coordinates. + moveToBeginningOfLine () { + return this.moveCursors(cursor => cursor.moveToBeginningOfLine()) + } + + // Essential: Move every cursor to the beginning of its line in screen coordinates. + moveToBeginningOfScreenLine () { + return this.moveCursors(cursor => cursor.moveToBeginningOfScreenLine()) + } + + // Essential: Move every cursor to the first non-whitespace character of its line. + moveToFirstCharacterOfLine () { + return this.moveCursors(cursor => cursor.moveToFirstCharacterOfLine()) + } + + // Essential: Move every cursor to the end of its line in buffer coordinates. + moveToEndOfLine () { + return this.moveCursors(cursor => cursor.moveToEndOfLine()) + } + + // Essential: Move every cursor to the end of its line in screen coordinates. + moveToEndOfScreenLine () { + return this.moveCursors(cursor => cursor.moveToEndOfScreenLine()) + } + + // Essential: Move every cursor to the beginning of its surrounding word. + moveToBeginningOfWord () { + return this.moveCursors(cursor => cursor.moveToBeginningOfWord()) + } + + // Essential: Move every cursor to the end of its surrounding word. + moveToEndOfWord () { + return this.moveCursors(cursor => cursor.moveToEndOfWord()) + } + + // Cursor Extended + + // Extended: Move every cursor to the top of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToTop () { + return this.moveCursors(cursor => cursor.moveToTop()) + } + + // Extended: Move every cursor to the bottom of the buffer. + // + // If there are multiple cursors, they will be merged into a single cursor. + moveToBottom () { + return this.moveCursors(cursor => cursor.moveToBottom()) + } + + // Extended: Move every cursor to the beginning of the next word. + moveToBeginningOfNextWord () { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextWord()) + } + + // Extended: Move every cursor to the previous word boundary. + moveToPreviousWordBoundary () { + return this.moveCursors(cursor => cursor.moveToPreviousWordBoundary()) + } + + // Extended: Move every cursor to the next word boundary. + moveToNextWordBoundary () { + return this.moveCursors(cursor => cursor.moveToNextWordBoundary()) + } + + // Extended: Move every cursor to the previous subword boundary. + moveToPreviousSubwordBoundary () { + return this.moveCursors(cursor => cursor.moveToPreviousSubwordBoundary()) + } + + // Extended: Move every cursor to the next subword boundary. + moveToNextSubwordBoundary () { + return this.moveCursors(cursor => cursor.moveToNextSubwordBoundary()) + } + + // Extended: Move every cursor to the beginning of the next paragraph. + moveToBeginningOfNextParagraph () { + return this.moveCursors(cursor => cursor.moveToBeginningOfNextParagraph()) + } + + // Extended: Move every cursor to the beginning of the previous paragraph. + moveToBeginningOfPreviousParagraph () { + return this.moveCursors(cursor => cursor.moveToBeginningOfPreviousParagraph()) + } + + // Extended: Returns the most recently added {Cursor} + getLastCursor () { + this.createLastSelectionIfNeeded() + return _.last(this.cursors) + } + + // Extended: Returns the word surrounding the most recently added cursor. + // + // * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. + getWordUnderCursor (options) { + return this.getTextInBufferRange(this.getLastCursor().getCurrentWordBufferRange(options)) + } + + // Extended: Get an Array of all {Cursor}s. + getCursors () { + this.createLastSelectionIfNeeded() + return this.cursors.slice() + } + + // Extended: Get all {Cursors}s, ordered by their position in the buffer + // instead of the order in which they were added. + // + // Returns an {Array} of {Selection}s. + getCursorsOrderedByBufferPosition () { + return this.getCursors().sort((a, b) => a.compare(b)) + } + + cursorsForScreenRowRange (startScreenRow, endScreenRow) { + const cursors = [] + for (let marker of this.selectionsMarkerLayer.findMarkers({intersectsScreenRowRange: [startScreenRow, endScreenRow]})) { + const cursor = this.cursorsByMarkerId.get(marker.id) + if (cursor) cursors.push(cursor) + } + return cursors + } + + // Add a cursor based on the given {DisplayMarker}. + addCursor (marker) { + const cursor = new Cursor({editor: this, marker, showCursorOnSelection: this.showCursorOnSelection}) + this.cursors.push(cursor) + this.cursorsByMarkerId.set(marker.id, cursor) + return cursor + } + + moveCursors (fn) { + return this.transact(() => { + this.getCursors().forEach(fn) + return this.mergeCursors() + }) + } + + cursorMoved (event) { + return this.emitter.emit('did-change-cursor-position', event) + } + + // Merge cursors that have the same screen position + mergeCursors () { + const positions = {} + for (let cursor of this.getCursors()) { + const position = cursor.getBufferPosition().toString() + if (positions.hasOwnProperty(position)) { + cursor.destroy() + } else { + positions[position] = true + } + } + } + + /* + Section: Selections + */ + + // Essential: Get the selected text of the most recently added selection. + // + // Returns a {String}. + getSelectedText () { + return this.getLastSelection().getText() + } + + // Essential: Get the {Range} of the most recently added selection in buffer + // coordinates. + // + // Returns a {Range}. + getSelectedBufferRange () { + return this.getLastSelection().getBufferRange() + } + + // Essential: Get the {Range}s of all selections in buffer coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedBufferRanges () { + return this.getSelections().map((selection) => selection.getBufferRange()) + } + + // Essential: Set the selected range in buffer coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRange (bufferRange, options) { + return this.setSelectedBufferRanges([bufferRange], options) + } + + // Essential: Set the selected ranges in buffer coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `bufferRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + setSelectedBufferRanges (bufferRanges, options = {}) { + if (!bufferRanges.length) throw new Error('Passed an empty array to setSelectedBufferRanges') + + const selections = this.getSelections() + for (let selection of selections.slice(bufferRanges.length)) { + selection.destroy() + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < bufferRanges.length; i++) { + let bufferRange = bufferRanges[i] + bufferRange = Range.fromObject(bufferRange) + if (selections[i]) { + selections[i].setBufferRange(bufferRange, options) + } else { + this.addSelectionForBufferRange(bufferRange, options) + } + } + }) + } + + // Essential: Get the {Range} of the most recently added selection in screen + // coordinates. + // + // Returns a {Range}. + getSelectedScreenRange () { + return this.getLastSelection().getScreenRange() + } + + // Essential: Get the {Range}s of all selections in screen coordinates. + // + // The ranges are sorted by when the selections were added. Most recent at the end. + // + // Returns an {Array} of {Range}s. + getSelectedScreenRanges () { + return this.getSelections().map((selection) => selection.getScreenRange()) + } + + // Essential: Set the selected range in screen coordinates. If there are multiple + // selections, they are reduced to a single selection with the given range. + // + // * `screenRange` A {Range} or range-compatible {Array}. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRange (screenRange, options) { + return this.setSelectedBufferRange(this.bufferRangeForScreenRange(screenRange, options), options) + } + + // Essential: Set the selected ranges in screen coordinates. If there are multiple + // selections, they are replaced by new selections with the given ranges. + // + // * `screenRanges` An {Array} of {Range}s or range-compatible {Array}s. + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + setSelectedScreenRanges (screenRanges, options = {}) { + if (!screenRanges.length) throw new Error('Passed an empty array to setSelectedScreenRanges') + + const selections = this.getSelections() + for (let selection of selections.slice(screenRanges.length)) { + selection.destroy() + } + + this.mergeIntersectingSelections(options, () => { + for (let i = 0; i < screenRanges.length; i++) { + let screenRange = screenRanges[i] + screenRange = Range.fromObject(screenRange) + if (selections[i]) { + selections[i].setScreenRange(screenRange, options) + } else { + this.addSelectionForScreenRange(screenRange, options) + } + } + }) + } + + // Essential: Add a selection for the given range in buffer coordinates. + // + // * `bufferRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // + // Returns the added {Selection}. + addSelectionForBufferRange (bufferRange, options = {}) { + bufferRange = Range.fromObject(bufferRange) + if (!options.preserveFolds) { + this.displayLayer.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true) + } + this.selectionsMarkerLayer.markBufferRange(bufferRange, {invalidate: 'never', reversed: options.reversed != null ? options.reversed : false}) + if (options.autoscroll !== false) this.getLastSelection().autoscroll() + return this.getLastSelection() + } + + // Essential: Add a selection for the given range in screen coordinates. + // + // * `screenRange` A {Range} + // * `options` (optional) An options {Object}: + // * `reversed` A {Boolean} indicating whether to create the selection in a + // reversed orientation. + // * `preserveFolds` A {Boolean}, which if `true` preserves the fold settings after the + // selection is set. + // Returns the added {Selection}. + addSelectionForScreenRange (screenRange, options = {}) { + return this.addSelectionForBufferRange(this.bufferRangeForScreenRange(screenRange), options) + } + + // Essential: Select from the current cursor position to the given position in + // buffer coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToBufferPosition (position) { + const lastSelection = this.getLastSelection() + lastSelection.selectToBufferPosition(position) + return this.mergeIntersectingSelections({reversed: lastSelection.isReversed()}) + } + + // Essential: Select from the current cursor position to the given position in + // screen coordinates. + // + // This method may merge selections that end up intersecting. + // + // * `position` An instance of {Point}, with a given `row` and `column`. + selectToScreenPosition (position, options) { + const lastSelection = this.getLastSelection() + lastSelection.selectToScreenPosition(position, options) + if (!options || !options.suppressSelectionMerge) { + return this.mergeIntersectingSelections({reversed: lastSelection.isReversed()}) + } + } + + // Essential: Move the cursor of each selection one character upward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectUp (rowCount) { + return this.expandSelectionsBackward(selection => selection.selectUp(rowCount)) + } + + // Essential: Move the cursor of each selection one character downward while + // preserving the selection's tail position. + // + // * `rowCount` (optional) {Number} number of rows to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectDown (rowCount) { + return this.expandSelectionsForward(selection => selection.selectDown(rowCount)) + } + + // Essential: Move the cursor of each selection one character leftward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectLeft (columnCount) { + return this.expandSelectionsBackward(selection => selection.selectLeft(columnCount)) + } + + // Essential: Move the cursor of each selection one character rightward while + // preserving the selection's tail position. + // + // * `columnCount` (optional) {Number} number of columns to select (default: 1) + // + // This method may merge selections that end up intersecting. + selectRight (columnCount) { + return this.expandSelectionsForward(selection => selection.selectRight(columnCount)) + } + + // Essential: Select from the top of the buffer to the end of the last selection + // in the buffer. + // + // This method merges multiple selections into a single selection. + selectToTop () { + return this.expandSelectionsBackward(selection => selection.selectToTop()) + } + + // Essential: Selects from the top of the first selection in the buffer to the end + // of the buffer. + // + // This method merges multiple selections into a single selection. + selectToBottom () { + return this.expandSelectionsForward(selection => selection.selectToBottom()) + } + + // Essential: Select all text in the buffer. + // + // This method merges multiple selections into a single selection. + selectAll () { + return this.expandSelectionsForward(selection => selection.selectAll()) + } + + // Essential: Move the cursor of each selection to the beginning of its line + // while preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToBeginningOfLine () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfLine()) + } + + // Essential: Move the cursor of each selection to the first non-whitespace + // character of its line while preserving the selection's tail position. If the + // cursor is already on the first character of the line, move it to the + // beginning of the line. + // + // This method may merge selections that end up intersecting. + selectToFirstCharacterOfLine () { + return this.expandSelectionsBackward(selection => selection.selectToFirstCharacterOfLine()) + } + + // Essential: Move the cursor of each selection to the end of its line while + // preserving the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToEndOfLine () { + return this.expandSelectionsForward(selection => selection.selectToEndOfLine()) + } + + // Essential: Expand selections to the beginning of their containing word. + // + // Operates on all selections. Moves the cursor to the beginning of the + // containing word while preserving the selection's tail position. + selectToBeginningOfWord () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfWord()) + } + + // Essential: Expand selections to the end of their containing word. + // + // Operates on all selections. Moves the cursor to the end of the containing + // word while preserving the selection's tail position. + selectToEndOfWord () { + return this.expandSelectionsForward(selection => selection.selectToEndOfWord()) + } + + // Extended: For each selection, move its cursor to the preceding subword + // boundary while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousSubwordBoundary () { + return this.expandSelectionsBackward(selection => selection.selectToPreviousSubwordBoundary()) + } + + // Extended: For each selection, move its cursor to the next subword boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextSubwordBoundary () { + return this.expandSelectionsForward(selection => selection.selectToNextSubwordBoundary()) + } + + // Essential: For each cursor, select the containing line. + // + // This method merges selections on successive lines. + selectLinesContainingCursors () { + return this.expandSelectionsForward(selection => selection.selectLine()) + } + + // Essential: Select the word surrounding each cursor. + selectWordsContainingCursors () { + return this.expandSelectionsForward(selection => selection.selectWord()) + } + + // Selection Extended + + // Extended: For each selection, move its cursor to the preceding word boundary + // while maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToPreviousWordBoundary () { + return this.expandSelectionsBackward(selection => selection.selectToPreviousWordBoundary()) + } + + // Extended: For each selection, move its cursor to the next word boundary while + // maintaining the selection's tail position. + // + // This method may merge selections that end up intersecting. + selectToNextWordBoundary () { + return this.expandSelectionsForward(selection => selection.selectToNextWordBoundary()) + } + + // Extended: Expand selections to the beginning of the next word. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // word while preserving the selection's tail position. + selectToBeginningOfNextWord () { + return this.expandSelectionsForward(selection => selection.selectToBeginningOfNextWord()) + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfNextParagraph () { + return this.expandSelectionsForward(selection => selection.selectToBeginningOfNextParagraph()) + } + + // Extended: Expand selections to the beginning of the next paragraph. + // + // Operates on all selections. Moves the cursor to the beginning of the next + // paragraph while preserving the selection's tail position. + selectToBeginningOfPreviousParagraph () { + return this.expandSelectionsBackward(selection => selection.selectToBeginningOfPreviousParagraph()) + } + + // Extended: Select the range of the given marker if it is valid. + // + // * `marker` A {DisplayMarker} + // + // Returns the selected {Range} or `undefined` if the marker is invalid. + selectMarker (marker) { + if (marker.isValid()) { + const range = marker.getBufferRange() + this.setSelectedBufferRange(range) + return range + } + } + + // Extended: Get the most recently added {Selection}. + // + // Returns a {Selection}. + getLastSelection () { + this.createLastSelectionIfNeeded() + return _.last(this.selections) + } + + getSelectionAtScreenPosition (position) { + const markers = this.selectionsMarkerLayer.findMarkers({containsScreenPosition: position}) + if (markers.length > 0) return this.cursorsByMarkerId.get(markers[0].id).selection + } + + // Extended: Get current {Selection}s. + // + // Returns: An {Array} of {Selection}s. + getSelections () { + this.createLastSelectionIfNeeded() + return this.selections.slice() + } + + // Extended: Get all {Selection}s, ordered by their position in the buffer + // instead of the order in which they were added. + // + // Returns an {Array} of {Selection}s. + getSelectionsOrderedByBufferPosition () { + return this.getSelections().sort((a, b) => a.compare(b)) + } + + // Extended: Determine if a given range in buffer coordinates intersects a + // selection. + // + // * `bufferRange` A {Range} or range-compatible {Array}. + // + // Returns a {Boolean}. + selectionIntersectsBufferRange (bufferRange) { + return this.getSelections().some(selection => selection.intersectsBufferRange(bufferRange)) + } + + // Selections Private + + // Add a similarly-shaped selection to the next eligible line below + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next following non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionBelow () { + return this.expandSelectionsForward(selection => selection.addSelectionBelow()) + } + + // Add a similarly-shaped selection to the next eligible line above + // each selection. + // + // Operates on all selections. If the selection is empty, adds an empty + // selection to the next preceding non-empty line as close to the current + // selection's column as possible. If the selection is non-empty, adds a + // selection to the next line that is long enough for a non-empty selection + // starting at the same column as the current selection to be added to it. + addSelectionAbove () { + return this.expandSelectionsBackward(selection => selection.addSelectionAbove()) + } + + // Calls the given function with each selection, then merges selections + expandSelectionsForward (fn) { + this.mergeIntersectingSelections(() => this.getSelections().forEach(fn)) + } + + // Calls the given function with each selection, then merges selections in the + // reversed orientation + expandSelectionsBackward (fn) { + this.mergeIntersectingSelections({reversed: true}, () => this.getSelections().forEach(fn)) + } + + finalizeSelections () { + for (let selection of this.getSelections()) { selection.finalize() } + } + + selectionsForScreenRows (startRow, endRow) { + return this.getSelections().filter(selection => selection.intersectsScreenRowRange(startRow, endRow)) + } + + // Merges intersecting selections. If passed a function, it executes + // the function with merging suppressed, then merges intersecting selections + // afterward. + mergeIntersectingSelections (...args) { + return this.mergeSelections(...args, (previousSelection, currentSelection) => { + const exclusive = !currentSelection.isEmpty() && !previousSelection.isEmpty() + return previousSelection.intersectsWith(currentSelection, exclusive) + }) + } + + mergeSelectionsOnSameRows (...args) { + return this.mergeSelections(...args, (previousSelection, currentSelection) => { + const screenRange = currentSelection.getScreenRange() + return previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) + }) + } + + avoidMergingSelections (...args) { + return this.mergeSelections(...args, () => false) + } + + mergeSelections (...args) { + const mergePredicate = args.pop() + let fn = args.pop() + let options = args.pop() + if (typeof fn !== 'function') { + options = fn + fn = () => {} + } + + if (this.suppressSelectionMerging) return fn() + + this.suppressSelectionMerging = true + const result = fn() + this.suppressSelectionMerging = false + + const selections = this.getSelectionsOrderedByBufferPosition() + let lastSelection = selections.shift() + for (const selection of selections) { + if (mergePredicate(lastSelection, selection)) { + lastSelection.merge(selection, options) + } else { + lastSelection = selection + } + } + + return result + } + + // Add a {Selection} based on the given {DisplayMarker}. + // + // * `marker` The {DisplayMarker} to highlight + // * `options` (optional) An {Object} that pertains to the {Selection} constructor. + // + // Returns the new {Selection}. + addSelection (marker, options = {}) { + const cursor = this.addCursor(marker) + let selection = new Selection(Object.assign({editor: this, marker, cursor}, options)) + this.selections.push(selection) + const selectionBufferRange = selection.getBufferRange() + this.mergeIntersectingSelections({preserveFolds: options.preserveFolds}) + + if (selection.destroyed) { + for (selection of this.getSelections()) { + if (selection.intersectsBufferRange(selectionBufferRange)) return selection + } + } else { + this.emitter.emit('did-add-cursor', cursor) + this.emitter.emit('did-add-selection', selection) + return selection + } + } + + // Remove the given selection. + removeSelection (selection) { + _.remove(this.cursors, selection.cursor) + _.remove(this.selections, selection) + this.cursorsByMarkerId.delete(selection.cursor.marker.id) + this.emitter.emit('did-remove-cursor', selection.cursor) + return this.emitter.emit('did-remove-selection', selection) + } + + // Reduce one or more selections to a single empty selection based on the most + // recently added cursor. + clearSelections (options) { + this.consolidateSelections() + this.getLastSelection().clear(options) + } + + // Reduce multiple selections to the least recently added selection. + consolidateSelections () { + const selections = this.getSelections() + if (selections.length > 1) { + for (let selection of selections.slice(1, (selections.length))) { selection.destroy() } + selections[0].autoscroll({center: true}) + return true + } else { + return false + } + } + + // Called by the selection + selectionRangeChanged (event) { + if (this.component) this.component.didChangeSelectionRange() + this.emitter.emit('did-change-selection-range', event) + } + + createLastSelectionIfNeeded () { + if (this.selections.length === 0) { + this.addSelectionForBufferRange([[0, 0], [0, 0]], {autoscroll: false, preserveFolds: true}) + } + } + + /* + Section: Searching and Replacing + */ + + // Essential: Scan regular expression matches in the entire buffer, calling the + // given iterator function on each match. + // + // `::scan` functions as the replace method as well via the `replace` + // + // If you're programmatically modifying the results, you may want to try + // {::backwardsScanInBufferRange} to avoid tripping over your own changes. + // + // * `regex` A {RegExp} to search for. + // * `options` (optional) {Object} + // * `leadingContextLineCount` {Number} default `0`; The number of lines + // before the matched line to include in the results object. + // * `trailingContextLineCount` {Number} default `0`; The number of lines + // after the matched line to include in the results object. + // * `iterator` A {Function} that's called on each match + // * `object` {Object} + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scan (regex, options = {}, iterator) { + if (_.isFunction(options)) { + iterator = options + options = {} + } + + return this.buffer.scan(regex, options, iterator) + } + + // Essential: Scan regular expression matches in a given range, calling the given + // iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + scanInBufferRange (regex, range, iterator) { return this.buffer.scanInRange(regex, range, iterator) } + + // Essential: Scan regular expression matches in a given range in reverse order, + // calling the given iterator function on each match. + // + // * `regex` A {RegExp} to search for. + // * `range` A {Range} in which to search. + // * `iterator` A {Function} that's called on each match with an {Object} + // containing the following keys: + // * `match` The current regular expression match. + // * `matchText` A {String} with the text of the match. + // * `range` The {Range} of the match. + // * `stop` Call this {Function} to terminate the scan. + // * `replace` Call this {Function} with a {String} to replace the match. + backwardsScanInBufferRange (regex, range, iterator) { return this.buffer.backwardsScanInRange(regex, range, iterator) } + + /* + Section: Tab Behavior + */ + + // Essential: Returns a {Boolean} indicating whether softTabs are enabled for this + // editor. + getSoftTabs () { return this.softTabs } + + // Essential: Enable or disable soft tabs for this editor. + // + // * `softTabs` A {Boolean} + setSoftTabs (softTabs) { + this.softTabs = softTabs + this.update({softTabs: this.softTabs}) + } + + // Returns a {Boolean} indicating whether atomic soft tabs are enabled for this editor. + hasAtomicSoftTabs () { return this.displayLayer.atomicSoftTabs } + + // Essential: Toggle soft tabs for this editor + toggleSoftTabs () { this.setSoftTabs(!this.getSoftTabs()) } + + // Essential: Get the on-screen length of tab characters. + // + // Returns a {Number}. + getTabLength () { return this.tokenizedBuffer.getTabLength() } + + // Essential: Set the on-screen length of tab characters. Setting this to a + // {Number} This will override the `editor.tabLength` setting. + // + // * `tabLength` {Number} length of a single tab. Setting to `null` will + // fallback to using the `editor.tabLength` config setting + setTabLength (tabLength) { this.update({tabLength}) } + + // Returns an {Object} representing the current invisible character + // substitutions for this editor. See {::setInvisibles}. + getInvisibles () { + if (!this.mini && this.showInvisibles && (this.invisibles != null)) { + return this.invisibles + } else { + return {} + } + } + + doesShowIndentGuide () { return this.showIndentGuide && !this.mini } + + getSoftWrapHangingIndentLength () { return this.displayLayer.softWrapHangingIndent } + + // Extended: Determine if the buffer uses hard or soft tabs. + // + // Returns `true` if the first non-comment line with leading whitespace starts + // with a space character. Returns `false` if it starts with a hard tab (`\t`). + // + // Returns a {Boolean} or undefined if no non-comment lines had leading + // whitespace. + usesSoftTabs () { + for (let bufferRow = 0, end = Math.min(1000, this.buffer.getLastRow()); bufferRow <= end; bufferRow++) { + const tokenizedLine = this.tokenizedBuffer.tokenizedLines[bufferRow] + if (tokenizedLine && tokenizedLine.isComment()) continue + const line = this.buffer.lineForRow(bufferRow) + if (line[0] === ' ') return true + if (line[0] === '\t') return false + } + } + + // Extended: Get the text representing a single level of indent. + // + // If soft tabs are enabled, the text is composed of N spaces, where N is the + // tab length. Otherwise the text is a tab character (`\t`). + // + // Returns a {String}. + getTabText () { return this.buildIndentString(1) } + + // If soft tabs are enabled, convert all hard tabs to soft tabs in the given + // {Range}. + normalizeTabsInBufferRange (bufferRange) { + if (!this.getSoftTabs()) { return } + return this.scanInBufferRange(/\t/g, bufferRange, ({replace}) => replace(this.getTabText())) + } + + /* + Section: Soft Wrap Behavior + */ + + // Essential: Determine whether lines in this editor are soft-wrapped. + // + // Returns a {Boolean}. + isSoftWrapped () { return this.softWrapped } + + // Essential: Enable or disable soft wrapping for this editor. + // + // * `softWrapped` A {Boolean} + // + // Returns a {Boolean}. + setSoftWrapped (softWrapped) { + this.update({softWrapped}) + return this.isSoftWrapped() + } + + getPreferredLineLength () { return this.preferredLineLength } + + // Essential: Toggle soft wrapping for this editor + // + // Returns a {Boolean}. + toggleSoftWrapped () { return this.setSoftWrapped(!this.isSoftWrapped()) } + + // Essential: Gets the column at which column will soft wrap + getSoftWrapColumn () { + if (this.isSoftWrapped() && !this.mini) { + if (this.softWrapAtPreferredLineLength) { + return Math.min(this.getEditorWidthInChars(), this.preferredLineLength) + } else { + return this.getEditorWidthInChars() + } + } else { + return this.maxScreenLineLength + } + } + + /* + Section: Indentation + */ + + // Essential: Get the indentation level of the given buffer row. + // + // Determines how deeply the given row is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // + // Returns a {Number}. + indentationForBufferRow (bufferRow) { + return this.indentLevelForLine(this.lineTextForBufferRow(bufferRow)) + } + + // Essential: Set the indentation level for the given buffer row. + // + // Inserts or removes hard tabs or spaces based on the soft tabs and tab length + // settings of this editor in order to bring it to the given indentation level. + // Note that if soft tabs are enabled and the tab length is 2, a row with 4 + // leading spaces would have an indentation level of 2. + // + // * `bufferRow` A {Number} indicating the buffer row. + // * `newLevel` A {Number} indicating the new indentation level. + // * `options` (optional) An {Object} with the following keys: + // * `preserveLeadingWhitespace` `true` to preserve any whitespace already at + // the beginning of the line (default: false). + setIndentationForBufferRow (bufferRow, newLevel, {preserveLeadingWhitespace} = {}) { + let endColumn + if (preserveLeadingWhitespace) { + endColumn = 0 + } else { + endColumn = this.lineTextForBufferRow(bufferRow).match(/^\s*/)[0].length + } + const newIndentString = this.buildIndentString(newLevel) + return this.buffer.setTextInRange([[bufferRow, 0], [bufferRow, endColumn]], newIndentString) + } + + // Extended: Indent rows intersecting selections by one level. + indentSelectedRows () { + return this.mutateSelectedText(selection => selection.indentSelectedRows()) + } + + // Extended: Outdent rows intersecting selections by one level. + outdentSelectedRows () { + return this.mutateSelectedText(selection => selection.outdentSelectedRows()) + } + + // Extended: Get the indentation level of the given line of text. + // + // Determines how deeply the given line is indented based on the soft tabs and + // tab length settings of this editor. Note that if soft tabs are enabled and + // the tab length is 2, a row with 4 leading spaces would have an indentation + // level of 2. + // + // * `line` A {String} representing a line of text. + // + // Returns a {Number}. + indentLevelForLine (line) { + return this.tokenizedBuffer.indentLevelForLine(line) + } + + // Extended: Indent rows intersecting selections based on the grammar's suggested + // indent level. + autoIndentSelectedRows () { + return this.mutateSelectedText(selection => selection.autoIndentSelectedRows()) + } + + // Indent all lines intersecting selections. See {Selection::indent} for more + // information. + indent (options = {}) { + if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndent() + this.mutateSelectedText(selection => selection.indent(options)) + } + + // Constructs the string used for indents. + buildIndentString (level, column = 0) { + if (this.getSoftTabs()) { + const tabStopViolation = column % this.getTabLength() + return _.multiplyString(' ', Math.floor(level * this.getTabLength()) - tabStopViolation) + } else { + const excessWhitespace = _.multiplyString(' ', Math.round((level - Math.floor(level)) * this.getTabLength())) + return _.multiplyString('\t', Math.floor(level)) + excessWhitespace + } + } + + /* + Section: Grammars + */ + + // Essential: Get the current {Grammar} of this editor. + getGrammar () { + return this.tokenizedBuffer.grammar + } + + // Essential: Set the current {Grammar} of this editor. + // + // Assigning a grammar will cause the editor to re-tokenize based on the new + // grammar. + // + // * `grammar` {Grammar} + setGrammar (grammar) { + return this.tokenizedBuffer.setGrammar(grammar) + } + + // Reload the grammar based on the file name. + reloadGrammar () { + return this.tokenizedBuffer.reloadGrammar() + } + + // Experimental: Get a notification when async tokenization is completed. + onDidTokenize (callback) { + return this.tokenizedBuffer.onDidTokenize(callback) + } + + /* + Section: Managing Syntax Scopes + */ + + // Essential: Returns a {ScopeDescriptor} that includes this editor's language. + // e.g. `['.source.ruby']`, or `['.source.coffee']`. You can use this with + // {Config::get} to get language specific config values. + getRootScopeDescriptor () { + return this.tokenizedBuffer.rootScopeDescriptor + } + + // Essential: Get the syntactic scopeDescriptor for the given position in buffer + // coordinates. Useful with {Config::get}. + // + // For example, if called with a position inside the parameter list of an + // anonymous CoffeeScript function, the method returns the following array: + // `["source.coffee", "meta.inline.function.coffee", "variable.parameter.function.coffee"]` + // + // * `bufferPosition` A {Point} or {Array} of [row, column]. + // + // Returns a {ScopeDescriptor}. + scopeDescriptorForBufferPosition (bufferPosition) { + return this.tokenizedBuffer.scopeDescriptorForPosition(bufferPosition) + } + + // Extended: Get the range in buffer coordinates of all tokens surrounding the + // cursor that match the given scope selector. + // + // For example, if you wanted to find the string surrounding the cursor, you + // could call `editor.bufferRangeForScopeAtCursor(".string.quoted")`. + // + // * `scopeSelector` {String} selector. e.g. `'.source.ruby'` + // + // Returns a {Range}. + bufferRangeForScopeAtCursor (scopeSelector) { + return this.bufferRangeForScopeAtPosition(scopeSelector, this.getCursorBufferPosition()) + } + + bufferRangeForScopeAtPosition (scopeSelector, position) { + return this.tokenizedBuffer.bufferRangeForScopeAtPosition(scopeSelector, position) + } + + // Extended: Determine if the given row is entirely a comment + isBufferRowCommented (bufferRow) { + const match = this.lineTextForBufferRow(bufferRow).match(/\S/) + if (match) { + if (!this.commentScopeSelector) this.commentScopeSelector = new TextMateScopeSelector('comment.*') + return this.commentScopeSelector.matches(this.scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) + } + } + + // Get the scope descriptor at the cursor. + getCursorScope () { + return this.getLastCursor().getScopeDescriptor() + } + + tokenForBufferPosition (bufferPosition) { + return this.tokenizedBuffer.tokenForPosition(bufferPosition) + } + + /* + Section: Clipboard Operations + */ + + // Essential: For each selection, copy the selected text. + copySelectedText () { + let maintainClipboard = false + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (selection.isEmpty()) { + const previousRange = selection.getBufferRange() + selection.selectLine() + selection.copy(maintainClipboard, true) + selection.setBufferRange(previousRange) + } else { + selection.copy(maintainClipboard, false) + } + maintainClipboard = true + } + } + + // Private: For each selection, only copy highlighted text. + copyOnlySelectedText () { + let maintainClipboard = false + for (let selection of this.getSelectionsOrderedByBufferPosition()) { + if (!selection.isEmpty()) { + selection.copy(maintainClipboard, false) + maintainClipboard = true + } + } + } + + // Essential: For each selection, cut the selected text. + cutSelectedText () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + if (selection.isEmpty()) { + selection.selectLine() + selection.cut(maintainClipboard, true) + } else { + selection.cut(maintainClipboard, false) + } + maintainClipboard = true + }) + } + + // Essential: For each selection, replace the selected text with the contents of + // the clipboard. + // + // If the clipboard contains the same number of selections as the current + // editor, each selection will be replaced with the content of the + // corresponding clipboard selection text. + // + // * `options` (optional) See {Selection::insertText}. + pasteText (options) { + options = Object.assign({}, options) + let {text: clipboardText, metadata} = this.constructor.clipboard.readWithMetadata() + if (!this.emitWillInsertTextEvent(clipboardText)) return false + + if (!metadata) metadata = {} + if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndentOnPaste() + + this.mutateSelectedText((selection, index) => { + let fullLine, indentBasis, text + if (metadata.selections && metadata.selections.length === this.getSelections().length) { + ({text, indentBasis, fullLine} = metadata.selections[index]) + } else { + ({indentBasis, fullLine} = metadata) + text = clipboardText + } + + if (indentBasis != null && (text.includes('\n') || !selection.cursor.hasPrecedingCharactersOnLine())) { + options.indentBasis = indentBasis + } else { + options.indentBasis = null + } + + let range + if (fullLine && selection.isEmpty()) { + const oldPosition = selection.getBufferRange().start + selection.setBufferRange([[oldPosition.row, 0], [oldPosition.row, 0]]) + range = selection.insertText(text, options) + const newPosition = oldPosition.translate([1, 0]) + selection.setBufferRange([newPosition, newPosition]) + } else { + range = selection.insertText(text, options) + } + + this.emitter.emit('did-insert-text', {text, range}) + }) + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing screen line following the cursor. Otherwise cut the selected + // text. + cutToEndOfLine () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + selection.cutToEndOfLine(maintainClipboard) + maintainClipboard = true + }) + } + + // Essential: For each selection, if the selection is empty, cut all characters + // of the containing buffer line following the cursor. Otherwise cut the + // selected text. + cutToEndOfBufferLine () { + let maintainClipboard = false + this.mutateSelectedText(selection => { + selection.cutToEndOfBufferLine(maintainClipboard) + maintainClipboard = true + }) + } + + /* + Section: Folds + */ + + // Essential: Fold the most recent cursor's row based on its indentation level. + // + // The fold will extend from the nearest preceding line with a lower + // indentation level up to the nearest following row with a lower indentation + // level. + foldCurrentRow () { + const {row} = this.getCursorBufferPosition() + const range = this.tokenizedBuffer.getFoldableRangeContainingPoint(Point(row, Infinity)) + if (range) { + const result = this.displayLayer.foldBufferRange(range) + this.scrollToCursorPosition() + return result + } + } + + // Essential: Unfold the most recent cursor's row by one level. + unfoldCurrentRow () { + const {row} = this.getCursorBufferPosition() + const result = this.displayLayer.destroyFoldsContainingBufferPositions([Point(row, Infinity)], false) + this.scrollToCursorPosition() + return result + } + + // Essential: Fold the given row in buffer coordinates based on its indentation + // level. + // + // If the given row is foldable, the fold will begin there. Otherwise, it will + // begin at the first foldable row preceding the given row. + // + // * `bufferRow` A {Number}. + foldBufferRow (bufferRow) { + let position = Point(bufferRow, Infinity) + while (true) { + const foldableRange = this.tokenizedBuffer.getFoldableRangeContainingPoint(position, this.getTabLength()) + if (foldableRange) { + const existingFolds = this.displayLayer.foldsIntersectingBufferRange(Range(foldableRange.start, foldableRange.start)) + if (existingFolds.length === 0) { + this.displayLayer.foldBufferRange(foldableRange) + this.scrollToCursorPosition() + } else { + const firstExistingFoldRange = this.displayLayer.bufferRangeForFold(existingFolds[0]) + if (firstExistingFoldRange.start.isLessThan(position)) { + position = Point(firstExistingFoldRange.start.row, 0) + continue + } + } + } + break + } + } + + // Essential: Unfold all folds containing the given row in buffer coordinates. + // + // * `bufferRow` A {Number} + unfoldBufferRow (bufferRow) { + const position = Point(bufferRow, Infinity) + const result = this.displayLayer.destroyFoldsContainingBufferPositions([position]) + this.scrollToCursorPosition() + return result + } + + // Extended: For each selection, fold the rows it intersects. + foldSelectedLines () { + for (let selection of this.selections) { + selection.fold() + } + } + + // Extended: Fold all foldable lines. + foldAll () { + this.displayLayer.destroyAllFolds() + for (let range of this.tokenizedBuffer.getFoldableRanges(this.getTabLength())) { + this.displayLayer.foldBufferRange(range) + } + this.scrollToCursorPosition() + } + + // Extended: Unfold all existing folds. + unfoldAll () { + const result = this.displayLayer.destroyAllFolds() + this.scrollToCursorPosition() + return result + } + + // Extended: Fold all foldable lines at the given indent level. + // + // * `level` A {Number}. + foldAllAtIndentLevel (level) { + this.displayLayer.destroyAllFolds() + for (let range of this.tokenizedBuffer.getFoldableRangesAtIndentLevel(level, this.getTabLength())) { + this.displayLayer.foldBufferRange(range) + } + this.scrollToCursorPosition() + } + + // Extended: Determine whether the given row in buffer coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtBufferRow (bufferRow) { + return this.tokenizedBuffer.isFoldableAtRow(bufferRow) + } + + // Extended: Determine whether the given row in screen coordinates is foldable. + // + // A *foldable* row is a row that *starts* a row range that can be folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldableAtScreenRow (screenRow) { + return this.isFoldableAtBufferRow(this.bufferRowForScreenRow(screenRow)) + } + + // Extended: Fold the given buffer row if it isn't currently folded, and unfold + // it otherwise. + toggleFoldAtBufferRow (bufferRow) { + let result + if (this.isFoldedAtBufferRow(bufferRow)) { + result = this.unfoldBufferRow(bufferRow) + } else { + result = this.foldBufferRow(bufferRow) + } + this.scrollToCursorPosition() + return result + } + + // Extended: Determine whether the most recently added cursor's row is folded. + // + // Returns a {Boolean}. + isFoldedAtCursorRow () { + return this.isFoldedAtBufferRow(this.getCursorBufferPosition().row) + } + + // Extended: Determine whether the given row in buffer coordinates is folded. + // + // * `bufferRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtBufferRow (bufferRow) { + const range = Range( + Point(bufferRow, 0), + Point(bufferRow, this.buffer.lineLengthForRow(bufferRow)) + ) + return this.displayLayer.foldsIntersectingBufferRange(range).length > 0 + } + + // Extended: Determine whether the given row in screen coordinates is folded. + // + // * `screenRow` A {Number} + // + // Returns a {Boolean}. + isFoldedAtScreenRow (screenRow) { + return this.isFoldedAtBufferRow(this.bufferRowForScreenRow(screenRow)) + } + + // Creates a new fold between two row numbers. + // + // startRow - The row {Number} to start folding at + // endRow - The row {Number} to end the fold + // + // Returns the new {Fold}. + foldBufferRowRange (startRow, endRow) { + const result = this.foldBufferRange(Range(Point(startRow, Infinity), Point(endRow, Infinity))) + this.scrollToCursorPosition() + return result + } + + foldBufferRange (range) { + return this.displayLayer.foldBufferRange(range) + } + + // Remove any {Fold}s found that intersect the given buffer range. + destroyFoldsIntersectingBufferRange (bufferRange) { + return this.displayLayer.destroyFoldsIntersectingBufferRange(bufferRange) + } + + // Remove any {Fold}s found that contain the given array of buffer positions. + destroyFoldsContainingBufferPositions (bufferPositions, excludeEndpoints) { + return this.displayLayer.destroyFoldsContainingBufferPositions(bufferPositions, excludeEndpoints) + } + + /* + Section: Gutters + */ + + // Essential: Add a custom {Gutter}. + // + // * `options` An {Object} with the following fields: + // * `name` (required) A unique {String} to identify this gutter. + // * `priority` (optional) A {Number} that determines stacking order between + // gutters. Lower priority items are forced closer to the edges of the + // window. (default: -100) + // * `visible` (optional) {Boolean} specifying whether the gutter is visible + // initially after being created. (default: true) + // + // Returns the newly-created {Gutter}. + addGutter (options) { + return this.gutterContainer.addGutter(options) + } + + // Essential: Get this editor's gutters. + // + // Returns an {Array} of {Gutter}s. + getGutters () { + return this.gutterContainer.getGutters() + } + + getLineNumberGutter () { + return this.lineNumberGutter + } + + // Essential: Get the gutter with the given name. + // + // Returns a {Gutter}, or `null` if no gutter exists for the given name. + gutterWithName (name) { + return this.gutterContainer.gutterWithName(name) + } + + /* + Section: Scrolling the TextEditor + */ + + // Essential: Scroll the editor to reveal the most recently added cursor if it is + // off-screen. + // + // * `options` (optional) {Object} + // * `center` Center the editor around the cursor if possible. (default: true) + scrollToCursorPosition (options) { + this.getLastCursor().autoscroll({center: options && options.center !== false}) + } + + // Essential: Scrolls the editor to the given buffer position. + // + // * `bufferPosition` An object that represents a buffer position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToBufferPosition (bufferPosition, options) { + return this.scrollToScreenPosition(this.screenPositionForBufferPosition(bufferPosition), options) + } + + // Essential: Scrolls the editor to the given screen position. + // + // * `screenPosition` An object that represents a screen position. It can be either + // an {Object} (`{row, column}`), {Array} (`[row, column]`), or {Point} + // * `options` (optional) {Object} + // * `center` Center the editor around the position if possible. (default: false) + scrollToScreenPosition (screenPosition, options) { + this.scrollToScreenRange(new Range(screenPosition, screenPosition), options) + } + + scrollToTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::scrollToTop instead.') + this.getElement().scrollToTop() + } + + scrollToBottom () { + Grim.deprecate('This is now a view method. Call TextEditorElement::scrollToTop instead.') + this.getElement().scrollToBottom() + } + + scrollToScreenRange (screenRange, options = {}) { + if (options.clip !== false) screenRange = this.clipScreenRange(screenRange) + const scrollEvent = {screenRange, options} + if (this.component) this.component.didRequestAutoscroll(scrollEvent) + this.emitter.emit('did-request-autoscroll', scrollEvent) + } + + getHorizontalScrollbarHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getHorizontalScrollbarHeight instead.') + return this.getElement().getHorizontalScrollbarHeight() + } + + getVerticalScrollbarWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getVerticalScrollbarWidth instead.') + return this.getElement().getVerticalScrollbarWidth() + } + + pageUp () { + this.moveUp(this.getRowsPerPage()) + } + + pageDown () { + this.moveDown(this.getRowsPerPage()) + } + + selectPageUp () { + this.selectUp(this.getRowsPerPage()) + } + + selectPageDown () { + this.selectDown(this.getRowsPerPage()) + } + + // Returns the number of rows per page + getRowsPerPage () { + if (this.component) { + const clientHeight = this.component.getScrollContainerClientHeight() + const lineHeight = this.component.getLineHeight() + return Math.max(1, Math.ceil(clientHeight / lineHeight)) + } else { + return 1 + } + } + + /* + Section: Config + */ + + // Experimental: Supply an object that will provide the editor with settings + // for specific syntactic scopes. See the `ScopedSettingsDelegate` in + // `text-editor-registry.js` for an example implementation. + setScopedSettingsDelegate (scopedSettingsDelegate) { + this.scopedSettingsDelegate = scopedSettingsDelegate + this.tokenizedBuffer.scopedSettingsDelegate = this.scopedSettingsDelegate + } + + // Experimental: Retrieve the {Object} that provides the editor with settings + // for specific syntactic scopes. + getScopedSettingsDelegate () { return this.scopedSettingsDelegate } + + // Experimental: Is auto-indentation enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndent () { return this.autoIndent } + + // Experimental: Is auto-indentation on paste enabled for this editor? + // + // Returns a {Boolean}. + shouldAutoIndentOnPaste () { return this.autoIndentOnPaste } + + // Experimental: Does this editor allow scrolling past the last line? + // + // Returns a {Boolean}. + getScrollPastEnd () { + if (this.getAutoHeight()) { + return false + } else { + return this.scrollPastEnd + } + } + + // Experimental: How fast does the editor scroll in response to mouse wheel + // movements? + // + // Returns a positive {Number}. + getScrollSensitivity () { return this.scrollSensitivity } + + // Experimental: Does this editor show cursors while there is a selection? + // + // Returns a positive {Boolean}. + getShowCursorOnSelection () { return this.showCursorOnSelection } + + // Experimental: Are line numbers enabled for this editor? + // + // Returns a {Boolean} + doesShowLineNumbers () { return this.showLineNumbers } + + // Experimental: Get the time interval within which text editing operations + // are grouped together in the editor's undo history. + // + // Returns the time interval {Number} in milliseconds. + getUndoGroupingInterval () { return this.undoGroupingInterval } + + // Experimental: Get the characters that are *not* considered part of words, + // for the purpose of word-based cursor movements. + // + // Returns a {String} containing the non-word characters. + getNonWordCharacters (scopes) { + if (this.scopedSettingsDelegate && this.scopedSettingsDelegate.getNonWordCharacters) { + return this.scopedSettingsDelegate.getNonWordCharacters(scopes) || this.nonWordCharacters + } else { + return this.nonWordCharacters + } + } + + /* + Section: Event Handlers + */ + + handleGrammarChange () { + this.unfoldAll() + return this.emitter.emit('did-change-grammar', this.getGrammar()) + } + + /* + Section: TextEditor Rendering + */ + + // Get the Element for the editor. + getElement () { + if (!this.component) { + if (!TextEditorComponent) TextEditorComponent = require('./text-editor-component') + if (!TextEditorElement) TextEditorElement = require('./text-editor-element') + this.component = new TextEditorComponent({ + model: this, + updatedSynchronously: TextEditorElement.prototype.updatedSynchronously, + initialScrollTopRow: this.initialScrollTopRow, + initialScrollLeftColumn: this.initialScrollLeftColumn + }) + } + return this.component.element + } + + getAllowedLocations () { + return ['center'] + } + + // Essential: Retrieves the greyed out placeholder of a mini editor. + // + // Returns a {String}. + getPlaceholderText () { return this.placeholderText } + + // Essential: Set the greyed out placeholder of a mini editor. Placeholder text + // will be displayed when the editor has no content. + // + // * `placeholderText` {String} text that is displayed when the editor has no content. + setPlaceholderText (placeholderText) { this.update({placeholderText}) } + + pixelPositionForBufferPosition (bufferPosition) { + Grim.deprecate('This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForBufferPosition` instead') + return this.getElement().pixelPositionForBufferPosition(bufferPosition) + } + + pixelPositionForScreenPosition (screenPosition) { + Grim.deprecate('This method is deprecated on the model layer. Use `TextEditorElement::pixelPositionForScreenPosition` instead') + return this.getElement().pixelPositionForScreenPosition(screenPosition) + } + + getVerticalScrollMargin () { + const maxScrollMargin = Math.floor(((this.height / this.getLineHeightInPixels()) - 1) / 2) + return Math.min(this.verticalScrollMargin, maxScrollMargin) + } + + setVerticalScrollMargin (verticalScrollMargin) { + this.verticalScrollMargin = verticalScrollMargin + return this.verticalScrollMargin + } + + getHorizontalScrollMargin () { + return Math.min(this.horizontalScrollMargin, Math.floor(((this.width / this.getDefaultCharWidth()) - 1) / 2)) + } + setHorizontalScrollMargin (horizontalScrollMargin) { + this.horizontalScrollMargin = horizontalScrollMargin + return this.horizontalScrollMargin + } + + getLineHeightInPixels () { return this.lineHeightInPixels } + setLineHeightInPixels (lineHeightInPixels) { + this.lineHeightInPixels = lineHeightInPixels + return this.lineHeightInPixels + } + + getKoreanCharWidth () { return this.koreanCharWidth } + getHalfWidthCharWidth () { return this.halfWidthCharWidth } + getDoubleWidthCharWidth () { return this.doubleWidthCharWidth } + getDefaultCharWidth () { return this.defaultCharWidth } + + ratioForCharacter (character) { + if (isKoreanCharacter(character)) { + return this.getKoreanCharWidth() / this.getDefaultCharWidth() + } else if (isHalfWidthCharacter(character)) { + return this.getHalfWidthCharWidth() / this.getDefaultCharWidth() + } else if (isDoubleWidthCharacter(character)) { + return this.getDoubleWidthCharWidth() / this.getDefaultCharWidth() + } else { + return 1 + } + } + + setDefaultCharWidth (defaultCharWidth, doubleWidthCharWidth, halfWidthCharWidth, koreanCharWidth) { + if (doubleWidthCharWidth == null) { doubleWidthCharWidth = defaultCharWidth } + if (halfWidthCharWidth == null) { halfWidthCharWidth = defaultCharWidth } + if (koreanCharWidth == null) { koreanCharWidth = defaultCharWidth } + if (defaultCharWidth !== this.defaultCharWidth || + (doubleWidthCharWidth !== this.doubleWidthCharWidth && + halfWidthCharWidth !== this.halfWidthCharWidth && + koreanCharWidth !== this.koreanCharWidth)) { + this.defaultCharWidth = defaultCharWidth + this.doubleWidthCharWidth = doubleWidthCharWidth + this.halfWidthCharWidth = halfWidthCharWidth + this.koreanCharWidth = koreanCharWidth + if (this.isSoftWrapped()) { + this.displayLayer.reset({ + softWrapColumn: this.getSoftWrapColumn() + }) + } + } + return defaultCharWidth + } + + setHeight (height) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setHeight instead.') + this.getElement().setHeight(height) + } + + getHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getHeight instead.') + return this.getElement().getHeight() + } + + getAutoHeight () { return this.autoHeight != null ? this.autoHeight : true } + + getAutoWidth () { return this.autoWidth != null ? this.autoWidth : false } + + setWidth (width) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setWidth instead.') + this.getElement().setWidth(width) + } + + getWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getWidth instead.') + return this.getElement().getWidth() + } + + // Use setScrollTopRow instead of this method + setFirstVisibleScreenRow (screenRow) { + this.setScrollTopRow(screenRow) + } + + getFirstVisibleScreenRow () { + return this.getElement().component.getFirstVisibleRow() + } + + getLastVisibleScreenRow () { + return this.getElement().component.getLastVisibleRow() + } + + getVisibleRowRange () { + return [this.getFirstVisibleScreenRow(), this.getLastVisibleScreenRow()] + } + + // Use setScrollLeftColumn instead of this method + setFirstVisibleScreenColumn (column) { + return this.setScrollLeftColumn(column) + } + + getFirstVisibleScreenColumn () { + return this.getElement().component.getFirstVisibleColumn() + } + + getScrollTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollTop instead.') + return this.getElement().getScrollTop() + } + + setScrollTop (scrollTop) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollTop instead.') + this.getElement().setScrollTop(scrollTop) + } + + getScrollBottom () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollBottom instead.') + return this.getElement().getScrollBottom() + } + + setScrollBottom (scrollBottom) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollBottom instead.') + this.getElement().setScrollBottom(scrollBottom) + } + + getScrollLeft () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollLeft instead.') + return this.getElement().getScrollLeft() + } + + setScrollLeft (scrollLeft) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollLeft instead.') + this.getElement().setScrollLeft(scrollLeft) + } + + getScrollRight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollRight instead.') + return this.getElement().getScrollRight() + } + + setScrollRight (scrollRight) { + Grim.deprecate('This is now a view method. Call TextEditorElement::setScrollRight instead.') + this.getElement().setScrollRight(scrollRight) + } + + getScrollHeight () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollHeight instead.') + return this.getElement().getScrollHeight() + } + + getScrollWidth () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getScrollWidth instead.') + return this.getElement().getScrollWidth() + } + + getMaxScrollTop () { + Grim.deprecate('This is now a view method. Call TextEditorElement::getMaxScrollTop instead.') + return this.getElement().getMaxScrollTop() + } + + getScrollTopRow () { + return this.getElement().component.getScrollTopRow() + } + + setScrollTopRow (scrollTopRow) { + this.getElement().component.setScrollTopRow(scrollTopRow) + } + + getScrollLeftColumn () { + return this.getElement().component.getScrollLeftColumn() + } + + setScrollLeftColumn (scrollLeftColumn) { + this.getElement().component.setScrollLeftColumn(scrollLeftColumn) + } + + intersectsVisibleRowRange (startRow, endRow) { + Grim.deprecate('This is now a view method. Call TextEditorElement::intersectsVisibleRowRange instead.') + return this.getElement().intersectsVisibleRowRange(startRow, endRow) + } + + selectionIntersectsVisibleRowRange (selection) { + Grim.deprecate('This is now a view method. Call TextEditorElement::selectionIntersectsVisibleRowRange instead.') + return this.getElement().selectionIntersectsVisibleRowRange(selection) + } + + screenPositionForPixelPosition (pixelPosition) { + Grim.deprecate('This is now a view method. Call TextEditorElement::screenPositionForPixelPosition instead.') + return this.getElement().screenPositionForPixelPosition(pixelPosition) + } + + pixelRectForScreenRange (screenRange) { + Grim.deprecate('This is now a view method. Call TextEditorElement::pixelRectForScreenRange instead.') + return this.getElement().pixelRectForScreenRange(screenRange) + } + + /* + Section: Utility + */ + + inspect () { + return `` + } + + emitWillInsertTextEvent (text) { + let result = true + const cancel = () => { result = false } + this.emitter.emit('will-insert-text', {cancel, text}) + return result + } + + /* + Section: Language Mode Delegated Methods + */ + + suggestedIndentForBufferRow (bufferRow, options) { + return this.tokenizedBuffer.suggestedIndentForBufferRow(bufferRow, options) + } + + // Given a buffer row, indent it. + // + // * bufferRow - The row {Number}. + // * options - An options {Object} to pass through to {TextEditor::setIndentationForBufferRow}. + autoIndentBufferRow (bufferRow, options) { + const indentLevel = this.suggestedIndentForBufferRow(bufferRow, options) + return this.setIndentationForBufferRow(bufferRow, indentLevel, options) + } + + // Indents all the rows between two buffer row numbers. + // + // * startRow - The row {Number} to start at + // * endRow - The row {Number} to end at + autoIndentBufferRows (startRow, endRow) { + let row = startRow + while (row <= endRow) { + this.autoIndentBufferRow(row) + row++ + } + } + + autoDecreaseIndentForBufferRow (bufferRow) { + const indentLevel = this.tokenizedBuffer.suggestedIndentForEditedBufferRow(bufferRow) + if (indentLevel != null) this.setIndentationForBufferRow(bufferRow, indentLevel) + } + + toggleLineCommentForBufferRow (row) { this.toggleLineCommentsForBufferRows(row, row) } + + toggleLineCommentsForBufferRows (start, end) { + let { + commentStartString, + commentEndString + } = this.tokenizedBuffer.commentStringsForPosition(Point(start, 0)) + if (!commentStartString) return + commentStartString = commentStartString.trim() + + if (commentEndString) { + commentEndString = commentEndString.trim() + const startDelimiterColumnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(start), + commentStartString + ) + if (startDelimiterColumnRange) { + const endDelimiterColumnRange = columnRangeForEndDelimiter( + this.buffer.lineForRow(end), + commentEndString + ) + if (endDelimiterColumnRange) { + this.buffer.transact(() => { + this.buffer.delete([[end, endDelimiterColumnRange[0]], [end, endDelimiterColumnRange[1]]]) + this.buffer.delete([[start, startDelimiterColumnRange[0]], [start, startDelimiterColumnRange[1]]]) + }) + } + } else { + this.buffer.transact(() => { + const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0].length + this.buffer.insert([start, indentLength], commentStartString + ' ') + this.buffer.insert([end, this.buffer.lineLengthForRow(end)], ' ' + commentEndString) + }) + } + } else { + let hasCommentedLines = false + let hasUncommentedLines = false + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + if (NON_WHITESPACE_REGEXP.test(line)) { + if (columnRangeForStartDelimiter(line, commentStartString)) { + hasCommentedLines = true + } else { + hasUncommentedLines = true + } + } + } + + const shouldUncomment = hasCommentedLines && !hasUncommentedLines + + if (shouldUncomment) { + for (let row = start; row <= end; row++) { + const columnRange = columnRangeForStartDelimiter( + this.buffer.lineForRow(row), + commentStartString + ) + if (columnRange) this.buffer.delete([[row, columnRange[0]], [row, columnRange[1]]]) + } + } else { + let minIndentLevel = Infinity + let minBlankIndentLevel = Infinity + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + const indentLevel = this.indentLevelForLine(line) + if (NON_WHITESPACE_REGEXP.test(line)) { + if (indentLevel < minIndentLevel) minIndentLevel = indentLevel + } else { + if (indentLevel < minBlankIndentLevel) minBlankIndentLevel = indentLevel + } + } + minIndentLevel = Number.isFinite(minIndentLevel) + ? minIndentLevel + : Number.isFinite(minBlankIndentLevel) + ? minBlankIndentLevel + : 0 + + const tabLength = this.getTabLength() + const indentString = ' '.repeat(tabLength * minIndentLevel) + for (let row = start; row <= end; row++) { + const line = this.buffer.lineForRow(row) + if (NON_WHITESPACE_REGEXP.test(line)) { + const indentColumn = columnForIndentLevel(line, minIndentLevel, this.getTabLength()) + this.buffer.insert(Point(row, indentColumn), commentStartString + ' ') + } else { + this.buffer.setTextInRange( + new Range(new Point(row, 0), new Point(row, Infinity)), + indentString + commentStartString + ' ' + ) + } + } + } + } + } + + rowRangeForParagraphAtBufferRow (bufferRow) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(bufferRow))) return + + const isCommented = this.tokenizedBuffer.isRowCommented(bufferRow) + + let startRow = bufferRow + while (startRow > 0) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(startRow - 1))) break + if (this.tokenizedBuffer.isRowCommented(startRow - 1) !== isCommented) break + startRow-- + } + + let endRow = bufferRow + const rowCount = this.getLineCount() + while (endRow < rowCount) { + if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(endRow + 1))) break + if (this.tokenizedBuffer.isRowCommented(endRow + 1) !== isCommented) break + endRow++ + } + + return new Range(new Point(startRow, 0), new Point(endRow, this.buffer.lineLengthForRow(endRow))) + } +} + +function columnForIndentLevel (line, indentLevel, tabLength) { + let column = 0 + let indentLength = 0 + const goalIndentLength = indentLevel * tabLength + while (indentLength < goalIndentLength) { + const char = line[column] + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength) + } else if (char === ' ') { + indentLength++ + } else { + break + } + column++ + } + return column +} + +function columnRangeForStartDelimiter (line, delimiter) { + const startColumn = line.search(NON_WHITESPACE_REGEXP) + if (startColumn === -1) return null + if (!line.startsWith(delimiter, startColumn)) return null + + let endColumn = startColumn + delimiter.length + if (line[endColumn] === ' ') endColumn++ + return [startColumn, endColumn] +} + +function columnRangeForEndDelimiter (line, delimiter) { + let startColumn = line.lastIndexOf(delimiter) + if (startColumn === -1) return null + + const endColumn = startColumn + delimiter.length + if (NON_WHITESPACE_REGEXP.test(line.slice(endColumn))) return null + if (line[startColumn - 1] === ' ') startColumn-- + return [startColumn, endColumn] +} + +class ChangeEvent { + constructor ({oldRange, newRange}) { + this.oldRange = oldRange + this.newRange = newRange + } + + get start () { + return this.newRange.start + } + + get oldExtent () { + return this.oldRange.getExtent() + } + + get newExtent () { + return this.newRange.getExtent() + } +} diff --git a/src/text-utils.coffee b/src/text-utils.coffee deleted file mode 100644 index f4d62772e..000000000 --- a/src/text-utils.coffee +++ /dev/null @@ -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 -} diff --git a/src/text-utils.js b/src/text-utils.js new file mode 100644 index 000000000..7dde49fd7 --- /dev/null +++ b/src/text-utils.js @@ -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 +} diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee deleted file mode 100644 index d5a2cb0d1..000000000 --- a/src/theme-manager.coffee +++ /dev/null @@ -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) diff --git a/src/theme-manager.js b/src/theme-manager.js new file mode 100644 index 000000000..6abf0fc74 --- /dev/null +++ b/src/theme-manager.js @@ -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)) + } +} diff --git a/src/theme-package.coffee b/src/theme-package.coffee deleted file mode 100644 index 053132d61..000000000 --- a/src/theme-package.coffee +++ /dev/null @@ -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) diff --git a/src/theme-package.js b/src/theme-package.js new file mode 100644 index 000000000..7ac01bd97 --- /dev/null +++ b/src/theme-package.js @@ -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 + } +} diff --git a/src/token-iterator.coffee b/src/token-iterator.coffee deleted file mode 100644 index f836d33d4..000000000 --- a/src/token-iterator.coffee +++ /dev/null @@ -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 diff --git a/src/token-iterator.js b/src/token-iterator.js new file mode 100644 index 000000000..a698fc748 --- /dev/null +++ b/src/token-iterator.js @@ -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 + } +} diff --git a/src/tokenized-buffer.js b/src/tokenized-buffer.js index b4bc0d41c..2a9446256 100644 --- a/src/tokenized-buffer.js +++ b/src/tokenized-buffer.js @@ -163,99 +163,12 @@ class TokenizedBuffer { Section - Comments */ - toggleLineCommentsForBufferRows (start, end) { - const scope = this.scopeDescriptorForPosition([start, 0]) - const commentStrings = this.commentStringsForScopeDescriptor(scope) - if (!commentStrings) return - const {commentStartString, commentEndString} = commentStrings - if (!commentStartString) return - - const commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') - const commentStartRegex = new OnigRegExp(`^(\\s*)(${commentStartRegexString})`) - - if (commentEndString) { - const shouldUncomment = commentStartRegex.testSync(this.buffer.lineForRow(start)) - if (shouldUncomment) { - const commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?') - const commentEndRegex = new OnigRegExp(`(${commentEndRegexString})(\\s*)$`) - const startMatch = commentStartRegex.searchSync(this.buffer.lineForRow(start)) - const endMatch = commentEndRegex.searchSync(this.buffer.lineForRow(end)) - if (startMatch && endMatch) { - this.buffer.transact(() => { - const columnStart = startMatch[1].length - const columnEnd = columnStart + startMatch[2].length - this.buffer.setTextInRange([[start, columnStart], [start, columnEnd]], '') - - const endLength = this.buffer.lineLengthForRow(end) - endMatch[2].length - const endColumn = endLength - endMatch[1].length - return this.buffer.setTextInRange([[end, endColumn], [end, endLength]], '') - }) - } - } else { - this.buffer.transact(() => { - const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0].length - this.buffer.insert([start, indentLength], commentStartString) - this.buffer.insert([end, this.buffer.lineLengthForRow(end)], commentEndString) - }) - } + commentStringsForPosition (position) { + if (this.scopedSettingsDelegate) { + const scope = this.scopeDescriptorForPosition(position) + return this.scopedSettingsDelegate.getCommentStrings(scope) } else { - let hasCommentedLines = false - let hasUncommentedLines = false - for (let row = start; row <= end; row++) { - const line = this.buffer.lineForRow(row) - if (NON_WHITESPACE_REGEX.test(line)) { - if (commentStartRegex.testSync(line)) { - hasCommentedLines = true - } else { - hasUncommentedLines = true - } - } - } - - const shouldUncomment = hasCommentedLines && !hasUncommentedLines - - if (shouldUncomment) { - for (let row = start; row <= end; row++) { - const match = commentStartRegex.searchSync(this.buffer.lineForRow(row)) - if (match) { - const columnStart = match[1].length - const columnEnd = columnStart + match[2].length - this.buffer.setTextInRange([[row, columnStart], [row, columnEnd]], '') - } - } - } else { - let minIndentLevel = Infinity - let minBlankIndentLevel = Infinity - for (let row = start; row <= end; row++) { - const line = this.buffer.lineForRow(row) - const indentLevel = this.indentLevelForLine(line) - if (NON_WHITESPACE_REGEX.test(line)) { - if (indentLevel < minIndentLevel) minIndentLevel = indentLevel - } else { - if (indentLevel < minBlankIndentLevel) minBlankIndentLevel = indentLevel - } - } - minIndentLevel = Number.isFinite(minIndentLevel) - ? minIndentLevel - : Number.isFinite(minBlankIndentLevel) - ? minBlankIndentLevel - : 0 - - const tabLength = this.getTabLength() - const indentString = ' '.repeat(tabLength * minIndentLevel) - for (let row = start; row <= end; row++) { - const line = this.buffer.lineForRow(row) - if (NON_WHITESPACE_REGEX.test(line)) { - const indentColumn = this.columnForIndentLevel(line, minIndentLevel) - this.buffer.insert(Point(row, indentColumn), commentStartString) - } else { - this.buffer.setTextInRange( - new Range(new Point(row, 0), new Point(row, Infinity)), - indentString + commentStartString - ) - } - } - } + return {} } } @@ -594,24 +507,6 @@ class TokenizedBuffer { return scopes } - columnForIndentLevel (line, indentLevel, tabLength = this.tabLength) { - let column = 0 - let indentLength = 0 - const goalIndentLength = indentLevel * tabLength - while (indentLength < goalIndentLength) { - const char = line[column] - if (char === '\t') { - indentLength += tabLength - (indentLength % tabLength) - } else if (char === ' ') { - indentLength++ - } else { - break - } - column++ - } - return column - } - indentLevelForLine (line, tabLength = this.tabLength) { let indentLength = 0 for (let i = 0, {length} = line; i < length; i++) { @@ -841,12 +736,6 @@ class TokenizedBuffer { } } - commentStringsForScopeDescriptor (scopes) { - if (this.scopedSettingsDelegate) { - return this.scopedSettingsDelegate.getCommentStrings(scopes) - } - } - regexForPattern (pattern) { if (pattern) { if (!this.regexesByPattern[pattern]) { diff --git a/src/tooltip-manager.coffee b/src/tooltip-manager.coffee deleted file mode 100644 index 1a9b6fe44..000000000 --- a/src/tooltip-manager.coffee +++ /dev/null @@ -1,176 +0,0 @@ -_ = require 'underscore-plus' -{Disposable, CompositeDisposable} = require 'event-kit' -Tooltip = null - -# Essential: Associates tooltips with HTML elements. -# -# You can get the `TooltipManager` via `atom.tooltips`. -# -# ## Examples -# -# The essence of displaying a tooltip -# -# ```coffee -# # display it -# disposable = atom.tooltips.add(div, {title: 'This is a tooltip'}) -# -# # remove it -# disposable.dispose() -# ``` -# -# In practice there are usually multiple tooltips. So we add them to a -# CompositeDisposable -# -# ```coffee -# {CompositeDisposable} = require 'atom' -# subscriptions = new CompositeDisposable -# -# div1 = document.createElement('div') -# div2 = document.createElement('div') -# subscriptions.add atom.tooltips.add(div1, {title: 'This is a tooltip'}) -# subscriptions.add atom.tooltips.add(div2, {title: 'Another tooltip'}) -# -# # remove them all -# subscriptions.dispose() -# ``` -# -# You can display a key binding in the tooltip as well with the -# `keyBindingCommand` option. -# -# ```coffee -# disposable = atom.tooltips.add @caseOptionButton, -# title: "Match Case" -# keyBindingCommand: 'find-and-replace:toggle-case-option' -# keyBindingTarget: @findEditor.element -# ``` -module.exports = -class TooltipManager - defaults: - trigger: 'hover' - container: 'body' - html: true - placement: 'auto top' - viewportPadding: 2 - - hoverDefaults: - {delay: {show: 1000, hide: 100}} - - constructor: ({@keymapManager, @viewRegistry}) -> - @tooltips = new Map() - - # Essential: Add a tooltip to the given element. - # - # * `target` An `HTMLElement` - # * `options` An object with one or more of the following options: - # * `title` A {String} or {Function} to use for the text in the tip. If - # a function is passed, `this` will be set to the `target` element. This - # option is mutually exclusive with the `item` option. - # * `html` A {Boolean} affecting the interpretation of the `title` option. - # If `true` (the default), the `title` string will be interpreted as HTML. - # Otherwise it will be interpreted as plain text. - # * `item` A view (object with an `.element` property) or a DOM element - # containing custom content for the tooltip. This option is mutually - # exclusive with the `title` option. - # * `class` A {String} with a class to apply to the tooltip element to - # enable custom styling. - # * `placement` A {String} or {Function} returning a string to indicate - # the position of the tooltip relative to `element`. Can be `'top'`, - # `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is - # specified, it will dynamically reorient the tooltip. For example, if - # placement is `'auto left'`, the tooltip will display to the left when - # possible, otherwise it will display right. - # When a function is used to determine the placement, it is called with - # the tooltip DOM node as its first argument and the triggering element - # DOM node as its second. The `this` context is set to the tooltip - # instance. - # * `trigger` A {String} indicating how the tooltip should be displayed. - # Choose from one of the following options: - # * `'hover'` Show the tooltip when the mouse hovers over the element. - # This is the default. - # * `'click'` Show the tooltip when the element is clicked. The tooltip - # will be hidden after clicking the element again or anywhere else - # outside of the tooltip itself. - # * `'focus'` Show the tooltip when the element is focused. - # * `'manual'` Show the tooltip immediately and only hide it when the - # returned disposable is disposed. - # * `delay` An object specifying the show and hide delay in milliseconds. - # Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and - # otherwise defaults to `0` for both values. - # * `keyBindingCommand` A {String} containing a command name. If you specify - # this option and a key binding exists that matches the command, it will - # be appended to the title or rendered alone if no title is specified. - # * `keyBindingTarget` An `HTMLElement` on which to look up the key binding. - # If this option is not supplied, the first of all matching key bindings - # for the given command will be rendered. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # tooltip. - add: (target, options) -> - if target.jquery - disposable = new CompositeDisposable - disposable.add @add(element, options) for element in target - return disposable - - Tooltip ?= require './tooltip' - - {keyBindingCommand, keyBindingTarget} = options - - if keyBindingCommand? - bindings = @keymapManager.findKeyBindings(command: keyBindingCommand, target: keyBindingTarget) - keystroke = getKeystroke(bindings) - if options.title? and keystroke? - options.title += " " + getKeystroke(bindings) - else if keystroke? - options.title = getKeystroke(bindings) - - delete options.selector - options = _.defaults(options, @defaults) - if options.trigger is 'hover' - options = _.defaults(options, @hoverDefaults) - - tooltip = new Tooltip(target, options, @viewRegistry) - - if not @tooltips.has(target) - @tooltips.set(target, []) - @tooltips.get(target).push(tooltip) - - hideTooltip = -> - tooltip.leave(currentTarget: target) - tooltip.hide() - - window.addEventListener('resize', hideTooltip) - - disposable = new Disposable => - window.removeEventListener('resize', hideTooltip) - hideTooltip() - tooltip.destroy() - - if @tooltips.has(target) - tooltipsForTarget = @tooltips.get(target) - index = tooltipsForTarget.indexOf(tooltip) - if index isnt -1 - tooltipsForTarget.splice(index, 1) - if tooltipsForTarget.length is 0 - @tooltips.delete(target) - - disposable - - # Extended: Find the tooltips that have been applied to the given element. - # - # * `target` The `HTMLElement` to find tooltips on. - # - # Returns an {Array} of `Tooltip` objects that match the `target`. - findTooltips: (target) -> - if @tooltips.has(target) - @tooltips.get(target).slice() - else - [] - -humanizeKeystrokes = (keystroke) -> - keystrokes = keystroke.split(' ') - keystrokes = (_.humanizeKeystroke(stroke) for stroke in keystrokes) - keystrokes.join(' ') - -getKeystroke = (bindings) -> - if bindings?.length - "#{humanizeKeystrokes(bindings[0].keystrokes)}" diff --git a/src/tooltip-manager.js b/src/tooltip-manager.js new file mode 100644 index 000000000..34f96775b --- /dev/null +++ b/src/tooltip-manager.js @@ -0,0 +1,201 @@ +const _ = require('underscore-plus') +const {Disposable, CompositeDisposable} = require('event-kit') +let Tooltip = null + +// Essential: Associates tooltips with HTML elements. +// +// You can get the `TooltipManager` via `atom.tooltips`. +// +// ## Examples +// +// The essence of displaying a tooltip +// +// ```javascript +// // display it +// const disposable = atom.tooltips.add(div, {title: 'This is a tooltip'}) +// +// // remove it +// disposable.dispose() +// ``` +// +// In practice there are usually multiple tooltips. So we add them to a +// CompositeDisposable +// +// ```javascript +// const {CompositeDisposable} = require('atom') +// const subscriptions = new CompositeDisposable() +// +// const div1 = document.createElement('div') +// const div2 = document.createElement('div') +// subscriptions.add(atom.tooltips.add(div1, {title: 'This is a tooltip'})) +// subscriptions.add(atom.tooltips.add(div2, {title: 'Another tooltip'})) +// +// // remove them all +// subscriptions.dispose() +// ``` +// +// You can display a key binding in the tooltip as well with the +// `keyBindingCommand` option. +// +// ```javascript +// disposable = atom.tooltips.add(this.caseOptionButton, { +// title: 'Match Case', +// keyBindingCommand: 'find-and-replace:toggle-case-option', +// keyBindingTarget: this.findEditor.element +// }) +// ``` +module.exports = +class TooltipManager { + constructor ({keymapManager, viewRegistry}) { + this.defaults = { + trigger: 'hover', + container: 'body', + html: true, + placement: 'auto top', + viewportPadding: 2 + } + + this.hoverDefaults = { + delay: {show: 1000, hide: 100} + } + + this.keymapManager = keymapManager + this.viewRegistry = viewRegistry + this.tooltips = new Map() + } + + // Essential: Add a tooltip to the given element. + // + // * `target` An `HTMLElement` + // * `options` An object with one or more of the following options: + // * `title` A {String} or {Function} to use for the text in the tip. If + // a function is passed, `this` will be set to the `target` element. This + // option is mutually exclusive with the `item` option. + // * `html` A {Boolean} affecting the interpretation of the `title` option. + // If `true` (the default), the `title` string will be interpreted as HTML. + // Otherwise it will be interpreted as plain text. + // * `item` A view (object with an `.element` property) or a DOM element + // containing custom content for the tooltip. This option is mutually + // exclusive with the `title` option. + // * `class` A {String} with a class to apply to the tooltip element to + // enable custom styling. + // * `placement` A {String} or {Function} returning a string to indicate + // the position of the tooltip relative to `element`. Can be `'top'`, + // `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is + // specified, it will dynamically reorient the tooltip. For example, if + // placement is `'auto left'`, the tooltip will display to the left when + // possible, otherwise it will display right. + // When a function is used to determine the placement, it is called with + // the tooltip DOM node as its first argument and the triggering element + // DOM node as its second. The `this` context is set to the tooltip + // instance. + // * `trigger` A {String} indicating how the tooltip should be displayed. + // Choose from one of the following options: + // * `'hover'` Show the tooltip when the mouse hovers over the element. + // This is the default. + // * `'click'` Show the tooltip when the element is clicked. The tooltip + // will be hidden after clicking the element again or anywhere else + // outside of the tooltip itself. + // * `'focus'` Show the tooltip when the element is focused. + // * `'manual'` Show the tooltip immediately and only hide it when the + // returned disposable is disposed. + // * `delay` An object specifying the show and hide delay in milliseconds. + // Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and + // otherwise defaults to `0` for both values. + // * `keyBindingCommand` A {String} containing a command name. If you specify + // this option and a key binding exists that matches the command, it will + // be appended to the title or rendered alone if no title is specified. + // * `keyBindingTarget` An `HTMLElement` on which to look up the key binding. + // If this option is not supplied, the first of all matching key bindings + // for the given command will be rendered. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // tooltip. + add (target, options) { + if (target.jquery) { + const disposable = new CompositeDisposable() + for (let i = 0; i < target.length; i++) { + disposable.add(this.add(target[i], options)) + } + return disposable + } + + if (Tooltip == null) { Tooltip = require('./tooltip') } + + const {keyBindingCommand, keyBindingTarget} = options + + if (keyBindingCommand != null) { + const bindings = this.keymapManager.findKeyBindings({command: keyBindingCommand, target: keyBindingTarget}) + const keystroke = getKeystroke(bindings) + if ((options.title != null) && (keystroke != null)) { + options.title += ` ${getKeystroke(bindings)}` + } else if (keystroke != null) { + options.title = getKeystroke(bindings) + } + } + + delete options.selector + options = _.defaults(options, this.defaults) + if (options.trigger === 'hover') { + options = _.defaults(options, this.hoverDefaults) + } + + const tooltip = new Tooltip(target, options, this.viewRegistry) + + if (!this.tooltips.has(target)) { + this.tooltips.set(target, []) + } + this.tooltips.get(target).push(tooltip) + + const hideTooltip = function () { + tooltip.leave({currentTarget: target}) + tooltip.hide() + } + + window.addEventListener('resize', hideTooltip) + + const disposable = new Disposable(() => { + window.removeEventListener('resize', hideTooltip) + hideTooltip() + tooltip.destroy() + + if (this.tooltips.has(target)) { + const tooltipsForTarget = this.tooltips.get(target) + const index = tooltipsForTarget.indexOf(tooltip) + if (index !== -1) { + tooltipsForTarget.splice(index, 1) + } + if (tooltipsForTarget.length === 0) { + this.tooltips.delete(target) + } + } + }) + + return disposable + } + + // Extended: Find the tooltips that have been applied to the given element. + // + // * `target` The `HTMLElement` to find tooltips on. + // + // Returns an {Array} of `Tooltip` objects that match the `target`. + findTooltips (target) { + if (this.tooltips.has(target)) { + return this.tooltips.get(target).slice() + } else { + return [] + } + } +} + +function humanizeKeystrokes (keystroke) { + let keystrokes = keystroke.split(' ') + keystrokes = (keystrokes.map((stroke) => _.humanizeKeystroke(stroke))) + return keystrokes.join(' ') +} + +function getKeystroke (bindings) { + if (bindings && bindings.length) { + return `${humanizeKeystrokes(bindings[0].keystrokes)}` + } +} diff --git a/src/uri-handler-registry.js b/src/uri-handler-registry.js new file mode 100644 index 000000000..297f916eb --- /dev/null +++ b/src/uri-handler-registry.js @@ -0,0 +1,129 @@ +const url = require('url') +const {Emitter, Disposable} = require('event-kit') + +// Private: Associates listener functions with URIs from outside the application. +// +// The global URI handler registry maps URIs to listener functions. URIs are mapped +// based on the hostname of the URI; the format is atom://package/command?args. +// The "core" package name is reserved for URIs handled by Atom core (it is not possible +// to register a package with the name "core"). +// +// Because URI handling can be triggered from outside the application (e.g. from +// the user's browser), package authors should take great care to ensure that malicious +// activities cannot be performed by an attacker. A good rule to follow is that +// **URI handlers should not take action on behalf of the user**. For example, clicking +// a link to open a pane item that prompts the user to install a package is okay; +// automatically installing the package right away is not. +// +// Packages can register their desire to handle URIs via a special key in their +// `package.json` called "uriHandler". The value of this key should be an object +// that contains, at minimum, a key named "method". This is the name of the method +// on your package object that Atom will call when it receives a URI your package +// is responsible for handling. It will pass the parsed URI as the first argument (by using +// [Node's `url.parse(uri, true)`](https://nodejs.org/docs/latest/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost)) +// and the raw URI string as the second argument. +// +// By default, Atom will defer activation of your package until a URI it needs to handle +// is triggered. If you need your package to activate right away, you can add +// `"deferActivation": false` to your "uriHandler" configuration object. When activation +// is deferred, once Atom receives a request for a URI in your package's namespace, it will +// activate your pacakge and then call `methodName` on it as before. +// +// If your package specifies a deprecated `urlMain` property, you cannot register URI handlers +// via the `uriHandler` key. +// +// ## Example +// +// Here is a sample package that will be activated and have its `handleURI` method called +// when a URI beginning with `atom://my-package` is triggered: +// +// `package.json`: +// +// ```javascript +// { +// "name": "my-package", +// "main": "./lib/my-package.js", +// "uriHandler": { +// "method": "handleURI" +// } +// } +// ``` +// +// `lib/my-package.js` +// +// ```javascript +// module.exports = { +// activate: function() { +// // code to activate your package +// } +// +// handleURI(parsedUri, rawUri) { +// // parse and handle uri +// } +// } +// ``` +module.exports = +class URIHandlerRegistry { + constructor (maxHistoryLength = 50) { + this.registrations = new Map() + this.history = [] + this.maxHistoryLength = maxHistoryLength + this._id = 0 + + this.emitter = new Emitter() + } + + registerHostHandler (host, callback) { + if (typeof callback !== 'function') { + throw new Error('Cannot register a URI host handler with a non-function callback') + } + + if (this.registrations.has(host)) { + throw new Error(`There is already a URI host handler for the host ${host}`) + } else { + this.registrations.set(host, callback) + } + + return new Disposable(() => { + this.registrations.delete(host) + }) + } + + handleURI (uri) { + const parsed = url.parse(uri, true) + const {protocol, slashes, auth, port, host} = parsed + if (protocol !== 'atom:' || slashes !== true || auth || port) { + throw new Error(`URIHandlerRegistry#handleURI asked to handle an invalid URI: ${uri}`) + } + + const registration = this.registrations.get(host) + const historyEntry = {id: ++this._id, uri: uri, handled: false, host} + try { + if (registration) { + historyEntry.handled = true + registration(parsed, uri) + } + } finally { + this.history.unshift(historyEntry) + if (this.history.length > this.maxHistoryLength) { + this.history.length = this.maxHistoryLength + } + this.emitter.emit('history-change') + } + } + + getRecentlyHandledURIs () { + return this.history + } + + onHistoryChange (cb) { + return this.emitter.on('history-change', cb) + } + + destroy () { + this.emitter.dispose() + this.registrations = new Map() + this.history = [] + this._id = 0 + } +} diff --git a/src/view-registry.coffee b/src/view-registry.coffee deleted file mode 100644 index f300cc031..000000000 --- a/src/view-registry.coffee +++ /dev/null @@ -1,201 +0,0 @@ -Grim = require 'grim' -{Disposable} = require 'event-kit' -_ = require 'underscore-plus' - -AnyConstructor = Symbol('any-constructor') - -# Essential: `ViewRegistry` handles the association between model and view -# types in Atom. We call this association a View Provider. As in, for a given -# model, this class can provide a view via {::getView}, as long as the -# model/view association was registered via {::addViewProvider} -# -# If you're adding your own kind of pane item, a good strategy for all but the -# simplest items is to separate the model and the view. The model handles -# application logic and is the primary point of API interaction. The view -# just handles presentation. -# -# Note: Models can be any object, but must implement a `getTitle()` function -# if they are to be displayed in a {Pane} -# -# View providers inform the workspace how your model objects should be -# presented in the DOM. A view provider must always return a DOM node, which -# makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) -# an ideal tool for implementing views in Atom. -# -# You can access the `ViewRegistry` object via `atom.views`. -module.exports = -class ViewRegistry - animationFrameRequest: null - documentReadInProgress: false - - constructor: (@atomEnvironment) -> - @clear() - - clear: -> - @views = new WeakMap - @providers = [] - @clearDocumentRequests() - - # Essential: Add a provider that will be used to construct views in the - # workspace's view layer based on model objects in its model layer. - # - # ## Examples - # - # Text editors are divided into a model and a view layer, so when you interact - # with methods like `atom.workspace.getActiveTextEditor()` you're only going - # to get the model object. We display text editors on screen by teaching the - # workspace what view constructor it should use to represent them: - # - # ```coffee - # atom.views.addViewProvider TextEditor, (textEditor) -> - # textEditorElement = new TextEditorElement - # textEditorElement.initialize(textEditor) - # textEditorElement - # ``` - # - # * `modelConstructor` (optional) Constructor {Function} for your model. If - # a constructor is given, the `createView` function will only be used - # for model objects inheriting from that constructor. Otherwise, it will - # will be called for any object. - # * `createView` Factory {Function} that is passed an instance of your model - # and must return a subclass of `HTMLElement` or `undefined`. If it returns - # `undefined`, then the registry will continue to search for other view - # providers. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # added provider. - addViewProvider: (modelConstructor, createView) -> - if arguments.length is 1 - switch typeof modelConstructor - when 'function' - provider = {createView: modelConstructor, modelConstructor: AnyConstructor} - when 'object' - Grim.deprecate("atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.") - provider = modelConstructor - else - throw new TypeError("Arguments to addViewProvider must be functions") - else - provider = {modelConstructor, createView} - - @providers.push(provider) - new Disposable => - @providers = @providers.filter (p) -> p isnt provider - - getViewProviderCount: -> - @providers.length - - # Essential: Get the view associated with an object in the workspace. - # - # If you're just *using* the workspace, you shouldn't need to access the view - # layer, but view layer access may be necessary if you want to perform DOM - # manipulation that isn't supported via the model API. - # - # ## View Resolution Algorithm - # - # The view associated with the object is resolved using the following - # sequence - # - # 1. Is the object an instance of `HTMLElement`? If true, return the object. - # 2. Does the object have a method named `getElement` that returns an - # instance of `HTMLElement`? If true, return that value. - # 3. Does the object have a property named `element` with a value which is - # an instance of `HTMLElement`? If true, return the property value. - # 4. Is the object a jQuery object, indicated by the presence of a `jquery` - # property? If true, return the root DOM element (i.e. `object[0]`). - # 5. Has a view provider been registered for the object? If true, use the - # provider to create a view associated with the object, and return the - # view. - # - # If no associated view is returned by the sequence an error is thrown. - # - # Returns a DOM element. - getView: (object) -> - return unless object? - - if view = @views.get(object) - view - else - view = @createView(object) - @views.set(object, view) - view - - createView: (object) -> - if object instanceof HTMLElement - return object - - if typeof object?.getElement is 'function' - element = object.getElement() - if element instanceof HTMLElement - return element - - if object?.element instanceof HTMLElement - return object.element - - if object?.jquery - return object[0] - - for provider in @providers - if provider.modelConstructor is AnyConstructor - if element = provider.createView(object, @atomEnvironment) - return element - continue - - if object instanceof provider.modelConstructor - if element = provider.createView?(object, @atomEnvironment) - return element - - if viewConstructor = provider.viewConstructor - element = new viewConstructor - element.initialize?(object) ? element.setModel?(object) - return element - - if viewConstructor = object?.getViewClass?() - view = new viewConstructor(object) - return view[0] - - throw new Error("Can't create a view for #{object.constructor.name} instance. Please register a view provider.") - - updateDocument: (fn) -> - @documentWriters.push(fn) - @requestDocumentUpdate() unless @documentReadInProgress - new Disposable => - @documentWriters = @documentWriters.filter (writer) -> writer isnt fn - - readDocument: (fn) -> - @documentReaders.push(fn) - @requestDocumentUpdate() - new Disposable => - @documentReaders = @documentReaders.filter (reader) -> reader isnt fn - - getNextUpdatePromise: -> - @nextUpdatePromise ?= new Promise (resolve) => - @resolveNextUpdatePromise = resolve - - clearDocumentRequests: -> - @documentReaders = [] - @documentWriters = [] - @nextUpdatePromise = null - @resolveNextUpdatePromise = null - if @animationFrameRequest? - cancelAnimationFrame(@animationFrameRequest) - @animationFrameRequest = null - - requestDocumentUpdate: -> - @animationFrameRequest ?= requestAnimationFrame(@performDocumentUpdate) - - performDocumentUpdate: => - resolveNextUpdatePromise = @resolveNextUpdatePromise - @animationFrameRequest = null - @nextUpdatePromise = null - @resolveNextUpdatePromise = null - - writer() while writer = @documentWriters.shift() - - @documentReadInProgress = true - reader() while reader = @documentReaders.shift() - @documentReadInProgress = false - - # process updates requested as a result of reads - writer() while writer = @documentWriters.shift() - - resolveNextUpdatePromise?() diff --git a/src/view-registry.js b/src/view-registry.js new file mode 100644 index 000000000..87bf8620f --- /dev/null +++ b/src/view-registry.js @@ -0,0 +1,259 @@ +const Grim = require('grim') +const {Disposable} = require('event-kit') + +const AnyConstructor = Symbol('any-constructor') + +// Essential: `ViewRegistry` handles the association between model and view +// types in Atom. We call this association a View Provider. As in, for a given +// model, this class can provide a view via {::getView}, as long as the +// model/view association was registered via {::addViewProvider} +// +// If you're adding your own kind of pane item, a good strategy for all but the +// simplest items is to separate the model and the view. The model handles +// application logic and is the primary point of API interaction. The view +// just handles presentation. +// +// Note: Models can be any object, but must implement a `getTitle()` function +// if they are to be displayed in a {Pane} +// +// View providers inform the workspace how your model objects should be +// presented in the DOM. A view provider must always return a DOM node, which +// makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) +// an ideal tool for implementing views in Atom. +// +// You can access the `ViewRegistry` object via `atom.views`. +module.exports = +class ViewRegistry { + constructor (atomEnvironment) { + this.animationFrameRequest = null + this.documentReadInProgress = false + this.performDocumentUpdate = this.performDocumentUpdate.bind(this) + this.atomEnvironment = atomEnvironment + this.clear() + } + + clear () { + this.views = new WeakMap() + this.providers = [] + this.clearDocumentRequests() + } + + // Essential: Add a provider that will be used to construct views in the + // workspace's view layer based on model objects in its model layer. + // + // ## Examples + // + // Text editors are divided into a model and a view layer, so when you interact + // with methods like `atom.workspace.getActiveTextEditor()` you're only going + // to get the model object. We display text editors on screen by teaching the + // workspace what view constructor it should use to represent them: + // + // ```coffee + // atom.views.addViewProvider TextEditor, (textEditor) -> + // textEditorElement = new TextEditorElement + // textEditorElement.initialize(textEditor) + // textEditorElement + // ``` + // + // * `modelConstructor` (optional) Constructor {Function} for your model. If + // a constructor is given, the `createView` function will only be used + // for model objects inheriting from that constructor. Otherwise, it will + // will be called for any object. + // * `createView` Factory {Function} that is passed an instance of your model + // and must return a subclass of `HTMLElement` or `undefined`. If it returns + // `undefined`, then the registry will continue to search for other view + // providers. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // added provider. + addViewProvider (modelConstructor, createView) { + let provider + if (arguments.length === 1) { + switch (typeof modelConstructor) { + case 'function': + provider = {createView: modelConstructor, modelConstructor: AnyConstructor} + break + case 'object': + Grim.deprecate('atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.') + provider = modelConstructor + break + default: + throw new TypeError('Arguments to addViewProvider must be functions') + } + } else { + provider = {modelConstructor, createView} + } + + this.providers.push(provider) + return new Disposable(() => { + this.providers = this.providers.filter(p => p !== provider) + }) + } + + getViewProviderCount () { + return this.providers.length + } + + // Essential: Get the view associated with an object in the workspace. + // + // If you're just *using* the workspace, you shouldn't need to access the view + // layer, but view layer access may be necessary if you want to perform DOM + // manipulation that isn't supported via the model API. + // + // ## View Resolution Algorithm + // + // The view associated with the object is resolved using the following + // sequence + // + // 1. Is the object an instance of `HTMLElement`? If true, return the object. + // 2. Does the object have a method named `getElement` that returns an + // instance of `HTMLElement`? If true, return that value. + // 3. Does the object have a property named `element` with a value which is + // an instance of `HTMLElement`? If true, return the property value. + // 4. Is the object a jQuery object, indicated by the presence of a `jquery` + // property? If true, return the root DOM element (i.e. `object[0]`). + // 5. Has a view provider been registered for the object? If true, use the + // provider to create a view associated with the object, and return the + // view. + // + // If no associated view is returned by the sequence an error is thrown. + // + // Returns a DOM element. + getView (object) { + if (object == null) { return } + + let view = this.views.get(object) + if (!view) { + view = this.createView(object) + this.views.set(object, view) + } + return view + } + + createView (object) { + if (object instanceof HTMLElement) { return object } + + let element + if (object && (typeof object.getElement === 'function')) { + element = object.getElement() + if (element instanceof HTMLElement) { + return element + } + } + + if (object && object.element instanceof HTMLElement) { + return object.element + } + + if (object && object.jquery) { + return object[0] + } + + for (let provider of this.providers) { + if (provider.modelConstructor === AnyConstructor) { + element = provider.createView(object, this.atomEnvironment) + if (element) { return element } + continue + } + + if (object instanceof provider.modelConstructor) { + element = provider.createView && provider.createView(object, this.atomEnvironment) + if (element) { return element } + + let ViewConstructor = provider.viewConstructor + if (ViewConstructor) { + element = new ViewConstructor() + if (element.initialize) { + element.initialize(object) + } else if (element.setModel) { + element.setModel(object) + } + return element + } + } + } + + if (object && object.getViewClass) { + let ViewConstructor = object.getViewClass() + if (ViewConstructor) { + const view = new ViewConstructor(object) + return view[0] + } + } + + throw new Error(`Can't create a view for ${object.constructor.name} instance. Please register a view provider.`) + } + + updateDocument (fn) { + this.documentWriters.push(fn) + if (!this.documentReadInProgress) { this.requestDocumentUpdate() } + return new Disposable(() => { + this.documentWriters = this.documentWriters.filter(writer => writer !== fn) + }) + } + + readDocument (fn) { + this.documentReaders.push(fn) + this.requestDocumentUpdate() + return new Disposable(() => { + this.documentReaders = this.documentReaders.filter(reader => reader !== fn) + }) + } + + getNextUpdatePromise () { + if (this.nextUpdatePromise == null) { + this.nextUpdatePromise = new Promise(resolve => { + this.resolveNextUpdatePromise = resolve + }) + } + + return this.nextUpdatePromise + } + + clearDocumentRequests () { + this.documentReaders = [] + this.documentWriters = [] + this.nextUpdatePromise = null + this.resolveNextUpdatePromise = null + if (this.animationFrameRequest != null) { + cancelAnimationFrame(this.animationFrameRequest) + this.animationFrameRequest = null + } + } + + requestDocumentUpdate () { + if (this.animationFrameRequest == null) { + this.animationFrameRequest = requestAnimationFrame(this.performDocumentUpdate) + } + } + + performDocumentUpdate () { + const { resolveNextUpdatePromise } = this + this.animationFrameRequest = null + this.nextUpdatePromise = null + this.resolveNextUpdatePromise = null + + var writer = this.documentWriters.shift() + while (writer) { + writer() + writer = this.documentWriters.shift() + } + + var reader = this.documentReaders.shift() + this.documentReadInProgress = true + while (reader) { + reader() + reader = this.documentReaders.shift() + } + this.documentReadInProgress = false + + // process updates requested as a result of reads + writer = this.documentWriters.shift() + while (writer) { + writer() + writer = this.documentWriters.shift() + } + + if (resolveNextUpdatePromise) { resolveNextUpdatePromise() } + } +} diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee deleted file mode 100644 index 6a277b612..000000000 --- a/src/window-event-handler.coffee +++ /dev/null @@ -1,189 +0,0 @@ -{Disposable, CompositeDisposable} = require 'event-kit' -listen = require './delegated-listener' - -# Handles low-level events related to the @window. -module.exports = -class WindowEventHandler - constructor: ({@atomEnvironment, @applicationDelegate}) -> - @reloadRequested = false - @subscriptions = new CompositeDisposable - - @handleNativeKeybindings() - - initialize: (@window, @document) -> - @subscriptions.add @atomEnvironment.commands.add @window, - 'window:toggle-full-screen': @handleWindowToggleFullScreen - 'window:close': @handleWindowClose - 'window:reload': @handleWindowReload - 'window:toggle-dev-tools': @handleWindowToggleDevTools - - if process.platform in ['win32', 'linux'] - @subscriptions.add @atomEnvironment.commands.add @window, - 'window:toggle-menu-bar': @handleWindowToggleMenuBar - - @subscriptions.add @atomEnvironment.commands.add @document, - 'core:focus-next': @handleFocusNext - 'core:focus-previous': @handleFocusPrevious - - @addEventListener(@window, 'beforeunload', @handleWindowBeforeunload) - @addEventListener(@window, 'focus', @handleWindowFocus) - @addEventListener(@window, 'blur', @handleWindowBlur) - - @addEventListener(@document, 'keyup', @handleDocumentKeyEvent) - @addEventListener(@document, 'keydown', @handleDocumentKeyEvent) - @addEventListener(@document, 'drop', @handleDocumentDrop) - @addEventListener(@document, 'dragover', @handleDocumentDragover) - @addEventListener(@document, 'contextmenu', @handleDocumentContextmenu) - @subscriptions.add listen(@document, 'click', 'a', @handleLinkClick) - @subscriptions.add listen(@document, 'submit', 'form', @handleFormSubmit) - - @subscriptions.add(@applicationDelegate.onDidEnterFullScreen(@handleEnterFullScreen)) - @subscriptions.add(@applicationDelegate.onDidLeaveFullScreen(@handleLeaveFullScreen)) - - # Wire commands that should be handled by Chromium for elements with the - # `.native-key-bindings` class. - handleNativeKeybindings: -> - bindCommandToAction = (command, action) => - @subscriptions.add @atomEnvironment.commands.add( - '.native-key-bindings', - command, - ((event) => @applicationDelegate.getCurrentWindow().webContents[action]()), - false - ) - - bindCommandToAction('core:copy', 'copy') - bindCommandToAction('core:paste', 'paste') - bindCommandToAction('core:undo', 'undo') - bindCommandToAction('core:redo', 'redo') - bindCommandToAction('core:select-all', 'selectAll') - bindCommandToAction('core:cut', 'cut') - - unsubscribe: -> - @subscriptions.dispose() - - on: (target, eventName, handler) -> - target.on(eventName, handler) - @subscriptions.add(new Disposable -> - target.removeListener(eventName, handler) - ) - - addEventListener: (target, eventName, handler) -> - target.addEventListener(eventName, handler) - @subscriptions.add(new Disposable(-> target.removeEventListener(eventName, handler))) - - handleDocumentKeyEvent: (event) => - @atomEnvironment.keymaps.handleKeyboardEvent(event) - event.stopImmediatePropagation() - - handleDrop: (event) -> - event.preventDefault() - event.stopPropagation() - - handleDragover: (event) -> - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'none' - - eachTabIndexedElement: (callback) -> - for element in @document.querySelectorAll('[tabindex]') - continue if element.disabled - continue unless element.tabIndex >= 0 - callback(element, element.tabIndex) - return - - handleFocusNext: => - focusedTabIndex = @document.activeElement.tabIndex ? -Infinity - - nextElement = null - nextTabIndex = Infinity - lowestElement = null - lowestTabIndex = Infinity - @eachTabIndexedElement (element, tabIndex) -> - if tabIndex < lowestTabIndex - lowestTabIndex = tabIndex - lowestElement = element - - if focusedTabIndex < tabIndex < nextTabIndex - nextTabIndex = tabIndex - nextElement = element - - if nextElement? - nextElement.focus() - else if lowestElement? - lowestElement.focus() - - handleFocusPrevious: => - focusedTabIndex = @document.activeElement.tabIndex ? Infinity - - previousElement = null - previousTabIndex = -Infinity - highestElement = null - highestTabIndex = -Infinity - @eachTabIndexedElement (element, tabIndex) -> - if tabIndex > highestTabIndex - highestTabIndex = tabIndex - highestElement = element - - if focusedTabIndex > tabIndex > previousTabIndex - previousTabIndex = tabIndex - previousElement = element - - if previousElement? - previousElement.focus() - else if highestElement? - highestElement.focus() - - handleWindowFocus: -> - @document.body.classList.remove('is-blurred') - - handleWindowBlur: => - @document.body.classList.add('is-blurred') - @atomEnvironment.storeWindowDimensions() - - handleEnterFullScreen: => - @document.body.classList.add("fullscreen") - - handleLeaveFullScreen: => - @document.body.classList.remove("fullscreen") - - handleWindowBeforeunload: (event) => - if not @reloadRequested and not @atomEnvironment.inSpecMode() and @atomEnvironment.getCurrentWindow().isWebViewFocused() - @atomEnvironment.hide() - @reloadRequested = false - @atomEnvironment.storeWindowDimensions() - @atomEnvironment.unloadEditorWindow() - @atomEnvironment.destroy() - - handleWindowToggleFullScreen: => - @atomEnvironment.toggleFullScreen() - - handleWindowClose: => - @atomEnvironment.close() - - handleWindowReload: => - @reloadRequested = true - @atomEnvironment.reload() - - handleWindowToggleDevTools: => - @atomEnvironment.toggleDevTools() - - handleWindowToggleMenuBar: => - @atomEnvironment.config.set('core.autoHideMenuBar', not @atomEnvironment.config.get('core.autoHideMenuBar')) - - if @atomEnvironment.config.get('core.autoHideMenuBar') - detail = "To toggle, press the Alt key or execute the window:toggle-menu-bar command" - @atomEnvironment.notifications.addInfo('Menu bar hidden', {detail}) - - handleLinkClick: (event) => - event.preventDefault() - uri = event.currentTarget?.getAttribute('href') - if uri and uri[0] isnt '#' and /^https?:\/\//.test(uri) - @applicationDelegate.openExternal(uri) - - handleFormSubmit: (event) -> - # Prevent form submits from changing the current window's URL - event.preventDefault() - - handleDocumentContextmenu: (event) => - event.preventDefault() - @atomEnvironment.contextMenu.showForEvent(event) diff --git a/src/window-event-handler.js b/src/window-event-handler.js new file mode 100644 index 000000000..6d380819b --- /dev/null +++ b/src/window-event-handler.js @@ -0,0 +1,253 @@ +const {Disposable, CompositeDisposable} = require('event-kit') +const listen = require('./delegated-listener') + +// Handles low-level events related to the `window`. +module.exports = +class WindowEventHandler { + constructor ({atomEnvironment, applicationDelegate}) { + this.handleDocumentKeyEvent = this.handleDocumentKeyEvent.bind(this) + this.handleFocusNext = this.handleFocusNext.bind(this) + this.handleFocusPrevious = this.handleFocusPrevious.bind(this) + this.handleWindowBlur = this.handleWindowBlur.bind(this) + this.handleEnterFullScreen = this.handleEnterFullScreen.bind(this) + this.handleLeaveFullScreen = this.handleLeaveFullScreen.bind(this) + this.handleWindowBeforeunload = this.handleWindowBeforeunload.bind(this) + this.handleWindowToggleFullScreen = this.handleWindowToggleFullScreen.bind(this) + this.handleWindowClose = this.handleWindowClose.bind(this) + this.handleWindowReload = this.handleWindowReload.bind(this) + this.handleWindowToggleDevTools = this.handleWindowToggleDevTools.bind(this) + this.handleWindowToggleMenuBar = this.handleWindowToggleMenuBar.bind(this) + this.handleLinkClick = this.handleLinkClick.bind(this) + this.handleDocumentContextmenu = this.handleDocumentContextmenu.bind(this) + this.atomEnvironment = atomEnvironment + this.applicationDelegate = applicationDelegate + this.reloadRequested = false + this.subscriptions = new CompositeDisposable() + + this.handleNativeKeybindings() + } + + initialize (window, document) { + this.window = window + this.document = document + this.subscriptions.add(this.atomEnvironment.commands.add(this.window, { + 'window:toggle-full-screen': this.handleWindowToggleFullScreen, + 'window:close': this.handleWindowClose, + 'window:reload': this.handleWindowReload, + 'window:toggle-dev-tools': this.handleWindowToggleDevTools + })) + + if (['win32', 'linux'].includes(process.platform)) { + this.subscriptions.add(this.atomEnvironment.commands.add(this.window, + {'window:toggle-menu-bar': this.handleWindowToggleMenuBar}) + ) + } + + this.subscriptions.add(this.atomEnvironment.commands.add(this.document, { + 'core:focus-next': this.handleFocusNext, + 'core:focus-previous': this.handleFocusPrevious + })) + + this.addEventListener(this.window, 'beforeunload', this.handleWindowBeforeunload) + this.addEventListener(this.window, 'focus', this.handleWindowFocus) + this.addEventListener(this.window, 'blur', this.handleWindowBlur) + + this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent) + this.addEventListener(this.document, 'keydown', this.handleDocumentKeyEvent) + this.addEventListener(this.document, 'drop', this.handleDocumentDrop) + this.addEventListener(this.document, 'dragover', this.handleDocumentDragover) + this.addEventListener(this.document, 'contextmenu', this.handleDocumentContextmenu) + this.subscriptions.add(listen(this.document, 'click', 'a', this.handleLinkClick)) + this.subscriptions.add(listen(this.document, 'submit', 'form', this.handleFormSubmit)) + + this.subscriptions.add(this.applicationDelegate.onDidEnterFullScreen(this.handleEnterFullScreen)) + this.subscriptions.add(this.applicationDelegate.onDidLeaveFullScreen(this.handleLeaveFullScreen)) + } + + // Wire commands that should be handled by Chromium for elements with the + // `.native-key-bindings` class. + handleNativeKeybindings () { + const bindCommandToAction = (command, action) => { + this.subscriptions.add( + this.atomEnvironment.commands.add( + '.native-key-bindings', + command, + event => this.applicationDelegate.getCurrentWindow().webContents[action](), + false + ) + ) + } + + bindCommandToAction('core:copy', 'copy') + bindCommandToAction('core:paste', 'paste') + bindCommandToAction('core:undo', 'undo') + bindCommandToAction('core:redo', 'redo') + bindCommandToAction('core:select-all', 'selectAll') + bindCommandToAction('core:cut', 'cut') + } + + unsubscribe () { + this.subscriptions.dispose() + } + + on (target, eventName, handler) { + target.on(eventName, handler) + this.subscriptions.add(new Disposable(function () { + target.removeListener(eventName, handler) + })) + } + + addEventListener (target, eventName, handler) { + target.addEventListener(eventName, handler) + this.subscriptions.add(new Disposable(function () { + target.removeEventListener(eventName, handler) + })) + } + + handleDocumentKeyEvent (event) { + this.atomEnvironment.keymaps.handleKeyboardEvent(event) + event.stopImmediatePropagation() + } + + handleDrop (event) { + event.preventDefault() + event.stopPropagation() + } + + handleDragover (event) { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'none' + } + + eachTabIndexedElement (callback) { + for (let element of this.document.querySelectorAll('[tabindex]')) { + if (element.disabled) { continue } + if (!(element.tabIndex >= 0)) { continue } + callback(element, element.tabIndex) + } + } + + handleFocusNext () { + const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : -Infinity + + let nextElement = null + let nextTabIndex = Infinity + let lowestElement = null + let lowestTabIndex = Infinity + this.eachTabIndexedElement(function (element, tabIndex) { + if (tabIndex < lowestTabIndex) { + lowestTabIndex = tabIndex + lowestElement = element + } + + if (focusedTabIndex < tabIndex && tabIndex < nextTabIndex) { + nextTabIndex = tabIndex + nextElement = element + } + }) + + if (nextElement != null) { + nextElement.focus() + } else if (lowestElement != null) { + lowestElement.focus() + } + } + + handleFocusPrevious () { + const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : Infinity + + let previousElement = null + let previousTabIndex = -Infinity + let highestElement = null + let highestTabIndex = -Infinity + this.eachTabIndexedElement(function (element, tabIndex) { + if (tabIndex > highestTabIndex) { + highestTabIndex = tabIndex + highestElement = element + } + + if (focusedTabIndex > tabIndex && tabIndex > previousTabIndex) { + previousTabIndex = tabIndex + previousElement = element + } + }) + + if (previousElement != null) { + previousElement.focus() + } else if (highestElement != null) { + highestElement.focus() + } + } + + handleWindowFocus () { + this.document.body.classList.remove('is-blurred') + } + + handleWindowBlur () { + this.document.body.classList.add('is-blurred') + this.atomEnvironment.storeWindowDimensions() + } + + handleEnterFullScreen () { + this.document.body.classList.add('fullscreen') + } + + handleLeaveFullScreen () { + this.document.body.classList.remove('fullscreen') + } + + handleWindowBeforeunload (event) { + if (!this.reloadRequested && !this.atomEnvironment.inSpecMode() && this.atomEnvironment.getCurrentWindow().isWebViewFocused()) { + this.atomEnvironment.hide() + } + this.reloadRequested = false + this.atomEnvironment.storeWindowDimensions() + this.atomEnvironment.unloadEditorWindow() + this.atomEnvironment.destroy() + } + + handleWindowToggleFullScreen () { + this.atomEnvironment.toggleFullScreen() + } + + handleWindowClose () { + this.atomEnvironment.close() + } + + handleWindowReload () { + this.reloadRequested = true + this.atomEnvironment.reload() + } + + handleWindowToggleDevTools () { + this.atomEnvironment.toggleDevTools() + } + + handleWindowToggleMenuBar () { + this.atomEnvironment.config.set('core.autoHideMenuBar', !this.atomEnvironment.config.get('core.autoHideMenuBar')) + + if (this.atomEnvironment.config.get('core.autoHideMenuBar')) { + const detail = 'To toggle, press the Alt key or execute the window:toggle-menu-bar command' + this.atomEnvironment.notifications.addInfo('Menu bar hidden', {detail}) + } + } + + handleLinkClick (event) { + event.preventDefault() + const uri = event.currentTarget && event.currentTarget.getAttribute('href') + if (uri && (uri[0] !== '#') && /^https?:\/\//.test(uri)) { + this.applicationDelegate.openExternal(uri) + } + } + + handleFormSubmit (event) { + // Prevent form submits from changing the current window's URL + event.preventDefault() + } + + handleDocumentContextmenu (event) { + event.preventDefault() + this.atomEnvironment.contextMenu.showForEvent(event) + } +} diff --git a/src/workspace.js b/src/workspace.js index 80dfc47cb..defb43df0 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -659,7 +659,7 @@ module.exports = class Workspace extends Model { // changing or closing tabs and ensures critical UI feedback, like changing the // highlighted tab, gets priority over work that can be done asynchronously. // - // * `callback` {Function} to be called when the active pane item stopts + // * `callback` {Function} to be called when the active pane item stops // changing. // * `item` The active pane item. // @@ -1050,10 +1050,10 @@ module.exports = class Workspace extends Model { // Essential: Search the workspace for items matching the given URI and hide them. // - // * `itemOrURI` (optional) The item to hide or a {String} containing the URI + // * `itemOrURI` The item to hide or a {String} containing the URI // of the item to hide. // - // Returns a {boolean} indicating whether any items were found (and hidden). + // Returns a {Boolean} indicating whether any items were found (and hidden). hide (itemOrURI) { let foundItems = false diff --git a/static/jasmine.less b/static/jasmine.less index ab2695179..dcd467c71 100644 --- a/static/jasmine.less +++ b/static/jasmine.less @@ -165,6 +165,7 @@ body { font-weight: bold; color: #d9534f; padding: 5px 0 5px 0; + white-space: pre-wrap; } .result-message.deprecation-message {