diff --git a/.coffeelintignore b/.coffeelintignore new file mode 100644 index 000000000..5c4e30278 --- /dev/null +++ b/.coffeelintignore @@ -0,0 +1,2 @@ +spec/fixtures +benchmark/fixtures diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..ab65307ae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,36 @@ +git: + depth: 10 + +env: + global: + - ATOM_ACCESS_TOKEN=da809a6077bb1b0aa7c5623f7b2d5f1fec2faae4 + + matrix: + - NODE_VERSION=0.12 + +os: + - linux + - osx + +sudo: false + +install: + - git clone https://github.com/creationix/nvm.git /tmp/.nvm + - source /tmp/.nvm/nvm.sh + - nvm install $NODE_VERSION + - nvm use $NODE_VERSION + +script: script/cibuild + +notifications: + email: + on_success: never + on_failure: change + +addons: + apt: + packages: + - build-essential + - git + - libgnome-keyring-dev + - fakeroot diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15e42f708..333eb0913 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,14 +4,15 @@ The following is a set of guidelines for contributing to Atom and its packages, which are hosted in the [Atom Organization](https://github.com/atom) on GitHub. -If you're unsure which package is causing your problem or if you're having an -issue with Atom core, please open an issue on the [main atom repository](https://github.com/atom/atom/issues). -These are just guidelines, not rules, use your best judgement and feel free to +These are just guidelines, not rules, use your best judgment and feel free to propose changes to this document in a pull request. ## Submitting Issues -* Check the [debugging guide](https://atom.io/docs/latest/debugging) for tips +* You can create an issue [here](https://github.com/atom/atom/issues/new), but + before doing that please read the notes below on debugging and submitting issues, + and include as many details as possible with your report. +* Check the [debugging guide](https://atom.io/docs/latest/hacking-atom-debugging) for tips on debugging. You might be able to find the cause of the problem and fix things yourself. * Include the version of Atom you are using and the OS. @@ -24,7 +25,8 @@ propose changes to this document in a pull request. will be logged. If you can reproduce the error, use this approach to get the full stack trace and include it in the issue. * On Mac, check Console.app for stack traces to include if reporting a crash. -* Perform a cursory search to see if a similar issue has already been submitted. +* Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Aatom) + to see if a similar issue has already been submitted. * Please setup a [profile picture](https://help.github.com/articles/how-do-i-set-up-my-profile-picture) to make yourself recognizable and so we can all get to know each other better. @@ -38,8 +40,12 @@ many packages and themes that are stored in other repos under the [language-javascript](https://github.com/atom/language-javascript), and [atom-light-ui](https://github.com/atom/atom-light-ui). +If your issue is related to a specific package, open an issue on that package's +issue tracker. If you're unsure which package is causing your problem or if +you're having an issue with Atom core, open an issue on this repository. + For more information on how to work with Atom's official packages, see -[Contributing to Atom Packages](https://atom.io/docs/latest/contributing-to-packages.html) +[Contributing to Atom Packages](https://github.com/atom/atom/blob/master/docs/contributing-to-packages.md) ## Pull Requests @@ -48,7 +54,7 @@ For more information on how to work with Atom's official packages, see [JavaScript](https://github.com/styleguide/javascript), and [CSS](https://github.com/styleguide/css) styleguides. * Include thoughtfully-worded, well-structured - [Jasmine](http://jasmine.github.io/) specs. + [Jasmine](http://jasmine.github.io/) specs in the `./spec` folder. Run them using `apm test`. See the [Specs Styleguide](#specs-styleguide) below. * Document new code based on the [Documentation Styleguide](#documentation-styleguide) * End files with a newline. @@ -60,7 +66,7 @@ For more information on how to work with Atom's official packages, see * Class methods and properties (methods starting with a `@`) * Instance methods and properties * Avoid platform-dependent code: - * Use `require('atom').fs.getHomeDirectory()` to get the home directory. + * Use `require('fs-plus').getHomeDirectory()` to get the home directory. * Use `path.join()` to concatenate filenames. * Use `os.tmpdir()` rather than `/tmp` when you need to reference the temporary directory. @@ -94,6 +100,9 @@ For more information on how to work with Atom's official packages, see * Set parameter defaults without spaces around the equal sign * `clear = (count=1) ->` instead of `clear = (count = 1) ->` +* Use spaces around operators + * `count + 1` instead of `count+1` +* Use spaces after commas (unless separated by newlines) * Use parentheses if it improves code clarity. * Prefer alphabetic keywords to symbolic keywords: * `a is b` instead of `a == b` @@ -104,6 +113,29 @@ For more information on how to work with Atom's official packages, see should be lower-case: * `getURI` instead of `getUri` * `uriToOpen` instead of `URIToOpen` +* Use `slice()` to copy an array +* Add an explicit `return` when your function ends with a `for`/`while` loop and + you don't want it to return a collected array. +* Use `this` instead of a standalone `@` + * `return this` instead of `return @` + +## Specs Styleguide + +- Include thoughtfully-worded, well-structured + [Jasmine](http://jasmine.github.io/) specs in the `./spec` folder. +- treat `describe` as a noun or situation. +- treat `it` as a statement about state or how an operation changes state. + +### Example + +```coffee +describe 'a dog', -> + it 'barks', -> + # spec here + describe 'when the dog is happy', -> + it 'wags its tail', -> + # spec here +``` ## Documentation Styleguide diff --git a/Dockerfile b/Dockerfile index 76fa18eae..d792c30c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # DESCRIPTION: Image to build Atom and create a .rpm file # Base docker image -FROM fedora:20 +FROM fedora:21 # Install dependencies RUN yum install -y \ @@ -12,11 +12,11 @@ RUN yum install -y \ glibc-devel \ git-core \ libgnome-keyring-devel \ - rpmdevtools + rpmdevtools \ + nodejs \ + npm -# Install node -RUN curl -sL https://rpm.nodesource.com/setup | bash - -RUN yum install -y nodejs +RUN npm install -g npm@1.4.28 --loglevel error ADD . /atom WORKDIR /atom diff --git a/LICENSE.md b/LICENSE.md index 4d231b456..bbb875dc2 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2014 GitHub Inc. +Copyright (c) 2015 GitHub Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 7ebdf9252..13f7374f2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ ![Atom](https://cloud.githubusercontent.com/assets/72919/2874231/3af1db48-d3dd-11e3-98dc-6066f8bc766f.png) -Atom is a hackable text editor for the 21st century, built on [atom-shell](https://github.com/atom/atom-shell), and based on everything we love about our favorite editors. We designed it to be deeply customizable, but still approachable using the default configuration. +[![Build Status](https://travis-ci.org/atom/atom.svg?branch=master)](https://travis-ci.org/atom/atom) +[![Dependency Status](https://david-dm.org/atom/atom.svg)](https://david-dm.org/atom/atom) +[![Join the Atom Community on Slack](http://atom-slack.herokuapp.com/badge.svg)](http://atom-slack.herokuapp.com/) + +Atom is a hackable text editor for the 21st century, built on [Electron](https://github.com/atom/electron), and based on everything we love about our favorite editors. We designed it to be deeply customizable, but still approachable using the default configuration. Visit [atom.io](https://atom.io) to learn more or visit the [Atom forum](https://discuss.atom.io). -Visit [issue #3684](https://github.com/atom/atom/issues/3684) to learn more -about the Atom 1.0 roadmap. +Follow [@AtomEditor](https://twitter.com/atomeditor) on Twitter for important +announcements. + +## Documentation + +If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](https://atom.io/docs/latest/) is free and available online, along with ePub, PDF and mobi versions. You can find the source to the manual in [atom/docs](https://github.com/atom/docs). + +The [API reference](https://atom.io/docs/api) for developing packages is also documented on Atom.io. ## Installing @@ -38,7 +48,7 @@ Currently only a 64-bit version is available. The Linux version does not currently automatically update so you will need to repeat these steps to upgrade to future releases. -### Red Hat Linux (Fedora, CentOS, Red Hat) +### Red Hat Linux (Fedora 21 and under, CentOS, Red Hat) Currently only a 64-bit version is available. @@ -49,13 +59,20 @@ Currently only a 64-bit version is available. The Linux version does not currently automatically update so you will need to repeat these steps to upgrade to future releases. +### Fedora 22+ + +Currently only a 64-bit version is available. + +1. Download `atom.x86_64.rpm` from the [Atom releases page](https://github.com/atom/atom/releases/latest). +2. Run `sudo dnf install atom.x86_64.rpm` on the downloaded package. +3. Launch Atom using the installed `atom` command. + +The Linux version does not currently automatically update so you will need to +repeat these steps to upgrade to future releases. + ## Building * [Linux](docs/build-instructions/linux.md) * [OS X](docs/build-instructions/os-x.md) * [FreeBSD](docs/build-instructions/freebsd.md) * [Windows](docs/build-instructions/windows.md) - -## Developing - -Check out the [guides](https://atom.io/docs/latest) and the [API reference](https://atom.io/docs/api). diff --git a/apm/package.json b/apm/package.json index f735cb6f4..d1f210f29 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "0.141.0" + "atom-package-manager": "1.0.1" } } diff --git a/benchmark/benchmark-helper.coffee b/benchmark/benchmark-helper.coffee index ae6bab740..d831572c5 100644 --- a/benchmark/benchmark-helper.coffee +++ b/benchmark/benchmark-helper.coffee @@ -29,7 +29,7 @@ window.benchmark = (args...) -> else count = defaultCount [fn, options] = args - { profile, focused } = (options ? {}) + {profile, focused} = (options ? {}) method = if focused then fit else it method description, -> @@ -69,7 +69,7 @@ window.keyIdentifierForKey = (key) -> "U+00" + charCode.toString(16) window.keydownEvent = (key, properties={}) -> - $.Event "keydown", _.extend({originalEvent: { keyIdentifier: keyIdentifierForKey(key) }}, properties) + $.Event "keydown", _.extend({originalEvent: {keyIdentifier: keyIdentifierForKey(key)}}, properties) window.clickEvent = (properties={}) -> $.Event "click", properties @@ -93,7 +93,7 @@ window.pagePixelPositionForPoint = (editorView, point) -> point = Point.fromObject point top = editorView.lines.offset().top + point.row * editorView.lineHeight left = editorView.lines.offset().left + point.column * editorView.charWidth - editorView.lines.scrollLeft() - { top, left } + {top, left} window.seteditorViewWidthInChars = (editorView, widthInChars, charWidth=editorView.charWidth) -> editorView.width(charWidth * widthInChars + editorView.lines.position().left) diff --git a/benchmark/benchmark-suite.coffee b/benchmark/benchmark-suite.coffee index 7a17f22c2..7b06d9758 100644 --- a/benchmark/benchmark-suite.coffee +++ b/benchmark/benchmark-suite.coffee @@ -213,7 +213,7 @@ describe "TokenizedBuffer.", -> beforeEach -> editor = benchmarkFixturesProject.openSync('medium.coffee') - { languageMode, buffer } = editor + {languageMode, buffer} = editor benchmark "construction", 20, -> - new TokenizedBuffer(buffer, { languageMode, tabLength: 2}) + new TokenizedBuffer(buffer, {languageMode, tabLength: 2}) diff --git a/benchmark/browser-process-startup.coffee b/benchmark/browser-process-startup.coffee index 06f2a0d48..2b06eaaa4 100755 --- a/benchmark/browser-process-startup.coffee +++ b/benchmark/browser-process-startup.coffee @@ -8,7 +8,7 @@ _ = require 'underscore-plus' temp = require 'temp' directoryToOpen = temp.mkdirSync('browser-process-startup-') -socketPath = path.join(os.tmpdir(), 'atom.sock') +socketPath = path.join(os.tmpdir(), "atom-#{process.env.USER}.sock") numberOfRuns = 10 deleteSocketFile = -> diff --git a/.npmrc b/build/.npmrc similarity index 100% rename from .npmrc rename to build/.npmrc diff --git a/build/Gruntfile.coffee b/build/Gruntfile.coffee index 05efaeb38..b48c7a773 100644 --- a/build/Gruntfile.coffee +++ b/build/Gruntfile.coffee @@ -69,6 +69,8 @@ module.exports = (grunt) -> expand: true src: [ 'src/**/*.coffee' + 'spec/*.coffee' + '!spec/*-spec.coffee' 'exports/**/*.coffee' 'static/**/*.coffee' ] @@ -92,7 +94,7 @@ module.exports = (grunt) -> prebuildLessConfig = src: [ 'static/**/*.less' - 'node_modules/bootstrap/less/bootstrap.less' + 'node_modules/atom-space-pen-views/stylesheets/**/*.less' ] csonConfig = @@ -125,10 +127,19 @@ module.exports = (grunt) -> {engines, theme} = grunt.file.readJSON(metadataPath) if engines?.atom? coffeeConfig.glob_to_multiple.src.push("#{directory}/**/*.coffee") + coffeeConfig.glob_to_multiple.src.push("!#{directory}/spec/**/*.coffee") + lessConfig.glob_to_multiple.src.push("#{directory}/**/*.less") - prebuildLessConfig.src.push("#{directory}/**/*.less") unless theme + lessConfig.glob_to_multiple.src.push("!#{directory}/spec/**/*.less") + + unless theme + prebuildLessConfig.src.push("#{directory}/**/*.less") + prebuildLessConfig.src.push("!#{directory}/spec/**/*.less") + csonConfig.glob_to_multiple.src.push("#{directory}/**/*.cson") - pegConfig.glob_to_multiple.src.push("#{directory}/**/*.pegjs") + csonConfig.glob_to_multiple.src.push("!#{directory}/spec/**/*.cson") + + pegConfig.glob_to_multiple.src.push("#{directory}/lib/*.pegjs") grunt.initConfig pkg: grunt.file.readJSON('package.json') @@ -223,14 +234,14 @@ module.exports = (grunt) -> ciTasks = ['output-disk-space', 'download-atom-shell', 'download-atom-shell-chromedriver', 'build'] ciTasks.push('dump-symbols') if process.platform isnt 'win32' - ciTasks.push('set-version', 'check-licenses', 'lint') + ciTasks.push('set-version', 'check-licenses', 'lint', 'generate-asar') ciTasks.push('mkdeb') if process.platform is 'linux' ciTasks.push('create-windows-installer') if process.platform is 'win32' ciTasks.push('test') if process.platform is 'darwin' - ciTasks.push('codesign') - ciTasks.push('publish-build') + ciTasks.push('codesign') unless process.env.TRAVIS + ciTasks.push('publish-build') unless process.env.TRAVIS grunt.registerTask('ci', ciTasks) - defaultTasks = ['download-atom-shell', 'download-atom-shell-chromedriver', 'build', 'set-version'] + defaultTasks = ['download-atom-shell', 'download-atom-shell-chromedriver', 'build', 'set-version', 'generate-asar'] defaultTasks.push 'install' unless process.platform is 'linux' grunt.registerTask('default', defaultTasks) diff --git a/build/deprecated-packages.json b/build/deprecated-packages.json new file mode 100644 index 000000000..ac980e165 --- /dev/null +++ b/build/deprecated-packages.json @@ -0,0 +1,1687 @@ +{ + "advanced-new-file": { + "version": "<=0.4.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "angularjs-helper": { + "version": "<=0.9.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "apex-ui-personalize": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "api-blueprint-preview": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "asciidoc-preview": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ask-stack": { + "version": "<=1.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "assign-align": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "asteroids": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-2048": { + "version": "<=1.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-angularjs": { + "hasAlternative": true, + "alternative": "angularjs" + }, + "atom-beautifier": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-beautify": { + "version": "<=0.27.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-browser-webview": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-charcode": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-cli-diff": { + "version": "<=0.11.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-compile-coffee": { + "version": "<=1.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-ctags": { + "version": "<=3.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-faker": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-flake8": { + "hasAlternative": true, + "alternative": "linter" + }, + "atom-grunt-configs": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-html-preview": { + "version": "<=0.1.6", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-html5-boilerplate": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-htmlizer": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-jsfmt": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-jshint": { + "version": "<=1.5.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-lint": { + "hasAlternative": true, + "alternative": "linter" + }, + "atom-pair": { + "version": "<=1.1.5", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-prettify": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-processing": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-python-debugger": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-rails": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-raml-preview": { + "version": "<=0.0.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-runner": { + "version": "<=2.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-semicolons": { + "version": "<=0.1.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-spotify": { + "version": "<=1.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-terminal-panel": { + "version": "<=4.3.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-typescript": { + "version": "<=4.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atom-ungit": { + "version": "<=0.4.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "atom-yeoman": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atomatigit": { + "version": "<=1.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atomic-emacs": { + "version": "<=0.5.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "atomic-rest": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "auto-detect-indentation": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "auto-indent": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "auto-replace-in-selection": { + "version": "<=2.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "auto-update-packages": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "autoclose-html": { + "version": "<=0.15.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "autocomplete-haskell": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "autocomplete-jedi": { + "hasAlternative": true, + "alternative": "autocomplete-plus-python-jedi" + }, + "autocomplete-paths": { + "version": "<=1.0.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "autocomplete-phpunit": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "autocomplete-plus-async": { + "hasAlternative": true, + "message": "`autocomplete-plus-async` has been replaced by `autocomplete-plus` which is bundled in core", + "alternative": "core" + }, + "autocomplete-plus-jedi": { + "version": "<=0.0.9", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "autocomplete-snippets": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "bezier-curve-editor": { + "version": "<=0.6.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "big-cursor": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "block-comment": { + "version": "<=0.4.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "browser-refresh": { + "version": "<=0.8.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "cabal": { + "version": "<=0.0.13", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "change-case": { + "version": "<=0.5.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "circle-ci": { + "version": "<=0.9.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "clang-format": { + "version": "<=1.8.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "clipboard-history": { + "version": "<=0.6.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "clone-cursor": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "closure-linter": { + "version": "<=0.2.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "code-links": { + "version": "<=0.3.8", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "codeship-status": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "coffee-compile": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "coffee-lint": { + "hasAlternative": true, + "alternative": "linter" + }, + "coffee-trace": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "coffeescript-preview": { + "hasAlternative": true, + "alternative": "preview" + }, + "color": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "color-picker": { + "version": "<=1.7.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "command-logger": { + "version": "<=0.20.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "comment": { + "version": "<=0.2.7", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "compass": { + "version": "<=0.8.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "composer": { + "version": "<=0.3.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "convert-to-utf8": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "coverage": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "csscomb": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ctags-status": { + "version": "<=1.2.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "cucumber-runner": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "cucumber-step": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "custom-title": { + "version": "<=0.7.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "cut-line": { + "hasAlternative": true, + "alternative": "core" + }, + "dash": { + "version": "<=1.0.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "data-atom": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "devdocs": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "django-templates": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "docblockr": { + "version": "<=0.6.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "easy-motion": { + "version": "<=1.1.4", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "editor-stats": { + "version": "<=0.16.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "editorconfig": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "elixir-cmd": { + "version": "<=0.2.6", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "emacs-mode": { + "version": "<=0.0.29", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ember-cli-helper": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "emmet": { + "version": "<=2.3.7", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "emp-debugger": { + "version": "<=0.6.13", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "emp-template-management": { + "version": "<=0.1.13", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "enhanced-tabs": { + "version": "<=1.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "erb-snippets": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "error-status": { + "version": "<=0.3.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "eslint": { + "version": "<=0.15.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "eval": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ex-mode": { + "version": "<=0.4.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "execute-as-ruby": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "expand-selection": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "explicit-reload": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "fancy-new-file": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "file-icon-supplement": { + "version": "<=0.7.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "file-icons": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "file-types": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "filetype-color": { + "version": "<=0.1.4", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "firepad": { + "version": "<=0.3.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "flake8": { + "hasAlternative": true, + "alternative": "linter" + }, + "floobits": { + "version": "<=0.4.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "function-name-in-status-bar": { + "version": "<=0.2.6", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "fuzzy-finder": { + "version": "<=0.60.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "get-routes": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gist-it": { + "version": "<=0.6.10", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-blame": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-control": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-diff": { + "version": "<=0.43.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-diff-details": { + "version": "<=0.8.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-log": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-plus": { + "version": "<=4.5.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "git-review": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "git-tab-status": { + "version": "<=1.5.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "github-issues": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gitignore-snippets": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gitter": { + "version": "<=0.6.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "go-oracle": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "go-plus": { + "version": "<=2.0.8", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "go-to-view": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gocode": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gradle-ci": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "grunt-runner": { + "version": "<=0.8.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "gulp-helper": { + "version": "<=4.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "gutter-shadow": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "hiera-eyaml": { + "version": "<=0.4.7", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "highlight-column": { + "version": "<=0.3.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "highlight-cov": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "highlight-css-color": { + "version": "<=1.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "highlight-line": { + "version": "<=0.9.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "highlight-selected": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "hipster-ipsum": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "html-entities": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "html-helper": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "html-img": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "html2haml": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "html2jade": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "htmlhint": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "icon-font-picker": { + "version": "<=0.0.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ide-flow": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ide-haskell": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "import": { + "version": "<=1.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "inc-dec-value": { + "version": "<=0.0.7", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "increment-number": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "indent-helper": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "indentation-jumper": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "inline-autocomplete": { + "version": "<=1.0.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ionic-atom": { + "version": "<=0.3.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "japanese-zen-han-convert": { + "version": "<=0.3.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "jsdoc": { + "version": "<=0.9.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "jsformat": { + "version": "<=0.8.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "jslint": { + "version": "<=1.2.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "jsonlint": { + "version": "<=1.0.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "jsonpp": { + "version": "<=0.0.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "keycodes": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "kinetic": { + "version": "<=0.2.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "language-javascript-semantic": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "language-jsoniq": { + "version": "<=1.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "language-rspec": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "language-typescript": { + "hasAlternative": true, + "alternative": "atom-typescript" + }, + "laravel-facades": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "last-cursor-position": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "layout-manager": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "less-autocompile": { + "version": "<=0.3.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "letter-spacing": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "line-count": { + "version": "<=0.3.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "line-jumper": { + "version": "<=0.13.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "linter": { + "version": "<=0.11.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "linter-flow": { + "version": "<=0.1.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "livereload": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "local-history": { + "version": "<=3.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "local-server": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "local-server-express": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "local-settings": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "localization": { + "version": "<=1.16.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "log-console": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "lorem-ipsum": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "mark-ring": { + "version": "<=3.0.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "markdown-format": { + "version": "<=2.5.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "markdown-helpers": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "markdown-pdf": { + "version": "<=1.3.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "markdown-preview-plus": { + "version": "<=1.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "markdown-stream": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "markdown-writer": { + "version": "<=1.3.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "marked": { + "version": "<=0.1.8", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "mate-subword-navigation": { + "version": "<=3.0.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "MavensMate-Atom": { + "version": "<=0.0.20", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "max-tabs": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "maximize-panes": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "mdurl": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "mechanical-keyboard": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "minifier": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "minimap": { + "version": "<=3.5.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "minimap-color-highlight": { + "version": "<=4.1.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "minimap-git-diff": { + "version": "<=3.0.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "mocha": { + "version": "<=0.0.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "mocha-ui": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "node-debugger": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "npm-autocomplete": { + "version": "<=0.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "omni-ruler": { + "version": "<=0.3.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "omnisharp-atom": { + "version": "<=0.4.9", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "open-git-modified-files": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "open-in-github-app": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "open-in-gitx": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "open-in-sourcetree": { + "version": "<=0.1.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "open-last-project": { + "hasAlternative": true, + "alternative": "core" + }, + "open-recent": { + "version": "<=2.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "package-cop": { + "version": "<=0.2.5", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "package-list-downloader": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "pair-programming": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "pane-layout-switcher": { + "version": "<=0.0.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "paredit": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "party-hard": { + "version": "<=0.3.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "path": { + "version": "<=0.4.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "pep8": { + "hasAlternative": true, + "alternative": "linter" + }, + "pepper-autocomplete": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "permute": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "php-documentation-online": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "php-getters-setters": { + "version": "<=0.5.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "php-server": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "phpunit": { + "version": "<=1.0.9", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "playlist": { + "version": "<=0.1.7", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "pretty-json": { + "version": "<=0.3.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "preview": { + "version": "<=0.14.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "preview-plus": { + "version": "<=1.1.42", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "project-colorize": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "project-manager": { + "version": "<=1.11.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "project-palette-finder": { + "version": "<=2.4.7", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "project-ring": { + "version": "<=0.20.5", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "python": { + "hasAlternative": true, + "alternative": "script" + }, + "python-coverage": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "python-isort": { + "version": "<=0.0.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "python-jedi": { + "version": "<=0.1.7", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "quick-move-file": { + "version": "<=0.7.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "r-exec": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "rails-navigation": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "react": { + "version": "<=0.5.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "recent-projects": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "regex-railroad-diagram": { + "version": "<=0.7.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "related-files": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "remote-atom": { + "version": "<=1.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "remote-edit": { + "version": "<=1.6.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "remote-sync": { + "version": "<=3.1.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "resize-panes": { + "hasAlternative": true, + "alternative": "core" + }, + "rest-client": { + "version": "<=0.3.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "revert-buffer": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "rsense": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "rspec": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "rst-preview-pandoc": { + "version": "<=0.1.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ruby-define-method": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ruby-hash-rocket": { + "version": "<=1.1.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ruby-strftime-reference": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "ruby-test": { + "version": "<=0.9.5", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ruler": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "run-command": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "run-file": { + "version": "<=0.9.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "run-in-browser": { + "version": "<=0.1.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "runcoderun": { + "version": "<=0.5.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "sass-autocompile": { + "version": "<=0.6.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "sassbeautify": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "save-commands": { + "version": "<=0.6.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "save-session": { + "version": "<=0.15.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "scope-inspector": { + "version": "<=0.2.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "script": { + "version": "<=2.20.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "script-runner": { + "version": "<=1.6.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "select-scope": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "selection-count": { + "hasAlternative": true, + "alternative": "core" + }, + "slash-closer": { + "version": "<=0.7.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "sloc": { + "version": "<=0.1.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "smarter-delete-line": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "space-block-jumper": { + "version": "<=0.4.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "space-tab": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "spark-dfu-util": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "status-tab-spacing": { + "version": "<=0.3.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "sublime-tabs": { + "hasAlternative": true, + "message": "`sublime-tabs` has been replaced by the 'Use Preview Tabs' option in the `tabs` package settings.", + "alternative": "core" + }, + "supercollider": { + "version": "<=0.4.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "supercopair": { + "version": "<=0.9.34", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "support-gbk": { + "version": "<=1.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "swift-playground": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "symbol-gen": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "synced-sidebar": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "tab-history": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "tab-switcher": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "tabs-to-spaces": { + "version": "<=0.8.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "tag": { + "version": "<=0.2.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "tasks": { + "version": "<=1.0.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "term": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "terminal-panel": { + "version": "<=1.11.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "terminal-status": { + "version": "<=1.6.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "test-status": { + "version": "<=0.27.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "the-closer": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "ti-alloy-related": { + "version": "<=0.8.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "tidal": { + "version": "<=0.6.6", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "tidy-markdown": { + "version": "<=0.2.2", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "timecop": { + "version": "<=0.23.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "timekeeper": { + "version": "<=0.4.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "toggle-tabs": { + "version": "<=0.1.8", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "travis-ci-status": { + "version": "<=0.13.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "true-color": { + "version": "<=0.4.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "turbo-javascript": { + "version": "<=0.0.10", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "turnip-step": { + "version": "<=1.0.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "unity-ui": { + "version": "<=1.0.5", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "update-package-dependencies": { + "version": "<=0.6.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "update-packages": { + "hasAlternative": true, + "alternative": "core" + }, + "vertical-align": { + "version": "<=0.6.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "view-tail-large-files": { + "version": "<=0.1.17", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "vim-mode": { + "version": "<=0.46.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "virtualenv": { + "version": "<=0.6.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "visual-bell": { + "version": "<=0.11.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "vnc": { + "version": "<=0.1.3", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "voicecode": { + "version": "<=0.9.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "w3c-validation": { + "version": "<=0.1.3", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "weather-package": { + "version": "<=1.5.4", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "web-view": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "webbox-color": { + "version": "<=0.5.4", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "webview-pane": { + "version": "<=0.0.1", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "wercker-status": { + "version": "<=0.3.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "white-cursor": { + "version": "<=0.5.1", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "whitespace": { + "version": "<=0.24.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "word-count": { + "version": "<=0.1.0", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "word-jumper": { + "version": "<=0.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "wordcount": { + "version": "<=2.2.0", + "hasDeprecations": true, + "latestHasDeprecations": false + }, + "wrap-lines": { + "hasAlternative": true, + "message": "`wrap-lines` has been replaced by a feature in core. Open the command palette and search for `autoflow`.", + "alternative": "core" + }, + "yosemite-unity-ui": { + "version": "<=0.3.13", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "yuno-commit": { + "version": "<=0.0.2", + "hasDeprecations": true, + "latestHasDeprecations": true + }, + "zentabs": { + "version": "<=0.6.1", + "hasDeprecations": true, + "latestHasDeprecations": false + } +} diff --git a/build/package.json b/build/package.json index ea5b8e6de..c02ff68aa 100644 --- a/build/package.json +++ b/build/package.json @@ -6,32 +6,33 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { + "asar": "^0.5.0", "async": "~0.2.9", - "donna": "1.0.7", + "donna": "1.0.10", "formidable": "~1.0.14", "fs-plus": "2.x", "github-releases": "~0.2.0", "grunt": "~0.4.1", - "grunt-atom-shell-installer": "^0.23.0", + "grunt-atom-shell-installer": "^0.29.0", "grunt-cli": "~0.1.9", "grunt-coffeelint": "git+https://github.com/atom/grunt-coffeelint.git#cfb99aa99811d52687969532bd5a98011ed95bfe", "grunt-contrib-coffee": "~0.12.0", - "grunt-contrib-csslint": "~0.1.2", + "grunt-contrib-csslint": "~0.2.0", "grunt-contrib-less": "~0.8.0", "grunt-cson": "0.14.0", - "grunt-download-atom-shell": "~0.12.0", - "grunt-lesslint": "0.13.0", + "grunt-download-atom-shell": "~0.14.0", + "grunt-lesslint": "0.17.0", "grunt-peg": "~1.1.0", "grunt-shell": "~0.3.1", "harmony-collections": "~0.3.8", - "legal-eagle": "~0.9.0", + "legal-eagle": "~0.10.0", "minidump": "~0.8", "npm": "2.5.1", "rcedit": "~0.3.0", "request": "~2.27.0", "rimraf": "~2.2.2", "runas": "^2", - "tello": "1.0.4", + "tello": "1.0.5", "temp": "~0.8.1", "underscore-plus": "1.x", "unzip": "~0.1.9", diff --git a/build/tasks/build-task.coffee b/build/tasks/build-task.coffee index fcb1eb6a8..6c2c4f309 100644 --- a/build/tasks/build-task.coffee +++ b/build/tasks/build-task.coffee @@ -22,10 +22,11 @@ module.exports = (grunt) -> mkdir appDir if process.platform isnt 'win32' - cp 'atom.sh', path.join(appDir, 'atom.sh') + cp 'atom.sh', path.resolve(appDir, '..', 'new-app', 'atom.sh') cp 'package.json', path.join(appDir, 'package.json') + packageNames = [] packageDirectories = [] nonPackageDirectories = [ 'benchmark' @@ -39,6 +40,7 @@ module.exports = (grunt) -> directory = path.join('node_modules', child) if isAtomPackage(directory) packageDirectories.push(directory) + packageNames.push(child) else nonPackageDirectories.push(directory) @@ -49,6 +51,8 @@ module.exports = (grunt) -> path.join('oniguruma', 'deps') path.join('less', 'dist') path.join('bootstrap', 'docs') + path.join('bootstrap', 'dist') + path.join('bootstrap', 'fonts') path.join('bootstrap', '_config.yml') path.join('bootstrap', '_includes') path.join('bootstrap', '_layouts') @@ -59,10 +63,13 @@ module.exports = (grunt) -> path.join('npm', 'node_modules', '.bin', 'clear') path.join('npm', 'node_modules', '.bin', 'starwars') path.join('pegjs', 'examples') + path.join('get-parameter-names', 'node_modules', 'testla') + path.join('get-parameter-names', 'node_modules', '.bin', 'testla') path.join('jasmine-reporters', 'ext') path.join('jasmine-node', 'node_modules', 'gaze') path.join('jasmine-node', 'spec') path.join('node_modules', 'nan') + path.join('node_modules', 'native-mate') path.join('build', 'binding.Makefile') path.join('build', 'config.gypi') path.join('build', 'gyp-mac-tool') @@ -76,19 +83,34 @@ module.exports = (grunt) -> path.join('resources', 'win') # These are only require in dev mode when the grammar isn't precompiled - path.join('atom-keymap', 'node_modules', 'loophole') - path.join('atom-keymap', 'node_modules', 'pegjs') - path.join('atom-keymap', 'node_modules', '.bin', 'pegjs') path.join('snippets', 'node_modules', 'loophole') path.join('snippets', 'node_modules', 'pegjs') path.join('snippets', 'node_modules', '.bin', 'pegjs') + # These aren't needed since WeakMap is built-in + path.join('emissary', 'node_modules', 'es6-weak-map') + path.join('property-accessors', 'node_modules', 'es6-weak-map') + '.DS_Store' '.jshintrc' '.npmignore' '.pairs' '.travis.yml' + 'appveyor.yml' + '.idea' + '.editorconfig' + '.lint' + '.lintignore' + '.eslintrc' + '.jshintignore' + 'coffeelint.json' + '.coffeelintignore' + '.gitattributes' + '.gitkeep' ] + + packageNames.forEach (packageName) -> ignoredPaths.push(path.join(packageName, 'spec')) + ignoredPaths = ignoredPaths.map (ignoredPath) -> _.escapeRegExp(ignoredPath) # Add .* to avoid matching hunspell_dictionaries. @@ -105,6 +127,7 @@ module.exports = (grunt) -> ignoredPaths.push "#{_.escapeRegExp(path.join('runas', 'src') + path.sep)}.*\\.(cc|h)*" ignoredPaths.push "#{_.escapeRegExp(path.join('scrollbar-style', 'src') + path.sep)}.*\\.(cc|h)*" ignoredPaths.push "#{_.escapeRegExp(path.join('spellchecker', 'src') + path.sep)}.*\\.(cc|h)*" + ignoredPaths.push "#{_.escapeRegExp(path.join('keyboard-layout', 'src') + path.sep)}.*\\.(cc|h|mm)*" # Ignore build files ignoredPaths.push "#{_.escapeRegExp(path.sep)}binding\\.gyp$" @@ -117,7 +140,7 @@ module.exports = (grunt) -> ignoredPaths.push path.join('spellchecker', 'vendor', 'hunspell_dictionaries') ignoredPaths = ignoredPaths.map (ignoredPath) -> "(#{ignoredPath})" - testFolderPattern = new RegExp("#{_.escapeRegExp(path.sep)}te?sts?#{_.escapeRegExp(path.sep)}") + testFolderPattern = new RegExp("#{_.escapeRegExp(path.sep)}_*te?sts?_*#{_.escapeRegExp(path.sep)}") exampleFolderPattern = new RegExp("#{_.escapeRegExp(path.sep)}examples?#{_.escapeRegExp(path.sep)}") benchmarkFolderPattern = new RegExp("#{_.escapeRegExp(path.sep)}benchmarks?#{_.escapeRegExp(path.sep)}") @@ -141,13 +164,12 @@ module.exports = (grunt) -> for directory in packageDirectories cp directory, path.join(appDir, directory), filter: filterPackage - cp 'spec', path.join(appDir, 'spec') cp 'src', path.join(appDir, 'src'), filter: /.+\.(cson|coffee)$/ cp 'static', path.join(appDir, 'static') - cp path.join('apm', 'node_modules', 'atom-package-manager'), path.join(appDir, 'apm'), filter: filterNodeModule + cp path.join('apm', 'node_modules', 'atom-package-manager'), path.resolve(appDir, '..', 'new-app', 'apm'), filter: filterNodeModule if process.platform isnt 'win32' - fs.symlinkSync(path.join('..', '..', 'bin', 'apm'), path.join(appDir, 'apm', 'node_modules', '.bin', 'apm')) + fs.symlinkSync(path.join('..', '..', 'bin', 'apm'), path.resolve(appDir, '..', 'new-app', 'apm', 'node_modules', '.bin', 'apm')) if process.platform is 'darwin' grunt.file.recurse path.join('resources', 'mac'), (sourcePath, rootDirectory, subDirectory='', filename) -> diff --git a/build/tasks/compile-packages-slug-task.coffee b/build/tasks/compile-packages-slug-task.coffee index 4e24f948e..7317f158a 100644 --- a/build/tasks/compile-packages-slug-task.coffee +++ b/build/tasks/compile-packages-slug-task.coffee @@ -2,15 +2,39 @@ path = require 'path' CSON = require 'season' fs = require 'fs-plus' _ = require 'underscore-plus' +normalizePackageData = require 'normalize-package-data' +semver = require 'semver' + +OtherPlatforms = ['darwin', 'freebsd', 'linux', 'sunos', 'win32'].filter (platform) -> platform isnt process.platform module.exports = (grunt) -> {spawn, rm} = require('./task-helpers')(grunt) + getMenu = (appDir) -> + menusPath = path.join(appDir, 'menus') + menuPath = path.join(menusPath, "#{process.platform}.json") + menu = CSON.readFileSync(menuPath) if fs.isFileSync(menuPath) + rm menusPath + menu + + getKeymaps = (appDir) -> + keymapsPath = path.join(appDir, 'keymaps') + keymaps = {} + for keymapPath in fs.listSync(keymapsPath, ['.json']) + name = path.basename(keymapPath, path.extname(keymapPath)) + continue unless OtherPlatforms.indexOf(name) is -1 + + keymap = CSON.readFileSync(keymapPath) + keymaps[path.basename(keymapPath)] = keymap + rm keymapsPath + keymaps + grunt.registerTask 'compile-packages-slug', 'Add bundled package metadata information to the main package.json file', -> appDir = fs.realpathSync(grunt.config.get('atom.appDir')) modulesDirectory = path.join(appDir, 'node_modules') packages = {} + invalidPackages = false for moduleDirectory in fs.listSync(modulesDirectory) continue if path.basename(moduleDirectory) is '.bin' @@ -19,6 +43,13 @@ module.exports = (grunt) -> metadata = grunt.file.readJSON(metadataPath) continue unless metadata?.engines?.atom? + reportPackageError = (msg) -> + invalidPackages = true + grunt.log.error("#{metadata.name}: #{msg}") + normalizePackageData metadata, reportPackageError, true + if metadata.repository?.type is 'git' + metadata.repository.url = metadata.repository.url?.replace(/^git\+/, '') + moduleCache = metadata._atomModuleCache ? {} rm metadataPath @@ -33,15 +64,19 @@ module.exports = (grunt) -> mainPath = require.resolve(path.resolve(moduleDirectory, metadata.main)) pack.main = path.relative(appDir, mainPath) - for keymapPath in fs.listSync(path.join(moduleDirectory, 'keymaps'), ['.cson', '.json']) + keymapsPath = path.join(moduleDirectory, 'keymaps') + for keymapPath in fs.listSync(keymapsPath, ['.cson', '.json']) relativePath = path.relative(appDir, keymapPath) pack.keymaps[relativePath] = CSON.readFileSync(keymapPath) rm keymapPath + rm keymapsPath if fs.listSync(keymapsPath).length is 0 - for menuPath in fs.listSync(path.join(moduleDirectory, 'menus'), ['.cson', '.json']) + menusPath = path.join(moduleDirectory, 'menus') + for menuPath in fs.listSync(menusPath, ['.cson', '.json']) relativePath = path.relative(appDir, menuPath) pack.menus[relativePath] = CSON.readFileSync(menuPath) rm menuPath + rm menusPath if fs.listSync(menusPath).length is 0 packages[metadata.name] = pack @@ -50,5 +85,14 @@ module.exports = (grunt) -> metadata = grunt.file.readJSON(path.join(appDir, 'package.json')) metadata._atomPackages = packages + metadata._atomMenu = getMenu(appDir) + metadata._atomKeymaps = getKeymaps(appDir) + metadata._deprecatedPackages = require('../deprecated-packages') + + for name, {version} of metadata._deprecatedPackages + if version and not semver.validRange(version) + invalidPackages = true + grunt.log.error("Invalid range: #{version} (#{name})") grunt.file.write(path.join(appDir, 'package.json'), JSON.stringify(metadata)) + not invalidPackages diff --git a/build/tasks/generate-asar-task.coffee b/build/tasks/generate-asar-task.coffee new file mode 100644 index 000000000..800721fee --- /dev/null +++ b/build/tasks/generate-asar-task.coffee @@ -0,0 +1,37 @@ +asar = require 'asar' +fs = require 'fs' +path = require 'path' + +module.exports = (grunt) -> + {cp, rm} = require('./task-helpers')(grunt) + + grunt.registerTask 'generate-asar', 'Generate asar archive for the app', -> + done = @async() + + unpack = [ + '*.node' + '.ctags' + 'ctags-darwin' + 'ctags-linux' + 'ctags-win32.exe' + '**/node_modules/spellchecker/**' + '**/resources/atom.png' + ] + unpack = "{#{unpack.join(',')}}" + + appDir = grunt.config.get('atom.appDir') + unless fs.existsSync(appDir) + grunt.log.error 'The app has to be built before generating asar archive.' + return done(false) + + asar.createPackageWithOptions appDir, path.resolve(appDir, '..', 'app.asar'), {unpack}, (err) -> + return done(err) if err? + + rm appDir + fs.renameSync path.resolve(appDir, '..', 'new-app'), appDir + + ctagsFolder = path.join("#{appDir}.asar.unpacked", 'node_modules', 'symbols-view', 'vendor') + for ctagsFile in fs.readdirSync(ctagsFolder) + fs.chmodSync(path.join(ctagsFolder, ctagsFile), "755") + + done() diff --git a/build/tasks/generate-license-task.coffee b/build/tasks/generate-license-task.coffee index 6b616f5cb..eaf1a9a66 100644 --- a/build/tasks/generate-license-task.coffee +++ b/build/tasks/generate-license-task.coffee @@ -17,7 +17,7 @@ module.exports = (grunt) -> licenseText = getLicenseText(dependencyLicenses) if mode is 'save' - targetPath = path.join(grunt.config.get('atom.appDir'), 'LICENSE.md') + targetPath = path.resolve(grunt.config.get('atom.appDir'), '..', 'LICENSE.md') fs.writeFileSync(targetPath, licenseText) else console.log licenseText diff --git a/build/tasks/install-task.coffee b/build/tasks/install-task.coffee index 5131f512c..86a827a1b 100644 --- a/build/tasks/install-task.coffee +++ b/build/tasks/install-task.coffee @@ -31,8 +31,6 @@ module.exports = (grunt) -> binDir = path.join(installDir, 'bin') shareDir = path.join(installDir, 'share', 'atom') - iconName = path.join(shareDir,'resources', 'app', 'resources', 'atom.png') - mkdir binDir cp 'atom.sh', path.join(binDir, 'atom') rm shareDir @@ -46,7 +44,7 @@ module.exports = (grunt) -> desktopInstallFile = path.join(installDir, 'share', 'applications', 'atom.desktop') {description} = grunt.file.readJSON('package.json') - iconName = path.join(shareDir, 'resources', 'app', 'resources', 'atom.png') + iconName = path.join(shareDir, 'resources', 'app.asar.unpacked', 'resources', 'atom.png') executable = path.join(shareDir, 'atom') template = _.template(String(fs.readFileSync(desktopFile))) filled = template({description, iconName, executable}) diff --git a/build/tasks/license-overrides.coffee b/build/tasks/license-overrides.coffee index b32ebb545..287d44b63 100644 --- a/build/tasks/license-overrides.coffee +++ b/build/tasks/license-overrides.coffee @@ -65,7 +65,7 @@ module.exports = IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """ - 'jschardet@1.1.0': + 'jschardet@1.1.1': license: 'LGPL' source: 'README.md in the repository' sourceText: """ diff --git a/build/tasks/mkrpm-task.coffee b/build/tasks/mkrpm-task.coffee index 8436fea7a..5744343a5 100644 --- a/build/tasks/mkrpm-task.coffee +++ b/build/tasks/mkrpm-task.coffee @@ -33,7 +33,7 @@ module.exports = (grunt) -> installDir = grunt.config.get('atom.installDir') shareDir = path.join(installDir, 'share', 'atom') iconName = 'atom' - executable = 'atom' + executable = path.join(shareDir, 'atom') data = {name, version, description, installDir, iconName, executable} specFilePath = fillTemplate(path.join('resources', 'linux', 'redhat', 'atom.spec'), data) diff --git a/build/tasks/output-build-filetypes.coffee b/build/tasks/output-build-filetypes.coffee index 64b952bbe..1b8aeb330 100644 --- a/build/tasks/output-build-filetypes.coffee +++ b/build/tasks/output-build-filetypes.coffee @@ -1,3 +1,4 @@ +asar = require 'asar' path = require 'path' module.exports = (grunt) -> @@ -5,17 +6,29 @@ module.exports = (grunt) -> shellAppDir = grunt.config.get('atom.shellAppDir') types = {} - grunt.file.recurse shellAppDir, (absolutePath, rootPath, relativePath, fileName) -> - extension = path.extname(fileName) or fileName - types[extension] ?= 0 - types[extension]++ + registerFile = (filePath) -> + extension = path.extname(filePath) or path.basename(filePath) + types[extension] ?= [] + types[extension].push(filePath) + + if extension is '.asar' + asar.listPackage(filePath).forEach (archivePath) -> + archivePath = archivePath.substring(1) + unless asar.statFile(filePath, archivePath, true).files + registerFile(archivePath) + + grunt.file.recurse shellAppDir, (absolutePath, rootPath, relativePath, fileName) -> registerFile(absolutePath) extensions = Object.keys(types).sort (extension1, extension2) -> - diff = types[extension2] - types[extension1] + diff = types[extension2].length - types[extension1].length if diff is 0 extension1.toLowerCase().localeCompare(extension2.toLowerCase()) else diff - extensions.forEach (extension) -> - grunt.log.error "#{extension}: #{types[extension]}" + if extension = grunt.option('extension') + types[extension]?.sort().forEach (filePath) -> + grunt.log.error filePath + else + extensions[0...25].forEach (extension) -> + grunt.log.error "#{extension}: #{types[extension].length}" diff --git a/build/tasks/output-for-loop-returns.coffee b/build/tasks/output-for-loop-returns.coffee new file mode 100644 index 000000000..f9b036120 --- /dev/null +++ b/build/tasks/output-for-loop-returns.coffee @@ -0,0 +1,22 @@ +path = require 'path' + +module.exports = (grunt) -> + grunt.registerTask 'output-for-loop-returns', 'Log methods that end with a for loop', -> + appDir = grunt.config.get('atom.appDir') + + jsPaths = [] + grunt.file.recurse path.join(appDir, 'src'), (absolutePath, rootPath, relativePath, fileName) -> + jsPaths.push(absolutePath) if path.extname(fileName) is '.js' + + jsPaths.forEach (jsPath) -> + js = grunt.file.read(jsPath) + method = null + for line, index in js.split('\n') + [match, className, methodName] = /^\s*([a-zA-Z]+)\.(?:prototype\.)?([a-zA-Z]+)\s*=\s*function\(/.exec(line) ? [] + if className and methodName + method = "#{className}::#{methodName}" + else + [match, ctorName] = /^\s*function\s+([a-zA-Z]+)\(/.exec(line) ? [] + + if /^\s*return\s+_results;\s*$/.test(line) + console.log(method ? "#{path.basename(jsPath)}:#{index}") diff --git a/build/tasks/prebuild-less-task.coffee b/build/tasks/prebuild-less-task.coffee index bcaa0467f..5e3bd274e 100644 --- a/build/tasks/prebuild-less-task.coffee +++ b/build/tasks/prebuild-less-task.coffee @@ -1,10 +1,36 @@ path = require 'path' fs = require 'fs' - +temp = require('temp').track() LessCache = require 'less-cache' module.exports = (grunt) -> - grunt.registerMultiTask 'prebuild-less', 'Prebuild cached of compiled LESS files', -> + {rm} = require('./task-helpers')(grunt) + + compileBootstrap = -> + appDir = grunt.config.get('atom.appDir') + bootstrapLessPath = path.join(appDir, 'static', 'bootstrap.less') + bootstrapCssPath = path.join(appDir, 'static', 'bootstrap.css') + + lessCache = new LessCache + cacheDir: temp.mkdirSync('atom-less-cache') + resourcePath: path.resolve('.') + + bootstrapCss = lessCache.readFileSync(bootstrapLessPath) + grunt.file.write(bootstrapCssPath, bootstrapCss) + rm(bootstrapLessPath) + rm(path.join(appDir, 'node_modules', 'bootstrap', 'less')) + + importFallbackVariables = (lessFilePath) -> + if lessFilePath.indexOf('static') is 0 + false + else if lessFilePath.indexOf('atom-space-pen-views') isnt -1 + false + else + true + + grunt.registerMultiTask 'prebuild-less', 'Prebuild cached of compiled Less files', -> + compileBootstrap() + prebuiltConfigurations = [ ['atom-dark-ui', 'atom-dark-syntax'] ['atom-dark-ui', 'atom-light-syntax'] @@ -57,19 +83,21 @@ module.exports = (grunt) -> themeMains.push(mainPath) if grunt.file.isFile(mainPath) importPaths.unshift(stylesheetsDir) if grunt.file.isDir(stylesheetsDir) - grunt.verbose.writeln("Building LESS cache for #{configuration.join(', ').yellow}") + grunt.verbose.writeln("Building Less cache for #{configuration.join(', ').yellow}") lessCache = new LessCache cacheDir: directory resourcePath: path.resolve('.') importPaths: importPaths cssForFile = (file) -> - baseVarImports = """ - @import "variables/ui-variables"; - @import "variables/syntax-variables"; - """ less = fs.readFileSync(file, 'utf8') - lessCache.cssForFile(file, [baseVarImports, less].join('\n')) + if importFallbackVariables(file) + baseVarImports = """ + @import "variables/ui-variables"; + @import "variables/syntax-variables"; + """ + less = [baseVarImports, less].join('\n') + lessCache.cssForFile(file, less) for file in @filesSrc grunt.verbose.writeln("File #{file.cyan} created in cache.") diff --git a/build/tasks/set-version-task.coffee b/build/tasks/set-version-task.coffee index 6f7a68c61..48b6091c4 100644 --- a/build/tasks/set-version-task.coffee +++ b/build/tasks/set-version-task.coffee @@ -46,7 +46,7 @@ module.exports = (grunt) -> strings = CompanyName: 'GitHub, Inc.' FileDescription: 'Atom' - LegalCopyright: 'Copyright (C) 2014 GitHub, Inc. All rights reserved' + LegalCopyright: 'Copyright (C) 2015 GitHub, Inc. All rights reserved' ProductName: 'Atom' ProductVersion: version diff --git a/build/tasks/spec-task.coffee b/build/tasks/spec-task.coffee index 73bb63f70..bfd5a8604 100644 --- a/build/tasks/spec-task.coffee +++ b/build/tasks/spec-task.coffee @@ -4,7 +4,13 @@ path = require 'path' _ = require 'underscore-plus' async = require 'async' -concurrency = 2 +# TODO: This should really be parallel on every platform, however: +# - On Windows, our fixtures step on each others toes. +# - On Travis, Mac workers haven't enough horsepower. +if process.env.TRAVIS or process.platform is 'win32' + concurrency = 1 +else + concurrency = 2 module.exports = (grunt) -> {isAtomPackage, spawn} = require('./task-helpers')(grunt) @@ -12,7 +18,7 @@ module.exports = (grunt) -> packageSpecQueue = null logDeprecations = (label, {stderr}={}) -> - return unless process.env.JANKY_SHA1 + return unless process.env.JANKY_SHA1 or process.env.CI stderr ?= '' deprecatedStart = stderr.indexOf('Calls to deprecated functions') return if deprecatedStart is -1 @@ -60,7 +66,7 @@ module.exports = (grunt) -> cwd: packagePath env: _.extend({}, process.env, ATOM_PATH: rootDir) - grunt.verbose.writeln "Launching #{path.basename(packagePath)} specs." + grunt.log.ok "Launching #{path.basename(packagePath)} specs." spawn options, (error, results, code) -> if process.platform is 'win32' if error @@ -78,7 +84,7 @@ module.exports = (grunt) -> continue unless isAtomPackage(packagePath) packageSpecQueue.push(packagePath) - packageSpecQueue.concurrency = concurrency - 1 + packageSpecQueue.concurrency = Math.max(1, concurrency - 1) packageSpecQueue.drain = -> callback(null, failedPackages) runCoreSpecs = (callback) -> @@ -98,19 +104,20 @@ module.exports = (grunt) -> else if process.platform is 'win32' options = cmd: process.env.comspec - args: ['/c', appPath, '--test', "--resource-path=#{resourcePath}", "--spec-directory=#{coreSpecsPath}", "--log-file=ci.log"] + args: ['/c', appPath, '--test', "--resource-path=#{resourcePath}", "--spec-directory=#{coreSpecsPath}", '--log-file=ci.log'] opts: env: _.extend({}, process.env, ATOM_INTEGRATION_TESTS_ENABLED: true ) + grunt.log.ok "Launching core specs." spawn options, (error, results, code) -> if process.platform is 'win32' process.stderr.write(fs.readFileSync('ci.log')) if error fs.unlinkSync('ci.log') else # TODO: Restore concurrency on Windows - packageSpecQueue.concurrency = concurrency + packageSpecQueue?.concurrency = concurrency logDeprecations('Core Specs', results) callback(null, error) @@ -118,13 +125,11 @@ module.exports = (grunt) -> grunt.registerTask 'run-specs', 'Run the specs', -> done = @async() startTime = Date.now() - - # TODO: This should really be parallel on both platforms, however our - # fixtures step on each others toes currently. - if process.platform in ['darwin', 'linux'] - method = async.parallel - else if process.platform is 'win32' - method = async.series + method = + if concurrency is 1 + async.series + else + async.parallel method [runCoreSpecs, runPackageSpecs], (error, results) -> [coreSpecFailed, failedPackages] = results @@ -138,4 +143,4 @@ module.exports = (grunt) -> if process.platform is 'win32' and process.env.JANKY_SHA1 done() else - done(!coreSpecFailed and failedPackages.length == 0) + done(not coreSpecFailed and failedPackages.length is 0) diff --git a/build/tasks/task-helpers.coffee b/build/tasks/task-helpers.coffee index a862f291f..2819c952a 100644 --- a/build/tasks/task-helpers.coffee +++ b/build/tasks/task-helpers.coffee @@ -55,9 +55,9 @@ module.exports = (grunt) -> proc.stderr.on 'data', (data) -> stderr.push(data.toString()) proc.on 'error', (processError) -> error ?= processError proc.on 'close', (exitCode, signal) -> - error ?= new Error(signal) if exitCode != 0 + error ?= new Error(signal) if exitCode isnt 0 results = {stderr: stderr.join(''), stdout: stdout.join(''), code: exitCode} - grunt.log.error results.stderr if exitCode != 0 + grunt.log.error results.stderr if exitCode isnt 0 callback(error, results, exitCode) isAtomPackage: (packagePath) -> diff --git a/coffeelint.json b/coffeelint.json index d8e19fc46..a5dd715e3 100644 --- a/coffeelint.json +++ b/coffeelint.json @@ -13,5 +13,25 @@ }, "no_debugger": { "level": "error" + }, + "prefer_english_operator": { + "level": "error" + }, + "colon_assignment_spacing": { + "spacing": { + "left": 0, + "right": 1 + }, + "level": "error" + }, + "braces_spacing": { + "spaces": 0, + "level": "error" + }, + "spacing_after_comma": { + "level": "error" + }, + "no_stand_alone_at": { + "level": "error" } } diff --git a/docs/README.md b/docs/README.md index e8863f520..c66788d87 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,21 @@ -# Welcome to the Atom Docs +# Atom Docs ![Atom](https://cloud.githubusercontent.com/assets/72919/2874231/3af1db48-d3dd-11e3-98dc-6066f8bc766f.png) -TODO: Write when docs move to a dedicated repo. +Most of the Atom user and developer documentation is contained in the [Atom Docs](https://github.com/atom/docs) repository. + +In this directory you can only find very specific build and API level documentation. Some of this may eventually move to the docs repository as well. + +## Build documentation + +Instructions for building Atom on various platforms from source. + +* [OS X](build-instructions/os-x.md) +* [Windows](build-instructions/windows.md) +* [Linux](build-instructions/linux.md) +* [FreeBSD](build-instructions/freebsd.md) + +## Other documentation here + +* [apm REST API](apm-rest-api.md) +* [Tips for contributing to packages](contributing-to-packages.md) diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md deleted file mode 100644 index 82d821eb0..000000000 --- a/docs/advanced/configuration.md +++ /dev/null @@ -1,58 +0,0 @@ -## Configuration API - -### Reading Config Settings - -If you are writing a package that you want to make configurable, you'll need to -read config settings via the `atom.config` global. You can read the current -value of a namespaced config key with `atom.config.get`: - -```coffeescript -# read a value with `config.get` -@showInvisibles() if atom.config.get "editor.showInvisibles" -``` - -Or you can subscribe via `atom.config.observe` to track changes from any view -object. - -```coffeescript -{View} = require 'space-pen' - -class MyView extends View - attached: -> - @fontSizeObserveSubscription = - atom.config.observe 'editor.fontSize', (newValue, {previous}) => - @adjustFontSize() - - detached: -> - @fontSizeObserveSubscription.dispose() -``` - -The `atom.config.observe` method will call the given callback immediately with -the current value for the specified key path, and it will also call it in the -future whenever the value of that key path changes. If you only want to invoke -the callback when the next time the value changes, use `atom.config.onDidChange` -instead. - -Subscription methods return *disposable* subscription objects. Note in the -example above how we save the subscription to the `@fontSizeObserveSubscription` -instance variable and dispose of it when the view is detached. To group multiple -subscriptions together, you can add them all to a -[`CompositeDisposable`][composite-disposable] that you dispose when the view is -detached. - -### Writing Config Settings - -The `atom.config` database is populated on startup from `~/.atom/config.cson`, -but you can programmatically write to it with `atom.config.set`: - -```coffeescript -# basic key update -atom.config.set("core.showInvisibles", true) -``` - -If you're exposing package configuration via specific key paths, you'll want to -associate them with a schema in your package's main module. Read more about -schemas in the [config API docs][config-api]. - -[composite-disposable]: https://atom.io/docs/api/latest/CompositeDisposable -[config-api]: https://atom.io/docs/api/latest/Config diff --git a/docs/advanced/keymaps.md b/docs/advanced/keymaps.md deleted file mode 100644 index ee966a45d..000000000 --- a/docs/advanced/keymaps.md +++ /dev/null @@ -1,171 +0,0 @@ -# Keymaps In-Depth - -## Structure of a Keymap File - -Keymap files are encoded as JSON or CSON files containing nested hashes. They -work much like style sheets, but instead of applying style properties to elements -matching the selector, they specify the meaning of keystrokes on elements -matching the selector. Here is an example of some bindings that apply when -keystrokes pass through `atom-text-editor` elements: - -```coffee -'atom-text-editor': - 'cmd-delete': 'editor:delete-to-beginning-of-line' - 'alt-backspace': 'editor:delete-to-beginning-of-word' - 'ctrl-A': 'editor:select-to-first-character-of-line' - 'ctrl-shift-e': 'editor:select-to-end-of-line' - 'cmd-left': 'editor:move-to-first-character-of-line' - -'atom-text-editor:not([mini])': - 'cmd-alt-[': 'editor:fold-current-row' - 'cmd-alt-]': 'editor:unfold-current-row' -``` - -Beneath the first selector are several bindings, mapping specific *keystroke -patterns* to *commands*. When an element with the `atom-text-editor` class is focused and -`cmd-delete` is pressed, an custom DOM event called -`editor:delete-to-beginning-of-line` is emitted on the `atom-text-editor` element. - -The second selector group also targets editors, but only if they don't have the -`mini` attribute. In this example, the commands for code folding don't really -make sense on mini-editors, so the selector restricts them to regular editors. - -### Keystroke Patterns - -Keystroke patterns express one or more keystrokes combined with optional -modifier keys. For example: `ctrl-w v`, or `cmd-shift-up`. A keystroke is -composed of the following symbols, separated by a `-`. A multi-keystroke pattern -can be expressed as keystroke patterns separated by spaces. - - -| Type | Examples -| --------------------|---------------------------- -| Character literals | `a` `4` `$` -| Modifier keys | `cmd` `ctrl` `alt` `shift` -| Special keys | `enter` `escape` `backspace` `delete` `tab` `home` `end` `pageup` `pagedown` `left` `right` `up` `down` - -### Commands - -Commands are custom DOM events that are triggered when a keystroke matches a -binding. This allows user interface code to listen for named commands without -specifying the specific keybinding that triggers it. For example, the following -code creates a command to insert the current date in an editor: - -```coffee -atom.commands.add 'atom-text-editor', - 'user:insert-date': (event) -> - editor = @getModel() - editor.insertText(new Date().toLocaleString()) -``` - -`atom.commands` refers to the global {CommandRegistry} instance where all commands -are set and consequently picked up by the command palette. - -When you are looking to bind new keys, it is often useful to use the command -palette (`ctrl-shift-p`) to discover what commands are being listened for in a -given focus context. Commands are "humanized" following a simple algorithm, so a -command like `editor:fold-current-row` would appear as "Editor: Fold Current -Row". - -### "Composed" Commands - -A common question is, "How do I make a single keybinding execute two or more -commands?" There isn't any direct support for this in Atom, but it can be -achieved by creating a custom command that performs the multiple actions -you desire and then creating a keybinding for that command. For example, let's -say I want to create a "composed" command that performs a Select Line followed -by Cut. You could add the following to your `init.coffee`: - -```coffee -atom.commands.add 'atom-text-editor', 'custom:cut-line', -> - editor = atom.workspace.getActiveTextEditor() - editor.selectLinesContainingCursors() - editor.cutSelectedText() -``` - -Then let's say we want to map this custom command to `alt-ctrl-z`, you could -add the following to your keymap: - -```coffee -'atom-text-editor': - 'alt-ctrl-z': 'custom:cut-line' -``` - -### Specificity and Cascade Order - -As is the case with CSS applying styles, when multiple bindings match for a -single element, the conflict is resolved by choosing the most *specific* -selector. If two matching selectors have the same specificity, the binding -for the selector appearing later in the cascade takes precedence. - -Currently, there's no way to specify selector ordering within a single keymap, -because JSON objects do not preserve order. We eventually plan to introduce a -custom CSS-like file format for keymaps that allows for ordering within a single -file. For now, we've opted to handle cases where selector ordering is critical -by breaking the keymap into two separate files, such as `snippets-1.cson` and -`snippets-2.cson`. - -## Removing Bindings - -When the keymap system encounters a binding with the `unset!` directive as its -command, it will treat the current element as if it had no key bindings matching -the current keystroke sequence and continue searching from its parent. If you -want to remove a binding from a keymap you don't control, such as keymaps in -Atom core or in packages, use the `unset!` directive. - -For example, the following code removes the keybinding for `a` in the Tree View, -which is normally used to trigger the `tree-view:add-file` command: - -```coffee -'.tree-view': - 'a': 'unset!' -``` - -![](https://cloud.githubusercontent.com/assets/38924/3174771/e7f6ce64-ebf4-11e3-922d-f280bffb3fc5.png) - -## Forcing Chromium's Native Keystroke Handling - -If you want to force the native browser behavior for a given keystroke, use the -`native!` directive as the command of a binding. This can be useful to enable -the correct behavior in native input elements, for example. If you apply the -`.native-key-bindings` class to an element, all the keystrokes typically handled -by the browser will be assigned the `native!` directive. - -## Overloading Key Bindings - -Occasionally, it makes sense to layer multiple actions on top of the same key -binding. An example of this is the snippets package. Snippets are inserted by -typing a snippet prefix such as `for` and then pressing `tab`. Every time `tab` -is pressed, we want to execute code attempting to expand a snippet if one exists -for the text preceding the cursor. If a snippet *doesn't* exist, we want `tab` -to actually insert whitespace. - -To achieve this, the snippets package makes use of the `.abortKeyBinding()` -method on the event object representing the `snippets:expand` command. - -```coffee-script -# pseudo-code -editor.command 'snippets:expand', (e) => - if @cursorFollowsValidPrefix() - @expandSnippet() - else - e.abortKeyBinding() -``` - -When the event handler observes that the cursor does not follow a valid prefix, -it calls `e.abortKeyBinding()`, telling the keymap system to continue searching -for another matching binding. - -## Step-by-Step: How Keydown Events are Mapped to Commands - -* A keydown event occurs on a *focused* element. -* Starting at the focused element, the keymap walks upward towards the root of - the document, searching for the most specific CSS selector that matches the - current DOM element and also contains a keystroke pattern matching the keydown - event. -* When a matching keystroke pattern is found, the search is terminated and the - pattern's corresponding command is triggered on the current element. -* If `.abortKeyBinding()` is called on the triggered event object, the search - is resumed, triggering a binding on the next-most-specific CSS selector for - the same element or continuing upward to parent elements. -* If no bindings are found, the event is handled by Chromium normally. diff --git a/docs/advanced/node-modules.md b/docs/advanced/node-modules.md deleted file mode 100644 index 173713803..000000000 --- a/docs/advanced/node-modules.md +++ /dev/null @@ -1,24 +0,0 @@ -## Developing Node Modules - -Atom contains a number of packages that are Node modules instead of Atom packages. If you want to -make changes to the Node modules, for instance `atom-keymap`, you have to link them into the -development environment differently than you would a normal Atom package. - -### Linking a Node Module Into Your Atom Dev Environment - -Here are the steps to run a local version of a node module *not an apm* within Atom. We're using -`atom-keymap` as an example: - -```bash -$ git clone https://github.com/atom/atom-keymap.git -$ cd atom-keymap -$ npm install -$ npm link -$ apm rebuild # This is the special step, it makes the npm work with Atom's version of Node -$ cd WHERE-YOU-CLONED-ATOM -$ npm link atom-keymap -$ atom # Should work! -``` - -After this, you'll have to `npm install` and `apm rebuild` when you make a change to the node -module's code. diff --git a/docs/advanced/scopes-and-scope-descriptors.md b/docs/advanced/scopes-and-scope-descriptors.md deleted file mode 100644 index 7ee82995e..000000000 --- a/docs/advanced/scopes-and-scope-descriptors.md +++ /dev/null @@ -1,87 +0,0 @@ -# Scoped Settings, Scopes and Scope Descriptors - -Atom supports language-specific settings. You can soft wrap only Markdown files, or set the tab length to 4 in Python files. - -Language-specific settings are a subset of something more general we call "scoped settings". Scoped settings allow targeting down to a specific syntax token type. For example, you could conceivably set a setting to target only Ruby comments, only code inside Markdown files, or even only JavaScript function names. - -## Scope names in syntax tokens - -Each token in the editor has a collection of scope names. For example, the aformentioned JavaScript function name might have the scope names `function` and `name`. An open paren might have the scope names `punctuation`, `parameters`, `begin`. - -Scope names work just like CSS classes. In fact, in the editor, scope names are attached to a token's DOM node as CSS classes. - -Take this piece of JavaScript: - -```js -function functionName() { - console.log('Log it out'); -} -``` - -In the dev tools, the first line's markup looks like this. - -![screen shot 2014-10-14 at 11 21 35 am](https://cloud.githubusercontent.com/assets/69169/4634321/2b1b923c-53cf-11e4-9268-6e57bcb14ec8.png) - -All the class names on the spans are scope names. Any scope name can be used to target a setting's value. - -## Scope Selectors - -Scope selectors allow you to target specific tokens just like a CSS selector targets specific nodes in the DOM. Some examples: - -```coffee -'.source.js' # selects all javascript tokens -'.source.js .function.name' # selects all javascript function names -'.function.name' # selects all function names in any language -``` - -[Config::set][config-set] accepts a `scopeSelector`. If you'd like to set a setting for JavaScript function names, you can give it the js function name `scopeSelector`: - -```coffee -atom.config.set('.source.js .function.name', 'my-package.my-setting', 'special value') -``` - -## Scope Descriptors - -A scope descriptor is an [Object][scope-descriptor] that wraps an `Array` of -`String`s. The Array describes a path from the root of the syntax tree to a -token including _all_ scope names for the entire path. - -In our JavaScript example above, a scope descriptor for the function name token would be: - -```coffee -['source.js', 'meta.function.js', 'entity.name.function.js'] -``` - -[Config::get][config-get] accepts a `scopeDescriptor`. You can get the value for your setting scoped to JavaScript function names via: - -```coffee -scopeDescriptor = ['source.js', 'meta.function.js', 'entity.name.function.js'] -value = atom.config.get(scopeDescriptor, 'my-package.my-setting') -``` - -But, you do not need to generate scope descriptors by hand. There are a couple methods available to get the scope descriptor from the editor: - -* [Editor::getRootScopeDescriptor][editor-getRootScopeDescriptor] to get the language's descriptor. eg. `[".source.js"]` -* [Editor::scopeDescriptorForBufferPosition][editor-scopeDescriptorForBufferPosition] to get the descriptor at a specific position in the buffer. -* [Cursor::getScopeDescriptor][cursor-getScopeDescriptor] to get a cursor's descriptor based on position. eg. if the cursor were in the name of the method in our example it would return `["source.js", "meta.function.js", "entity.name.function.js"]` - -Let's revisit our example using these methods: - -```coffee -editor = atom.workspace.getActiveTextEditor() -cursor = editor.getLastCursor() -valueAtCursor = atom.config.get(cursor.getScopeDescriptor(), 'my-package.my-setting') -valueForLanguage = atom.config.get(editor.getRootScopeDescriptor(), 'my-package.my-setting') -``` - - -[config]:https://atom.io/docs/api/latest/Config -[config-get]:https://atom.io/docs/api/latest/Config#instance-get -[config-set]:https://atom.io/docs/api/latest/Config#instance-set -[config-observe]:https://atom.io/docs/api/latest/Config#instance-observe - -[editor-getRootScopeDescriptor]:https://atom.io/docs/api/latest/TextEditor#instance-getRootScopeDescriptor -[editor-scopeDescriptorForBufferPosition]:https://atom.io/docs/api/latest/TextEditor#instance-scopeDescriptorForBufferPosition - -[cursor-getScopeDescriptor]:https://atom.io/docs/api/latest/Cursor#instance-getScopeDescriptor -[scope-descriptor]:https://atom.io/docs/api/latest/ScopeDescriptor diff --git a/docs/advanced/serialization.md b/docs/advanced/serialization.md deleted file mode 100644 index c2a5f303a..000000000 --- a/docs/advanced/serialization.md +++ /dev/null @@ -1,75 +0,0 @@ -## Serialization in Atom - -When a window is refreshed or restored from a previous session, the view and its -associated objects are *deserialized* from a JSON representation that was stored -during the window's previous shutdown. For your own views and objects to be -compatible with refreshing, you'll need to make them play nicely with the -serializing and deserializing. - -### Package Serialization Hook - -Your package's main module can optionally include a `serialize` method, which -will be called before your package is deactivated. You should return JSON, which -will be handed back to you as an argument to `activate` next time it is called. -In the following example, the package keeps an instance of `MyObject` in the -same state across refreshes. - -```coffee-script -module.exports = - activate: (state) -> - @myObject = - if state - atom.deserializers.deserialize(state) - else - new MyObject("Hello") - - serialize: -> - @myObject.serialize() -``` - -### Serialization Methods - -```coffee-script -class MyObject - atom.deserializers.add(this) - - @deserialize: ({data}) -> new MyObject(data) - constructor: (@data) -> - serialize: -> { deserializer: 'MyObject', data: @data } -``` - -#### .serialize() -Objects that you want to serialize should implement `.serialize()`. This method -should return a serializable object, and it must contain a key named -`deserializer` whose value is the name of a registered deserializer that can -convert the rest of the data to an object. It's usually just the name of the -class itself. - -#### @deserialize(data) -The other side of the coin is the `deserialize` method, which is usually a -class-level method on the same class that implements `serialize`. This method's -job is to convert a state object returned from a previous call `serialize` back -into a genuine object. - -#### atom.deserializers.add(klass) -You need to call the `atom.deserializers.add` method with your class in -order to make it available to the deserialization system. Now you can call the -global `deserialize` method with state returned from `serialize`, and your -class's `deserialize` method will be selected automatically. - -### Versioning - -```coffee-script -class MyObject - atom.deserializers.add(this) - - @version: 2 - @deserialize: (state) -> ... - serialize: -> { version: @constructor.version, ... } -``` - -Your serializable class can optionally have a class-level `@version` property -and include a `version` key in its serialized state. When deserializing, Atom -will only attempt to call deserialize if the two versions match, and otherwise -return undefined. We plan on implementing a migration system in the future, but -this at least protects you from improperly deserializing old state. diff --git a/docs/build-instructions/freebsd.md b/docs/build-instructions/freebsd.md index 147c9da23..8029aa5f2 100644 --- a/docs/build-instructions/freebsd.md +++ b/docs/build-instructions/freebsd.md @@ -19,4 +19,18 @@ FreeBSD -RELEASE 64-bit is the recommended platform. sudo script/grunt install # Installs command to /usr/local/bin/atom ``` +## Advanced Options + +### Custom install directory + +```sh +sudo script/grunt install --install-dir /install/atom/here +``` + +### Custom build directory + +```sh +script/build --build-dir /build/atom/here +``` + ## Troubleshooting diff --git a/docs/build-instructions/linux.md b/docs/build-instructions/linux.md index 7b95e8a17..4874489ab 100644 --- a/docs/build-instructions/linux.md +++ b/docs/build-instructions/linux.md @@ -38,7 +38,7 @@ Ubuntu LTS 12.04 64-bit is the recommended platform. ### openSUSE -* `sudo zypper install nodejs make gcc gcc-c++ glibc-devel git-core libgnome-keyring-devel rpmdevtools` +* `sudo zypper install nodejs nodejs-devel make gcc gcc-c++ glibc-devel git-core libgnome-keyring-devel rpmdevtools` ## Instructions diff --git a/docs/build-instructions/os-x.md b/docs/build-instructions/os-x.md index d8685cff8..7201676b1 100644 --- a/docs/build-instructions/os-x.md +++ b/docs/build-instructions/os-x.md @@ -14,6 +14,11 @@ script/build # Creates application at /Applications/Atom.app ``` +### `script/build` Options + * `--install-dir` - The full path to the final built application (must include `.app` in the path), e.g. `script/build --install-dir /Users/username/full/path/to/Atom.app` + * `--build-dir` - Build the application in this directory. + * `--verbose` - Verbose mode. A lot more information output. + ## Troubleshooting ### OSX build error reports in atom/atom diff --git a/docs/build-instructions/windows.md b/docs/build-instructions/windows.md index fddb941c7..a5d0757cb 100644 --- a/docs/build-instructions/windows.md +++ b/docs/build-instructions/windows.md @@ -24,14 +24,19 @@ ## Instructions - ```bat - # Use the `Git Shell` app which was installed by GitHub for Windows. Also Make - # sure you have logged into the GitHub for Windows GUI App. - cd C:\ - git clone https://github.com/atom/atom/ - cd atom - script/build # Creates application in the `Program Files` directory - ``` +```bash +# Use the `Git Shell` program which was installed by GitHub for Windows. +# Also make sure that you are logged into GitHub for Windows. +cd C:\ +git clone https://github.com/atom/atom/ +cd atom +script/build # Creates application in the `Program Files` directory +``` + +### `script/build` Options + * `--install-dir` - Creates the final built application in this directory. + * `--build-dir` - Build the application in this directory. + * `--verbose` - Verbose mode. A lot more information output. ## Why do I have to use GitHub for Windows? @@ -78,6 +83,7 @@ If none of this works, do install Github for Windows and use its Git shell. Make ``` $env:GYP_MSVS_VERSION=2013 ``` + * If you are using Visual Studio 2013 and the build fails with some other error message this environment variable might still be required. * Other `node-gyp` errors on first build attempt, even though the right node and python versions are installed. * Do try the build command one more time, as experience shows it often works on second try in many of these cases. diff --git a/docs/contributing-to-packages.md b/docs/contributing-to-packages.md index 42de6e37b..4576635ff 100644 --- a/docs/contributing-to-packages.md +++ b/docs/contributing-to-packages.md @@ -40,9 +40,8 @@ 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, -which is normally bound to `cmd-shift-o`. You can also run dev mode from the -command line with `atom --dev`. +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 diff --git a/docs/converting-a-text-mate-bundle.md b/docs/converting-a-text-mate-bundle.md deleted file mode 100644 index 3471bc83b..000000000 --- a/docs/converting-a-text-mate-bundle.md +++ /dev/null @@ -1,52 +0,0 @@ -## Converting a TextMate Bundle - -This guide will show you how to convert a [TextMate][TextMate] bundle to an -Atom package. - -Converting a TextMate bundle will allow you to use its editor preferences, -snippets, and colorization inside Atom. - -### Install apm - -The `apm` command line utility that ships with Atom supports converting -a TextMate bundle to an Atom package. - -Check that you have `apm` installed by running the following command in your -terminal: - -```sh -apm help init -``` - -You should see a message print out with details about the `apm init` command. - -If you do not, launch Atom and run the _Atom > Install Shell Commands_ menu -to install the `apm` and `atom` commands. - -### Convert the Package - -Let's convert the TextMate bundle for the [R][R] programming language. You can find other existing TextMate bundles [here][TextMateOrg]. - -You can convert the R bundle with the following command: - -```sh -apm init --package ~/.atom/packages/language-r --convert https://github.com/textmate/r.tmbundle -``` - -You can now browse to `~/.atom/packages/language-r` to see the converted bundle. - -:tada: Your new package is now ready to use, launch Atom and open a `.r` file in -the editor to see it in action! - -### Further Reading - -* Check out [Publishing a Package](publishing-a-package.html) for more information - on publishing the package you just created to [atom.io][atomio]. - -[atomio]: https://atom.io -[CSS]: https://en.wikipedia.org/wiki/Cascading_Style_Sheets -[Less]: http://lesscss.org -[plist]: https://en.wikipedia.org/wiki/Property_list -[R]: https://en.wikipedia.org/wiki/R_(programming_language) -[TextMate]: http://macromates.com -[TextMateOrg]: https://github.com/textmate diff --git a/docs/converting-a-text-mate-theme.md b/docs/converting-a-text-mate-theme.md deleted file mode 100644 index 79dacd523..000000000 --- a/docs/converting-a-text-mate-theme.md +++ /dev/null @@ -1,68 +0,0 @@ -## Converting a TextMate Theme - -This guide will show you how to convert a [TextMate][TextMate] theme to an Atom -theme. - -### Differences - -TextMate themes use [plist][plist] files while Atom themes use [CSS][CSS] or -[Less][Less] to style the UI and syntax in the editor. - -The utility that converts the theme first parses the theme's plist file and -then creates comparable CSS rules and properties that will style Atom similarly. - -### Install apm - -The `apm` command line utility that ships with Atom supports converting -a TextMate theme to an Atom theme. - -Check that you have `apm` installed by running the following command in your -terminal: - -```sh -apm help init -``` - -You should see a message print out with details about the `apm init` command. - -If you do not, launch Atom and run the _Atom > Install Shell Commands_ menu -to install the `apm` and `atom` commands. - -You can now run `apm help init` to see all the options for initializing new -packages and themes. - -### Convert the Theme - -Download the theme you wish to convert, you can browse existing TextMate themes -[here][TextMateThemes]. - -Now, let's say you've downloaded the theme to `~/Downloads/MyTheme.tmTheme`, -you can convert the theme with the following command: - -```sh -apm init --theme ~/.atom/packages/my-theme --convert ~/Downloads/MyTheme.tmTheme -``` - -You can browse to `~/.atom/packages/my-theme` to see the converted theme. - -### Activate the Theme - -Now that your theme is installed to `~/.atom/packages` you can enable it -by launching Atom and selecting the _Atom > Preferences..._ menu. - -Select the _Themes_ link on the left side and choose _My Theme_ from the -__Syntax Theme__ dropdown menu to enable your new theme. - -:tada: Your theme is now enabled, open an editor to see it in action! - -### Further Reading - -* Check out [Publishing a Package](publishing-a-package.html) for more information - on publishing the theme you just created to [atom.io][atomio]. - -[atomio]: https://atom.io -[CSS]: https://en.wikipedia.org/wiki/Cascading_Style_Sheets -[Less]: http://lesscss.org -[plist]: https://en.wikipedia.org/wiki/Property_list -[TextMate]: http://macromates.com -[TextMateThemes]: http://wiki.macromates.com/Themes/UserSubmittedThemes diff --git a/docs/creating-a-package.md b/docs/creating-a-package.md deleted file mode 100644 index d00f6ecc1..000000000 --- a/docs/creating-a-package.md +++ /dev/null @@ -1,515 +0,0 @@ -# Creating Packages - -Packages are at the core of Atom. Nearly everything outside of the main editor -is handled by a package. That includes "core" pieces like the [file tree][file-tree], -[status bar][status-bar], [syntax highlighting][cs-syntax], and more. - -A package can contain a variety of different resource types to change Atom's -behavior. The basic package layout is as follows: - -```text -my-package/ - grammars/ - keymaps/ - lib/ - menus/ - spec/ - snippets/ - styles/ - index.coffee - package.json -``` - -Not every package will have (or need) all of these directories. - -We have [a tutorial on creating your first package][first-package]. - -There are also guides for converting [TextMate bundles][convert-bundle] and -[TextMate themes][convert-theme] so they work in Atom. - -## package.json - -Similar to [npm packages][npm], Atom packages contain a _package.json_ file -in their top-level directory. This file contains metadata about the package, -such as the path to its "main" module, library dependencies, and manifests -specifying the order in which its resources should be loaded. - -In addition to the regular [npm package.json keys][npm-keys] available, Atom -package.json files have their own additions. - -- `main` (**Required**): the path to the CoffeeScript file that's the entry point -to your package. -- `styles` (**Optional**): an Array of Strings identifying the order of the -style sheets your package needs to load. If not specified, style sheets in the -_styles_ directory are added alphabetically. -- `keymaps`(**Optional**): an Array of Strings identifying the order of the -key mappings your package needs to load. If not specified, mappings in the -_keymaps_ directory are added alphabetically. -- `menus`(**Optional**): an Array of Strings identifying the order of -the menu mappings your package needs to load. If not specified, mappings -in the _menus_ directory are added alphabetically. -- `snippets` (**Optional**): an Array of Strings identifying the order of the -snippets your package needs to load. If not specified, snippets in the -_snippets_ directory are added alphabetically. -- `activationCommands` (**Optional**): an Array of Strings identifying commands that -trigger your package's activation. You can delay the loading of your package -until one of these events is triggered. -- `providedServices` (**Optional**): an Object describing the services that your -package provides, which can be used by other packages. The keys of this object -are the names of the services, and the values are Objects with the following -keys: - - `description` (**Optional**) a String describing the service - - `versions` (**Required**) an Object whose keys are Semver version strings, - and whose values are names of methods in your package's top-level module - that return a value implementing the service. -- `consumedServices` (**Optional**): an Object describing the services that your -package uses, which can be provided by other packages. The keys of this object -are the names of the services, and the values are Objects with the following -keys: - - `versions` (**Required**) an Object whose keys are Semver version ranges - and whose values are names of methods in your package's top-level module - that are called with values implementing the service. - -## Source Code - -If you want to extend Atom's behavior, your package should contain a single -top-level module, which you export from _index.coffee_ (or whichever file is -indicated by the `main` key in your _package.json_ file). The remainder of your -code should be placed in the `lib` directory, and required from your top-level -file. - -Your package's top-level module is a singleton object that manages the lifecycle -of your extensions to Atom. Even if your package creates ten different views and -appends them to different parts of the DOM, it's all managed from your top-level -object. - -Your package's top-level module should implement the following methods: - -- `activate(state)`: This **required** method is called when your -package is activated. It is passed the state data from the last time the window -was serialized if your module implements the `serialize()` method. Use this to -do initialization work when your package is started (like setting up DOM -elements or binding events). - -- `serialize()`: This **optional** method is called when the window is shutting -down, allowing you to return JSON to represent the state of your component. When -the window is later restored, the data you returned is passed to your -module's `activate` method so you can restore your view to where the user left -off. - -- `deactivate()`: This **optional** method is called when the window is shutting -down, or when your package is being updated or disabled. If your package is -watching any files, holding external resources, providing commands or subscribing -to events, release them here. - -### Simple Package Code - -Your directory would look like this: - -```text -my-package/ - package.json - index.coffee - lib/ - my-package.coffee -``` - -`index.coffee` might be: -```coffeescript -module.exports = require "./lib/my-package" -``` - -`my-package/my-package.coffee` might start: -```coffeescript -module.exports = - activate: (state) -> # ... - deactivate: -> # ... - serialize: -> # ... -``` - -Beyond this simple contract, your package has access to [Atom's API][api]. Be aware -that the Atom 1.0 API is mostly frozen. Refer to the API documentation for what -is public. That said, please collaborate with us if you need an API that doesn't -exist. Our goal is to build out Atom's API organically based on the needs of -package authors like you. - -## Style Sheets - -Style sheets for your package should be placed in the _styles_ directory. -Any style sheets in this directory will be loaded and attached to the DOM when -your package is activated. Style sheets can be written as CSS or [Less], but -Less is recommended. - -Ideally, you won't need much in the way of styling. We've provided a standard -set of components which define both the colors and UI elements for any package -that fits into Atom seamlessly. You can view all of Atom's UI components by -opening the styleguide: open the command palette (`cmd-shift-P`) and search for -_styleguide_, or just type `cmd-ctrl-shift-G`. - -If you _do_ need special styling, try to keep only structural styles in the -package style sheets. If you _must_ specify colors and sizing, these should be -taken from the active theme's [ui-variables.less][ui-variables]. For more -information, see the [theme variables docs][theme-variables]. If you follow this -guideline, your package will look good out of the box with any theme! - -An optional `styleSheets` array in your _package.json_ can list the style sheets -by name to specify a loading order; otherwise, style sheets are loaded -alphabetically. - -## Keymaps - -It's recommended that you provide key bindings for commonly used actions for -your extension, especially if you're also adding a new command: - -```coffeescript -'.tree-view-scroller': - 'ctrl-V': 'changer:magic' -``` - -Keymaps are placed in the _keymaps_ subdirectory. By default, all keymaps are -loaded in alphabetical order. An optional `keymaps` array in your _package.json_ -can specify which keymaps to load and in what order. - - -Keybindings are executed by determining which element the keypress occurred on. -In the example above, `changer:magic` command is executed when pressing `ctrl-V` -on the `.tree-view-scroller` element. - -See the [main keymaps documentation][keymaps] for more detailed information on -how keymaps work. - -## Menus - -Menus are placed in the _menus_ subdirectory. By default, all menus are loaded -in alphabetical order. An optional `menus` array in your _package.json_ can -specify which menus to load and in what order. - -### Application Menu - -It's recommended that you create an application menu item for common actions -with your package that aren't tied to a specific element: - -```coffeescript -'menu': [ - { - 'label': 'Packages' - 'submenu': [ - { - 'label': 'My Package' - 'submenu': [ - { - 'label': 'Toggle' - 'command': 'my-package:toggle' - } - ] - } - ] - } -] -``` - -To add your own item to the application menu, simply create a top level `menu` -key in any menu configuration file in _menus_. This can be a JSON or [CSON] -file. - -The menu templates you specify are merged with all other templates provided -by other packages in the order which they were loaded. - -### Context Menu - -It's recommended to specify a context menu item for commands that are linked to -specific parts of the interface, like adding a file in the tree-view: - -```coffeescript -'context-menu': - '.tree-view': [ - {label: 'Add file', command: 'tree-view:add-file'} - ] - 'atom-workspace': [ - {label: 'Inspect Element', command: 'core:inspect'} - ] -``` - -To add your own item to the application menu simply create a top level -`context-menu` key in any menu configuration file in _menus_. This can be a -JSON or [CSON] file. - -Context menus are created by determining which element was selected and then -adding all of the menu items whose selectors match that element (in the order -which they were loaded). The process is then repeated for the elements until -reaching the top of the DOM tree. - -In the example above, the `Add file` item will only appear when the focused item -or one of its parents has the `tree-view` class applied to it. - -You can also add separators and submenus to your context menus. To add a -submenu, provide a `submenu` key instead of a command. To add a separator, add -an item with a single `type: 'separator'` key/value pair. - -```coffeescript -'context-menu': - 'atom-workspace': [ - { - label: 'Text' - submenu: [ - {label: 'Inspect Element', command: 'core:inspect'} - {type: 'separator'} - {label: 'Selector All', command: 'core:select-all'} - {type: 'separator'} - {label: 'Deleted Selected Text', command: 'core:delete'} - ] - } - ] -``` - -## Snippets - -An extension can supply language snippets in the _snippets_ directory which -allows the user to enter repetitive text quickly: - -```coffeescript -".source.coffee .specs": - "Expect": - prefix: "ex" - body: "expect($1).to$2" - "Describe": - prefix: "de" - body: """ - describe "${1:description}", -> - ${2:body} - """ -``` - -A snippets file contains scope selectors at its top level (`.source.coffee -.spec`). Each scope selector contains a hash of snippets keyed by their name -(`Expect`, `Describe`). Each snippet also specifies a `prefix` and a `body` key. -The `prefix` represents the first few letters to type before hitting the `tab` -key to autocomplete. The `body` defines the autofilled text. You can use -placeholders like `$1`, `$2`, to indicate regions in the body the user can -navigate to every time they hit `tab`. - -All files in the directory are automatically loaded, unless the _package.json_ -supplies a `snippets` key. As with all scoped items, snippets loaded later take -precedence over earlier snippets when two snippets match a scope with the same -specificity. - -## Language Grammars - -If you're developing a new language grammar, you'll want to place your file in -the _grammars_ directory. Each grammar is a pairing of two keys, `match` and -`captures`. `match` is a regular expression identifying the pattern to -highlight, while `captures` is an object representing what to do with each -matching group. - -For example: - - -```coffeescript -{ - 'match': '(?:^|\\s)(__[^_]+__)' - 'captures': - '1': 'name': 'markup.bold.gfm' -} -``` - -This indicates that the first matching capture (`(__[^_]+__)`) should have the -`markup.bold.gfm` token applied to it. - -To capture a single group, simply use the `name` key instead: - -```coffeescript -{ - 'match': '^#{1,6}\\s+.+$' - 'name': 'markup.heading.gfm' -} -``` - -This indicates that Markdown header lines (`#`, `##`, `###`) should be applied -with the `markup.heading.gfm` token. - -More information about the significance of these tokens can be found in -[section 12.4 of the TextMate Manual][tm-tokens]. - -Your grammar should also include a `filetypes` array, which is a list of file -extensions your grammar supports: - -```coffeescript -'fileTypes': [ - 'markdown' - 'md' - 'mkd' - 'mkdown' - 'ron' -] -``` - -## Adding Configuration Settings - -You can support config settings in your package that are editable in the -settings view. Specify a `config` key in your package main: - -```coffeescript -module.exports = - # Your config schema! - config: - someInt: - type: 'integer' - default: 23 - minimum: 1 - activate: (state) -> # ... - # ... -``` - -To define the configuration, we use [json schema][json-schema] which allows you -to indicate the type your value should be, its default, etc. - -See the [Config API Docs](https://atom.io/docs/api/latest/Config) for more -details specifying your configuration. - -## Interacting With Other Packages Via Services - -Atom packages can interact with each other through versioned APIs called -*services*. To provide a service, in your `package.json`, specify one or more -version numbers, each paired with the name of a method on your package's main module: - -```json -{ - "providedServices": { - "my-service": { - "description": "Does a useful thing", - "versions": { - "1.2.3": "provideMyServiceV1", - "2.3.4": "provideMyServiceV2", - } - } - } -} -``` - -In your package's main module, implement the methods named above. These methods -will be called any time a package is activated that consumes their corresponding -service. They should return a value that implements the service's API. - - -```coffeescript -module.exports = - activate: -> # ... - - provideMyServiceV1: -> - adaptToLegacyAPI(myService) - - provideMyServiceV2: -> - myService -``` - -Similarly, to consume a service, specify one or more [version *ranges*][version-ranges], -each paired with the name of a method on the package's main module: - -```json -{ - "consumedServices": { - "another-service": { - "versions": { - "^1.2.3": "consumeAnotherServiceV1", - ">=2.3.4 <2.5": "consumeAnotherServiceV2", - } - } - } -} -``` - -These methods will be called any time a package is activated that *provides* their -corresponding service. They will receive the service object as an argument. You -will usually need to perform some kind of cleanup in the event that the package -providing the service is deactivated. To do this, return a `Disposable` from -your service-consuming method: - -```coffeescript -{Disposable} = require 'atom' - -module.exports = - activate: -> # ... - - consumeAnotherServiceV1: (service) -> - useService(adaptServiceFromLegacyAPI(service)) - new Disposable -> stopUsingService(service) - - consumeAnotherServiceV2: (service) -> - useService(service) - new Disposable -> stopUsingService(service) -``` - -## Bundle External Resources - -It's common to ship external resources like images and fonts in the package, to -make it easy to reference the resources in HTML or CSS, you can use the `atom` -protocol URLs to load resources in the package. - -The URLs should be in the format of -`atom://package-name/relative-path-to-package-of-resource`, for example, the -`atom://image-view/images/transparent-background.gif` would be equivalent to -`~/.atom/packages/image-view/images/transparent-background.gif`. - -You can also use the `atom` protocol URLs in themes. - -## Writing Tests - -Your package **should** have tests, and if they're placed in the _spec_ -directory, they can be run by Atom. - -Under the hood, [Jasmine] executes your tests, so you can assume that any DSL -available there is also available to your package. - -## Running Tests - -Once you've got your test suite written, you can run it by pressing -`cmd-alt-ctrl-p` or via the _Developer > Run Package Specs_ menu. - -You can also use the `apm test` command to run them from the command line. It -prints the test output and results to the console and returns the proper status -code depending on whether the tests passed or failed. - -## Publishing - -Atom bundles a command line utility called apm which can be used to publish -Atom packages to the public registry. - -Once your package is written and ready for distribution you can run the -following to publish your package: - -```sh -cd my-package -apm publish minor -``` - -This will update your `package.json` to have a new minor `version`, commit the -change, create a new [Git tag][git-tag], and then upload the package to the -registry. - -Run `apm help publish` to see all the available options and `apm help` to see -all the other available commands. - -[api]: https://atom.io/docs/api/latest -[file-tree]: https://github.com/atom/tree-view -[status-bar]: https://github.com/atom/status-bar -[cs-syntax]: https://github.com/atom/language-coffee-script -[npm]: https://en.wikipedia.org/wiki/Npm_(software) -[npm-keys]: https://docs.npmjs.com/files/package.json -[git-tag]: http://git-scm.com/book/en/Git-Basics-Tagging -[wrap-guide]: https://github.com/atom/wrap-guide/ -[keymaps]: advanced/keymaps.md -[theme-variables]: theme-variables.md -[tm-tokens]: http://manual.macromates.com/en/language_grammars.html -[spacepen]: https://github.com/nathansobo/space-pen -[path]: http://nodejs.org/docs/latest/api/path.html -[jquery]: http://jquery.com/ -[underscore]: http://underscorejs.org/ -[jasmine]: http://jasmine.github.io -[cson]: https://github.com/atom/season -[Less]: http://lesscss.org -[ui-variables]: https://github.com/atom/atom-dark-ui/blob/master/styles/ui-variables.less -[first-package]: your-first-package.html -[convert-bundle]: converting-a-text-mate-bundle.html -[convert-theme]: converting-a-text-mate-theme.html -[json-schema]: http://json-schema.org/ -[version-ranges]: https://docs.npmjs.com/misc/semver#ranges diff --git a/docs/creating-a-theme.md b/docs/creating-a-theme.md deleted file mode 100644 index 6e8ab1dc4..000000000 --- a/docs/creating-a-theme.md +++ /dev/null @@ -1,148 +0,0 @@ -# Creating a Theme - -Atom's interface is rendered using HTML, and it's styled via [Less] which is a -superset of CSS. Don't worry if you haven't heard of Less before; it's just like -CSS, but with a few handy extensions. - -Atom supports two types of themes: _UI_ and _syntax_. UI themes style -elements such as the tree view, the tabs, drop-down lists, and the status bar. -Syntax themes style the code inside the editor. - -Themes can be installed and changed from the settings view which you can open -by selecting the _Atom > Preferences..._ menu and navigating to the _Install_ -section and the _Themes_ section on the left hand side. - -## Getting Started - -Themes are pretty straightforward but it's still helpful to be familiar with -a few things before starting: - -* Less is a superset of CSS, but it has some really handy features like - variables. If you aren't familiar with its syntax, take a few minutes - to [familiarize yourself][less-tutorial]. -* You may also want to review the concept of a _[package.json]_, too. This file - is used to help distribute your theme to Atom users. -* Your theme's _package.json_ must contain a `"theme"` key with a value - of `"ui"` or `"syntax"` for Atom to recognize and load it as a theme. -* You can find existing themes to install or fork on - [atom.io][atomio-themes]. - -## Creating a Syntax Theme - -Let's create your first theme. - -To get started, hit `cmd-shift-P`, and start typing "Generate Syntax Theme" to -generate a new theme package. Select "Generate Syntax Theme," and you'll be -asked for the path where your theme will be created. Let's call ours -_motif-syntax_. __Tip:__ syntax themes should end with _-syntax_. - -Atom will pop open a new window, showing the _motif-syntax_ theme, with a -default set of folders and files created for us. If you open the settings view -(`cmd-,`) and navigate to the _Themes_ section on the left, you'll see the -_Motif_ theme listed in the _Syntax Theme_ drop-down. Select it from the menu to -activate it, now when you open an editor you should see that your new -_motif-syntax_ theme in action. - -Open up _styles/colors.less_ to change the various colors variables which -have been already been defined. For example, turn `@red` into `#f4c2c1`. - -Then open _styles/base.less_ and modify the various selectors that have -been already been defined. These selectors style different parts of code in the -editor such as comments, strings and the line numbers in the gutter. - -As an example, let's make the `.gutter` `background-color` into `@red`. - -Reload Atom by pressing `cmd-alt-ctrl-l` to see the changes you made reflected -in your Atom window. Pretty neat! - -__Tip:__ You can avoid reloading to see changes you make by opening an atom -window in dev mode. To open a Dev Mode Atom window run `atom --dev .` in the -terminal, use `cmd-shift-o` or use the _View > Developer > Open in Dev Mode_ -menu. When you edit your theme, changes will instantly be reflected! - -> Note: It's advised to _not_ specify a `font-family` in your syntax theme because it will override the Font Family field in Atom's settings. If you still like to recommend a font that goes well with your theme, we recommend you do so in your README. - -## Creating an Interface Theme - -Interface themes **must** provide a `ui-variables.less` file which contains all -of the variables provided by the [core themes][ui-variables]. - -To create an interface UI theme, do the following: - -1. Fork one of the following repositories: - * [atom-dark-ui] - * [atom-light-ui] -2. Clone the forked repository to the local filesystem -3. Open a terminal in the forked theme's directory -4. Open your new theme in a Dev Mode Atom window run `atom --dev .` in the - terminal or use the _View > Developer > Open in Dev Mode_ menu -5. Change the name of the theme in the theme's `package.json` file -6. Name your theme end with a `-ui`. i.e. `super-white-ui` -7. Run `apm link` to symlink your repository to `~/.atom/packages` -8. Reload Atom using `cmd-alt-ctrl-L` -9. Enable the theme via _UI Theme_ drop-down in the _Themes_ section of the - settings view -10. Make changes! Since you opened the theme in a Dev Mode window, changes will - be instantly reflected in the editor without having to reload. - -## Development workflow - -There are a few of tools to help make theme development faster and easier. - -### Live Reload - -Reloading by hitting `cmd-alt-ctrl-L` after you make changes to your theme is -less than ideal. Atom supports [live updating][livereload] of styles on Dev Mode -Atom windows. - -To enable a Dev Mode window: - -1. Open your theme directory in a dev window by either going to the - __View > Developer > Open in Dev Mode__ menu or by hitting the `cmd-shift-o` - shortcut -2. Make a change to your theme file and save it. Your change should be - immediately applied! - -If you'd like to reload all the styles at any time, you can use the shortcut -`cmd-ctrl-shift-r`. - -### Developer Tools - -Atom is based on the Chrome browser, and supports Chrome's Developer Tools. You -can open them by selecting the _View > Toggle Developer Tools_ menu, or by -using the `cmd-alt-i` shortcut. - -The dev tools allow you to inspect elements and take a look at their CSS -properties. - -![devtools-img] - -Check out Google's [extensive tutorial][devtools-tutorial] for a short -introduction. - -### Atom Styleguide - -If you are creating an interface theme, you'll want a way to see how your theme -changes affect all the components in the system. The [styleguide] is a page that -renders every component Atom supports. - -To open the styleguide, open the command palette (`cmd-shift-P`) and search for -_styleguide_, or use the shortcut `cmd-ctrl-shift-g`. - -![styleguide-img] - -[atomio-themes]: https://atom.io/themes -[Less]: http://lesscss.org/ -[git]: http://git-scm.com/ -[atom]: https://atom.io/ -[package.json]: ./creating-a-package.html#package-json -[less-tutorial]: https://speakerdeck.com/danmatthews/less-css -[devtools-tutorial]: https://developer.chrome.com/devtools/docs/dom-and-styles -[ui-variables]: ./theme-variables.html -[livereload]: https://github.com/atom/dev-live-reload -[styleguide]: https://github.com/atom/styleguide -[atom-dark-ui]: https://github.com/atom/atom-dark-ui -[atom-light-ui]: https://github.com/atom/atom-light-ui -[styleguide-img]: https://f.cloud.github.com/assets/69169/1347390/2d431d98-36af-11e3-8f8e-3f4ce1e67adb.png -[devtools-img]: https://f.cloud.github.com/assets/69169/1347391/2d51f91c-36af-11e3-806f-f7b334af43e9.png -[themesettings-img]: https://f.cloud.github.com/assets/69169/1347569/3150bd0c-36b2-11e3-9d69-423503acfe3f.png diff --git a/docs/customizing-atom.md b/docs/customizing-atom.md deleted file mode 100644 index d56bd640a..000000000 --- a/docs/customizing-atom.md +++ /dev/null @@ -1,193 +0,0 @@ -# Customizing Atom - -To change a setting, configure a theme, or install a package just open the -Settings view in the current window by pressing `cmd-,`. - -## Changing The Theme - -Atom comes with both light and dark UI themes as well as several syntax themes. -You are also encouraged to [create or fork][create-theme] your own theme. - -To change the active theme just open the Settings view (`cmd-,`) and select the -`Themes` section from the left hand side. You will see a drop-down menu to -change the active _Syntax_ and _UI_ themes. - -You can also install more themes from here by browsing the featured themes or -searching for a specific theme. - -## Installing Packages - -You can install non-bundled packages by going to the `Packages` section on left -hand side of the Settings view (`cmd-,`). You will see several featured packages -and you can also search for packages from here. The packages listed here have -been published to [atom.io](http://atom.io/packages) which is the official -registry for Atom packages. - -You can also install packages from the command line using `apm`. - -Check that you have `apm` installed by running the following command in your -terminal: - -```sh -apm help install -``` - -You should see a message print out with details about the `apm install` command. - -If you do not, launch Atom and run the _Atom > Install Shell Commands_ menu -to install the `apm` and `atom` commands. - -You can also install packages by using the `apm install` command: - -* `apm install ` to install the latest version. - -* `apm install @` to install a specific version. - -For example `apm install emmet@0.1.5` installs the `0.1.5` release of the -[Emmet](https://github.com/atom/emmet) package into `~/.atom/packages`. - -You can also use `apm` to find new packages to install: - -* `apm search coffee` to search for CoffeeScript packages. - -* `apm view emmet` to see more information about a specific package. - -## Customizing Key Bindings - -Atom keymaps work similarly to style sheets. Just as style sheets use selectors -to apply styles to elements, Atom keymaps use selectors to associate keystrokes -with events in specific contexts. Here's a small example, excerpted from Atom's -built-in keymaps: - -```coffee -'atom-text-editor': - 'enter': 'editor:newline' - -'atom-text-editor[mini] input': - 'enter': 'core:confirm' -``` - -This keymap defines the meaning of `enter` in two different contexts. In a -normal editor, pressing `enter` emits the `editor:newline` event, which causes -the editor to insert a newline. But if the same keystroke occurs inside of a -select list's mini-editor, it instead emits the `core:confirm` event based on -the binding in the more-specific selector. - -By default, `~/.atom/keymap.cson` is loaded when Atom is started. It will always -be loaded last, giving you the chance to override bindings that are defined by -Atom's core keymaps or third-party packages. - -You can open this file in an editor from the _Atom > Open Your Keymap_ menu. - -You'll want to know all the commands available to you. Open the Settings panel -(`cmd-,`) and select the _Keybindings_ tab. It will show you all the keybindings -currently in use. - -## Advanced Configuration - -Atom loads configuration settings from the `config.cson` file in your _~/.atom_ -directory, which contains [CoffeeScript-style JSON][CSON] (CSON): - -```coffee -'core': - 'excludeVcsIgnoredPaths': true -'editor': - 'fontSize': 18 -``` - -The configuration itself is grouped by the package name or one of the two core -namespaces: `core` and `editor`. - -You can open this file in an editor from the _Atom > Open Your Config_ menu. - -### Custom Configuration Location - -You can override the location that Atom stores configuration files and folders -in by setting the `ATOM_HOME` environment variable. The `ATOM_HOME` path will be -used instead of `~/.atom` when it is set. - -This option can be useful when you want to make Atom portable across machines. - -### Configuration Key Reference - -- `core` - - `disabledPackages`: An array of package names to disable - - `excludeVcsIgnoredPaths`: Don't search within files specified by _.gitignore_ - - `followSymlinks`: Follow symlinks when searching and scanning root directory - - `ignoredNames`: File names to ignore across all of Atom - - `projectHome`: The directory where projects are assumed to be located - - `themes`: An array of theme names to load, in cascading order -- `editor` - - `autoIndent`: Enable/disable basic auto-indent (defaults to `true`) - - `nonWordCharacters`: A string of non-word characters to define word boundaries - - `fontSize`: The editor font size - - `fontFamily`: The editor font family - - `invisibles`: Specify characters that Atom renders for invisibles in this hash - - `tab`: Hard tab characters - - `cr`: Carriage return (for Microsoft-style line endings) - - `eol`: `\n` characters - - `space`: Leading and trailing space characters - - `preferredLineLength`: Identifies the length of a line (defaults to `80`) - - `showInvisibles`: Whether to render placeholders for invisible characters (defaults to `false`) - - `showIndentGuide`: Show/hide indent indicators within the editor - - `showLineNumbers`: Show/hide line numbers within the gutter - - `softWrap`: Enable/disable soft wrapping of text within the editor - - `softWrapAtPreferredLineLength`: Enable/disable soft line wrapping at `preferredLineLength` - - `tabLength`: Number of spaces within a tab (defaults to `2`) -- `fuzzyFinder` - - `ignoredNames`: Files to ignore *only* in the fuzzy-finder -- `whitespace` - - `ensureSingleTrailingNewline`: Whether to reduce multiple newlines to one at the end of files - - `removeTrailingWhitespace`: Enable/disable striping of whitespace at the end of lines (defaults to `true`) -- `wrap-guide` - - `columns`: Array of hashes with a `pattern` and `column` key to match the - the path of the current editor to a column position. - -### Quick Personal Hacks - -### init.coffee - -When Atom finishes loading, it will evaluate _init.coffee_ in your _~/.atom_ -directory, giving you a chance to run arbitrary personal [CoffeeScript][] code to -make customizations. You have full access to Atom's API from code in this file. -If customizations become extensive, consider [creating a package][creating-a-package]. - -You can open this file in an editor from the _Atom > Open Your Init Script_ -menu. - -For example, if you have the Audio Beep configuration setting enabled, you -could add the following code to your _~/.atom/init.coffee_ file to have Atom -greet you with an audio beep every time it loads: - -```coffee -atom.beep() -``` - -This file can also be named _init.js_ and contain JavaScript code. - -### styles.less - -If you want to apply quick-and-dirty personal styling changes without creating -an entire theme that you intend to publish, you can add styles to the -_styles.less_ file in your _~/.atom_ directory. - -You can open this file in an editor from the _Atom > Open Your Stylesheet_ menu. - -For example, to change the color of the cursor, you could add the following -rule to your _~/.atom/styles.less_ file: - -```less -atom-text-editor::shadow .cursor { - border-color: pink; -} -``` - -Unfamiliar with Less? Read more about it [here][Less]. - -This file can also be named _styles.css_ and contain CSS. - -[creating-a-package]: creating-a-package.md -[create-theme]: creating-a-theme.md -[Less]: http://www.lesscss.org -[CSON]: https://github.com/atom/season -[CoffeeScript]: http://coffeescript.org/ diff --git a/docs/debugging.md b/docs/debugging.md deleted file mode 100644 index ec202e7f8..000000000 --- a/docs/debugging.md +++ /dev/null @@ -1,133 +0,0 @@ -# Debugging - -Atom provides several tools to help you understand unexpected behavior and debug problems. This guide describes some of those tools and a few approaches to help you debug and provide more helpful information when [submitting issues]: - -* [Update to the latest version](#update-to-the-latest-version) -* [Check for linked packages](#check-for-linked-packages) -* [Check Atom and package settings](#check-atom-and-package-settings) -* [Check the keybindings](#check-the-keybindings) -* [Check if the problem shows up in safe mode](#check-if-the-problem-shows-up-in-safe-mode) -* [Check your config files](#check-your-config-files) -* [Check for errors in the developer tools](#check-for-errors-in-the-developer-tools) - -## Update to the latest version - -You might be running into an issue which was already fixed in a more recent version of Atom than the one you're using. - -If you're building Atom from source, pull down the latest version of master and [re-build][building atom]. - -If you're using released version, check which version of Atom you're using: - -```shell -$ atom --version -0.99.0 -``` - -Head on over to the [list of releases][atom releases] and see if there's a more recent release. You can update to the most recent release by downloading Atom from the releases page, or with the in-app auto-updater. The in-app auto-updater checks for and downloads a new version after you restart Atom, or if you use the Atom > Check for Update menu option. - -## Check for linked packages - -If you develop or contribute to Atom packages, there may be left-over packages linked to your `~/.atom/packages` or `~/.atom/dev/packages` directories. You can use: - -```shell -$ apm links -``` - -to list all linked development packages. You can remove the links using the `apm unlink` command. See `apm unlink --help` for details. - -## Check Atom and package settings - -In some cases, unexpected behavior might be caused by misconfigured or unconfigured settings in Atom or in one of the packages. - -Open Atom's Settings View with `cmd-,` or the Atom > Preferences menu option. - -![Settings View] - -Check Atom's settings in the Settings pane, there's a description of each configuration option [here][customizing guide]. For example, if you want Atom to use hard tabs (real tabs) and not soft tabs (spaces), disable the "Soft Tabs" option. - -Since Atom ships with a set of packages and you can install additional packages yourself, check the list of packages and their settings. For example, if you'd like to get rid of the vertical line in the middle of the editor, disable the [Wrap Guide package]. And if you don't like it when Atom strips trailing whitespace or ensures that there's a single trailing newline in the file, you can configure that in the [Whitespace packages'][whitespace package] settings. - -![Package Settings] - -## Check the keybindings - -If a command is not executing when you hit a keystroke or the wrong command is executing, there might be an issue with the keybindings for that keystroke. Atom ships with the [Keybinding resolver][keybinding resolver package], a neat package which helps you understand which keybindings are executed. - -Show the keybinding resolver with cmd-. or with "Key Binding Resolver: Show" from the Command palette. With the keybinding resolver shown, hit a keystroke: - -![Keybinding Resolver] - -The keybinding resolver shows you a list of keybindings that exist for the keystroke, where each item in the list has the following: -* the command for the keybinding, -* the CSS selector used to define the context in which the keybinding is valid, and -* the file in which the keybinding is defined. - -Of all the keybinding that are listed (grey color), at most one keybinding is matched and executed (green color). If the command you wanted to trigger isn't listed, then a keybinding for that command hasn't been defined. More keybindings are provided by [packages] and you can [define your own keybindings][customizing keybindings]. - -If multiple keybindings are matched, Atom determines which keybinding will be executed based on the [specificity of the selectors and the order in which they were loaded][specificity and order]. If the command you wanted to trigger is listed in the Keybinding resolver, but wasn't the one that was executed, this is normally explained by one of two causes: -* the keystroke was not used in the context defined by the keybinding's selector. For example, you can't trigger the "Tree View: Add File" command if the Tree View is not focused, or -* there is another keybinding that took precedence. This often happens when you install a package which defines keybinding that conflict with existing keybindings. If the package's keybindings have selectors with higher specificity or were loaded later, they'll have priority over existing ones. - -Atom loads core Atom keybindings and package keybindings first, and user-defined keybindings after last. Since user-defined keybindings are loaded last, you can use your `keymap.cson` file to tweak the keybindings and sort out problems like these. For example, you can remove keybindings with [the `unset!` directive][unset directive]. - -If you notice that a package's keybindings are taking precedence over core Atom keybindings, it might be a good idea to report the issue on the package's GitHub repository. - -## Check if the problem shows up in safe mode - -A large part of Atom's functionality comes from packages you can install. In some cases, these packages might be causing unexpected behavior, problems, or performance issues. - -To determine if a package you installed is causing problems, start Atom from the terminal in safe mode: - -``` -$ atom --safe -``` - -This starts Atom, but does not load packages from `~/.atom/packages` or `~/.atom/dev/packages`. If you can no longer reproduce the problem in safe mode, it's likely it was caused by one of the packages. - -To figure out which package is causing trouble, start Atom normally again and open Settings (`cmd-,`). Since Settings allow you to disable each installed package, you can disable packages one by one until you can no longer reproduce the issue. Restart (`cmd-q`) or reload (`cmd-ctrl-alt-l`) Atom after you disable each package to make sure it's completely gone. - -When you find the problematic package, you can disable or uninstall the package, and consider creating an issue on the package's GitHub repository. - -## Check your config files - -You might have defined some custom functionality or styles in Atom's [Init script or Stylesheet]. In some situations, these personal hacks might be causing problems so try clearing those files and restarting Atom. - -## Check for errors in the developer tools - -When an error is thrown in Atom, the developer tools are automatically shown with the error logged in the Console tab. However, if the dev tools are open before the error is triggered, a full stack trace for the error will be logged: - -![devtools error] - -If you can reproduce the error, use this approach to get the full stack trace. The stack trace might point to a problem in your [Init script][init script or stylesheet] or a specific package you installed, which you can then disable and report an issue on its GitHub repository. - -## Check that you have a build toolchain installed - -If you are having issues installing a package using `apm install`, this could be -because the package has dependencies on libraries that contain native code -and so you will need to have a C++ compiler and Python installed to be able to -install it. - -You can run `apm install --check` to see if [apm][apm] can build native code on -your machine. - -Check out the pre-requisites in the [build instructions][build-instructions] for -your platform for more details. - -[apm]: https://github.com/atom/apm -[build-instructions]: https://github.com/atom/atom/tree/master/docs/build-instructions -[submitting issues]: https://github.com/atom/atom/blob/master/CONTRIBUTING.md#submitting-issues -[building atom]: https://github.com/atom/atom#building -[atom releases]: https://github.com/atom/atom/releases -[customizing guide]: https://atom.io/docs/latest/customizing-atom#configuration-key-reference -[settings view]: https://f.cloud.github.com/assets/671378/2241795/ba4827d8-9ce4-11e3-93a8-6666ee100917.png -[package settings]: https://cloud.githubusercontent.com/assets/38924/3173588/7e5f6b0c-ebe8-11e3-9ec3-e8d140967e79.png -[wrap guide package]: https://atom.io/packages/wrap-guide -[whitespace package]: https://atom.io/packages/whitespace -[keybinding resolver package]: https://atom.io/packages/keybinding-resolver -[keybinding resolver]: https://f.cloud.github.com/assets/671378/2241702/5dd5a102-9cde-11e3-9e3f-1d999930492f.png -[customizing keybindings]: https://atom.io/docs/latest/customizing-atom#customizing-key-bindings -[packages]: https://atom.io/packages -[specificity and order]: https://atom.io/docs/latest/advanced/keymaps#specificity-and-cascade-order -[unset directive]: https://atom.io/docs/latest/advanced/keymaps#removing-bindings -[init script or stylesheet]: https://atom.io/docs/latest/customizing-atom#quick-personal-hacks -[devtools error]: https://cloud.githubusercontent.com/assets/38924/3177710/11b4e510-ec13-11e3-96db-a2e8a7891773.png diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 03e0a5abe..000000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,109 +0,0 @@ -# Getting Started - -Welcome to Atom! This guide provides a quick introduction so you can be -productive as quickly as possible. There are also guides which cover -[configuring], [theming], and [extending] Atom. - -## The Command Palette - -If there's one key-command you remember in Atom, it should be `cmd-shift-P`. You -can always press `cmd-shift-P` to bring up a list of commands (and key bindings) -that are relevant to the currently focused interface element. This is a great -way to explore the system and learn key bindings interactively. For information -about adding or changing a key binding refer to the [customizing key -bindings][key-bindings] section. - -![Command Palette] - -## The Basics - -### Working With Files - -Atom windows are scoped to a single directory on disk. If you launch Atom from -the command line via the `atom` command and don't specify a path, Atom opens a -window for the current working directory. The current window's directory will be -visible as the root of the tree view on the left, and also serve as the context -for all file-related operations. - -#### Finding Files - -The fastest way to find a file is to use the fuzzy finder. Press `cmd-t` and -begin typing the name of the file you're looking for. If you are looking for a -file that is already open press `cmd-b` to bring up a searchable list of open -files. If you are using Git you can use `cmd-shift-b` to search the list of -files modified and untracked in your project's repository. - -You can also use the tree view to navigate to a file. To open and focus the -tree view, press `ctrl-0`. The tree view can be toggled open and closed with -`cmd-\`. - -#### Adding, Moving, Deleting Files - -You can add, move, and delete files and folders by right-clicking them in the -tree view and selecting the desired operation from the context menu. You can -also perform these operations from the keyboard by selecting a file or folder -and using `a` to add, `m` to move, and `delete` to delete. - -### Searching - -#### Find and Replace - -To search within a buffer use `cmd-f`. To search the entire project use -`cmd-shift-f`. - -#### Navigating By Symbols - -To jump to a symbol such as a method definition, press `cmd-r`. This opens a -list of all symbols in the current file, which you can fuzzy filter similarly to -`cmd-t`. - -To search for symbols across your project, use `cmd-shift-r`. First you'll need -to make sure you have `tags` (or `TAGS`) file generated for your project. -This can be done by installing [ctags](http://ctags.sourceforge.net/) and -running a command such as `ctags -R src/` from the command line in your -project's root directory. Using [Homebrew](http://brew.sh/)? Just run -`brew install ctags`. - -You can customize how tags are generated by creating your own `.ctags` file -in your home directory (`~/.ctags`). Here is [a good example][ctags] to start -from. - -### Split Panes - -You can split any editor pane horizontally or vertically by using `cmd-k right` -or `cmd-k down`. Once you have a split pane, you can move focus between them -with `cmd-k cmd-right` or `cmd-k cmd-down`. To close a pane, close all its -editors with `cmd-w`, then press `cmd-w` one more time to close the pane. You -can configure panes to auto-close when empty in the Settings view. - -### Folding - -You can fold blocks of code by clicking the arrows that appear when you hover -your mouse cursor over the gutter. You can also fold and unfold from the -keyboard with `alt-cmd-[` and `alt-cmd-]`. To fold everything, use -`alt-cmd-shift-{` and to unfold everything use `alt-cmd-shift-}`. You can also -fold at a specific indentation level with `cmd-k cmd-N` where N is the -indentation depth. - -### Soft-Wrap - -If you want to toggle soft wrap, trigger the command from the command palette. -Press `cmd-shift-P` to open the palette, then type "wrap" to find the correct -command. By default, lines will wrap based on the size of the editor. If you -prefer to wrap at a specific line length, toggle "Wrap at preferred line length" -in preferences. - -## Configuration - -Press `cmd-,` to open the Settings view. This is the place to change settings, -install packages, and change the theme. - -For more advanced configuration see the [customization guide][customization]. - -[configuring]: customizing-atom.md -[theming]: creating-a-theme.md -[extending]: creating-a-package.md -[customization]: customizing-atom.md -[key-bindings]: customizing-atom.md#customizing-key-bindings -[command palette]: https://f.cloud.github.com/assets/1424/1091618/ee7c3554-166a-11e3-9955-aaa61bb5509c.png -[ctags]: https://github.com/atom/symbols-view/blob/master/lib/.ctags diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index e034e5784..000000000 --- a/docs/index.md +++ /dev/null @@ -1,30 +0,0 @@ -## Guides - -* [Getting Started](getting-started.md) -* [Customizing Atom](customizing-atom.md) -* [Creating a Package](creating-a-package.md) -* [Creating a Theme](creating-a-theme.md) -* [Publishing a Package](publishing-a-package.md) -* [Writing Specs](writing-specs.md) -* [Converting a TextMate Bundle](converting-a-text-mate-bundle.md) -* [Converting a TextMate Theme](converting-a-text-mate-theme.md) -* [Contributing](contributing.md) -* [Contributing to Core Packages](contributing-to-packages.md) -* [Debugging](debugging.md) -* [Your First Package](your-first-package.md) - -### Advanced Topics - -* [Configuration](advanced/configuration.md) -* [Developing Node Modules](advanced/node-modules.md) -* [Keymaps](advanced/keymaps.md) -* [Serialization](advanced/serialization.md) -* [Scopes and Scope Descriptors](advanced/scopes-and-scope-descriptors.md) -* [Theme Variables](theme-variables.md) -* [apm REST API](apm-rest-api.md) - -### Upgrading to 1.0 APIs - -* [Upgrading Your Package](upgrading/upgrading-your-package.md) -* [Upgrading Your UI Theme Or Package Selectors](upgrading/upgrading-your-ui-theme.md) -* [Upgrading Your Syntax Theme](upgrading/upgrading-your-syntax-theme.md) diff --git a/docs/publishing-a-package.md b/docs/publishing-a-package.md deleted file mode 100644 index 264876c7a..000000000 --- a/docs/publishing-a-package.md +++ /dev/null @@ -1,114 +0,0 @@ -## Publishing a Package - -This guide will show you how to publish a package or theme to the -[atom.io][atomio] package registry. - -Publishing a package allows other people to install it and use it in Atom. It -is a great way to share what you've made and get feedback and contributions from -others. - -This guide assumes your package's name is `my-package` but you should pick a -better name. - -### Install apm - -The `apm` command line utility that ships with Atom supports publishing packages -to the atom.io registry. - -Check that you have `apm` installed by running the following command in your -terminal: - -```sh -apm help publish -``` - -You should see a message print out with details about the `apm publish` command. - -If you do not, launch Atom and run the _Atom > Install Shell Commands_ menu -to install the `apm` and `atom` commands. - -### Prepare Your Package - -If you've followed the steps in the [your first package][your-first-package] -doc then you should be ready to publish and you can skip to the next step. - -If not, there are a few things you should check before publishing: - - * Your *package.json* file has `name`, `description`, and `repository` fields. - * Your *package.json* file has a `version` field with a value of `"0.0.0"`. - * Your *package.json* file has an `engines` field that contains an entry - for Atom such as: `"engines": {"atom": ">=0.50.0"}`. - * Your package has a `README.md` file at the root. - * Your package is in a Git repository that has been pushed to - [GitHub][github]. Follow [this guide][repo-guide] if your package isn't - already on GitHub. - -### Publish Your Package - -Before you publish a package it is a good idea to check ahead of time if -a package with the same name has already been published to atom.io. You can do -that by visiting `https://atom.io/packages/my-package` to see if the package -already exists. If it does, update your package's name to something that is -available before proceeding. - -Now let's review what the `apm publish` command does: - - 1. Registers the package name on atom.io if it is being published for the - first time. - 2. Updates the `version` field in the *package.json* file and commits it. - 3. Creates a new [Git tag][git-tag] for the version being published. - 4. Pushes the tag and current branch up to GitHub. - 5. Updates atom.io with the new version being published. - -Now run the following commands to publish your package: - -```sh -cd ~/github/my-package -apm publish minor -``` - -If this is the first package you are publishing, the `apm publish` command may -prompt you for your GitHub username and password. This is required to publish -and you only need to enter this information the first time you publish. The -credentials are stored securely in your [keychain][keychain] once you login. - -:tada: Your package is now published and available on atom.io. Head on over to -`https://atom.io/packages/my-package` to see your package's page. - -With `apm publish`, you can bump the version and publish by using -```sh -apm publish -``` -where `` can be `major`, `minor` and `patch`. - -The `major` option to the publish command tells apm to increment the first -digit of the version before publishing so the published version will be `1.0.0` -and the Git tag created will be `v1.0.0`. - -The `minor` option to the publish command tells apm to increment the second -digit of the version before publishing so the published version will be `0.1.0` -and the Git tag created will be `v0.1.0`. - -The `patch` option to the publish command tells apm to increment the third -digit of the version before publishing so the published version will be `0.0.1` -and the Git tag created will be `v0.0.1`. - -Use `major` when you make a huge change, like a rewrite, or a large change to the functionality or interface. -Use `minor` when adding or removing a feature. -Use `patch` when you make a small change like a bug fix that does not add or remove features. - -### Further Reading - -* Check out [semantic versioning][semver] to learn more about versioning your - package releases. -* Consult the [Atom.io package API docs][apm-rest-api] to learn more about how - `apm` works. - -[atomio]: https://atom.io -[github]: https://github.com -[git-tag]: http://git-scm.com/book/en/Git-Basics-Tagging -[keychain]: https://en.wikipedia.org/wiki/Keychain_(Apple) -[repo-guide]: http://guides.github.com/overviews/desktop -[semver]: http://semver.org -[your-first-package]: your-first-package.html -[apm-rest-api]: apm-rest-api.md diff --git a/docs/theme-variables.md b/docs/theme-variables.md deleted file mode 100644 index 0316fc215..000000000 --- a/docs/theme-variables.md +++ /dev/null @@ -1,116 +0,0 @@ -# Style variables - -Atom's UI provides a set of variables you can use in your own themes and packages. - -## Use in Themes - -Each custom theme must specify a `ui-variables.less` file with all of the -following variables defined. The top-most theme specified in the theme settings -will be loaded and available for import. - -## Use in Packages - -In any of your package's `.less` files, you can access the theme variables -by importing the `ui-variables` file from Atom. - -Your package should generally only specify structural styling, and these should -come from [the style guide][styleguide]. Your package shouldn't specify colors, -padding sizes, or anything in absolute pixels. You should instead use the theme -variables. If you follow this guideline, your package will look good out of the -box with any theme! - -Here's an example `.less` file that a package can define using theme variables: - -```css -@import "ui-variables"; - -.my-selector { - background-color: @base-background-color; - padding: @component-padding; -} -``` - -## Variables - -### Text colors - -* `@text-color` -* `@text-color-subtle` -* `@text-color-highlight` -* `@text-color-selected` -* `@text-color-info` - A blue -* `@text-color-success`- A green -* `@text-color-warning`- An orange or yellow -* `@text-color-error` - A red - -### Background colors - -* `@background-color-info` - A blue -* `@background-color-success` - A green -* `@background-color-warning` - An orange or yellow -* `@background-color-error` - A red -* `@background-color-highlight` -* `@background-color-selected` -* `@app-background-color` - The app's background under all the editor components - -### Component colors - -* `@base-background-color` - -* `@base-border-color` - - -* `@pane-item-background-color` - -* `@pane-item-border-color` - - -* `@input-background-color` - -* `@input-border-color` - - -* `@tool-panel-background-color` - -* `@tool-panel-border-color` - - -* `@inset-panel-background-color` - -* `@inset-panel-border-color` - - -* `@panel-heading-background-color` - -* `@panel-heading-border-color` - - -* `@overlay-background-color` - -* `@overlay-border-color` - - -* `@button-background-color` - -* `@button-background-color-hover` - -* `@button-background-color-selected` - -* `@button-border-color` - - -* `@tab-bar-background-color` - -* `@tab-bar-border-color` - -* `@tab-background-color` - -* `@tab-background-color-active` - -* `@tab-border-color` - - -* `@tree-view-background-color` - -* `@tree-view-border-color` - - -* `@ui-site-color-1` - -* `@ui-site-color-2` - -* `@ui-site-color-3` - -* `@ui-site-color-4` - -* `@ui-site-color-5` - - -### Component sizes - -* `@disclosure-arrow-size` - - -* `@component-padding` - -* `@component-icon-padding` - -* `@component-icon-size` - -* `@component-line-height` - -* `@component-border-radius` - - -* `@tab-height` - - -### Fonts - -* `@font-size` - -* `@font-family` - - -[styleguide]: https://github.com/atom/styleguide diff --git a/docs/upgrading/upgrading-your-package.md b/docs/upgrading/upgrading-your-package.md deleted file mode 100644 index e7d603a36..000000000 --- a/docs/upgrading/upgrading-your-package.md +++ /dev/null @@ -1,625 +0,0 @@ -# Upgrading Your Package - -Atom is rapidly approaching 1.0. Much of the effort leading up to the 1.0 has been cleaning up APIs in an attempt to future proof, and make a more pleasant experience developing packages. - -This document will guide you through the large bits of upgrading your package to work with 1.0 APIs. - -## TL;DR - -We've set deprecation messages and errors in strategic places to help make sure you don't miss anything. You should be able to get 95% of the way to an updated package just by fixing errors and deprecations. There are a couple of things you can do to get the full effect of all the errors and deprecations. - -### Use atom-space-pen-views - -If you use any class from `require 'atom'` with a `$` or `View` in the name, add the `atom-space-pen-views` module to your package's `package.json` file's dependencies: - -```js -{ - "dependencies": { - "atom-space-pen-views": "^2.0.3" - } -} -``` - -Then run `apm install` in your package directory. - -### Require views from atom-space-pen-views - -Anywhere you are requiring one of the following from `atom` you need to require them from `atom-space-pen-views` instead. - -```coffee -# require these from 'atom-space-pen-views' rather than 'atom' -$ -$$ -$$$ -View -TextEditorView -ScrollView -SelectListView -``` - -So this: - -```coffee -# Old way -{$, TextEditorView, View, GitRepository} = require 'atom' -``` - -Would be replaced by this: - -```coffee -# New way -{GitRepository} = require 'atom' -{$, TextEditorView, View} = require 'atom-space-pen-views' -``` - -### Run specs and test your package - -You wrote specs, right!? Here's where they shine. Run them with `cmd-shift-P`, and search for `run package specs`. It will show all the deprecation messages and errors. - -### Update the engines field in package.json - -When you are deprecation free and all done converting, upgrade the `engines` field in your package.json: - -```json -{ - "engines": { - "atom": ">=0.174.0 <2.0.0" - } -} -``` - -### Examples - -We have upgraded all the core packages. Please see [this issue](https://github.com/atom/atom/issues/4011) for a link to all the upgrade PRs. - -## Deprecations - -All of the methods in Atom core that have changes will emit deprecation messages when called. These messages are shown in two places: your **package specs**, and in **Deprecation Cop**. - -### Specs - -Just run your specs, and all the deprecations will be displayed in yellow. - -![spec-deps](https://cloud.githubusercontent.com/assets/69169/5637943/b85114ba-95b5-11e4-8681-b81ea8f556d7.png) - -### Deprecation Cop - -Run an atom window in dev mode (`atom -d`) with your package loaded, and open Deprecation Cop (search for `deprecation` in the command palette). Deprecated methods will be appear in Deprecation Cop only after they have been called. - -![dep-cop](https://cloud.githubusercontent.com/assets/69169/5637914/6e702fa2-95b5-11e4-92cc-a236ddacee21.png) - -When deprecation cop is open, and deprecated methods are called, a `Refresh` button will appear in the top right of the Deprecation Cop interface. So exercise your package, then come back to Deprecation Cop and click the `Refresh` button. - -## Upgrading your Views - -Previous to 1.0, views were baked into Atom core. These views were based on jQuery and `space-pen`. They looked something like this: - -```coffee -# The old way: getting views from atom -{$, TextEditorView, View} = require 'atom' - -module.exports = -class SomeView extends View - @content: -> - @div class: 'find-and-replace', => - @div class: 'block', => - @subview 'myEditor', new TextEditorView(mini: true) - #... -``` - -### The New - -`require 'atom'` no longer provides view helpers or jQuery. Atom core is now 'view agnostic'. The preexisting view system is available from a new npm package: `atom-space-pen-views`. - -`atom-space-pen-views` now provides jQuery, `space-pen` views, and Atom specific views: - - -```coffee -# These are now provided by atom-space-pen-views -$ -$$ -$$$ -View -TextEditorView -ScrollView -SelectListView -``` - -### Adding the module dependencies - -To use the new views, you need to specify the `atom-space-pen-views` module in your package's `package.json` file's dependencies: - -```js -{ - "dependencies": { - "atom-space-pen-views": "^2.0.3" - } -} -``` - -`space-pen` bundles jQuery. If you do not need `space-pen` or any of the views, you can require jQuery directly. - -```js -{ - "dependencies": { - "jquery": "^2" - } -} -``` - -### Converting your views - -Sometimes it is as simple as converting the requires at the top of each view page. I assume you read the 'TL;DR' section and have updated all of your requires. - -### Upgrading classes extending any space-pen View - -#### `afterAttach` and `beforeRemove` updated - -The `afterAttach` and `beforeRemove` hooks have been replaced with -`attached` and `detached` and the semantics have changed. - -`afterAttach` was called whenever the node was attached to another DOM node, even if that parent node wasn't present in the DOM. `afterAttach` also was called with a boolean indicating whether or not the element and its parents were on the DOM. Now the `attached` hook is _only_ called when the node and all of its parents are actually on the DOM, and is not called with a boolean. - -`beforeRemove` was only called when `$.fn.remove` was called, which was typically used when the node was completely removed from the DOM. The new `detached` hook is called whenever the DOM node is _detached_, which could happen if the node is being detached for reattachment later. In short, if `beforeRemove` is called the node is never coming back. With `detached` it might be attached again later. - -```coffee -# Old way -{View} = require 'atom' -class MyView extends View - afterAttach: (onDom) -> - #... - - beforeRemove: -> - #... -``` - -```coffee -# New way -{View} = require 'atom-space-pen-views' -class MyView extends View - attached: -> - # Always called with the equivalent of @afterAttach(true)! - #... - - detached: -> - #... -``` - -#### `subscribe` and `subscribeToCommand` methods removed - -The `subscribe` and `subscribeToCommand` methods have been removed. See the Eventing and Disposables section for more info. - -### Upgrading to the new TextEditorView - -All of the atom-specific methods available on the `TextEditorView` have been moved to the `TextEditor`, available via `TextEditorView::getModel`. See the [`TextEditorView` docs][TextEditorView] and [`TextEditor` docs][TextEditor] for more info. - -### Upgrading classes extending ScrollView - -The `ScrollView` has very minor changes. - -You can no longer use `@off` to remove default behavior for `core:move-up`, `core:move-down`, etc. - -```coffee -# Old way to turn off default behavior -class ResultsView extends ScrollView - initialize: (@model) -> - super() - # turn off default scrolling behavior from ScrollView - @off 'core:move-up' - @off 'core:move-down' - @off 'core:move-left' - @off 'core:move-right' -``` - -```coffee -# New way to turn off default behavior -class ResultsView extends ScrollView - initialize: (@model) -> - disposable = super() - # turn off default scrolling behavior from ScrollView - disposable.dispose() -``` - -* Check out [an example](https://github.com/atom/find-and-replace/pull/311/files#diff-9) from find-and-replace. -* See the [docs][ScrollView] for all the options. - -### Upgrading classes extending SelectListView - -Your SelectListView might look something like this: - -```coffee -# Old! -class CommandPaletteView extends SelectListView - initialize: -> - super() - @addClass('command-palette overlay from-top') - atom.workspaceView.command 'command-palette:toggle', => @toggle() - - confirmed: ({name, jQuery}) -> - @cancel() - # do something with the result - - toggle: -> - if @hasParent() - @cancel() - else - @attach() - - attach: -> - @storeFocusedElement() - - items = [] # TODO: build items - @setItems(items) - - atom.workspaceView.append(this) - @focusFilterEditor() - - confirmed: ({name, jQuery}) -> - @cancel() -``` - -This attaches and detaches itself from the dom when toggled, canceling magically detaches it from the DOM, and it uses the classes `overlay` and `from-top`. - -The new SelectListView no longer automatically detaches itself from the DOM when cancelled. It's up to you to implement whatever cancel beahavior you want. Using the new APIs to mimic the sematics of the old class, it should look like this: - -```coffee -# New! -class CommandPaletteView extends SelectListView - initialize: -> - super() - # no more need for the `overlay` and `from-top` classes - @addClass('command-palette') - atom.commands.add 'atom-workspace', 'command-palette:toggle', => @toggle() - - # You need to implement the `cancelled` method and hide. - cancelled: -> - @hide() - - confirmed: ({name, jQuery}) -> - @cancel() - # do something with the result - - toggle: -> - # Toggling now checks panel visibility, - # and hides / shows rather than attaching to / detaching from the DOM. - if @panel?.isVisible() - @cancel() - else - @show() - - show: -> - # Now you will add your select list as a modal panel to the workspace - @panel ?= atom.workspace.addModalPanel(item: this) - @panel.show() - - @storeFocusedElement() - - items = [] # TODO: build items - @setItems(items) - - @focusFilterEditor() - - hide: -> - @panel?.hide() -``` - -* And check out the [conversion of CommandPaletteView][selectlistview-example] as a real-world example. -* See the [SelectListView docs][SelectListView] for all options. - -## Using the model layer rather than the view layer - -The API no longer exposes any specialized view objects or view classes. `atom.workspaceView`, and all the view classes: `WorkspaceView`, `EditorView`, `PaneView`, etc. have been globally deprecated. - -Nearly all of the atom-specific actions performed by the old view objects can now be managed via the model layer. For example, here's adding a panel to the interface using the `atom.workspace` model instead of the `workspaceView`: - -```coffee -# Old! -div = document.createElement('div') -atom.workspaceView.appendToTop(div) -``` - -```coffee -# New! -div = document.createElement('div') -atom.workspace.addTopPanel(item: div) -``` - -For actions that still require the view, such as dispatching commands or munging css classes, you'll access the view via the `atom.views.getView()` method. This will return a subclass of `HTMLElement` rather than a jQuery object or an instance of a deprecated view class (e.g. `WorkspaceView`). - -```coffee -# Old! -workspaceView = atom.workspaceView -editorView = workspaceView.getActiveEditorView() -paneView = editorView.getPaneView() -``` - -```coffee -# New! -# Generally, just use the models -workspace = atom.workspace -editor = workspace.getActiveTextEditor() -pane = editor.getPane() - -# If you need views, get them with `getView` -workspaceElement = atom.views.getView(atom.workspace) -editorElement = atom.views.getView(editor) -paneElement = atom.views.getView(pane) -``` - -## Updating Specs - -`atom.workspaceView`, the `WorkspaceView` class and the `EditorView` class have been deprecated. These two objects are used heavily throughout specs, mostly to dispatch events and commands. This section will explain how to remove them while still retaining the ability to dispatch events and commands. - -### Removing WorkspaceView references - -`WorkspaceView` has been deprecated. Everything you could do on the view, you can now do on the `Workspace` model. - -Requiring `WorkspaceView` from `atom` and accessing any methods on it will throw a deprecation warning. Many specs lean heavily on `WorkspaceView` to trigger commands and fetch `EditorView` objects. - -Your specs might contain something like this: - -```coffee -# Old! -{WorkspaceView} = require 'atom' -describe 'FindView', -> - beforeEach -> - atom.workspaceView = new WorkspaceView() -``` - -Instead, we will use the `atom.views.getView()` method. This will return a plain `HTMLElement`, not a `WorkspaceView` or jQuery object. - -```coffee -# New! -describe 'FindView', -> - workspaceElement = null - beforeEach -> - workspaceElement = atom.views.getView(atom.workspace) -``` - -### Attaching the workspace to the DOM - -The workspace needs to be attached to the DOM in some cases. For example, view hooks only work (`attached()` on `View`, `attachedCallback()` on custom elements) when there is a descendant attached to the DOM. - -You might see this in your specs: - -```coffee -# Old! -atom.workspaceView.attachToDom() -``` - -Change it to: - -```coffee -# New! -jasmine.attachToDOM(workspaceElement) -``` - -### Removing EditorView references - -Like `WorkspaceView`, `EditorView` has been deprecated. Everything you needed to do on the view you are now able to do on the `TextEditor` model. - -In many cases, you will not even need to get the editor's view anymore. Any of those instances should be updated to use the `TextEditor` instance instead. You should really only need the editor's view when you plan on triggering a command on the view in a spec. - -Your specs might contain something like this: - -```coffee -# Old! -describe 'Something', -> - [editorView] = [] - beforeEach -> - editorView = atom.workspaceView.getActiveView() -``` - -We're going to use `atom.views.getView()` again to get the editor element. As in the case of the `workspaceElement`, `getView` will return a subclass of `HTMLElement` rather than an `EditorView` or jQuery object. - -```coffee -# New! -describe 'Something', -> - [editor, editorElement] = [] - beforeEach -> - editor = atom.workspace.getActiveTextEditor() - editorElement = atom.views.getView(editor) -``` - -### Dispatching commands - -Since the `editorElement` objects are no longer `jQuery` objects, they no longer support `trigger()`. Additionally, Atom has a new command dispatcher, `atom.commands`, that we use rather than commandeering jQuery's `trigger` method. - -From this: - -```coffee -# Old! -workspaceView.trigger 'a-package:toggle' -editorView.trigger 'find-and-replace:show' -``` - -To this: - -```coffee -# New! -atom.commands.dispatch workspaceElement, 'a-package:toggle' -atom.commands.dispatch editorElement, 'find-and-replace:show' -``` - -## Eventing and Disposables - -A couple large things changed with respect to events: - -1. All model events are now exposed as event subscription methods that return [`Disposable`][disposable] objects -1. The `subscribe()` method is no longer available on `space-pen` `View` objects -1. An Emitter is now provided from `require 'atom'` - -### Consuming Events - -All events from the Atom API are now methods that return a [`Disposable`][disposable] object, on which you can call `dispose()` to unsubscribe. - -```coffee -# Old! -editor.on 'changed', -> -``` - -```coffee -# New! -disposable = editor.onDidChange -> - -# You can unsubscribe at some point in the future via `dispose()` -disposable.dispose() -``` - -Deprecation warnings will guide you toward the correct methods. - -#### Using a CompositeDisposable - -You can group multiple disposables into a single disposable with a `CompositeDisposable`. - -```coffee -{CompositeDisposable} = require 'atom' - -class Something - constructor: -> - editor = atom.workspace.getActiveTextEditor() - @disposables = new CompositeDisposable - @disposables.add editor.onDidChange -> - @disposables.add editor.onDidChangePath -> - - destroy: -> - @disposables.dispose() -``` - -### Removing View::subscribe and Subscriber::subscribe calls - -There were a couple permutations of `subscribe()`. In these examples, a `CompositeDisposable` is used as it will commonly be useful where conversion is necessary. - -#### subscribe(unsubscribable) - -This one is very straight forward. - -```coffee -# Old! -@subscribe editor.on 'changed', -> -``` - -```coffee -# New! -disposables = new CompositeDisposable -disposables.add editor.onDidChange -> -``` - -#### subscribe(modelObject, event, method) - -When the modelObject is an Atom model object, the change is very simple. Just use the correct event method, and add it to your CompositeDisposable. - -```coffee -# Old! -@subscribe editor, 'changed', -> -``` - -```coffee -# New! -disposables = new CompositeDisposable -disposables.add editor.onDidChange -> -``` - -#### subscribe(jQueryObject, selector(optional), event, method) - -Things are a little more complicated when subscribing to a DOM or jQuery element. Atom no longer provides helpers for subscribing to elements. You can use jQuery or the native DOM APIs, whichever you prefer. - -```coffee -# Old! -@subscribe $(window), 'focus', -> -``` - -```coffee -# New! -{Disposable, CompositeDisposable} = require 'atom' -disposables = new CompositeDisposable - -# New with jQuery -focusCallback = -> -$(window).on 'focus', focusCallback -disposables.add new Disposable -> - $(window).off 'focus', focusCallback - -# New with native APIs -focusCallback = -> -window.addEventListener 'focus', focusCallback -disposables.add new Disposable -> - window.removeEventListener 'focus', focusCallback -``` - -### Providing Events: Using the Emitter - -You no longer need to require `emissary` to get an emitter. We now provide an `Emitter` class from `require 'atom'`. We have a specific pattern for use of the `Emitter`. Rather than mixing it in, we instantiate a member variable, and create explicit subscription methods. For more information see the [`Emitter` docs][emitter]. - -```coffee -# New! -{Emitter} = require 'atom' - -class Something - constructor: -> - @emitter = new Emitter - - destroy: -> - @emitter.dispose() - - onDidChange: (callback) -> - @emitter.on 'did-change', callback - - methodThatFiresAChange: -> - @emitter.emit 'did-change', {data: 2} - -# Using the evented class -something = new Something -something.onDidChange (eventObject) -> - console.log eventObject.data # => 2 -something.methodThatFiresAChange() -``` - -## Subscribing To Commands - -`$.fn.command` and `View::subscribeToCommand` are no longer available. Now we use `atom.commands.add`, and collect the results in a `CompositeDisposable`. See [the docs][commands-add] for more info. - -```coffee -# Old! -atom.workspaceView.command 'core:close core:cancel', -> - -# When inside a View class, you might see this -@subscribeToCommand 'core:close core:cancel', -> -``` - -```coffee -# New! -@disposables.add atom.commands.add 'atom-workspace', - 'core:close': -> - 'core:cancel': -> - -# You can register commands directly on individual DOM elements in addition to -# using selectors. When in a View class, you should have a `@element` object -# available. `@element` is a plain HTMLElement object -@disposables.add atom.commands.add @element, - 'core:close': -> - 'core:cancel': -> -``` - -## Upgrading your stylesheet's selectors - -Many selectors have changed, and we have introduced the [Shadow DOM][shadowdom] to the editor. See the [Upgrading Your UI Theme And Package Selectors guide][upgrading-selectors] for more information in upgrading your package stylesheets. - -## Help us improve this guide! - -Did you hit something painful that wasn't in here? Want to reword some bit of it? Find something incorrect? Please edit [this file][guide], and send a pull request. Contributions are greatly appreciated. - - - - -[texteditorview]:https://github.com/atom/atom-space-pen-views#texteditorview -[scrollview]:https://github.com/atom/atom-space-pen-views#scrollview -[selectlistview]:https://github.com/atom/atom-space-pen-views#selectlistview -[selectlistview-example]:https://github.com/atom/command-palette/pull/19/files -[emitter]:https://atom.io/docs/api/latest/Emitter -[texteditor]:https://atom.io/docs/api/latest/TextEditor -[disposable]:https://atom.io/docs/api/latest/Disposable -[commands-add]:https://atom.io/docs/api/latest/CommandRegistry#instance-add -[upgrading-selectors]:https://atom.io/docs/latest/upgrading/upgrading-your-ui-theme -[shadowdom]:http://blog.atom.io/2014/11/18/avoiding-style-pollution-with-the-shadow-dom.html -[guide]:https://github.com/atom/atom/blob/master/docs/upgrading/upgrading-your-package.md diff --git a/docs/upgrading/upgrading-your-syntax-theme.md b/docs/upgrading/upgrading-your-syntax-theme.md deleted file mode 100644 index 3a8241ca0..000000000 --- a/docs/upgrading/upgrading-your-syntax-theme.md +++ /dev/null @@ -1,24 +0,0 @@ -# Upgrading Your Syntax Theme - -Text editor content is now rendered in the shadow DOM, which shields it from being styled by global style sheets to protect against accidental style pollution. For more background on the shadow DOM, check out the [Shadow DOM 101][shadow-dom-101] on HTML 5 Rocks. - -Syntax themes are specifically intended to style only text editor content, so they are automatically loaded directly into the text editor's shadow DOM when it is enabled. This happens automatically when the the theme's `package.json` contains a `theme: "syntax"` declaration, so you don't need to change anything to target the appropriate context. - -When theme style sheets are loaded into the text editor's shadow DOM, selectors intended to target the editor from the *outside* no longer make sense. Styles targeting the `.editor` and `.editor-colors` classes instead need to target the `:host` pseudo-element, which matches against the containing `atom-text-editor` node. Check out the [Shadow DOM 201][host-pseudo-element] article for more information about the `:host` pseudo-element. - -Here's an example from Atom's light syntax theme. Note that the `atom-text-editor` selector intended to target the editor from the outside has been retained to allow the theme to keep working during the transition phase when it is possible to disable the shadow DOM. - -```css -atom-text-editor, :host { /* :host added */ - background-color: @syntax-background-color; - color: @syntax-text-color; - - .invisible-character { - color: @syntax-invisible-character-color; - } - /* more nested selectors... */ -} -``` - -[shadow-dom-101]: http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom -[host-pseudo-element]: http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom-201#toc-style-host diff --git a/docs/upgrading/upgrading-your-ui-theme.md b/docs/upgrading/upgrading-your-ui-theme.md deleted file mode 100644 index 5be0344bb..000000000 --- a/docs/upgrading/upgrading-your-ui-theme.md +++ /dev/null @@ -1,137 +0,0 @@ -# Upgrading Your UI Theme Or Package Selectors - -In addition to changes in Atom's scripting API, we'll also be making some breaking changes to Atom's DOM structure, requiring style sheets and keymaps in both packages and themes to be updated. - -## Deprecation Cop - -Deprecation cop will list usages of deprecated selector patterns to guide you. You can access it via the command palette (`cmd-shift-p`, then search for `Deprecation`). It breaks the deprecations down by package: - -![dep-cop](https://cloud.githubusercontent.com/assets/69169/5078860/d38a5df4-6e64-11e4-95b6-eb585ee9bbfc.png) - -## Custom Tags - -Rather than adding classes to standard HTML elements to indicate their role, Atom now uses custom element names. For example, `
` has now been replaced with ``. Selectors should be updated accordingly. Note that tag names have lower specificity than classes in CSS, so you'll need to take care in converting things. - -Old Selector | New Selector ---------------------|-------------------------------- -`.editor` | `atom-text-editor` -`.editor.mini` | `atom-text-editor[mini]` -`.workspace` | `atom-workspace` -`.horizontal` | `atom-workspace-axis.horizontal` -`.vertical` | `atom-workspace-axis.vertical` -`.pane-container` | `atom-pane-container` -`.pane` | `atom-pane` -`.tool-panel` | `atom-panel` -`.panel-top` | `atom-panel.top` -`.panel-bottom` | `atom-panel.bottom` -`.panel-left` | `atom-panel.left` -`.panel-right` | `atom-panel.right` -`.overlay` | `atom-panel.modal` - -## Supporting the Shadow DOM - -Text editor content is now rendered in the shadow DOM, which shields it from being styled by global style sheets to protect against accidental style pollution. For more background on the shadow DOM, check out the [Shadow DOM 101][shadow-dom-101] on HTML 5 Rocks. If you need to style text editor content in a UI theme, you'll need to circumvent this protection for any rules that target the text editor's content. Some examples of the kinds of UI theme styles needing to be updated: - -* Highlight decorations -* Gutter decorations -* Line decorations -* Scrollbar styling -* Anything targeting a child selector of `.editor` - -During a transition phase, it will be possible to enable or disable the text editor's shadow DOM in the settings, so themes will need to be compatible with both approaches. - -### Shadow DOM Selectors - -Chromium provides two tools for bypassing shadow boundaries, the `::shadow` pseudo-element and the `/deep/` combinator. For an in-depth explanation of styling the shadow DOM, see the [Shadow DOM 201][shadow-dom-201] article on HTML 5 Rocks. - -#### ::shadow - -The `::shadow` pseudo-element allows you to bypass a single shadow root. For example, say you want to update a highlight decoration for a linter package. Initially, the style looks as follows: - -```css -// Without shadow DOM support -atom-text-editor .highlight.my-linter { - background: hotpink; -} -``` - -In order for this style to apply with the shadow DOM enabled, you will need to add a second selector with the `::shadow` pseudo-element. You should leave the original selector in place so your theme continues to work with the shadow DOM disabled during the transition period. - -```css -// With shadow DOM support -atom-text-editor .highlight.my-linter, -atom-text-editor::shadow .highlight.my-linter { - background: hotpink; -} -``` - -Check out the [find-and-replace][find-and-replace] package for another example of using `::shadow` to pierce the shadow DOM. - -#### /deep/ - -The `/deep/` combinator overrides *all* shadow boundaries, making it useful for rules you want to apply globally such as scrollbar styling. Here's a snippet containing scrollbar styling for the Atom Dark UI theme before shadow DOM support: - -```css -// Without shadow DOM support -.scrollbars-visible-always { - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track, - ::-webkit-scrollbar-corner { - background: @scrollbar-background-color; - } - - ::-webkit-scrollbar-thumb { - background: @scrollbar-color; - border-radius: 5px; - box-shadow: 0 0 1px black inset; - } -} -``` - -To style scrollbars even inside of the shadow DOM, each rule needs to be prefixed with `/deep/`. We use `/deep/` instead of `::shadow` because we don't care about the selector of the host element in this case. We just want our styling to apply everywhere. - -```css -// With shadow DOM support using /deep/ -.scrollbars-visible-always { - /deep/ ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - /deep/ ::-webkit-scrollbar-track, - /deep/ ::-webkit-scrollbar-corner { - background: @scrollbar-background-color; - } - - /deep/ ::-webkit-scrollbar-thumb { - background: @scrollbar-color; - border-radius: 5px; - box-shadow: 0 0 1px black inset; - } -} -``` - -### Context-Targeted Style Sheets - -The selector features discussed above allow you to target shadow DOM content with specific selectors, but Atom also allows you to target a specific shadow DOM context with an entire style sheet. The context into which a style sheet is loaded is based on the file name. If you want to load a style sheet into the editor, name it with the `.atom-text-editor.less` or `.atom-text-editor.css` extensions. - -``` -my-ui-theme/ - styles/ - index.less # loaded globally - index.atom-text-editor.less # loaded in the text editor shadow DOM -``` - -Check out this [style sheet](https://github.com/atom/decoration-example/blob/master/styles/decoration-example.atom-text-editor.less) from the decoration-example package for an example of context-targeting. - -Inside a context-targeted style sheet, there's no need to use the `::shadow` or `/deep/` expressions. If you want to refer to the element containing the shadow root, you can use the `::host` pseudo-element. - -During the transition phase, style sheets targeting the `atom-text-editor` context will *also* be loaded globally. Make sure you update your selectors in a way that maintains compatibility with the shadow DOM being disabled. That means if you use a `::host` pseudo element, you should also include the same style rule matches against `atom-text-editor`. - -[shadow-dom-101]: http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom -[shadow-dom-201]: http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom-201#toc-style-cat-hat -[find-and-replace]: https://github.com/atom/find-and-replace/blob/95351f261bc384960a69b66bf12eae8002da63f9/styles/find-and-replace.less#L10 diff --git a/docs/writing-specs.md b/docs/writing-specs.md deleted file mode 100644 index 38db57439..000000000 --- a/docs/writing-specs.md +++ /dev/null @@ -1,136 +0,0 @@ -# Writing specs - -Atom uses [Jasmine](http://jasmine.github.io/1.3/introduction.html) as its spec framework. Any new functionality should have specs to guard against regressions. - -## Create a new spec - -[Atom specs](https://github.com/atom/atom/tree/master/spec) and [package specs](https://github.com/atom/markdown-preview/tree/master/spec) are added to their respective `spec` directory. The example below creates a spec for Atom core. - -0. Create a spec file - - Spec files **must** end with `-spec` so add `sample-spec.coffee` to `atom/spec`. - -0. Add one or more `describe` methods - - The `describe` method takes two arguments, a description and a function. If the description explains a behavior it typically begins with `when`; if it is more like a unit test it begins with the method name. - - ```coffee - describe "when a test is written", -> - # contents - ``` - - or - - ```coffee - describe "Editor::moveUp", -> - # contents - ``` - -0. Add one or more `it` method - - The `it` method also takes two arguments, a description and a function. Try and make the description flow with the `it` method. For example, a description of `this should work` doesn't read well as `it this should work`. But a description of `should work` sounds great as `it should work`. - - ```coffee - describe "when a test is written", -> - it "has some expectations that should pass", -> - # Expectations - ``` - -0. Add one or more expectations - - The best way to learn about expectations is to read the [jasmine documentation](http://jasmine.github.io/1.3/introduction.html#section-Expectations) about them. Below is a simple example. - - ```coffee - describe "when a test is written", -> - it "has some expectations that should pass", -> - expect("apples").toEqual("apples") - expect("oranges").not.toEqual("apples") - ``` - -## Asynchronous specs - -Writing Asynchronous specs can be tricky at first. Some examples. - -0. Promises - - Working with promises is rather easy in Atom. You can use our `waitsForPromise` function. - - ```coffee - describe "when we open a file", -> - it "should be opened in an editor", -> - waitsForPromise -> - atom.workspace.open('c.coffee').then (editor) -> - expect(editor.getPath()).toContain 'c.coffee' - ``` - - This method can be used in the `describe`, `it`, `beforeEach` and `afterEach` functions. - - ```coffee - describe "when we open a file", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open 'c.coffee' - - it "should be opened in an editor", -> - expect(atom.workspace.getActiveTextEditor().getPath()).toContain 'c.coffee' - - ``` - - If you need to wait for multiple promises use a new `waitsForPromise` function for each promise. (Caution: Without `beforeEach` this example will fail!) - - ```coffee - describe "waiting for the packages to load", -> - - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js') - waitsForPromise -> - atom.packages.activatePackage('tabs') - waitsForPromise -> - atom.packages.activatePackage('tree-view') - - it 'should have waited long enough', -> - expect(atom.packages.isPackageActive('tabs')).toBe true - expect(atom.packages.isPackageActive('tree-view')).toBe true - ``` - -0. Asynchronous functions with callbacks - - Specs for asynchronous functions can be done using the `waitsFor` and `runs` functions. A simple example. - - ```coffee - describe "fs.readdir(path, cb)", -> - it "is async", -> - spy = jasmine.createSpy('fs.readdirSpy') - - fs.readdir('/tmp/example', spy) - waitsFor -> - spy.callCount > 0 - runs -> - exp = [null, ['example.coffee']] - expect(spy.mostRecentCall.args).toEqual exp - expect(spy).toHaveBeenCalledWith(null, ['example.coffee']) - ``` - -For a more detailed documentation on asynchronous tests please visit the [jasmine documentation](http://jasmine.github.io/1.3/introduction.html#section-Asynchronous_Support). - - -## Running specs - -Most of the time you'll want to run specs by triggering the `window:run-package-specs` command. This command is not only to run package specs, it is also for Atom core specs. This will run all the specs in the current project's spec directory. If you want to run the Atom core specs and **all** the default package specs trigger the `window:run-all-specs` command. - -To run a limited subset of specs use the `fdescribe` or `fit` methods. You can use those to focus a single spec or several specs. In the example above, focusing an individual spec looks like this: - -```coffee -describe "when a test is written", -> - fit "has some expectations that should pass", -> - expect("apples").toEqual("apples") - expect("oranges").not.toEqual("apples") -``` - -### Running on CI - -It is now easy to run the specs in a CI environment like Travis and AppVeyor. See the -[Travis CI For Your Packages](http://blog.atom.io/2014/04/25/ci-for-your-packages.html) -and [AppVeyor CI For Your Packages](http://blog.atom.io/2014/07/28/windows-ci-for-your-packages.html) -posts for more details. diff --git a/docs/your-first-package.md b/docs/your-first-package.md deleted file mode 100644 index 42aca7c31..000000000 --- a/docs/your-first-package.md +++ /dev/null @@ -1,158 +0,0 @@ -# Create Your First Package - -This tutorial will guide you though creating a simple command that replaces the -selected text with [ascii art](https://en.wikipedia.org/wiki/ASCII_art). When you -run our new command with the word "cool" selected, it will be replaced with: - -``` - ___ - /\_ \ - ___ ___ ___\//\ \ - /'___\ / __`\ / __`\\ \ \ -/\ \__//\ \L\ \/\ \L\ \\_\ \_ -\ \____\ \____/\ \____//\____\ - \/____/\/___/ \/___/ \/____/ -``` - -The final package can be viewed at -[https://github.com/atom/ascii-art](https://github.com/atom/ascii-art). - -To begin, press `cmd-shift-P` to bring up the [Command -Palette](https://github.com/atom/command-palette). Type "generate package" and -select the "Package Generator: Generate Package" command. Now we need to name -the package. Try to avoid naming your package with the *atom-* prefix, for -example we are going to call this package _ascii-art_. - -Atom will open a new window with the contents of our new _ascii-art_ package -displayed in the Tree View. Because this window is opened **after** the package -is created, the ASCII Art package will be loaded and available in our new -window. To verify this, toggle the Command Palette (`cmd-shift-P`) and type -"ASCII Art". You'll see a new `ASCII Art: Toggle` command. When triggered, this -command displays a default message. - -Now let's edit the package files to make our ASCII Art package do something -interesting. Since this package doesn't need any UI, we can remove all -view-related code. Start by opening up _lib/ascii-art.coffee_. Remove all view -code, so the `module.exports` section looks like this: - -```coffeescript -module.exports = - activate: -> -``` - -## Create a Command - -Now let's add a command. We recommend that you namespace your commands with the -package name followed by a `:`, so we'll call our command `ascii-art:convert`. -Register the command in _lib/ascii-art.coffee_: - -```coffeescript -module.exports = - activate: -> - atom.commands.add 'atom-workspace', "ascii-art:convert", => @convert() - - convert: -> - # This assumes the active pane item is an editor - editor = atom.workspace.getActivePaneItem() - editor.insertText('Hello, World!') -``` - -The `atom.commands.add` method takes a selector, command name, and a callback. -The callback executes when the command is triggered on an element matching the -selector. In this case, when the command is triggered the callback will call the -`convert` method and insert 'Hello, World!'. - -## Reload the Package - -Before we can trigger `ascii-art:convert`, we need to load the latest code for -our package by reloading the window. Run the command `window:reload` from the -command palette or by pressing `ctrl-alt-cmd-l`. - -## Trigger the Command - -Now open the command panel and search for the `ascii-art:convert` command. But -it's not there! To fix this, open _package.json_ and find the property called -`activationCommands`. Activation Commands speed up Atom's load time by allowing it to -delay a package's activation until it's needed. So remove the existing command -and add `ascii-art:convert` to the `activationCommands` array: - -```json -"activationCommands": ["ascii-art:convert"], -``` - -First, reload the window by running the command `window:reload`. Now when you -run the `ascii-art:convert` command it will output 'Hello, World!' - -## Add a Key Binding - -Now let's add a key binding to trigger the `ascii-art:convert` command. Open -_keymaps/ascii-art.cson_ and add a key binding linking `ctrl-alt-a` to the -`ascii-art:convert` command. You can delete the pre-existing key binding since -you don't need it anymore. When finished, the file will have this: - -```coffeescript -'atom-text-editor': - 'ctrl-alt-a': 'ascii-art:convert' -``` - -Notice `atom-text-editor` on the first line. Just like CSS, keymap selectors -*scope* key bindings so they only apply to specific elements. In this case, our -binding is only active for elements matching the `atom-text-editor` selector. If -the Tree View has focus, pressing `ctrl-alt-a` won't trigger the -`ascii-art:convert` command. But if the editor has focus, the -`ascii-art:convert` method *will* be triggered. More information on key bindings -can be found in the [keymaps](advanced/keymaps.html) documentation. - -Now reload the window and verify that the key binding works! You can also verify -that it **doesn't** work when the Tree View is focused. - -## Add the ASCII Art - -Now we need to convert the selected text to ASCII art. To do this we will use -the [figlet](https://npmjs.org/package/figlet) [node](http://nodejs.org/) module -from [npm](https://npmjs.org/). Open _package.json_ and add the latest version of -figlet to the dependencies: - -```json -"dependencies": { - "figlet": "1.0.8" -} -``` - -After saving the file, run the command 'update-package-dependencies:update' from -the Command Palette. This will install the package's node module dependencies, -only figlet in this case. You will need to run -'update-package-dependencies:update' whenever you update the dependencies field -in your _package.json_ file. - -Now require the figlet node module in _lib/ascii-art.coffee_ and instead of -inserting 'Hello, World!' convert the selected text to ASCII art. - -```coffeescript -convert: -> - # This assumes the active pane item is an editor - editor = atom.workspace.getActivePaneItem() - selection = editor.getLastSelection() - - figlet = require 'figlet' - figlet selection.getText(), {font: "Larry 3D 2"}, (error, asciiArt) -> - if error - console.error(error) - else - selection.insertText("\n#{asciiArt}\n") -``` - -Select some text in an editor window and hit `ctrl-alt-a`. :tada: You're now an -ASCII art professional! - -## Further reading - -* [Getting your project on GitHub guide](http://guides.github.com/overviews/desktop) - -* [Writing specs](writing-specs.md) for your package - -* [Creating a package guide](creating-a-package.html) for more information - on the mechanics of packages - -* [Publishing a package guide](publishing-a-package.html) for more information - on publishing your package to [atom.io](https://atom.io) diff --git a/dot-atom/config.cson b/dot-atom/config.cson deleted file mode 100644 index a93b83e66..000000000 --- a/dot-atom/config.cson +++ /dev/null @@ -1,7 +0,0 @@ -'editor': - 'fontSize': 16 -'core': - 'themes': [ - 'atom-dark-ui' - 'atom-dark-syntax' - ] diff --git a/dot-atom/keymap.cson b/dot-atom/keymap.cson index ae63892bf..dea689c12 100644 --- a/dot-atom/keymap.cson +++ b/dot-atom/keymap.cson @@ -17,8 +17,8 @@ # 'ctrl-p': 'core:move-down' # # You can find more information about keymaps in these guides: -# * https://atom.io/docs/latest/customizing-atom#customizing-key-bindings -# * https://atom.io/docs/latest/advanced/keymaps +# * https://atom.io/docs/latest/using-atom-basic-customization#customizing-key-bindings +# * https://atom.io/docs/latest/behind-atom-keymaps-in-depth # # This file uses CoffeeScript Object Notation (CSON). # If you are unfamiliar with CSON, you can read more about it here: diff --git a/dot-atom/styles.less b/dot-atom/styles.less index 50c5066e6..a321469e7 100644 --- a/dot-atom/styles.less +++ b/dot-atom/styles.less @@ -2,24 +2,31 @@ * Your Stylesheet * * This stylesheet is loaded when Atom starts up and is reloaded automatically - * when it is changed. + * when it is changed and saved. * - * If you are unfamiliar with LESS, you can read more about it here: - * http://www.lesscss.org + * Add your own CSS or Less to fully customize Atom. + * If you are unfamiliar with Less, you can read more about it here: + * http://lesscss.org */ + +/* + * Examples + * (To see them, uncomment and save) + */ + +// style the background color of the tree view .tree-view { - + // background-color: whitesmoke; } -// style the background and foreground colors on the atom-text-editor-element -// itself +// style the background and foreground colors on the atom-text-editor-element itself atom-text-editor { - + // color: white; + // background-color: hsl(180, 24%, 12%); } -// To style other content in the text editor's shadow DOM, use the ::shadow -// expression +// To style other content in the text editor's shadow DOM, use the ::shadow expression atom-text-editor::shadow .cursor { - + // border-color: red; } diff --git a/exports/atom.coffee b/exports/atom.coffee index 36b69d892..5a3d9673e 100644 --- a/exports/atom.coffee +++ b/exports/atom.coffee @@ -1,14 +1,19 @@ -{Point, Range} = require 'text-buffer' +TextBuffer = require 'text-buffer' +{Point, Range} = TextBuffer +{File, Directory} = require 'pathwatcher' {Emitter, Disposable, CompositeDisposable} = require 'event-kit' -{deprecate} = require 'grim' +{includeDeprecatedAPIs, deprecate} = require 'grim' module.exports = BufferedNodeProcess: require '../src/buffered-node-process' BufferedProcess: require '../src/buffered-process' GitRepository: require '../src/git-repository' Notification: require '../src/notification' + TextBuffer: TextBuffer Point: Point Range: Range + File: File + Directory: Directory Emitter: Emitter Disposable: Disposable CompositeDisposable: CompositeDisposable @@ -19,109 +24,103 @@ unless process.env.ATOM_SHELL_INTERNAL_RUN_AS_NODE module.exports.Task = require '../src/task' module.exports.TextEditor = require '../src/text-editor' - {$, $$, $$$, View} = require '../src/space-pen-extensions' + if includeDeprecatedAPIs + {$, $$, $$$, View} = require '../src/space-pen-extensions' - Object.defineProperty module.exports, 'Workspace', get: -> - deprecate """ - Requiring `Workspace` from `atom` is no longer supported. - If you need this, please open an issue on - https://github.com/atom/atom/issues/new - And let us know what you are using it for. - """ - require '../src/workspace' + Object.defineProperty module.exports, 'Workspace', get: -> + deprecate """ + Requiring `Workspace` from `atom` is no longer supported. + If you need this, please open an issue on + https://github.com/atom/atom/issues/new + And let us know what you are using it for. + """ + require '../src/workspace' - Object.defineProperty module.exports, 'WorkspaceView', get: -> - deprecate """ - Requiring `WorkspaceView` from `atom` is no longer supported. - Use `atom.views.getView(atom.workspace)` instead. - """ - require '../src/workspace-view' + Object.defineProperty module.exports, 'WorkspaceView', get: -> + deprecate """ + Requiring `WorkspaceView` from `atom` is no longer supported. + Use `atom.views.getView(atom.workspace)` instead. + """ + require '../src/workspace-view' - Object.defineProperty module.exports, '$', get: -> - deprecate """ - Requiring `$` from `atom` is no longer supported. - If you are using `space-pen`, please require `$` from `atom-space-pen-views`. Otherwise require `jquery` instead: - `{$} = require 'atom-space-pen-views'` - or - `$ = require 'jquery'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - Or add `"jquery": "^2"` to your package dependencies. - """ - $ + Object.defineProperty module.exports, '$', get: -> + deprecate """ + Requiring `$` from `atom` is no longer supported. + If you are using `space-pen`, please require `$` from `atom-space-pen-views`. Otherwise require `jquery` instead: + `{$} = require 'atom-space-pen-views'` + or + `$ = require 'jquery'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + Or add `"jquery": "^2"` to your package dependencies. + """ + $ - Object.defineProperty module.exports, '$$', get: -> - deprecate """ - Requiring `$$` from `atom` is no longer supported. - Please require `atom-space-pen-views` instead: - `{$$} = require 'atom-space-pen-views'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - $$ + Object.defineProperty module.exports, '$$', get: -> + deprecate """ + Requiring `$$` from `atom` is no longer supported. + Please require `atom-space-pen-views` instead: + `{$$} = require 'atom-space-pen-views'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + $$ - Object.defineProperty module.exports, '$$$', get: -> - deprecate """ - Requiring `$$$` from `atom` is no longer supported. - Please require `atom-space-pen-views` instead: - `{$$$} = require 'atom-space-pen-views'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - $$$ + Object.defineProperty module.exports, '$$$', get: -> + deprecate """ + Requiring `$$$` from `atom` is no longer supported. + Please require `atom-space-pen-views` instead: + `{$$$} = require 'atom-space-pen-views'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + $$$ - Object.defineProperty module.exports, 'View', get: -> - deprecate """ - Requiring `View` from `atom` is no longer supported. - Please require `atom-space-pen-views` instead: - `{View} = require 'atom-space-pen-views'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - View + Object.defineProperty module.exports, 'View', get: -> + deprecate """ + Requiring `View` from `atom` is no longer supported. + Please require `atom-space-pen-views` instead: + `{View} = require 'atom-space-pen-views'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + View - Object.defineProperty module.exports, 'EditorView', get: -> - deprecate """ - Requiring `EditorView` from `atom` is no longer supported. - Please require `TextEditorView` from `atom-space-pen-view` instead: - `{TextEditorView} = require 'atom-space-pen-views'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - require '../src/text-editor-view' + Object.defineProperty module.exports, 'EditorView', get: -> + deprecate """ + Requiring `EditorView` from `atom` is no longer supported. + Please require `TextEditorView` from `atom-space-pen-view` instead: + `{TextEditorView} = require 'atom-space-pen-views'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + require '../src/text-editor-view' - Object.defineProperty module.exports, 'TextEditorView', get: -> - deprecate """ - Requiring `TextEditorView` from `atom` is no longer supported. - Please require `TextEditorView` from `atom-space-pen-view` instead: - `{TextEditorView} = require 'atom-space-pen-views'` - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - require '../src/text-editor-view' + Object.defineProperty module.exports, 'TextEditorView', get: -> + deprecate """ + Requiring `TextEditorView` from `atom` is no longer supported. + Please require `TextEditorView` from `atom-space-pen-view` instead: + `{TextEditorView} = require 'atom-space-pen-views'` + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + require '../src/text-editor-view' - Object.defineProperty module.exports, 'ScrollView', get: -> - deprecate """ - Requiring `ScrollView` from `atom` is no longer supported. - Please require `ScrollView` from `atom-space-pen-view` instead: - `{ScrollView} = require 'atom-space-pen-views'` - Note that the API has changed slightly! Please read the docs at https://github.com/atom/atom-space-pen-views - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - require '../src/scroll-view' + Object.defineProperty module.exports, 'ScrollView', get: -> + deprecate """ + Requiring `ScrollView` from `atom` is no longer supported. + Please require `ScrollView` from `atom-space-pen-view` instead: + `{ScrollView} = require 'atom-space-pen-views'` + Note that the API has changed slightly! Please read the docs at https://github.com/atom/atom-space-pen-views + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + require '../src/scroll-view' - Object.defineProperty module.exports, 'SelectListView', get: -> - deprecate """ - Requiring `SelectListView` from `atom` is no longer supported. - Please require `SelectListView` from `atom-space-pen-view` instead: - `{SelectListView} = require 'atom-space-pen-views'` - Note that the API has changed slightly! Please read the docs at https://github.com/atom/atom-space-pen-views - Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. - """ - require '../src/select-list-view' + Object.defineProperty module.exports, 'SelectListView', get: -> + deprecate """ + Requiring `SelectListView` from `atom` is no longer supported. + Please require `SelectListView` from `atom-space-pen-view` instead: + `{SelectListView} = require 'atom-space-pen-views'` + Note that the API has changed slightly! Please read the docs at https://github.com/atom/atom-space-pen-views + Add `"atom-space-pen-views": "^2.0.3"` to your package dependencies. + """ + require '../src/select-list-view' - Object.defineProperty module.exports, 'React', get: -> - deprecate "Please require `react-atom-fork` instead: `React = require 'react-atom-fork'`. Add `\"react-atom-fork\": \"^0.11\"` to your package dependencies." - require 'react-atom-fork' - - Object.defineProperty module.exports, 'Reactionary', get: -> - deprecate "Please require `reactionary-atom-fork` instead: `Reactionary = require 'reactionary-atom-fork'`. Add `\"reactionary-atom-fork\": \"^0.9\"` to your package dependencies." - require 'reactionary-atom-fork' - -Object.defineProperty module.exports, 'Git', get: -> - deprecate "Please require `GitRepository` instead of `Git`: `{GitRepository} = require 'atom'`" - module.exports.GitRepository +if includeDeprecatedAPIs + Object.defineProperty module.exports, 'Git', get: -> + deprecate "Please require `GitRepository` instead of `Git`: `{GitRepository} = require 'atom'`" + module.exports.GitRepository diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index 79e33a8c8..8180f65bb 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -18,7 +18,6 @@ 'ctrl-d': 'core:delete' # Atom Specific - 'cmd-O': 'application:open-dev' 'cmd-alt-ctrl-s': 'application:run-all-specs' 'enter': 'core:confirm' 'escape': 'core:cancel' @@ -37,6 +36,7 @@ 'cmd-N': 'application:new-window' 'cmd-W': 'window:close' 'cmd-o': 'application:open' + 'cmd-O': 'application:add-project-folder' 'cmd-T': 'pane:reopen-closed-item' 'cmd-n': 'application:new-file' 'cmd-s': 'core:save' diff --git a/keymaps/linux.cson b/keymaps/linux.cson index 6e13d70a0..9c1bbd4a1 100644 --- a/keymaps/linux.cson +++ b/keymaps/linux.cson @@ -10,8 +10,8 @@ 'ctrl-shift-i': 'window:toggle-dev-tools' 'ctrl-alt-p': 'window:run-package-specs' 'ctrl-alt-s': 'application:run-all-specs' - 'ctrl-alt-o': 'application:open-dev' 'ctrl-shift-o': 'application:open-folder' + 'ctrl-alt-o': 'application:add-project-folder' 'ctrl-shift-pageup': 'pane:move-item-left' 'ctrl-shift-pagedown': 'pane:move-item-right' 'F11': 'window:toggle-full-screen' diff --git a/keymaps/win32.cson b/keymaps/win32.cson index e26aea717..2a89962f3 100644 --- a/keymaps/win32.cson +++ b/keymaps/win32.cson @@ -14,8 +14,8 @@ 'ctrl-alt-i': 'window:toggle-dev-tools' 'ctrl-alt-p': 'window:run-package-specs' 'ctrl-alt-s': 'application:run-all-specs' - 'ctrl-alt-o': 'application:open-dev' 'ctrl-shift-o': 'application:open-folder' + 'ctrl-alt-o': 'application:add-project-folder' 'ctrl-shift-left': 'pane:move-item-left' 'ctrl-shift-right': 'pane:move-item-right' 'F11': 'window:toggle-full-screen' @@ -77,6 +77,15 @@ 'ctrl-k ctrl-down': 'window:focus-pane-below' 'ctrl-k ctrl-left': 'window:focus-pane-on-left' 'ctrl-k ctrl-right': 'window:focus-pane-on-right' + 'alt-1': 'pane:show-item-1' + 'alt-2': 'pane:show-item-2' + 'alt-3': 'pane:show-item-3' + 'alt-4': 'pane:show-item-4' + 'alt-5': 'pane:show-item-5' + 'alt-6': 'pane:show-item-6' + 'alt-7': 'pane:show-item-7' + 'alt-8': 'pane:show-item-8' + 'alt-9': 'pane:show-item-9' 'atom-workspace atom-text-editor': # Platform Bindings diff --git a/menus/darwin.cson b/menus/darwin.cson index 7283ba16f..17072e14f 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -34,6 +34,7 @@ { label: 'New Window', command: 'application:new-window' } { label: 'New File', command: 'application:new-file' } { label: 'Open...', command: 'application:open' } + { label: 'Add Project Folder...', command: 'application:add-project-folder' } { label: 'Reopen Last Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: 'Save', command: 'core:save' } @@ -80,6 +81,8 @@ { label: 'Lower Case', command: 'editor:lower-case' } { type: 'separator' } { label: 'Delete to End of Word', command: 'editor:delete-to-end-of-word' } + { label: 'Delete to Previous Word Boundary', command: 'editor:delete-to-previous-word-boundary' } + { label: 'Delete to Next Word Boundary', command: 'editor:delete-to-next-word-boundary' } { label: 'Delete Line', command: 'editor:delete-line' } { type: 'separator' } { label: 'Transpose', command: 'editor:transpose' } @@ -167,6 +170,10 @@ ] } { type: 'separator' } + { label: 'Increase Font Size', command: 'window:increase-font-size' } + { label: 'Decrease Font Size', command: 'window:decrease-font-size' } + { label: 'Reset Font Size', command: 'window:reset-font-size' } + { type: 'separator' } { label: 'Toggle Soft Wrap', command: 'editor:toggle-soft-wrap' } ] } @@ -217,6 +224,7 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] 'atom-pane': [ @@ -225,5 +233,6 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] diff --git a/menus/linux.cson b/menus/linux.cson index fc1e58785..9363d02e5 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -6,6 +6,7 @@ { label: '&New File', command: 'application:new-file' } { label: '&Open File...', command: 'application:open-file' } { label: 'Open Folder...', command: 'application:open-folder' } + { label: 'Add Project Folder...', command: 'application:add-project-folder' } { label: 'Reopen Last &Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: '&Save', command: 'core:save' } @@ -54,6 +55,8 @@ { label: '&Lower Case', command: 'editor:lower-case' } { type: 'separator' } { label: 'Delete to End of &Word', command: 'editor:delete-to-end-of-word' } + { label: 'Delete to Previous Word Boundary', command: 'editor:delete-to-previous-word-boundary' } + { label: 'Delete to Next Word Boundary', command: 'editor:delete-to-next-word-boundary' } { label: '&Delete Line', command: 'editor:delete-line' } { type: 'separator' } { label: '&Transpose', command: 'editor:transpose' } @@ -95,6 +98,25 @@ { label: '&Reload', command: 'window:reload' } { label: 'Toggle &Full Screen', command: 'window:toggle-full-screen' } { label: 'Toggle Menu Bar', command: 'window:toggle-menu-bar' } + { + label: 'Panes' + submenu: [ + { label: 'Split Up', command: 'pane:split-up' } + { label: 'Split Down', command: 'pane:split-down' } + { label: 'Split Left', command: 'pane:split-left' } + { label: 'Split Right', command: 'pane:split-right' } + { type: 'separator' } + { label: 'Focus Next Pane', command: 'window:focus-next-pane' } + { label: 'Focus Previous Pane', command: 'window:focus-previous-pane' } + { type: 'separator' } + { label: 'Focus Pane Above', command: 'window:focus-pane-above' } + { label: 'Focus Pane Below', command: 'window:focus-pane-below' } + { label: 'Focus Pane On Left', command: 'window:focus-pane-on-left' } + { label: 'Focus Pane On Right', command: 'window:focus-pane-on-right' } + { type: 'separator' } + { label: 'Close Pane', command: 'pane:close' } + ] + } { label: 'Developer' submenu: [ @@ -105,6 +127,10 @@ ] } { type: 'separator' } + { label: '&Increase Font Size', command: 'window:increase-font-size' } + { label: '&Decrease Font Size', command: 'window:decrease-font-size' } + { label: 'Re&set Font Size', command: 'window:reset-font-size' } + { type: 'separator' } { label: 'Toggle Soft &Wrap', command: 'editor:toggle-soft-wrap' } ] } @@ -174,6 +200,7 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] 'atom-pane': [ @@ -182,5 +209,6 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] diff --git a/menus/win32.cson b/menus/win32.cson index a3e5c8b8d..068817888 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -6,6 +6,7 @@ { label: '&New File', command: 'application:new-file' } { label: '&Open File...', command: 'application:open-file' } { label: 'Open Folder...', command: 'application:open-folder' } + { label: 'Add Project Folder...', command: 'application:add-project-folder' } { label: 'Reopen Last &Item', command: 'pane:reopen-closed-item' } { type: 'separator' } { label: 'Se&ttings', command: 'application:show-settings' } @@ -61,6 +62,8 @@ { label: '&Lower Case', command: 'editor:lower-case' } { type: 'separator' } { label: 'Delete to End of &Word', command: 'editor:delete-to-end-of-word' } + { label: 'Delete to Previous Word Boundary', command: 'editor:delete-to-previous-word-boundary' } + { label: 'Delete to Next Word Boundary', command: 'editor:delete-to-next-word-boundary' } { label: '&Delete Line', command: 'editor:delete-line' } { type: 'separator' } { label: '&Transpose', command: 'editor:transpose' } @@ -123,6 +126,10 @@ ] } { type: 'separator' } + { label: '&Increase Font Size', command: 'window:increase-font-size' } + { label: '&Decrease Font Size', command: 'window:decrease-font-size' } + { label: 'Re&set Font Size', command: 'window:reset-font-size' } + { type: 'separator' } { label: 'Toggle Soft &Wrap', command: 'editor:toggle-soft-wrap' } ] } @@ -196,6 +203,7 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] 'atom-pane': [ @@ -204,5 +212,6 @@ {label: 'Split Down', command: 'pane:split-down'} {label: 'Split Left', command: 'pane:split-left'} {label: 'Split Right', command: 'pane:split-right'} + {label: 'Close Pane', command: 'pane:close'} {type: 'separator'} ] diff --git a/package.json b/package.json index dbcedb53a..8cca44bf7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "0.185.0", + "version": "1.0.1", "description": "A hackable text editor for the 21st Century.", "main": "./src/browser/main.js", "repository": { @@ -11,147 +11,146 @@ "bugs": { "url": "https://github.com/atom/atom/issues" }, - "licenses": [ - { - "type": "MIT", - "url": "http://github.com/atom/atom/raw/master/LICENSE.md" - } - ], - "atomShellVersion": "0.21.0", + "license": "MIT", + "atomShellVersion": "0.22.3", "dependencies": { "async": "0.2.6", - "atom-keymap": "^3.1.3", + "atom-keymap": "^5.1.5", "atom-space-pen-views": "^2.0.4", - "babel-core": "^4.0.2", - "bootstrap": "git+https://github.com/atom/bootstrap.git#6af81906189f1747fd6c93479e3d998ebe041372", - "clear-cut": "0.4.0", - "coffee-cash": "0.7.0", + "babel-core": "^5.1.11", + "bootstrap": "^3.3.4", + "clear-cut": "^2.0.1", + "coffee-cash": "0.8.0", "coffee-script": "1.8.0", - "coffeestack": "^1.1.1", + "coffeestack": "^1.1.2", "color": "^0.7.3", "delegato": "^1", "emissary": "^1.3.3", - "event-kit": "^1.0.3", - "first-mate": "^3.0.0", - "fs-plus": "^2.5", + "event-kit": "^1.2.0", + "first-mate": "^4.1.7", + "fs-plus": "^2.8.0", "fstream": "0.1.24", "fuzzaldrin": "^2.1", "git-utils": "^3.0.0", - "grim": "1.2", + "grim": "1.4.1", "jasmine-json": "~0.0", "jasmine-tagged": "^1.1.4", "jquery": "^2.1.1", "less-cache": "0.22", - "marked": "^0.3", + "marked": "^0.3.3", "mixto": "^1", + "normalize-package-data": "^2.0.0", "nslog": "^2.0.0", - "oniguruma": "^4.0.0", - "optimist": "0.4.0", - "pathwatcher": "^3.3.1", + "oniguruma": "^4.1", + "pathwatcher": "^4.4.1", "property-accessors": "^1.1.3", "q": "^1.1.2", "random-words": "0.0.1", - "react-atom-fork": "^0.11.5", - "reactionary-atom-fork": "^1.0.0", "runas": "2.0.0", - "scandal": "2.0.0", - "scoped-property-store": "^0.16.2", - "scrollbar-style": "^2.0.0", - "season": "^5.1.4", - "semver": "~4.2", + "scandal": "2.0.3", + "scoped-property-store": "^0.17.0", + "scrollbar-style": "^3.1", + "season": "^5.3", + "semver": "^4.3.3", "serializable": "^1", - "service-hub": "^0.4.0", + "service-hub": "^0.5.0", "space-pen": "3.8.2", "stacktrace-parser": "0.1.1", "temp": "0.8.1", - "text-buffer": "^4.1.5", + "text-buffer": "6.3.7", "theorist": "^1.0.2", - "underscore-plus": "^1.6.6" + "typescript-simple": "1.0.0", + "underscore-plus": "^1.6.6", + "yargs": "^3.9" }, "packageDependencies": { - "atom-dark-syntax": "0.26.0", - "atom-dark-ui": "0.47.0", - "atom-light-syntax": "0.26.0", + "atom-dark-syntax": "0.27.0", + "atom-dark-ui": "0.49.0", + "atom-light-syntax": "0.28.0", "atom-light-ui": "0.41.0", - "base16-tomorrow-dark-theme": "0.25.0", - "base16-tomorrow-light-theme": "0.8.0", - "one-dark-ui": "0.5.0", - "one-dark-syntax": "0.3.0", - "one-light-syntax": "0.4.0", - "one-light-ui": "0.4.0", - "solarized-dark-syntax": "0.32.0", - "solarized-light-syntax": "0.19.0", - "archive-view": "0.50.0", - "autocomplete": "0.44.0", - "autoflow": "0.22.0", - "autosave": "0.20.0", - "background-tips": "0.23.0", + "base16-tomorrow-dark-theme": "0.26.0", + "base16-tomorrow-light-theme": "0.9.0", + "one-dark-ui": "0.9.1", + "one-dark-syntax": "0.7.1", + "one-light-syntax": "0.7.0", + "one-light-ui": "0.9.1", + "solarized-dark-syntax": "0.35.0", + "solarized-light-syntax": "0.21.0", + "archive-view": "0.58.0", + "autocomplete-atom-api": "0.9.0", + "autocomplete-css": "0.8.0", + "autocomplete-html": "0.7.2", + "autocomplete-plus": "2.17.4", + "autocomplete-snippets": "1.7.0", + "autoflow": "0.25.0", + "autosave": "0.21.0", + "background-tips": "0.25.0", "bookmarks": "0.35.0", - "bracket-matcher": "0.71.0", - "command-palette": "0.34.0", - "deprecation-cop": "0.37.0", - "dev-live-reload": "0.41.0", - "encoding-selector": "0.18.0", - "exception-reporting": "0.24.0", - "find-and-replace": "0.159.0", - "fuzzy-finder": "0.69.0", - "git-diff": "0.54.0", + "bracket-matcher": "0.76.0", + "command-palette": "0.36.0", + "deprecation-cop": "0.53.0", + "dev-live-reload": "0.46.0", + "encoding-selector": "0.20.0", + "exception-reporting": "0.25.0", + "find-and-replace": "0.174.1", + "fuzzy-finder": "0.87.0", + "git-diff": "0.55.0", "go-to-line": "0.30.0", - "grammar-selector": "0.45.0", - "image-view": "0.49.0", - "incompatible-packages": "0.22.0", - "keybinding-resolver": "0.29.0", + "grammar-selector": "0.47.0", + "image-view": "0.54.0", + "incompatible-packages": "0.24.0", + "keybinding-resolver": "0.33.0", "link": "0.30.0", - "markdown-preview": "0.137.0", - "metrics": "0.45.0", - "notifications": "0.31.0", - "open-on-github": "0.33.0", - "package-generator": "0.38.0", - "release-notes": "0.51.0", - "settings-view": "0.183.0", - "snippets": "0.78.0", - "spell-check": "0.55.0", - "status-bar": "0.60.0", + "markdown-preview": "0.150.0", + "metrics": "0.51.0", + "notifications": "0.56.0", + "open-on-github": "0.37.0", + "package-generator": "0.39.0", + "release-notes": "0.53.0", + "settings-view": "0.209.0", + "snippets": "0.95.0", + "spell-check": "0.59.0", + "status-bar": "0.74.0", "styleguide": "0.44.0", - "symbols-view": "0.86.0", - "tabs": "0.67.0", + "symbols-view": "0.99.0", + "tabs": "0.81.0", "timecop": "0.31.0", - "tree-view": "0.164.0", - "update-package-dependencies": "0.8.0", - "welcome": "0.24.0", - "whitespace": "0.29.0", - "wrap-guide": "0.31.0", - "language-c": "0.41.0", - "language-clojure": "0.13.0", - "language-coffee-script": "0.39.0", - "language-csharp": "0.5.0", - "language-css": "0.28.0", - "language-gfm": "0.64.0", + "tree-view": "0.174.0", + "update-package-dependencies": "0.10.0", + "welcome": "0.29.0", + "whitespace": "0.30.0", + "wrap-guide": "0.35.0", + "language-c": "0.45.0", + "language-clojure": "0.16.0", + "language-coffee-script": "0.41.0", + "language-csharp": "0.6.0", + "language-css": "0.32.0", + "language-gfm": "0.77.0", "language-git": "0.10.0", - "language-go": "0.21.0", - "language-html": "0.29.0", - "language-hyperlink": "0.12.2", - "language-java": "0.14.0", - "language-javascript": "0.60.0", - "language-json": "0.12.0", - "language-less": "0.25.0", - "language-make": "0.13.0", + "language-go": "0.27.0", + "language-html": "0.40.0", + "language-hyperlink": "0.13.0", + "language-java": "0.15.0", + "language-javascript": "0.80.0", + "language-json": "0.15.0", + "language-less": "0.27.0", + "language-make": "0.14.0", "language-mustache": "0.11.0", "language-objective-c": "0.15.0", - "language-perl": "0.11.0", - "language-php": "0.21.0", + "language-perl": "0.26.0", + "language-php": "0.26.0", "language-property-list": "0.8.0", - "language-python": "0.32.0", - "language-ruby": "0.49.0", - "language-ruby-on-rails": "0.20.0", - "language-sass": "0.35.0", - "language-shellscript": "0.12.0", + "language-python": "0.37.0", + "language-ruby": "0.56.0", + "language-ruby-on-rails": "0.22.0", + "language-sass": "0.39.0", + "language-shellscript": "0.15.0", "language-source": "0.9.0", - "language-sql": "0.14.0", - "language-text": "0.6.0", - "language-todo": "0.16.0", - "language-toml": "0.15.0", - "language-xml": "0.28.0", + "language-sql": "0.17.0", + "language-text": "0.7.0", + "language-todo": "0.25.0", + "language-toml": "0.16.0", + "language-xml": "0.30.0", "language-yaml": "0.22.0" }, "private": true, diff --git a/resources/linux/debian/control.in b/resources/linux/debian/control.in index 1578f544b..7e47f27c6 100644 --- a/resources/linux/debian/control.in +++ b/resources/linux/debian/control.in @@ -1,6 +1,7 @@ Package: <%= name %> Version: <%= version %> -Depends: git, gconf2, gconf-service, libgtk2.0-0, libudev0 | libudev1, libgcrypt11 | libgcrypt20, libnotify4, libxtst6, libnss3, python, gvfs-bin, xdg-utils +Depends: git, gconf2, gconf-service, libgtk2.0-0, libudev0 | libudev1, libgcrypt11 | libgcrypt20, libnotify4, libxtst6, libnss3, python, gvfs-bin, xdg-utils, libcap2 +Recommends: lsb-release Suggests: libgnome-keyring0, gir1.2-gnomekeyring-1.0 Section: <%= section %> Priority: optional diff --git a/resources/linux/redhat/atom.spec.in b/resources/linux/redhat/atom.spec.in index 369aeea70..8ef886e31 100644 --- a/resources/linux/redhat/atom.spec.in +++ b/resources/linux/redhat/atom.spec.in @@ -7,6 +7,8 @@ URL: https://atom.io/ AutoReqProv: no # Avoid libchromiumcontent.so missing dependency Prefix: <%= installDir %> +Requires: lsb-core-noarch + %description <%= description %> diff --git a/resources/win/apm.sh b/resources/win/apm.sh index c895f2bce..b50a70a82 100644 --- a/resources/win/apm.sh +++ b/resources/win/apm.sh @@ -1,3 +1,4 @@ #!/bin/sh -"$0/../../app/apm/bin/node.exe" "$0/../../app/apm/lib/cli.js" "$@" +directory=$(dirname "$0") +"$directory/../app/apm/bin/node.exe" "$directory/../app/apm/lib/cli.js" "$@" diff --git a/resources/win/atom.sh b/resources/win/atom.sh index 96370bee5..b6edeeb57 100644 --- a/resources/win/atom.sh +++ b/resources/win/atom.sh @@ -15,8 +15,10 @@ while getopts ":fhtvw-:" opt; do esac done +directory=$(dirname "$0") + if [ $EXPECT_OUTPUT ]; then - "$0/../../../atom.exe" "$@" + "$directory/../../atom.exe" "$@" else - "$0/../../app/apm/bin/node.exe" "$0/../atom.js" "$@" + "$directory/../app/apm/bin/node.exe" "$directory/atom.js" "$@" fi diff --git a/script/bootstrap b/script/bootstrap index 270abff7c..4c9435852 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -35,7 +35,7 @@ function bootstrap() { var npmPath = path.resolve(__dirname, '..', 'build', 'node_modules', '.bin', 'npm'); var initialNpmCommand = fs.existsSync(npmPath) ? npmPath : 'npm'; - var npmFlags = ' --userconfig=' + path.resolve('.npmrc') + ' '; + var npmFlags = ' --userconfig=' + path.resolve(__dirname, '..', 'build', '.npmrc') + ' '; var packagesToDedupe = [ 'abbrev', diff --git a/script/build b/script/build index 1f0f123ce..a40a02e13 100755 --- a/script/build +++ b/script/build @@ -1,13 +1,12 @@ #!/usr/bin/env node var cp = require('./utils/child-process-wrapper.js'); +var runGrunt = require('./utils/run-grunt.js'); var path = require('path'); process.chdir(path.dirname(__dirname)); cp.safeExec('node script/bootstrap', function() { // build/node_modules/.bin/grunt "$@" - var gruntPath = path.join('build', 'node_modules', '.bin', 'grunt') + (process.platform === 'win32' ? '.cmd' : ''); - var args = ['--gruntfile', path.resolve('build', 'Gruntfile.coffee')]; - args = args.concat(process.argv.slice(2)); - cp.safeSpawn(gruntPath, args, process.exit); + var args = process.argv.slice(2); + runGrunt(args, process.exit); }); diff --git a/script/cibuild b/script/cibuild index 678e2f123..08ff65c6e 100755 --- a/script/cibuild +++ b/script/cibuild @@ -31,17 +31,54 @@ function readEnvironmentVariables() { } function removeNodeModules() { + var fsPlus; try { - require('fs-plus').removeSync(path.resolve(__dirname, '..', 'node_modules')); + fsPlus = require('fs-plus'); + } catch (error) { + return; + } + + try { + fsPlus.removeSync(path.resolve(__dirname, '..', 'node_modules')); } catch (error) { console.error(error.message); process.exit(1); } } +function removeTempFolders() { + var fsPlus; + try { + fsPlus = require('fs-plus'); + } catch (error) { + return; + } + + var temp = require('os').tmpdir(); + if (!fsPlus.isDirectorySync(temp)) + return; + + var deletedFolders = 0; + + fsPlus.readdirSync(temp).filter(function(folderName) { + return folderName.indexOf('npm-') === 0; + }).forEach(function(folderName) { + try { + fsPlus.removeSync(path.join(temp, folderName)); + deletedFolders++; + } catch (error) { + console.error("Failed to delete npm temp folder: " + error.message); + } + }); + + if (deletedFolders > 0) + console.log("Deleted " + deletedFolders + " npm folders from temp directory"); +} + readEnvironmentVariables(); removeNodeModules(); -cp.safeExec.bind(global, 'npm install npm', {cwd: path.resolve(__dirname, '..', 'build')}, function() { +removeTempFolders(); +cp.safeExec.bind(global, 'npm install npm --loglevel error', {cwd: path.resolve(__dirname, '..', 'build')}, function() { cp.safeExec.bind(global, 'node script/bootstrap', function(error) { if (error) process.exit(1); diff --git a/script/grunt b/script/grunt index 23b12cf85..99f6860d2 100755 --- a/script/grunt +++ b/script/grunt @@ -1,17 +1,6 @@ #!/usr/bin/env node -var cp = require('./utils/child-process-wrapper.js'); -var fs = require('fs'); -var path = require('path'); +var runGrunt = require('./utils/run-grunt.js'); -// node build/node_modules/.bin/grunt "$@" -var gruntPath = path.resolve(__dirname, '..', 'build', 'node_modules', '.bin', 'grunt') + (process.platform === 'win32' ? '.cmd' : ''); - -if (!fs.existsSync(gruntPath)) { - console.error('Grunt command does not exist at: ' + gruntPath); - console.error('Run script/bootstrap to install Grunt'); - process.exit(1); -} - -var args = ['--gruntfile', path.resolve('build', 'Gruntfile.coffee')]; -args = args.concat(process.argv.slice(2)); -cp.safeSpawn(gruntPath, args, process.exit); +// build/node_modules/.bin/grunt "$@" +var args = process.argv.slice(2); +runGrunt(args, process.exit); diff --git a/script/mkdeb b/script/mkdeb index 272fe23f3..c39b6d649 100755 --- a/script/mkdeb +++ b/script/mkdeb @@ -33,7 +33,7 @@ cp "$ICON_FILE" "$TARGET/usr/share/pixmaps" # Copy generated LICENSE.md to /usr/share/doc/atom/copyright mkdir -m $FILE_MODE -p "$TARGET/usr/share/doc/atom" -cp "$TARGET/usr/share/atom/resources/app/LICENSE.md" "$TARGET/usr/share/doc/atom/copyright" +cp "$TARGET/usr/share/atom/resources/LICENSE.md" "$TARGET/usr/share/doc/atom/copyright" # Add lintian overrides mkdir -m $FILE_MODE -p "$TARGET/usr/share/lintian/overrides" diff --git a/script/rpmbuild b/script/rpmbuild index 0e0f4882c..1b33bb86e 100755 --- a/script/rpmbuild +++ b/script/rpmbuild @@ -3,4 +3,4 @@ set -e script/build -script/grunt mkrpm publish-build --stack --install-dir /usr +script/grunt mkrpm publish-build --stack --no-color --install-dir /usr diff --git a/script/test b/script/test index 2c3b37147..e8cb53006 100755 --- a/script/test +++ b/script/test @@ -1,10 +1,10 @@ #!/usr/bin/env node var safeExec = require('./utils/child-process-wrapper.js').safeExec; +var runGrunt = require('./utils/run-grunt.js'); var path = require('path'); process.chdir(path.dirname(__dirname)); safeExec('node script/bootstrap', function() { - var gruntPath = path.join('node_modules', '.bin', 'grunt') + (process.platform === 'win32' ? '.cmd' : ''); - safeExec(gruntPath + ' ci --stack --no-color', process.exit); + runGrunt(["ci", "--stack", "--no-color"], process.exit); }); diff --git a/script/utils/run-grunt.js b/script/utils/run-grunt.js new file mode 100644 index 000000000..f63d1009a --- /dev/null +++ b/script/utils/run-grunt.js @@ -0,0 +1,17 @@ +var cp = require('./child-process-wrapper.js'); +var fs = require('fs'); +var path = require('path'); + +module.exports = function(additionalArgs, callback) { + var gruntPath = path.join('build', 'node_modules', '.bin', 'grunt') + (process.platform === 'win32' ? '.cmd' : ''); + + if (!fs.existsSync(gruntPath)) { + console.error('Grunt command does not exist at: ' + gruntPath); + console.error('Run script/bootstrap to install Grunt'); + process.exit(1); + } + + var args = ['--gruntfile', path.resolve('build', 'Gruntfile.coffee')]; + args = args.concat(additionalArgs); + cp.safeSpawn(gruntPath, args, callback); +}; diff --git a/script/utils/translate-crash-log-addresses.coffee b/script/utils/translate-crash-log-addresses.coffee index 7fb01532b..fdb540d64 100755 --- a/script/utils/translate-crash-log-addresses.coffee +++ b/script/utils/translate-crash-log-addresses.coffee @@ -17,7 +17,7 @@ parse_stack_trace = (raw) -> addresses = [] for line in raw columns = line.split /\ +/ - if columns[1] == 'libcef.dylib' and /0x[a-f0-9]+/.test columns[3] + if columns[1] is 'libcef.dylib' and /0x[a-f0-9]+/.test columns[3] lines[columns[0]] = addresses.length addresses.push '0x' + parseInt(columns[5]).toString(16) + ' ' @@ -36,12 +36,12 @@ parse_log_file = (content) -> lines = content.split /\r?\n/ for line in lines - if state == 'start' + if state is 'start' if /Thread \d+ Crashed::/.test line console.log line state = 'parse' - else if state == 'parse' - break if line == '' + else if state is 'parse' + break if line is '' stack_trace.push line parse_stack_trace stack_trace @@ -53,4 +53,3 @@ process.stdin.on 'data', (chunk) -> input += chunk process.stdin.on 'end', -> parse_log_file input - diff --git a/script/utils/verify-requirements.js b/script/utils/verify-requirements.js index 554c27dd0..890172753 100644 --- a/script/utils/verify-requirements.js +++ b/script/utils/verify-requirements.js @@ -57,7 +57,7 @@ function verifyNpm(cb) { var npmMajorVersion = +versionArray[0] || 0; var npmMinorVersion = +versionArray[1] || 0; if (npmMajorVersion === 1 && npmMinorVersion < 4) - cb("npm v1.4+ is required to build Atom."); + cb("npm v1.4+ is required to build Atom. Version " + npmVersion + " was detected."); else cb(null, "npm: v" + npmVersion); }); diff --git a/spec/atom-spec.coffee b/spec/atom-spec.coffee index 12d094428..99a7f432e 100644 --- a/spec/atom-spec.coffee +++ b/spec/atom-spec.coffee @@ -55,7 +55,7 @@ describe "the `atom` global", -> describe "loading default config", -> it 'loads the default core config', -> expect(atom.config.get('core.excludeVcsIgnoredPaths')).toBe true - expect(atom.config.get('core.followSymlinks')).toBe false + expect(atom.config.get('core.followSymlinks')).toBe true expect(atom.config.get('editor.showInvisibles')).toBe false describe "window onerror handler", -> @@ -139,7 +139,7 @@ describe "the `atom` global", -> windowState: null spyOn(Atom, 'getLoadSettings').andCallFake -> loadSettings - spyOn(Atom, 'getStorageDirPath').andReturn(temp.mkdirSync("storage-dir-")) + spyOn(Atom.getStorageFolder(), 'getPath').andReturn(temp.mkdirSync("storage-dir-")) atom.mode = "editor" atom.state.stuff = "cool" @@ -152,3 +152,47 @@ describe "the `atom` global", -> loadSettings.initialPaths = [dir2, dir1] atom2 = Atom.loadOrCreate("editor") expect(atom2.state.stuff).toBe("cool") + + 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 "adds a second path to the project", -> + initialPaths = atom.project.getPaths() + tempDirectory = temp.mkdirSync("a-new-directory") + spyOn(atom, "pickFolder").andCallFake (callback) -> + callback([tempDirectory]) + atom.addProjectFolder() + expect(atom.project.getPaths()).toEqual(initialPaths.concat([tempDirectory])) + + 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) diff --git a/spec/buffered-process-spec.coffee b/spec/buffered-process-spec.coffee index ea9abe001..04cff0b6d 100644 --- a/spec/buffered-process-spec.coffee +++ b/spec/buffered-process-spec.coffee @@ -13,21 +13,44 @@ describe "BufferedProcess", -> window.onerror = oldOnError describe "when there is an error handler specified", -> - it "calls the error handler and does not throw an exception", -> - process = new BufferedProcess - command: 'bad-command-nope' - args: ['nothing'] - options: {} + describe "when an error event is emitted by the process", -> + it "calls the error handler and does not throw an exception", -> + process = new BufferedProcess + command: 'bad-command-nope' + args: ['nothing'] + options: {} - errorSpy = jasmine.createSpy().andCallFake (error) -> error.handle() - process.onWillThrowError(errorSpy) + errorSpy = jasmine.createSpy().andCallFake (error) -> error.handle() + process.onWillThrowError(errorSpy) - waitsFor -> errorSpy.callCount > 0 + waitsFor -> errorSpy.callCount > 0 - runs -> - expect(window.onerror).not.toHaveBeenCalled() - expect(errorSpy).toHaveBeenCalled() - expect(errorSpy.mostRecentCall.args[0].error.message).toContain 'spawn bad-command-nope ENOENT' + runs -> + expect(window.onerror).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + expect(errorSpy.mostRecentCall.args[0].error.message).toContain 'spawn bad-command-nope ENOENT' + + describe "when an error is thrown spawning the process", -> + it "calls the error handler and does not throw an exception", -> + spyOn(ChildProcess, 'spawn').andCallFake -> + error = new Error('Something is really wrong') + error.code = 'EAGAIN' + throw error + + process = new BufferedProcess + command: 'ls' + args: [] + options: {} + + errorSpy = jasmine.createSpy().andCallFake (error) -> error.handle() + process.onWillThrowError(errorSpy) + + waitsFor -> errorSpy.callCount > 0 + + runs -> + expect(window.onerror).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + expect(errorSpy.mostRecentCall.args[0].error.message).toContain 'Something is really wrong' describe "when there is not an error handler specified", -> it "calls the error handler and does not throw an exception", -> @@ -73,3 +96,21 @@ describe "BufferedProcess", -> expect(ChildProcess.spawn.argsForCall[0][1][0]).toBe '/s' expect(ChildProcess.spawn.argsForCall[0][1][1]).toBe '/c' expect(ChildProcess.spawn.argsForCall[0][1][2]).toBe '"dir"' + + it "calls the specified stdout, stderr, and exit callbacks ", -> + stdout = '' + stderr = '' + exitCallback = jasmine.createSpy('exit callback') + process = new BufferedProcess + command: atom.packages.getApmPath() + args: ['-h'] + options: {} + stdout: (lines) -> stdout += lines + stderr: (lines) -> stderr += lines + exit: exitCallback + + waitsFor -> exitCallback.callCount is 1 + + runs -> + expect(stderr).toContain 'apm - Atom Package Manager' + expect(stdout).toEqual '' diff --git a/spec/command-registry-spec.coffee b/spec/command-registry-spec.coffee index 2af185b16..0a8cafa6b 100644 --- a/spec/command-registry-spec.coffee +++ b/spec/command-registry-spec.coffee @@ -148,6 +148,36 @@ describe "CommandRegistry", -> grandchild.dispatchEvent(new CustomEvent('command-2', bubbles: true)) expect(calls).toEqual [] + describe "::add(selector, commandName, callback)", -> + it "throws an error when called with an invalid selector", -> + badSelector = '<>' + addError = null + try + registry.add badSelector, 'foo:bar', -> + catch error + addError = error + expect(addError.message).toContain(badSelector) + + it "throws an error when called with a non-function callback and selector target", -> + badCallback = null + addError = null + + try + registry.add '.selector', 'foo:bar', badCallback + catch error + addError = error + expect(addError.message).toContain("Can't register a command with non-function callback.") + + it "throws an error when called with an non-function callback and object target", -> + badCallback = null + addError = null + + try + registry.add document.body, 'foo:bar', badCallback + catch error + addError = error + expect(addError.message).toContain("Can't register a command with non-function callback.") + describe "::findCommands({target})", -> it "returns commands that can be invoked on the target or its ancestors", -> registry.add '.parent', 'namespace:command-1', -> diff --git a/spec/compile-cache-spec.coffee b/spec/compile-cache-spec.coffee index 534457157..6eb6556d0 100644 --- a/spec/compile-cache-spec.coffee +++ b/spec/compile-cache-spec.coffee @@ -3,26 +3,37 @@ CSON = require 'season' CoffeeCache = require 'coffee-cash' babel = require '../src/babel' +typescript = require '../src/typescript' CompileCache = require '../src/compile-cache' describe "Compile Cache", -> describe ".addPathToCache(filePath)", -> - it "adds the path to the correct CSON, CoffeeScript, or babel cache", -> + it "adds the path to the correct CSON, CoffeeScript, babel or typescript cache", -> spyOn(CSON, 'readFileSync').andCallThrough() spyOn(CoffeeCache, 'addPathToCache').andCallThrough() spyOn(babel, 'addPathToCache').andCallThrough() + spyOn(typescript, 'addPathToCache').andCallThrough() CompileCache.addPathToCache(path.join(__dirname, 'fixtures', 'cson.cson')) expect(CSON.readFileSync.callCount).toBe 1 expect(CoffeeCache.addPathToCache.callCount).toBe 0 expect(babel.addPathToCache.callCount).toBe 0 + expect(typescript.addPathToCache.callCount).toBe 0 CompileCache.addPathToCache(path.join(__dirname, 'fixtures', 'coffee.coffee')) expect(CSON.readFileSync.callCount).toBe 1 expect(CoffeeCache.addPathToCache.callCount).toBe 1 expect(babel.addPathToCache.callCount).toBe 0 + expect(typescript.addPathToCache.callCount).toBe 0 CompileCache.addPathToCache(path.join(__dirname, 'fixtures', 'babel', 'babel-double-quotes.js')) expect(CSON.readFileSync.callCount).toBe 1 expect(CoffeeCache.addPathToCache.callCount).toBe 1 expect(babel.addPathToCache.callCount).toBe 1 + expect(typescript.addPathToCache.callCount).toBe 0 + + CompileCache.addPathToCache(path.join(__dirname, 'fixtures', 'typescript', 'valid.ts')) + expect(CSON.readFileSync.callCount).toBe 1 + expect(CoffeeCache.addPathToCache.callCount).toBe 1 + expect(babel.addPathToCache.callCount).toBe 1 + expect(typescript.addPathToCache.callCount).toBe 1 diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index 4abd3b0db..b75a121a1 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -251,9 +251,9 @@ describe "Config", -> it "removes all scoped and unscoped properties for that key-path", -> atom.config.setDefaults("foo.bar", baz: 100) - atom.config.set("foo.bar", { baz: 1, ok: 2 }, scopeSelector: ".a") - atom.config.set("foo.bar", { baz: 11, ok: 12 }, scopeSelector: ".b") - atom.config.set("foo.bar", { baz: 21, ok: 22 }) + atom.config.set("foo.bar", {baz: 1, ok: 2}, scopeSelector: ".a") + atom.config.set("foo.bar", {baz: 11, ok: 12}, scopeSelector: ".b") + atom.config.set("foo.bar", {baz: 21, ok: 22}) atom.config.unset("foo.bar.baz") @@ -486,6 +486,7 @@ describe "Config", -> atom.config.set('foo.bar.baz', "value 1") expect(observeHandler).toHaveBeenCalledWith("value 1") + advanceClock(100) # complete pending save that was requested in ::set observeHandler.reset() atom.config.loadUserConfig() @@ -789,6 +790,21 @@ describe "Config", -> expect(warnSpy).toHaveBeenCalled() expect(warnSpy.mostRecentCall.args[0]).toContain "foo.int" + describe "when there is a pending save", -> + it "does not change the config settings", -> + fs.writeFileSync atom.config.configFilePath, "'*': foo: bar: 'baz'" + + atom.config.set("foo.bar", "quux") + atom.config.loadUserConfig() + expect(atom.config.get("foo.bar")).toBe "quux" + + advanceClock(100) + expect(atom.config.save.callCount).toBe 1 + + expect(atom.config.get("foo.bar")).toBe "quux" + atom.config.loadUserConfig() + expect(atom.config.get("foo.bar")).toBe "baz" + describe ".observeUserConfig()", -> updatedHandler = null @@ -854,7 +870,7 @@ describe "Config", -> expect(atom.config.get('foo.bar')).toBe 'baz' expect(atom.config.get('foo.baz')).toBe 'ok' - describe 'when the default value is a complex value', -> + describe "when the default value is a complex value", -> beforeEach -> atom.config.setSchema 'foo.bar', type: 'array' @@ -877,7 +893,7 @@ describe "Config", -> expect(atom.config.get('foo.bar')).toEqual ['baz', 'ok'] expect(atom.config.get('foo.baz')).toBe 'another' - describe 'when scoped settings are used', -> + describe "when scoped settings are used", -> it "fires a change event for scoped settings that are removed", -> scopedSpy = jasmine.createSpy() atom.config.onDidChange('foo.scoped', scope: ['.source.ruby'], scopedSpy) @@ -987,7 +1003,6 @@ describe "Config", -> expect(fs.existsSync(atom.config.configDirPath)).toBeTruthy() expect(fs.existsSync(path.join(atom.config.configDirPath, 'packages'))).toBeTruthy() expect(fs.isFileSync(path.join(atom.config.configDirPath, 'snippets.cson'))).toBeTruthy() - expect(fs.isFileSync(path.join(atom.config.configDirPath, 'config.cson'))).toBeTruthy() expect(fs.isFileSync(path.join(atom.config.configDirPath, 'init.coffee'))).toBeTruthy() expect(fs.isFileSync(path.join(atom.config.configDirPath, 'styles.less'))).toBeTruthy() @@ -1365,6 +1380,16 @@ describe "Config", -> expect(atom.config.set('foo.bar.aString', nope: 'nope')).toBe false expect(atom.config.get('foo.bar.aString')).toBe 'ok' + describe 'when the schema has a "maximumLength" key', -> + it "trims the string to be no longer than the specified maximum", -> + schema = + type: 'string' + default: 'ok' + maximumLength: 3 + atom.config.setSchema('foo.bar.aString', schema) + atom.config.set('foo.bar.aString', 'abcdefg') + expect(atom.config.get('foo.bar.aString')).toBe 'abc' + describe 'when the value has an "object" type', -> beforeEach -> schema = diff --git a/spec/context-menu-manager-spec.coffee b/spec/context-menu-manager-spec.coffee index 1c3c1f21a..ab1f05080 100644 --- a/spec/context-menu-manager-spec.coffee +++ b/spec/context-menu-manager-spec.coffee @@ -151,6 +151,14 @@ describe "ContextMenuManager", -> shouldDisplay = false expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual [] + it "throws an error when the selector is invalid", -> + addError = null + try + contextMenu.add '<>': [{label: 'A', command: 'a'}] + catch error + addError = error + expect(addError.message).toContain('<>') + describe "when the menus are specified in a legacy format", -> beforeEach -> jasmine.snapshotDeprecations() diff --git a/spec/custom-gutter-component-spec.coffee b/spec/custom-gutter-component-spec.coffee new file mode 100644 index 000000000..4b7d81fba --- /dev/null +++ b/spec/custom-gutter-component-spec.coffee @@ -0,0 +1,129 @@ +CustomGutterComponent = require '../src/custom-gutter-component' +Gutter = require '../src/gutter' + +describe "CustomGutterComponent", -> + [gutterComponent, gutter] = [] + + beforeEach -> + mockGutterContainer = {} + gutter = new Gutter(mockGutterContainer, {name: 'test-gutter'}) + gutterComponent = new CustomGutterComponent({gutter}) + + it "creates a gutter DOM node with only an empty 'custom-decorations' child node when it is initialized", -> + expect(gutterComponent.getDomNode().classList.contains('gutter')).toBe true + expect(gutterComponent.getDomNode().getAttribute('gutter-name')).toBe 'test-gutter' + expect(gutterComponent.getDomNode().children.length).toBe 1 + decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) + expect(decorationsWrapperNode.classList.contains('custom-decorations')).toBe true + + it "makes its view accessible from the view registry", -> + expect(gutterComponent.getDomNode()).toBe atom.views.getView(gutter) + + it "hides its DOM node when ::hideNode is called, and shows its DOM node when ::showNode is called", -> + gutterComponent.hideNode() + expect(gutterComponent.getDomNode().style.display).toBe 'none' + gutterComponent.showNode() + expect(gutterComponent.getDomNode().style.display).toBe '' + + describe "::updateSync", -> + decorationItem1 = document.createElement('div') + + buildTestState = (customDecorations) -> + mockTestState = + content: if customDecorations then customDecorations else {} + styles: + scrollHeight: 100 + scrollTop: 10 + backgroundColor: 'black' + + mockTestState + + it "sets the custom-decoration wrapper's scrollHeight, scrollTop, and background color", -> + decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) + expect(decorationsWrapperNode.style.height).toBe '' + expect(decorationsWrapperNode.style['-webkit-transform']).toBe '' + expect(decorationsWrapperNode.style.backgroundColor).toBe '' + + gutterComponent.updateSync(buildTestState({})) + expect(decorationsWrapperNode.style.height).not.toBe '' + expect(decorationsWrapperNode.style['-webkit-transform']).not.toBe '' + expect(decorationsWrapperNode.style.backgroundColor).not.toBe '' + + it "creates a new DOM node for a new decoration and adds it to the gutter at the right place", -> + customDecorations = + 'decoration-id-1': + top: 0 + height: 10 + item: decorationItem1 + class: 'test-class-1' + + gutterComponent.updateSync(buildTestState(customDecorations)) + decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) + expect(decorationsWrapperNode.children.length).toBe 1 + + decorationNode = decorationsWrapperNode.children.item(0) + expect(decorationNode.style.top).toBe '0px' + expect(decorationNode.style.height).toBe '10px' + expect(decorationNode.classList.contains('test-class-1')).toBe true + expect(decorationNode.classList.contains('decoration')).toBe true + expect(decorationNode.children.length).toBe 1 + + decorationItem = decorationNode.children.item(0) + expect(decorationItem).toBe decorationItem1 + + it "updates the existing DOM node for a decoration that existed but has new properties", -> + initialCustomDecorations = + 'decoration-id-1': + top: 0 + height: 10 + item: decorationItem1 + class: 'test-class-1' + gutterComponent.updateSync(buildTestState(initialCustomDecorations)) + initialDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) + + # Change the dimensions and item, remove the class. + decorationItem2 = document.createElement('div') + changedCustomDecorations = + 'decoration-id-1': + top: 10 + height: 20 + item: decorationItem2 + gutterComponent.updateSync(buildTestState(changedCustomDecorations)) + changedDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) + expect(changedDecorationNode).toBe initialDecorationNode + expect(changedDecorationNode.style.top).toBe '10px' + expect(changedDecorationNode.style.height).toBe '20px' + expect(changedDecorationNode.classList.contains('test-class-1')).toBe false + expect(changedDecorationNode.classList.contains('decoration')).toBe true + expect(changedDecorationNode.children.length).toBe 1 + decorationItem = changedDecorationNode.children.item(0) + expect(decorationItem).toBe decorationItem2 + + # Remove the item, add a class. + changedCustomDecorations = + 'decoration-id-1': + top: 10 + height: 20 + class: 'test-class-2' + gutterComponent.updateSync(buildTestState(changedCustomDecorations)) + changedDecorationNode = gutterComponent.getDomNode().children.item(0).children.item(0) + expect(changedDecorationNode).toBe initialDecorationNode + expect(changedDecorationNode.style.top).toBe '10px' + expect(changedDecorationNode.style.height).toBe '20px' + expect(changedDecorationNode.classList.contains('test-class-2')).toBe true + expect(changedDecorationNode.classList.contains('decoration')).toBe true + expect(changedDecorationNode.children.length).toBe 0 + + it "removes any decorations that existed previously but aren't in the latest update", -> + customDecorations = + 'decoration-id-1': + top: 0 + height: 10 + class: 'test-class-1' + gutterComponent.updateSync(buildTestState(customDecorations)) + decorationsWrapperNode = gutterComponent.getDomNode().children.item(0) + expect(decorationsWrapperNode.children.length).toBe 1 + + emptyCustomDecorations = {} + gutterComponent.updateSync(buildTestState(emptyCustomDecorations)) + expect(decorationsWrapperNode.children.length).toBe 0 diff --git a/spec/default-directory-provider-spec.coffee b/spec/default-directory-provider-spec.coffee index 780f2afd5..357348c4a 100644 --- a/spec/default-directory-provider-spec.coffee +++ b/spec/default-directory-provider-spec.coffee @@ -1,5 +1,6 @@ DefaultDirectoryProvider = require "../src/default-directory-provider" path = require "path" +fs = require 'fs-plus' temp = require "temp" describe "DefaultDirectoryProvider", -> @@ -15,8 +16,8 @@ describe "DefaultDirectoryProvider", -> provider = new DefaultDirectoryProvider() tmp = temp.mkdirSync() nonNormalizedPath = tmp + path.sep + ".." + path.sep + path.basename(tmp) - expect(tmp.contains("..")).toBe false - expect(nonNormalizedPath.contains("..")).toBe true + expect(tmp.includes("..")).toBe false + expect(nonNormalizedPath.includes("..")).toBe true directory = provider.directoryForURISync(nonNormalizedPath) expect(directory.getPath()).toEqual tmp @@ -30,6 +31,12 @@ describe "DefaultDirectoryProvider", -> directory = provider.directoryForURISync(file) expect(directory.getPath()).toEqual tmp + it "creates a Directory with a path as a uri when passed a uri", -> + provider = new DefaultDirectoryProvider() + uri = 'remote://server:6792/path/to/a/dir' + directory = provider.directoryForURISync(uri) + expect(directory.getPath()).toEqual uri + describe ".directoryForURI(uri)", -> it "returns a Promise that resolves to a Directory with a path that matches the uri", -> provider = new DefaultDirectoryProvider() diff --git a/spec/display-buffer-spec.coffee b/spec/display-buffer-spec.coffee index 23b658c04..7d82ebcf5 100644 --- a/spec/display-buffer-spec.coffee +++ b/spec/display-buffer-spec.coffee @@ -44,11 +44,10 @@ describe "DisplayBuffer", -> it "renders line numbers correctly", -> originalLineCount = displayBuffer.getLineCount() oneHundredLines = [0..100].join("\n") - buffer.insert([0,0], oneHundredLines) + buffer.insert([0, 0], oneHundredLines) expect(displayBuffer.getLineCount()).toBe 100 + originalLineCount it "reassigns the scrollTop if it exceeds the max possible value after lines are removed", -> - displayBuffer.manageScrollPosition = true displayBuffer.setHeight(50) displayBuffer.setLineHeightInPixels(10) displayBuffer.setScrollTop(80) @@ -107,14 +106,35 @@ describe "DisplayBuffer", -> buffer.setTextInRange([[0, 0], [1, 0]], 'abcdefghijklmnopqrstuvwxyz\n') displayBuffer.setEditorWidthInChars(10) expect(displayBuffer.tokenizedLineForScreenRow(0).text).toBe 'abcdefghij' + expect(displayBuffer.tokenizedLineForScreenRow(0).bufferDelta).toBe 'abcdefghij'.length expect(displayBuffer.tokenizedLineForScreenRow(1).text).toBe 'klmnopqrst' + expect(displayBuffer.tokenizedLineForScreenRow(1).bufferDelta).toBe 'klmnopqrst'.length expect(displayBuffer.tokenizedLineForScreenRow(2).text).toBe 'uvwxyz' + expect(displayBuffer.tokenizedLineForScreenRow(2).bufferDelta).toBe 'uvwxyz'.length describe "when there is a whitespace character at the max length boundary", -> it "wraps the line at the first non-whitespace character following the boundary", -> expect(displayBuffer.tokenizedLineForScreenRow(3).text).toBe ' var pivot = items.shift(), current, left = [], ' expect(displayBuffer.tokenizedLineForScreenRow(4).text).toBe ' right = [];' + describe "when the only whitespace characters are at the beginning of the line", -> + beforeEach -> + displayBuffer.setEditorWidthInChars(10) + + it "wraps the line at the max length when indented with tabs", -> + buffer.setTextInRange([[0, 0], [1, 0]], '\t\tabcdefghijklmnopqrstuvwxyz') + + expect(displayBuffer.tokenizedLineForScreenRow(0).text).toBe ' abcdef' + expect(displayBuffer.tokenizedLineForScreenRow(1).text).toBe ' ghijkl' + expect(displayBuffer.tokenizedLineForScreenRow(2).text).toBe ' mnopqr' + + it "wraps the line at the max length when indented with spaces", -> + buffer.setTextInRange([[0, 0], [1, 0]], ' abcdefghijklmnopqrstuvwxyz') + + expect(displayBuffer.tokenizedLineForScreenRow(0).text).toBe ' abcdef' + expect(displayBuffer.tokenizedLineForScreenRow(1).text).toBe ' ghijkl' + expect(displayBuffer.tokenizedLineForScreenRow(2).text).toBe ' mnopqr' + describe "when there are hard tabs", -> beforeEach -> buffer.setText(buffer.getText().replace(new RegExp(' ', 'g'), '\t')) @@ -124,9 +144,42 @@ describe "DisplayBuffer", -> expect(displayBuffer.tokenizedLineForScreenRow(3).tokens[1].isHardTab).toBeTruthy() describe "when a line is wrapped", -> - it "correctly tokenizes soft wrap indentation tokens", -> - expect(displayBuffer.tokenizedLineForScreenRow(4).tokens[0].isSoftWrapIndentation).toBeTruthy() - expect(displayBuffer.tokenizedLineForScreenRow(4).tokens[1].isSoftWrapIndentation).toBeTruthy() + it "breaks soft-wrap indentation into a token for each indentation level to support indent guides", -> + tokenizedLine = displayBuffer.tokenizedLineForScreenRow(4) + + expect(tokenizedLine.tokens[0].value).toBe(" ") + expect(tokenizedLine.tokens[0].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[1].value).toBe(" ") + expect(tokenizedLine.tokens[1].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[2].isSoftWrapIndentation).toBeFalsy() + + describe "when editor.softWrapHangingIndent is set", -> + beforeEach -> + atom.config.set('editor.softWrapHangingIndent', 3) + + it "further indents wrapped lines", -> + expect(displayBuffer.tokenizedLineForScreenRow(10).text).toBe " return " + expect(displayBuffer.tokenizedLineForScreenRow(11).text).toBe " sort(left).concat(pivot).concat(sort(right)" + expect(displayBuffer.tokenizedLineForScreenRow(12).text).toBe " );" + + it "includes hanging indent when breaking soft-wrap indentation into tokens", -> + tokenizedLine = displayBuffer.tokenizedLineForScreenRow(4) + + expect(tokenizedLine.tokens[0].value).toBe(" ") + expect(tokenizedLine.tokens[0].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[1].value).toBe(" ") + expect(tokenizedLine.tokens[1].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[2].value).toBe(" ") # hanging indent + expect(tokenizedLine.tokens[2].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[3].value).toBe(" ") # odd space + expect(tokenizedLine.tokens[3].isSoftWrapIndentation).toBeTruthy() + + expect(tokenizedLine.tokens[4].isSoftWrapIndentation).toBeFalsy() describe "when the buffer changes", -> describe "when buffer lines are updated", -> @@ -236,7 +289,6 @@ describe "DisplayBuffer", -> it "sets ::scrollLeft to 0 and keeps it there when soft wrapping is enabled", -> displayBuffer.setDefaultCharWidth(10) displayBuffer.setWidth(85) - displayBuffer.manageScrollPosition = true displayBuffer.setSoftWrapped(false) displayBuffer.setScrollLeft(Infinity) @@ -330,10 +382,10 @@ describe "DisplayBuffer", -> describe "when creating a fold where one already exists", -> it "returns existing fold and does't create new fold", -> - fold = displayBuffer.createFold(0,10) + fold = displayBuffer.createFold(0, 10) expect(displayBuffer.findMarkers(class: 'fold').length).toBe 1 - newFold = displayBuffer.createFold(0,10) + newFold = displayBuffer.createFold(0, 10) expect(newFold).toBe fold expect(displayBuffer.findMarkers(class: 'fold').length).toBe 1 @@ -589,8 +641,11 @@ describe "DisplayBuffer", -> expect(displayBuffer.outermostFoldsInBufferRowRange(3, 18)).toEqual [fold1, fold3, fold5] expect(displayBuffer.outermostFoldsInBufferRowRange(5, 16)).toEqual [fold3] - describe "::clipScreenPosition(screenPosition, wrapBeyondNewlines: false, wrapAtSoftNewlines: false, skipAtomicTokens: false)", -> + describe "::clipScreenPosition(screenPosition, wrapBeyondNewlines: false, wrapAtSoftNewlines: false, clip: 'closest')", -> beforeEach -> + tabLength = 4 + + displayBuffer.setTabLength(tabLength) displayBuffer.setSoftWrapped(true) displayBuffer.setEditorWidthInChars(50) @@ -649,19 +704,43 @@ describe "DisplayBuffer", -> expect(displayBuffer.clipScreenPosition([3, 58], wrapAtSoftNewlines: true)).toEqual [4, 4] expect(displayBuffer.clipScreenPosition([3, 1000], wrapAtSoftNewlines: true)).toEqual [4, 4] - describe "when skipAtomicTokens is false (the default)", -> - it "clips screen positions in the middle of atomic tab characters to the beginning of the character", -> + describe "when clip is 'closest' (the default)", -> + it "clips screen positions in the middle of atomic tab characters to the closest edge of the character", -> buffer.insert([0, 0], '\t') expect(displayBuffer.clipScreenPosition([0, 0])).toEqual [0, 0] expect(displayBuffer.clipScreenPosition([0, 1])).toEqual [0, 0] + expect(displayBuffer.clipScreenPosition([0, 2])).toEqual [0, 0] + expect(displayBuffer.clipScreenPosition([0, tabLength-1])).toEqual [0, tabLength] expect(displayBuffer.clipScreenPosition([0, tabLength])).toEqual [0, tabLength] - describe "when skipAtomicTokens is true", -> + describe "when clip is 'backward'", -> + it "clips screen positions in the middle of atomic tab characters to the beginning of the character", -> + buffer.insert([0, 0], '\t') + expect(displayBuffer.clipScreenPosition([0, 0], clip: 'backward')).toEqual [0, 0] + expect(displayBuffer.clipScreenPosition([0, tabLength-1], clip: 'backward')).toEqual [0, 0] + expect(displayBuffer.clipScreenPosition([0, tabLength], clip: 'backward')).toEqual [0, tabLength] + + describe "when clip is 'forward'", -> it "clips screen positions in the middle of atomic tab characters to the end of the character", -> buffer.insert([0, 0], '\t') - expect(displayBuffer.clipScreenPosition([0, 0], skipAtomicTokens: true)).toEqual [0, 0] - expect(displayBuffer.clipScreenPosition([0, 1], skipAtomicTokens: true)).toEqual [0, tabLength] - expect(displayBuffer.clipScreenPosition([0, tabLength], skipAtomicTokens: true)).toEqual [0, tabLength] + expect(displayBuffer.clipScreenPosition([0, 0], clip: 'forward')).toEqual [0, 0] + expect(displayBuffer.clipScreenPosition([0, 1], clip: 'forward')).toEqual [0, tabLength] + expect(displayBuffer.clipScreenPosition([0, tabLength], clip: 'forward')).toEqual [0, tabLength] + + describe "::screenPositionForPixelPosition(pixelPosition)", -> + it "clips pixel positions above buffer start", -> + displayBuffer.setLineHeightInPixels(20) + expect(displayBuffer.screenPositionForPixelPosition(top: -Infinity, left: -Infinity)).toEqual [0, 0] + expect(displayBuffer.screenPositionForPixelPosition(top: -Infinity, left: Infinity)).toEqual [0, 0] + expect(displayBuffer.screenPositionForPixelPosition(top: -1, left: Infinity)).toEqual [0, 0] + expect(displayBuffer.screenPositionForPixelPosition(top: 0, left: Infinity)).toEqual [0, 29] + + it "clips pixel positions below buffer end", -> + displayBuffer.setLineHeightInPixels(20) + expect(displayBuffer.screenPositionForPixelPosition(top: Infinity, left: -Infinity)).toEqual [12, 2] + expect(displayBuffer.screenPositionForPixelPosition(top: Infinity, left: Infinity)).toEqual [12, 2] + expect(displayBuffer.screenPositionForPixelPosition(top: displayBuffer.getHeight() + 1, left: 0)).toEqual [12, 2] + expect(displayBuffer.screenPositionForPixelPosition(top: displayBuffer.getHeight() - 1, left: 0)).toEqual [12, 0] describe "::screenPositionForBufferPosition(bufferPosition, options)", -> it "clips the specified buffer position", -> @@ -670,6 +749,25 @@ describe "DisplayBuffer", -> expect(displayBuffer.screenPositionForBufferPosition([100000, 0])).toEqual [12, 2] expect(displayBuffer.screenPositionForBufferPosition([100000, 100000])).toEqual [12, 2] + it "clips to the (left or right) edge of an atomic token without simply rounding up", -> + tabLength = 4 + displayBuffer.setTabLength(tabLength) + + buffer.insert([0, 0], '\t') + expect(displayBuffer.screenPositionForBufferPosition([0, 0])).toEqual [0, 0] + expect(displayBuffer.screenPositionForBufferPosition([0, 1])).toEqual [0, tabLength] + + it "clips to the edge closest to the given position when it's inside a soft tab", -> + tabLength = 4 + displayBuffer.setTabLength(tabLength) + + buffer.insert([0, 0], ' ') + expect(displayBuffer.screenPositionForBufferPosition([0, 0])).toEqual [0, 0] + expect(displayBuffer.screenPositionForBufferPosition([0, 1])).toEqual [0, 0] + expect(displayBuffer.screenPositionForBufferPosition([0, 2])).toEqual [0, 0] + expect(displayBuffer.screenPositionForBufferPosition([0, 3])).toEqual [0, 4] + expect(displayBuffer.screenPositionForBufferPosition([0, 4])).toEqual [0, 4] + describe "position translation in the presence of hard tabs", -> it "correctly translates positions on either side of a tab", -> buffer.setText('\t') @@ -928,11 +1026,14 @@ describe "DisplayBuffer", -> expect(markerChangedHandler).not.toHaveBeenCalled() expect(marker2ChangedHandler).not.toHaveBeenCalled() expect(marker3ChangedHandler).not.toHaveBeenCalled() - # but still updates the markers + + # markers positions are updated based on the text change expect(marker.getScreenRange()).toEqual [[5, 4], [5, 10]] expect(marker.getHeadScreenPosition()).toEqual [5, 10] expect(marker.getTailScreenPosition()).toEqual [5, 4] - expect(marker2.isValid()).toBeTruthy() + + # but marker snapshots are not restored until the end of the undo. + expect(marker2.isValid()).toBeFalsy() expect(marker3.isValid()).toBeFalsy() buffer.undo() @@ -940,6 +1041,8 @@ describe "DisplayBuffer", -> expect(markerChangedHandler).toHaveBeenCalled() expect(marker2ChangedHandler).toHaveBeenCalled() expect(marker3ChangedHandler).toHaveBeenCalled() + expect(marker2.isValid()).toBeTruthy() + expect(marker3.isValid()).toBeFalsy() # Redo change ---- @@ -953,18 +1056,23 @@ describe "DisplayBuffer", -> expect(markerChangedHandler).not.toHaveBeenCalled() expect(marker2ChangedHandler).not.toHaveBeenCalled() expect(marker3ChangedHandler).not.toHaveBeenCalled() - # but still updates the markers + + # markers positions are updated based on the text change expect(marker.getScreenRange()).toEqual [[5, 7], [5, 13]] expect(marker.getHeadScreenPosition()).toEqual [5, 13] expect(marker.getTailScreenPosition()).toEqual [5, 7] + + # but marker snapshots are not restored until the end of the undo. expect(marker2.isValid()).toBeFalsy() - expect(marker3.isValid()).toBeTruthy() + expect(marker3.isValid()).toBeFalsy() buffer.redo() expect(changeHandler).toHaveBeenCalled() expect(markerChangedHandler).toHaveBeenCalled() expect(marker2ChangedHandler).toHaveBeenCalled() expect(marker3ChangedHandler).toHaveBeenCalled() + expect(marker2.isValid()).toBeFalsy() + expect(marker3.isValid()).toBeTruthy() it "updates the position of markers before emitting change events that aren't caused by a buffer change", -> displayBuffer.onDidChange changeHandler = jasmine.createSpy("changeHandler").andCallFake -> @@ -980,6 +1088,21 @@ describe "DisplayBuffer", -> expect(changeHandler).toHaveBeenCalled() expect(markerChangedHandler).toHaveBeenCalled() + it "emits the correct events when markers are mutated inside event listeners", -> + marker.onDidChange -> + if marker.getHeadScreenPosition().isEqual([5, 9]) + marker.setHeadScreenPosition([5, 8]) + + marker.setHeadScreenPosition([5, 9]) + + headChanges = for [event] in markerChangedHandler.argsForCall + {old: event.oldHeadScreenPosition, new: event.newHeadScreenPosition} + + expect(headChanges).toEqual [ + {old: [5, 10], new: [5, 9]} + {old: [5, 9], new: [5, 8]} + ] + describe "::findMarkers(attributes)", -> it "allows the startBufferRow and endBufferRow to be specified", -> marker1 = displayBuffer.markBufferRange([[0, 0], [3, 0]], class: 'a') @@ -1106,6 +1229,11 @@ describe "DisplayBuffer", -> decoration.destroy() expect(displayBuffer.decorationForId(decoration.id)).not.toBeDefined() + it "does not leak disposables", -> + disposablesSize = displayBuffer.disposables.disposables.size + decoration.destroy() + expect(displayBuffer.disposables.disposables.size).toBe(disposablesSize - 1) + describe "when a decoration is updated via Decoration::update()", -> it "emits an 'updated' event containing the new and old params", -> decoration.onDidChangeProperties updatedSpy = jasmine.createSpy() @@ -1113,7 +1241,7 @@ describe "DisplayBuffer", -> {oldProperties, newProperties} = updatedSpy.mostRecentCall.args[0] expect(oldProperties).toEqual decorationProperties - expect(newProperties).toEqual type: 'line-number', class: 'two', id: decoration.id + expect(newProperties).toEqual type: 'line-number', gutterName: 'line-number', class: 'two', id: decoration.id describe "::getDecorations(properties)", -> it "returns decorations matching the given optional properties", -> @@ -1123,7 +1251,6 @@ describe "DisplayBuffer", -> describe "::setScrollTop", -> beforeEach -> - displayBuffer.manageScrollPosition = true displayBuffer.setLineHeightInPixels(10) it "disallows negative values", -> @@ -1145,7 +1272,6 @@ describe "DisplayBuffer", -> describe "when editor.scrollPastEnd is false", -> beforeEach -> atom.config.set("editor.scrollPastEnd", false) - displayBuffer.manageScrollPosition = true displayBuffer.setLineHeightInPixels(10) it "does not add the height of the view to the scroll height", -> @@ -1158,7 +1284,6 @@ describe "DisplayBuffer", -> describe "when editor.scrollPastEnd is true", -> beforeEach -> atom.config.set("editor.scrollPastEnd", true) - displayBuffer.manageScrollPosition = true displayBuffer.setLineHeightInPixels(10) it "adds the height of the view to the scroll height", -> @@ -1170,7 +1295,6 @@ describe "DisplayBuffer", -> describe "::setScrollLeft", -> beforeEach -> - displayBuffer.manageScrollPosition = true displayBuffer.setLineHeightInPixels(10) displayBuffer.setDefaultCharWidth(10) @@ -1191,18 +1315,21 @@ describe "DisplayBuffer", -> describe "::scrollToScreenPosition(position, [options])", -> beforeEach -> - displayBuffer.manageScrollPosition = true displayBuffer.setLineHeightInPixels(10) displayBuffer.setDefaultCharWidth(10) displayBuffer.setHorizontalScrollbarHeight(0) displayBuffer.setHeight(50) - displayBuffer.setWidth(50) + displayBuffer.setWidth(150) it "sets the scroll top and scroll left so the given screen position is in view", -> displayBuffer.scrollToScreenPosition([8, 20]) expect(displayBuffer.getScrollBottom()).toBe (9 + displayBuffer.getVerticalScrollMargin()) * 10 expect(displayBuffer.getScrollRight()).toBe (20 + displayBuffer.getHorizontalScrollMargin()) * 10 + displayBuffer.scrollToScreenPosition([8, 20]) + expect(displayBuffer.getScrollBottom()).toBe (9 + displayBuffer.getVerticalScrollMargin()) * 10 + expect(displayBuffer.getScrollRight()).toBe (20 + displayBuffer.getHorizontalScrollMargin()) * 10 + describe "when the 'center' option is true", -> it "vertically scrolls to center the given position vertically", -> displayBuffer.scrollToScreenPosition([8, 20], center: true) @@ -1247,3 +1374,52 @@ describe "DisplayBuffer", -> expect(displayBuffer.getScrollWidth()).toBe 10 * 63 + operatorWidth * 2 + cursorWidth expect(changedSpy.callCount).toBe 1 + + describe "::getVisibleRowRange()", -> + beforeEach -> + displayBuffer.setLineHeightInPixels(10) + displayBuffer.setHeight(100) + + it "returns the first and the last visible rows", -> + displayBuffer.setScrollTop(0) + + expect(displayBuffer.getVisibleRowRange()).toEqual [0, 9] + + it "includes partially visible rows in the range", -> + displayBuffer.setScrollTop(5) + + expect(displayBuffer.getVisibleRowRange()).toEqual [0, 10] + + it "returns an empty range when lineHeight is 0", -> + displayBuffer.setLineHeightInPixels(0) + + expect(displayBuffer.getVisibleRowRange()).toEqual [0, 0] + + it "ends at last buffer row even if there's more space available", -> + displayBuffer.setHeight(150) + displayBuffer.setScrollTop(60) + + expect(displayBuffer.getVisibleRowRange()).toEqual [0, 13] + + describe "::decorateMarker", -> + describe "when decorating gutters", -> + [marker] = [] + + beforeEach -> + marker = displayBuffer.markBufferRange([[1, 0], [1, 0]]) + + it "creates a decoration that is both of 'line-number' and 'gutter' type when called with the 'line-number' type", -> + decorationProperties = {type: 'line-number', class: 'one'} + decoration = displayBuffer.decorateMarker(marker, decorationProperties) + expect(decoration.isType('line-number')).toBe true + expect(decoration.isType('gutter')).toBe true + expect(decoration.getProperties().gutterName).toBe 'line-number' + expect(decoration.getProperties().class).toBe 'one' + + it "creates a decoration that is only of 'gutter' type if called with the 'gutter' type and a 'gutterName'", -> + decorationProperties = {type: 'gutter', gutterName: 'test-gutter', class: 'one'} + decoration = displayBuffer.decorateMarker(marker, decorationProperties) + expect(decoration.isType('gutter')).toBe true + expect(decoration.isType('line-number')).toBe false + expect(decoration.getProperties().gutterName).toBe 'test-gutter' + expect(decoration.getProperties().class).toBe 'one' diff --git a/spec/fixtures/packages/package-with-different-directory-name/package.json b/spec/fixtures/packages/package-with-different-directory-name/package.json new file mode 100644 index 000000000..079d0dfb2 --- /dev/null +++ b/spec/fixtures/packages/package-with-different-directory-name/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-a-totally-different-name", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/package-with-empty-activation-commands/package.json b/spec/fixtures/packages/package-with-empty-activation-commands/package.json index 8b58333ab..46741f3e3 100644 --- a/spec/fixtures/packages/package-with-empty-activation-commands/package.json +++ b/spec/fixtures/packages/package-with-empty-activation-commands/package.json @@ -1,5 +1,5 @@ { - "name": "no events", + "name": "package-with-empty-activation-commands", "version": "0.1.0", "activationCommands": {"atom-workspace": []} } diff --git a/spec/fixtures/packages/package-with-incompatible-native-module/package.json b/spec/fixtures/packages/package-with-incompatible-native-module/package.json index eec67175a..857bc7221 100644 --- a/spec/fixtures/packages/package-with-incompatible-native-module/package.json +++ b/spec/fixtures/packages/package-with-incompatible-native-module/package.json @@ -1,5 +1,5 @@ { "name": "package-with-incompatible-native-module", - "version": "1.0", + "version": "1.0.0", "main": "./main.js" } diff --git a/spec/fixtures/packages/package-with-invalid-activation-commands/package.json b/spec/fixtures/packages/package-with-invalid-activation-commands/package.json new file mode 100644 index 000000000..aa3398f59 --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-activation-commands/package.json @@ -0,0 +1,9 @@ +{ + "name": "package-with-invalid-activation-commands", + "version": "1.0.0", + "activationCommands": { + "<>": [ + "foo:bar" + ] + } +} diff --git a/spec/fixtures/packages/package-with-invalid-context-menu/menus/menu.json b/spec/fixtures/packages/package-with-invalid-context-menu/menus/menu.json new file mode 100644 index 000000000..67197f8fe --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-context-menu/menus/menu.json @@ -0,0 +1,10 @@ +{ + "context-menu": { + "<>": [ + { + "label": "Hello", + "command:": "world" + } + ] + } +} diff --git a/spec/fixtures/packages/package-with-invalid-context-menu/package.json b/spec/fixtures/packages/package-with-invalid-context-menu/package.json new file mode 100644 index 000000000..c742d422a --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-context-menu/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-invalid-context-menu", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/package-with-invalid-grammar/grammars/grammar.json b/spec/fixtures/packages/package-with-invalid-grammar/grammars/grammar.json new file mode 100644 index 000000000..41ec4c527 --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-grammar/grammars/grammar.json @@ -0,0 +1 @@ +>< diff --git a/spec/fixtures/packages/package-with-invalid-grammar/package.json b/spec/fixtures/packages/package-with-invalid-grammar/package.json new file mode 100644 index 000000000..4ed4081f5 --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-grammar/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-invalid-grammar", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/package-with-invalid-settings/package.json b/spec/fixtures/packages/package-with-invalid-settings/package.json new file mode 100644 index 000000000..b09dccb8b --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-settings/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-invalid-settings", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/package-with-invalid-settings/settings/settings.json b/spec/fixtures/packages/package-with-invalid-settings/settings/settings.json new file mode 100644 index 000000000..41ec4c527 --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-settings/settings/settings.json @@ -0,0 +1 @@ +>< diff --git a/spec/fixtures/packages/package-with-invalid-url-package-json/package.json b/spec/fixtures/packages/package-with-invalid-url-package-json/package.json new file mode 100644 index 000000000..cc119ffaa --- /dev/null +++ b/spec/fixtures/packages/package-with-invalid-url-package-json/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-invalid-url-package-json", + "repository": "foo" +} diff --git a/spec/fixtures/packages/package-with-main/package.cson b/spec/fixtures/packages/package-with-main/package.cson index a93a109c4..e799f6ca8 100644 --- a/spec/fixtures/packages/package-with-main/package.cson +++ b/spec/fixtures/packages/package-with-main/package.cson @@ -1 +1 @@ -'main': 'main-module.coffee' \ No newline at end of file +'main': 'main-module.coffee' diff --git a/spec/fixtures/packages/package-with-missing-consumed-services/index.coffee b/spec/fixtures/packages/package-with-missing-consumed-services/index.coffee new file mode 100644 index 000000000..0f1fc41e0 --- /dev/null +++ b/spec/fixtures/packages/package-with-missing-consumed-services/index.coffee @@ -0,0 +1,4 @@ +module.exports = + activate: -> + + deactivate: -> diff --git a/spec/fixtures/packages/package-with-missing-consumed-services/package.json b/spec/fixtures/packages/package-with-missing-consumed-services/package.json new file mode 100644 index 000000000..d0e0ddfcb --- /dev/null +++ b/spec/fixtures/packages/package-with-missing-consumed-services/package.json @@ -0,0 +1,11 @@ +{ + "name": "package-with-missing-consumed-services", + + "consumedServices": { + "service-1": { + "versions": { + ">=0.1": "consumeMissingService" + } + } + } +} diff --git a/spec/fixtures/packages/package-with-missing-provided-services/index.coffee b/spec/fixtures/packages/package-with-missing-provided-services/index.coffee new file mode 100644 index 000000000..0f1fc41e0 --- /dev/null +++ b/spec/fixtures/packages/package-with-missing-provided-services/index.coffee @@ -0,0 +1,4 @@ +module.exports = + activate: -> + + deactivate: -> diff --git a/spec/fixtures/packages/package-with-missing-provided-services/package.json b/spec/fixtures/packages/package-with-missing-provided-services/package.json new file mode 100644 index 000000000..a090354a2 --- /dev/null +++ b/spec/fixtures/packages/package-with-missing-provided-services/package.json @@ -0,0 +1,12 @@ +{ + "name": "package-with-missing-provided-services", + + "providedServices": { + "service-1": { + "description": "The first service", + "versions": { + "0.2.9": "provideMissingService" + } + } + } +} diff --git a/spec/fixtures/packages/package-with-provided-services/index.coffee b/spec/fixtures/packages/package-with-provided-services/index.coffee index b6413b119..86f319946 100644 --- a/spec/fixtures/packages/package-with-provided-services/index.coffee +++ b/spec/fixtures/packages/package-with-provided-services/index.coffee @@ -3,6 +3,9 @@ module.exports = deactivate: -> + provideFirstServiceV2: -> + 'first-service-v2' + provideFirstServiceV3: -> 'first-service-v3' diff --git a/spec/fixtures/packages/package-with-provided-services/package.json b/spec/fixtures/packages/package-with-provided-services/package.json index 144fc2dac..d95bbf9b7 100644 --- a/spec/fixtures/packages/package-with-provided-services/package.json +++ b/spec/fixtures/packages/package-with-provided-services/package.json @@ -5,6 +5,7 @@ "service-1": { "description": "The first service", "versions": { + "0.2.9": "provideFirstServiceV2", "0.3.1": "provideFirstServiceV3", "0.4.1": "provideFirstServiceV4" } diff --git a/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson new file mode 100644 index 000000000..8b4d85412 --- /dev/null +++ b/spec/fixtures/packages/package-with-rb-filetype/grammars/rb.cson @@ -0,0 +1,11 @@ +'name': 'Test Ruby' +'scopeName': 'test.rb' +'fileTypes': [ + 'rb' +] +'patterns': [ + { + 'match': 'ruby' + 'name': 'meta.class.ruby' + } +] diff --git a/spec/fixtures/packages/package-with-rb-filetype/package.json b/spec/fixtures/packages/package-with-rb-filetype/package.json new file mode 100644 index 000000000..350967dc5 --- /dev/null +++ b/spec/fixtures/packages/package-with-rb-filetype/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-rb-filetype", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/package-with-short-url-package-json/package.json b/spec/fixtures/packages/package-with-short-url-package-json/package.json new file mode 100644 index 000000000..5da40ab08 --- /dev/null +++ b/spec/fixtures/packages/package-with-short-url-package-json/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-with-short-url-package-json", + "repository": "example/repo" +} diff --git a/spec/fixtures/packages/sublime-tabs/package.json b/spec/fixtures/packages/sublime-tabs/package.json new file mode 100644 index 000000000..2fd01501d --- /dev/null +++ b/spec/fixtures/packages/sublime-tabs/package.json @@ -0,0 +1,4 @@ +{ + "name": "sublime-tabs", + "version": "1.0.0" +} diff --git a/spec/fixtures/packages/theme-with-invalid-styles/index.less b/spec/fixtures/packages/theme-with-invalid-styles/index.less new file mode 100644 index 000000000..b554d9e46 --- /dev/null +++ b/spec/fixtures/packages/theme-with-invalid-styles/index.less @@ -0,0 +1 @@ +<> diff --git a/spec/fixtures/packages/theme-with-invalid-styles/package.json b/spec/fixtures/packages/theme-with-invalid-styles/package.json new file mode 100644 index 000000000..1dd1ee48b --- /dev/null +++ b/spec/fixtures/packages/theme-with-invalid-styles/package.json @@ -0,0 +1,4 @@ +{ + "name": "theme-with-invalid-styles", + "theme": "ui" +} diff --git a/spec/fixtures/packages/wordcount/package.json b/spec/fixtures/packages/wordcount/package.json new file mode 100644 index 000000000..bf04495aa --- /dev/null +++ b/spec/fixtures/packages/wordcount/package.json @@ -0,0 +1,4 @@ +{ + "name": "wordcount", + "version": "2.0.9" +} diff --git a/spec/fixtures/typescript/invalid.ts b/spec/fixtures/typescript/invalid.ts new file mode 100644 index 000000000..7a8d0b6d0 --- /dev/null +++ b/spec/fixtures/typescript/invalid.ts @@ -0,0 +1 @@ +var foo = 123 123; // Syntax error diff --git a/spec/fixtures/typescript/valid.ts b/spec/fixtures/typescript/valid.ts new file mode 100644 index 000000000..46cf54693 --- /dev/null +++ b/spec/fixtures/typescript/valid.ts @@ -0,0 +1,2 @@ +var inc = v => v + 1 +export = inc diff --git a/spec/git-repository-provider-spec.coffee b/spec/git-repository-provider-spec.coffee index 59e3f55af..15e1dcc60 100644 --- a/spec/git-repository-provider-spec.coffee +++ b/spec/git-repository-provider-spec.coffee @@ -16,6 +16,7 @@ describe "GitRepositoryProvider", -> expect(result).toBeInstanceOf GitRepository expect(provider.pathToRepository[result.getPath()]).toBeTruthy() expect(result.statusTask).toBeTruthy() + expect(result.getType()).toBe 'git' it "returns the same GitRepository for different Directory objects in the same repo", -> provider = new GitRepositoryProvider atom.project diff --git a/spec/syntax-spec.coffee b/spec/grammars-spec.coffee similarity index 86% rename from spec/syntax-spec.coffee rename to spec/grammars-spec.coffee index ecc0c0406..cc975468f 100644 --- a/spec/syntax-spec.coffee +++ b/spec/grammars-spec.coffee @@ -2,7 +2,7 @@ path = require 'path' fs = require 'fs-plus' temp = require 'temp' -describe "the `syntax` global", -> +describe "the `grammars` global", -> beforeEach -> waitsForPromise -> atom.packages.activatePackage('language-text') @@ -25,9 +25,9 @@ describe "the `syntax` global", -> filePath = '/foo/bar/file.js' expect(atom.grammars.selectGrammar(filePath).name).not.toBe 'Ruby' atom.grammars.setGrammarOverrideForPath(filePath, 'source.ruby') - syntax2 = atom.deserializers.deserialize(atom.grammars.serialize()) - syntax2.addGrammar(grammar) for grammar in atom.grammars.grammars when grammar isnt atom.grammars.nullGrammar - expect(syntax2.selectGrammar(filePath).name).toBe 'Ruby' + grammars2 = atom.deserializers.deserialize(atom.grammars.serialize()) + grammars2.addGrammar(grammar) for grammar in atom.grammars.grammars when grammar isnt atom.grammars.nullGrammar + expect(grammars2.selectGrammar(filePath).name).toBe 'Ruby' describe ".selectGrammar(filePath)", -> it "can use the filePath to load the correct grammar based on the grammar's filetype", -> @@ -94,6 +94,16 @@ describe "the `syntax` global", -> grammar2 = atom.grammars.loadGrammarSync(grammarPath2) expect(atom.grammars.selectGrammar('more.test', '')).toBe grammar2 + it "favors non-bundled packages when breaking scoring ties", -> + waitsForPromise -> + atom.packages.activatePackage(path.join(__dirname, 'fixtures', 'packages', 'package-with-rb-filetype')) + + runs -> + atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true + atom.grammars.grammarForScopeName('test.rb').bundledPackage = false + + expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe 'test.rb' + describe "when there is no file path", -> it "does not throw an exception (regression)", -> expect(-> atom.grammars.selectGrammar(null, '#!/usr/bin/ruby')).not.toThrow() diff --git a/spec/gutter-container-component-spec.coffee b/spec/gutter-container-component-spec.coffee new file mode 100644 index 000000000..5d815fea8 --- /dev/null +++ b/spec/gutter-container-component-spec.coffee @@ -0,0 +1,139 @@ +Gutter = require '../src/gutter' +GutterContainerComponent = require '../src/gutter-container-component' + +describe "GutterContainerComponent", -> + [gutterContainerComponent] = [] + mockGutterContainer = {} + + buildTestState = (gutters) -> + styles = + scrollHeight: 100 + scrollTop: 10 + backgroundColor: 'black' + + mockTestState = {gutters: []} + for gutter in gutters + if gutter.name is 'line-number' + content = {maxLineNumberDigits: 10, lineNumbers: {}} + else + content = {} + mockTestState.gutters.push({gutter, styles, content, visible: gutter.visible}) + + mockTestState + + beforeEach -> + mockEditor = {} + mockMouseDown = -> + gutterContainerComponent = new GutterContainerComponent({editor: mockEditor, onMouseDown: mockMouseDown}) + + it "creates a DOM node with no child gutter nodes when it is initialized", -> + expect(gutterContainerComponent.getDomNode() instanceof HTMLElement).toBe true + expect(gutterContainerComponent.getDomNode().children.length).toBe 0 + + describe "when updated with state that contains a new line-number gutter", -> + it "adds a LineNumberGutterComponent to its children", -> + lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) + testState = buildTestState([lineNumberGutter]) + + expect(gutterContainerComponent.getDomNode().children.length).toBe 0 + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expectedGutterNode = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedGutterNode.classList.contains('gutter')).toBe true + expectedLineNumbersNode = expectedGutterNode.children.item(0) + expect(expectedLineNumbersNode.classList.contains('line-numbers')).toBe true + + expect(gutterContainerComponent.getLineNumberGutterComponent().getDomNode()).toBe expectedGutterNode + + describe "when updated with state that contains a new custom gutter", -> + it "adds a CustomGutterComponent to its children", -> + customGutter = new Gutter(mockGutterContainer, {name: 'custom'}) + testState = buildTestState([customGutter]) + + expect(gutterContainerComponent.getDomNode().children.length).toBe 0 + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expectedGutterNode = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedGutterNode.classList.contains('gutter')).toBe true + expectedCustomDecorationsNode = expectedGutterNode.children.item(0) + expect(expectedCustomDecorationsNode.classList.contains('custom-decorations')).toBe true + + describe "when updated with state that contains a new gutter that is not visible", -> + it "creates the gutter view but hides it, and unhides it when it is later updated to be visible", -> + customGutter = new Gutter(mockGutterContainer, {name: 'custom', visible: false}) + testState = buildTestState([customGutter]) + + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedCustomGutterNode.style.display).toBe 'none' + + customGutter.show() + testState = buildTestState([customGutter]) + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedCustomGutterNode.style.display).toBe '' + + describe "when updated with a gutter that already exists", -> + it "reuses the existing gutter view, instead of recreating it", -> + customGutter = new Gutter(mockGutterContainer, {name: 'custom'}) + testState = buildTestState([customGutter]) + + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) + + testState = buildTestState([customGutter]) + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + expect(gutterContainerComponent.getDomNode().children.item(0)).toBe expectedCustomGutterNode + + it "removes a gutter from the DOM if it does not appear in the latest state update", -> + lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) + testState = buildTestState([lineNumberGutter]) + gutterContainerComponent.updateSync(testState) + + expect(gutterContainerComponent.getDomNode().children.length).toBe 1 + testState = buildTestState([]) + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 0 + + describe "when updated with multiple gutters", -> + it "positions (and repositions) the gutters to match the order they appear in each state update", -> + lineNumberGutter = new Gutter(mockGutterContainer, {name: 'line-number'}) + customGutter1 = new Gutter(mockGutterContainer, {name: 'custom', priority: -100}) + testState = buildTestState([customGutter1, lineNumberGutter]) + + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 2 + expectedCustomGutterNode = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedCustomGutterNode).toBe atom.views.getView(customGutter1) + expectedLineNumbersNode = gutterContainerComponent.getDomNode().children.item(1) + expect(expectedLineNumbersNode).toBe atom.views.getView(lineNumberGutter) + + # Add a gutter. + customGutter2 = new Gutter(mockGutterContainer, {name: 'custom2', priority: -10}) + testState = buildTestState([customGutter1, customGutter2, lineNumberGutter]) + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 3 + expectedCustomGutterNode1 = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedCustomGutterNode1).toBe atom.views.getView(customGutter1) + expectedCustomGutterNode2 = gutterContainerComponent.getDomNode().children.item(1) + expect(expectedCustomGutterNode2).toBe atom.views.getView(customGutter2) + expectedLineNumbersNode = gutterContainerComponent.getDomNode().children.item(2) + expect(expectedLineNumbersNode).toBe atom.views.getView(lineNumberGutter) + + # Hide one gutter, reposition one gutter, remove one gutter; and add a new gutter. + customGutter2.hide() + customGutter3 = new Gutter(mockGutterContainer, {name: 'custom3', priority: 100}) + testState = buildTestState([customGutter2, customGutter1, customGutter3]) + gutterContainerComponent.updateSync(testState) + expect(gutterContainerComponent.getDomNode().children.length).toBe 3 + expectedCustomGutterNode2 = gutterContainerComponent.getDomNode().children.item(0) + expect(expectedCustomGutterNode2).toBe atom.views.getView(customGutter2) + expect(expectedCustomGutterNode2.style.display).toBe 'none' + expectedCustomGutterNode1 = gutterContainerComponent.getDomNode().children.item(1) + expect(expectedCustomGutterNode1).toBe atom.views.getView(customGutter1) + expectedCustomGutterNode3 = gutterContainerComponent.getDomNode().children.item(2) + expect(expectedCustomGutterNode3).toBe atom.views.getView(customGutter3) diff --git a/spec/gutter-container-spec.coffee b/spec/gutter-container-spec.coffee new file mode 100644 index 000000000..03c9ebc10 --- /dev/null +++ b/spec/gutter-container-spec.coffee @@ -0,0 +1,52 @@ +Gutter = require '../src/gutter' +GutterContainer = require '../src/gutter-container' + +describe 'GutterContainer', -> + gutterContainer = null + fakeTextEditor = {} + + 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() diff --git a/spec/gutter-spec.coffee b/spec/gutter-spec.coffee new file mode 100644 index 000000000..30748b787 --- /dev/null +++ b/spec/gutter-spec.coffee @@ -0,0 +1,68 @@ +Gutter = require '../src/gutter' + +describe 'Gutter', -> + fakeGutterContainer = {} + 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/integration/helpers/start-atom.coffee b/spec/integration/helpers/start-atom.coffee index b5f5a8c1f..646281171 100644 --- a/spec/integration/helpers/start-atom.coffee +++ b/spec/integration/helpers/start-atom.coffee @@ -1,4 +1,5 @@ path = require "path" +http = require "http" temp = require("temp").track() remote = require "remote" async = require "async" @@ -9,8 +10,29 @@ webdriverio = require "../../../build/node_modules/webdriverio" AtomPath = remote.process.argv[0] AtomLauncherPath = path.join(__dirname, "..", "helpers", "atom-launcher.sh") ChromedriverPath = path.resolve(__dirname, '..', '..', '..', 'atom-shell', 'chromedriver', 'chromedriver') -SocketPath = path.join(temp.mkdirSync("socket-dir"), "atom.sock") +SocketPath = path.join(temp.mkdirSync("socket-dir"), "atom-#{process.env.USER}.sock") ChromedriverPort = 9515 +ChromedriverURLBase = "/wd/hub" +ChromedriverStatusURL = "http://localhost:#{ChromedriverPort}#{ChromedriverURLBase}/status" + +chromeDriverUp = (done) -> + checkStatus = -> + http + .get ChromedriverStatusURL, (response) -> + if response.statusCode is 200 + done() + else + chromeDriverUp(done) + .on("error", -> chromeDriverUp(done)) + setTimeout(checkStatus, 100) + +chromeDriverDown = (done) -> + checkStatus = -> + http + .get ChromedriverStatusURL, (response) -> + chromeDriverDown(done) + .on("error", done) + setTimeout(checkStatus, 100) buildAtomClient = (args, env) -> client = webdriverio.remote( @@ -94,6 +116,15 @@ buildAtomClient = (args, env) -> ]), env: extend({}, process.env, env)) done() + .addCommand "dispatchCommand", (command, done) -> + @execute "atom.commands.dispatch(document.activeElement, '#{command}')" + .call(done) + + .addCommand "simulateQuit", (done) -> + @execute -> atom.unloadEditorWindow() + .execute -> require("remote").require("app").emit("before-quit") + .call(done) + module.exports = (args, env, fn) -> [chromedriver, chromedriverLogs, chromedriverExit] = [] @@ -101,7 +132,7 @@ module.exports = (args, env, fn) -> chromedriver = spawn(ChromedriverPath, [ "--verbose", "--port=#{ChromedriverPort}", - "--url-base=/wd/hub" + "--url-base=#{ChromedriverURLBase}" ]) chromedriverLogs = [] @@ -114,11 +145,12 @@ module.exports = (args, env, fn) -> chromedriver.stderr.on "close", -> resolve(errorCode) - waits(100) + waitsFor("webdriver to start", chromeDriverUp, 15000) - waitsFor("webdriver to finish", (done) -> + waitsFor("tests to run", (done) -> finish = once -> client + .simulateQuit() .end() .then(-> chromedriver.kill()) .then(chromedriverExit.then( @@ -133,8 +165,10 @@ module.exports = (args, env, fn) -> client = buildAtomClient(args, env) client.on "error", (err) -> - jasmine.getEnv().currentSpec.fail(JSON.stringify(err)) + jasmine.getEnv().currentSpec.fail(new Error(err.response?.body?.value?.message)) finish() fn(client.init()).then(finish) , 30000) + + waitsFor("webdriver to stop", chromeDriverDown, 15000) diff --git a/spec/integration/startup-spec.coffee b/spec/integration/startup-spec.coffee index f3253b81e..d2583ed27 100644 --- a/spec/integration/startup-spec.coffee +++ b/spec/integration/startup-spec.coffee @@ -2,26 +2,29 @@ # # ATOM_INTEGRATION_TESTS_ENABLED=true apm test return unless process.env.ATOM_INTEGRATION_TESTS_ENABLED +# Integration tests require a fast machine and, for now, we cannot afford to +# run them on Travis. +return if process.env.TRAVIS fs = require "fs" path = require "path" temp = require("temp").track() -AtomHome = temp.mkdirSync('atom-home') -fs.writeFileSync(path.join(AtomHome, 'config.cson'), fs.readFileSync(path.join(__dirname, 'fixtures', 'atom-home', 'config.cson'))) -runAtom = require("./helpers/start-atom") +runAtom = require "./helpers/start-atom" describe "Starting Atom", -> - [tempDirPath, otherTempDirPath] = [] + [tempDirPath, otherTempDirPath, atomHome] = [] beforeEach -> jasmine.useRealClock() + atomHome = temp.mkdirSync('atom-home') + fs.writeFileSync(path.join(atomHome, 'config.cson'), fs.readFileSync(path.join(__dirname, 'fixtures', 'atom-home', 'config.cson'))) tempDirPath = temp.mkdirSync("empty-dir") otherTempDirPath = temp.mkdirSync("another-temp-dir") describe "opening a new file", -> it "opens the parent directory and creates an empty text editor", -> - runAtom [path.join(tempDirPath, "new-file")], {ATOM_HOME: AtomHome}, (client) -> + runAtom [path.join(tempDirPath, "new-file")], {ATOM_HOME: atomHome}, (client) -> client .waitForWindowCount(1, 1000) .waitForExist("atom-workspace", 5000) @@ -36,13 +39,77 @@ describe "Starting Atom", -> .keys("Hello!") .execute -> atom.workspace.getActiveTextEditor().getText() .then ({value}) -> expect(value).toBe "Hello!" + .dispatchCommand("editor:delete-line") + + it "opens the file to the specified line number", -> + filePath = path.join(fs.realpathSync(tempDirPath), "new-file") + fs.writeFileSync filePath, """ + 1 + 2 + 3 + 4 + """ + + runAtom ["#{filePath}:3"], {ATOM_HOME: atomHome}, (client) -> + client + .waitForWindowCount(1, 1000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(1, 1000) + .waitForExist("atom-text-editor", 5000) + .then (exists) -> expect(exists).toBe true + + .execute -> atom.workspace.getActiveTextEditor().getPath() + .then ({value}) -> expect(value).toBe filePath + + .execute -> atom.workspace.getActiveTextEditor().getCursorBufferPosition() + .then ({value}) -> + expect(value.row).toBe 2 + expect(value.column).toBe 0 + + it "opens the file to the specified line number and column number", -> + filePath = path.join(fs.realpathSync(tempDirPath), "new-file") + fs.writeFileSync filePath, """ + 1 + 2 + 3 + 4 + """ + + runAtom ["#{filePath}:2:2"], {ATOM_HOME: atomHome}, (client) -> + client + .waitForWindowCount(1, 1000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(1, 1000) + .waitForExist("atom-text-editor", 5000) + .then (exists) -> expect(exists).toBe true + + .execute -> atom.workspace.getActiveTextEditor().getPath() + .then ({value}) -> expect(value).toBe filePath + + .execute -> atom.workspace.getActiveTextEditor().getCursorBufferPosition() + .then ({value}) -> + expect(value.row).toBe 1 + expect(value.column).toBe 1 + + it "removes all trailing whitespace and colons from the specified path", -> + filePath = path.join(tempDirPath, "new-file") + runAtom ["#{filePath}: "], {ATOM_HOME: atomHome}, (client) -> + client + .waitForWindowCount(1, 1000) + .waitForExist("atom-workspace", 5000) + .waitForPaneItemCount(1, 1000) + .waitForExist("atom-text-editor", 5000) + .then (exists) -> expect(exists).toBe true + + .execute -> atom.workspace.getActiveTextEditor().getPath() + .then ({value}) -> expect(value).toBe filePath describe "when there is already a window open", -> it "reuses that window when opening files, but not when opening directories", -> tempFilePath = path.join(temp.mkdirSync("a-third-dir"), "a-file") fs.writeFileSync(tempFilePath, "This file was already here.") - runAtom [path.join(tempDirPath, "new-file")], {ATOM_HOME: AtomHome}, (client) -> + runAtom [path.join(tempDirPath, "new-file")], {ATOM_HOME: atomHome}, (client) -> client .waitForWindowCount(1, 1000) .waitForExist("atom-workspace", 5000) @@ -50,7 +117,7 @@ describe "Starting Atom", -> # Opening another file reuses the same window and does not change the # project paths. - .startAnotherAtom([tempFilePath], ATOM_HOME: AtomHome) + .startAnotherAtom([tempFilePath], ATOM_HOME: atomHome) .waitForPaneItemCount(2, 5000) .waitForWindowCount(1, 1000) .treeViewRootDirectories() @@ -60,7 +127,7 @@ describe "Starting Atom", -> # Opening another directory creates a second window. .waitForNewWindow(-> - @startAnotherAtom([otherTempDirPath], ATOM_HOME: AtomHome) + @startAnotherAtom([otherTempDirPath], ATOM_HOME: atomHome) , 5000) .waitForExist("atom-workspace", 5000) .waitForPaneItemCount(0, 1000) @@ -69,15 +136,14 @@ describe "Starting Atom", -> describe "reopening a directory that was previously opened", -> it "remembers the state of the window", -> - runAtom [tempDirPath], {ATOM_HOME: AtomHome}, (client) -> + runAtom [tempDirPath], {ATOM_HOME: atomHome}, (client) -> client .waitForExist("atom-workspace", 5000) .waitForPaneItemCount(0, 3000) .execute -> atom.workspace.open() .waitForPaneItemCount(1, 3000) - .execute -> atom.unloadEditorWindow() - runAtom [tempDirPath], {ATOM_HOME: AtomHome}, (client) -> + runAtom [tempDirPath], {ATOM_HOME: atomHome}, (client) -> client .waitForExist("atom-workspace", 5000) .waitForPaneItemCount(1, 5000) @@ -87,7 +153,7 @@ describe "Starting Atom", -> nestedDir = path.join(otherTempDirPath, "nested-dir") fs.mkdirSync(nestedDir) - runAtom [tempDirPath, otherTempDirPath, "--multi-folder"], {ATOM_HOME: AtomHome}, (client) -> + runAtom [tempDirPath, otherTempDirPath], {ATOM_HOME: atomHome}, (client) -> client .waitForExist("atom-workspace", 5000) .treeViewRootDirectories() @@ -95,67 +161,79 @@ describe "Starting Atom", -> # Opening one of those directories again reuses the same window and # does not change the project paths. - .startAnotherAtom([nestedDir], ATOM_HOME: AtomHome) + .startAnotherAtom([nestedDir], ATOM_HOME: atomHome) .waitForExist("atom-workspace", 5000) .treeViewRootDirectories() .then ({value}) -> expect(value).toEqual([tempDirPath, otherTempDirPath]) - it "opens each path in its own window unless the --multi-folder flag is passed", -> - runAtom [tempDirPath, otherTempDirPath], {ATOM_HOME: AtomHome}, (client) -> - treeViewDirs = [] + describe "when there is an existing window with no project path", -> + it "reuses that window to open a directory", -> + runAtom [], {ATOM_HOME: atomHome}, (client) -> + client + .waitForExist("atom-workspace") + .treeViewRootDirectories() + .then ({value}) -> expect(value).toEqual([]) + + .startAnotherAtom([tempDirPath], ATOM_HOME: atomHome) + .waitUntil(-> + @treeViewRootDirectories() + .then ({value}) -> value[0] is tempDirPath + , 5000) + .then (result) -> expect(result).toBe(true) + .waitForWindowCount(1, 5000) + + describe "launching with no path", -> + it "opens a new window with a single untitled buffer", -> + runAtom [], {ATOM_HOME: atomHome}, (client) -> + client + .waitForExist("atom-workspace") + .waitForPaneItemCount(1, 5000) + + # Opening with no file paths always creates a new window, even if + # existing windows have no project paths. + .waitForNewWindow(-> + @startAnotherAtom([], ATOM_HOME: atomHome) + , 5000) + .waitForExist("atom-workspace") + .waitForPaneItemCount(1, 5000) + + it "reopens any previously opened windows", -> + runAtom [tempDirPath], {ATOM_HOME: atomHome}, (client) -> + client + .waitForExist("atom-workspace") + .waitForNewWindow(-> + @startAnotherAtom([otherTempDirPath], ATOM_HOME: atomHome) + , 5000) + .waitForExist("atom-workspace") + + runAtom [], {ATOM_HOME: atomHome}, (client) -> + windowProjectPaths = [] client - .waitForExist("atom-workspace", 5000) .waitForWindowCount(2, 10000) .then ({value: windowHandles}) -> - @window(windowHandles[0]) - .waitForExist("atom-workspace") - .treeViewRootDirectories() - .then ({value}) -> - expect(value).toHaveLength(1) - treeViewDirs.push(value[0]) - - .window(windowHandles[1]) - .waitForExist("atom-workspace") - .treeViewRootDirectories() - .then ({value}) -> - expect(value).toHaveLength(1) - treeViewDirs.push(value[0]) - .then -> - expect(treeViewDirs.sort()).toEqual([tempDirPath, otherTempDirPath].sort()) - - describe "when there is an existing window with no project path", -> - describe "opening a directory", -> - it "opens the directory in the existing window", -> - runAtom [], {ATOM_HOME: AtomHome}, (client) -> - client .waitForExist("atom-workspace") .treeViewRootDirectories() - .then ({value}) -> expect(value).toEqual([]) + .then ({value: directories}) -> windowProjectPaths.push(directories) - .startAnotherAtom([tempDirPath], ATOM_HOME: AtomHome) - .waitUntil(-> - @treeViewRootDirectories() - .then ({value}) -> value[0] is tempDirPath - , 5000) - .then (result) -> expect(result).toBe(true) - .waitForWindowCount(1, 5000) - - describe "launching with no path", -> - it "always opens a new window with a single untitled buffer", -> - runAtom [], {ATOM_HOME: AtomHome}, (client) -> - client + .window(windowHandles[1]) .waitForExist("atom-workspace") - .waitForPaneItemCount(1, 5000) + .treeViewRootDirectories() + .then ({value: directories}) -> windowProjectPaths.push(directories) - runAtom [], {ATOM_HOME: AtomHome}, (client) -> - client - .waitForExist("atom-workspace") - .waitForPaneItemCount(1, 5000) + .call -> + expect(windowProjectPaths.sort()).toEqual [ + [tempDirPath] + [otherTempDirPath] + ].sort() - # Opening with no file paths always creates a new window, even if - # existing windows have no project paths. - .waitForNewWindow(-> - @startAnotherAtom([], ATOM_HOME: AtomHome) - , 5000) + describe "opening a remote directory", -> + it "opens the parent directory and creates an empty text editor", -> + remoteDirectory = 'remote://server:3437/some/directory/path' + runAtom [remoteDirectory], {ATOM_HOME: atomHome}, (client) -> + client + .waitForWindowCount(1, 1000) + .waitForExist("atom-workspace", 5000) + .treeViewRootDirectories() + .then ({value}) -> expect(value).toEqual([remoteDirectory]) diff --git a/spec/jasmine-helper.coffee b/spec/jasmine-helper.coffee index 7642de7d7..31f65167b 100644 --- a/spec/jasmine-helper.coffee +++ b/spec/jasmine-helper.coffee @@ -7,7 +7,7 @@ module.exports.runSpecSuite = (specSuite, logFile, logErrors=true) -> {TerminalReporter} = require 'jasmine-tagged' - disableFocusMethods() if process.env.JANKY_SHA1 + disableFocusMethods() if process.env.JANKY_SHA1 or process.env.CI TimeReporter = require './time-reporter' timeReporter = new TimeReporter() @@ -27,8 +27,15 @@ module.exports.runSpecSuite = (specSuite, logFile, logErrors=true) -> fs.closeSync(logStream) if logStream? if process.env.JANKY_SHA1 grim = require 'grim' - grim.logDeprecations() if grim.getDeprecationsLength() > 0 - atom.exit(runner.results().failedCount > 0 ? 1 : 0) + + if grim.getDeprecationsLength() > 0 + grim.logDeprecations() + return atom.exit(1) + + if runner.results().failedCount > 0 + atom.exit(1) + else + atom.exit(0) else AtomReporter = require './atom-reporter' reporter = new AtomReporter() diff --git a/spec/language-mode-spec.coffee b/spec/language-mode-spec.coffee index 07b875a91..7ea4a1ae9 100644 --- a/spec/language-mode-spec.coffee +++ b/spec/language-mode-spec.coffee @@ -101,6 +101,18 @@ describe "LanguageMode", -> expect(languageMode.suggestedIndentForBufferRow(0)).toBe 0 expect(languageMode.suggestedIndentForBufferRow(1)).toBe 1 expect(languageMode.suggestedIndentForBufferRow(2)).toBe 2 + expect(languageMode.suggestedIndentForBufferRow(5)).toBe 3 + expect(languageMode.suggestedIndentForBufferRow(7)).toBe 2 + expect(languageMode.suggestedIndentForBufferRow(9)).toBe 1 + expect(languageMode.suggestedIndentForBufferRow(11)).toBe 1 + + it "does not take invisibles into account", -> + atom.config.set('editor.showInvisibles', true) + expect(languageMode.suggestedIndentForBufferRow(0)).toBe 0 + expect(languageMode.suggestedIndentForBufferRow(1)).toBe 1 + expect(languageMode.suggestedIndentForBufferRow(2)).toBe 2 + expect(languageMode.suggestedIndentForBufferRow(5)).toBe 3 + expect(languageMode.suggestedIndentForBufferRow(7)).toBe 2 expect(languageMode.suggestedIndentForBufferRow(9)).toBe 1 expect(languageMode.suggestedIndentForBufferRow(11)).toBe 1 @@ -124,25 +136,33 @@ describe "LanguageMode", -> // lines var sort = function(items) {}; // comment line after fn + + var nosort = function(items) { + return item; + } + }; ''' it "will limit paragraph range to comments", -> range = languageMode.rowRangeForParagraphAtBufferRow(0) - expect(range).toEqual [[0,0], [0,29]] + expect(range).toEqual [[0, 0], [0, 29]] range = languageMode.rowRangeForParagraphAtBufferRow(10) - expect(range).toEqual [[10,0], [10,14]] + expect(range).toEqual [[10, 0], [10, 14]] range = languageMode.rowRangeForParagraphAtBufferRow(11) expect(range).toBeFalsy() range = languageMode.rowRangeForParagraphAtBufferRow(12) - expect(range).toEqual [[12,0], [13,10]] + expect(range).toEqual [[12, 0], [13, 10]] range = languageMode.rowRangeForParagraphAtBufferRow(14) - expect(range).toEqual [[14,0], [14,32]] + expect(range).toEqual [[14, 0], [14, 32]] range = languageMode.rowRangeForParagraphAtBufferRow(15) - expect(range).toEqual [[15,0], [15,26]] + expect(range).toEqual [[15, 0], [15, 26]] + + range = languageMode.rowRangeForParagraphAtBufferRow(18) + expect(range).toEqual [[17, 0], [19, 3]] describe "coffeescript", -> beforeEach -> @@ -297,9 +317,9 @@ describe "LanguageMode", -> atom.packages.unloadPackages() it "maintains cursor buffer position when a folding/unfolding", -> - editor.setCursorBufferPosition([5,5]) + editor.setCursorBufferPosition([5, 5]) languageMode.foldAll() - expect(editor.getCursorBufferPosition()).toEqual([5,5]) + expect(editor.getCursorBufferPosition()).toEqual([5, 5]) describe ".unfoldAll()", -> it "unfolds every folded line", -> @@ -351,7 +371,7 @@ describe "LanguageMode", -> describe "when the bufferRow is in a multi-line comment", -> it "searches upward and downward for surrounding comment lines and folds them as a single fold", -> - buffer.insert([1,0], " //this is a comment\n // and\n //more docs\n\n//second comment") + buffer.insert([1, 0], " //this is a comment\n // and\n //more docs\n\n//second comment") languageMode.foldBufferRow(1) fold = editor.tokenizedLineForScreenRow(1).fold expect(fold.getStartRow()).toBe 1 @@ -359,7 +379,7 @@ describe "LanguageMode", -> describe "when the bufferRow is a single-line comment", -> it "searches upward for the first row that begins a syntatic region containing the folded row (and folds it)", -> - buffer.insert([1,0], " //this is a single line comment\n") + buffer.insert([1, 0], " //this is a single line comment\n") languageMode.foldBufferRow(1) fold = editor.tokenizedLineForScreenRow(0).fold expect(fold.getStartRow()).toBe 0 diff --git a/spec/menu-manager-spec.coffee b/spec/menu-manager-spec.coffee index d92c921c4..f86b6e1a0 100644 --- a/spec/menu-manager-spec.coffee +++ b/spec/menu-manager-spec.coffee @@ -1,3 +1,4 @@ +path = require 'path' MenuManager = require '../src/menu-manager' describe "MenuManager", -> @@ -46,3 +47,32 @@ describe "MenuManager", -> menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}] expect(menu.template[originalItemCount]).toEqual {label: "A", submenu: [{label: "B", command: "b"}]} + + describe "::update()", -> + 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.keymap.add 'test', 'atom-workspace': 'ctrl-b': 'b' + menu.update() + + waits 1 + + runs -> expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toEqual ['ctrl-b'] + + 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.keymap.add 'test', 'atom-workspace': 'ctrl-b': 'b' + atom.keymap.add 'test', 'atom-text-editor': 'ctrl-b': 'unset!' + + waits 1 + + runs -> expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined() + + it "updates the application menu when a keymap is reloaded", -> + spyOn(menu, 'update') + keymapPath = path.join(__dirname, 'fixtures', 'packages', 'package-with-keymaps', 'keymaps', 'keymap-1.cson') + atom.keymaps.reloadKeymap(keymapPath) + expect(menu.update).toHaveBeenCalled() diff --git a/spec/package-manager-spec.coffee b/spec/package-manager-spec.coffee index d9898505c..26b675975 100644 --- a/spec/package-manager-spec.coffee +++ b/spec/package-manager-spec.coffee @@ -1,3 +1,4 @@ +path = require 'path' {$, $$} = require '../src/space-pen-extensions' Package = require '../src/package' {Disposable} = require 'atom' @@ -18,7 +19,6 @@ describe "PackageManager", -> expect(pack.metadata.name).toBe "package-with-index" it "returns the package if it has an invalid keymap", -> - spyOn(console, 'warn') pack = atom.packages.loadPackage("package-with-broken-keymap") expect(pack instanceof Package).toBe true expect(pack.metadata.name).toBe "package-with-broken-keymap" @@ -29,11 +29,27 @@ describe "PackageManager", -> expect(pack.metadata.name).toBe "package-with-invalid-styles" expect(pack.stylesheets.length).toBe 0 + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(-> pack.reloadStylesheets()).not.toThrow() + expect(addErrorHandler.callCount).toBe 2 + expect(addErrorHandler.argsForCall[1][0].message).toContain("Failed to reload the package-with-invalid-styles package stylesheets") + it "returns null if the package has an invalid package.json", -> - spyOn(console, 'warn') + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) expect(atom.packages.loadPackage("package-with-broken-package-json")).toBeNull() - expect(console.warn.callCount).toBe(1) - expect(console.warn.argsForCall[0][0]).toContain("Failed to load package.json") + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-with-broken-package-json package") + + it "normalizes short repository urls in package.json", -> + {metadata} = atom.packages.loadPackage("package-with-short-url-package-json") + expect(metadata.repository.type).toBe "git" + expect(metadata.repository.url).toBe "https://github.com/example/repo.git" + + {metadata} = atom.packages.loadPackage("package-with-invalid-url-package-json") + expect(metadata.repository.type).toBe "git" + expect(metadata.repository.url).toBe "foo" it "returns null if the package is not found in any package directory", -> spyOn(console, 'warn') @@ -41,6 +57,24 @@ describe "PackageManager", -> expect(console.warn.callCount).toBe(1) expect(console.warn.argsForCall[0][0]).toContain("Could not resolve") + describe "when the package is deprecated", -> + grim = require 'grim' + includeDeprecatedAPIs = null + + beforeEach -> + {includeDeprecatedAPIs} = grim + + afterEach -> + grim.includeDeprecatedAPIs = includeDeprecatedAPIs + + it "returns null", -> + grim.includeDeprecatedAPIs = false + expect(atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'packages', 'wordcount'))).toBeNull() + expect(atom.packages.isDeprecatedPackage('wordcount', '2.1.9')).toBe true + expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.0')).toBe true + expect(atom.packages.isDeprecatedPackage('wordcount', '2.2.1')).toBe false + expect(atom.packages.getDeprecatedPackageMetadata('wordcount').version).toBe '<=2.2.0' + it "invokes ::onDidLoadPackage listeners with the loaded package", -> loadedPackage = null atom.packages.onDidLoadPackage (pack) -> loadedPackage = pack @@ -212,6 +246,46 @@ describe "PackageManager", -> runs -> expect(mainModule.activate.callCount).toBe 1 + it "adds a notification when the activation commands are invalid", -> + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(-> atom.packages.activatePackage('package-with-invalid-activation-commands')).not.toThrow() + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to activate the package-with-invalid-activation-commands package") + + it "adds a notification when the context menu is invalid", -> + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(-> atom.packages.activatePackage('package-with-invalid-context-menu')).not.toThrow() + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to activate the package-with-invalid-context-menu package") + + it "adds a notification when the grammar is invalid", -> + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + + expect(-> atom.packages.activatePackage('package-with-invalid-grammar')).not.toThrow() + + waitsFor -> + addErrorHandler.callCount > 0 + + runs -> + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load a package-with-invalid-grammar package grammar") + + it "adds a notification when the settings are invalid", -> + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + + expect(-> atom.packages.activatePackage('package-with-invalid-settings')).not.toThrow() + + waitsFor -> + addErrorHandler.callCount > 0 + + runs -> + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-with-invalid-settings package settings") + describe "when the package has no main module", -> it "does not throw an exception", -> spyOn(console, "error") @@ -257,11 +331,13 @@ describe "PackageManager", -> runs -> expect(activatedPackage.name).toBe 'package-with-main' describe "when the package throws an error while loading", -> - it "logs a warning instead of throwing an exception", -> + it "adds a notification instead of throwing an exception", -> atom.config.set("core.disabledPackages", []) - spyOn(console, "warn") + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) expect(-> atom.packages.activatePackage("package-that-throws-an-exception")).not.toThrow() - expect(console.warn).toHaveBeenCalled() + expect(addErrorHandler.callCount).toBe 1 + expect(addErrorHandler.argsForCall[0][0].message).toContain("Failed to load the package-that-throws-an-exception package") describe "when the package is not found", -> it "rejects the promise", -> @@ -288,32 +364,32 @@ describe "PackageManager", -> element2 = $$ -> @div class: 'test-2' element3 = $$ -> @div class: 'test-3' - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element1[0])).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element2[0])).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element3[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element2[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element3[0])).toHaveLength 0 waitsForPromise -> atom.packages.activatePackage("package-with-keymaps") runs -> - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element1[0])[0].command).toBe "test-1" - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element2[0])[0].command).toBe "test-2" - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element3[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1[0])[0].command).toBe "test-1" + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element2[0])[0].command).toBe "test-2" + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element3[0])).toHaveLength 0 describe "when the metadata contains a 'keymaps' manifest", -> it "loads only the keymaps specified by the manifest, in the specified order", -> element1 = $$ -> @div class: 'test-1' element3 = $$ -> @div class: 'test-3' - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element1[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1[0])).toHaveLength 0 waitsForPromise -> atom.packages.activatePackage("package-with-keymaps-manifest") runs -> - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target:element1[0])[0].command).toBe 'keymap-1' - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-n', target:element1[0])[0].command).toBe 'keymap-2' - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-y', target:element3[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: element1[0])[0].command).toBe 'keymap-1' + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-n', target: element1[0])[0].command).toBe 'keymap-2' + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-y', target: element3[0])).toHaveLength 0 describe "when the keymap file is empty", -> it "does not throw an error on activation", -> @@ -480,6 +556,7 @@ describe "PackageManager", -> atom.packages.activatePackage("package-with-provided-services") runs -> + expect(consumerModule.consumeFirstServiceV3.callCount).toBe(1) expect(consumerModule.consumeFirstServiceV3).toHaveBeenCalledWith('first-service-v3') expect(consumerModule.consumeFirstServiceV4).toHaveBeenCalledWith('first-service-v4') expect(consumerModule.consumeSecondService).toHaveBeenCalledWith('second-service') @@ -504,6 +581,21 @@ describe "PackageManager", -> expect(consumerModule.consumeFirstServiceV4).not.toHaveBeenCalled() expect(consumerModule.consumeSecondService).not.toHaveBeenCalled() + it "ignores provided and consumed services that do not exist", -> + addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + + waitsForPromise -> + atom.packages.activatePackage("package-with-missing-consumed-services") + + waitsForPromise -> + atom.packages.activatePackage("package-with-missing-provided-services") + + runs -> + expect(atom.packages.isPackageActive("package-with-missing-consumed-services")).toBe true + expect(atom.packages.isPackageActive("package-with-missing-provided-services")).toBe true + expect(addErrorHandler.callCount).toBe 0 + describe "::deactivatePackage(id)", -> afterEach -> atom.packages.unloadPackages() @@ -587,8 +679,8 @@ describe "PackageManager", -> runs -> atom.packages.deactivatePackage('package-with-keymaps') - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target: ($$ -> @div class: 'test-1')[0])).toHaveLength 0 - expect(atom.keymaps.findKeyBindings(keystrokes:'ctrl-z', target: ($$ -> @div class: 'test-2')[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: ($$ -> @div class: 'test-1')[0])).toHaveLength 0 + expect(atom.keymaps.findKeyBindings(keystrokes: 'ctrl-z', target: ($$ -> @div class: 'test-2')[0])).toHaveLength 0 it "removes the package's stylesheets", -> waitsForPromise -> @@ -751,3 +843,66 @@ describe "PackageManager", -> expect(atom.config.get('core.themes')).not.toContain packageName expect(atom.config.get('core.themes')).not.toContain packageName expect(atom.config.get('core.disabledPackages')).not.toContain packageName + + describe "deleting non-bundled autocomplete packages", -> + [autocompleteCSSPath, autocompletePlusPath] = [] + fs = require 'fs-plus' + path = require 'path' + + beforeEach -> + fixturePath = path.resolve(__dirname, './fixtures/packages') + autocompleteCSSPath = path.join(fixturePath, 'autocomplete-css') + autocompletePlusPath = path.join(fixturePath, 'autocomplete-plus') + + try + fs.mkdirSync(autocompleteCSSPath) + fs.writeFileSync(path.join(autocompleteCSSPath, 'package.json'), '{}') + fs.symlinkSync(path.join(fixturePath, 'package-with-main'), autocompletePlusPath, 'dir') + + expect(fs.isDirectorySync(autocompleteCSSPath)).toBe true + expect(fs.isSymbolicLinkSync(autocompletePlusPath)).toBe true + + jasmine.unspy(atom.packages, 'uninstallAutocompletePlus') + + afterEach -> + try + fs.unlink autocompletePlusPath, -> + + it "removes the packages", -> + atom.packages.loadPackages() + + waitsFor -> + not fs.isDirectorySync(autocompleteCSSPath) + + runs -> + expect(fs.isDirectorySync(autocompleteCSSPath)).toBe false + expect(fs.isSymbolicLinkSync(autocompletePlusPath)).toBe true + + describe "when the deprecated sublime-tabs package is installed", -> + grim = require 'grim' + includeDeprecatedAPIs = null + + beforeEach -> + {includeDeprecatedAPIs} = grim + grim.includeDeprecatedAPIs = false + + afterEach -> + grim.includeDeprecatedAPIs = includeDeprecatedAPIs + + it "enables the tree-view and tabs package", -> + atom.config.pushAtKeyPath('core.disabledPackages', 'tree-view') + atom.config.pushAtKeyPath('core.disabledPackages', 'tabs') + + spyOn(atom.packages, 'getAvailablePackagePaths').andReturn [ + path.join(__dirname, 'fixtures', 'packages', 'sublime-tabs') + path.resolve(__dirname, '..', 'node_modules', 'tree-view') + path.resolve(__dirname, '..', 'node_modules', 'tabs') + ] + atom.packages.loadPackages() + + waitsFor -> + not atom.packages.isPackageDisabled('tree-view') and not atom.packages.isPackageDisabled('tabs') + + runs -> + expect(atom.packages.isPackageLoaded('tree-view')).toBe true + expect(atom.packages.isPackageLoaded('tabs')).toBe true diff --git a/spec/package-spec.coffee b/spec/package-spec.coffee index 87d243ce2..9ea000ab9 100644 --- a/spec/package-spec.coffee +++ b/spec/package-spec.coffee @@ -105,3 +105,13 @@ describe "Package", -> theme.onDidDeactivate spy = jasmine.createSpy() theme.deactivate() expect(spy).toHaveBeenCalled() + + describe ".loadMetadata()", -> + [packagePath, pack, metadata] = [] + + beforeEach -> + packagePath = atom.project.getDirectories()[0]?.resolve('packages/package-with-different-directory-name') + metadata = Package.loadMetadata(packagePath, true) + + it "uses the package name defined in package.json", -> + expect(metadata.name).toBe 'package-with-a-totally-different-name' diff --git a/spec/pane-container-element-spec.coffee b/spec/pane-container-element-spec.coffee new file mode 100644 index 000000000..8947a6d31 --- /dev/null +++ b/spec/pane-container-element-spec.coffee @@ -0,0 +1,162 @@ +PaneContainer = require '../src/pane-container' +PaneAxisElement = require '../src/pane-axis-element' +PaneAxis = require '../src/pane-axis' + +describe "PaneContainerElement", -> + describe "when panes are added or removed", -> + [paneAxisElement, paneAxis] = [] + + beforeEach -> + paneAxis = new PaneAxis + paneAxisElement = new PaneAxisElement().initialize(paneAxis) + + childTagNames = -> + child.nodeName.toLowerCase() for child in paneAxisElement.children + + it "inserts or removes resize elements", -> + expect(childTagNames()).toEqual [] + + paneAxis.addChild(new PaneAxis) + expect(childTagNames()).toEqual [ + 'atom-pane-axis' + ] + + paneAxis.addChild(new PaneAxis) + expect(childTagNames()).toEqual [ + 'atom-pane-axis' + 'atom-pane-resize-handle' + 'atom-pane-axis' + ] + + paneAxis.addChild(new PaneAxis) + expect(childTagNames()).toEqual [ + 'atom-pane-axis' + 'atom-pane-resize-handle' + 'atom-pane-axis' + 'atom-pane-resize-handle' + 'atom-pane-axis' + ] + + paneAxis.removeChild(paneAxis.getChildren()[2]) + expect(childTagNames()).toEqual [ + 'atom-pane-axis' + 'atom-pane-resize-handle' + 'atom-pane-axis' + ] + + describe "when the resize element is dragged ", -> + [container, containerElement] = [] + + beforeEach -> + container = new PaneContainer + containerElement = atom.views.getView(container) + document.querySelector('#jasmine-content').appendChild(containerElement) + + dragElementToPosition = (element, clientX) -> + element.dispatchEvent(new MouseEvent('mousedown', + view: window + bubbles: true + button: 0 + )) + + element.dispatchEvent(new MouseEvent 'mousemove', + view: window + bubbles: true + clientX: clientX + ) + + element.dispatchEvent(new MouseEvent 'mouseup', + iew: window + bubbles: true + button: 0 + ) + + getElementWidth = (element) -> + element.getBoundingClientRect().width + + expectPaneScale = (pairs...) -> + for [pane, expectedFlexScale] in pairs + expect(pane.getFlexScale()).toBeCloseTo(expectedFlexScale, 0.1) + + getResizeElement = (i) -> + containerElement.querySelectorAll('atom-pane-resize-handle')[i] + + getPaneElement = (i) -> + containerElement.querySelectorAll('atom-pane')[i] + + it "adds and removes panes in the direction that the pane is being dragged", -> + leftPane = container.getActivePane() + expectPaneScale [leftPane, 1] + + middlePane = leftPane.splitRight() + expectPaneScale [leftPane, 1], [middlePane, 1] + + dragElementToPosition( + getResizeElement(0), + getElementWidth(getPaneElement(0)) / 2 + ) + expectPaneScale [leftPane, 0.5], [middlePane, 1.5] + + rightPane = middlePane.splitRight() + expectPaneScale [leftPane, 0.5], [middlePane, 1.5], [rightPane, 1] + + dragElementToPosition( + getResizeElement(1), + getElementWidth(getPaneElement(0)) + getElementWidth(getPaneElement(1)) / 2 + ) + expectPaneScale [leftPane, 0.5], [middlePane, 0.75], [rightPane, 1.75] + + middlePane.close() + expectPaneScale [leftPane, 0.44], [rightPane, 1.55] + + leftPane.close() + expectPaneScale [rightPane, 1] + + it "splits or closes panes in orthogonal direction that the pane is being dragged", -> + leftPane = container.getActivePane() + expectPaneScale [leftPane, 1] + + rightPane = leftPane.splitRight() + expectPaneScale [leftPane, 1], [rightPane, 1] + + dragElementToPosition( + getResizeElement(0), + getElementWidth(getPaneElement(0)) / 2 + ) + expectPaneScale [leftPane, 0.5], [rightPane, 1.5] + + # dynamically split pane, pane's flexScale will become to 1 + lowerPane = leftPane.splitDown() + expectPaneScale [lowerPane, 1], [leftPane, 1], [leftPane.getParent(), 0.5] + + # dynamically close pane, the pane's flexscale will recorver to origin value + lowerPane.close() + expectPaneScale [leftPane, 0.5], [rightPane, 1.5] + + it "unsubscribes from mouse events when the pane is detached", -> + container.getActivePane().splitRight() + element = getResizeElement(0) + spyOn(document, 'addEventListener').andCallThrough() + spyOn(document, 'removeEventListener').andCallThrough() + spyOn(element, 'resizeStopped').andCallThrough() + + element.dispatchEvent(new MouseEvent('mousedown', + view: window + bubbles: true + button: 0 + )) + + waitsFor -> + document.addEventListener.callCount is 2 + + runs -> + expect(element.resizeStopped.callCount).toBe 0 + container.destroy() + expect(element.resizeStopped.callCount).toBe 1 + expect(document.removeEventListener.callCount).toBe 2 + + it "does not throw an error when resized to fit content in a detached state", -> + container.getActivePane().splitRight() + element = getResizeElement(0) + element.remove() + expect(-> element.resizeToFitContent()).not.toThrow() diff --git a/spec/pane-container-view-spec.coffee b/spec/pane-container-view-spec.coffee index 6c088c343..1b9cf9813 100644 --- a/spec/pane-container-view-spec.coffee +++ b/spec/pane-container-view-spec.coffee @@ -15,7 +15,7 @@ describe "PaneContainerView", -> @deserialize: ({name}) -> new TestView(name) @content: -> @div tabindex: -1 initialize: (@name) -> @text(@name) - serialize: -> { deserializer: 'TestView', @name } + serialize: -> {deserializer: 'TestView', @name} getURI: -> path.join(temp.dir, @name) save: -> @saved = true isEqual: (other) -> @name is other?.name diff --git a/spec/pane-spec.coffee b/spec/pane-spec.coffee index ac6e1d26f..ba2f02802 100644 --- a/spec/pane-spec.coffee +++ b/spec/pane-spec.coffee @@ -414,7 +414,7 @@ describe "Pane", -> pane.getActiveItem().path = __filename pane.getActiveItem().saveAs = jasmine.createSpy("saveAs") pane.saveActiveItemAs() - expect(atom.showSaveDialogSync).toHaveBeenCalledWith(__filename) + expect(atom.showSaveDialogSync).toHaveBeenCalledWith(defaultPath: __filename) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') describe "when the current item does not have a saveAs method", -> @@ -696,7 +696,10 @@ describe "Pane", -> pane = null beforeEach -> - pane = new Pane(items: [new Item("A", "a"), new Item("B", "b"), new Item("C", "c")]) + params = + items: [new Item("A", "a"), new Item("B", "b"), new Item("C", "c")] + flexScale: 2 + pane = new Pane(params) it "can serialize and deserialize the pane and all its items", -> newPane = pane.testSerialization() diff --git a/spec/pane-view-spec.coffee b/spec/pane-view-spec.coffee index 763cc9ab4..19fa247e0 100644 --- a/spec/pane-view-spec.coffee +++ b/spec/pane-view-spec.coffee @@ -14,9 +14,9 @@ describe "PaneView", -> @content: ({id, text}) -> @div class: 'test-view', id: id, tabindex: -1, text initialize: ({@id, @text}) -> @emitter = new Emitter - serialize: -> { deserializer: 'TestView', @id, @text } + serialize: -> {deserializer: 'TestView', @id, @text} getURI: -> @id - isEqual: (other) -> other? and @id == other.id and @text == other.text + isEqual: (other) -> other? and @id is other.id and @text is other.text changeTitle: -> @emitter.emit 'did-change-title', 'title' onDidChangeTitle: (callback) -> @@ -118,6 +118,33 @@ describe "PaneView", -> paneModel.activateItem(view2) expect(pane.itemViews.find('#view-2').length).toBe 1 + describe "when the new activeItem implements ::getPath", -> + beforeEach -> + paneModel.activateItem(editor1) + + it "adds the file path as a data attribute to the pane", -> + expect(pane).toHaveAttr('data-active-item-path') + + it "adds the file name as a data attribute to the pane", -> + expect(pane).toHaveAttr('data-active-item-name') + + describe "when the activeItem is destroyed", -> + it "removes the data attributes", -> + pane.destroyItems() + expect(pane).not.toHaveAttr('data-active-item-path') + expect(pane).not.toHaveAttr('data-active-item-name') + + describe "when the new activeItem does not implement ::getPath", -> + beforeEach -> + paneModel.activateItem(editor1) + paneModel.activateItem(document.createElement('div')) + + it "does not add the file path as a data attribute to the pane", -> + expect(pane).not.toHaveAttr('data-active-item-path') + + it "does not add the file name as data attribute to the pane", -> + expect(pane).not.toHaveAttr('data-active-item-name') + describe "when an item is destroyed", -> it "triggers the 'pane:item-removed' event with the item and its former index", -> itemRemovedHandler = jasmine.createSpy("itemRemovedHandler") @@ -222,7 +249,7 @@ describe "PaneView", -> fs.removeSync(filePath) waitsFor -> - pane.items.length == 4 + pane.items.length is 4 describe "when a pane is destroyed", -> [pane2, pane2Model] = [] @@ -333,3 +360,30 @@ describe "PaneView", -> pane3 = container3.getRoot() container3.attachToDom() expect(pane3).not.toMatchSelector(':has(:focus)') + + describe "drag and drop", -> + buildDragEvent = (type, files) -> + dataTransfer = + files: files + data: {} + setData: (key, value) -> @data[key] = value + getData: (key) -> @data[key] + + event = new CustomEvent("drop") + event.dataTransfer = dataTransfer + event + + describe "when a file is dragged to window", -> + it "opens it", -> + spyOn(atom, "open") + event = buildDragEvent("drop", [ {path: "/fake1"}, {path: "/fake2"} ]) + pane[0].dispatchEvent(event) + expect(atom.open.callCount).toBe 1 + expect(atom.open.argsForCall[0][0]).toEqual pathsToOpen: ['/fake1', '/fake2'] + + describe "when a non-file is dragged to window", -> + it "does nothing", -> + spyOn(atom, "open") + event = buildDragEvent("drop", []) + pane[0].dispatchEvent(event) + expect(atom.open).not.toHaveBeenCalled() diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee index 0d115f56f..d89802243 100644 --- a/spec/project-spec.coffee +++ b/spec/project-spec.coffee @@ -78,6 +78,20 @@ describe "Project", -> expect(directories.length).toBe 1 expect(directories[0].getPath()).toBe tmp + it "gets the parent directory from the default directory provider if it's a local directory", -> + tmp = temp.mkdirSync() + atom.project.setPaths([path.join(tmp, "not-existing")]) + directories = atom.project.getDirectories() + expect(directories.length).toBe 1 + expect(directories[0].getPath()).toBe tmp + + it "only normalizes the directory path if it isn't on the local filesystem", -> + nonLocalFsDirectory = "custom_proto://abc/def" + atom.project.setPaths([nonLocalFsDirectory]) + directories = atom.project.getDirectories() + expect(directories.length).toBe 1 + expect(directories[0].getPath()).toBe path.normalize(nonLocalFsDirectory) + it "tries to update repositories when a new RepositoryProvider is registered", -> tmp = temp.mkdirSync('atom-project') atom.project.setPaths([tmp]) @@ -207,7 +221,7 @@ describe "Project", -> beforeEach -> absolutePath = require.resolve('./fixtures/dir/a') newBufferHandler = jasmine.createSpy('newBufferHandler') - atom.project.on 'buffer-created', 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'", -> @@ -422,6 +436,23 @@ describe "Project", -> 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", -> + ftpURI = "ftp://example.com/some/folder" + directoryProvider = + directoryForURISync: (uri) -> + # Dummy implementation of Directory for which GitRepositoryProvider + # will not try to create a GitRepository. + getPath: -> ftpURI + getSubdirectory: -> {} + isRoot: -> true + off: -> + atom.packages.serviceHub.provide( + "atom.directory-provider", "0.1.0", directoryProvider) + atom.project.setPaths([ftpURI]) + expect(atom.project.getPaths()).toEqual [ftpURI] + atom.project.removePath(ftpURI) + expect(atom.project.getPaths()).toEqual [] + describe ".relativize(path)", -> it "returns the path, relative to whichever root directory it is inside of", -> atom.project.addPath(temp.mkdirSync("another-path")) @@ -455,6 +486,11 @@ describe "Project", -> 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 ".contains(path)", -> it "returns whether or not the given path is in one of the root directories", -> rootPath = atom.project.getPaths()[0] @@ -466,8 +502,12 @@ describe "Project", -> describe ".eachBuffer(callback)", -> beforeEach -> + jasmine.snapshotDeprecations() atom.project.bufferForPathSync('a') + afterEach -> + jasmine.restoreDeprecationsSnapshot() + it "invokes the callback for existing buffer", -> count = 0 count = 0 diff --git a/spec/random-editor-spec.coffee b/spec/random-editor-spec.coffee index bb5028d9a..d235ebc25 100644 --- a/spec/random-editor-spec.coffee +++ b/spec/random-editor-spec.coffee @@ -4,7 +4,7 @@ TextBuffer = require 'text-buffer' TextEditor = require '../src/text-editor' describe "TextEditor", -> - [editor, tokenizedBuffer, buffer, steps, previousSteps] = [] + [editor, tokenizedBuffer, buffer, steps] = [] softWrapColumn = 80 @@ -13,8 +13,6 @@ describe "TextEditor", -> atom.config.set('editor.preferredLineLength', softWrapColumn) it "properly renders soft-wrapped lines when randomly mutated", -> - previousSteps = JSON.parse(localStorage.steps ? '[]') - times 10, (i) -> buffer = new TextBuffer editor = new TextEditor({buffer}) @@ -47,6 +45,9 @@ describe "TextEditor", -> {bufferRows, screenLines} = getReferenceScreenLines() for bufferRow, screenRow in bufferRows console.log screenRow, bufferRow, screenLines[screenRow].text + console.log "==== steps to reproduce this failure: ===" + for step in steps + console.log 'editor.' + step[0] + '('+ step[1..].map((a) -> JSON.stringify(a)).join(', ') + ')' randomlyMutateEditor = -> if Math.random() < .2 @@ -79,34 +80,11 @@ describe "TextEditor", -> text getReferenceScreenLines = -> - if editor.isSoftWrapped() - screenLines = [] - bufferRows = [] - for bufferRow in [0..tokenizedBuffer.getLastRow()] - for screenLine in softWrapLine(tokenizedBuffer.tokenizedLineForRow(bufferRow)) - screenLines.push(screenLine) - bufferRows.push(bufferRow) - else - screenLines = tokenizedBuffer.tokenizedLines.slice() - bufferRows = [0..tokenizedBuffer.getLastRow()] + referenceEditor = new TextEditor({}) + referenceEditor.setEditorWidthInChars(80) + referenceEditor.setText(editor.getText()) + referenceEditor.setSoftWrapped(editor.isSoftWrapped()) + screenLines = referenceEditor.tokenizedLinesForScreenRows(0, referenceEditor.getLastScreenRow()) + bufferRows = referenceEditor.bufferRowsForScreenRows(0, referenceEditor.getLastScreenRow()) + {screenLines, bufferRows} - - softWrapLine = (tokenizedLine) -> - wrappedLines = [] - while tokenizedLine.text.length > softWrapColumn and wrapScreenColumn = findWrapColumn(tokenizedLine.text) - [wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt(wrapScreenColumn) - wrappedLines.push(wrappedLine) - wrappedLines.push(tokenizedLine) - wrappedLines - - findWrapColumn = (line) -> - if /\s/.test(line[softWrapColumn]) - # search forward for the start of a word past the boundary - for column in [softWrapColumn..line.length] - return column if /\S/.test(line[column]) - return line.length - else - # search backward for the start of the word on the boundary - for column in [softWrapColumn..0] - return column + 1 if /\s/.test(line[column]) - return softWrapColumn diff --git a/spec/selection-spec.coffee b/spec/selection-spec.coffee index 0e64b60fd..bdccb799d 100644 --- a/spec/selection-spec.coffee +++ b/spec/selection-spec.coffee @@ -14,18 +14,18 @@ describe "Selection", -> describe ".deleteSelectedText()", -> describe "when nothing is selected", -> it "deletes nothing", -> - selection.setBufferRange [[0,3], [0,3]] + 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.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.setBufferRange [[0, 0], [0, endOfLine]] selection.deleteSelectedText() expect(buffer.lineForRow(0)).toBe "" @@ -33,15 +33,15 @@ describe "Selection", -> describe "when multiple lines are selected", -> it "deletes selected text and clears the selection", -> - selection.setBufferRange [[0,1], [2,39]] + 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.cursor.setScreenPosition [0, 13] + selection.selectToScreenPosition [0, 4] selection.delete() expect(buffer.lineForRow(0)).toBe "var = function () {" diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index d34c72b1a..5ba54a242 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -24,7 +24,7 @@ TextEditorElement = require '../src/text-editor-element' TokenizedBuffer = require '../src/tokenized-buffer' TextEditorComponent = require '../src/text-editor-component' pathwatcher = require 'pathwatcher' -clipboard = require 'clipboard' +clipboard = require '../src/safe-clipboard' atom.themes.loadBaseStylesheets() atom.themes.requireStylesheet '../static/jasmine' @@ -49,9 +49,28 @@ Object.defineProperty document, 'title', get: -> documentTitle set: (title) -> documentTitle = title +Set.prototype.jasmineToString = -> + result = "Set {" + first = true + @forEach (element) -> + result += ", " unless first + result += element.toString() + first = false + result + "}" + +Set.prototype.isEqual = (other) -> + if other instanceof Set + return false if @size isnt other.size + values = @values() + until (next = values.next()).done + return false unless other.has(next.value) + true + else + false + jasmine.getEnv().addEqualityTester(_.isEqual) # Use underscore's definition of equality for toEqual assertions -if process.env.JANKY_SHA1 and process.platform is 'win32' +if process.env.CI jasmine.getEnv().defaultTimeoutInterval = 60000 else jasmine.getEnv().defaultTimeoutInterval = 5000 @@ -69,10 +88,9 @@ if specDirectory specPackageName = JSON.parse(fs.readFileSync(path.join(specPackagePath, 'package.json')))?.name specProjectPath = path.join(specDirectory, 'fixtures') -isCoreSpec = specDirectory == fs.realpathSync(__dirname) +isCoreSpec = specDirectory is fs.realpathSync(__dirname) beforeEach -> - Grim.clearDeprecations() if isCoreSpec $.fx.off = true documentTitle = null projectPath = specProjectPath ? path.join(@specDirectory, 'fixtures') @@ -141,6 +159,8 @@ beforeEach -> spyOn(clipboard, 'writeText').andCallFake (text) -> clipboardContent = text spyOn(clipboard, 'readText').andCallFake -> clipboardContent + spyOn(atom.packages, 'uninstallAutocompletePlus') + addCustomMatchers(this) afterEach -> @@ -164,7 +184,6 @@ afterEach -> jasmine.unspy(atom, 'saveSync') ensureNoPathSubscriptions() - atom.grammars.clearObservers() waits(0) # yield to ui thread to make screen update more frequently ensureNoPathSubscriptions = -> @@ -235,7 +254,7 @@ addCustomMatchers = (spec) -> else notText = if @isNot then " not" else "" this.message = => "Expected object with length #{@actual.length} to#{notText} have length #{expected}" - @actual.length == expected + @actual.length is expected toExistOnDisk: (expected) -> notText = this.isNot and " not" or "" @@ -298,7 +317,7 @@ window.mousemoveEvent = (properties={}) -> window.waitsForPromise = (args...) -> if args.length > 1 - { shouldReject, timeout } = args[0] + {shouldReject, timeout} = args[0] else shouldReject = false fn = _.last(args) @@ -313,7 +332,7 @@ window.waitsForPromise = (args...) -> else promise.then(moveOn) promise.catch.call promise, (error) -> - jasmine.getEnv().currentSpec.fail("Expected promise to be resolved, but it was rejected with #{jasmine.pp(error)}") + jasmine.getEnv().currentSpec.fail("Expected promise to be resolved, but it was rejected with: #{error?.message} #{jasmine.pp(error)}") moveOn() window.resetTimeouts = -> @@ -329,7 +348,7 @@ window.fakeSetTimeout = (callback, ms) -> id window.fakeClearTimeout = (idToClear) -> - window.timeouts = window.timeouts.filter ([id]) -> id != idToClear + window.timeouts = window.timeouts.filter ([id]) -> id isnt idToClear window.fakeSetInterval = (callback, ms) -> id = ++window.intervalCount @@ -359,7 +378,7 @@ window.pagePixelPositionForPoint = (editorView, point) -> point = Point.fromObject point top = editorView.renderedLines.offset().top + point.row * editorView.lineHeight left = editorView.renderedLines.offset().left + point.column * editorView.charWidth - editorView.renderedLines.scrollLeft() - { top, left } + {top, left} window.tokensText = (tokens) -> _.pluck(tokens, 'value').join('') @@ -370,7 +389,7 @@ window.setEditorWidthInChars = (editorView, widthInChars, charWidth=editorView.c window.setEditorHeightInLines = (editorView, heightInLines, lineHeight=editorView.lineHeight) -> editorView.height(editorView.getEditor().getLineHeightInPixels() * heightInLines) - editorView.component?.measureHeightAndWidth() + editorView.component?.measureDimensions() $.fn.resultOfTrigger = (type) -> event = $.Event(type) diff --git a/spec/task-spec.coffee b/spec/task-spec.coffee index 81a8713ad..947db5567 100644 --- a/spec/task-spec.coffee +++ b/spec/task-spec.coffee @@ -57,3 +57,42 @@ describe "Task", -> expect(deprecations.length).toBe 1 expect(deprecations[0].getStacks()[0][1].fileName).toBe handlerPath jasmine.restoreDeprecationsSnapshot() + + it "adds data listeners to standard out and error to report output", -> + task = new Task(require.resolve('./fixtures/task-spec-handler')) + {stdout, stderr} = task.childProcess + + task.start() + task.start() + expect(stdout.listeners('data').length).toBe 1 + expect(stderr.listeners('data').length).toBe 1 + + task.terminate() + expect(stdout.listeners('data').length).toBe 0 + expect(stderr.listeners('data').length).toBe 0 + + describe "::cancel()", -> + it "dispatches 'task:cancelled' when invoked on an active task", -> + task = new Task(require.resolve('./fixtures/task-spec-handler')) + cancelledEventSpy = jasmine.createSpy('eventSpy') + task.on('task:cancelled', cancelledEventSpy) + completedEventSpy = jasmine.createSpy('eventSpy') + task.on('task:completed', completedEventSpy) + + expect(task.cancel()).toBe(true) + expect(cancelledEventSpy).toHaveBeenCalled() + expect(completedEventSpy).not.toHaveBeenCalled() + + it "does not dispatch 'task:cancelled' when invoked on an inactive task", -> + handlerResult = null + task = Task.once require.resolve('./fixtures/task-spec-handler'), (result) -> + handlerResult = result + + waitsFor -> + handlerResult? + + runs -> + cancelledEventSpy = jasmine.createSpy('eventSpy') + task.on('task:cancelled', cancelledEventSpy) + expect(task.cancel()).toBe(false) + expect(cancelledEventSpy).not.toHaveBeenCalled() diff --git a/spec/text-editor-component-spec.coffee b/spec/text-editor-component-spec.coffee index 86aab0856..358fd099f 100644 --- a/spec/text-editor-component-spec.coffee +++ b/spec/text-editor-component-spec.coffee @@ -7,10 +7,10 @@ nbsp = String.fromCharCode(160) describe "TextEditorComponent", -> [contentNode, editor, wrapperView, wrapperNode, component, componentNode, verticalScrollbarNode, horizontalScrollbarNode] = [] - [lineHeightInPixels, charWidth, nextAnimationFrame, noAnimationFrame, lineOverdrawMargin] = [] + [lineHeightInPixels, charWidth, nextAnimationFrame, noAnimationFrame, tileSize, tileHeightInPixels] = [] beforeEach -> - lineOverdrawMargin = 2 + tileSize = 3 waitsForPromise -> atom.packages.activatePackage('language-javascript') @@ -34,7 +34,7 @@ describe "TextEditorComponent", -> contentNode = document.querySelector('#jasmine-content') contentNode.style.width = '1000px' - wrapperView = new TextEditorView(editor, {lineOverdrawMargin}) + wrapperView = new TextEditorView(editor, {tileSize}) wrapperView.attachToDom() wrapperNode = wrapperView.element wrapperNode.setUpdatedSynchronously(false) @@ -45,64 +45,146 @@ describe "TextEditorComponent", -> component.setFontSize(20) lineHeightInPixels = editor.getLineHeightInPixels() + tileHeightInPixels = tileSize * lineHeightInPixels charWidth = editor.getDefaultCharWidth() - componentNode = component.domNode + componentNode = component.getDomNode() verticalScrollbarNode = componentNode.querySelector('.vertical-scrollbar') horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar') - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() afterEach -> contentNode.style.width = '' - describe "line rendering", -> - it "renders the currently-visible lines plus the overdraw margin", -> - wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + describe "async updates", -> + it "handles corrupted state gracefully", -> + # trigger state updates, e.g. presenter.updateLinesState + editor.insertNewline() + + # simulate state corruption + component.presenter.startRow = -1 + component.presenter.endRow = 9999 + + expect(nextAnimationFrame).not.toThrow() + + it "doesn't update when an animation frame was requested but the component got destroyed before its delivery", -> + editor.setText("You shouldn't see this update.") + expect(nextAnimationFrame).not.toBe(noAnimationFrame) + + component.destroy() nextAnimationFrame() - linesNode = componentNode.querySelector('.lines') - expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" - expect(componentNode.querySelectorAll('.line').length).toBe 6 + 2 # no margin above - expect(component.lineNodeForScreenRow(0).textContent).toBe editor.tokenizedLineForScreenRow(0).text - expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 - expect(component.lineNodeForScreenRow(5).textContent).toBe editor.tokenizedLineForScreenRow(5).text - expect(component.lineNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels + expect(component.lineNodeForScreenRow(0).textContent).not.toBe("You shouldn't see this update.") - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels + describe "line rendering", -> + expectTileContainsRow = (tileNode, screenRow, {top}) -> + lineNode = tileNode.querySelector("[data-screen-row='#{screenRow}']") + tokenizedLine = editor.tokenizedLineForScreenRow(screenRow) + + expect(lineNode.offsetTop).toBe(top) + if tokenizedLine.text is "" + expect(lineNode.innerHTML).toBe(" ") + else + expect(lineNode.textContent).toBe(tokenizedLine.text) + + it "renders the currently-visible lines in a tiled fashion", -> + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + component.measureDimensions() + nextAnimationFrame() + + tilesNodes = componentNode.querySelector(".lines").querySelectorAll(".tile") + + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + expect(tilesNodes[0].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[0], 0, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 1, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 2, top: 2 * lineHeightInPixels) + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels}px, 0px)" + expect(tilesNodes[1].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[1], 3, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 4, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 5, top: 2 * lineHeightInPixels) + + expect(tilesNodes[2].style['-webkit-transform']).toBe "translate3d(0px, #{2 * tileHeightInPixels}px, 0px)" + expect(tilesNodes[2].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[2], 6, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 7, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 8, top: 2 * lineHeightInPixels) + + expect(component.lineNodeForScreenRow(9)).toBeUndefined() + + verticalScrollbarNode.scrollTop = tileSize * lineHeightInPixels + 5 verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) nextAnimationFrame() - expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, #{-4.5 * lineHeightInPixels}px, 0px)" - expect(componentNode.querySelectorAll('.line').length).toBe 6 + 4 # margin above and below - expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels - expect(component.lineNodeForScreenRow(2).textContent).toBe editor.tokenizedLineForScreenRow(2).text - expect(component.lineNodeForScreenRow(9).offsetTop).toBe 9 * lineHeightInPixels - expect(component.lineNodeForScreenRow(9).textContent).toBe editor.tokenizedLineForScreenRow(9).text + tilesNodes = componentNode.querySelector(".lines").querySelectorAll(".tile") - it "updates the top position of subsequent lines when lines are inserted or removed", -> + expect(component.lineNodeForScreenRow(2)).toBeUndefined() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, #{0 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[0].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[0], 3, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 4, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 5, top: 2 * lineHeightInPixels) + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[1].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[1], 6, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 7, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 8, top: 2 * lineHeightInPixels) + + expect(tilesNodes[2].style['-webkit-transform']).toBe "translate3d(0px, #{2 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[2].querySelectorAll(".line").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[2], 9, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 10, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 11, top: 2 * lineHeightInPixels) + + it "updates the top position of subsequent tiles when lines are inserted or removed", -> + wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px' + component.measureDimensions() editor.getBuffer().deleteRows(0, 1) nextAnimationFrame() - lineNodes = componentNode.querySelectorAll('.line') - expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 - expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels - expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels + tilesNodes = componentNode.querySelector(".lines").querySelectorAll(".tile") + + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + expectTileContainsRow(tilesNodes[0], 0, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 1, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 2, top: 2 * lineHeightInPixels) + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels}px, 0px)" + expectTileContainsRow(tilesNodes[1], 3, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 4, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 5, top: 2 * lineHeightInPixels) editor.getBuffer().insert([0, 0], '\n\n') nextAnimationFrame() - lineNodes = componentNode.querySelectorAll('.line') - expect(component.lineNodeForScreenRow(0).offsetTop).toBe 0 * lineHeightInPixels - expect(component.lineNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels - expect(component.lineNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels - expect(component.lineNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels - expect(component.lineNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels + tilesNodes = componentNode.querySelector(".lines").querySelectorAll(".tile") + + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + expectTileContainsRow(tilesNodes[0], 0, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 1, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[0], 2, top: 2 * lineHeightInPixels) + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels}px, 0px)" + expectTileContainsRow(tilesNodes[1], 3, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 4, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[1], 5, top: 2 * lineHeightInPixels) + + expect(tilesNodes[2].style['-webkit-transform']).toBe "translate3d(0px, #{2 * tileHeightInPixels}px, 0px)" + expectTileContainsRow(tilesNodes[2], 6, top: 0 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 7, top: 1 * lineHeightInPixels) + expectTileContainsRow(tilesNodes[2], 8, top: 2 * lineHeightInPixels) it "updates the lines when lines are inserted or removed above the rendered row range", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() verticalScrollbarNode.scrollTop = 5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) @@ -152,7 +234,7 @@ describe "TextEditorComponent", -> it "renders the .lines div at the full height of the editor if there aren't enough lines to scroll vertically", -> editor.setText('') wrapperNode.style.height = '300px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() linesNode = componentNode.querySelector('.lines') @@ -164,7 +246,7 @@ describe "TextEditorComponent", -> lineNodes = componentNode.querySelectorAll('.line') componentNode.style.width = gutterWidth + (30 * charWidth) + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(editor.getScrollWidth()).toBeGreaterThan scrollViewNode.offsetWidth @@ -172,11 +254,13 @@ describe "TextEditorComponent", -> # lines caused full-screen repaints after switching away from an editor # and back again Please ensure you don't cause a performance regression if # you change this behavior. + editorFullWidth = editor.getScrollWidth() + editor.getVerticalScrollbarWidth() + for lineNode in lineNodes - expect(lineNode.style.width).toBe editor.getScrollWidth() + 'px' + expect(lineNode.style.width).toBe editorFullWidth + 'px' componentNode.style.width = gutterWidth + editor.getScrollWidth() + 100 + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() scrollViewWidth = scrollViewNode.offsetWidth @@ -187,16 +271,22 @@ describe "TextEditorComponent", -> atom.config.set("editor.showInvisibles", false) expect(component.lineNodeForScreenRow(10).textContent).toBe nbsp - it "gives the lines div the same background color as the editor to improve GPU performance", -> + it "gives the lines and tiles divs the same background color as the editor to improve GPU performance", -> linesNode = componentNode.querySelector('.lines') backgroundColor = getComputedStyle(wrapperNode).backgroundColor expect(linesNode.style.backgroundColor).toBe backgroundColor + for tileNode in linesNode.querySelectorAll(".tile") + expect(tileNode.style.backgroundColor).toBe(backgroundColor) + wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)' advanceClock(atom.views.documentPollingInterval) nextAnimationFrame() expect(linesNode.style.backgroundColor).toBe 'rgb(255, 0, 0)' + for tileNode in linesNode.querySelectorAll(".tile") + expect(tileNode.style.backgroundColor).toBe("rgb(255, 0, 0)") + it "applies .leading-whitespace for lines with leading spaces and/or tabs", -> editor.setText(' a') @@ -328,7 +418,7 @@ describe "TextEditorComponent", -> editor.setSoftWrapped(true) nextAnimationFrame() componentNode.style.width = 16 * charWidth + editor.getVerticalScrollbarWidth() + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() it "doesn't show end of line invisibles at the end of wrapped lines", -> @@ -467,62 +557,108 @@ describe "TextEditorComponent", -> expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy() describe "gutter rendering", -> - it "renders the currently-visible line numbers", -> + expectTileContainsRow = (tileNode, screenRow, {top, text}) -> + lineNode = tileNode.querySelector("[data-screen-row='#{screenRow}']") + + expect(lineNode.offsetTop).toBe(top) + expect(lineNode.textContent).toBe(text) + + it "renders the currently-visible line numbers in a tiled fashion", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() - expect(componentNode.querySelectorAll('.line-number').length).toBe 6 + 2 + 1 # line overdraw margin below + dummy line number - expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1" - expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}6" + tilesNodes = componentNode.querySelector(".line-numbers").querySelectorAll(".tile") - verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + expect(tilesNodes.length).toBe(3) + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + + expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe 3 + expectTileContainsRow(tilesNodes[0], 0, top: lineHeightInPixels * 0, text: "#{nbsp}1") + expectTileContainsRow(tilesNodes[0], 1, top: lineHeightInPixels * 1, text: "#{nbsp}2") + expectTileContainsRow(tilesNodes[0], 2, top: lineHeightInPixels * 2, text: "#{nbsp}3") + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels}px, 0px)" + expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe 3 + expectTileContainsRow(tilesNodes[1], 3, top: lineHeightInPixels * 0, text: "#{nbsp}4") + expectTileContainsRow(tilesNodes[1], 4, top: lineHeightInPixels * 1, text: "#{nbsp}5") + expectTileContainsRow(tilesNodes[1], 5, top: lineHeightInPixels * 2, text: "#{nbsp}6") + + expect(tilesNodes[2].style['-webkit-transform']).toBe "translate3d(0px, #{2 * tileHeightInPixels}px, 0px)" + expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe 3 + expectTileContainsRow(tilesNodes[2], 6, top: lineHeightInPixels * 0, text: "#{nbsp}7") + expectTileContainsRow(tilesNodes[2], 7, top: lineHeightInPixels * 1, text: "#{nbsp}8") + expectTileContainsRow(tilesNodes[2], 8, top: lineHeightInPixels * 2, text: "#{nbsp}9") + + verticalScrollbarNode.scrollTop = tileSize * lineHeightInPixels + 5 verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) nextAnimationFrame() - expect(componentNode.querySelectorAll('.line-number').length).toBe 6 + 4 + 1 # line overdraw margin above/below + dummy line number + tilesNodes = componentNode.querySelector(".line-numbers").querySelectorAll(".tile") - expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}3" - expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(7).textContent).toBe "#{nbsp}8" - expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe 7 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(2)).toBeUndefined() + expect(tilesNodes.length).toBe(3) + + expect(tilesNodes[0].style['-webkit-transform']).toBe "translate3d(0px, #{0 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[0].querySelectorAll(".line-number").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[0], 3, top: lineHeightInPixels * 0, text: "#{nbsp}4") + expectTileContainsRow(tilesNodes[0], 4, top: lineHeightInPixels * 1, text: "#{nbsp}5") + expectTileContainsRow(tilesNodes[0], 5, top: lineHeightInPixels * 2, text: "#{nbsp}6") + + expect(tilesNodes[1].style['-webkit-transform']).toBe "translate3d(0px, #{1 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[1].querySelectorAll(".line-number").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[1], 6, top: 0 * lineHeightInPixels, text: "#{nbsp}7") + expectTileContainsRow(tilesNodes[1], 7, top: 1 * lineHeightInPixels, text: "#{nbsp}8") + expectTileContainsRow(tilesNodes[1], 8, top: 2 * lineHeightInPixels, text: "#{nbsp}9") + + expect(tilesNodes[2].style['-webkit-transform']).toBe "translate3d(0px, #{2 * tileHeightInPixels - 5}px, 0px)" + expect(tilesNodes[2].querySelectorAll(".line-number").length).toBe(tileSize) + expectTileContainsRow(tilesNodes[2], 9, top: 0 * lineHeightInPixels, text: "10") + expectTileContainsRow(tilesNodes[2], 10, top: 1 * lineHeightInPixels, text: "11") + expectTileContainsRow(tilesNodes[2], 11, top: 2 * lineHeightInPixels, text: "12") it "updates the translation of subsequent line numbers when lines are inserted or removed", -> editor.getBuffer().insert([0, 0], '\n\n') nextAnimationFrame() lineNumberNodes = componentNode.querySelectorAll('.line-number') - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 * lineHeightInPixels expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 0 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe 2 * lineHeightInPixels editor.getBuffer().insert([0, 0], '\n\n') nextAnimationFrame() - expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 + expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe 0 * lineHeightInPixels expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe 1 * lineHeightInPixels expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe 2 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 3 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 4 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe 5 * lineHeightInPixels - expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe 6 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe 0 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe 2 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe 0 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe 1 * lineHeightInPixels + expect(component.lineNumberNodeForScreenRow(8).offsetTop).toBe 2 * lineHeightInPixels it "renders • characters for soft-wrapped lines", -> editor.setSoftWrapped(true) wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 30 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() - expect(componentNode.querySelectorAll('.line-number').length).toBe 6 + lineOverdrawMargin + 1 # 1 dummy line componentNode + expect(componentNode.querySelectorAll('.line-number').length).toBe 9 + 1 # 3 line-numbers tiles + 1 dummy line expect(component.lineNumberNodeForScreenRow(0).textContent).toBe "#{nbsp}1" expect(component.lineNumberNodeForScreenRow(1).textContent).toBe "#{nbsp}•" expect(component.lineNumberNodeForScreenRow(2).textContent).toBe "#{nbsp}2" expect(component.lineNumberNodeForScreenRow(3).textContent).toBe "#{nbsp}•" expect(component.lineNumberNodeForScreenRow(4).textContent).toBe "#{nbsp}3" expect(component.lineNumberNodeForScreenRow(5).textContent).toBe "#{nbsp}•" + expect(component.lineNumberNodeForScreenRow(6).textContent).toBe "#{nbsp}4" + expect(component.lineNumberNodeForScreenRow(7).textContent).toBe "#{nbsp}•" + expect(component.lineNumberNodeForScreenRow(8).textContent).toBe "#{nbsp}•" it "pads line numbers to be right-justified based on the maximum number of line number digits", -> editor.getBuffer().setText([1..10].join('\n')) @@ -551,7 +687,7 @@ describe "TextEditorComponent", -> it "renders the .line-numbers div at the full height of the editor even if it's taller than its content", -> wrapperNode.style.height = componentNode.offsetHeight + 100 + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(componentNode.querySelector('.line-numbers').offsetHeight).toBe componentNode.offsetHeight @@ -560,35 +696,39 @@ describe "TextEditorComponent", -> lineNumbersNode = gutterNode.querySelector('.line-numbers') {backgroundColor} = getComputedStyle(wrapperNode) expect(lineNumbersNode.style.backgroundColor).toBe backgroundColor + for tileNode in lineNumbersNode.querySelectorAll(".tile") + expect(tileNode.style.backgroundColor).toBe(backgroundColor) # favor gutter color if it's assigned gutterNode.style.backgroundColor = 'rgb(255, 0, 0)' advanceClock(atom.views.documentPollingInterval) nextAnimationFrame() expect(lineNumbersNode.style.backgroundColor).toBe 'rgb(255, 0, 0)' + for tileNode in lineNumbersNode.querySelectorAll(".tile") + expect(tileNode.style.backgroundColor).toBe("rgb(255, 0, 0)") - it "hides or shows the gutter based on the '::isGutterVisible' property on the model and the global 'editor.showLineNumbers' config setting", -> - expect(component.gutterComponent?).toBe true + it "hides or shows the gutter based on the '::isLineNumberGutterVisible' property on the model and the global 'editor.showLineNumbers' config setting", -> + expect(component.gutterContainerComponent.getLineNumberGutterComponent()?).toBe true - editor.setGutterVisible(false) + editor.setLineNumberGutterVisible(false) nextAnimationFrame() - expect(componentNode.querySelector('.gutter')).toBeNull() + expect(componentNode.querySelector('.gutter').style.display).toBe 'none' atom.config.set("editor.showLineNumbers", false) nextAnimationFrame() - expect(componentNode.querySelector('.gutter')).toBeNull() + expect(componentNode.querySelector('.gutter').style.display).toBe 'none' - editor.setGutterVisible(true) + editor.setLineNumberGutterVisible(true) nextAnimationFrame() - expect(componentNode.querySelector('.gutter')).toBeNull() + expect(componentNode.querySelector('.gutter').style.display).toBe 'none' atom.config.set("editor.showLineNumbers", true) nextAnimationFrame() - expect(componentNode.querySelector('.gutter')).toBeDefined() + expect(componentNode.querySelector('.gutter').style.display).toBe '' expect(component.lineNumberNodeForScreenRow(3)?).toBe true describe "fold decorations", -> @@ -642,7 +782,7 @@ describe "TextEditorComponent", -> editor.setSoftWrapped(true) nextAnimationFrame() componentNode.style.width = 16 * charWidth + editor.getVerticalScrollbarWidth() + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() it "doesn't add the foldable class for soft-wrapped lines", -> @@ -658,6 +798,16 @@ describe "TextEditorComponent", -> beforeEach -> gutterNode = componentNode.querySelector('.gutter') + describe "when the component is destroyed", -> + it "stops listening for folding events", -> + component.destroy() + + lineNumber = component.lineNumberNodeForScreenRow(1) + target = lineNumber.querySelector('.icon-right') + target.dispatchEvent(buildClickEvent(target)) + + expect(nextAnimationFrame).toBe(noAnimationFrame) + it "folds and unfolds the block represented by the fold indicator when clicked", -> expect(lineNumberHasClass(1, 'folded')).toBe false @@ -682,11 +832,11 @@ describe "TextEditorComponent", -> describe "cursor rendering", -> it "renders the currently visible cursors", -> cursor1 = editor.getLastCursor() - cursor1.setScreenPosition([0, 5]) + cursor1.setScreenPosition([0, 5], autoscroll: false) wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() cursorNodes = componentNode.querySelectorAll('.cursor') @@ -695,8 +845,8 @@ describe "TextEditorComponent", -> expect(cursorNodes[0].offsetWidth).toBe charWidth expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{5 * charWidth}px, #{0 * lineHeightInPixels}px)" - cursor2 = editor.addCursorAtScreenPosition([8, 11]) - cursor3 = editor.addCursorAtScreenPosition([4, 10]) + cursor2 = editor.addCursorAtScreenPosition([8, 11], autoscroll: false) + cursor3 = editor.addCursorAtScreenPosition([4, 10], autoscroll: false) nextAnimationFrame() cursorNodes = componentNode.querySelectorAll('.cursor') @@ -714,13 +864,13 @@ describe "TextEditorComponent", -> cursorNodes = componentNode.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 2 - expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{10 * charWidth}px, #{4 * lineHeightInPixels}px)" - expect(cursorNodes[1].style['-webkit-transform']).toBe "translate(#{11 * charWidth}px, #{8 * lineHeightInPixels}px)" + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{10 * charWidth - horizontalScrollbarNode.scrollLeft}px, #{4 * lineHeightInPixels - verticalScrollbarNode.scrollTop}px)" + expect(cursorNodes[1].style['-webkit-transform']).toBe "translate(#{11 * charWidth - horizontalScrollbarNode.scrollLeft}px, #{8 * lineHeightInPixels - verticalScrollbarNode.scrollTop}px)" - wrapperView.on 'cursor:moved', cursorMovedListener = jasmine.createSpy('cursorMovedListener') + editor.onDidChangeCursorPosition cursorMovedListener = jasmine.createSpy('cursorMovedListener') cursor3.setScreenPosition([4, 11], autoscroll: false) nextAnimationFrame() - expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{11 * charWidth}px, #{4 * lineHeightInPixels}px)" + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{11 * charWidth - horizontalScrollbarNode.scrollLeft}px, #{4 * lineHeightInPixels - verticalScrollbarNode.scrollTop}px)" expect(cursorMovedListener).toHaveBeenCalled() cursor3.destroy() @@ -728,7 +878,7 @@ describe "TextEditorComponent", -> cursorNodes = componentNode.querySelectorAll('.cursor') expect(cursorNodes.length).toBe 1 - expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{11 * charWidth}px, #{8 * lineHeightInPixels}px)" + expect(cursorNodes[0].style['-webkit-transform']).toBe "translate(#{11 * charWidth - horizontalScrollbarNode.scrollLeft}px, #{8 * lineHeightInPixels - verticalScrollbarNode.scrollTop}px)" it "accounts for character widths when positioning cursors", -> atom.config.set('editor.fontFamily', 'sans-serif') @@ -883,14 +1033,15 @@ describe "TextEditorComponent", -> it "renders 2 regions for 2-line selections", -> editor.setSelectedScreenRange([[1, 6], [2, 10]]) nextAnimationFrame() - regions = componentNode.querySelectorAll('.selection .region') + tileNode = componentNode.querySelector(".lines").querySelectorAll(".tile")[0] + regions = tileNode.querySelectorAll('.selection .region') expect(regions.length).toBe 2 region1Rect = regions[0].getBoundingClientRect() expect(region1Rect.top).toBe 1 * lineHeightInPixels expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right + expect(region1Rect.right).toBe tileNode.getBoundingClientRect().right region2Rect = regions[1].getBoundingClientRect() expect(region2Rect.top).toBe 2 * lineHeightInPixels @@ -898,23 +1049,49 @@ describe "TextEditorComponent", -> expect(region2Rect.left).toBe scrollViewClientLeft + 0 expect(region2Rect.width).toBe 10 * charWidth - it "renders 3 regions for selections with more than 2 lines", -> - editor.setSelectedScreenRange([[1, 6], [5, 10]]) + it "renders 3 regions per tile for selections with more than 2 lines", -> + editor.setSelectedScreenRange([[0, 6], [5, 10]]) nextAnimationFrame() - regions = componentNode.querySelectorAll('.selection .region') - expect(regions.length).toBe 3 + + # Tile 0 + tileNode = componentNode.querySelector(".lines").querySelectorAll(".tile")[0] + regions = tileNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(3) region1Rect = regions[0].getBoundingClientRect() - expect(region1Rect.top).toBe 1 * lineHeightInPixels + expect(region1Rect.top).toBe 0 expect(region1Rect.height).toBe 1 * lineHeightInPixels expect(region1Rect.left).toBe scrollViewClientLeft + 6 * charWidth - expect(region1Rect.right).toBe scrollViewNode.getBoundingClientRect().right + expect(region1Rect.right).toBe tileNode.getBoundingClientRect().right region2Rect = regions[1].getBoundingClientRect() - expect(region2Rect.top).toBe 2 * lineHeightInPixels - expect(region2Rect.height).toBe 3 * lineHeightInPixels + expect(region2Rect.top).toBe 1 * lineHeightInPixels + expect(region2Rect.height).toBe 1 * lineHeightInPixels expect(region2Rect.left).toBe scrollViewClientLeft + 0 - expect(region2Rect.right).toBe scrollViewNode.getBoundingClientRect().right + expect(region2Rect.right).toBe tileNode.getBoundingClientRect().right + + region3Rect = regions[2].getBoundingClientRect() + expect(region3Rect.top).toBe 2 * lineHeightInPixels + expect(region3Rect.height).toBe 1 * lineHeightInPixels + expect(region3Rect.left).toBe scrollViewClientLeft + 0 + expect(region3Rect.right).toBe tileNode.getBoundingClientRect().right + + # Tile 3 + tileNode = componentNode.querySelector(".lines").querySelectorAll(".tile")[1] + regions = tileNode.querySelectorAll('.selection .region') + expect(regions.length).toBe(3) + + region1Rect = regions[0].getBoundingClientRect() + expect(region1Rect.top).toBe 3 * lineHeightInPixels + expect(region1Rect.height).toBe 1 * lineHeightInPixels + expect(region1Rect.left).toBe scrollViewClientLeft + 0 + expect(region1Rect.right).toBe tileNode.getBoundingClientRect().right + + region2Rect = regions[1].getBoundingClientRect() + expect(region2Rect.top).toBe 4 * lineHeightInPixels + expect(region2Rect.height).toBe 1 * lineHeightInPixels + expect(region2Rect.left).toBe scrollViewClientLeft + 0 + expect(region2Rect.right).toBe tileNode.getBoundingClientRect().right region3Rect = regions[2].getBoundingClientRect() expect(region3Rect.top).toBe 5 * lineHeightInPixels @@ -981,7 +1158,7 @@ describe "TextEditorComponent", -> # Shrink editor vertically wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() # Add decorations that are out of range @@ -990,7 +1167,7 @@ describe "TextEditorComponent", -> nextAnimationFrame() # Scroll decorations into view - verticalScrollbarNode.scrollTop = 2.5 * lineHeightInPixels + verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) nextAnimationFrame() expect(lineAndLineNumberHaveClass(9, 'b')).toBe true @@ -1005,7 +1182,7 @@ describe "TextEditorComponent", -> editor.setText("a line that wraps, ok") editor.setSoftWrapped(true) componentNode.style.width = 16 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() marker.destroy() @@ -1121,7 +1298,7 @@ describe "TextEditorComponent", -> it "does not render highlights for off-screen lines until they come on-screen", -> wrapperNode.style.height = 2.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() marker = editor.displayBuffer.markBufferRange([[9, 2], [9, 4]], invalidate: 'inside') @@ -1136,15 +1313,16 @@ describe "TextEditorComponent", -> # Nothing when outside the rendered row range expect(regions.length).toBe 0 - verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels + verticalScrollbarNode.scrollTop = 6 * lineHeightInPixels verticalScrollbarNode.dispatchEvent(new UIEvent('scroll')) nextAnimationFrame() + expect(component.presenter.endRow).toBeGreaterThan(8) regions = componentNode.querySelectorAll('.some-highlight .region') expect(regions.length).toBe 1 regionRect = regions[0].style - expect(regionRect.top).toBe 9 * lineHeightInPixels + 'px' + expect(regionRect.top).toBe (0 + 'px') expect(regionRect.height).toBe 1 * lineHeightInPixels + 'px' expect(regionRect.left).toBe 2 * charWidth + 'px' expect(regionRect.width).toBe 2 * charWidth + 'px' @@ -1188,10 +1366,10 @@ describe "TextEditorComponent", -> it "allows multiple space-delimited decoration classes", -> decoration.setProperties(type: 'highlight', class: 'foo bar') nextAnimationFrame() - expect(componentNode.querySelectorAll('.foo.bar').length).toBe 1 + expect(componentNode.querySelectorAll('.foo.bar').length).toBe 2 decoration.setProperties(type: 'highlight', class: 'bar baz') nextAnimationFrame() - expect(componentNode.querySelectorAll('.bar.baz').length).toBe 1 + expect(componentNode.querySelectorAll('.bar.baz').length).toBe 2 it "renders classes on the regions directly if 'deprecatedRegionClass' option is defined", -> decoration = editor.decorateMarker(marker, type: 'highlight', class: 'test-highlight', deprecatedRegionClass: 'test-highlight-region') @@ -1203,7 +1381,7 @@ describe "TextEditorComponent", -> describe "when flashing a decoration via Decoration::flash()", -> highlightNode = null beforeEach -> - highlightNode = componentNode.querySelector('.test-highlight') + highlightNode = componentNode.querySelectorAll('.test-highlight')[1] it "adds and removes the flash class specified in ::flash", -> expect(highlightNode.classList.contains('flash-class')).toBe false @@ -1239,13 +1417,15 @@ describe "TextEditorComponent", -> regionStyle = componentNode.querySelector('.test-highlight .region').style originalTop = parseInt(regionStyle.top) + expect(originalTop).toBe(2 * lineHeightInPixels) + editor.getBuffer().insert([0, 0], '\n') nextAnimationFrame() regionStyle = componentNode.querySelector('.test-highlight .region').style newTop = parseInt(regionStyle.top) - expect(newTop).toBe originalTop + lineHeightInPixels + expect(newTop).toBe(0) it "moves rendered highlights when the marker is manually moved", -> regionStyle = componentNode.querySelector('.test-highlight .region').style @@ -1255,7 +1435,7 @@ describe "TextEditorComponent", -> nextAnimationFrame() regionStyle = componentNode.querySelector('.test-highlight .region').style - expect(parseInt(regionStyle.top)).toBe 5 * lineHeightInPixels + expect(parseInt(regionStyle.top)).toBe 2 * lineHeightInPixels describe "when a decoration is updated via Decoration::update", -> it "renders the decoration's new params", -> @@ -1268,10 +1448,12 @@ describe "TextEditorComponent", -> expect(componentNode.querySelector('.new-test-highlight')).toBeTruthy() describe "overlay decoration rendering", -> - [item] = [] + [item, gutterWidth] = [] beforeEach -> item = document.createElement('div') item.classList.add 'overlay-test' + item.style.background = 'red' + gutterWidth = componentNode.querySelector('.gutter').offsetWidth describe "when the marker is empty", -> it "renders an overlay decoration when added and removes the overlay when the decoration is destroyed", -> @@ -1288,71 +1470,29 @@ describe "TextEditorComponent", -> overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test') expect(overlay).toBe null - it "renders in the correct position on initial display and when the marker moves", -> - editor.setCursorBufferPosition([2, 5]) - - marker = editor.getLastCursor().getMarker() - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([2, 5]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - editor.moveRight() - editor.moveRight() - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([2, 7]) - - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - describe "when the marker is not empty", -> it "renders at the head of the marker by default", -> marker = editor.displayBuffer.markBufferRange([[2, 5], [2, 10]], invalidate: 'never') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) nextAnimationFrame() + nextAnimationFrame() position = wrapperNode.pixelPositionForBufferPosition([2, 10]) overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - it "renders at the head of the marker when the marker is reversed", -> - marker = editor.displayBuffer.markBufferRange([[2, 5], [2, 10]], invalidate: 'never', reversed: true) - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([2, 5]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - it "renders at the tail of the marker when the 'position' option is 'tail'", -> - marker = editor.displayBuffer.markBufferRange([[2, 5], [2, 10]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', position: 'tail', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([2, 5]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' + expect(overlay.style.left).toBe position.left + gutterWidth + 'px' expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' describe "positioning the overlay when near the edge of the editor", -> - [itemWidth, itemHeight] = [] + [itemWidth, itemHeight, windowWidth, windowHeight] = [] beforeEach -> + atom.storeWindowDimensions() + itemWidth = 4 * editor.getDefaultCharWidth() itemHeight = 4 * editor.getLineHeightInPixels() - gutterWidth = componentNode.querySelector('.gutter').offsetWidth windowWidth = gutterWidth + 30 * editor.getDefaultCharWidth() - windowHeight = 9 * editor.getLineHeightInPixels() + windowHeight = 10 * editor.getLineHeightInPixels() item.style.width = itemWidth + 'px' item.style.height = itemHeight + 'px' @@ -1360,139 +1500,40 @@ describe "TextEditorComponent", -> wrapperNode.style.width = windowWidth + 'px' wrapperNode.style.height = windowHeight + 'px' - component.measureHeightAndWidth() + atom.setWindowDimensions({width: windowWidth, height: windowHeight}) + + component.measureDimensions() + component.measureWindowSize() nextAnimationFrame() - it "flips horizontally when near the right edge", -> + afterEach -> + atom.restoreWindowDimensions() + + # This spec should actually run on Linux as well, see TextEditorComponent#measureWindowSize for further information. + it "slides horizontally left when near the right edge on #win32 and #darwin", -> marker = editor.displayBuffer.markBufferRange([[0, 26], [0, 26]], invalidate: 'never') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) nextAnimationFrame() + nextAnimationFrame() position = wrapperNode.pixelPositionForBufferPosition([0, 26]) overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' + expect(overlay.style.left).toBe position.left + gutterWidth + 'px' expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' editor.insertText('a') nextAnimationFrame() - position = wrapperNode.pixelPositionForBufferPosition([0, 27]) - - expect(overlay.style.left).toBe position.left - itemWidth + 'px' + expect(overlay.style.left).toBe windowWidth - itemWidth + 'px' expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - it "flips vertically when near the bottom edge", -> - marker = editor.displayBuffer.markBufferRange([[4, 0], [4, 0]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + editor.insertText('b') nextAnimationFrame() - position = wrapperNode.pixelPositionForBufferPosition([4, 0]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' + expect(overlay.style.left).toBe windowWidth - itemWidth + 'px' expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - editor.insertNewline() - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([5, 0]) - - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top - itemHeight + 'px' - - describe "when the editor is very small", -> - beforeEach -> - gutterWidth = componentNode.querySelector('.gutter').offsetWidth - windowWidth = gutterWidth + 6 * editor.getDefaultCharWidth() - windowHeight = 6 * editor.getLineHeightInPixels() - - wrapperNode.style.width = windowWidth + 'px' - wrapperNode.style.height = windowHeight + 'px' - - component.measureHeightAndWidth() - nextAnimationFrame() - - it "does not flip horizontally and force the overlay to have a negative left", -> - marker = editor.displayBuffer.markBufferRange([[0, 2], [0, 2]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([0, 2]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - editor.insertText('a') - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([0, 3]) - - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - it "does not flip vertically and force the overlay to have a negative top", -> - marker = editor.displayBuffer.markBufferRange([[1, 0], [1, 0]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([1, 0]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - editor.insertNewline() - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([2, 0]) - - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - - describe "when editor scroll position is not 0", -> - it "flips horizontally when near the right edge", -> - editor.setScrollLeft(2 * editor.getDefaultCharWidth()) - marker = editor.displayBuffer.markBufferRange([[0, 28], [0, 28]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([0, 28]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - editor.insertText('a') - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([0, 29]) - - expect(overlay.style.left).toBe position.left - itemWidth + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - it "flips vertically when near the bottom edge", -> - editor.setScrollTop(2 * editor.getLineHeightInPixels()) - marker = editor.displayBuffer.markBufferRange([[6, 0], [6, 0]], invalidate: 'never') - decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([6, 0]) - - overlay = component.getTopmostDOMNode().querySelector('atom-overlay') - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top + editor.getLineHeightInPixels() + 'px' - - editor.insertNewline() - nextAnimationFrame() - - position = wrapperNode.pixelPositionForBufferPosition([7, 0]) - - expect(overlay.style.left).toBe position.left + 'px' - expect(overlay.style.top).toBe position.top - itemHeight + 'px' - describe "hidden input field", -> it "renders the hidden input field at the position of the last cursor if the cursor is on screen and the editor is focused", -> editor.setVerticalScrollMargin(0) @@ -1501,7 +1542,7 @@ describe "TextEditorComponent", -> inputNode = componentNode.querySelector('.hidden-input') wrapperNode.style.height = 5 * lineHeightInPixels + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(editor.getCursorScreenPosition()).toEqual [0, 0] @@ -1513,7 +1554,7 @@ describe "TextEditorComponent", -> expect(inputNode.offsetLeft).toBe 0 # In bounds, not focused - editor.setCursorBufferPosition([5, 4]) + editor.setCursorBufferPosition([5, 4], autoscroll: false) nextAnimationFrame() expect(inputNode.offsetTop).toBe 0 expect(inputNode.offsetLeft).toBe 0 @@ -1531,7 +1572,7 @@ describe "TextEditorComponent", -> expect(inputNode.offsetLeft).toBe 0 # Out of bounds, not focused - editor.setCursorBufferPosition([1, 2]) + editor.setCursorBufferPosition([1, 2], autoscroll: false) nextAnimationFrame() expect(inputNode.offsetTop).toBe 0 expect(inputNode.offsetLeft).toBe 0 @@ -1548,6 +1589,22 @@ describe "TextEditorComponent", -> beforeEach -> linesNode = componentNode.querySelector('.lines') + describe "when the mouse is single-clicked above the first line", -> + it "moves the cursor to the start of file buffer position", -> + editor.setText('foo') + editor.setCursorBufferPosition([0, 3]) + height = 4.5 * lineHeightInPixels + wrapperNode.style.height = height + 'px' + wrapperNode.style.width = 10 * charWidth + 'px' + component.measureDimensions() + nextAnimationFrame() + + coordinates = clientCoordinatesForScreenPosition([0, 2]) + coordinates.clientY = -1 + linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates)) + nextAnimationFrame() + expect(editor.getCursorScreenPosition()).toEqual [0, 0] + describe "when the mouse is single-clicked below the last line", -> it "moves the cursor to the end of file buffer position", -> editor.setText('foo') @@ -1555,7 +1612,7 @@ describe "TextEditorComponent", -> height = 4.5 * lineHeightInPixels wrapperNode.style.height = height + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() coordinates = clientCoordinatesForScreenPosition([0, 2]) @@ -1569,7 +1626,7 @@ describe "TextEditorComponent", -> it "moves the cursor to the nearest screen position", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() editor.setScrollTop(3.5 * lineHeightInPixels) editor.setScrollLeft(2 * charWidth) nextAnimationFrame() @@ -1586,11 +1643,28 @@ describe "TextEditorComponent", -> expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [5, 6]] describe "when the command key is held down", -> - it "adds a cursor at the nearest screen position", -> - editor.setCursorScreenPosition([3, 4]) - linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) - nextAnimationFrame() - expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + describe "the current cursor position and screen position do not match", -> + it "adds a cursor at the nearest screen position", -> + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), metaKey: true)) + nextAnimationFrame() + expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]], [[5, 6], [5, 6]]] + + describe "when there are multiple cursors, and one of the cursor's screen position is the same as the mouse click screen position", -> + it "removes a cursor at the mouse screen position", -> + editor.setCursorScreenPosition([3, 4]) + editor.addCursorAtScreenPosition([5, 2]) + editor.addCursorAtScreenPosition([7, 5]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), metaKey: true)) + nextAnimationFrame() + expect(editor.getSelectedScreenRanges()).toEqual [[[5, 2], [5, 2]], [[7, 5], [7, 5]]] + + describe "when there is a single cursor and the click occurs at the cursor's screen position", -> + it "neither adds a new cursor nor removes the current cursor", -> + editor.setCursorScreenPosition([3, 4]) + linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), metaKey: true)) + nextAnimationFrame() + expect(editor.getSelectedScreenRanges()).toEqual [[[3, 4], [3, 4]]] describe "when a non-folded line is double-clicked", -> describe "when no modifier keys are held down", -> @@ -1718,6 +1792,14 @@ describe "TextEditorComponent", -> beforeEach -> gutterNode = componentNode.querySelector('.gutter') + describe "when the component is destroyed", -> + it "stops listening for selection events", -> + component.destroy() + + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [0, 0]] + describe "when the gutter is clicked", -> it "selects the clicked row", -> gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4))) @@ -1845,6 +1927,135 @@ describe "TextEditorComponent", -> nextAnimationFrame() expect(editor.getSelectedScreenRange()).toEqual [[3, 4], [9, 0]] + describe "when soft wrap is enabled", -> + beforeEach -> + gutterNode = componentNode.querySelector('.gutter') + + editor.setSoftWrapped(true) + nextAnimationFrame() + componentNode.style.width = 21 * charWidth + editor.getVerticalScrollbarWidth() + 'px' + component.measureDimensions() + nextAnimationFrame() + + describe "when the gutter is clicked", -> + it "selects the clicked buffer row", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [2, 0]] + + describe "when the gutter is meta-clicked", -> + it "creates a new selection for the clicked buffer row", -> + editor.setSelectedScreenRange([[1, 0], [1, 2]]) + + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[1, 0], [1, 2]], [[2, 0], [5, 0]]] + + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[1, 0], [1, 2]], [[2, 0], [5, 0]], [[5, 0], [10, 0]]] + + describe "when the gutter is shift-clicked", -> + beforeEach -> + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + + describe "when the clicked row is before the current selection's tail", -> + it "selects to the beginning of the clicked buffer row", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), shiftKey: true)) + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [7, 4]] + + describe "when the clicked row is after the current selection's tail", -> + it "selects to the beginning of the buffer row following the clicked buffer row", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), shiftKey: true)) + expect(editor.getSelectedScreenRange()).toEqual [[7, 4], [16, 0]] + + describe "when the gutter is clicked and dragged", -> + describe "when dragging downward", -> + it "selects the buffer rows between the start and end of the drag", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6))) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6))) + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [10, 0]] + + describe "when dragging upward", -> + it "selects the buffer rows between the start and end of the drag", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6))) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(1))) + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [10, 0]] + + describe "when the gutter is meta-clicked and dragged", -> + beforeEach -> + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + + describe "when dragging downward", -> + it "selects the buffer rows between the start and end of the drag", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), metaKey: true)) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3), metaKey: true)) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(3), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[7, 4], [7, 6]], [[0, 0], [5, 0]]] + + it "merges overlapping selections", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), metaKey: true)) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7), metaKey: true)) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(7), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[0, 0], [10, 0]]] + + describe "when dragging upward", -> + it "selects the buffer rows between the start and end of the drag", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), metaKey: true)) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11), metaKey: true)) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(11), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[7, 4], [7, 6]], [[10, 0], [19, 0]]] + + it "merges overlapping selections", -> + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), metaKey: true)) + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(9), metaKey: true)) + nextAnimationFrame() + gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(9), metaKey: true)) + expect(editor.getSelectedScreenRanges()).toEqual [[[5, 0], [19, 0]]] + + describe "when the gutter is shift-clicked and dragged", -> + describe "when the shift-click is below the existing selection's tail", -> + describe "when dragging downward", -> + it "selects the buffer rows between the existing selection's tail and the end of the drag", -> + editor.setSelectedScreenRange([[1, 4], [1, 7]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), shiftKey: true)) + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11))) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[1, 4], [16, 0]] + + describe "when dragging upward", -> + it "selects the buffer rows between the end of the drag and the tail of the existing selection", -> + editor.setSelectedScreenRange([[1, 4], [1, 7]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), shiftKey: true)) + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7))) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[1, 4], [10, 0]] + + describe "when the shift-click is above the existing selection's tail", -> + describe "when dragging upward", -> + it "selects the buffer rows between the end of the drag and the tail of the existing selection", -> + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(3), shiftKey: true)) + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1))) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[0, 0], [7, 4]] + + describe "when dragging downward", -> + it "selects the buffer rows between the existing selection's tail and the end of the drag", -> + editor.setSelectedScreenRange([[7, 4], [7, 6]]) + gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), shiftKey: true)) + + gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3))) + nextAnimationFrame() + expect(editor.getSelectedScreenRange()).toEqual [[2, 0], [7, 4]] + describe "focus handling", -> inputNode = null @@ -1889,7 +2100,7 @@ describe "TextEditorComponent", -> describe "scrolling", -> it "updates the vertical scrollbar when the scrollTop is changed in the model", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.scrollTop).toBe 0 @@ -1900,21 +2111,31 @@ describe "TextEditorComponent", -> it "updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model", -> componentNode.style.width = 30 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() - linesNode = componentNode.querySelector('.lines') - expect(linesNode.style['-webkit-transform']).toBe "translate3d(0px, 0px, 0px)" + tilesNodes = componentNode.querySelector(".lines").querySelectorAll(".tile") + + top = 0 + for tileNode in tilesNodes + expect(tileNode.style['-webkit-transform']).toBe "translate3d(0px, #{top}px, 0px)" + top += tileNode.offsetHeight + expect(horizontalScrollbarNode.scrollLeft).toBe 0 editor.setScrollLeft(100) nextAnimationFrame() - expect(linesNode.style['-webkit-transform']).toBe "translate3d(-100px, 0px, 0px)" + + top = 0 + for tileNode in tilesNodes + expect(tileNode.style['-webkit-transform']).toBe "translate3d(-100px, #{top}px, 0px)" + top += tileNode.offsetHeight + expect(horizontalScrollbarNode.scrollLeft).toBe 100 it "updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes", -> componentNode.style.width = 30 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(editor.getScrollLeft()).toBe 0 @@ -1927,7 +2148,7 @@ describe "TextEditorComponent", -> it "does not obscure the last line with the horizontal scrollbar", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() editor.setScrollBottom(editor.getScrollHeight()) nextAnimationFrame() lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow()) @@ -1937,7 +2158,7 @@ describe "TextEditorComponent", -> # Scroll so there's no space below the last line when the horizontal scrollbar disappears wrapperNode.style.width = 100 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom bottomOfEditor = componentNode.getBoundingClientRect().bottom @@ -1946,7 +2167,7 @@ describe "TextEditorComponent", -> it "does not obscure the last character of the longest line with the vertical scrollbar", -> wrapperNode.style.height = 7 * lineHeightInPixels + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() editor.setScrollLeft(Infinity) nextAnimationFrame() @@ -1960,21 +2181,21 @@ describe "TextEditorComponent", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = '1000px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.display).toBe '' expect(horizontalScrollbarNode.style.display).toBe 'none' componentNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.display).toBe '' expect(horizontalScrollbarNode.style.display).toBe '' wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.display).toBe 'none' @@ -1983,7 +2204,7 @@ describe "TextEditorComponent", -> it "makes the dummy scrollbar divs only as tall/wide as the actual scrollbars", -> wrapperNode.style.height = 4 * lineHeightInPixels + 'px' wrapperNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() atom.styles.addStyleSheet """ @@ -2012,21 +2233,21 @@ describe "TextEditorComponent", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = '1000px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.bottom).toBe '0px' expect(horizontalScrollbarNode.style.right).toBe verticalScrollbarNode.offsetWidth + 'px' expect(scrollbarCornerNode.style.display).toBe 'none' componentNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.bottom).toBe horizontalScrollbarNode.offsetHeight + 'px' expect(horizontalScrollbarNode.style.right).toBe verticalScrollbarNode.offsetWidth + 'px' expect(scrollbarCornerNode.style.display).toBe '' wrapperNode.style.height = 20 * lineHeightInPixels + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(verticalScrollbarNode.style.bottom).toBe horizontalScrollbarNode.offsetHeight + 'px' expect(horizontalScrollbarNode.style.right).toBe '0px' @@ -2035,7 +2256,7 @@ describe "TextEditorComponent", -> it "accounts for the width of the gutter in the scrollWidth of the horizontal scrollbar", -> gutterNode = componentNode.querySelector('.gutter') componentNode.style.width = 10 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(horizontalScrollbarNode.scrollWidth).toBe editor.getScrollWidth() @@ -2049,7 +2270,7 @@ describe "TextEditorComponent", -> beforeEach -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() it "updates the scrollLeft or scrollTop on mousewheel events depending on which delta is greater (x or y)", -> @@ -2093,7 +2314,7 @@ describe "TextEditorComponent", -> it "keeps the line on the DOM if it is scrolled off-screen", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() lineNode = componentNode.querySelector('.line') wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500) @@ -2106,7 +2327,7 @@ describe "TextEditorComponent", -> it "does not set the mouseWheelScreenRow if scrolling horizontally", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() lineNode = componentNode.querySelector('.line') wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 10, wheelDeltaY: 0) @@ -2149,7 +2370,7 @@ describe "TextEditorComponent", -> it "keeps the line number on the DOM if it is scrolled off-screen", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() lineNumberNode = componentNode.querySelectorAll('.line-number')[1] wheelEvent = new WheelEvent('mousewheel', wheelDeltaX: 0, wheelDeltaY: -500) @@ -2164,7 +2385,7 @@ describe "TextEditorComponent", -> wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px' wrapperNode.style.width = 20 * charWidth + 'px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() # try to scroll past the top, which is impossible @@ -2234,6 +2455,7 @@ describe "TextEditorComponent", -> expect(editor.lineTextForBufferRow(0)).toBe 'üvar quicksort = function () {' it "does not handle input events when input is disabled", -> + nextAnimationFrame = noAnimationFrame # This spec is flaky on the build machine, so this. component.setInputEnabled(false) componentNode.dispatchEvent(buildTextInputEvent(data: 'x', target: inputNode)) expect(nextAnimationFrame).toBe noAnimationFrame @@ -2254,7 +2476,7 @@ describe "TextEditorComponent", -> currentTime += 99 componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', bubbles: true, cancelable: true)) - currentTime += 100 + currentTime += 101 componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', bubbles: true, cancelable: true)) expect(editor.getText()).toBe "xy\nxy\nxy" @@ -2369,12 +2591,12 @@ describe "TextEditorComponent", -> hiddenParent.style.display = 'none' contentNode.appendChild(hiddenParent) - wrapperView = new TextEditorView(editor, {lineOverdrawMargin}) + wrapperView = new TextEditorView(editor, {tileSize}) wrapperNode = wrapperView.element wrapperView.appendTo(hiddenParent) {component} = wrapperView - componentNode = component.domNode + componentNode = component.getDomNode() expect(componentNode.querySelectorAll('.line').length).toBe 0 hiddenParent.style.display = 'block' @@ -2427,7 +2649,7 @@ describe "TextEditorComponent", -> initialLineHeightInPixels = editor.getLineHeightInPixels() initialCharWidth = editor.getDefaultCharWidth() - component.setFontFamily('sans-serif') + component.setFontFamily('serif') expect(editor.getDefaultCharWidth()).toBe initialCharWidth wrapperView.show() @@ -2436,7 +2658,7 @@ describe "TextEditorComponent", -> it "does not re-measure character widths until the editor is shown again", -> wrapperView.hide() - component.setFontFamily('sans-serif') + component.setFontFamily('serif') wrapperView.show() editor.setCursorBufferPosition([0, Infinity]) @@ -2490,7 +2712,7 @@ describe "TextEditorComponent", -> advanceClock(atom.views.documentPollingInterval) nextAnimationFrame() - expect(componentNode.querySelectorAll('.line')).toHaveLength(4 + lineOverdrawMargin + 1) + expect(componentNode.querySelectorAll('.line')).toHaveLength(6) gutterWidth = componentNode.querySelector('.gutter').offsetWidth componentNode.style.width = gutterWidth + 14 * charWidth + editor.getVerticalScrollbarWidth() + 'px' @@ -2560,7 +2782,7 @@ describe "TextEditorComponent", -> describe "when the wrapper view has an explicit height", -> it "does not assign a height on the component node", -> wrapperNode.style.height = '200px' - component.measureHeightAndWidth() + component.measureDimensions() nextAnimationFrame() expect(componentNode.style.height).toBe '' @@ -2581,7 +2803,7 @@ describe "TextEditorComponent", -> expect(wrapperNode.classList.contains('mini')).toBe true it "does not have an opaque background on lines", -> - expect(component.linesComponent.domNode.getAttribute('style')).not.toContain 'background-color' + expect(component.linesComponent.getDomNode().getAttribute('style')).not.toContain 'background-color' it "does not render invisible characters", -> atom.config.set('editor.invisibles', eol: 'E') @@ -2606,7 +2828,7 @@ describe "TextEditorComponent", -> expect(componentNode.querySelector('.placeholder-text')).toBeNull() describe "legacy editor compatibility", -> - it "triggers the screen-lines-changed event before the editor:display-update event", -> + it "triggers the screen-lines-changed event before the editor:display-updated event", -> editor.setSoftWrapped(true) callingOrder = [] @@ -2615,7 +2837,7 @@ describe "TextEditorComponent", -> editor.insertText("HELLO! HELLO!\n HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! HELLO! ") nextAnimationFrame() - expect(callingOrder).toEqual ['screen-lines-changed', 'editor:display-updated'] + expect(callingOrder).toEqual ['screen-lines-changed', 'editor:display-updated', 'editor:display-updated'] it "works with the ::setEditorHeightInLines and ::setEditorWidthInChars helpers", -> setEditorHeightInLines(wrapperView, 7) @@ -2798,7 +3020,7 @@ describe "TextEditorComponent", -> clipboardWrittenTo = false spyOn(require('ipc'), 'send').andCallFake (eventName, selectedText) -> if eventName is 'write-text-to-selection-clipboard' - require('clipboard').writeText(selectedText, 'selection') + require('../src/safe-clipboard').writeText(selectedText, 'selection') clipboardWrittenTo = true atom.clipboard.write('') diff --git a/spec/text-editor-element-spec.coffee b/spec/text-editor-element-spec.coffee index f6082b571..ea5f55968 100644 --- a/spec/text-editor-element-spec.coffee +++ b/spec/text-editor-element-spec.coffee @@ -23,7 +23,7 @@ describe "TextEditorElement", -> it "honors the 'gutter-hidden' attribute", -> jasmineContent.innerHTML = "" element = jasmineContent.firstChild - expect(element.getModel().isGutterVisible()).toBe false + expect(element.getModel().isLineNumberGutterVisible()).toBe false it "honors the text content", -> jasmineContent.innerHTML = "testing" @@ -65,6 +65,35 @@ describe "TextEditorElement", -> element.getModel().destroy() expect(component.mounted).toBe false + describe "when the editor is detached from the DOM and then reattached", -> + it "does not render duplicate line numbers", -> + editor = new TextEditor + editor.setText('1\n2\n3') + element = atom.views.getView(editor) + + jasmine.attachToDOM(element) + + initialCount = element.shadowRoot.querySelectorAll('.line-number').length + + element.remove() + jasmine.attachToDOM(element) + expect(element.shadowRoot.querySelectorAll('.line-number').length).toBe initialCount + + it "does not render duplicate decorations in custom gutters", -> + editor = new TextEditor + editor.setText('1\n2\n3') + editor.addGutter({name: 'test-gutter'}) + marker = editor.markBufferRange([[0, 0], [2, 0]]) + editor.decorateMarker(marker, {type: 'gutter', gutterName: 'test-gutter'}) + element = atom.views.getView(editor) + + jasmine.attachToDOM(element) + initialDecorationCount = element.shadowRoot.querySelectorAll('.decoration').length + + element.remove() + jasmine.attachToDOM(element) + expect(element.shadowRoot.querySelectorAll('.decoration').length).toBe initialDecorationCount + describe "focus and blur handling", -> describe "when the editor.useShadowDOM config option is true", -> it "proxies focus/blur events to/from the hidden input inside the shadow root", -> diff --git a/spec/text-editor-presenter-spec.coffee b/spec/text-editor-presenter-spec.coffee index 4ad319cc1..5acbc447a 100644 --- a/spec/text-editor-presenter-spec.coffee +++ b/spec/text-editor-presenter-spec.coffee @@ -29,13 +29,16 @@ describe "TextEditorPresenter", -> model: editor explicitHeight: 130 contentFrameWidth: 500 + windowWidth: 500 + windowHeight: 130 + boundingClientRect: {left: 0, top: 0, width: 500, height: 130} + gutterWidth: 0 lineHeight: 10 baseCharacterWidth: 10 horizontalScrollbarHeight: 10 verticalScrollbarWidth: 10 scrollTop: 0 scrollLeft: 0 - lineOverdrawMargin: 0 new TextEditorPresenter(params) @@ -55,6 +58,157 @@ describe "TextEditorPresenter", -> expectNoStateUpdate = (presenter, fn) -> expectStateUpdatedToBe(false, presenter, fn) + tiledContentContract = (stateFn) -> + it "contains states for tiles that are visible on screen", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) + + expectValues stateFn(presenter).tiles[0], { + top: 0 + } + expectValues stateFn(presenter).tiles[2], { + top: 2 + } + expectValues stateFn(presenter).tiles[4], { + top: 4 + } + expectValues stateFn(presenter).tiles[6], { + top: 6 + } + + expect(stateFn(presenter).tiles[8]).toBeUndefined() + + expectStateUpdate presenter, -> presenter.setScrollTop(3) + + expect(stateFn(presenter).tiles[0]).toBeUndefined() + + expectValues stateFn(presenter).tiles[2], { + top: -1 + } + expectValues stateFn(presenter).tiles[4], { + top: 1 + } + expectValues stateFn(presenter).tiles[6], { + top: 3 + } + expectValues stateFn(presenter).tiles[8], { + top: 5 + } + expectValues stateFn(presenter).tiles[10], { + top: 7 + } + + expect(stateFn(presenter).tiles[12]).toBeUndefined() + + it "includes state for all tiles if no external ::explicitHeight is assigned", -> + presenter = buildPresenter(explicitHeight: null, tileSize: 2) + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[12]).toBeDefined() + + it "is empty until all of the required measurements are assigned", -> + presenter = buildPresenter(explicitHeight: null, lineHeight: null, scrollTop: null) + expect(stateFn(presenter).tiles).toEqual({}) + + presenter.setExplicitHeight(25) + expect(stateFn(presenter).tiles).toEqual({}) + + presenter.setLineHeight(10) + expect(stateFn(presenter).tiles).toEqual({}) + + presenter.setScrollTop(0) + expect(stateFn(presenter).tiles).not.toEqual({}) + + it "updates when ::scrollTop changes", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeUndefined() + + expectStateUpdate presenter, -> presenter.setScrollTop(2) + + expect(stateFn(presenter).tiles[0]).toBeUndefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeDefined() + expect(stateFn(presenter).tiles[10]).toBeUndefined() + + it "updates when ::explicitHeight changes", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeUndefined() + + expectStateUpdate presenter, -> presenter.setExplicitHeight(8) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeDefined() + expect(stateFn(presenter).tiles[10]).toBeUndefined() + + + it "updates when ::lineHeight changes", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeUndefined() + + expectStateUpdate presenter, -> presenter.setLineHeight(2) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeDefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeUndefined() + + it "does not remove out-of-view tiles corresponding to ::mouseWheelScreenRow until ::stoppedScrollingDelay elapses", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2, stoppedScrollingDelay: 200) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[6]).toBeDefined() + expect(stateFn(presenter).tiles[8]).toBeUndefined() + + presenter.setMouseWheelScreenRow(0) + expectStateUpdate presenter, -> presenter.setScrollTop(4) + + expect(stateFn(presenter).tiles[0]).toBeDefined() + expect(stateFn(presenter).tiles[2]).toBeUndefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[12]).toBeUndefined() + + expectStateUpdate presenter, -> advanceClock(200) + + expect(stateFn(presenter).tiles[0]).toBeUndefined() + expect(stateFn(presenter).tiles[2]).toBeUndefined() + expect(stateFn(presenter).tiles[4]).toBeDefined() + expect(stateFn(presenter).tiles[12]).toBeUndefined() + + + # should clear ::mouseWheelScreenRow after stoppedScrollingDelay elapses even if we don't scroll first + presenter.setMouseWheelScreenRow(4) + advanceClock(200) + expectStateUpdate presenter, -> presenter.setScrollTop(6) + expect(stateFn(presenter).tiles[4]).toBeUndefined() + + it "does not preserve deleted on-screen tiles even if they correspond to ::mouseWheelScreenRow", -> + presenter = buildPresenter(explicitHeight: 6, scrollTop: 0, lineHeight: 1, tileSize: 2, stoppedScrollingDelay: 200) + + presenter.setMouseWheelScreenRow(2) + + expectStateUpdate presenter, -> editor.setText("") + + expect(stateFn(presenter).tiles[2]).toBeUndefined() + expect(stateFn(presenter).tiles[0]).toBeDefined() + describe "during state retrieval", -> it "does not trigger onDidUpdateState events", -> presenter = buildPresenter() @@ -351,11 +505,11 @@ describe "TextEditorPresenter", -> expectValues presenter.getState().hiddenInput, {top: 0, left: 0} expectStateUpdate presenter, -> editor.setCursorBufferPosition([11, 43]) - expectValues presenter.getState().hiddenInput, {top: 50 - 10, left: 300 - 10} + expectValues presenter.getState().hiddenInput, {top: 11 * 10 - editor.getScrollTop(), left: 43 * 10 - editor.getScrollLeft()} newCursor = null expectStateUpdate presenter, -> newCursor = editor.addCursorAtBufferPosition([6, 10]) - expectValues presenter.getState().hiddenInput, {top: (6 * 10) - 40, left: (10 * 10) - 70} + expectValues presenter.getState().hiddenInput, {top: (6 * 10) - editor.getScrollTop(), left: (10 * 10) - editor.getScrollLeft()} expectStateUpdate presenter, -> newCursor.destroy() expectValues presenter.getState().hiddenInput, {top: 50 - 10, left: 300 - 10} @@ -495,6 +649,11 @@ describe "TextEditorPresenter", -> expect(presenter.getState().content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 + it "isn't clipped to 0 when the longest line is folded (regression)", -> + presenter = buildPresenter(contentFrameWidth: 50, baseCharacterWidth: 10) + editor.foldBufferRow(0) + expect(presenter.getState().content.scrollWidth).toBe 10 * editor.getMaxScreenLineLength() + 1 + describe ".scrollTop", -> it "tracks the value of ::scrollTop", -> presenter = buildPresenter(scrollTop: 10, lineHeight: 10, explicitHeight: 20) @@ -648,208 +807,117 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> editor.setPlaceholderText("new-placeholder-text") expect(presenter.getState().content.placeholderText).toBe "new-placeholder-text" - describe ".lines", -> - lineStateForScreenRow = (presenter, screenRow) -> - presenter.getState().content.lines[presenter.model.tokenizedLineForScreenRow(screenRow).id] + describe ".tiles", -> + lineStateForScreenRow = (presenter, row) -> + lineId = presenter.model.tokenizedLineForScreenRow(row).id + tileRow = presenter.tileForRow(row) + presenter.getState().content.tiles[tileRow]?.lines[lineId] - it "contains states for lines that are visible on screen, plus and minus the overdraw margin", -> - presenter = buildPresenter(explicitHeight: 15, scrollTop: 50, lineHeight: 10, lineOverdrawMargin: 1) + tiledContentContract (presenter) -> presenter.getState().content - expect(lineStateForScreenRow(presenter, 3)).toBeUndefined() + describe "[tileId].lines[lineId]", -> # line state objects + it "includes the state for visible lines in a tile", -> + presenter = buildPresenter(explicitHeight: 3, scrollTop: 4, lineHeight: 1, tileSize: 3, stoppedScrollingDelay: 200) - line4 = editor.tokenizedLineForScreenRow(4) - expectValues lineStateForScreenRow(presenter, 4), { - screenRow: 4 - text: line4.text - tokens: line4.tokens - top: 10 * 4 - } + expect(lineStateForScreenRow(presenter, 2)).toBeUndefined() - line5 = editor.tokenizedLineForScreenRow(5) - expectValues lineStateForScreenRow(presenter, 5), { - screenRow: 5 - text: line5.text - tokens: line5.tokens - top: 10 * 5 - } + line3 = editor.tokenizedLineForScreenRow(3) + expectValues lineStateForScreenRow(presenter, 3), { + screenRow: 3 + text: line3.text + tags: line3.tags + specialTokens: line3.specialTokens + firstNonWhitespaceIndex: line3.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line3.firstTrailingWhitespaceIndex + invisibles: line3.invisibles + top: 0 + } - line6 = editor.tokenizedLineForScreenRow(6) - expectValues lineStateForScreenRow(presenter, 6), { - screenRow: 6 - text: line6.text - tokens: line6.tokens - top: 10 * 6 - } + line4 = editor.tokenizedLineForScreenRow(4) + expectValues lineStateForScreenRow(presenter, 4), { + screenRow: 4 + text: line4.text + tags: line4.tags + specialTokens: line4.specialTokens + firstNonWhitespaceIndex: line4.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line4.firstTrailingWhitespaceIndex + invisibles: line4.invisibles + top: 1 + } - line7 = editor.tokenizedLineForScreenRow(7) - expectValues lineStateForScreenRow(presenter, 7), { - screenRow: 7 - text: line7.text - tokens: line7.tokens - top: 10 * 7 - } + line5 = editor.tokenizedLineForScreenRow(5) + expectValues lineStateForScreenRow(presenter, 5), { + screenRow: 5 + text: line5.text + tags: line5.tags + specialTokens: line5.specialTokens + firstNonWhitespaceIndex: line5.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line5.firstTrailingWhitespaceIndex + invisibles: line5.invisibles + top: 2 + } - line8 = editor.tokenizedLineForScreenRow(8) - expectValues lineStateForScreenRow(presenter, 8), { - screenRow: 8 - text: line8.text - tokens: line8.tokens - top: 10 * 8 - } + line6 = editor.tokenizedLineForScreenRow(6) + expectValues lineStateForScreenRow(presenter, 6), { + screenRow: 6 + text: line6.text + tags: line6.tags + specialTokens: line6.specialTokens + firstNonWhitespaceIndex: line6.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line6.firstTrailingWhitespaceIndex + invisibles: line6.invisibles + top: 0 + } - expect(lineStateForScreenRow(presenter, 9)).toBeUndefined() + line7 = editor.tokenizedLineForScreenRow(7) + expectValues lineStateForScreenRow(presenter, 7), { + screenRow: 7 + text: line7.text + tags: line7.tags + specialTokens: line7.specialTokens + firstNonWhitespaceIndex: line7.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line7.firstTrailingWhitespaceIndex + invisibles: line7.invisibles + top: 1 + } - it "does not overdraw above the first row", -> - presenter = buildPresenter(explicitHeight: 15, scrollTop: 10, lineHeight: 10, lineOverdrawMargin: 2) - expect(lineStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineStateForScreenRow(presenter, 1)).toBeDefined() - expect(lineStateForScreenRow(presenter, 2)).toBeDefined() - expect(lineStateForScreenRow(presenter, 3)).toBeDefined() - expect(lineStateForScreenRow(presenter, 4)).toBeDefined() - expect(lineStateForScreenRow(presenter, 5)).toBeDefined() - expect(lineStateForScreenRow(presenter, 6)).toBeUndefined() + line8 = editor.tokenizedLineForScreenRow(8) + expectValues lineStateForScreenRow(presenter, 8), { + screenRow: 8 + text: line8.text + tags: line8.tags + specialTokens: line8.specialTokens + firstNonWhitespaceIndex: line8.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line8.firstTrailingWhitespaceIndex + invisibles: line8.invisibles + top: 2 + } - it "does not overdraw below the last row", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 105, lineHeight: 10, lineOverdrawMargin: 2) - expect(lineStateForScreenRow(presenter, 7)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 8)).toBeDefined() - expect(lineStateForScreenRow(presenter, 9)).toBeDefined() - expect(lineStateForScreenRow(presenter, 10)).toBeDefined() - expect(lineStateForScreenRow(presenter, 11)).toBeDefined() - expect(lineStateForScreenRow(presenter, 12)).toBeDefined() + expect(lineStateForScreenRow(presenter, 9)).toBeUndefined() - it "includes state for all lines if no external ::explicitHeight is assigned", -> - presenter = buildPresenter(explicitHeight: null) - expect(lineStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineStateForScreenRow(presenter, 12)).toBeDefined() + it "updates when the editor's content changes", -> + presenter = buildPresenter(explicitHeight: 25, scrollTop: 10, lineHeight: 10, tileSize: 2) - it "is empty until all of the required measurements are assigned", -> - presenter = buildPresenter(explicitHeight: null, lineHeight: null, scrollTop: null) - expect(presenter.getState().content.lines).toEqual({}) + expectStateUpdate presenter, -> buffer.insert([2, 0], "hello\nworld\n") - presenter.setExplicitHeight(25) - expect(presenter.getState().content.lines).toEqual({}) + line1 = editor.tokenizedLineForScreenRow(1) + expectValues lineStateForScreenRow(presenter, 1), { + text: line1.text + tags: line1.tags + } - presenter.setLineHeight(10) - expect(presenter.getState().content.lines).toEqual({}) + line2 = editor.tokenizedLineForScreenRow(2) + expectValues lineStateForScreenRow(presenter, 2), { + text: line2.text + tags: line2.tags + } - presenter.setScrollTop(0) - expect(presenter.getState().content.lines).not.toEqual({}) + line3 = editor.tokenizedLineForScreenRow(3) + expectValues lineStateForScreenRow(presenter, 3), { + text: line3.text + tags: line3.tags + } - it "updates when ::scrollTop changes", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 1) - - expect(lineStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineStateForScreenRow(presenter, 4)).toBeDefined() - expect(lineStateForScreenRow(presenter, 5)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setScrollTop(25) - - expect(lineStateForScreenRow(presenter, 0)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 1)).toBeDefined() - expect(lineStateForScreenRow(presenter, 6)).toBeDefined() - expect(lineStateForScreenRow(presenter, 7)).toBeUndefined() - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter(explicitHeight: 15, scrollTop: 15, lineHeight: 10, lineOverdrawMargin: 1) - - line5 = editor.tokenizedLineForScreenRow(5) - - expect(lineStateForScreenRow(presenter, 4)).toBeDefined() - expect(lineStateForScreenRow(presenter, 5)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setExplicitHeight(35) - - expect(lineStateForScreenRow(presenter, 5)).toBeDefined() - expect(lineStateForScreenRow(presenter, 6)).toBeDefined() - expect(lineStateForScreenRow(presenter, 7)).toBeUndefined() - - it "updates when ::lineHeight changes", -> - presenter = buildPresenter(explicitHeight: 15, scrollTop: 10, lineHeight: 10, lineOverdrawMargin: 0) - - expect(lineStateForScreenRow(presenter, 0)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 1)).toBeDefined() - expect(lineStateForScreenRow(presenter, 2)).toBeDefined() - expect(lineStateForScreenRow(presenter, 4)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setLineHeight(5) - - expect(lineStateForScreenRow(presenter, 0)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 1)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 2)).toBeDefined() - expect(lineStateForScreenRow(presenter, 5)).toBeDefined() - expect(lineStateForScreenRow(presenter, 6)).toBeUndefined() - - it "updates when the editor's content changes", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 10, lineHeight: 10) - - expectStateUpdate presenter, -> buffer.insert([2, 0], "hello\nworld\n") - - line1 = editor.tokenizedLineForScreenRow(1) - expectValues lineStateForScreenRow(presenter, 1), { - text: line1.text - tokens: line1.tokens - } - - line2 = editor.tokenizedLineForScreenRow(2) - expectValues lineStateForScreenRow(presenter, 2), { - text: line2.text - tokens: line2.tokens - } - - line3 = editor.tokenizedLineForScreenRow(3) - expectValues lineStateForScreenRow(presenter, 3), { - text: line3.text - tokens: line3.tokens - } - - it "does not remove out-of-view lines corresponding to ::mouseWheelScreenRow until ::stoppedScrollingDelay elapses", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 1, stoppedScrollingDelay: 200) - - expect(lineStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineStateForScreenRow(presenter, 4)).toBeDefined() - expect(lineStateForScreenRow(presenter, 5)).toBeUndefined() - - presenter.setMouseWheelScreenRow(0) - expectStateUpdate presenter, -> presenter.setScrollTop(35) - - expect(lineStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineStateForScreenRow(presenter, 1)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 7)).toBeDefined() - expect(lineStateForScreenRow(presenter, 8)).toBeUndefined() - - expectStateUpdate presenter, -> advanceClock(200) - - expect(lineStateForScreenRow(presenter, 0)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 1)).toBeUndefined() - expect(lineStateForScreenRow(presenter, 2)).toBeDefined() - expect(lineStateForScreenRow(presenter, 7)).toBeDefined() - expect(lineStateForScreenRow(presenter, 8)).toBeUndefined() - - # should clear ::mouseWheelScreenRow after stoppedScrollingDelay elapses even if we don't scroll first - presenter.setMouseWheelScreenRow(2) - advanceClock(200) - expectStateUpdate presenter, -> presenter.setScrollTop(45) - expect(lineStateForScreenRow(presenter, 2)).toBeUndefined() - - it "does not preserve on-screen lines even if they correspond to ::mouseWheelScreenRow", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 1, stoppedScrollingDelay: 200) - oldLine3 = editor.tokenizedLineForScreenRow(6) - - presenter.setMouseWheelScreenRow(3) - - expectStateUpdate presenter, -> editor.getBuffer().insert([3, Infinity], 'xyz') - newLine3 = editor.tokenizedLineForScreenRow(3) - - expect(presenter.getState().content.lines[oldLine3.id]).toBeUndefined() - expect(presenter.getState().content.lines[newLine3.id]).toBeDefined() - - it "does not attempt to preserve lines corresponding to ::mouseWheelScreenRow if they have been deleted", -> - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10, lineOverdrawMargin: 1, stoppedScrollingDelay: 200) - presenter.setMouseWheelScreenRow(10) - editor.setText('') - - describe "[lineId]", -> # line state objects it "includes the .endOfLineInvisibles if the editor.showInvisibles config option is true", -> editor.setText("hello\nworld\r\n") presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineHeight: 10) @@ -1002,9 +1070,9 @@ describe "TextEditorPresenter", -> presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toEqual {top: 2 * 10, left: 4 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 1)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10, left: 12 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10 - 20, left: 12 * 10, width: 10, height: 10} expect(stateForCursor(presenter, 4)).toBeUndefined() it "is empty until all of the required measurements are assigned", -> @@ -1040,8 +1108,21 @@ describe "TextEditorPresenter", -> expect(stateForCursor(presenter, 0)).toBeUndefined() expect(stateForCursor(presenter, 1)).toBeUndefined() expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10, left: 12 * 10, width: 10, height: 10} - expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 10, left: 4 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 3)).toEqual {top: 0, left: 12 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 10 - 50, left: 4 * 10, width: 10, height: 10} + + it "updates when ::scrollTop changes after the model was changed", -> + editor.setCursorBufferPosition([8, 22]) + presenter = buildPresenter(explicitHeight: 50, scrollTop: 10 * 8) + + expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 10 * 22, width: 10, height: 10} + + expectStateUpdate presenter, -> + editor.getBuffer().deleteRow(12) + editor.getBuffer().deleteRow(11) + editor.getBuffer().deleteRow(10) + + expect(stateForCursor(presenter, 0)).toEqual {top: 20, left: 10 * 22, width: 10, height: 10} it "updates when ::explicitHeight changes", -> editor.setSelectedBufferRanges([ @@ -1055,9 +1136,9 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> presenter.setExplicitHeight(30) expect(stateForCursor(presenter, 0)).toBeUndefined() - expect(stateForCursor(presenter, 1)).toEqual {top: 2 * 10, left: 4 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 1)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10, left: 12 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 10 - 20, left: 12 * 10, width: 10, height: 10} expect(stateForCursor(presenter, 4)).toBeUndefined() it "updates when ::lineHeight changes", -> @@ -1074,15 +1155,15 @@ describe "TextEditorPresenter", -> expect(stateForCursor(presenter, 0)).toBeUndefined() expect(stateForCursor(presenter, 1)).toBeUndefined() expect(stateForCursor(presenter, 2)).toBeUndefined() - expect(stateForCursor(presenter, 3)).toEqual {top: 5 * 5, left: 12 * 10, width: 10, height: 5} - expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 5, left: 4 * 10, width: 10, height: 5} + expect(stateForCursor(presenter, 3)).toEqual {top: 5, left: 12 * 10, width: 10, height: 5} + expect(stateForCursor(presenter, 4)).toEqual {top: 8 * 5 - 20, left: 4 * 10, width: 10, height: 5} it "updates when ::baseCharacterWidth changes", -> editor.setCursorBufferPosition([2, 4]) presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(20) - expect(stateForCursor(presenter, 0)).toEqual {top: 2 * 10, left: 4 * 20, width: 20, height: 10} + expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 4 * 20, width: 20, height: 10} it "updates when scoped character widths change", -> waitsForPromise -> @@ -1108,11 +1189,11 @@ describe "TextEditorPresenter", -> # moving into view expect(stateForCursor(presenter, 0)).toBeUndefined() editor.getCursors()[0].setBufferPosition([2, 4]) - expect(stateForCursor(presenter, 0)).toEqual {top: 2 * 10, left: 4 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 0)).toEqual {top: 0, left: 4 * 10, width: 10, height: 10} # showing expectStateUpdate presenter, -> editor.getSelections()[1].clear() - expect(stateForCursor(presenter, 1)).toEqual {top: 3 * 10, left: 5 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 1)).toEqual {top: 5, left: 5 * 10, width: 10, height: 10} # hiding expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[3, 4], [3, 5]]) @@ -1124,11 +1205,11 @@ describe "TextEditorPresenter", -> # adding expectStateUpdate presenter, -> editor.addCursorAtBufferPosition([4, 4]) - expect(stateForCursor(presenter, 2)).toEqual {top: 4 * 10, left: 4 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 2)).toEqual {top: 5, left: 4 * 10, width: 10, height: 10} # moving added cursor expectStateUpdate presenter, -> editor.getCursors()[2].setBufferPosition([4, 6]) - expect(stateForCursor(presenter, 2)).toEqual {top: 4 * 10, left: 6 * 10, width: 10, height: 10} + expect(stateForCursor(presenter, 2)).toEqual {top: 5, left: 6 * 10, width: 10, height: 10} # destroying destroyedCursor = editor.getCursors()[2] @@ -1199,12 +1280,22 @@ describe "TextEditorPresenter", -> expect(presenter.getState().content.cursorsVisible).toBe false describe ".highlights", -> - stateForHighlight = (presenter, decoration) -> - presenter.getState().content.highlights[decoration.id] + expectUndefinedStateForHighlight = (presenter, decoration) -> + for tileId, tileState of presenter.getState().content.tiles + state = stateForHighlightInTile(presenter, decoration, tileId) + expect(state).toBeUndefined() - stateForSelection = (presenter, selectionIndex) -> + stateForHighlightInTile = (presenter, decoration, tile) -> + presenter.getState().content.tiles[tile]?.highlights[decoration.id] + + stateForSelectionInTile = (presenter, selectionIndex, tile) -> selection = presenter.model.getSelections()[selectionIndex] - stateForHighlight(presenter, selection.decoration) + stateForHighlightInTile(presenter, selection.decoration, tile) + + expectUndefinedStateForSelection = (presenter, selectionIndex) -> + for tileId, tileState of presenter.getState().content.tiles + state = stateForSelectionInTile(presenter, selectionIndex, tileId) + expect(state).toBeUndefined() it "contains states for highlights that are visible on screen", -> # off-screen above @@ -1219,11 +1310,11 @@ describe "TextEditorPresenter", -> marker3 = editor.markBufferRange([[0, 6], [3, 6]]) highlight3 = editor.decorateMarker(marker3, type: 'highlight', class: 'c') - # on-screen + # on-screen, spans over 2 tiles marker4 = editor.markBufferRange([[2, 6], [4, 6]]) highlight4 = editor.decorateMarker(marker4, type: 'highlight', class: 'd') - # partially off-screen below, 2 of 3 regions on screen + # partially off-screen below, spans over 3 tiles, 2 of 3 regions on screen marker5 = editor.markBufferRange([[3, 6], [6, 6]]) highlight5 = editor.decorateMarker(marker5, type: 'highlight', class: 'e') @@ -1239,107 +1330,138 @@ describe "TextEditorPresenter", -> marker8 = editor.markBufferRange([[2, 2], [2, 2]]) highlight8 = editor.decorateMarker(marker8, type: 'highlight', class: 'h') - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + # partially off-screen above, empty + marker9 = editor.markBufferRange([[0, 0], [2, 0]], invalidate: 'touch') + highlight9 = editor.decorateMarker(marker9, type: 'highlight', class: 'h') - expect(stateForHighlight(presenter, highlight1)).toBeUndefined() + presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - expectValues stateForHighlight(presenter, highlight2), { + expectUndefinedStateForHighlight(presenter, highlight1) + + expectValues stateForHighlightInTile(presenter, highlight2, 2), { class: 'b' regions: [ - {top: 2 * 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} + {top: 0, left: 0 * 10, width: 6 * 10, height: 1 * 10} ] } - expectValues stateForHighlight(presenter, highlight3), { + expectValues stateForHighlightInTile(presenter, highlight3, 2), { class: 'c' regions: [ - {top: 2 * 10, left: 0 * 10, right: 0, height: 1 * 10} - {top: 3 * 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} + {top: 0, left: 0 * 10, right: 0, height: 1 * 10} + {top: 10, left: 0 * 10, width: 6 * 10, height: 1 * 10} ] } - expectValues stateForHighlight(presenter, highlight4), { + expectValues stateForHighlightInTile(presenter, highlight4, 2), { class: 'd' regions: [ - {top: 2 * 10, left: 6 * 10, right: 0, height: 1 * 10} - {top: 3 * 10, left: 0, right: 0, height: 1 * 10} - {top: 4 * 10, left: 0, width: 6 * 10, height: 1 * 10} + {top: 0, left: 6 * 10, right: 0, height: 1 * 10} + {top: 10, left: 0, right: 0, height: 1 * 10} + ] + } + expectValues stateForHighlightInTile(presenter, highlight4, 4), { + class: 'd' + regions: [ + {top: 0, left: 0, width: 60, height: 1 * 10} ] } - expectValues stateForHighlight(presenter, highlight5), { + expectValues stateForHighlightInTile(presenter, highlight5, 2), { class: 'e' regions: [ - {top: 3 * 10, left: 6 * 10, right: 0, height: 1 * 10} - {top: 4 * 10, left: 0 * 10, right: 0, height: 2 * 10} + {top: 10, left: 6 * 10, right: 0, height: 1 * 10} ] } - expectValues stateForHighlight(presenter, highlight6), { + expectValues stateForHighlightInTile(presenter, highlight5, 4), { + class: 'e' + regions: [ + {top: 0, left: 0, right: 0, height: 1 * 10} + {top: 10, left: 0, right: 0, height: 1 * 10} + ] + } + + expect(stateForHighlightInTile(presenter, highlight5, 6)).toBeUndefined() + + expectValues stateForHighlightInTile(presenter, highlight6, 4), { class: 'f' regions: [ - {top: 5 * 10, left: 6 * 10, right: 0, height: 1 * 10} + {top: 10, left: 6 * 10, right: 0, height: 1 * 10} ] } - expect(stateForHighlight(presenter, highlight7)).toBeUndefined() - expect(stateForHighlight(presenter, highlight8)).toBeUndefined() + expect(stateForHighlightInTile(presenter, highlight6, 6)).toBeUndefined() + + expectUndefinedStateForHighlight(presenter, highlight7) + expectUndefinedStateForHighlight(presenter, highlight8) + expectUndefinedStateForHighlight(presenter, highlight9) it "is empty until all of the required measurements are assigned", -> editor.setSelectedBufferRanges([ [[0, 2], [2, 4]], ]) - presenter = buildPresenter(explicitHeight: null, lineHeight: null, scrollTop: null, baseCharacterWidth: null) - expect(presenter.getState().content.highlights).toEqual({}) + presenter = buildPresenter(explicitHeight: null, lineHeight: null, scrollTop: null, baseCharacterWidth: null, tileSize: 2) + for tileId, tileState of presenter.getState().content.tiles + expect(tileState.highlights).toEqual({}) presenter.setExplicitHeight(25) - expect(presenter.getState().content.highlights).toEqual({}) + for tileId, tileState of presenter.getState().content.tiles + expect(tileState.highlights).toEqual({}) presenter.setLineHeight(10) - expect(presenter.getState().content.highlights).toEqual({}) + for tileId, tileState of presenter.getState().content.tiles + expect(tileState.highlights).toEqual({}) presenter.setScrollTop(0) - expect(presenter.getState().content.highlights).toEqual({}) + for tileId, tileState of presenter.getState().content.tiles + expect(tileState.highlights).toEqual({}) presenter.setBaseCharacterWidth(8) - expect(presenter.getState().content.highlights).not.toEqual({}) + assignedAnyHighlight = false + for tileId, tileState of presenter.getState().content.tiles + assignedAnyHighlight ||= _.isEqual(tileState.highlights, {}) + + expect(assignedAnyHighlight).toBe(true) it "does not include highlights for invalid markers", -> marker = editor.markBufferRange([[2, 2], [2, 4]], invalidate: 'touch') highlight = editor.decorateMarker(marker, type: 'highlight', class: 'h') - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) + + expect(stateForHighlightInTile(presenter, highlight, 2)).toBeDefined() - expect(stateForHighlight(presenter, highlight)).toBeDefined() expectStateUpdate presenter, -> editor.getBuffer().insert([2, 2], "stuff") - expect(stateForHighlight(presenter, highlight)).toBeUndefined() + + expectUndefinedStateForHighlight(presenter, highlight) it "updates when ::scrollTop changes", -> editor.setSelectedBufferRanges([ [[6, 2], [6, 4]], ]) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - expect(stateForSelection(presenter, 0)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 0) expectStateUpdate presenter, -> presenter.setScrollTop(5 * 10) - expect(stateForSelection(presenter, 0)).toBeDefined() + expect(stateForSelectionInTile(presenter, 0, 6)).toBeDefined() expectStateUpdate presenter, -> presenter.setScrollTop(2 * 10) - expect(stateForSelection(presenter, 0)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 0) it "updates when ::explicitHeight changes", -> editor.setSelectedBufferRanges([ [[6, 2], [6, 4]], ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 20) + presenter = buildPresenter(explicitHeight: 20, scrollTop: 20, tileSize: 2) - expect(stateForSelection(presenter, 0)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 0) expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(stateForSelection(presenter, 0)).toBeDefined() + expect(stateForSelectionInTile(presenter, 0, 6)).toBeDefined() expectStateUpdate presenter, -> presenter.setExplicitHeight(20) - expect(stateForSelection(presenter, 0)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 0) it "updates when ::lineHeight changes", -> editor.setSelectedBufferRanges([ @@ -1347,26 +1469,26 @@ describe "TextEditorPresenter", -> [[3, 4], [3, 6]], ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0) + presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - expectValues stateForSelection(presenter, 0), { + expectValues stateForSelectionInTile(presenter, 0, 2), { regions: [ - {top: 2 * 10, left: 2 * 10, width: 2 * 10, height: 10} + {top: 0, left: 2 * 10, width: 2 * 10, height: 10} ] } - expect(stateForSelection(presenter, 1)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 1) expectStateUpdate presenter, -> presenter.setLineHeight(5) - expectValues stateForSelection(presenter, 0), { + expectValues stateForSelectionInTile(presenter, 0, 2), { regions: [ - {top: 2 * 5, left: 2 * 10, width: 2 * 10, height: 5} + {top: 0, left: 2 * 10, width: 2 * 10, height: 5} ] } - expectValues stateForSelection(presenter, 1), { + expectValues stateForSelectionInTile(presenter, 1, 2), { regions: [ - {top: 3 * 5, left: 4 * 10, width: 2 * 10, height: 5} + {top: 5, left: 4 * 10, width: 2 * 10, height: 5} ] } @@ -1375,14 +1497,14 @@ describe "TextEditorPresenter", -> [[2, 2], [2, 4]], ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0) + presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - expectValues stateForSelection(presenter, 0), { - regions: [{top: 2 * 10, left: 2 * 10, width: 2 * 10, height: 10}] + expectValues stateForSelectionInTile(presenter, 0, 2), { + regions: [{top: 0, left: 2 * 10, width: 2 * 10, height: 10}] } expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(20) - expectValues stateForSelection(presenter, 0), { - regions: [{top: 2 * 10, left: 2 * 20, width: 2 * 20, height: 10}] + expectValues stateForSelectionInTile(presenter, 0, 2), { + regions: [{top: 0, left: 2 * 20, width: 2 * 20, height: 10}] } it "updates when scoped character widths change", -> @@ -1394,14 +1516,14 @@ describe "TextEditorPresenter", -> [[2, 4], [2, 6]], ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0) + presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - expectValues stateForSelection(presenter, 0), { - regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + expectValues stateForSelectionInTile(presenter, 0, 2), { + regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] } expectStateUpdate presenter, -> presenter.setScopedCharacterWidth(['source.js', 'keyword.control.js'], 'i', 20) - expectValues stateForSelection(presenter, 0), { - regions: [{top: 2 * 10, left: 4 * 10, width: 20 + 10, height: 10}] + expectValues stateForSelectionInTile(presenter, 0, 2), { + regions: [{top: 0, left: 4 * 10, width: 20 + 10, height: 10}] } it "updates when highlight decorations are added, moved, hidden, shown, or destroyed", -> @@ -1409,68 +1531,79 @@ describe "TextEditorPresenter", -> [[1, 2], [1, 4]], [[3, 4], [3, 6]] ]) - presenter = buildPresenter(explicitHeight: 20, scrollTop: 0) + presenter = buildPresenter(explicitHeight: 20, scrollTop: 0, tileSize: 2) - expectValues stateForSelection(presenter, 0), { - regions: [{top: 1 * 10, left: 2 * 10, width: 2 * 10, height: 10}] + expectValues stateForSelectionInTile(presenter, 0, 0), { + regions: [{top: 10, left: 2 * 10, width: 2 * 10, height: 10}] } - expect(stateForSelection(presenter, 1)).toBeUndefined() + expectUndefinedStateForSelection(presenter, 1) # moving into view - expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]]) - expectValues stateForSelection(presenter, 1), { - regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]], autoscroll: false) + expectValues stateForSelectionInTile(presenter, 1, 2), { + regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] } # becoming empty - expectStateUpdate presenter, -> editor.getSelections()[1].clear() - expect(stateForSelection(presenter, 1)).toBeUndefined() + expectStateUpdate presenter, -> editor.getSelections()[1].clear(autoscroll: false) + expectUndefinedStateForSelection(presenter, 1) # becoming non-empty - expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]]) - expectValues stateForSelection(presenter, 1), { - regions: [{top: 2 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[2, 4], [2, 6]], autoscroll: false) + expectValues stateForSelectionInTile(presenter, 1, 2), { + regions: [{top: 0, left: 4 * 10, width: 2 * 10, height: 10}] } # moving out of view - expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[3, 4], [3, 6]]) - expect(stateForSelection(presenter, 1)).toBeUndefined() + expectStateUpdate presenter, -> editor.getSelections()[1].setBufferRange([[3, 4], [3, 6]], autoscroll: false) + expectUndefinedStateForSelection(presenter, 1) # adding - expectStateUpdate presenter, -> editor.addSelectionForBufferRange([[1, 4], [1, 6]]) - expectValues stateForSelection(presenter, 2), { - regions: [{top: 1 * 10, left: 4 * 10, width: 2 * 10, height: 10}] + expectStateUpdate presenter, -> editor.addSelectionForBufferRange([[1, 4], [1, 6]], autoscroll: false) + expectValues stateForSelectionInTile(presenter, 2, 0), { + regions: [{top: 10, left: 4 * 10, width: 2 * 10, height: 10}] } # moving added selection - expectStateUpdate presenter, -> editor.getSelections()[2].setBufferRange([[1, 4], [1, 8]]) - expectValues stateForSelection(presenter, 2), { - regions: [{top: 1 * 10, left: 4 * 10, width: 4 * 10, height: 10}] + expectStateUpdate presenter, -> editor.getSelections()[2].setBufferRange([[1, 4], [1, 8]], autoscroll: false) + expectValues stateForSelectionInTile(presenter, 2, 0), { + regions: [{top: 10, left: 4 * 10, width: 4 * 10, height: 10}] } # destroying destroyedSelection = editor.getSelections()[2] expectStateUpdate presenter, -> destroyedSelection.destroy() - expect(stateForHighlight(presenter, destroyedSelection.decoration)).toBeUndefined() + expectUndefinedStateForHighlight(presenter, destroyedSelection.decoration) it "updates when highlight decorations' properties are updated", -> - marker = editor.markBufferRange([[2, 2], [2, 4]]) + marker = editor.markBufferPosition([2, 2]) highlight = editor.decorateMarker(marker, type: 'highlight', class: 'a') - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - expectValues stateForHighlight(presenter, highlight), {class: 'a'} - expectStateUpdate presenter, -> highlight.setProperties(class: 'b', type: 'highlight') - expectValues stateForHighlight(presenter, highlight), {class: 'b'} + expectUndefinedStateForHighlight(presenter, highlight) + + expectStateUpdate presenter, -> + marker.setBufferRange([[2, 2], [2, 4]]) + highlight.setProperties(class: 'b', type: 'highlight') + + expectValues stateForHighlightInTile(presenter, highlight, 2), {class: 'b'} it "increments the .flashCount and sets the .flashClass and .flashDuration when the highlight model flashes", -> - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter(explicitHeight: 30, scrollTop: 20, tileSize: 2) - marker = editor.markBufferRange([[2, 2], [2, 4]]) + marker = editor.markBufferPosition([2, 2]) highlight = editor.decorateMarker(marker, type: 'highlight', class: 'a') - expectStateUpdate presenter, -> highlight.flash('b', 500) + expectStateUpdate presenter, -> + marker.setBufferRange([[2, 2], [5, 2]]) + highlight.flash('b', 500) - expectValues stateForHighlight(presenter, highlight), { + expectValues stateForHighlightInTile(presenter, highlight, 2), { + flashClass: 'b' + flashDuration: 500 + flashCount: 1 + } + expectValues stateForHighlightInTile(presenter, highlight, 4), { flashClass: 'b' flashDuration: 500 flashCount: 1 @@ -1478,18 +1611,23 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> highlight.flash('c', 600) - expectValues stateForHighlight(presenter, highlight), { + expectValues stateForHighlightInTile(presenter, highlight, 2), { + flashClass: 'c' + flashDuration: 600 + flashCount: 2 + } + expectValues stateForHighlightInTile(presenter, highlight, 4), { flashClass: 'c' flashDuration: 600 flashCount: 2 } describe ".overlays", -> + [item] = [] stateForOverlay = (presenter, decoration) -> presenter.getState().content.overlays[decoration.id] it "contains state for overlay decorations both initially and when their markers move", -> - item = {} marker = editor.markBufferPosition([2, 13], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) @@ -1497,14 +1635,14 @@ describe "TextEditorPresenter", -> # Initial state expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} } # Change range expectStateUpdate presenter, -> marker.setBufferRange([[2, 13], [4, 6]]) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 4 * 10, left: 6 * 10} + pixelPosition: {top: 5 * 10 - presenter.state.content.scrollTop, left: 6 * 10} } # Valid -> invalid @@ -1515,14 +1653,14 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> editor.undo() expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 4 * 10, left: 6 * 10} + pixelPosition: {top: 5 * 10 - presenter.state.content.scrollTop, left: 6 * 10} } # Reverse direction expectStateUpdate presenter, -> marker.setBufferRange([[2, 13], [4, 6]], reversed: true) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} } # Destroy @@ -1533,502 +1671,235 @@ describe "TextEditorPresenter", -> decoration2 = editor.decorateMarker(marker, {type: 'overlay', item}) expectValues stateForOverlay(presenter, decoration2), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - presenter.state.content.scrollTop, left: 13 * 10} } it "updates when ::baseCharacterWidth changes", -> - item = {} + scrollTop = 20 marker = editor.markBufferPosition([2, 13], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter({explicitHeight: 30, scrollTop}) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} } expectStateUpdate presenter, -> presenter.setBaseCharacterWidth(5) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 5} + pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 5} } it "updates when ::lineHeight changes", -> - item = {} + scrollTop = 20 marker = editor.markBufferPosition([2, 13], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', item}) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter({explicitHeight: 30, scrollTop}) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} } expectStateUpdate presenter, -> presenter.setLineHeight(5) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 5, left: 13 * 10} + pixelPosition: {top: 3 * 5 - scrollTop, left: 13 * 10} } it "honors the 'position' option on overlay decorations", -> - item = {} + scrollTop = 20 marker = editor.markBufferRange([[2, 13], [4, 14]], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', position: 'tail', item}) - presenter = buildPresenter(explicitHeight: 30, scrollTop: 20) + presenter = buildPresenter({explicitHeight: 30, scrollTop}) expectValues stateForOverlay(presenter, decoration), { item: item - pixelPosition: {top: 2 * 10, left: 13 * 10} + pixelPosition: {top: 3 * 10 - scrollTop, left: 13 * 10} } it "is empty until all of the required measurements are assigned", -> - item = {} marker = editor.markBufferRange([[2, 13], [4, 14]], invalidate: 'touch') decoration = editor.decorateMarker(marker, {type: 'overlay', position: 'tail', item}) - presenter = buildPresenter(baseCharacterWidth: null, lineHeight: null) + presenter = buildPresenter(baseCharacterWidth: null, lineHeight: null, windowWidth: null, windowHeight: null, boundingClientRect: null) expect(presenter.getState().content.overlays).toEqual({}) presenter.setBaseCharacterWidth(10) expect(presenter.getState().content.overlays).toEqual({}) presenter.setLineHeight(10) + expect(presenter.getState().content.overlays).toEqual({}) + + presenter.setWindowSize(500, 100) + expect(presenter.getState().content.overlays).toEqual({}) + + presenter.setBoundingClientRect({top: 0, left: 0, height: 100, width: 500}) expect(presenter.getState().content.overlays).not.toEqual({}) - describe ".gutter", -> - describe ".scrollHeight", -> - it "is initialized based on ::lineHeight, the number of lines, and ::explicitHeight", -> - presenter = buildPresenter() - expect(presenter.getState().gutter.scrollHeight).toBe editor.getScreenLineCount() * 10 - - presenter = buildPresenter(explicitHeight: 500) - expect(presenter.getState().gutter.scrollHeight).toBe 500 - - it "updates when the ::lineHeight changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> presenter.setLineHeight(20) - expect(presenter.getState().gutter.scrollHeight).toBe editor.getScreenLineCount() * 20 - - it "updates when the line count changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") - expect(presenter.getState().gutter.scrollHeight).toBe editor.getScreenLineCount() * 10 - - it "updates when ::explicitHeight changes", -> - presenter = buildPresenter() - expectStateUpdate presenter, -> presenter.setExplicitHeight(500) - expect(presenter.getState().gutter.scrollHeight).toBe 500 - - it "adds the computed clientHeight to the computed scrollHeight if editor.scrollPastEnd is true", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(presenter.getState().gutter.scrollHeight).toBe presenter.contentHeight - - expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", true) - expect(presenter.getState().gutter.scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", false) - expect(presenter.getState().gutter.scrollHeight).toBe presenter.contentHeight - - describe ".scrollTop", -> - it "tracks the value of ::scrollTop", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 20) - expect(presenter.getState().gutter.scrollTop).toBe 10 - expectStateUpdate presenter, -> presenter.setScrollTop(50) - expect(presenter.getState().gutter.scrollTop).toBe 50 - - it "never exceeds the computed scrollHeight minus the computed clientHeight", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(100) - expect(presenter.getState().gutter.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setExplicitHeight(60) - expect(presenter.getState().gutter.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(5) - expect(presenter.getState().gutter.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - expectStateUpdate presenter, -> editor.getBuffer().delete([[8, 0], [12, 0]]) - expect(presenter.getState().gutter.scrollTop).toBe presenter.scrollHeight - presenter.clientHeight - - # Scroll top only gets smaller when needed as dimensions change, never bigger - scrollTopBefore = presenter.getState().verticalScrollbar.scrollTop - expectStateUpdate presenter, -> editor.getBuffer().insert([9, Infinity], '\n\n\n') - expect(presenter.getState().gutter.scrollTop).toBe scrollTopBefore - - it "never goes negative", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(-100) - expect(presenter.getState().gutter.scrollTop).toBe 0 - - it "adds the computed clientHeight to the computed scrollHeight if editor.scrollPastEnd is true", -> - presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(presenter.getState().gutter.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - atom.config.set("editor.scrollPastEnd", true) - expectStateUpdate presenter, -> presenter.setScrollTop(300) - expect(presenter.getState().gutter.scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) - - expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", false) - expect(presenter.getState().gutter.scrollTop).toBe presenter.contentHeight - presenter.clientHeight - - describe ".backgroundColor", -> - it "is assigned to ::gutterBackgroundColor if present, and to ::backgroundColor otherwise", -> - presenter = buildPresenter(backgroundColor: "rgba(255, 0, 0, 0)", gutterBackgroundColor: "rgba(0, 255, 0, 0)") - expect(presenter.getState().gutter.backgroundColor).toBe "rgba(0, 255, 0, 0)" - - expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 255, 0)") - expect(presenter.getState().gutter.backgroundColor).toBe "rgba(0, 0, 255, 0)" - - expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 0, 0)") - expect(presenter.getState().gutter.backgroundColor).toBe "rgba(255, 0, 0, 0)" - - expectStateUpdate presenter, -> presenter.setBackgroundColor("rgba(0, 0, 255, 0)") - expect(presenter.getState().gutter.backgroundColor).toBe "rgba(0, 0, 255, 0)" - - describe ".maxLineNumberDigits", -> - it "is set to the number of digits used by the greatest line number", -> - presenter = buildPresenter() - expect(editor.getLastBufferRow()).toBe 12 - expect(presenter.getState().gutter.maxLineNumberDigits).toBe 2 - - editor.setText("1\n2\n3") - expect(presenter.getState().gutter.maxLineNumberDigits).toBe 1 - - describe ".lineNumbers", -> - lineNumberStateForScreenRow = (presenter, screenRow) -> - editor = presenter.model - bufferRow = editor.bufferRowForScreenRow(screenRow) - wrapCount = screenRow - editor.screenRowForBufferRow(bufferRow) - if wrapCount > 0 - key = bufferRow + '-' + wrapCount - else - key = bufferRow - - presenter.getState().gutter.lineNumbers[key] - - it "contains states for line numbers that are visible on screen, plus and minus the overdraw margin", -> - editor.foldBufferRow(4) - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 30, lineHeight: 10, lineOverdrawMargin: 1) - - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {screenRow: 2, bufferRow: 2, softWrapped: false, top: 2 * 10} - expectValues lineNumberStateForScreenRow(presenter, 3), {screenRow: 3, bufferRow: 3, softWrapped: false, top: 3 * 10} - expectValues lineNumberStateForScreenRow(presenter, 4), {screenRow: 4, bufferRow: 3, softWrapped: true, top: 4 * 10} - expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 4, softWrapped: false, top: 5 * 10} - expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 7, softWrapped: false, top: 6 * 10} - expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 8, softWrapped: false, top: 7 * 10} - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - it "includes states for all line numbers if no ::explicitHeight is assigned", -> - presenter = buildPresenter(explicitHeight: null) - expect(lineNumberStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 12)).toBeDefined() - - it "updates when ::scrollTop changes", -> - editor.foldBufferRow(4) - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 30, lineOverdrawMargin: 1) - - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 8} - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setScrollTop(20) - - expect(lineNumberStateForScreenRow(presenter, 0)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 1), {bufferRow: 1} - expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 7} - expect(lineNumberStateForScreenRow(presenter, 7)).toBeUndefined() - - it "updates when ::explicitHeight changes", -> - editor.foldBufferRow(4) - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 30, lineOverdrawMargin: 1) - - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 8} - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setExplicitHeight(35) - - expect(lineNumberStateForScreenRow(presenter, 0)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} - expectValues lineNumberStateForScreenRow(presenter, 8), {bufferRow: 8} - expect(lineNumberStateForScreenRow(presenter, 9)).toBeUndefined() - - it "updates when ::lineHeight changes", -> - editor.foldBufferRow(4) - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 0, lineOverdrawMargin: 0) - - expectValues lineNumberStateForScreenRow(presenter, 0), {bufferRow: 0} - expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} - expect(lineNumberStateForScreenRow(presenter, 4)).toBeUndefined() - - expectStateUpdate presenter, -> presenter.setLineHeight(5) - - expectValues lineNumberStateForScreenRow(presenter, 0), {bufferRow: 0} - expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 4} - expect(lineNumberStateForScreenRow(presenter, 6)).toBeUndefined() - - it "updates when the editor's content changes", -> - editor.foldBufferRow(4) - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(50) - presenter = buildPresenter(explicitHeight: 35, scrollTop: 30, lineOverdrawMargin: 0) - - expect(lineNumberStateForScreenRow(presenter, 2)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 4} - expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 7} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 8} - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - expectStateUpdate presenter, -> - editor.getBuffer().insert([3, Infinity], new Array(25).join("x ")) - - expect(lineNumberStateForScreenRow(presenter, 2)).toBeUndefined() - expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 3} - expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 4} - expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 7} - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - it "does not remove out-of-view line numbers corresponding to ::mouseWheelScreenRow until ::stoppedScrollingDelay elapses", -> - presenter = buildPresenter(explicitHeight: 25, lineOverdrawMargin: 1, stoppedScrollingDelay: 200) - - expect(lineNumberStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 4)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 5)).toBeUndefined() - - presenter.setMouseWheelScreenRow(0) - expectStateUpdate presenter, -> presenter.setScrollTop(35) - - expect(lineNumberStateForScreenRow(presenter, 0)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 7)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - expectStateUpdate presenter, -> advanceClock(200) - - expect(lineNumberStateForScreenRow(presenter, 0)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() - expect(lineNumberStateForScreenRow(presenter, 7)).toBeDefined() - expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() - - it "correctly handles the first screen line being soft-wrapped", -> - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(30) - presenter = buildPresenter(explicitHeight: 25, scrollTop: 50) - - expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 3, softWrapped: true} - expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 3, softWrapped: true} - expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 4, softWrapped: false} - - describe ".decorationClasses", -> - it "adds decoration classes to the relevant line number state objects, both initially and when decorations change", -> - marker1 = editor.markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration1 = editor.decorateMarker(marker1, type: 'line-number', class: 'a') - presenter = buildPresenter() - marker2 = editor.markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') - decoration2 = editor.decorateMarker(marker2, type: 'line-number', class: 'b') - - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> editor.getBuffer().insert([5, 0], 'x') - expect(marker1.isValid()).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> editor.undo() - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> marker1.setBufferRange([[2, 0], [4, 2]]) - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> decoration1.destroy() - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> marker2.destroy() - expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() - - it "honors the 'onlyEmpty' option on line-number decorations", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 1]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyEmpty: true) - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> marker.clearTail() - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "honors the 'onlyNonEmpty' option on line-number decorations", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 2]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyNonEmpty: true) - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - expectStateUpdate presenter, -> marker.clearTail() - - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "honors the 'onlyHead' option on line-number decorations", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 2]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyHead: true) - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] - - it "does not decorate the last line of a non-empty line-number decoration range if it ends at column 0", -> - presenter = buildPresenter() - marker = editor.markBufferRange([[4, 0], [6, 0]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') - - expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] - expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() - - it "does not apply line-number decorations to mini editors", -> - editor.setMini(true) - presenter = buildPresenter() - marker = editor.markBufferRange([[0, 0], [0, 0]]) - decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toBeNull() - - expectStateUpdate presenter, -> editor.setMini(false) - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toEqual ['cursor-line', 'cursor-line-no-selection', 'a'] - - expectStateUpdate presenter, -> editor.setMini(true) - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toBeNull() - - it "only applies line-number decorations to screen rows that are spanned by their marker when lines are soft-wrapped", -> - editor.setText("a line that wraps, ok") - editor.setSoftWrapped(true) - editor.setEditorWidthInChars(16) - marker = editor.markBufferRange([[0, 0], [0, 2]]) - editor.decorateMarker(marker, type: 'line-number', class: 'a') - presenter = buildPresenter(explicitHeight: 10) - - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() - - marker.setBufferRange([[0, 0], [0, Infinity]]) - expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' - expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toContain 'a' - - describe ".foldable", -> - it "marks line numbers at the start of a foldable region as foldable", -> - presenter = buildPresenter() - expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe false - - it "updates the foldable class on the correct line numbers when the foldable positions change", -> - presenter = buildPresenter() - editor.getBuffer().insert([0, 0], '\n') - expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe false - expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe true - expect(lineNumberStateForScreenRow(presenter, 6).foldable).toBe false - - it "updates the foldable class on a line number that becomes foldable", -> - presenter = buildPresenter() - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false - - editor.getBuffer().insert([11, 44], '\n fold me') - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe true - - editor.undo() - expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false - - describe ".visible", -> - it "is true iff the editor isn't mini, ::isGutterVisible is true on the editor, and 'editor.showLineNumbers' is enabled in config", -> - presenter = buildPresenter() - - expect(editor.isGutterVisible()).toBe true - expect(presenter.getState().gutter.visible).toBe true - - expectStateUpdate presenter, -> editor.setMini(true) - expect(presenter.getState().gutter.visible).toBe false - - expectStateUpdate presenter, -> editor.setMini(false) - expect(presenter.getState().gutter.visible).toBe true - - expectStateUpdate presenter, -> editor.setGutterVisible(false) - expect(presenter.getState().gutter.visible).toBe false - - expectStateUpdate presenter, -> editor.setGutterVisible(true) - expect(presenter.getState().gutter.visible).toBe true - - expectStateUpdate presenter, -> atom.config.set('editor.showLineNumbers', false) - expect(presenter.getState().gutter.visible).toBe false - - it "updates when the editor's grammar changes", -> - presenter = buildPresenter() - - atom.config.set('editor.showLineNumbers', false, scopeSelector: '.source.js') - expect(presenter.getState().gutter.visible).toBe true - stateUpdated = false - presenter.onDidUpdateState -> stateUpdated = true - - waitsForPromise -> atom.packages.activatePackage('language-javascript') - - runs -> - expect(stateUpdated).toBe true - expect(presenter.getState().gutter.visible).toBe false + describe "when the overlay has been measured", -> + [gutterWidth, windowWidth, windowHeight, itemWidth, itemHeight, contentMargin, boundingClientRect, contentFrameWidth] = [] + beforeEach -> + item = {} + gutterWidth = 5 * 10 # 5 chars wide + contentFrameWidth = 30 * 10 + windowWidth = gutterWidth + contentFrameWidth + windowHeight = 9 * 10 + + itemWidth = 4 * 10 + itemHeight = 4 * 10 + contentMargin = 0 + + boundingClientRect = + top: 0 + left: 0, + width: windowWidth + height: windowHeight + + it "slides horizontally left when near the right edge", -> + scrollLeft = 20 + marker = editor.markBufferPosition([0, 26], invalidate: 'never') + decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + + presenter = buildPresenter({scrollLeft, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) + expectStateUpdate presenter, -> + presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) + + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: 26 * 10 + gutterWidth - scrollLeft} + } + + expectStateUpdate presenter, -> editor.insertText('a') + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: windowWidth - itemWidth} + } + + expectStateUpdate presenter, -> editor.insertText('b') + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: windowWidth - itemWidth} + } + + it "flips vertically when near the bottom edge", -> + scrollTop = 10 + marker = editor.markBufferPosition([5, 0], invalidate: 'never') + decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + + presenter = buildPresenter({scrollTop, windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) + expectStateUpdate presenter, -> + presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) + + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 6 * 10 - scrollTop, left: gutterWidth} + } + + expectStateUpdate presenter, -> + editor.insertNewline() + editor.setScrollTop(scrollTop) # I'm fighting the editor + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 6 * 10 - scrollTop - itemHeight, left: gutterWidth} + } + + describe "when the overlay item has a margin", -> + beforeEach -> + itemWidth = 12 * 10 + contentMargin = -(gutterWidth + 2 * 10) + + it "slides horizontally right when near the left edge with margin", -> + editor.setCursorBufferPosition([0, 3]) + cursor = editor.getLastCursor() + marker = cursor.marker + decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + + presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) + expectStateUpdate presenter, -> + presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) + + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: 3 * 10 + gutterWidth} + } + + expectStateUpdate presenter, -> cursor.moveLeft() + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: -contentMargin} + } + + expectStateUpdate presenter, -> cursor.moveLeft() + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 1 * 10, left: -contentMargin} + } + + describe "when the editor is very small", -> + beforeEach -> + windowWidth = gutterWidth + 6 * 10 + windowHeight = 6 * 10 + contentFrameWidth = windowWidth - gutterWidth + boundingClientRect.width = windowWidth + boundingClientRect.height = windowHeight + + it "does not flip vertically and force the overlay to have a negative top", -> + marker = editor.markBufferPosition([1, 0], invalidate: 'never') + decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + + presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) + expectStateUpdate presenter, -> + presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) + + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 2 * 10, left: 0 * 10 + gutterWidth} + } + + expectStateUpdate presenter, -> editor.insertNewline() + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 3 * 10, left: gutterWidth} + } + + it "does not adjust horizontally and force the overlay to have a negative left", -> + itemWidth = 6 * 10 + + marker = editor.markBufferPosition([0, 0], invalidate: 'never') + decoration = editor.decorateMarker(marker, {type: 'overlay', item}) + + presenter = buildPresenter({windowWidth, windowHeight, contentFrameWidth, boundingClientRect, gutterWidth}) + expectStateUpdate presenter, -> + presenter.setOverlayDimensions(decoration.id, itemWidth, itemHeight, contentMargin) + + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 10, left: gutterWidth} + } + + windowWidth = gutterWidth + 5 * 10 + expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 10, left: windowWidth - itemWidth} + } + + windowWidth = gutterWidth + 1 * 10 + expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 10, left: 0} + } + + windowWidth = gutterWidth + expectStateUpdate presenter, -> presenter.setWindowSize(windowWidth, windowHeight) + expectValues stateForOverlay(presenter, decoration), { + item: item + pixelPosition: {top: 10, left: 0} + } describe ".height", -> it "tracks the computed content height if ::autoHeight is true so the editor auto-expands vertically", -> @@ -2056,6 +1927,678 @@ describe "TextEditorPresenter", -> expectStateUpdate presenter, -> presenter.setFocused(false) expect(presenter.getState().focused).toBe false + describe ".gutters", -> + getStateForGutterWithName = (presenter, gutterName) -> + gutterDescriptions = presenter.getState().gutters + for description in gutterDescriptions + gutter = description.gutter + return description if gutter.name is gutterName + + describe "the array itself, an array of gutter descriptions", -> + it "updates when gutters are added to the editor model, and keeps the gutters sorted by priority", -> + presenter = buildPresenter() + gutter1 = editor.addGutter({name: 'test-gutter-1', priority: -100, visible: true}) + gutter2 = editor.addGutter({name: 'test-gutter-2', priority: 100, visible: false}) + + expectedGutterOrder = [gutter1, editor.gutterWithName('line-number'), gutter2] + for gutterDescription, index in presenter.getState().gutters + expect(gutterDescription.gutter).toEqual expectedGutterOrder[index] + + it "updates when the visibility of a gutter changes", -> + presenter = buildPresenter() + gutter = editor.addGutter({name: 'test-gutter', visible: true}) + expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe true + gutter.hide() + expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe false + + it "updates when a gutter is removed", -> + presenter = buildPresenter() + gutter = editor.addGutter({name: 'test-gutter', visible: true}) + expect(getStateForGutterWithName(presenter, 'test-gutter').visible).toBe true + gutter.destroy() + expect(getStateForGutterWithName(presenter, 'test-gutter')).toBeUndefined() + + describe "for a gutter description that corresponds to the line-number gutter", -> + getLineNumberGutterState = (presenter) -> + gutterDescriptions = presenter.getState().gutters + for description in gutterDescriptions + gutter = description.gutter + return description if gutter.name is 'line-number' + + describe ".visible", -> + it "is true iff the editor isn't mini, ::isLineNumberGutterVisible is true on the editor, and the 'editor.showLineNumbers' config is enabled", -> + presenter = buildPresenter() + + expect(editor.isLineNumberGutterVisible()).toBe true + expect(getLineNumberGutterState(presenter).visible).toBe true + + expectStateUpdate presenter, -> editor.setMini(true) + expect(getLineNumberGutterState(presenter)).toBeUndefined() + + expectStateUpdate presenter, -> editor.setMini(false) + expect(getLineNumberGutterState(presenter).visible).toBe true + + expectStateUpdate presenter, -> editor.setLineNumberGutterVisible(false) + expect(getLineNumberGutterState(presenter).visible).toBe false + + expectStateUpdate presenter, -> editor.setLineNumberGutterVisible(true) + expect(getLineNumberGutterState(presenter).visible).toBe true + + expectStateUpdate presenter, -> atom.config.set('editor.showLineNumbers', false) + expect(getLineNumberGutterState(presenter).visible).toBe false + + it "gets updated when the editor's grammar changes", -> + presenter = buildPresenter() + + atom.config.set('editor.showLineNumbers', false, scopeSelector: '.source.js') + expect(getLineNumberGutterState(presenter).visible).toBe true + stateUpdated = false + presenter.onDidUpdateState -> stateUpdated = true + + waitsForPromise -> atom.packages.activatePackage('language-javascript') + + runs -> + expect(stateUpdated).toBe true + expect(getLineNumberGutterState(presenter).visible).toBe false + + describe ".content.maxLineNumberDigits", -> + it "is set to the number of digits used by the greatest line number", -> + presenter = buildPresenter() + expect(editor.getLastBufferRow()).toBe 12 + expect(getLineNumberGutterState(presenter).content.maxLineNumberDigits).toBe 2 + + editor.setText("1\n2\n3") + expect(getLineNumberGutterState(presenter).content.maxLineNumberDigits).toBe 1 + + describe ".content.tiles", -> + lineNumberStateForScreenRow = (presenter, screenRow) -> + editor = presenter.model + tileRow = presenter.tileForRow(screenRow) + bufferRow = editor.bufferRowForScreenRow(screenRow) + wrapCount = screenRow - editor.screenRowForBufferRow(bufferRow) + if wrapCount > 0 + key = bufferRow + '-' + wrapCount + else + key = bufferRow + + gutterState = getLineNumberGutterState(presenter) + gutterState.content.tiles[tileRow]?.lineNumbers[key] + + tiledContentContract (presenter) -> getLineNumberGutterState(presenter).content + + describe ".lineNumbers[id]", -> + it "contains states for line numbers that are visible on screen", -> + editor.foldBufferRow(4) + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(50) + presenter = buildPresenter(explicitHeight: 25, scrollTop: 30, lineHeight: 10, tileSize: 2) + + expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() + expectValues lineNumberStateForScreenRow(presenter, 2), {screenRow: 2, bufferRow: 2, softWrapped: false, top: 0 * 10} + expectValues lineNumberStateForScreenRow(presenter, 3), {screenRow: 3, bufferRow: 3, softWrapped: false, top: 1 * 10} + expectValues lineNumberStateForScreenRow(presenter, 4), {screenRow: 4, bufferRow: 3, softWrapped: true, top: 0 * 10} + expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 4, softWrapped: false, top: 1 * 10} + expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 7, softWrapped: false, top: 0 * 10} + expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 8, softWrapped: false, top: 1 * 10} + expect(lineNumberStateForScreenRow(presenter, 8)).toBeUndefined() + + it "updates when the editor's content changes", -> + editor.foldBufferRow(4) + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(50) + presenter = buildPresenter(explicitHeight: 35, scrollTop: 30, tileSize: 2) + + expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() + expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} + expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} + expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} + expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 4} + expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 7} + expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 8} + expectValues lineNumberStateForScreenRow(presenter, 8), {bufferRow: 8} + expectValues lineNumberStateForScreenRow(presenter, 9), {bufferRow: 9} + expect(lineNumberStateForScreenRow(presenter, 10)).toBeUndefined() + + expectStateUpdate presenter, -> + editor.getBuffer().insert([3, Infinity], new Array(25).join("x ")) + + expect(lineNumberStateForScreenRow(presenter, 1)).toBeUndefined() + expectValues lineNumberStateForScreenRow(presenter, 2), {bufferRow: 2} + expectValues lineNumberStateForScreenRow(presenter, 3), {bufferRow: 3} + expectValues lineNumberStateForScreenRow(presenter, 4), {bufferRow: 3} + expectValues lineNumberStateForScreenRow(presenter, 5), {bufferRow: 3} + expectValues lineNumberStateForScreenRow(presenter, 6), {bufferRow: 4} + expectValues lineNumberStateForScreenRow(presenter, 7), {bufferRow: 7} + expectValues lineNumberStateForScreenRow(presenter, 8), {bufferRow: 8} + expectValues lineNumberStateForScreenRow(presenter, 9), {bufferRow: 8} + expect(lineNumberStateForScreenRow(presenter, 10)).toBeUndefined() + + it "correctly handles the first screen line being soft-wrapped", -> + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(30) + presenter = buildPresenter(explicitHeight: 25, scrollTop: 50, tileSize: 2) + + expectValues lineNumberStateForScreenRow(presenter, 5), {screenRow: 5, bufferRow: 3, softWrapped: true} + expectValues lineNumberStateForScreenRow(presenter, 6), {screenRow: 6, bufferRow: 3, softWrapped: true} + expectValues lineNumberStateForScreenRow(presenter, 7), {screenRow: 7, bufferRow: 4, softWrapped: false} + + describe ".decorationClasses", -> + it "adds decoration classes to the relevant line number state objects, both initially and when decorations change", -> + marker1 = editor.markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') + decoration1 = editor.decorateMarker(marker1, type: 'line-number', class: 'a') + presenter = buildPresenter() + marker2 = editor.markBufferRange([[4, 0], [6, 2]], invalidate: 'touch') + decoration2 = editor.decorateMarker(marker2, type: 'line-number', class: 'b') + + expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> editor.getBuffer().insert([5, 0], 'x') + expect(marker1.isValid()).toBe false + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> editor.undo() + expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> marker1.setBufferRange([[2, 0], [4, 2]]) + expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a', 'b'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] + expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> decoration1.destroy() + expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['b'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['b'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['b'] + expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> marker2.destroy() + expect(lineNumberStateForScreenRow(presenter, 2).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 3).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 7).decorationClasses).toBeNull() + + it "honors the 'onlyEmpty' option on line-number decorations", -> + presenter = buildPresenter() + marker = editor.markBufferRange([[4, 0], [6, 1]]) + decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyEmpty: true) + + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() + + expectStateUpdate presenter, -> marker.clearTail() + + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] + + it "honors the 'onlyNonEmpty' option on line-number decorations", -> + presenter = buildPresenter() + marker = editor.markBufferRange([[4, 0], [6, 2]]) + decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyNonEmpty: true) + + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] + + expectStateUpdate presenter, -> marker.clearTail() + + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() + + it "honors the 'onlyHead' option on line-number decorations", -> + presenter = buildPresenter() + marker = editor.markBufferRange([[4, 0], [6, 2]]) + decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a', onlyHead: true) + + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toBeNull() + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toEqual ['a'] + + it "does not decorate the last line of a non-empty line-number decoration range if it ends at column 0", -> + presenter = buildPresenter() + marker = editor.markBufferRange([[4, 0], [6, 0]]) + decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') + + expect(lineNumberStateForScreenRow(presenter, 4).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 5).decorationClasses).toEqual ['a'] + expect(lineNumberStateForScreenRow(presenter, 6).decorationClasses).toBeNull() + + it "does not apply line-number decorations to mini editors", -> + editor.setMini(true) + presenter = buildPresenter() + marker = editor.markBufferRange([[0, 0], [0, 0]]) + decoration = editor.decorateMarker(marker, type: 'line-number', class: 'a') + # A mini editor will have no gutters. + expect(getLineNumberGutterState(presenter)).toBeUndefined() + + expectStateUpdate presenter, -> editor.setMini(false) + expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toEqual ['cursor-line', 'cursor-line-no-selection', 'a'] + + expectStateUpdate presenter, -> editor.setMini(true) + expect(getLineNumberGutterState(presenter)).toBeUndefined() + + it "only applies line-number decorations to screen rows that are spanned by their marker when lines are soft-wrapped", -> + editor.setText("a line that wraps, ok") + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(16) + marker = editor.markBufferRange([[0, 0], [0, 2]]) + editor.decorateMarker(marker, type: 'line-number', class: 'a') + presenter = buildPresenter(explicitHeight: 10) + + expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' + expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toBeNull() + + marker.setBufferRange([[0, 0], [0, Infinity]]) + expect(lineNumberStateForScreenRow(presenter, 0).decorationClasses).toContain 'a' + expect(lineNumberStateForScreenRow(presenter, 1).decorationClasses).toContain 'a' + + describe ".foldable", -> + it "marks line numbers at the start of a foldable region as foldable", -> + presenter = buildPresenter() + expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe false + expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false + expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe false + + it "updates the foldable class on the correct line numbers when the foldable positions change", -> + presenter = buildPresenter() + editor.getBuffer().insert([0, 0], '\n') + expect(lineNumberStateForScreenRow(presenter, 0).foldable).toBe false + expect(lineNumberStateForScreenRow(presenter, 1).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 2).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 3).foldable).toBe false + expect(lineNumberStateForScreenRow(presenter, 4).foldable).toBe false + expect(lineNumberStateForScreenRow(presenter, 5).foldable).toBe true + expect(lineNumberStateForScreenRow(presenter, 6).foldable).toBe false + + it "updates the foldable class on a line number that becomes foldable", -> + presenter = buildPresenter() + expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false + + editor.getBuffer().insert([11, 44], '\n fold me') + expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe true + + editor.undo() + expect(lineNumberStateForScreenRow(presenter, 11).foldable).toBe false + + describe "for a gutter description that corresponds to a custom gutter", -> + describe ".content", -> + getContentForGutterWithName = (presenter, gutterName) -> + fullState = getStateForGutterWithName(presenter, gutterName) + return fullState.content if fullState + + [presenter, gutter, decorationItem, decorationParams] = [] + [marker1, decoration1, marker2, decoration2, marker3, decoration3] = [] + + # Set the scrollTop to 0 to show the very top of the file. + # Set the explicitHeight to make 10 lines visible. + scrollTop = 0 + lineHeight = 10 + explicitHeight = lineHeight * 10 + + beforeEach -> + # At the beginning of each test, decoration1 and decoration2 are in visible range, + # but not decoration3. + presenter = buildPresenter({explicitHeight, scrollTop, lineHeight}) + gutter = editor.addGutter({name: 'test-gutter', visible: true}) + decorationItem = document.createElement('div') + decorationItem.class = 'decoration-item' + decorationParams = + type: 'gutter' + gutterName: 'test-gutter' + class: 'test-class' + item: decorationItem + marker1 = editor.markBufferRange([[0, 0], [1, 0]]) + decoration1 = editor.decorateMarker(marker1, decorationParams) + marker2 = editor.markBufferRange([[9, 0], [12, 0]]) + decoration2 = editor.decorateMarker(marker2, decorationParams) + marker3 = editor.markBufferRange([[13, 0], [14, 0]]) + decoration3 = editor.decorateMarker(marker3, decorationParams) + + # Clear any batched state updates. + presenter.getState() + + it "contains all decorations within the visible buffer range", -> + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBe lineHeight * marker1.getScreenRange().start.row + expect(decorationState[decoration1.id].height).toBe lineHeight * marker1.getScreenRange().getRowCount() + expect(decorationState[decoration1.id].item).toBe decorationItem + expect(decorationState[decoration1.id].class).toBe 'test-class' + + expect(decorationState[decoration2.id].top).toBe lineHeight * marker2.getScreenRange().start.row + expect(decorationState[decoration2.id].height).toBe lineHeight * marker2.getScreenRange().getRowCount() + expect(decorationState[decoration2.id].item).toBe decorationItem + expect(decorationState[decoration2.id].class).toBe 'test-class' + + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates when ::scrollTop changes", -> + # This update will scroll decoration1 out of view, and decoration3 into view. + expectStateUpdate presenter, -> presenter.setScrollTop(scrollTop + lineHeight * 5) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id].top).toBeDefined() + + it "updates when ::explicitHeight changes", -> + # This update will make all three decorations visible. + expectStateUpdate presenter, -> presenter.setExplicitHeight(explicitHeight + lineHeight * 5) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBeDefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id].top).toBeDefined() + + it "updates when ::lineHeight changes", -> + # This update will make all three decorations visible. + expectStateUpdate presenter, -> presenter.setLineHeight(Math.ceil(1.0 * explicitHeight / marker3.getBufferRange().end.row)) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBeDefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id].top).toBeDefined() + + it "updates when the editor's content changes", -> + # This update will add enough lines to push decoration2 out of view. + expectStateUpdate presenter, -> editor.setTextInBufferRange([[8, 0], [9, 0]], '\n\n\n\n\n') + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBeDefined() + expect(decorationState[decoration2.id]).toBeUndefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates when a decoration's marker is modified", -> + # This update will move decoration1 out of view. + expectStateUpdate presenter, -> + newRange = new Range([13, 0], [14, 0]) + marker1.setBufferRange(newRange) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + describe "when a decoration's properties are modified", -> + it "updates the item applied to the decoration, if the decoration item is changed", -> + # This changes the decoration class. The visibility of the decoration should not be affected. + newItem = document.createElement('div') + newItem.class = 'new-decoration-item' + newDecorationParams = + type: 'gutter' + gutterName: 'test-gutter' + class: 'test-class' + item: newItem + expectStateUpdate presenter, -> decoration1.setProperties(newDecorationParams) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].item).toBe newItem + expect(decorationState[decoration2.id].item).toBe decorationItem + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates the class applied to the decoration, if the decoration class is changed", -> + # This changes the decoration item. The visibility of the decoration should not be affected. + newDecorationParams = + type: 'gutter' + gutterName: 'test-gutter' + class: 'new-test-class' + item: decorationItem + expectStateUpdate presenter, -> decoration1.setProperties(newDecorationParams) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].class).toBe 'new-test-class' + expect(decorationState[decoration2.id].class).toBe 'test-class' + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates the type of the decoration, if the decoration type is changed", -> + # This changes the type of the decoration. This should remove the decoration from the gutter. + newDecorationParams = + type: 'line' + gutterName: 'test-gutter' # This is an invalid/meaningless option here, but it shouldn't matter. + class: 'test-class' + item: decorationItem + expectStateUpdate presenter, -> decoration1.setProperties(newDecorationParams) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates the gutter the decoration targets, if the decoration gutterName is changed", -> + # This changes which gutter this decoration applies to. Since this gutter does not exist, + # the decoration should not appear in the customDecorations state. + newDecorationParams = + type: 'gutter' + gutterName: 'test-gutter-2' + class: 'new-test-class' + item: decorationItem + expectStateUpdate presenter, -> decoration1.setProperties(newDecorationParams) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + # After adding the targeted gutter, the decoration will appear in the state for that gutter, + # since it should be visible. + expectStateUpdate presenter, -> editor.addGutter({name: 'test-gutter-2'}) + newGutterDecorationState = getContentForGutterWithName(presenter, 'test-gutter-2') + expect(newGutterDecorationState[decoration1.id].top).toBeDefined() + expect(newGutterDecorationState[decoration2.id]).toBeUndefined() + expect(newGutterDecorationState[decoration3.id]).toBeUndefined() + oldGutterDecorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(oldGutterDecorationState[decoration1.id]).toBeUndefined() + expect(oldGutterDecorationState[decoration2.id].top).toBeDefined() + expect(oldGutterDecorationState[decoration3.id]).toBeUndefined() + + it "updates when the editor's mini state changes, and is cleared when the editor is mini", -> + expectStateUpdate presenter, -> editor.setMini(true) + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState).toBeUndefined() + + # The decorations should return to the original state. + expectStateUpdate presenter, -> editor.setMini(false) + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBeDefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates when a gutter's visibility changes, and is cleared when the gutter is not visible", -> + expectStateUpdate presenter, -> gutter.hide() + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id]).toBeUndefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + # The decorations should return to the original state. + expectStateUpdate presenter, -> gutter.show() + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBeDefined() + expect(decorationState[decoration2.id].top).toBeDefined() + expect(decorationState[decoration3.id]).toBeUndefined() + + it "updates when a gutter is added to the editor", -> + decorationParams = + type: 'gutter' + gutterName: 'test-gutter-2' + class: 'test-class' + marker4 = editor.markBufferRange([[0, 0], [1, 0]]) + decoration4 = editor.decorateMarker(marker4, decorationParams) + expectStateUpdate presenter, -> editor.addGutter({name: 'test-gutter-2'}) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter-2') + expect(decorationState[decoration1.id]).toBeUndefined() + expect(decorationState[decoration2.id]).toBeUndefined() + expect(decorationState[decoration3.id]).toBeUndefined() + expect(decorationState[decoration4.id].top).toBeDefined() + + it "updates when editor lines are folded", -> + oldDimensionsForDecoration1 = + top: lineHeight * marker1.getScreenRange().start.row + height: lineHeight * marker1.getScreenRange().getRowCount() + oldDimensionsForDecoration2 = + top: lineHeight * marker2.getScreenRange().start.row + height: lineHeight * marker2.getScreenRange().getRowCount() + + # Based on the contents of sample.js, this should affect all but the top + # part of decoration1. + expectStateUpdate presenter, -> editor.foldBufferRow(0) + + decorationState = getContentForGutterWithName(presenter, 'test-gutter') + expect(decorationState[decoration1.id].top).toBe oldDimensionsForDecoration1.top + expect(decorationState[decoration1.id].height).not.toBe oldDimensionsForDecoration1.height + # Due to the issue described here: https://github.com/atom/atom/issues/6454, decoration2 + # will be bumped up to the row that was folded and still made visible, instead of being + # entirely collapsed. (The same thing will happen to decoration3.) + expect(decorationState[decoration2.id].top).not.toBe oldDimensionsForDecoration2.top + expect(decorationState[decoration2.id].height).not.toBe oldDimensionsForDecoration2.height + + describe "regardless of what kind of gutter a gutter description corresponds to", -> + [customGutter] = [] + + getStylesForGutterWithName = (presenter, gutterName) -> + fullState = getStateForGutterWithName(presenter, gutterName) + return fullState.styles if fullState + + beforeEach -> + customGutter = editor.addGutter({name: 'test-gutter', priority: -1, visible: true}) + + afterEach -> + customGutter.destroy() + + describe ".scrollHeight", -> + it "is initialized based on ::lineHeight, the number of lines, and ::explicitHeight", -> + presenter = buildPresenter() + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 10 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 10 + + presenter = buildPresenter(explicitHeight: 500) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe 500 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe 500 + + it "updates when the ::lineHeight changes", -> + presenter = buildPresenter() + expectStateUpdate presenter, -> presenter.setLineHeight(20) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 20 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 20 + + it "updates when the line count changes", -> + presenter = buildPresenter() + expectStateUpdate presenter, -> editor.getBuffer().append("\n\n\n") + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe editor.getScreenLineCount() * 10 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe editor.getScreenLineCount() * 10 + + it "updates when ::explicitHeight changes", -> + presenter = buildPresenter() + expectStateUpdate presenter, -> presenter.setExplicitHeight(500) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe 500 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe 500 + + it "adds the computed clientHeight to the computed scrollHeight if editor.scrollPastEnd is true", -> + presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) + expectStateUpdate presenter, -> presenter.setScrollTop(300) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight + + expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", true) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight + presenter.clientHeight - (presenter.lineHeight * 3) + + expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", false) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollHeight).toBe presenter.contentHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollHeight).toBe presenter.contentHeight + + describe ".scrollTop", -> + it "tracks the value of ::scrollTop", -> + presenter = buildPresenter(scrollTop: 10, explicitHeight: 20) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 10 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 10 + expectStateUpdate presenter, -> presenter.setScrollTop(50) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 50 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 50 + + it "never exceeds the computed scrollHeight minus the computed clientHeight", -> + presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) + expectStateUpdate presenter, -> presenter.setScrollTop(100) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + + expectStateUpdate presenter, -> presenter.setExplicitHeight(60) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + + expectStateUpdate presenter, -> presenter.setHorizontalScrollbarHeight(5) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + + expectStateUpdate presenter, -> editor.getBuffer().delete([[8, 0], [12, 0]]) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.scrollHeight - presenter.clientHeight + + # Scroll top only gets smaller when needed as dimensions change, never bigger + scrollTopBefore = presenter.getState().verticalScrollbar.scrollTop + expectStateUpdate presenter, -> editor.getBuffer().insert([9, Infinity], '\n\n\n') + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe scrollTopBefore + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe scrollTopBefore + + it "never goes negative", -> + presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) + expectStateUpdate presenter, -> presenter.setScrollTop(-100) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe 0 + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe 0 + + it "adds the computed clientHeight to the computed scrollHeight if editor.scrollPastEnd is true", -> + presenter = buildPresenter(scrollTop: 10, explicitHeight: 50, horizontalScrollbarHeight: 10) + expectStateUpdate presenter, -> presenter.setScrollTop(300) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - presenter.clientHeight + + atom.config.set("editor.scrollPastEnd", true) + expectStateUpdate presenter, -> presenter.setScrollTop(300) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - (presenter.lineHeight * 3) + + expectStateUpdate presenter, -> atom.config.set("editor.scrollPastEnd", false) + expect(getStylesForGutterWithName(presenter, 'line-number').scrollTop).toBe presenter.contentHeight - presenter.clientHeight + expect(getStylesForGutterWithName(presenter, 'test-gutter').scrollTop).toBe presenter.contentHeight - presenter.clientHeight + + describe ".backgroundColor", -> + it "is assigned to ::gutterBackgroundColor if present, and to ::backgroundColor otherwise", -> + presenter = buildPresenter(backgroundColor: "rgba(255, 0, 0, 0)", gutterBackgroundColor: "rgba(0, 255, 0, 0)") + expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 255, 0, 0)" + expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 255, 0, 0)" + + expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 255, 0)") + expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 0, 255, 0)" + expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 0, 255, 0)" + + expectStateUpdate presenter, -> presenter.setGutterBackgroundColor("rgba(0, 0, 0, 0)") + expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(255, 0, 0, 0)" + expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(255, 0, 0, 0)" + + expectStateUpdate presenter, -> presenter.setBackgroundColor("rgba(0, 0, 255, 0)") + expect(getStylesForGutterWithName(presenter, 'line-number').backgroundColor).toBe "rgba(0, 0, 255, 0)" + expect(getStylesForGutterWithName(presenter, 'test-gutter').backgroundColor).toBe "rgba(0, 0, 255, 0)" + # disabled until we fix an issue with display buffer markers not updating when # they are moved on screen but not in the buffer xdescribe "when the model and view measurements are mutated randomly", -> @@ -2086,7 +2629,7 @@ describe "TextEditorPresenter", -> editor.setEditorWidthInChars(80) presenterParams = model: editor - lineOverdrawMargin: 1 + presenter = new TextEditorPresenter(presenterParams) statements = [] diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index c2d46c569..4ed605409 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -1,4 +1,7 @@ -clipboard = require 'clipboard' +fs = require 'fs-plus' +path = require 'path' +temp = require 'temp' +clipboard = require '../src/safe-clipboard' TextEditor = require '../src/text-editor' describe "TextEditor", -> @@ -19,6 +22,17 @@ describe "TextEditor", -> atom.packages.activatePackage('language-javascript') describe "when the editor is deserialized", -> + it "returns undefined when the path cannot be read", -> + pathToOpen = path.join(temp.mkdirSync(), 'file.txt') + editor1 = null + + waitsForPromise -> + atom.project.open(pathToOpen).then (o) -> editor1 = o + + runs -> + fs.mkdirSync(pathToOpen) + expect(editor1.testSerialization()).toBeUndefined() + 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) @@ -36,23 +50,40 @@ describe "TextEditor", -> it "preserves the invisibles setting", -> atom.config.set('editor.showInvisibles', true) - previousInvisibles = editor.displayBuffer.invisibles + previousInvisibles = editor.tokenizedLineForScreenRow(0).invisibles editor2 = editor.testSerialization() - expect(editor2.displayBuffer.invisibles).toEqual previousInvisibles - expect(editor2.displayBuffer.tokenizedBuffer.invisibles).toEqual previousInvisibles + expect(previousInvisibles).toBeDefined() + expect(editor2.displayBuffer.tokenizedLineForScreenRow(0).invisibles).toEqual previousInvisibles it "updates invisibles if the settings have changed between serialization and deserialization", -> atom.config.set('editor.showInvisibles', true) - previousInvisibles = editor.displayBuffer.invisibles state = editor.serialize() atom.config.set('editor.invisibles', eol: '?') editor2 = TextEditor.deserialize(state) - expect(editor2.displayBuffer.invisibles.eol).toBe '?' - expect(editor2.displayBuffer.tokenizedBuffer.invisibles.eol).toBe '?' + expect(editor.tokenizedLineForScreenRow(0).invisibles.eol).toBe '?' + + 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.open('sample.js', largeFileMode: true).then (o) -> editor = o + + runs -> + buffer = editor.getBuffer() + expect(editor.tokenizedLineForScreenRow(0).text).toBe buffer.lineForRow(0) + expect(editor.tokenizedLineForScreenRow(0).tokens.length).toBe 1 + expect(editor.tokenizedLineForScreenRow(1).tokens.length).toBe 2 # soft tab + expect(editor.tokenizedLineForScreenRow(12).text).toBe buffer.lineForRow(12) + expect(editor.tokenizedLineForScreenRow(0).tokens.length).toBe 1 + expect(editor.getCursorScreenPosition()).toEqual [0, 0] + editor.insertText('hey"') + expect(editor.tokenizedLineForScreenRow(0).tokens.length).toBe 1 + expect(editor.tokenizedLineForScreenRow(1).tokens.length).toBe 2 # sof tab describe "when the editor is constructed with an initialLine option", -> it "positions the cursor on the specified line", -> @@ -229,7 +260,7 @@ describe "TextEditor", -> describe "when the cursor moves", -> it "clears a goal column established by vertical movement", -> editor.setText('b') - editor.setCursorBufferPosition([0,0]) + editor.setCursorBufferPosition([0, 0]) editor.insertNewline() editor.moveUp() editor.insertText('a') @@ -264,7 +295,7 @@ describe "TextEditor", -> expect(editor.getCursorScreenPosition().column).not.toBe 6 # clear the goal column by explicitly setting the cursor position - editor.setCursorScreenPosition([4,6]) + editor.setCursorScreenPosition([4, 6]) expect(editor.getCursorScreenPosition().column).toBe 6 editor.moveDown() @@ -319,7 +350,7 @@ describe "TextEditor", -> describe "when there is a selection", -> beforeEach -> - editor.setSelectedBufferRange([[4, 9],[5, 10]]) + editor.setSelectedBufferRange([[4, 9], [5, 10]]) it "moves above the selection", -> cursor = editor.getLastCursor() @@ -332,7 +363,7 @@ describe "TextEditor", -> editor.moveUp() expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0,0] + 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", -> @@ -398,7 +429,7 @@ describe "TextEditor", -> describe "when there is a selection", -> beforeEach -> - editor.setSelectedBufferRange([[4, 9],[5, 10]]) + editor.setSelectedBufferRange([[4, 9], [5, 10]]) it "moves below the selection", -> cursor = editor.getLastCursor() @@ -412,7 +443,7 @@ describe "TextEditor", -> editor.moveDown() expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12,2] + expect(cursor1.getBufferPosition()).toEqual [12, 2] describe ".moveLeft()", -> it "moves the cursor by one column to the left", -> @@ -483,7 +514,7 @@ describe "TextEditor", -> describe "when there is a selection", -> beforeEach -> - editor.setSelectedBufferRange([[5, 22],[5, 27]]) + editor.setSelectedBufferRange([[5, 22], [5, 27]]) it "moves to the left of the selection", -> cursor = editor.getLastCursor() @@ -500,7 +531,7 @@ describe "TextEditor", -> [cursor1, cursor2] = editor.getCursors() editor.moveLeft() expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [0,0] + expect(cursor1.getBufferPosition()).toEqual [0, 0] describe ".moveRight()", -> it "moves the cursor by one column to the right", -> @@ -547,7 +578,7 @@ describe "TextEditor", -> lastLine = buffer.lineForRow(lastLineIndex) expect(lastLine.length).toBeGreaterThan(0) - lastPosition = { row: lastLineIndex, column: lastLine.length } + lastPosition = {row: lastLineIndex, column: lastLine.length} editor.setCursorScreenPosition(lastPosition) editor.moveRight() @@ -555,7 +586,7 @@ describe "TextEditor", -> describe "when there is a selection", -> beforeEach -> - editor.setSelectedBufferRange([[5, 22],[5, 27]]) + editor.setSelectedBufferRange([[5, 22], [5, 27]]) it "moves to the left of the selection", -> cursor = editor.getLastCursor() @@ -572,23 +603,23 @@ describe "TextEditor", -> editor.moveRight() expect(editor.getCursors()).toEqual [cursor1] - expect(cursor1.getBufferPosition()).toEqual [12,2] + 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.setCursorScreenPosition [11, 1] + editor.addCursorAtScreenPosition [12, 0] editor.moveToTop() expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [0,0] + expect(editor.getCursorBufferPosition()).toEqual [0, 0] describe ".moveToBottom()", -> it "moves the cusor to the bottom of the buffer", -> - editor.setCursorScreenPosition [0,0] - editor.addCursorAtScreenPosition [1,0] + editor.setCursorScreenPosition [0, 0] + editor.addCursorAtScreenPosition [1, 0] editor.moveToBottom() expect(editor.getCursors().length).toBe 1 - expect(editor.getCursorBufferPosition()).toEqual [12,2] + expect(editor.getCursorBufferPosition()).toEqual [12, 2] describe ".moveToBeginningOfScreenLine()", -> describe "when soft wrap is on", -> @@ -601,14 +632,14 @@ describe "TextEditor", -> expect(cursor.getScreenPosition()).toEqual [1, 0] describe "when soft wrap is off", -> - it "moves cursor to the beginning of then line", -> - editor.setCursorScreenPosition [0,5] - editor.addCursorAtScreenPosition [1,7] + 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] + expect(cursor1.getBufferPosition()).toEqual [0, 0] + expect(cursor2.getBufferPosition()).toEqual [1, 0] describe ".moveToEndOfScreenLine()", -> describe "when soft wrap is on", -> @@ -622,13 +653,13 @@ describe "TextEditor", -> describe "when soft wrap is off", -> it "moves cursor to the end of line", -> - editor.setCursorScreenPosition [0,0] - editor.addCursorAtScreenPosition [1,0] + 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] + expect(cursor1.getBufferPosition()).toEqual [0, 29] + expect(cursor2.getBufferPosition()).toEqual [1, 30] describe ".moveToBeginningOfLine()", -> it "moves cursor to the beginning of the buffer line", -> @@ -653,58 +684,58 @@ describe "TextEditor", -> 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.setEditorWidthInChars(10) - editor.setCursorScreenPosition [2,5] - editor.addCursorAtScreenPosition [8,7] + 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] + 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] + 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.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] + 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] + 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.setCursorScreenPosition [1, 2] editor.moveToFirstCharacterOfLine() cursor = editor.getLastCursor() - expect(cursor.getBufferPosition()).toEqual [1,0] + 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", -> atom.config.set('editor.showInvisibles', true) - editor.setCursorScreenPosition [1,7] + editor.setCursorScreenPosition [1, 7] editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1,2] + expect(editor.getCursorBufferPosition()).toEqual [1, 2] editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1,0] + 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", -> atom.config.set('editor.showInvisibles', true) buffer.setTextInRange([[1, 0], [1, Infinity]], '\t\t\ta', normalizeLineEndings: false) - editor.setCursorScreenPosition [1,7] + editor.setCursorScreenPosition [1, 7] editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1,3] + expect(editor.getCursorBufferPosition()).toEqual [1, 3] editor.moveToFirstCharacterOfLine() - expect(editor.getCursorBufferPosition()).toEqual [1,0] + expect(editor.getCursorBufferPosition()).toEqual [1, 0] describe ".moveToBeginningOfWord()", -> it "moves the cursor to the beginning of the word", -> @@ -794,9 +825,9 @@ describe "TextEditor", -> 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] + editor.setCursorBufferPosition [0, 6] + editor.addCursorAtBufferPosition [1, 11] + editor.addCursorAtBufferPosition [2, 0] [cursor1, cursor2, cursor3] = editor.getCursors() editor.moveToBeginningOfNextWord() @@ -807,7 +838,7 @@ describe "TextEditor", -> # When the cursor is on whitespace editor.setText("ab cde- ") - editor.setCursorBufferPosition [0,2] + editor.setCursorBufferPosition [0, 2] cursor = editor.getLastCursor() editor.moveToBeginningOfNextWord() @@ -831,35 +862,29 @@ describe "TextEditor", -> describe ".moveToBeginningOfNextParagraph()", -> it "moves the cursor before the first line of the next paragraph", -> - editor.setCursorBufferPosition [0,6] - cursor = editor.getLastCursor() + editor.setCursorBufferPosition [0, 6] + editor.foldBufferRow(4) editor.moveToBeginningOfNextParagraph() - - expect(cursor.getBufferPosition()).toEqual { row : 10, column : 0 } + expect(editor.getCursorBufferPosition()).toEqual [10, 0] editor.setText("") - editor.setCursorBufferPosition [0,0] - cursor = editor.getLastCursor() + editor.setCursorBufferPosition [0, 0] editor.moveToBeginningOfNextParagraph() - - expect(cursor.getBufferPosition()).toEqual [0, 0] + expect(editor.getCursorBufferPosition()).toEqual [0, 0] describe ".moveToBeginningOfPreviousParagraph()", -> it "moves the cursor before the first line of the pevious paragraph", -> - editor.setCursorBufferPosition [10,0] - cursor = editor.getLastCursor() + editor.setCursorBufferPosition [10, 0] + editor.foldBufferRow(4) editor.moveToBeginningOfPreviousParagraph() - - expect(cursor.getBufferPosition()).toEqual { row : 0, column : 0 } + expect(editor.getCursorBufferPosition()).toEqual [0, 0] editor.setText("") - editor.setCursorBufferPosition [0,0] - cursor = editor.getLastCursor() + editor.setCursorBufferPosition [0, 0] editor.moveToBeginningOfPreviousParagraph() - - expect(cursor.getBufferPosition()).toEqual [0, 0] + 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", -> @@ -891,6 +916,12 @@ describe "TextEditor", -> editor.setCursorBufferPosition([3, 1]) expect(editor.getCurrentParagraphBufferRange()).toBeUndefined() + 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) @@ -908,20 +939,19 @@ describe "TextEditor", -> 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.marker).toBe cursor1.marker + 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]) + cursor1 = editor.addCursorAtBufferPosition([1, 4]) + cursor2 = editor.addCursorAtBufferPosition([1, 4]) expect(cursor2.marker).toBe cursor1.marker describe "autoscroll", -> beforeEach -> - editor.manageScrollPosition = true editor.setVerticalScrollMargin(2) editor.setHorizontalScrollMargin(2) editor.setLineHeightInPixels(10) @@ -971,9 +1001,8 @@ describe "TextEditor", -> it "scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor", -> editor.setScrollRight(editor.getScrollWidth()) - editor.setCursorScreenPosition([6, 62]) - expect(editor.getScrollRight()).toBe editor.getScrollWidth() + editor.setCursorScreenPosition([6, 62], autoscroll: false) editor.moveLeft() expect(editor.getScrollLeft()).toBe 59 * 10 @@ -994,6 +1023,45 @@ describe "TextEditor", -> editor.undo() expect(editor.getScrollTop()).toBe 0 + it "doesn't scroll when the cursor moves into the visible area", -> + editor.setCursorBufferPosition([0, 0]) + editor.setScrollTop(40) + expect(editor.getVisibleRowRange()).toEqual([4, 9]) + editor.setCursorBufferPosition([6, 0]) + expect(editor.getScrollTop()).toBe 40 + + it "honors the autoscroll option on cursor and selection manipulation methods", -> + expect(editor.getScrollTop()).toBe 0 + editor.addCursorAtScreenPosition([11, 11], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.addCursorAtBufferPosition([11, 11], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.setCursorScreenPosition([11, 11], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.setCursorBufferPosition([11, 11], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.addSelectionForBufferRange([[11, 11], [11, 11]], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.addSelectionForScreenRange([[11, 11], [11, 12]], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.setSelectedBufferRange([[11, 0], [11, 1]], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.setSelectedScreenRange([[11, 0], [11, 6]], autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + editor.clearSelections(autoscroll: false) + expect(editor.getScrollTop()).toBe 0 + + editor.addSelectionForScreenRange([[0, 0], [0, 4]]) + + editor.getCursors()[0].setScreenPosition([11, 11], autoscroll: true) + expect(editor.getScrollTop()).toBeGreaterThan 0 + editor.getCursors()[0].setBufferPosition([0, 0], autoscroll: true) + expect(editor.getScrollTop()).toBe 0 + editor.getSelections()[0].setScreenRange([[11, 0], [11, 4]], autoscroll: true) + expect(editor.getScrollTop()).toBeGreaterThan 0 + editor.getSelections()[0].setBufferRange([[0, 0], [0, 4]], autoscroll: true) + expect(editor.getScrollTop()).toBe 0 + describe '.logCursorScope()', -> beforeEach -> spyOn(atom.notifications, 'addInfo') @@ -1027,28 +1095,28 @@ describe "TextEditor", -> 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]]]) + 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]] + 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]] + 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]] + 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]] + 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]]]) + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]], [[2, 15], [3, 25]]]) [selection1, selection2, selection3] = editor.getSelections() editor.selectDown() @@ -1057,7 +1125,7 @@ describe "TextEditor", -> 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) + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[1, 10], [1, 20]]], reversed: true) [selection1, selection2] = editor.getSelections() editor.selectUp() @@ -1067,7 +1135,7 @@ describe "TextEditor", -> 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) + editor.setSelectedBufferRanges([[[0, 9], [0, 13]], [[0, 13], [1, 20]]], reversed: true) [selection1, selection2] = editor.getSelections() editor.selectLeft() @@ -1076,7 +1144,7 @@ describe "TextEditor", -> expect(selection1.isReversed()).toBeTruthy() it "merges selections when they intersect when moving right", -> - editor.setSelectedBufferRanges([[[0,9], [0,14]], [[0,14], [1,20]]]) + editor.setSelectedBufferRanges([[[0, 9], [0, 14]], [[0, 14], [1, 20]]]) [selection1, selection2] = editor.getSelections() editor.selectRight() @@ -1086,24 +1154,24 @@ describe "TextEditor", -> 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]]]) + 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]] + 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]] + 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]] + 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]] + 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", -> @@ -1176,22 +1244,22 @@ describe "TextEditor", -> describe ".selectToTop()", -> it "selects text from cusor position to the top of the buffer", -> - editor.setCursorScreenPosition [11,2] - editor.addCursorAtScreenPosition [10,0] + 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.getCursorBufferPosition()).toEqual [0, 0] + expect(editor.getLastSelection().getBufferRange()).toEqual [[0, 0], [11, 2]] expect(editor.getLastSelection().isReversed()).toBeTruthy() describe ".selectToBottom()", -> it "selects text from cusor position to the bottom of the buffer", -> - editor.setCursorScreenPosition [10,0] - editor.addCursorAtScreenPosition [9,3] + 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.getCursorBufferPosition()).toEqual [12, 2] + expect(editor.getLastSelection().getBufferRange()).toEqual [[9, 3], [12, 2]] expect(editor.getLastSelection().isReversed()).toBeFalsy() describe ".selectAll()", -> @@ -1201,60 +1269,59 @@ describe "TextEditor", -> describe ".selectToBeginningOfLine()", -> it "selects text from cusor position to beginning of line", -> - editor.setCursorScreenPosition [12,2] - editor.addCursorAtScreenPosition [11,3] + 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(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.getBufferRange()).toEqual [[12, 0], [12, 2]] expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[11,0], [11,3]] + expect(selection2.getBufferRange()).toEqual [[11, 0], [11, 3]] expect(selection2.isReversed()).toBeTruthy() describe ".selectToEndOfLine()", -> it "selects text from cusor position to end of line", -> - editor.setCursorScreenPosition [12,0] - editor.addCursorAtScreenPosition [11,3] + 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(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.getBufferRange()).toEqual [[12, 0], [12, 2]] expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[11,3], [11,44]] + expect(selection2.getBufferRange()).toEqual [[11, 3], [11, 44]] expect(selection2.isReversed()).toBeFalsy() describe ".selectLinesContainingCursors()", -> it "selects the entire line (including newlines) at given row", -> editor.setCursorScreenPosition([1, 2]) editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[1,0], [2,0]] + 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]] + expect(editor.getSelectedBufferRange()).toEqual [[12, 0], [12, 2]] editor.setCursorBufferPosition([0, 2]) editor.selectLinesContainingCursors() editor.selectLinesContainingCursors() - expect(editor.getSelectedBufferRange()).toEqual [[0,0], [2,0]] + expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [2, 0]] it "autoscrolls to the selection", -> - editor.manageScrollPosition = true editor.setLineHeightInPixels(10) editor.setDefaultCharWidth(10) editor.setHeight(50) @@ -1270,59 +1337,59 @@ describe "TextEditor", -> describe ".selectToBeginningOfWord()", -> it "selects text from cusor position to beginning of word", -> - editor.setCursorScreenPosition [0,13] - editor.addCursorAtScreenPosition [3,49] + 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(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.getBufferRange()).toEqual [[0, 4], [0, 13]] expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[3,47], [3,49]] + expect(selection2.getBufferRange()).toEqual [[3, 47], [3, 49]] expect(selection2.isReversed()).toBeTruthy() describe ".selectToEndOfWord()", -> it "selects text from cusor position to end of word", -> - editor.setCursorScreenPosition [0,4] - editor.addCursorAtScreenPosition [3,48] + 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(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.getBufferRange()).toEqual [[0, 4], [0, 13]] expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3,48], [3,50]] + expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 50]] expect(selection2.isReversed()).toBeFalsy() describe ".selectToBeginningOfNextWord()", -> it "selects text from cusor position to beginning of next word", -> - editor.setCursorScreenPosition [0,4] - editor.addCursorAtScreenPosition [3,48] + 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(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.getBufferRange()).toEqual [[0, 4], [0, 14]] expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[3,48], [3,51]] + expect(selection2.getBufferRange()).toEqual [[3, 48], [3, 51]] expect(selection2.isReversed()).toBeFalsy() describe ".selectToPreviousWordBoundary()", -> @@ -1336,13 +1403,13 @@ describe "TextEditor", -> expect(editor.getSelections().length).toBe 4 [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0,8], [0,4]] + expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 4]] expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[2,0], [1,30]] + expect(selection2.getBufferRange()).toEqual [[2, 0], [1, 30]] expect(selection2.isReversed()).toBeTruthy() - expect(selection3.getBufferRange()).toEqual [[3,4], [3,0]] + expect(selection3.getBufferRange()).toEqual [[3, 4], [3, 0]] expect(selection3.isReversed()).toBeTruthy() - expect(selection4.getBufferRange()).toEqual [[3,14], [3,13]] + expect(selection4.getBufferRange()).toEqual [[3, 14], [3, 13]] expect(selection4.isReversed()).toBeTruthy() describe ".selectToNextWordBoundary()", -> @@ -1356,13 +1423,13 @@ describe "TextEditor", -> expect(editor.getSelections().length).toBe 4 [selection1, selection2, selection3, selection4] = editor.getSelections() - expect(selection1.getBufferRange()).toEqual [[0,8], [0,13]] + expect(selection1.getBufferRange()).toEqual [[0, 8], [0, 13]] expect(selection1.isReversed()).toBeFalsy() - expect(selection2.getBufferRange()).toEqual [[2,40], [3,0]] + expect(selection2.getBufferRange()).toEqual [[2, 40], [3, 0]] expect(selection2.isReversed()).toBeFalsy() - expect(selection3.getBufferRange()).toEqual [[4,0], [4,4]] + expect(selection3.getBufferRange()).toEqual [[4, 0], [4, 4]] expect(selection3.isReversed()).toBeFalsy() - expect(selection4.getBufferRange()).toEqual [[3,30], [3,31]] + expect(selection4.getBufferRange()).toEqual [[3, 30], [3, 31]] expect(selection4.isReversed()).toBeFalsy() describe ".selectWordsContainingCursors()", -> @@ -1424,27 +1491,27 @@ describe "TextEditor", -> 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.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(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.getBufferRange()).toEqual [[0, 0], [0, 5]] expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1,2], [1,7]] + 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.getBufferRange()).toEqual [[0, 0], [0, 5]] expect(selection1.isReversed()).toBeTruthy() - expect(selection2.getBufferRange()).toEqual [[1,0], [1,7]] + expect(selection2.getBufferRange()).toEqual [[1, 0], [1, 7]] expect(selection2.isReversed()).toBeTruthy() describe ".setSelectedBufferRanges(ranges)", -> @@ -1473,7 +1540,7 @@ describe "TextEditor", -> describe "when the 'preserveFolds' option is false (the default)", -> it "removes folds that contain the selections", -> - editor.setSelectedBufferRange([[0,0], [0,0]]) + editor.setSelectedBufferRange([[0, 0], [0, 0]]) editor.createFold(1, 4) editor.createFold(2, 3) editor.createFold(6, 8) @@ -1487,7 +1554,7 @@ describe "TextEditor", -> describe "when the 'preserveFolds' option is true", -> it "does not remove folds that contain the selections", -> - editor.setSelectedBufferRange([[0,0], [0,0]]) + editor.setSelectedBufferRange([[0, 0], [0, 0]]) editor.createFold(1, 4) editor.createFold(6, 8) editor.setSelectedBufferRanges([[[2, 2], [3, 3]], [[6, 0], [6, 1]]], preserveFolds: true) @@ -1505,9 +1572,12 @@ describe "TextEditor", -> editor.setSelectedScreenRanges([[[6, 2], [6, 4]]]) expect(editor.getSelectedScreenRanges()).toEqual [[[6, 2], [6, 4]]] - it "merges intersecting selections and unfolds the fold", -> - editor.setSelectedScreenRanges([[[2, 2], [3, 3]], [[3, 0], [5, 5]]]) - expect(editor.getSelectedScreenRanges()).toEqual [[[2, 2], [8, 5]]] + 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 "recyles existing selection instances", -> selection = editor.getLastSelection() @@ -1518,24 +1588,28 @@ describe "TextEditor", -> expect(selection1.getScreenRange()).toEqual [[2, 2], [3, 4]] describe ".setSelectedBufferRange(range)", -> - describe "when the 'autoscroll' option is true", -> - it "autoscrolls to the selection", -> - editor.manageScrollPosition = true - editor.setLineHeightInPixels(10) - editor.setDefaultCharWidth(10) - editor.setHeight(50) - editor.setWidth(50) - editor.setHorizontalScrollbarHeight(0) + it "autoscrolls the selection if it is last unless the 'autoscroll' option is false", -> + editor.setVerticalScrollMargin(2) + editor.setHorizontalScrollMargin(2) + editor.setLineHeightInPixels(10) + editor.setDefaultCharWidth(10) + editor.setHeight(70) + editor.setWidth(100) + editor.setHorizontalScrollbarHeight(0) - expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollTop()).toBe 0 - editor.setSelectedBufferRange([[5, 6], [6, 8]], autoscroll: true) - expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 - expect(editor.getScrollRight()).toBe 50 + editor.setSelectedBufferRange([[5, 6], [6, 8]]) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (8 + editor.getHorizontalScrollMargin()) * 10 - editor.setSelectedBufferRange([[6, 6], [6, 8]], autoscroll: true) - expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 - expect(editor.getScrollRight()).toBe (8 + editor.getHorizontalScrollMargin()) * 10 + editor.setSelectedBufferRange([[0, 0], [0, 0]]) + expect(editor.getScrollTop()).toBe 0 + expect(editor.getScrollLeft()).toBe 0 + + editor.setSelectedBufferRange([[6, 6], [6, 8]]) + expect(editor.getScrollBottom()).toBe (7 + editor.getVerticalScrollMargin()) * 10 + expect(editor.getScrollRight()).toBe (8 + editor.getHorizontalScrollMargin()) * 10 describe ".selectMarker(marker)", -> describe "if the marker is valid", -> @@ -1557,16 +1631,15 @@ describe "TextEditor", -> expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [0, 0]], [[3, 4], [5, 6]]] it "autoscrolls to the added selection if needed", -> - editor.manageScrollPosition = true - + editor.setVerticalScrollMargin(2) + editor.setHorizontalScrollMargin(2) editor.setLineHeightInPixels(10) editor.setDefaultCharWidth(10) - editor.setHeight(50) - editor.setWidth(50) - + editor.setHeight(80) + editor.setWidth(100) editor.addSelectionForBufferRange([[8, 10], [8, 15]]) - expect(editor.getScrollTop()).toBe 75 - expect(editor.getScrollLeft()).toBe 160 + expect(editor.getScrollBottom()).toBe (9 * 10) + (2 * 10) + expect(editor.getScrollRight()).toBe (15 * 10) + (2 * 10) describe ".addSelectionBelow()", -> describe "when the selection is non-empty", -> @@ -1623,7 +1696,54 @@ describe "TextEditor", -> [[6, 22], [6, 28]] ] + it "can add selections to soft-wrapped line segments", -> + editor.setSoftWrapped(true) + editor.setEditorWidthInChars(40) + + 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.project.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.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() @@ -1687,7 +1807,54 @@ describe "TextEditor", -> [[3, 22], [3, 38]] ] + it "can add selections to soft-wrapped line segments", -> + editor.setSoftWrapped(true) + 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.project.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.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() @@ -1825,7 +1992,6 @@ describe "TextEditor", -> expect(cursor2.getBufferPosition()).toEqual [2, 7] it "autoscrolls to the last cursor", -> - editor.manageScrollPosition = true editor.setCursorScreenPosition([1, 2]) editor.addCursorAtScreenPosition([10, 4]) editor.setLineHeightInPixels(10) @@ -1838,7 +2004,7 @@ describe "TextEditor", -> 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.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 22], [0, 24]]]) editor.insertText("x") [cursor1, cursor2] = editor.getCursors() @@ -1868,8 +2034,8 @@ describe "TextEditor", -> describe "when there is a selection that ends on a folded line", -> it "destroys the selection", -> - editor.createFold(2,4) - editor.setSelectedBufferRange([[1,0], [2,0]]) + editor.createFold(2, 4) + editor.setSelectedBufferRange([[1, 0], [2, 0]]) editor.insertText('holy cow') expect(editor.tokenizedLineForScreenRow(2).fold).toBeUndefined() @@ -1990,17 +2156,17 @@ describe "TextEditor", -> expect(editor.lineTextForBufferRow(9)).toBe " }" [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [4,0] - expect(cursor2.getBufferPosition()).toEqual [8,0] + 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.setCursorBufferPosition([0, 2]) editor.insertNewlineBelow() - expect(editor.getCursorBufferPosition()).toEqual [1,0] + expect(editor.getCursorBufferPosition()).toEqual [1, 0] editor.undo() - expect(editor.getCursorBufferPosition()).toEqual [0,2] + 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", -> atom.config.set("editor.autoIndent", true) @@ -2014,48 +2180,48 @@ describe "TextEditor", -> 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.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.setCursorBufferPosition([3, 4]) editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [3,0] + 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] + expect(editor.getCursorBufferPosition()).toEqual [3, 4] it "indents the new line to the correct level when editor.autoIndent is true", -> atom.config.set('editor.autoIndent', true) editor.setText(' var test') - editor.setCursorBufferPosition([0,2]) + editor.setCursorBufferPosition([0, 2]) editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [0,2] + 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.setCursorBufferPosition([1, 2]) editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [1,2] + 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.setCursorBufferPosition([1, 1]) editor.insertNewlineAbove() - expect(editor.getCursorBufferPosition()).toEqual [1,2] + expect(editor.getCursorBufferPosition()).toEqual [1, 2] expect(editor.lineTextForBufferRow(0)).toBe 'function() {' expect(editor.lineTextForBufferRow(1)).toBe ' ' expect(editor.lineTextForBufferRow(2)).toBe '}' @@ -2063,7 +2229,7 @@ describe "TextEditor", -> 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", -> atom.config.set('editor.autoIndent', true) - editor.setCursorBufferPosition([9,2]) + editor.setCursorBufferPosition([9, 2]) editor.insertNewline() expect(editor.lineTextForBufferRow(10)).toBe ' };' @@ -2113,9 +2279,9 @@ describe "TextEditor", -> describe "when the cursor is on the first column of a line below a fold", -> it "deletes the folded lines", -> - editor.setCursorScreenPosition([4,0]) + editor.setCursorScreenPosition([4, 0]) editor.foldCurrentRow() - editor.setCursorScreenPosition([5,0]) + editor.setCursorScreenPosition([5, 0]) editor.backspace() expect(buffer.lineForRow(4)).toBe " return sort(left).concat(pivot).concat(sort(right));" @@ -2123,9 +2289,9 @@ describe "TextEditor", -> describe "when the cursor is in the middle of a line below a fold", -> it "backspaces as normal", -> - editor.setCursorScreenPosition([4,0]) + editor.setCursorScreenPosition([4, 0]) editor.foldCurrentRow() - editor.setCursorScreenPosition([5,5]) + editor.setCursorScreenPosition([5, 5]) editor.backspace() expect(buffer.lineForRow(7)).toBe " }" @@ -2190,18 +2356,18 @@ describe "TextEditor", -> expect(editor.lineTextForBufferRow(5)).toBe " }" [cursor1, cursor2] = editor.getCursors() - expect(cursor1.getBufferPosition()).toEqual [2,40] - expect(cursor2.getBufferPosition()).toEqual [4,30] + 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.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.setSelectedBufferRange([[3, 0], [4, 0]]) editor.foldBufferRow(4) editor.backspace() @@ -2210,10 +2376,66 @@ describe "TextEditor", -> describe "when there are multiple selections", -> it "removes all selected text", -> - editor.setSelectedBufferRanges([[[0,4], [0,13]], [[0,16], [0,24]]]) + 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", -> @@ -2350,8 +2572,8 @@ describe "TextEditor", -> describe "when the cursor is on a folded line", -> it "removes the lines contained by the fold", -> editor.setSelectedBufferRange([[2, 0], [2, 0]]) - editor.createFold(2,4) - editor.createFold(2,6) + editor.createFold(2, 4) + editor.createFold(2, 6) oldLine7 = buffer.lineForRow(7) oldLine8 = buffer.lineForRow(8) @@ -2406,8 +2628,8 @@ describe "TextEditor", -> 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] + 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", -> @@ -2420,7 +2642,7 @@ describe "TextEditor", -> 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.setSelectedBufferRanges([[[0, 4], [0, 13]], [[0, 16], [0, 24]]]) editor.delete() expect(editor.lineTextForBufferRow(0)).toBe 'var = () {' @@ -2595,6 +2817,20 @@ describe "TextEditor", -> """ + 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", -> @@ -2616,7 +2852,7 @@ describe "TextEditor", -> 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.setSelectedBufferRanges([[[2, 20], [2, 30]], [[3, 20], [3, 20]]]) editor.cutToEndOfLine() @@ -2626,7 +2862,7 @@ describe "TextEditor", -> describe ".copySelectedText()", -> it "copies selected text onto the clipboard", -> - editor.setSelectedBufferRanges([[[0,4], [0,13]], [[1,6], [1, 10]], [[2,8], [2, 13]]]) + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]], [[2, 8], [2, 13]]]) editor.copySelectedText() expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" @@ -2657,6 +2893,20 @@ describe "TextEditor", -> [[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 ".pasteText()", -> copyText = (text, {startColumn, textEditor}={}) -> startColumn ?= 0 @@ -2665,7 +2915,7 @@ describe "TextEditor", -> textEditor.insertText(text) numberOfNewlines = text.match(/\n/g)?.length endColumn = text.match(/[^\n]*$/)[0]?.length - textEditor.getLastSelection().setBufferRange([[0,startColumn], [numberOfNewlines,endColumn]]) + textEditor.getLastSelection().setBufferRange([[0, startColumn], [numberOfNewlines, endColumn]]) textEditor.cutSelectedText() it "pastes text into the buffer", -> @@ -2679,34 +2929,59 @@ describe "TextEditor", -> beforeEach -> atom.config.set("editor.autoIndentOnPaste", true) - describe "when only whitespace precedes the cursor", -> + 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", -> - expect(editor.indentationForBufferRow(5)).toBe(3) - 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 block + # 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) - expect(editor.indentationForBufferRow(6)).toBe(4) - expect(editor.indentationForBufferRow(7)).toBe(5) - # Preserve the indentation of the next row - expect(editor.indentationForBufferRow(8)).toBe(3) + atom.clipboard.write("/**\n\t * testing\n\t * indent\n\t **/\n", indentBasis: 1) + editor.setCursorBufferPosition([5, 0]) + editor.pasteText() - describe "when non-whitespace characters precede the cursor", -> - it "does not auto-indent the first line being pasted", -> + # 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 a single line of text", -> + 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(); - } + if (x) { + y(); + } """ - atom.clipboard.write(" z();") + 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 -> @@ -2751,8 +3026,12 @@ describe "TextEditor", -> editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) editor.copySelectedText() - it "pastes each selection separately into the buffer", -> - 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() @@ -2805,7 +3084,7 @@ describe "TextEditor", -> describe "when nothing is selected", -> describe "when softTabs is enabled", -> it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0,3], [0,3]]) + 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()]] @@ -2814,7 +3093,7 @@ describe "TextEditor", -> it "indents line and retains selection", -> convertToHardTabs(buffer) editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0,3], [0,3]]) + 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]] @@ -2822,7 +3101,7 @@ describe "TextEditor", -> describe "when one line is selected", -> describe "when softTabs is enabled", -> it "indents line and retains selection", -> - editor.setSelectedBufferRange([[0,4], [0,14]]) + 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()]] @@ -2831,7 +3110,7 @@ describe "TextEditor", -> it "indents line and retains selection", -> convertToHardTabs(buffer) editor.setSoftTabs(false) - editor.setSelectedBufferRange([[0,4], [0,14]]) + 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]] @@ -2839,7 +3118,7 @@ describe "TextEditor", -> 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.setSelectedBufferRange([[9, 1], [11, 15]]) editor.indentSelectedRows() expect(buffer.lineForRow(9)).toBe " };" expect(buffer.lineForRow(10)).toBe "" @@ -2847,7 +3126,7 @@ describe "TextEditor", -> 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.setSelectedBufferRange([[9, 1], [11, 0]]) editor.indentSelectedRows() expect(buffer.lineForRow(9)).toBe " };" expect(buffer.lineForRow(10)).toBe "" @@ -2858,7 +3137,7 @@ describe "TextEditor", -> it "indents selected lines (that are not empty) and retains selection", -> convertToHardTabs(buffer) editor.setSoftTabs(false) - editor.setSelectedBufferRange([[9,1], [11,15]]) + editor.setSelectedBufferRange([[9, 1], [11, 15]]) editor.indentSelectedRows() expect(buffer.lineForRow(9)).toBe "\t\t};" expect(buffer.lineForRow(10)).toBe "" @@ -2868,7 +3147,7 @@ describe "TextEditor", -> describe ".outdentSelectedRows()", -> describe "when nothing is selected", -> it "outdents line and retains selection", -> - editor.setSelectedBufferRange([[1,3], [1,3]]) + 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()]] @@ -2905,14 +3184,14 @@ describe "TextEditor", -> describe "when one line is selected", -> it "outdents line and retains editor", -> - editor.setSelectedBufferRange([[1,4], [1,14]]) + 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.setSelectedBufferRange([[0, 1], [3, 15]]) editor.outdentSelectedRows() expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" @@ -2921,7 +3200,7 @@ describe "TextEditor", -> 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.setSelectedBufferRange([[0, 1], [3, 0]]) editor.outdentSelectedRows() expect(buffer.lineForRow(0)).toBe "var quicksort = function () {" expect(buffer.lineForRow(1)).toBe "var sort = function(items) {" @@ -2934,7 +3213,7 @@ describe "TextEditor", -> 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.getLastSelection().setBufferRange([[2, 0], [6, 0]]) editor.autoIndentSelectedRows() expect(editor.lineTextForBufferRow(2)).toBe " function() {" @@ -3170,8 +3449,8 @@ describe "TextEditor", -> expect(editor.getCursors().length).toBe 2 expect(editor.getCursors()).toEqual [cursor1, cursor3] - expect(cursor1.getBufferPosition()).toEqual [0,0] - expect(cursor3.getBufferPosition()).toEqual [1,2] + expect(cursor1.getBufferPosition()).toEqual [0, 0] + expect(cursor3.getBufferPosition()).toEqual [1, 2] describe 'reading text', -> it '.lineTextForScreenRow(row)', -> @@ -3209,6 +3488,20 @@ describe "TextEditor", -> 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) @@ -3315,7 +3608,7 @@ describe "TextEditor", -> describe "when there is a selection", -> it "upper cases the current selection", -> editor.buffer.setText("abc") - editor.setSelectedBufferRange([[0,0], [0,2]]) + editor.setSelectedBufferRange([[0, 0], [0, 2]]) editor.upperCase() expect(editor.lineTextForBufferRow(0)).toBe 'ABc' expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] @@ -3332,7 +3625,7 @@ describe "TextEditor", -> describe "when there is a selection", -> it "lower cases the current selection", -> editor.buffer.setText("ABC") - editor.setSelectedBufferRange([[0,0], [0,2]]) + editor.setSelectedBufferRange([[0, 0], [0, 2]]) editor.lowerCase() expect(editor.lineTextForBufferRow(0)).toBe 'abC' expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 2]] @@ -3413,17 +3706,18 @@ describe "TextEditor", -> it "returns the indent level when the line has only leading tabs", -> expect(editor.indentLevelForLine("\t\thello")).toBe(2) - it "returns the indent level when the line has mixed leading whitespace 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(3.5) + 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(1) + expect(editor.indentLevelForLine(" \thello")).toBe(1) + expect(editor.indentLevelForLine(" \t hello")).toBe(1) + expect(editor.indentLevelForLine(" \t \thello")).toBe(2) + expect(editor.indentLevelForLine(" \t \thello")).toBe(2.5) describe "when the buffer is reloaded", -> it "preserves the current cursor position", -> editor.setCursorScreenPosition([0, 1]) editor.buffer.reload() - expect(editor.getCursorScreenPosition()).toEqual [0,1] + expect(editor.getCursorScreenPosition()).toEqual [0, 1] describe "when a better-matched grammar is added to syntax", -> it "switches to the better-matched grammar and re-tokenizes the buffer", -> @@ -3881,8 +4175,9 @@ describe "TextEditor", -> runs -> grammar = atom.grammars.selectGrammar("text.js") - {tokens} = grammar.tokenizeLine("var i; // http://github.com") + {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.modifier.js"] @@ -3959,7 +4254,7 @@ describe "TextEditor", -> editor.setLineHeightInPixels(10) editor.setDefaultCharWidth(10) editor.setHeight(60) - editor.setWidth(50) + editor.setWidth(130) editor.setHorizontalScrollbarHeight(0) expect(editor.getScrollTop()).toBe 0 expect(editor.getScrollLeft()).toBe 0 @@ -3975,8 +4270,6 @@ describe "TextEditor", -> describe ".pageUp/Down()", -> it "scrolls one screen height up or down and moves the cursor one page length", -> - editor.manageScrollPosition = true - editor.setLineHeightInPixels(10) editor.setHeight(50) expect(editor.getScrollHeight()).toBe 130 @@ -4000,8 +4293,6 @@ describe "TextEditor", -> describe ".selectPageUp/Down()", -> it "selects one screen height of text up or down", -> - editor.manageScrollPosition = true - editor.setLineHeightInPixels(10) editor.setHeight(50) expect(editor.getScrollHeight()).toBe 130 @@ -4009,28 +4300,28 @@ describe "TextEditor", -> editor.selectPageDown() expect(editor.getScrollTop()).toBe 30 - expect(editor.getSelectedBufferRanges()).toEqual [[[0,0], [5,0]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [5, 0]]] editor.selectPageDown() expect(editor.getScrollTop()).toBe 80 - expect(editor.getSelectedBufferRanges()).toEqual [[[0,0], [10,0]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [10, 0]]] editor.selectPageDown() expect(editor.getScrollTop()).toBe 80 - expect(editor.getSelectedBufferRanges()).toEqual [[[0,0], [12,2]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] editor.moveToBottom() editor.selectPageUp() expect(editor.getScrollTop()).toBe 50 - expect(editor.getSelectedBufferRanges()).toEqual [[[7,0], [12,2]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[7, 0], [12, 2]]] editor.selectPageUp() expect(editor.getScrollTop()).toBe 0 - expect(editor.getSelectedBufferRanges()).toEqual [[[2,0], [12,2]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[2, 0], [12, 2]]] editor.selectPageUp() expect(editor.getScrollTop()).toBe 0 - expect(editor.getSelectedBufferRanges()).toEqual [[[0,0], [12,2]]] + expect(editor.getSelectedBufferRanges()).toEqual [[[0, 0], [12, 2]]] describe '.get/setPlaceholderText()', -> it 'can be created with placeholderText', -> @@ -4345,3 +4636,135 @@ describe "TextEditor", -> expect(cursor2.getBufferPosition()).toEqual([0,0]) expect(cursor3.getBufferPosition()).toEqual([1,2]) expect(cursor4.getBufferPosition()).toEqual([1,2]) + + 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 "casts 'gutter' type to 'line-number' unless a gutter name is specified.", -> + jasmine.snapshotDeprecations() + + lineNumberDecoration = editor.decorateMarker(marker, {type: 'gutter'}) + customGutterDecoration = editor.decorateMarker(marker, {type: 'gutter', gutterName: 'custom'}) + expect(lineNumberDecoration.getProperties().type).toBe 'line-number' + expect(lineNumberDecoration.getProperties().gutterName).toBe 'line-number' + expect(customGutterDecoration.getProperties().type).toBe 'gutter' + expect(customGutterDecoration.getProperties().gutterName).toBe 'custom' + + jasmine.restoreDeprecationsSnapshot() + + 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 [] diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee index 94e5fad61..98a85f389 100644 --- a/spec/theme-manager-spec.coffee +++ b/spec/theme-manager-spec.coffee @@ -84,7 +84,7 @@ describe "ThemeManager", -> atom.config.set('core.themes', []) waitsFor -> - didChangeActiveThemesHandler.callCount == 1 + didChangeActiveThemesHandler.callCount is 1 runs -> didChangeActiveThemesHandler.reset() @@ -92,7 +92,7 @@ describe "ThemeManager", -> atom.config.set('core.themes', ['atom-dark-ui']) waitsFor -> - didChangeActiveThemesHandler.callCount == 1 + didChangeActiveThemesHandler.callCount is 1 runs -> didChangeActiveThemesHandler.reset() @@ -101,7 +101,7 @@ describe "ThemeManager", -> atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-ui']) waitsFor -> - didChangeActiveThemesHandler.callCount == 1 + didChangeActiveThemesHandler.callCount is 1 runs -> didChangeActiveThemesHandler.reset() @@ -111,7 +111,7 @@ describe "ThemeManager", -> atom.config.set('core.themes', []) waitsFor -> - didChangeActiveThemesHandler.callCount == 1 + didChangeActiveThemesHandler.callCount is 1 runs -> didChangeActiveThemesHandler.reset() @@ -120,7 +120,7 @@ describe "ThemeManager", -> atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui']) waitsFor -> - didChangeActiveThemesHandler.callCount == 1 + didChangeActiveThemesHandler.callCount is 1 runs -> expect($('style[priority=1]')).toHaveLength 2 @@ -129,6 +129,7 @@ describe "ThemeManager", -> 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.views.getView(atom.workspace) themeManager.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() @@ -389,6 +390,13 @@ describe "ThemeManager", -> 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')).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 -> spyOn(console, 'warn') diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index 0adcd2756..23520518a 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -287,7 +287,7 @@ describe "TokenizedBuffer", -> describe "when there is an insertion that is larger than the chunk size", -> it "tokenizes the initial chunk synchronously, then tokenizes the remaining lines in the background", -> commentBlock = _.multiplyString("// a comment\n", tokenizedBuffer.chunkSize + 2) - buffer.insert([0,0], commentBlock) + buffer.insert([0, 0], commentBlock) expect(tokenizedBuffer.tokenizedLineForRow(0).ruleStack?).toBeTruthy() expect(tokenizedBuffer.tokenizedLineForRow(4).ruleStack?).toBeTruthy() expect(tokenizedBuffer.tokenizedLineForRow(5).ruleStack?).toBeFalsy() @@ -296,14 +296,6 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.tokenizedLineForRow(5).ruleStack?).toBeTruthy() expect(tokenizedBuffer.tokenizedLineForRow(6).ruleStack?).toBeTruthy() - describe ".findOpeningBracket(closingBufferPosition)", -> - it "returns the position of the matching bracket, skipping any nested brackets", -> - expect(tokenizedBuffer.findOpeningBracket([9, 2])).toEqual [1, 29] - - describe ".findClosingBracket(startBufferPosition)", -> - it "returns the position of the matching bracket, skipping any nested brackets", -> - expect(tokenizedBuffer.findClosingBracket([1, 29])).toEqual [9, 2] - it "tokenizes leading whitespace based on the new tab length", -> expect(tokenizedBuffer.tokenizedLineForRow(5).tokens[0].isAtomic).toBeTruthy() expect(tokenizedBuffer.tokenizedLineForRow(5).tokens[0].value).toBe " " @@ -330,6 +322,22 @@ describe "TokenizedBuffer", -> expect(tokens[2].value).toBe " \u030b" expect(tokens[2].hasLeadingWhitespace()).toBe false + it "does not break out soft tabs across a scope boundary", -> + waitsForPromise -> + atom.packages.activatePackage('language-gfm') + + runs -> + tokenizedBuffer.setTabLength(4) + tokenizedBuffer.setGrammar(atom.grammars.selectGrammar('.md')) + buffer.setText(' 0 + + expect(length).toBe 4 + describe "when the buffer contains hard-tabs", -> beforeEach -> waitsForPromise -> @@ -352,7 +360,7 @@ describe "TokenizedBuffer", -> tabAsSpaces = _.multiplyString(' ', tokenizedBuffer.getTabLength()) screenLine0 = tokenizedBuffer.tokenizedLineForRow(0) expect(screenLine0.text).toBe "# Econ 101#{tabAsSpaces}" - { tokens } = screenLine0 + {tokens} = screenLine0 expect(tokens.length).toBe 4 expect(tokens[0].value).toBe "#" @@ -452,7 +460,7 @@ describe "TokenizedBuffer", -> it "renders each UTF-8 surrogate pair as its own atomic token", -> screenLine0 = tokenizedBuffer.tokenizedLineForRow(0) expect(screenLine0.text).toBe "'abc\uD835\uDF97def'" - { tokens } = screenLine0 + {tokens} = screenLine0 expect(tokens.length).toBe 5 expect(tokens[0].value).toBe "'" @@ -464,7 +472,7 @@ describe "TokenizedBuffer", -> screenLine1 = tokenizedBuffer.tokenizedLineForRow(1) expect(screenLine1.text).toBe "//\uD835\uDF97xyz" - { tokens } = screenLine1 + {tokens} = screenLine1 expect(tokens.length).toBe 4 expect(tokens[0].value).toBe '//' @@ -549,7 +557,7 @@ describe "TokenizedBuffer", -> runs -> fullyTokenize(tokenizedBuffer) {tokens} = tokenizedBuffer.tokenizedLineForRow(0) - expect(tokens[0]).toEqual value: '<', scopes: ["text.html.ruby","meta.tag.block.any.html","punctuation.definition.tag.begin.html"] + expect(tokens[0]).toEqual value: '<', scopes: ["text.html.ruby", "meta.tag.block.any.html", "punctuation.definition.tag.begin.html"] describe ".tokenForPosition(position)", -> afterEach -> @@ -560,9 +568,9 @@ describe "TokenizedBuffer", -> buffer = atom.project.bufferForPathSync('sample.js') tokenizedBuffer = new TokenizedBuffer({buffer}) fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenForPosition([1,0]).scopes).toEqual ["source.js"] - expect(tokenizedBuffer.tokenForPosition([1,1]).scopes).toEqual ["source.js"] - expect(tokenizedBuffer.tokenForPosition([1,2]).scopes).toEqual ["source.js", "storage.modifier.js"] + expect(tokenizedBuffer.tokenForPosition([1, 0]).scopes).toEqual ["source.js"] + expect(tokenizedBuffer.tokenForPosition([1, 1]).scopes).toEqual ["source.js"] + expect(tokenizedBuffer.tokenForPosition([1, 2]).scopes).toEqual ["source.js", "storage.modifier.js"] describe ".bufferRangeForScopeAtPosition(selector, position)", -> beforeEach -> @@ -588,20 +596,20 @@ describe "TokenizedBuffer", -> buffer.setText('\ttest') tokenizedBuffer = new TokenizedBuffer({buffer}) fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0, 0]).value).toBe ' ' atom.config.set('editor.tabLength', 6) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0, 0]).value).toBe ' ' it "does not allow the tab length to be less than 1", -> buffer = atom.project.bufferForPathSync('sample.js') buffer.setText('\ttest') tokenizedBuffer = new TokenizedBuffer({buffer}) fullyTokenize(tokenizedBuffer) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0, 0]).value).toBe ' ' atom.config.set('editor.tabLength', 1) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0, 0]).value).toBe ' ' atom.config.set('editor.tabLength', 0) - expect(tokenizedBuffer.tokenForPosition([0,0]).value).toBe ' ' + expect(tokenizedBuffer.tokenForPosition([0, 0]).value).toBe ' ' describe "when the invisibles value changes", -> beforeEach -> @@ -611,7 +619,8 @@ describe "TokenizedBuffer", -> tokenizedBuffer = new TokenizedBuffer({buffer}) fullyTokenize(tokenizedBuffer) - tokenizedBuffer.setInvisibles(space: 'S', tab: 'T') + atom.config.set("editor.showInvisibles", true) + atom.config.set("editor.invisibles", space: 'S', tab: 'T') fullyTokenize(tokenizedBuffer) expect(tokenizedBuffer.tokenizedLineForRow(0).text).toBe "SST Sa line with tabsTand T spacesSTS" @@ -623,7 +632,7 @@ describe "TokenizedBuffer", -> tokenizedBuffer = new TokenizedBuffer({buffer}) atom.config.set('editor.showInvisibles', true) - tokenizedBuffer.setInvisibles(cr: 'R', eol: 'N') + atom.config.set("editor.invisibles", cr: 'R', eol: 'N') fullyTokenize(tokenizedBuffer) expect(tokenizedBuffer.tokenizedLineForRow(0).endOfLineInvisibles).toEqual ['R', 'N'] @@ -634,7 +643,7 @@ describe "TokenizedBuffer", -> expect(left.endOfLineInvisibles).toBe null expect(right.endOfLineInvisibles).toEqual ['R', 'N'] - tokenizedBuffer.setInvisibles(cr: 'R', eol: false) + atom.config.set("editor.invisibles", cr: 'R', eol: false) expect(tokenizedBuffer.tokenizedLineForRow(0).endOfLineInvisibles).toEqual ['R'] expect(tokenizedBuffer.tokenizedLineForRow(1).endOfLineInvisibles).toEqual [] @@ -688,28 +697,14 @@ describe "TokenizedBuffer", -> it "sets leading and trailing whitespace correctly on a line with invisible characters that is copied", -> buffer.setText(" \t a line with tabs\tand \tspaces \t ") - tokenizedBuffer.setInvisibles(space: 'S', tab: 'T') + atom.config.set("editor.showInvisibles", true) + atom.config.set("editor.invisibles", space: 'S', tab: 'T') fullyTokenize(tokenizedBuffer) line = tokenizedBuffer.tokenizedLineForRow(0).copy() expect(line.tokens[0].firstNonWhitespaceIndex).toBe 2 expect(line.tokens[line.tokens.length - 1].firstTrailingWhitespaceIndex).toBe 0 - it "sets the ::firstNonWhitespaceIndex and ::firstTrailingWhitespaceIndex correctly when tokens are split for soft-wrapping", -> - tokenizedBuffer.setInvisibles(space: 'S') - buffer.setText(" token ") - fullyTokenize(tokenizedBuffer) - token = tokenizedBuffer.tokenizedLines[0].tokens[0] - - [leftToken, rightToken] = token.splitAt(1) - expect(leftToken.hasInvisibleCharacters).toBe true - expect(leftToken.firstNonWhitespaceIndex).toBe 1 - expect(leftToken.firstTrailingWhitespaceIndex).toBe null - - expect(leftToken.hasInvisibleCharacters).toBe true - expect(rightToken.firstNonWhitespaceIndex).toBe null - expect(rightToken.firstTrailingWhitespaceIndex).toBe 5 - describe ".indentLevel on tokenized lines", -> beforeEach -> buffer = atom.project.bufferForPathSync('sample.js') @@ -749,7 +744,7 @@ describe "TokenizedBuffer", -> it "updates empty line indent guides when the empty line is the last line", -> buffer.insert([12, 2], '\n') - # The newline and he tab need to be in two different operations to surface the bug + # The newline and the tab need to be in two different operations to surface the bug buffer.insert([12, 0], ' ') expect(tokenizedBuffer.tokenizedLineForRow(13).indentLevel).toBe 1 @@ -878,3 +873,25 @@ describe "TokenizedBuffer", -> expect(tokenizedBuffer.tokenizedLineForRow(6).foldable).toBe true expect(tokenizedBuffer.tokenizedLineForRow(7).foldable).toBe false expect(tokenizedBuffer.tokenizedLineForRow(8).foldable).toBe false + + describe "when the buffer is configured with the null grammar", -> + it "uses the placeholder tokens and does not actually tokenize using the grammar", -> + spyOn(atom.grammars.nullGrammar, 'tokenizeLine').andCallThrough() + buffer = atom.project.bufferForPathSync('sample.will-use-the-null-grammar') + buffer.setText('a\nb\nc') + + tokenizedBuffer = new TokenizedBuffer({buffer}) + tokenizeCallback = jasmine.createSpy('onDidTokenize') + tokenizedBuffer.onDidTokenize(tokenizeCallback) + + fullyTokenize(tokenizedBuffer) + + expect(tokenizeCallback.callCount).toBe 1 + expect(atom.grammars.nullGrammar.tokenizeLine.callCount).toBe 0 + + expect(tokenizedBuffer.tokenizedLineForRow(0).tokens.length).toBe 1 + expect(tokenizedBuffer.tokenizedLineForRow(0).tokens[0].value).toBe 'a' + expect(tokenizedBuffer.tokenizedLineForRow(1).tokens.length).toBe 1 + expect(tokenizedBuffer.tokenizedLineForRow(1).tokens[0].value).toBe 'b' + expect(tokenizedBuffer.tokenizedLineForRow(2).tokens.length).toBe 1 + expect(tokenizedBuffer.tokenizedLineForRow(2).tokens[0].value).toBe 'c' diff --git a/spec/tokenized-line-spec.coffee b/spec/tokenized-line-spec.coffee index 0da83c91c..2914ec089 100644 --- a/spec/tokenized-line-spec.coffee +++ b/spec/tokenized-line-spec.coffee @@ -17,24 +17,3 @@ describe "TokenizedLine", -> it "returns false when the line is not only whitespace", -> expect(editor.tokenizedLineForScreenRow(0).isOnlyWhitespace()).toBe false expect(editor.tokenizedLineForScreenRow(2).isOnlyWhitespace()).toBe false - - describe "::getScopeTree()", -> - it "returns a tree whose inner nodes are scopeDescriptor and whose leaf nodes are tokens in those scopeDescriptor", -> - [tokens, tokenIndex] = [] - - ensureValidScopeTree = (scopeTree, scopeDescriptor=[]) -> - if scopeTree.children? - for child in scopeTree.children - ensureValidScopeTree(child, scopeDescriptor.concat([scopeTree.scope])) - else - expect(scopeTree).toBe tokens[tokenIndex++] - expect(scopeDescriptor).toEqual scopeTree.scopes - - waitsForPromise -> - atom.project.open('coffee.coffee').then (o) -> editor = o - - runs -> - tokenIndex = 0 - tokens = editor.tokenizedLineForScreenRow(1).tokens - scopeTree = editor.tokenizedLineForScreenRow(1).getScopeTree() - ensureValidScopeTree(scopeTree) diff --git a/spec/tooltip-manager-spec.coffee b/spec/tooltip-manager-spec.coffee index 74b875658..88d235398 100644 --- a/spec/tooltip-manager-spec.coffee +++ b/spec/tooltip-manager-spec.coffee @@ -1,9 +1,13 @@ TooltipManager = require '../src/tooltip-manager' {$} = require '../src/space-pen-extensions' +_ = require "underscore-plus" describe "TooltipManager", -> [manager, element] = [] + ctrlX = _.humanizeKeystroke("ctrl-x") + ctrlY = _.humanizeKeystroke("ctrl-y") + beforeEach -> manager = new TooltipManager element = document.createElement('div') @@ -35,7 +39,7 @@ describe "TooltipManager", -> hover element, -> tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "Title ⌃X ⌃Y" + expect(tooltipElement).toHaveText "Title #{ctrlX} #{ctrlY}" describe "when no title is specified", -> it "shows the key binding corresponding to the command alone", -> @@ -45,7 +49,7 @@ describe "TooltipManager", -> hover element, -> tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "⌃X ⌃Y" + expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}" describe "when a keyBindingTarget is specified", -> it "looks up the key binding relative to the target", -> @@ -57,7 +61,7 @@ describe "TooltipManager", -> hover element, -> tooltipElement = document.body.querySelector(".tooltip") - expect(tooltipElement).toHaveText "⌃X ⌃Y" + 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 diff --git a/spec/typescript-spec.coffee b/spec/typescript-spec.coffee new file mode 100644 index 000000000..493715d36 --- /dev/null +++ b/spec/typescript-spec.coffee @@ -0,0 +1,30 @@ +typescript = require '../src/typescript' +crypto = require 'crypto' + +describe "TypeScript transpiler support", -> + describe "::createTypeScriptVersionAndOptionsDigest", -> + it "returns a digest for the library version and specified options", -> + defaultOptions = + target: 1 # ES5 + module: 'commonjs' + sourceMap: true + version = '1.4.1' + shasum = crypto.createHash('sha1') + shasum.update('typescript', 'utf8') + shasum.update('\0', 'utf8') + shasum.update(version, 'utf8') + shasum.update('\0', 'utf8') + shasum.update(JSON.stringify(defaultOptions)) + expectedDigest = shasum.digest('hex') + + observedDigest = typescript.createTypeScriptVersionAndOptionsDigest(version, defaultOptions) + expect(observedDigest).toEqual expectedDigest + + describe "when there is a .ts file", -> + it "transpiles it using typescript", -> + transpiled = require('./fixtures/typescript/valid.ts') + expect(transpiled(3)).toBe 4 + + describe "when the .ts file is invalid", -> + it "does not transpile", -> + expect(-> require('./fixtures/typescript/invalid.ts')).toThrow() diff --git a/spec/view-registry-spec.coffee b/spec/view-registry-spec.coffee index 4d0d72abc..df822309e 100644 --- a/spec/view-registry-spec.coffee +++ b/spec/view-registry-spec.coffee @@ -119,6 +119,36 @@ describe "ViewRegistry", -> frameRequests[0]() expect(events).toEqual ['write 4', 'read 3'] + it "performs writes requested from read callbacks in the same animation frame", -> + spyOn(window, 'setInterval').andCallFake(fakeSetInterval) + spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) + events = [] + + registry.pollDocument -> events.push('poll') + registry.pollAfterNextUpdate() + registry.updateDocument -> events.push('write 1') + registry.readDocument -> + registry.updateDocument -> events.push('write from read 1') + events.push('read 1') + registry.readDocument -> + registry.updateDocument -> events.push('write from read 2') + events.push('read 2') + registry.updateDocument -> events.push('write 2') + + expect(frameRequests.length).toBe 1 + frameRequests[0]() + expect(frameRequests.length).toBe 1 + + expect(events).toEqual [ + 'write 1' + 'write 2' + 'read 1' + 'read 2' + 'poll' + 'write from read 1' + 'write from read 2' + ] + it "pauses DOM polling when reads or writes are pending", -> spyOn(window, 'setInterval').andCallFake(fakeSetInterval) spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) @@ -137,6 +167,21 @@ describe "ViewRegistry", -> advanceClock(registry.documentPollingInterval) expect(events).toEqual ['write', 'read', 'poll', 'poll'] + it "polls the document after updating when ::pollAfterNextUpdate() has been called", -> + events = [] + registry.pollDocument -> events.push('poll') + registry.updateDocument -> events.push('write') + registry.readDocument -> events.push('read') + frameRequests.shift()() + expect(events).toEqual ['write', 'read'] + + events = [] + registry.pollAfterNextUpdate() + registry.updateDocument -> events.push('write') + registry.readDocument -> events.push('read') + frameRequests.shift()() + expect(events).toEqual ['write', 'read', 'poll'] + describe "::pollDocument(fn)", -> it "calls all registered reader functions on an interval until they are disabled via a returned disposable", -> spyOn(window, 'setInterval').andCallFake(fakeSetInterval) diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee index 883474e85..94e605a36 100644 --- a/spec/window-spec.coffee +++ b/spec/window-spec.coffee @@ -141,33 +141,6 @@ describe "Window", -> expect(buffer.getSubscriptionCount()).toBe 0 - describe "drag and drop", -> - buildDragEvent = (type, files) -> - dataTransfer = - files: files - data: {} - setData: (key, value) -> @data[key] = value - getData: (key) -> @data[key] - - event = new CustomEvent("drop") - event.dataTransfer = dataTransfer - event - - describe "when a file is dragged to window", -> - it "opens it", -> - spyOn(atom, "open") - event = buildDragEvent("drop", [ {path: "/fake1"}, {path: "/fake2"} ]) - document.dispatchEvent(event) - expect(atom.open.callCount).toBe 1 - expect(atom.open.argsForCall[0][0]).toEqual pathsToOpen: ['/fake1', '/fake2'] - - describe "when a non-file is dragged to window", -> - it "does nothing", -> - spyOn(atom, "open") - event = buildDragEvent("drop", []) - document.dispatchEvent(event) - expect(atom.open).not.toHaveBeenCalled() - describe "when a link is clicked", -> it "opens the http/https links in an external application", -> shell = require 'shell' @@ -285,22 +258,49 @@ describe "Window", -> it "adds it to the project's paths", -> pathToOpen = __filename atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - expect(atom.project.getPaths()[0]).toBe __dirname + + waitsFor -> + atom.project.getPaths().length is 1 + + runs -> + expect(atom.project.getPaths()[0]).toBe __dirname describe "when the opened path does not exist but its parent directory does", -> it "adds the parent directory to the project paths", -> pathToOpen = path.join(__dirname, 'this-path-does-not-exist.txt') atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - expect(atom.project.getPaths()[0]).toBe __dirname + + waitsFor -> + atom.project.getPaths().length is 1 + + runs -> + expect(atom.project.getPaths()[0]).toBe __dirname describe "when the opened path is a file", -> it "opens it in the workspace", -> pathToOpen = __filename atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] - expect(atom.workspace.open.mostRecentCall.args[0]).toBe __filename + + waitsFor -> + atom.workspace.open.callCount is 1 + + runs -> + expect(atom.workspace.open.mostRecentCall.args[0]).toBe __filename + describe "when the opened path is a directory", -> it "does not open it in the workspace", -> pathToOpen = __dirname atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] expect(atom.workspace.open.callCount).toBe 0 + + 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' + atom.getCurrentWindow().send 'message', 'open-locations', [{pathToOpen}] + + waitsFor -> + atom.project.getPaths().length is 1 + + runs -> + expect(atom.project.getPaths()[0]).toBe pathToOpen diff --git a/spec/workspace-element-spec.coffee b/spec/workspace-element-spec.coffee new file mode 100644 index 000000000..1771b2363 --- /dev/null +++ b/spec/workspace-element-spec.coffee @@ -0,0 +1,44 @@ +ipc = require 'ipc' +path = require 'path' +temp = require('temp').track() + +describe "WorkspaceElement", -> + workspaceElement = null + + beforeEach -> + workspaceElement = atom.views.getView(atom.workspace) + + describe "the 'window:run-package-specs' command", -> + it "runs the package specs for the active item's project path, or the first project path", -> + spyOn(ipc, 'send') + + # No project paths. Don't try to run specs. + atom.commands.dispatch(workspaceElement, "window:run-package-specs") + expect(ipc.send).not.toHaveBeenCalledWith("run-package-specs") + + projectPaths = [temp.mkdirSync("dir1-"), temp.mkdirSync("dir2-")] + atom.project.setPaths(projectPaths) + + # No active item. Use first project directory. + atom.commands.dispatch(workspaceElement, "window:run-package-specs") + expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) + ipc.send.reset() + + # Active item doesn't implement ::getPath(). Use first project directory. + item = document.createElement("div") + atom.workspace.getActivePane().activateItem(item) + atom.commands.dispatch(workspaceElement, "window:run-package-specs") + expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) + ipc.send.reset() + + # Active item has no path. Use first project directory. + item.getPath = -> null + atom.commands.dispatch(workspaceElement, "window:run-package-specs") + expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[0], "spec")) + ipc.send.reset() + + # Active item has path. Use project path for item path. + item.getPath = -> path.join(projectPaths[1], "a-file.txt") + atom.commands.dispatch(workspaceElement, "window:run-package-specs") + expect(ipc.send).toHaveBeenCalledWith("run-package-specs", path.join(projectPaths[1], "spec")) + ipc.send.reset() diff --git a/spec/workspace-spec.coffee b/spec/workspace-spec.coffee index e40ef1925..9a99d3ed2 100644 --- a/spec/workspace-spec.coffee +++ b/spec/workspace-spec.coffee @@ -189,7 +189,7 @@ describe "Workspace", -> workspace.open('a', split: 'right').then (o) -> editor = o runs -> - pane2 = workspace.getPanes().filter((p) -> p != pane1)[0] + pane2 = workspace.getPanes().filter((p) -> p isnt pane1)[0] expect(workspace.getActivePane()).toBe pane2 expect(pane1.items).toEqual [] expect(pane2.items).toEqual [editor] @@ -218,27 +218,62 @@ describe "Workspace", -> workspace.open('a', split: 'right').then (o) -> editor = o runs -> - pane4 = workspace.getPanes().filter((p) -> p != pane1)[0] + pane4 = workspace.getPanes().filter((p) -> p isnt pane1)[0] expect(workspace.getActivePane()).toBe pane4 expect(pane4.items).toEqual [editor] expect(workspace.paneContainer.root.children[0]).toBe pane1 expect(workspace.paneContainer.root.children[1]).toBe pane4 + describe "when the file is over 2MB", -> + it "opens the editor with largeFileMode: true", -> + spyOn(fs, 'getSizeSync').andReturn 2 * 1048577 # 2MB + + editor = null + waitsForPromise -> + workspace.open('sample.js').then (e) -> editor = e + + runs -> + expect(editor.displayBuffer.largeFileMode).toBe true + + describe "when the file is over 20MB", -> + it "prompts the user to make sure they want to open a file this big", -> + spyOn(fs, 'getSizeSync').andReturn 20 * 1048577 # 20MB + spyOn(atom, 'confirm').andCallFake -> selectedButtonIndex + selectedButtonIndex = 1 # cancel + + editor = null + waitsForPromise -> + workspace.open('sample.js').then (e) -> editor = e + + runs -> + expect(editor).toBeUndefined() + expect(atom.confirm).toHaveBeenCalled() + + atom.confirm.reset() + selectedButtonIndex = 0 # open the file + + waitsForPromise -> + workspace.open('sample.js').then (e) -> editor = e + + runs -> + expect(atom.confirm).toHaveBeenCalled() + expect(editor.displayBuffer.largeFileMode).toBe true + describe "when passed a path that matches a custom opener", -> it "returns the resource returned by the custom opener", -> - fooOpener = (pathToOpen, options) -> { foo: pathToOpen, options } if pathToOpen?.match(/\.foo/) - barOpener = (pathToOpen) -> { bar: pathToOpen } if pathToOpen?.match(/^bar:\/\//) + fooOpener = (pathToOpen, options) -> {foo: pathToOpen, options} if pathToOpen?.match(/\.foo/) + barOpener = (pathToOpen) -> {bar: pathToOpen} if pathToOpen?.match(/^bar:\/\//) workspace.addOpener(fooOpener) workspace.addOpener(barOpener) waitsForPromise -> pathToOpen = atom.project.getDirectories()[0]?.resolve('a.foo') workspace.open(pathToOpen, hey: "there").then (item) -> - expect(item).toEqual { foo: pathToOpen, options: {hey: "there"} } + expect(item).toEqual {foo: pathToOpen, options: {hey: "there"}} waitsForPromise -> workspace.open("bar://baz").then (item) -> - expect(item).toEqual { bar: "bar://baz" } + expect(item).toEqual {bar: "bar://baz"} it "notifies ::onDidAddTextEditor observers", -> absolutePath = require.resolve('./fixtures/dir/a') @@ -272,20 +307,6 @@ describe "Workspace", -> beforeEach -> atom.notifications.onDidAddNotification notificationSpy = jasmine.createSpy() - describe "when a large file is opened", -> - beforeEach -> - spyOn(fs, 'getSizeSync').andReturn 2 * 1048577 # 2MB - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain '< 2MB' - describe "when a file does not exist", -> it "creates an empty buffer for the specified path", -> waitsForPromise -> @@ -426,6 +447,35 @@ describe "Workspace", -> workspace.decreaseFontSize() expect(atom.config.get('editor.fontSize')).toBe 1 + describe "::resetFontSize()", -> + it "resets the font size to the window's starting font size", -> + originalFontSize = atom.config.get('editor.fontSize') + + workspace.increaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + 1 + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + workspace.decreaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize - 1 + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + + it "does nothing if the font size has not been changed", -> + originalFontSize = atom.config.get('editor.fontSize') + + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + + it "resets the font size when the editor's font size changes", -> + originalFontSize = atom.config.get('editor.fontSize') + + atom.config.set('editor.fontSize', originalFontSize + 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + atom.config.set('editor.fontSize', originalFontSize - 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe originalFontSize + describe "::openLicense()", -> it "opens the license as plain-text in a buffer", -> waitsForPromise -> workspace.openLicense() @@ -835,7 +885,7 @@ describe "Workspace", -> runs -> expect(results).toHaveLength 3 - resultForA = _.find results, ({filePath}) -> path.basename(filePath) == 'a' + resultForA = _.find results, ({filePath}) -> path.basename(filePath) is 'a' expect(resultForA.matches).toHaveLength 1 expect(resultForA.matches[0].matchText).toBe 'Elephant' @@ -912,6 +962,92 @@ describe "Workspace", -> .then -> expect(resultPaths).toEqual([file2]) + describe "when a custom directory searcher is registered", -> + fakeSearch = null + # Function that is invoked once all of the fields on fakeSearch are set. + onFakeSearchCreated = null + + class FakeSearch + constructor: (@options) -> + # Note that hoisting resolve and reject in this way is generally frowned upon. + @promise = new Promise (resolve, reject) => + @hoistedResolve = resolve + @hoistedReject = reject + onFakeSearchCreated?(this) + then: (args...) -> + @promise.then.apply(@promise, args) + cancel: -> + @cancelled = true + # According to the spec for a DirectorySearcher, invoking `cancel()` should + # resolve the thenable rather than reject it. + @hoistedResolve() + + beforeEach -> + fakeSearch = null + onFakeSearchCreated = null + atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { + canSearchDirectory: (directory) -> directory.getPath() is dir1 + search: (directory, regex, options) -> fakeSearch = new FakeSearch(options) + }) + + it "can override the DefaultDirectorySearcher on a per-directory basis", -> + foreignFilePath = 'ssh://foreign-directory:8080/hello.txt' + numPathsSearchedInDir2 = 1 + numPathsToPretendToSearchInCustomDirectorySearcher = 10 + searchResult = + filePath: foreignFilePath, + matches: [ + { + lineText: 'Hello world', + lineTextOffset: 0, + matchText: 'Hello', + range: [[0, 0], [0, 5]], + }, + ] + onFakeSearchCreated = (fakeSearch) -> + fakeSearch.options.didMatch(searchResult) + fakeSearch.options.didSearchPaths(numPathsToPretendToSearchInCustomDirectorySearcher) + fakeSearch.hoistedResolve() + + resultPaths = [] + onPathsSearched = jasmine.createSpy('onPathsSearched') + waitsForPromise -> + atom.workspace.scan /aaaa/, {onPathsSearched}, ({filePath}) -> + resultPaths.push(filePath) + + runs -> + expect(resultPaths.sort()).toEqual([foreignFilePath, file2].sort()) + # onPathsSearched should be called once by each DirectorySearcher. The order is not + # guaranteed, so we can only verify the total number of paths searched is correct + # after the second call. + expect(onPathsSearched.callCount).toBe(2) + expect(onPathsSearched.mostRecentCall.args[0]).toBe( + numPathsToPretendToSearchInCustomDirectorySearcher + numPathsSearchedInDir2) + + it "can be cancelled when the object returned by scan() has its cancel() method invoked", -> + thenable = atom.workspace.scan /aaaa/, -> + expect(fakeSearch.cancelled).toBe(undefined) + thenable.cancel() + expect(fakeSearch.cancelled).toBe(true) + + resultOfPromiseSearch = null + waitsForPromise -> + thenable.then (promiseResult) -> resultOfPromiseSearch = promiseResult + + runs -> + expect(resultOfPromiseSearch).toBe('cancelled') + + it "will have the side-effect of failing the overall search if it fails", -> + cancelableSearch = atom.workspace.scan /aaaa/, -> + fakeSearch.hoistedReject() + + didReject = false + waitsForPromise -> + cancelableSearch.catch -> didReject = true + + runs -> + expect(didReject).toBe(true) + describe "::replace(regex, replacementText, paths, iterator)", -> [filePath, commentFilePath, sampleContent, sampleCommentContent] = [] @@ -998,7 +1134,7 @@ describe "Workspace", -> atom.project.open('sample.js').then (o) -> editor = o runs -> - editor.buffer.setTextInRange([[0,0],[0,0]], 'omg') + editor.buffer.setTextInRange([[0, 0], [0, 0]], 'omg') expect(editor.isModified()).toBeTruthy() waitsForPromise -> diff --git a/spec/workspace-view-spec.coffee b/spec/workspace-view-spec.coffee index 586e72fe8..a160cdb39 100644 --- a/spec/workspace-view-spec.coffee +++ b/spec/workspace-view-spec.coffee @@ -242,10 +242,15 @@ describe "WorkspaceView", -> describe "the scrollbar visibility class", -> it "has a class based on the style of the scrollbar", -> + style = 'legacy' scrollbarStyle = require 'scrollbar-style' - scrollbarStyle.emitValue 'legacy' + spyOn(scrollbarStyle, 'getPreferredScrollbarStyle').andCallFake -> style + + atom.workspaceView.element.observeScrollbarStyle() expect(atom.workspaceView).toHaveClass 'scrollbars-visible-always' - scrollbarStyle.emitValue 'overlay' + + style = 'overlay' + atom.workspaceView.element.observeScrollbarStyle() expect(atom.workspaceView).toHaveClass 'scrollbars-visible-when-scrolling' describe "editor font styling", -> diff --git a/src/atom.coffee b/src/atom.coffee index 303b0cfe7..316c51e29 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -6,15 +6,15 @@ remote = require 'remote' shell = require 'shell' _ = require 'underscore-plus' -{deprecate} = require 'grim' -{Emitter} = require 'event-kit' -{Model} = require 'theorist' +{deprecate, includeDeprecatedAPIs} = require 'grim' +{CompositeDisposable, Emitter} = require 'event-kit' fs = require 'fs-plus' {convertStackTrace, convertLine} = require 'coffeestack' - +Model = require './model' {$} = require './space-pen-extensions' WindowEventHandler = require './window-event-handler' StylesElement = require './styles-element' +StorageFolder = require './storage-folder' # Essential: Atom global for dealing with packages, themes, menus, and the window. # @@ -34,35 +34,36 @@ class Atom extends Model atom = @deserialize(@loadState(mode)) ? new this({mode, @version}) atom.deserializeTimings.atom = Date.now() - startTime - workspaceViewDeprecationMessage = """ - atom.workspaceView is no longer available. - In most cases you will not need the view. See the Workspace docs for - alternatives: https://atom.io/docs/api/latest/Workspace. - If you do need the view, please use `atom.views.getView(atom.workspace)`, - which returns an HTMLElement. - """ + if includeDeprecatedAPIs + workspaceViewDeprecationMessage = """ + atom.workspaceView is no longer available. + In most cases you will not need the view. See the Workspace docs for + alternatives: https://atom.io/docs/api/latest/Workspace. + If you do need the view, please use `atom.views.getView(atom.workspace)`, + which returns an HTMLElement. + """ - serviceHubDeprecationMessage = """ - atom.services is no longer available. To register service providers and - consumers, use the `providedServices` and `consumedServices` fields in - your package's package.json. - """ + serviceHubDeprecationMessage = """ + atom.services is no longer available. To register service providers and + consumers, use the `providedServices` and `consumedServices` fields in + your package's package.json. + """ - Object.defineProperty atom, 'workspaceView', - get: -> - deprecate(workspaceViewDeprecationMessage) - atom.__workspaceView - set: (newValue) -> - deprecate(workspaceViewDeprecationMessage) - atom.__workspaceView = newValue + Object.defineProperty atom, 'workspaceView', + get: -> + deprecate(workspaceViewDeprecationMessage) + atom.__workspaceView + set: (newValue) -> + deprecate(workspaceViewDeprecationMessage) + atom.__workspaceView = newValue - Object.defineProperty atom, 'services', - get: -> - deprecate(serviceHubDeprecationMessage) - atom.packages.serviceHub - set: (newValue) -> - deprecate(serviceHubDeprecationMessage) - atom.packages.serviceHub = newValue + Object.defineProperty atom, 'services', + get: -> + deprecate(serviceHubDeprecationMessage) + atom.packages.serviceHub + set: (newValue) -> + deprecate(serviceHubDeprecationMessage) + atom.packages.serviceHub = newValue atom @@ -73,34 +74,24 @@ class Atom extends Model # Loads and returns the serialized state corresponding to this window # if it exists; otherwise returns undefined. @loadState: (mode) -> - statePath = @getStatePath(@getLoadSettings().initialPaths, mode) + if stateKey = @getStateKey(@getLoadSettings().initialPaths, mode) + if state = @getStorageFolder().load(stateKey) + return state - if fs.existsSync(statePath) + if windowState = @getLoadSettings().windowState try - stateString = fs.readFileSync(statePath, 'utf8') + JSON.parse(@getLoadSettings().windowState) catch error - console.warn "Error reading window state: #{statePath}", error.stack, error - else - stateString = @getLoadSettings().windowState - - try - JSON.parse(stateString) if stateString? - catch error - console.warn "Error parsing window state: #{statePath} #{error.stack}", error + console.warn "Error parsing window state: #{statePath} #{error.stack}", error # Returns the path where the state for the current window will be # located if it exists. - @getStatePath: (paths, mode) -> - switch mode - when 'spec' - filename = 'spec' - when 'editor' - if paths?.length > 0 - sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex') - filename = "editor-#{sha1}" - - if filename - path.join(@getStorageDirPath(), filename) + @getStateKey: (paths, mode) -> + if mode is 'spec' + 'spec' + else if mode is 'editor' and paths?.length > 0 + sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex') + "editor-#{sha1}" else null @@ -110,15 +101,12 @@ class Atom extends Model @getConfigDirPath: -> @configDirPath ?= process.env.ATOM_HOME - # Get the path to Atom's storage directory. - # - # Returns the absolute path to ~/.atom/storage - @getStorageDirPath: -> - @storageDirPath ?= path.join(@getConfigDirPath(), 'storage') + @getStorageFolder: -> + @storageFolder ?= new StorageFolder(@getConfigDirPath()) # Returns the load settings hash associated with the current window. @getLoadSettings: -> - @loadSettings ?= JSON.parse(decodeURIComponent(location.search.substr(14))) + @loadSettings ?= JSON.parse(decodeURIComponent(location.hash.substr(1))) cloned = _.deepClone(@loadSettings) # The loadSettings.windowState could be large, request it only when needed. cloned.__defineGetter__ 'windowState', => @@ -127,6 +115,11 @@ class Atom extends Model @getCurrentWindow().loadSettings.windowState = value cloned + @updateLoadSetting: (key, value) -> + @getLoadSettings() + @loadSettings[key] = value + location.hash = encodeURIComponent(JSON.stringify(@loadSettings)) + @getCurrentWindow: -> remote.getCurrentWindow() @@ -158,7 +151,7 @@ class Atom extends Model # Public: A {TooltipManager} instance tooltips: null - # Experimental: A {NotificationManager} instance + # Public: A {NotificationManager} instance notifications: null # Public: A {Project} instance @@ -192,6 +185,7 @@ class Atom extends Model # Call .loadOrCreate instead constructor: (@state) -> @emitter = new Emitter + @disposables = new CompositeDisposable {@mode} = @state DeserializerManager = require './deserializer-manager' @deserializers = new DeserializerManager() @@ -202,12 +196,6 @@ class Atom extends Model # # Call after this instance has been assigned to the `atom` global. initialize: -> - # Disable deprecations unless in dev mode or spec mode so that regular - # editor performance isn't impacted by generating stack traces for - # deprecated calls. - unless @inDevMode() or @inSpecMode() - require('grim').deprecate = -> - sourceMapCache = {} window.onerror = => @@ -227,12 +215,16 @@ class Atom extends Model if openDevTools @openDevTools() - @executeJavaScriptInDevTools('InspectorFrontendAPI.showConsole()') + @executeJavaScriptInDevTools('DevToolsAPI.showConsole()') - @emit 'uncaught-error', arguments... + @emit 'uncaught-error', arguments... if includeDeprecatedAPIs @emitter.emit 'did-throw-error', {message, url, line, column, originalError} - @unsubscribe() + @disposables?.dispose() + @disposables = new CompositeDisposable + + @displayWindow() unless @inSpecMode() + @setBodyPlatformClass() @loadTime = null @@ -264,7 +256,10 @@ class Atom extends Model @config = new Config({configDirPath, resourcePath}) @keymaps = new KeymapManager({configDirPath, resourcePath}) - @keymap = @keymaps # Deprecated + + if includeDeprecatedAPIs + @keymap = @keymaps # Deprecated + @keymaps.subscribeToFileReadFailure() @tooltips = new TooltipManager @notifications = new NotificationManager @@ -280,11 +275,12 @@ class Atom extends Model @grammars = @deserializers.deserialize(@state.grammars ? @state.syntax) ? new GrammarRegistry() - Object.defineProperty this, 'syntax', get: -> - deprecate "The atom.syntax global is deprecated. Use atom.grammars instead." - @grammars + if includeDeprecatedAPIs + Object.defineProperty this, 'syntax', get: -> + deprecate "The atom.syntax global is deprecated. Use atom.grammars instead." + @grammars - @subscribe @packages.onDidActivateInitialPackages => @watchThemes() + @disposables.add @packages.onDidActivateInitialPackages => @watchThemes() Project = require './project' TextBuffer = require 'text-buffer' @@ -343,15 +339,15 @@ class Atom extends Model # Public: Is the current window in development mode? inDevMode: -> - @getLoadSettings().devMode + @devMode ?= @getLoadSettings().devMode # Public: Is the current window in safe mode? inSafeMode: -> - @getLoadSettings().safeMode + @safeMode ?= @getLoadSettings().safeMode # Public: Is the current window running specs? inSpecMode: -> - @getLoadSettings().isSpec + @specMode ?= @getLoadSettings().isSpec # Public: Get the version of the Atom application. # @@ -406,10 +402,11 @@ class Atom extends Model open: (options) -> ipc.send('open', options) - # Extended: Show the native dialog to prompt the user to select a folder. + # Extended: Prompt the user to select one or more folders. # - # * `callback` A {Function} to call once the user has selected a folder. - # * `path` {String} the path to the folder the user selected. + # * `callback` A {Function} to call once the user has confirmed the selection. + # * `paths` An {Array} of {String} paths that the user selected, or `null` + # if the user dismissed the dialog. pickFolder: (callback) -> responseChannel = "atom-pick-folder-response" ipc.on responseChannel, (path) -> @@ -475,9 +472,13 @@ class Atom extends Model ipc.send('call-window-method', 'restart') # Extended: Returns a {Boolean} true when the current window is maximized. - isMaximixed: -> + isMaximized: -> @getCurrentWindow().isMaximized() + isMaximixed: -> + deprecate "Use atom.isMaximized() instead" + @isMaximized() + maximize: -> ipc.send('call-window-method', 'maximize') @@ -488,22 +489,27 @@ class Atom extends Model # Extended: Set the full screen state of the current window. setFullScreen: (fullScreen=false) -> ipc.send('call-window-method', 'setFullScreen', fullScreen) - if fullScreen then document.body.classList.add("fullscreen") else document.body.classList.remove("fullscreen") + if fullScreen + document.body.classList.add("fullscreen") + else + document.body.classList.remove("fullscreen") # Extended: Toggle the full screen state of the current window. toggleFullScreen: -> - @setFullScreen(!@isFullScreen()) + @setFullScreen(not @isFullScreen()) - # Schedule the window to be shown and focused on the next tick. + # Restore the window to its previous dimensions and show it. # - # This is done in a next tick to prevent a white flicker from occurring - # if called synchronously. - displayWindow: ({maximize}={}) -> + # Also restores the full screen and maximized state on the next tick to + # prevent resize glitches. + displayWindow: -> + dimensions = @restoreWindowDimensions() + @show() + @focus() + setImmediate => - @show() - @focus() - @setFullScreen(true) if @workspace.fullScreen - @maximize() if maximize + @setFullScreen(true) if @workspace?.fullScreen + @maximize() if dimensions?.maximized and process.platform isnt 'darwin' # Get the dimensions of this window. # @@ -577,17 +583,23 @@ class Atom extends Model dimensions = @getWindowDimensions() @state.windowDimensions = dimensions if @isValidDimensions(dimensions) + storeWindowBackground: -> + return if @inSpecMode() + + workspaceElement = @views.getView(@workspace) + backgroundColor = window.getComputedStyle(workspaceElement)['background-color'] + window.localStorage.setItem('atom:window-background-color', backgroundColor) + # Call this method when establishing a real application window. startEditorWindow: -> - {resourcePath, safeMode} = @getLoadSettings() + {safeMode} = @getLoadSettings() CommandInstaller = require './command-installer' - CommandInstaller.installAtomCommand resourcePath, false, (error) -> + CommandInstaller.installAtomCommand false, (error) -> console.warn error.message if error? - CommandInstaller.installApmCommand resourcePath, false, (error) -> + CommandInstaller.installApmCommand false, (error) -> console.warn error.message if error? - dimensions = @restoreWindowDimensions() @loadConfig() @keymaps.loadBundledKeymaps() @themes.loadBaseStylesheets() @@ -601,16 +613,16 @@ class Atom extends Model @requireUserInitScript() unless safeMode @menu.update() - @subscribe @config.onDidChange 'core.autoHideMenuBar', ({newValue}) => + @disposables.add @config.onDidChange 'core.autoHideMenuBar', ({newValue}) => @setAutoHideMenuBar(newValue) @setAutoHideMenuBar(true) if @config.get('core.autoHideMenuBar') - maximize = dimensions?.maximized and process.platform isnt 'darwin' - @displayWindow({maximize}) + @openInitialEmptyEditorIfNecessary() unloadEditorWindow: -> return if not @project + @storeWindowBackground() @state.grammars = @grammars.serialize() @state.project = @project.serialize() @state.workspace = @workspace.serialize() @@ -629,6 +641,10 @@ class Atom extends Model @windowEventHandler?.unsubscribe() + openInitialEmptyEditorIfNecessary: -> + if @getLoadSettings().initialPaths?.length is 0 and @workspace.getPaneItems().length is 0 + @workspace.open(null) + ### Section: Messaging the User ### @@ -708,13 +724,18 @@ class Atom extends Model deserializeWorkspaceView: -> Workspace = require './workspace' - WorkspaceView = require './workspace-view' + + if includeDeprecatedAPIs + WorkspaceView = require './workspace-view' startTime = Date.now() @workspace = Workspace.deserialize(@state.workspace) ? new Workspace workspaceElement = @views.getView(@workspace) - @__workspaceView = workspaceElement.__spacePenView + + if includeDeprecatedAPIs + @__workspaceView = workspaceElement.__spacePenView + @deserializeTimings.workspace = Date.now() - startTime @keymaps.defaultTarget = workspaceElement @@ -741,14 +762,12 @@ class Atom extends Model # Only reload stylesheets from non-theme packages for pack in @packages.getActivePackages() when pack.getType() isnt 'theme' pack.reloadStylesheets?() - null + return # Notify the browser project of the window's current project path watchProjectPath: -> - onProjectPathChanged = => - ipc.send('window-command', 'project-path-changed', @project.getPaths()) - @subscribe @project.onDidChangePaths(onProjectPathChanged) - onProjectPathChanged() + @disposables.add @project.onDidChangePaths => + @constructor.updateLoadSetting('initialPaths', @project.getPaths()) exit: (status) -> app = remote.require('app') @@ -761,21 +780,29 @@ class Atom extends Model setRepresentedFilename: (filename) -> ipc.send('call-window-method', 'setRepresentedFilename', filename) + addProjectFolder: -> + @pickFolder (selectedPaths = []) => + @project.addPath(selectedPath) for selectedPath in selectedPaths + showSaveDialog: (callback) -> callback(showSaveDialogSync()) - showSaveDialogSync: (defaultPath) -> - defaultPath ?= @project?.getPath() + showSaveDialogSync: (options={}) -> + if _.isString(options) + options = defaultPath: options + else + options = _.clone(options) currentWindow = @getCurrentWindow() dialog = remote.require('dialog') - dialog.showSaveDialog currentWindow, {title: 'Save File', defaultPath} + options.title ?= 'Save File' + options.defaultPath ?= @project?.getPaths()[0] + dialog.showSaveDialog currentWindow, options saveSync: -> - stateString = JSON.stringify(@state) - if statePath = @constructor.getStatePath(@project?.getPaths(), @mode) - fs.writeFileSync(statePath, stateString, 'utf8') + if storageKey = @constructor.getStateKey(@project?.getPaths(), @mode) + @constructor.getStorageFolder().store(storageKey, @state) else - @getCurrentWindow().loadSettings.windowState = stateString + @getCurrentWindow().loadSettings.windowState = JSON.stringify(@state) crashMainProcess: -> remote.process.crash() @@ -816,6 +843,7 @@ class Atom extends Model delete window[key] else window[key] = value + return onUpdateAvailable: (callback) -> @emitter.on 'update-available', callback @@ -823,17 +851,18 @@ class Atom extends Model updateAvailable: (details) -> @emitter.emit 'update-available', details - # Deprecated: Callers should be converted to use atom.deserializers - registerRepresentationClass: -> - deprecate("Callers should be converted to use atom.deserializers") - - # Deprecated: Callers should be converted to use atom.deserializers - registerRepresentationClasses: -> - deprecate("Callers should be converted to use atom.deserializers") - setBodyPlatformClass: -> document.body.classList.add("platform-#{process.platform}") setAutoHideMenuBar: (autoHide) -> ipc.send('call-window-method', 'setAutoHideMenuBar', autoHide) - ipc.send('call-window-method', 'setMenuBarVisibility', !autoHide) + ipc.send('call-window-method', 'setMenuBarVisibility', not autoHide) + +if includeDeprecatedAPIs + # Deprecated: Callers should be converted to use atom.deserializers + Atom::registerRepresentationClass = -> + deprecate("Callers should be converted to use atom.deserializers") + + # Deprecated: Callers should be converted to use atom.deserializers + Atom::registerRepresentationClasses = -> + deprecate("Callers should be converted to use atom.deserializers") diff --git a/src/babel.coffee b/src/babel.coffee index c93112e78..7058c85c2 100644 --- a/src/babel.coffee +++ b/src/babel.coffee @@ -28,21 +28,17 @@ defaultOptions = 'useStrict' ] - # Includes support for es7 features listed at: - # http://babeljs.io/docs/usage/transformers/#es7-experimental-. - experimental: true - optional: [ # Target a version of the regenerator runtime that # supports yield so the transpiled code is cleaner/smaller. 'asyncToGenerator' - - # Because Atom is currently packaged with a fork of React v0.11, - # it makes sense to use the reactCompat transform so the React - # JSX transformer produces pre-v0.12 code. - 'reactCompat' ] + # Includes support for es7 features listed at: + # http://babeljs.io/docs/usage/experimental/. + stage: 0 + + ### shasum - Hash with an update() method. value - Must be a value that could be returned by JSON.parse(). diff --git a/src/browser/application-menu.coffee b/src/browser/application-menu.coffee index 5218ff304..74da80e43 100644 --- a/src/browser/application-menu.coffee +++ b/src/browser/application-menu.coffee @@ -9,11 +9,10 @@ _ = require 'underscore-plus' # and maintain the state of all menu items. module.exports = class ApplicationMenu - constructor: (@version) -> + constructor: (@version, @autoUpdateManager) -> @windowTemplates = new WeakMap() @setActiveTemplate(@getDefaultTemplate()) - global.atomApplication.autoUpdateManager.on 'state-changed', (state) => - @showUpdateMenuItem(state) + @autoUpdateManager.on 'state-changed', (state) => @showUpdateMenuItem(state) # Public: Updates the entire menu with the given keybindings. # @@ -33,7 +32,7 @@ class ApplicationMenu @menu = Menu.buildFromTemplate(_.deepClone(template)) Menu.setApplicationMenu(@menu) - @showUpdateMenuItem(global.atomApplication.autoUpdateManager.getState()) + @showUpdateMenuItem(@autoUpdateManager.getState()) # Register a BrowserWindow with this application menu. addWindow: (window) -> @@ -82,19 +81,20 @@ class ApplicationMenu # window specific items. enableWindowSpecificItems: (enable) -> for item in @flattenMenuItems(@menu) - item.enabled = enable if item.metadata?['windowSpecific'] + item.enabled = enable if item.metadata?.windowSpecific + return # Replaces VERSION with the current version. substituteVersion: (template) -> - if (item = _.find(@flattenMenuTemplate(template), ({label}) -> label == 'VERSION')) + if (item = _.find(@flattenMenuTemplate(template), ({label}) -> label is 'VERSION')) item.label = "Version #{@version}" # Sets the proper visible state the update menu items showUpdateMenuItem: (state) -> - checkForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label == 'Check for Update') - checkingForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label == 'Checking for Update') - downloadingUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label == 'Downloading Update') - installUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label == 'Restart and Install Update') + checkForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Check for Update') + checkingForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Checking for Update') + downloadingUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Downloading Update') + installUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Restart and Install Update') return unless checkForUpdateItem? and checkingForUpdateItem? and downloadingUpdateItem? and installUpdateItem? @@ -120,11 +120,11 @@ class ApplicationMenu [ label: "Atom" submenu: [ - { label: "Check for Update", metadata: {autoUpdate: true}} - { label: 'Reload', accelerator: 'Command+R', click: => @focusedWindow()?.reload() } - { label: 'Close Window', accelerator: 'Command+Shift+W', click: => @focusedWindow()?.close() } - { label: 'Toggle Dev Tools', accelerator: 'Command+Alt+I', click: => @focusedWindow()?.toggleDevTools() } - { label: 'Quit', accelerator: 'Command+Q', click: -> app.quit() } + {label: "Check for Update", metadata: {autoUpdate: true}} + {label: 'Reload', accelerator: 'Command+R', click: => @focusedWindow()?.reload()} + {label: 'Close Window', accelerator: 'Command+Shift+W', click: => @focusedWindow()?.close()} + {label: 'Toggle Dev Tools', accelerator: 'Command+Alt+I', click: => @focusedWindow()?.toggleDevTools()} + {label: 'Quit', accelerator: 'Command+Q', click: -> app.quit()} ] ] @@ -145,7 +145,7 @@ class ApplicationMenu if item.command item.accelerator = @acceleratorForCommand(item.command, keystrokesByCommand) item.click = -> global.atomApplication.sendCommand(item.command) - item.metadata['windowSpecific'] = true unless /^application:/.test(item.command) + item.metadata.windowSpecific = true unless /^application:/.test(item.command) @translateTemplate(item.submenu, keystrokesByCommand) if item.submenu template @@ -161,8 +161,8 @@ class ApplicationMenu firstKeystroke = keystrokesByCommand[command]?[0] return null unless firstKeystroke - modifiers = firstKeystroke.split('-') - key = modifiers.pop() + modifiers = firstKeystroke.split(/-(?=.)/) + key = modifiers.pop().toUpperCase().replace('+', 'Plus') modifiers = modifiers.map (modifier) -> modifier.replace(/shift/ig, "Shift") @@ -170,5 +170,5 @@ class ApplicationMenu .replace(/ctrl/ig, "Ctrl") .replace(/alt/ig, "Alt") - keys = modifiers.concat([key.toUpperCase()]) + keys = modifiers.concat([key]) keys.join("+") diff --git a/src/browser/atom-application.coffee b/src/browser/atom-application.coffee index 7324aa460..6b2651ac1 100644 --- a/src/browser/atom-application.coffee +++ b/src/browser/atom-application.coffee @@ -3,6 +3,7 @@ ApplicationMenu = require './application-menu' AtomProtocolHandler = require './atom-protocol-handler' AutoUpdateManager = require './auto-update-manager' BrowserWindow = require 'browser-window' +StorageFolder = require '../storage-folder' Menu = require 'menu' app = require 'app' fs = require 'fs-plus' @@ -18,7 +19,7 @@ DefaultSocketPath = if process.platform is 'win32' '\\\\.\\pipe\\atom-sock' else - path.join(os.tmpdir(), 'atom.sock') + path.join(os.tmpdir(), "atom-#{process.env.USER}.sock") # The application's singleton class. # @@ -43,7 +44,6 @@ class AtomApplication createAtomApplication() return - client = net.connect {path: options.socketPath}, -> client.write JSON.stringify(options), -> client.end() @@ -56,11 +56,12 @@ class AtomApplication atomProtocolHandler: null resourcePath: null version: null + quitting: false exit: (status) -> app.exit(status) constructor: (options) -> - {@resourcePath, @version, @devMode, @safeMode, @socketPath, @enableMultiFolderProject} = options + {@resourcePath, @version, @devMode, @safeMode, @socketPath} = options # Normalize to make sure drive letter case is consistent on Windows @resourcePath = path.normalize(@resourcePath) if @resourcePath @@ -71,31 +72,40 @@ class AtomApplication @pathsToOpen ?= [] @windows = [] - @autoUpdateManager = new AutoUpdateManager(@version) - @applicationMenu = new ApplicationMenu(@version) + @autoUpdateManager = new AutoUpdateManager(@version, options.test) + @applicationMenu = new ApplicationMenu(@version, @autoUpdateManager) @atomProtocolHandler = new AtomProtocolHandler(@resourcePath, @safeMode) @listenForArgumentsFromNewProcess() @setupJavaScriptArguments() @handleEvents() + @storageFolder = new StorageFolder(process.env.ATOM_HOME) - @openWithOptions(options) + if options.pathsToOpen?.length > 0 or options.urlsToOpen?.length > 0 or options.test + @openWithOptions(options) + else + @loadState() or @openPath(options) - # Opens a new window based on the options provided. - openWithOptions: ({pathsToOpen, urlsToOpen, test, pidToKillWhenClosed, devMode, safeMode, newWindow, specDirectory, logFile}) -> + openWithOptions: ({pathsToOpen, urlsToOpen, test, pidToKillWhenClosed, devMode, safeMode, newWindow, specDirectory, logFile, profileStartup}) -> if test @runSpecs({exitWhenDone: true, @resourcePath, specDirectory, logFile}) else if pathsToOpen.length > 0 - @openPaths({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode}) + @openPaths({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup}) else if urlsToOpen.length > 0 @openUrl({urlToOpen, devMode, safeMode}) for urlToOpen in urlsToOpen else - @openPath({pidToKillWhenClosed, newWindow, devMode, safeMode}) # Always open a editor window if this is the first instance of Atom. + # Always open a editor window if this is the first instance of Atom. + @openPath({pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup}) # Public: Removes the {AtomWindow} from the global window list. removeWindow: (window) -> - @windows.splice @windows.indexOf(window), 1 - @applicationMenu?.enableWindowSpecificItems(false) if @windows.length == 0 + if @windows.length is 1 + @applicationMenu?.enableWindowSpecificItems(false) + if process.platform in ['win32', 'linux'] + app.quit() + return + @windows.splice(@windows.indexOf(window), 1) + @saveState(true) unless window.isSpec # Public: Adds the {AtomWindow} to the global window list. addWindow: (window) -> @@ -106,10 +116,14 @@ class AtomApplication unless window.isSpec focusHandler = => @lastFocusedWindow = window + blurHandler = => @saveState(false) window.browserWindow.on 'focus', focusHandler + window.browserWindow.on 'blur', blurHandler window.browserWindow.once 'closed', => @lastFocusedWindow = null if window is @lastFocusedWindow window.browserWindow.removeListener 'focus', focusHandler + window.browserWindow.removeListener 'blur', blurHandler + window.browserWindow.webContents.once 'did-finish-load', => @saveState(false) # Creates server to listen for additional atom application launches. # @@ -157,7 +171,7 @@ class AtomApplication @on 'application:open-folder', -> @promptForPathToOpen('folder', getLoadSettings()) @on 'application:open-dev', -> @promptForPathToOpen('all', devMode: true) @on 'application:open-safe', -> @promptForPathToOpen('all', safeMode: true) - @on 'application:inspect', ({x,y, atomWindow}) -> + @on 'application:inspect', ({x, y, atomWindow}) -> atomWindow ?= @focusedWindow() atomWindow?.browserWindow.inspectElement(x, y) @@ -166,10 +180,13 @@ class AtomApplication @on 'application:open-roadmap', -> require('shell').openExternal('https://atom.io/roadmap?app') @on 'application:open-faq', -> require('shell').openExternal('https://atom.io/faq') @on 'application:open-terms-of-use', -> require('shell').openExternal('https://atom.io/terms') - @on 'application:report-issue', -> require('shell').openExternal('https://github.com/atom/atom/issues/new') + @on 'application:report-issue', -> require('shell').openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#submitting-issues') @on 'application:search-issues', -> require('shell').openExternal('https://github.com/issues?q=+is%3Aissue+user%3Aatom') - @on 'application:install-update', -> @autoUpdateManager.install() + @on 'application:install-update', => + @quitting = true + @autoUpdateManager.install() + @on 'application:check-for-update', => @autoUpdateManager.check() if process.platform is 'darwin' @@ -190,16 +207,18 @@ class AtomApplication @openPathOnEvent('application:open-your-keymap', 'atom://.atom/keymap') @openPathOnEvent('application:open-your-snippets', 'atom://.atom/snippets') @openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet') - @openPathOnEvent('application:open-license', path.join(@resourcePath, 'LICENSE.md')) + @openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) - app.on 'window-all-closed', -> - app.quit() if process.platform in ['win32', 'linux'] + app.on 'before-quit', => + @saveState(false) + @quitting = true app.on 'will-quit', => @killAllProcesses() @deleteSocketFile() app.on 'will-exit', => + @saveState(false) @killAllProcesses() @deleteSocketFile() @@ -251,9 +270,11 @@ class AtomApplication @promptForPath "folder", (selectedPaths) -> event.sender.send(responseChannel, selectedPaths) - clipboard = null + ipc.on 'cancel-window-close', => + @quitting = false + + clipboard = require '../safe-clipboard' ipc.on 'write-text-to-selection-clipboard', (event, selectedText) -> - clipboard ?= require 'clipboard' clipboard.writeText(selectedText, 'selection') # Public: Executes the given command. @@ -325,7 +346,7 @@ class AtomApplication focusedWindow: -> _.find @windows, (atomWindow) -> atomWindow.isFocused() - # Public: Opens multiple paths, in existing windows if possible. + # Public: Opens a single path, in an existing window if possible. # # options - # :pathToOpen - The file path to open @@ -333,11 +354,12 @@ class AtomApplication # :newWindow - Boolean of whether this should be opened in a new window. # :devMode - Boolean to control the opened window's dev mode. # :safeMode - Boolean to control the opened window's safe mode. + # :profileStartup - Boolean to control creating a profile of the startup time. # :window - {AtomWindow} to open file paths in. - openPath: ({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, window}) -> - @openPaths({pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, window}) + openPath: ({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window}) -> + @openPaths({pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window}) - # Public: Opens a single path, in an existing window if possible. + # Public: Opens multiple paths, in existing windows if possible. # # options - # :pathsToOpen - The array of file paths to open @@ -347,13 +369,13 @@ class AtomApplication # :safeMode - Boolean to control the opened window's safe mode. # :windowDimensions - Object with height and width keys. # :window - {AtomWindow} to open file paths in. - openPaths: ({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, window}={}) -> - if pathsToOpen?.length > 1 and not @enableMultiFolderProject - for pathToOpen in pathsToOpen - @openPath({pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, window}) - return + openPaths: ({pathsToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, profileStartup, window}={}) -> + pathsToOpen = pathsToOpen.map (pathToOpen) -> + if fs.existsSync(pathToOpen) + fs.normalize(pathToOpen) + else + pathToOpen - pathsToOpen = (fs.normalize(pathToOpen) for pathToOpen in pathsToOpen) locationsToOpen = (@locationForPathToOpen(pathToOpen) for pathToOpen in pathsToOpen) unless pidToKillWhenClosed or newWindow @@ -382,7 +404,7 @@ class AtomApplication bootstrapScript ?= require.resolve('../window-bootstrap') resourcePath ?= @resourcePath - openedWindow = new AtomWindow({locationsToOpen, bootstrapScript, resourcePath, devMode, safeMode, windowDimensions}) + openedWindow = new AtomWindow({locationsToOpen, bootstrapScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup}) if pidToKillWhenClosed? @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow @@ -393,11 +415,13 @@ class AtomApplication # Kill all processes associated with opened windows. killAllProcesses: -> @killProcess(pid) for pid of @pidsToOpenWindows + return # Kill process associated with the given opened window. killProcessForWindow: (openedWindow) -> for pid, trackedWindow of @pidsToOpenWindows @killProcess(pid) if trackedWindow is openedWindow + return # Kill the process with the given pid. killProcess: (pid) -> @@ -409,6 +433,29 @@ class AtomApplication console.log("Killing process #{pid} failed: #{error.code ? error.message}") delete @pidsToOpenWindows[pid] + saveState: (allowEmpty=false) -> + return if @quitting + states = [] + for window in @windows + unless window.isSpec + if loadSettings = window.getLoadSettings() + states.push(initialPaths: loadSettings.initialPaths) + if states.length > 0 or allowEmpty + @storageFolder.store('application.json', states) + + loadState: -> + if (states = @storageFolder.load('application.json'))?.length > 0 + for state in states + @openWithOptions({ + pathsToOpen: state.initialPaths + urlsToOpen: [] + devMode: @devMode + safeMode: @safeMode + }) + true + else + false + # Open an atom:// url. # # The host of the URL being opened is assumed to be the package name @@ -477,15 +524,18 @@ class AtomApplication locationForPathToOpen: (pathToOpen) -> return {pathToOpen} unless pathToOpen + return {pathToOpen} if url.parse(pathToOpen).protocol? return {pathToOpen} if fs.existsSync(pathToOpen) + pathToOpen = pathToOpen.replace(/[:\s]+$/, '') + [fileToOpen, initialLine, initialColumn] = path.basename(pathToOpen).split(':') return {pathToOpen} unless initialLine - return {pathToOpen} unless parseInt(initialLine) > 0 + return {pathToOpen} unless parseInt(initialLine) >= 0 # Convert line numbers to a base of 0 - initialLine -= 1 if initialLine - initialColumn -= 1 if initialColumn + initialLine = Math.max(0, initialLine - 1) if initialLine + initialColumn = Math.max(0, initialColumn - 1) if initialColumn pathToOpen = path.join(path.dirname(pathToOpen), fileToOpen) {pathToOpen, initialLine, initialColumn} diff --git a/src/browser/atom-window.coffee b/src/browser/atom-window.coffee index 77888fed4..7b0b2cb85 100644 --- a/src/browser/atom-window.coffee +++ b/src/browser/atom-window.coffee @@ -61,39 +61,41 @@ class AtomWindow pathToOpen loadSettings.initialPaths.sort() - @projectPaths = loadSettings.initialPaths @browserWindow.loadSettings = loadSettings @browserWindow.once 'window:loaded', => @emit 'window:loaded' @loaded = true - @browserWindow.on 'project-path-changed', (@projectPaths) => - - @browserWindow.loadUrl @getUrl(loadSettings) + @setLoadSettings(loadSettings) @browserWindow.focusOnWebView() if @isSpec - @openLocations(locationsToOpen) unless @isSpecWindow() + hasPathToOpen = not (locationsToOpen.length is 1 and not locationsToOpen[0].pathToOpen?) + @openLocations(locationsToOpen) if hasPathToOpen and not @isSpecWindow() - getUrl: (loadSettingsObj) -> + setLoadSettings: (loadSettingsObj) -> # Ignore the windowState when passing loadSettings via URL, since it could # be quite large. loadSettings = _.clone(loadSettingsObj) delete loadSettings['windowState'] - url.format + @browserWindow.loadUrl url.format protocol: 'file' pathname: "#{@resourcePath}/static/index.html" slashes: true - query: {loadSettings: JSON.stringify(loadSettings)} + hash: encodeURIComponent(JSON.stringify(loadSettings)) - hasProjectPath: -> @projectPaths?.length > 0 + getLoadSettings: -> + if @browserWindow.webContents?.loaded + hash = url.parse(@browserWindow.webContents.getUrl()).hash.substr(1) + JSON.parse(decodeURIComponent(hash)) + + hasProjectPath: -> @getLoadSettings().initialPaths?.length > 0 setupContextMenu: -> - ContextMenu = null + ContextMenu = require './context-menu' @browserWindow.on 'context-menu', (menuTemplate) => - ContextMenu ?= require './context-menu' new ContextMenu(menuTemplate, this) containsPaths: (paths) -> @@ -102,7 +104,7 @@ class AtomWindow true containsPath: (pathToCheck) -> - @projectPaths.some (projectPath) -> + @getLoadSettings()?.initialPaths?.some (projectPath) -> if not projectPath false else if not pathToCheck @@ -162,7 +164,6 @@ class AtomWindow openLocations: (locationsToOpen) -> if @loaded - @focus() @sendMessage 'open-locations', locationsToOpen else @browserWindow.once 'window:loaded', => @openLocations(locationsToOpen) diff --git a/src/browser/auto-update-manager.coffee b/src/browser/auto-update-manager.coffee index a0c1f2da8..a2c239789 100644 --- a/src/browser/auto-update-manager.coffee +++ b/src/browser/auto-update-manager.coffee @@ -15,7 +15,7 @@ module.exports = class AutoUpdateManager _.extend @prototype, EventEmitter.prototype - constructor: (@version) -> + constructor: (@version, @testMode) -> @state = IdleState if process.platform is 'win32' # Squirrel for Windows can't handle query params @@ -33,6 +33,10 @@ class AutoUpdateManager else autoUpdater = require 'auto-updater' + autoUpdater.on 'error', (event, message) => + @setState(ErrorState) + console.error "Error Downloading Update: #{message}" + autoUpdater.setFeedUrl @feedUrl autoUpdater.on 'checking-for-update', => @@ -44,16 +48,12 @@ class AutoUpdateManager autoUpdater.on 'update-available', => @setState(DownladingState) - autoUpdater.on 'error', (event, message) => - @setState(ErrorState) - console.error "Error Downloading Update: #{message}" - autoUpdater.on 'update-downloaded', (event, releaseNotes, @releaseVersion) => @setState(UpdateAvailableState) @emitUpdateAvailableEvent(@getWindows()...) # Only released versions should check for updates. - @check(hidePopups: true) unless /\w{7}/.test(@version) + @scheduleUpdateCheck() unless /\w{7}/.test(@version) switch process.platform when 'win32' @@ -65,6 +65,7 @@ class AutoUpdateManager return unless @releaseVersion? for atomWindow in windows atomWindow.sendMessage('update-available', {@releaseVersion}) + return setState: (state) -> return if @state is state @@ -74,15 +75,21 @@ class AutoUpdateManager getState: -> @state + scheduleUpdateCheck: -> + checkForUpdates = => @check(hidePopups: true) + fourHours = 1000 * 60 * 60 * 4 + setInterval(checkForUpdates, fourHours) + checkForUpdates() + check: ({hidePopups}={}) -> unless hidePopups autoUpdater.once 'update-not-available', @onUpdateNotAvailable autoUpdater.once 'error', @onUpdateError - autoUpdater.checkForUpdates() + autoUpdater.checkForUpdates() unless @testMode install: -> - autoUpdater.quitAndInstall() + autoUpdater.quitAndInstall() unless @testMode onUpdateNotAvailable: => autoUpdater.removeListener 'error', @onUpdateError diff --git a/src/browser/auto-updater-win32.coffee b/src/browser/auto-updater-win32.coffee index 89018a396..4d043ac4e 100644 --- a/src/browser/auto-updater-win32.coffee +++ b/src/browser/auto-updater-win32.coffee @@ -51,12 +51,13 @@ class AutoUpdater @emit 'update-not-available' return + @emit 'update-available' + @installUpdate (error) => if error? @emit 'update-not-available' return - @emit 'update-available' @emit 'update-downloaded', {}, update.releaseNotes, update.version, new Date(), 'https://atom.io', => @quitAndInstall() module.exports = new AutoUpdater() diff --git a/src/browser/main.coffee b/src/browser/main.coffee index b73556307..392fd1995 100644 --- a/src/browser/main.coffee +++ b/src/browser/main.coffee @@ -4,7 +4,8 @@ crashReporter = require 'crash-reporter' app = require 'app' fs = require 'fs-plus' path = require 'path' -optimist = require 'optimist' +yargs = require 'yargs' +url = require 'url' nslog = require 'nslog' console.log = nslog @@ -45,9 +46,11 @@ start = -> cwd = args.executedFrom?.toString() or process.cwd() args.pathsToOpen = args.pathsToOpen.map (pathToOpen) -> - pathToOpen = fs.normalize(pathToOpen) - if cwd - path.resolve(cwd, pathToOpen) + normalizedPath = fs.normalize(pathToOpen) + if url.parse(pathToOpen).protocol? + pathToOpen + else if cwd + path.resolve(cwd, normalizedPath) else path.resolve(pathToOpen) @@ -85,18 +88,16 @@ setupCoffeeCache = -> parseCommandLine = -> version = app.getVersion() - options = optimist(process.argv[1..]) + options = yargs(process.argv[1..]).wrap(100) options.usage """ Atom Editor v#{version} Usage: atom [options] [path ...] - One or more paths to files or folders to open may be specified. - - File paths will open in the current window. - - Folder paths will open in an existing window if that folder has already been - opened or a new window if it hasn't. + One or more paths to files or folders may be specified. If there is an + existing Atom window that contains all of the given folders, the paths + will be opened in that window. Otherwise, they will be opened in a new + window. Environment Variables: @@ -106,11 +107,15 @@ parseCommandLine = -> ATOM_HOME The root path for all configuration files and folders. Defaults to `~/.atom`. """ + # Deprecated 1.0 API preview flag + options.alias('1', 'one').boolean('1').describe('1', 'This option is no longer supported. Atom now defaults to launching with the 1.0 API. Use --include-deprecated-apis to run Atom with deprecated APIs.') + options.boolean('include-deprecated-apis').describe('include-deprecated-apis', 'Include deprecated APIs.') options.alias('d', 'dev').boolean('d').describe('d', 'Run in development mode.') options.alias('f', 'foreground').boolean('f').describe('f', 'Keep the browser process in the foreground.') options.alias('h', 'help').boolean('h').describe('h', 'Print this usage message.') options.alias('l', 'log-file').string('l').describe('l', 'Log all output to file.') options.alias('n', 'new-window').boolean('n').describe('n', 'Open a new window.') + options.boolean('profile-startup').describe('profile-startup', 'Create a profile of the startup execution time.') options.alias('r', 'resource-path').string('r').describe('r', 'Set the path to the Atom source directory and enable dev-mode.') options.alias('s', 'spec-directory').string('s').describe('s', 'Set the directory from which to run package specs (default: Atom\'s spec directory).') options.boolean('safe').describe('safe', 'Do not load packages from ~/.atom/packages or ~/.atom/dev/packages.') @@ -118,7 +123,7 @@ parseCommandLine = -> options.alias('v', 'version').boolean('v').describe('v', 'Print the version.') options.alias('w', 'wait').boolean('w').describe('w', 'Wait for window to be closed before returning.') options.string('socket-path') - options.boolean('multi-folder') + args = options.argv if args.help @@ -133,14 +138,13 @@ parseCommandLine = -> devMode = args['dev'] safeMode = args['safe'] pathsToOpen = args._ - pathsToOpen = [executedFrom] if executedFrom and pathsToOpen.length is 0 test = args['test'] specDirectory = args['spec-directory'] newWindow = args['new-window'] pidToKillWhenClosed = args['pid'] if args['wait'] logFile = args['log-file'] socketPath = args['socket-path'] - enableMultiFolderProject = args['multi-folder'] + profileStartup = args['profile-startup'] if args['resource-path'] devMode = true @@ -166,6 +170,6 @@ parseCommandLine = -> process.env.PATH = args['path-environment'] if args['path-environment'] {resourcePath, pathsToOpen, executedFrom, test, version, pidToKillWhenClosed, - devMode, safeMode, newWindow, specDirectory, logFile, socketPath, enableMultiFolderProject} + devMode, safeMode, newWindow, specDirectory, logFile, socketPath, profileStartup} start() diff --git a/src/browser/squirrel-update.coffee b/src/browser/squirrel-update.coffee index df786af69..be90e6cd6 100644 --- a/src/browser/squirrel-update.coffee +++ b/src/browser/squirrel-update.coffee @@ -76,11 +76,23 @@ installContextMenu = (callback) -> installMenu directoryKeyPath, '%1', -> installMenu(backgroundKeyPath, '%V', callback) +isAscii = (text) -> + index = 0 + while index < text.length + return false if text.charCodeAt(index) > 127 + index++ + true + # Get the user's PATH environment variable registry value. getPath = (callback) -> spawnReg ['query', environmentKeyPath, '/v', 'Path'], (error, stdout) -> if error? if error.code is 1 + # FIXME Don't overwrite path when reading value is disabled + # https://github.com/atom/atom/issues/5092 + if stdout.indexOf('ERROR: Registry editing has been disabled by your administrator.') isnt -1 + return callback(error) + # The query failed so the Path does not exist yet in the registry return callback(null, '') else @@ -96,7 +108,12 @@ getPath = (callback) -> segments = lines[lines.length - 1]?.split(' ') if segments[1] is 'Path' and segments.length >= 3 pathEnv = segments?[3..].join(' ') - callback(null, pathEnv) + if isAscii(pathEnv) + callback(null, pathEnv) + else + # FIXME Don't corrupt non-ASCII PATH values + # https://github.com/atom/atom/issues/5063 + callback(new Error('PATH contains non-ASCII values')) else callback(new Error('Registry query for PATH failed')) @@ -122,7 +139,7 @@ addCommandsToPath = (callback) -> atomShCommandPath = path.join(binFolder, 'atom') relativeAtomShPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'atom.sh')) - atomShCommand = "#!/bin/sh\r\n\"$0/../#{relativeAtomShPath.replace(/\\/g, '/')}\" \"$@\"" + atomShCommand = "#!/bin/sh\r\n\"$(dirname \"$0\")/#{relativeAtomShPath.replace(/\\/g, '/')}\" \"$@\"" apmCommandPath = path.join(binFolder, 'apm.cmd') relativeApmPath = path.relative(binFolder, path.join(process.resourcesPath, 'app', 'apm', 'bin', 'apm.cmd')) @@ -130,7 +147,7 @@ addCommandsToPath = (callback) -> apmShCommandPath = path.join(binFolder, 'apm') relativeApmShPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'apm.sh')) - apmShCommand = "#!/bin/sh\r\n\"$0/../#{relativeApmShPath.replace(/\\/g, '/')}\" \"$@\"" + apmShCommand = "#!/bin/sh\r\n\"$(dirname \"$0\")/#{relativeApmShPath.replace(/\\/g, '/')}\" \"$@\"" fs.writeFile atomCommandPath, atomCommand, -> fs.writeFile atomShCommandPath, atomShCommand, -> diff --git a/src/buffered-process.coffee b/src/buffered-process.coffee index 9748e5557..c7097d711 100644 --- a/src/buffered-process.coffee +++ b/src/buffered-process.coffee @@ -48,6 +48,7 @@ class BufferedProcess constructor: ({command, args, options, stdout, stderr, exit}={}) -> @emitter = new Emitter options ?= {} + @command = command # Related to joyent/node#2318 if process.platform is 'win32' # Quote all arguments and escapes inner quotes @@ -69,50 +70,12 @@ class BufferedProcess cmdArgs = ['/s', '/c', "\"#{cmdArgs.join(' ')}\""] cmdOptions = _.clone(options) cmdOptions.windowsVerbatimArguments = true - @process = ChildProcess.spawn(@getCmdPath(), cmdArgs, cmdOptions) + @spawn(@getCmdPath(), cmdArgs, cmdOptions) else - @process = ChildProcess.spawn(command, args, options) + @spawn(command, args, options) + @killed = false - - stdoutClosed = true - stderrClosed = true - processExited = true - exitCode = 0 - triggerExitCallback = -> - return if @killed - if stdoutClosed and stderrClosed and processExited - exit?(exitCode) - - if stdout - stdoutClosed = false - @bufferStream @process.stdout, stdout, -> - stdoutClosed = true - triggerExitCallback() - - if stderr - stderrClosed = false - @bufferStream @process.stderr, stderr, -> - stderrClosed = true - triggerExitCallback() - - if exit - processExited = false - @process.on 'exit', (code) -> - exitCode = code - processExited = true - triggerExitCallback() - - @process.on 'error', (error) => - handled = false - handle = -> handled = true - - @emitter.emit 'will-throw-error', {error, handle} - - if error.code is 'ENOENT' and error.syscall.indexOf('spawn') is 0 - error = new Error("Failed to spawn command `#{command}`. Make sure `#{command}` is installed and on your PATH", error.path) - error.name = 'BufferedProcessError' - - throw error unless handled + @handleEvents(stdout, stderr, exit) ### Section: Event Subscription @@ -164,6 +127,8 @@ class BufferedProcess # This is required since killing the cmd.exe does not terminate child # processes. killOnWindows: -> + return unless @process? + parentPid = @process.pid cmd = 'wmic' args = [ @@ -174,7 +139,12 @@ class BufferedProcess 'processid' ] - wmicProcess = ChildProcess.spawn(cmd, args) + try + wmicProcess = ChildProcess.spawn(cmd, args) + catch spawnError + @killProcess() + return + wmicProcess.on 'error', -> # ignore errors output = '' wmicProcess.stdout.on 'data', (data) -> output += data @@ -220,3 +190,55 @@ class BufferedProcess @killProcess() undefined + + spawn: (command, args, options) -> + try + @process = ChildProcess.spawn(command, args, options) + catch spawnError + process.nextTick => @handleError(spawnError) + + handleEvents: (stdout, stderr, exit) -> + return unless @process? + + stdoutClosed = true + stderrClosed = true + processExited = true + exitCode = 0 + triggerExitCallback = -> + return if @killed + if stdoutClosed and stderrClosed and processExited + exit?(exitCode) + + if stdout + stdoutClosed = false + @bufferStream @process.stdout, stdout, -> + stdoutClosed = true + triggerExitCallback() + + if stderr + stderrClosed = false + @bufferStream @process.stderr, stderr, -> + stderrClosed = true + triggerExitCallback() + + if exit + processExited = false + @process.on 'exit', (code) -> + exitCode = code + processExited = true + triggerExitCallback() + + @process.on 'error', (error) => @handleError(error) + return + + handleError: (error) -> + handled = false + handle = -> handled = true + + @emitter.emit 'will-throw-error', {error, handle} + + if error.code is 'ENOENT' and error.syscall.indexOf('spawn') is 0 + error = new Error("Failed to spawn command `#{@command}`. Make sure `#{@command}` is installed and on your PATH", error.path) + error.name = 'BufferedProcessError' + + throw error unless handled diff --git a/src/clipboard.coffee b/src/clipboard.coffee index 3fba6f4e8..84ff9ab3b 100644 --- a/src/clipboard.coffee +++ b/src/clipboard.coffee @@ -1,5 +1,5 @@ -clipboard = require 'clipboard' crypto = require 'crypto' +clipboard = require './safe-clipboard' # Extended: Represents the clipboard used for copying and pasting in Atom. # @@ -31,7 +31,7 @@ class Clipboard # {::readWithMetadata}. # # * `text` The {String} to store. - # * `metadata` The additional info to associate with the text. + # * `metadata` (optional) The additional info to associate with the text. write: (text, metadata) -> @signatureForMetadata = @md5(text) @metadata = metadata diff --git a/src/command-installer.coffee b/src/command-installer.coffee index 1d3a16777..afd5000c1 100644 --- a/src/command-installer.coffee +++ b/src/command-installer.coffee @@ -1,12 +1,10 @@ path = require 'path' -_ = require 'underscore-plus' -async = require 'async' fs = require 'fs-plus' runas = null # defer until used symlinkCommand = (sourcePath, destinationPath, callback) -> fs.unlink destinationPath, (error) -> - if error? and error?.code != 'ENOENT' + if error? and error?.code isnt 'ENOENT' callback(error) else fs.makeTree path.dirname(destinationPath), (error) -> @@ -17,13 +15,13 @@ symlinkCommand = (sourcePath, destinationPath, callback) -> symlinkCommandWithPrivilegeSync = (sourcePath, destinationPath) -> runas ?= require 'runas' - if runas('/bin/rm', ['-f', destinationPath], admin: true) != 0 + if runas('/bin/rm', ['-f', destinationPath], admin: true) isnt 0 throw new Error("Failed to remove '#{destinationPath}'") - if runas('/bin/mkdir', ['-p', path.dirname(destinationPath)], admin: true) != 0 + if runas('/bin/mkdir', ['-p', path.dirname(destinationPath)], admin: true) isnt 0 throw new Error("Failed to create directory '#{destinationPath}'") - if runas('/bin/ln', ['-s', sourcePath, destinationPath], admin: true) != 0 + if runas('/bin/ln', ['-s', sourcePath, destinationPath], admin: true) isnt 0 throw new Error("Failed to symlink '#{sourcePath}' to '#{destinationPath}'") module.exports = @@ -36,12 +34,11 @@ module.exports = message: "Failed to install shell commands" detailedMessage: error.message - resourcePath = atom.getLoadSettings().resourcePath - @installAtomCommand resourcePath, true, (error) => + @installAtomCommand true, (error) => if error? showErrorDialog(error) else - @installApmCommand resourcePath, true, (error) -> + @installApmCommand true, (error) -> if error? showErrorDialog(error) else @@ -49,12 +46,12 @@ module.exports = message: "Commands installed." detailedMessage: "The shell commands `atom` and `apm` are installed." - installAtomCommand: (resourcePath, askForPrivilege, callback) -> - commandPath = path.join(resourcePath, 'atom.sh') + installAtomCommand: (askForPrivilege, callback) -> + commandPath = path.join(process.resourcesPath, 'app', 'atom.sh') @createSymlink commandPath, askForPrivilege, callback - installApmCommand: (resourcePath, askForPrivilege, callback) -> - commandPath = path.join(resourcePath, 'apm', 'node_modules', '.bin', 'apm') + installApmCommand: (askForPrivilege, callback) -> + commandPath = path.join(process.resourcesPath, 'app', 'apm', 'node_modules', '.bin', 'apm') @createSymlink commandPath, askForPrivilege, callback createSymlink: (commandPath, askForPrivilege, callback) -> diff --git a/src/command-registry.coffee b/src/command-registry.coffee index 71e35f7e5..870093e2f 100644 --- a/src/command-registry.coffee +++ b/src/command-registry.coffee @@ -1,10 +1,9 @@ {Emitter, Disposable, CompositeDisposable} = require 'event-kit' -{specificity} = require 'clear-cut' +{calculateSpecificity, validateSelector} = require 'clear-cut' _ = require 'underscore-plus' {$} = require './space-pen-extensions' SequenceCount = 0 -SpecificityCache = {} # Public: Associates listener functions with commands in a # context-sensitive way using CSS selectors. You can access a global instance of @@ -19,6 +18,12 @@ SpecificityCache = {} # command event listeners globally on `atom.commands` and constrain them to # specific kinds of elements with CSS selectors. # +# Command names must follow the `namespace:action` pattern, where `namespace` +# will typically be the name of your package, and `action` describes the +# behavior of your command. If either part consists of multiple words, these +# must be separated by hyphens. E.g. `awesome-package:turn-it-up-to-eleven`. +# All words should be lowercased. +# # As the event bubbles upward through the DOM, all registered event listeners # with matching selectors are invoked in order of specificity. In the event of a # specificity tie, the most recently registered listener is invoked first. This @@ -49,6 +54,7 @@ class CommandRegistry destroy: -> for commandName of @registeredCommands window.removeEventListener(commandName, @handleCommandEvent, true) + return # Public: Add one or more command listeners associated with a selector. # @@ -86,7 +92,11 @@ class CommandRegistry disposable.add @add(target, commandName, callback) return disposable + if typeof callback isnt 'function' + throw new Error("Can't register a command with non-function callback.") + if typeof target is 'string' + validateSelector(target) @addSelectorBasedListener(target, commandName, callback) else @addInlineListener(target, commandName, callback) @@ -185,6 +195,7 @@ class CommandRegistry @selectorBasedListenersByCommandName = {} for commandName, listeners of snapshot @selectorBasedListenersByCommandName[commandName] = listeners.slice() + return handleCommandEvent: (originalEvent) => propagationStopped = false @@ -237,7 +248,7 @@ class CommandRegistry class SelectorBasedListener constructor: (@selector, @callback) -> - @specificity = (SpecificityCache[@selector] ?= specificity(@selector)) + @specificity = calculateSpecificity(@selector) @sequenceNumber = SequenceCount++ compare: (other) -> diff --git a/src/compile-cache.coffee b/src/compile-cache.coffee index c31f5bdd1..8fe8d6711 100644 --- a/src/compile-cache.coffee +++ b/src/compile-cache.coffee @@ -2,6 +2,7 @@ path = require 'path' CSON = require 'season' CoffeeCache = require 'coffee-cash' babel = require './babel' +typescript = require './typescript' # This file is required directly by apm so that files can be cached during # package install so that the first package load in Atom doesn't have to @@ -16,6 +17,7 @@ exports.addPathToCache = (filePath, atomHome) -> CoffeeCache.setCacheDirectory(path.join(cacheDir, 'coffee')) CSON.setCacheDir(path.join(cacheDir, 'cson')) babel.setCacheDirectory(path.join(cacheDir, 'js', 'babel')) + typescript.setCacheDirectory(path.join(cacheDir, 'ts')) switch path.extname(filePath) when '.coffee' @@ -24,3 +26,5 @@ exports.addPathToCache = (filePath, atomHome) -> CSON.readFileSync(filePath) when '.js' babel.addPathToCache(filePath) + when '.ts' + typescript.addPathToCache(filePath) diff --git a/src/config-schema.coffee b/src/config-schema.coffee index 1796bb64c..6e8fbfdb7 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -9,7 +9,7 @@ module.exports = properties: ignoredNames: type: 'array' - default: [".git", ".hg", ".svn", ".DS_Store", "Thumbs.db"] + default: [".git", ".hg", ".svn", ".DS_Store", "._*", "Thumbs.db"] items: type: 'string' excludeVcsIgnoredPaths: @@ -18,7 +18,7 @@ module.exports = title: 'Exclude VCS Ignored Paths' followSymlinks: type: 'boolean' - default: false + default: true title: 'Follow symlinks' description: 'Used when searching and when opening files with the fuzzy finder.' disabledPackages: @@ -28,7 +28,7 @@ module.exports = type: 'string' themes: type: 'array' - default: ['atom-dark-ui', 'atom-dark-syntax'] + default: ['one-dark-ui', 'one-dark-syntax'] items: type: 'string' projectHome: @@ -98,21 +98,17 @@ module.exports = type: ['string', 'null'] # These can be used as globals or scoped, thus defaults. - completions: - type: "array" - items: - type: "string" - default: [] fontFamily: type: 'string' default: '' fontSize: type: 'integer' - default: 16 + default: 14 minimum: 1 + maximum: 100 lineHeight: type: ['string', 'number'] - default: 1.3 + default: 1.5 showInvisibles: type: 'boolean' default: false @@ -143,12 +139,18 @@ module.exports = softWrap: type: 'boolean' default: false + description: 'Wraps lines that exceed the width of the window. When `Soft Wrap At Preferred Line Length` is set, it will wrap to the number of characters defined by the `Preferred Line Length` setting.' softTabs: type: 'boolean' default: true softWrapAtPreferredLineLength: type: 'boolean' default: false + description: 'Will wrap to the number of characters defined by the `Preferred Line Length` setting. This will only take effect when soft wrap is enabled globally or for the current language.' + softWrapHangingIndent: + type: 'integer' + default: 0 + minimum: 0 scrollSensitivity: type: 'integer' default: 40 @@ -177,15 +179,19 @@ module.exports = eol: type: ['boolean', 'string'] default: '\u00ac' + maximumLength: 1 space: type: ['boolean', 'string'] default: '\u00b7' + maximumLength: 1 tab: type: ['boolean', 'string'] default: '\u00bb' + maximumLength: 1 cr: type: ['boolean', 'string'] default: '\u00a4' + maximumLength: 1 zoomFontWhenCtrlScrolling: type: 'boolean' default: process.platform isnt 'darwin' diff --git a/src/config.coffee b/src/config.coffee index 937bb1307..f781e02e9 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -1,6 +1,5 @@ _ = require 'underscore-plus' fs = require 'fs-plus' -EmitterMixin = require('emissary').Emitter {CompositeDisposable, Disposable, Emitter} = require 'event-kit' CSON = require 'season' path = require 'path' @@ -78,7 +77,7 @@ ScopeDescriptor = require './scope-descriptor' # # ... # ``` # -# See [Creating a Package](https://atom.io/docs/latest/creating-a-package) for +# See [package docs](https://atom.io/docs/latest/hacking-atom-package-word-count) for # more info. # # ## Config Schemas @@ -290,7 +289,6 @@ ScopeDescriptor = require './scope-descriptor' # module.exports = class Config - EmitterMixin.includeInto(this) @schemaEnforcers = {} @addSchemaEnforcer: (typeName, enforcerFunction) -> @@ -301,6 +299,7 @@ class Config for typeName, functions of filters for name, enforcerFunction of functions @addSchemaEnforcer(typeName, enforcerFunction) + return @executeSchemaEnforcers: (keyPath, value, schema) -> error = null @@ -333,9 +332,16 @@ class Config @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson']) @configFilePath ?= path.join(@configDirPath, 'config.cson') @transactDepth = 0 + @savePending = false - @debouncedSave = _.debounce(@save, 100) - @debouncedLoad = _.debounce(@loadUserConfig, 100) + @requestLoad = _.debounce(@loadUserConfig, 100) + @requestSave = => + @savePending = true + debouncedSave.call(this) + save = => + @savePending = false + @save() + debouncedSave = _.debounce(save, 100) ### Section: Config Subscription @@ -360,7 +366,7 @@ class Config # * `scopeDescriptor` (optional) {ScopeDescriptor} describing a path from # the root of the syntax tree to a token. Get one by calling # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. - # See [the scopes docs](https://atom.io/docs/latest/advanced/scopes-and-scope-descriptors) + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) # for more information. # * `callback` {Function} to call when the value of the key changes. # * `value` the new value of the key @@ -370,7 +376,7 @@ class Config observe: -> if arguments.length is 2 [keyPath, callback] = arguments - else if arguments.length is 3 and (_.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor) + else if Grim.includeDeprecatedAPIs and arguments.length is 3 and (_.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor) Grim.deprecate """ Passing a scope descriptor as the first argument to Config::observe is deprecated. Pass a `scope` in an options hash as the third argument instead. @@ -379,7 +385,7 @@ class Config else if arguments.length is 3 and (_.isString(arguments[0]) and _.isObject(arguments[1])) [keyPath, options, callback] = arguments scopeDescriptor = options.scope - if options.callNow? + if Grim.includeDeprecatedAPIs and options.callNow? Grim.deprecate """ Config::observe no longer takes a `callNow` option. Use ::onDidChange instead. Note that ::onDidChange passes its callback different arguments. @@ -403,7 +409,7 @@ class Config # * `scopeDescriptor` (optional) {ScopeDescriptor} describing a path from # the root of the syntax tree to a token. Get one by calling # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. - # See [the scopes docs](https://atom.io/docs/latest/advanced/scopes-and-scope-descriptors) + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) # for more information. # * `callback` {Function} to call when the value of the key changes. # * `event` {Object} @@ -418,7 +424,7 @@ class Config [callback] = arguments else if arguments.length is 2 [keyPath, callback] = arguments - else if _.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor + else if Grim.includeDeprecatedAPIs and _.isArray(arguments[0]) or arguments[0] instanceof ScopeDescriptor Grim.deprecate """ Passing a scope descriptor as the first argument to Config::onDidChange is deprecated. Pass a `scope` in an options hash as the third argument instead. @@ -487,7 +493,7 @@ class Config # * `scope` (optional) {ScopeDescriptor} describing a path from # the root of the syntax tree to a token. Get one by calling # {editor.getLastCursor().getScopeDescriptor()} - # See [the scopes docs](https://atom.io/docs/latest/advanced/scopes-and-scope-descriptors) + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) # for more information. # # Returns the value from Atom's default settings, the user's configuration @@ -497,7 +503,7 @@ class Config if typeof arguments[0] is 'string' or not arguments[0]? [keyPath, options] = arguments {scope} = options - else + else if Grim.includeDeprecatedAPIs Grim.deprecate """ Passing a scope descriptor as the first argument to Config::get is deprecated. Pass a `scope` in an options hash as the final argument instead. @@ -568,7 +574,7 @@ class Config # setting to the default value. # * `options` (optional) {Object} # * `scopeSelector` (optional) {String}. eg. '.source.ruby' - # See [the scopes docs](https://atom.io/docs/latest/advanced/scopes-and-scope-descriptors) + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) # for more information. # * `source` (optional) {String} The name of a file with which the setting # is associated. Defaults to the user's config file. @@ -577,7 +583,7 @@ class Config # * `true` if the value was set. # * `false` if the value was not able to be coerced to the type specified in the setting's schema. set: -> - if arguments[0]?[0] is '.' + if Grim.includeDeprecatedAPIs and arguments[0]?[0] is '.' Grim.deprecate """ Passing a scope selector as the first argument to Config::set is deprecated. Pass a `scopeSelector` in an options hash as the final argument instead. @@ -606,7 +612,7 @@ class Config else @setRawValue(keyPath, value) - @debouncedSave() if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors + @requestSave() if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors true # Essential: Restore the setting at `keyPath` to its default value. @@ -616,7 +622,7 @@ class Config # * `scopeSelector` (optional) {String}. See {::set} # * `source` (optional) {String}. See {::set} unset: (keyPath, options) -> - if typeof options is 'string' + if Grim.includeDeprecatedAPIs and typeof options is 'string' Grim.deprecate """ Passing a scope selector as the first argument to Config::unset is deprecated. Pass a `scopeSelector` in an options hash as the second argument instead. @@ -636,7 +642,7 @@ class Config _.setValueForKeyPath(settings, keyPath, undefined) settings = withoutEmptyObjects(settings) @set(null, settings, {scopeSelector, source, priority: @priorityForSource(source)}) if settings? - @debouncedSave() + @requestSave() else @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector) @emitChangeEvent() @@ -651,47 +657,6 @@ class Config getSources: -> _.uniq(_.pluck(@scopedSettingsStore.propertySets, 'source')).sort() - # Deprecated: Restore the global setting at `keyPath` to its default value. - # - # Returns the new value. - restoreDefault: (scopeSelector, keyPath) -> - Grim.deprecate("Use ::unset instead.") - @unset(scopeSelector, keyPath) - @get(keyPath) - - # Deprecated: Get the global default value of the key path. _Please note_ that in most - # cases calling this is not necessary! {::get} returns the default value when - # a custom value is not specified. - # - # * `scopeSelector` (optional) {String}. eg. '.source.ruby' - # * `keyPath` The {String} name of the key. - # - # Returns the default value. - getDefault: -> - Grim.deprecate("Use `::get(keyPath, {scope, excludeSources: [atom.config.getUserConfigPath()]})` instead") - if arguments.length is 1 - [keyPath] = arguments - else - [scopeSelector, keyPath] = arguments - scope = [scopeSelector] - @get(keyPath, {scope, excludeSources: [@getUserConfigPath()]}) - - # Deprecated: Is the value at `keyPath` its default value? - # - # * `scopeSelector` (optional) {String}. eg. '.source.ruby' - # * `keyPath` The {String} name of the key. - # - # Returns a {Boolean}, `true` if the current value is the default, `false` - # otherwise. - isDefault: -> - Grim.deprecate("Use `not ::get(keyPath, {scope, sources: [atom.config.getUserConfigPath()]})?` instead") - if arguments.length is 1 - [keyPath] = arguments - else - [scopeSelector, keyPath] = arguments - scope = [scopeSelector] - not @get(keyPath, {scope, sources: [@getUserConfigPath()]})? - # Extended: Retrieve the schema for a specific key path. The schema will tell # you what type the keyPath expects, and other metadata about the config # option. @@ -708,12 +673,6 @@ class Config schema = schema.properties?[key] schema - # Deprecated: Returns a new {Object} containing all of the global settings and - # defaults. Returns the scoped settings when a `scopeSelector` is specified. - getSettings: -> - Grim.deprecate "Use ::get(keyPath) instead" - _.deepExtend({}, @settings, @defaultSettings) - # Extended: Get the {String} path to the config file being used. getUserConfigPath: -> @configFilePath @@ -731,31 +690,6 @@ class Config @transactDepth-- @emitChangeEvent() - ### - Section: Deprecated - ### - - getInt: (keyPath) -> - Grim.deprecate '''Config::getInt is no longer necessary. Use ::get instead. - Make sure the config option you are accessing has specified an `integer` - schema. See the schema section of - https://atom.io/docs/api/latest/Config for more info.''' - parseInt(@get(keyPath)) - - getPositiveInt: (keyPath, defaultValue=0) -> - Grim.deprecate '''Config::getPositiveInt is no longer necessary. Use ::get instead. - Make sure the config option you are accessing has specified an `integer` - schema with `minimum: 1`. See the schema section of - https://atom.io/docs/api/latest/Config for more info.''' - Math.max(@getInt(keyPath), 0) or defaultValue - - toggle: (keyPath) -> - Grim.deprecate 'Config::toggle is no longer supported. Please remove from your code.' - @set(keyPath, !@get(keyPath)) - - unobserve: (keyPath) -> - Grim.deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.' - ### Section: Internal methods used by core ### @@ -830,9 +764,10 @@ class Config CSON.writeFileSync(@configFilePath, {}) try - userConfig = CSON.readFileSync(@configFilePath) - @resetUserSettings(userConfig) - @configFileHasErrors = false + unless @savePending + userConfig = CSON.readFileSync(@configFilePath) + @resetUserSettings(userConfig) + @configFileHasErrors = false catch error @configFileHasErrors = true message = "Failed to load `#{path.basename(@configFilePath)}`" @@ -849,7 +784,7 @@ class Config observeUserConfig: -> try @watchSubscription ?= pathWatcher.watch @configFilePath, (eventType) => - @debouncedLoad() if eventType is 'change' and @watchSubscription? + @requestLoad() if eventType is 'change' and @watchSubscription? catch error @notifyFailure """ Unable to watch path: `#{path.basename(@configFilePath)}`. Make sure you have permissions to @@ -898,6 +833,7 @@ class Config @transact => @settings = {} @set(key, value, save: false) for key, value of newSettings + return getRawValue: (keyPath, options) -> unless options?.excludeSources?.indexOf(@getUserConfigPath()) >= 0 @@ -958,6 +894,7 @@ class Config @setRawDefault(keyPath, defaults) catch e console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + return deepClone: (object) -> if object instanceof Color @@ -1053,16 +990,6 @@ class Config @emitChangeEvent() - addScopedSettings: (source, selector, value, options) -> - Grim.deprecate("Use ::set instead") - settingsBySelector = {} - settingsBySelector[selector] = value - disposable = @scopedSettingsStore.addProperties(source, settingsBySelector, options) - @emitChangeEvent() - new Disposable => - disposable.dispose() - @emitChangeEvent() - setRawScopedValue: (keyPath, value, source, selector, options) -> if keyPath? newValue = {} @@ -1091,11 +1018,6 @@ class Config oldValue = newValue callback(event) - settingsForScopeDescriptor: (scopeDescriptor, keyPath) -> - Grim.deprecate("Use Config::getAll instead") - entries = @getAll(null, scope: scopeDescriptor) - value for {value} in entries when _.valueForKeyPath(value, keyPath)? - # Base schema enforcers. These will coerce raw input into the specified type, # and will throw an error when the value cannot be coerced. Throwing the error # will indicate that the value should not be set. @@ -1138,6 +1060,12 @@ Config.addSchemaEnforcers throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string") value + validateMaximumLength: (keyPath, value, schema) -> + if typeof schema.maximumLength is 'number' and value.length > schema.maximumLength + value.slice(0, schema.maximumLength) + else + value + 'null': # null sort of isnt supported. It will just unset in this case coerce: (keyPath, value, schema) -> @@ -1212,7 +1140,7 @@ splitKeyPath = (keyPath) -> startIndex = 0 keyPathArray = [] for char, i in keyPath - if char is '.' and (i is 0 or keyPath[i-1] != '\\') + if char is '.' and (i is 0 or keyPath[i-1] isnt '\\') keyPathArray.push keyPath.substring(startIndex, i) startIndex = i + 1 keyPathArray.push keyPath.substr(startIndex, keyPath.length) @@ -1229,3 +1157,71 @@ withoutEmptyObjects = (object) -> else resultObject = object resultObject + +# TODO remove in 1.0 API +Config::unobserve = (keyPath) -> + Grim.deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.' + +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(Config) + + Config::restoreDefault = (scopeSelector, keyPath) -> + Grim.deprecate("Use ::unset instead.") + @unset(scopeSelector, keyPath) + @get(keyPath) + + Config::getDefault = -> + Grim.deprecate("Use `::get(keyPath, {scope, excludeSources: [atom.config.getUserConfigPath()]})` instead") + if arguments.length is 1 + [keyPath] = arguments + else + [scopeSelector, keyPath] = arguments + scope = [scopeSelector] + @get(keyPath, {scope, excludeSources: [@getUserConfigPath()]}) + + Config::isDefault = -> + Grim.deprecate("Use `not ::get(keyPath, {scope, sources: [atom.config.getUserConfigPath()]})?` instead") + if arguments.length is 1 + [keyPath] = arguments + else + [scopeSelector, keyPath] = arguments + scope = [scopeSelector] + not @get(keyPath, {scope, sources: [@getUserConfigPath()]})? + + Config::getSettings = -> + Grim.deprecate "Use ::get(keyPath) instead" + _.deepExtend({}, @settings, @defaultSettings) + + Config::getInt = (keyPath) -> + Grim.deprecate '''Config::getInt is no longer necessary. Use ::get instead. + Make sure the config option you are accessing has specified an `integer` + schema. See the schema section of + https://atom.io/docs/api/latest/Config for more info.''' + parseInt(@get(keyPath)) + + Config::getPositiveInt = (keyPath, defaultValue=0) -> + Grim.deprecate '''Config::getPositiveInt is no longer necessary. Use ::get instead. + Make sure the config option you are accessing has specified an `integer` + schema with `minimum: 1`. See the schema section of + https://atom.io/docs/api/latest/Config for more info.''' + Math.max(@getInt(keyPath), 0) or defaultValue + + Config::toggle = (keyPath) -> + Grim.deprecate 'Config::toggle is no longer supported. Please remove from your code.' + @set(keyPath, not @get(keyPath)) + + Config::addScopedSettings = (source, selector, value, options) -> + Grim.deprecate("Use ::set instead") + settingsBySelector = {} + settingsBySelector[selector] = value + disposable = @scopedSettingsStore.addProperties(source, settingsBySelector, options) + @emitChangeEvent() + new Disposable => + disposable.dispose() + @emitChangeEvent() + + Config::settingsForScopeDescriptor = (scopeDescriptor, keyPath) -> + Grim.deprecate("Use Config::getAll instead") + entries = @getAll(null, scope: scopeDescriptor) + value for {value} in entries when _.valueForKeyPath(value, keyPath)? diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee index b890fc29a..73bcbf440 100644 --- a/src/context-menu-manager.coffee +++ b/src/context-menu-manager.coffee @@ -1,15 +1,13 @@ -{$} = require './space-pen-extensions' _ = require 'underscore-plus' -remote = require 'remote' path = require 'path' CSON = require 'season' fs = require 'fs-plus' -{specificity} = require 'clear-cut' +{calculateSpecificity, validateSelector} = require 'clear-cut' {Disposable} = require 'event-kit' Grim = require 'grim' MenuHelpers = require './menu-helpers' -SpecificityCache = {} +platformContextMenu = require('../package.json')?._atomMenu?['context-menu'] # Extended: Provides a registry for commands that you'd like to appear in the # context menu. @@ -50,10 +48,13 @@ class ContextMenuManager atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() loadPlatformItems: -> - menusDirPath = path.join(@resourcePath, 'menus') - platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) - map = CSON.readFileSync(platformMenuPath) - atom.contextMenu.add(map['context-menu']) + if platformContextMenu? + @add(platformContextMenu) + else + menusDirPath = path.join(@resourcePath, 'menus') + platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) + map = CSON.readFileSync(platformMenuPath) + @add(map['context-menu']) # Public: Add context menu items scoped by CSS selectors. # @@ -99,30 +100,35 @@ class ContextMenuManager # whether to display this item on a given context menu deployment. Called # with the following argument: # * `event` The click event that deployed the context menu. + # + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # added menu items. add: (itemsBySelector) -> - # Detect deprecated file path as first argument - if itemsBySelector? and typeof itemsBySelector isnt 'object' - Grim.deprecate """ - ContextMenuManager::add has changed to take a single object as its - argument. Please see - https://atom.io/docs/api/latest/ContextMenuManager for more info. - """ - itemsBySelector = arguments[1] - devMode = arguments[2]?.devMode - - # Detect deprecated format for items object - for key, value of itemsBySelector - unless _.isArray(value) + if Grim.includeDeprecatedAPIs + # Detect deprecated file path as first argument + if itemsBySelector? and typeof itemsBySelector isnt 'object' Grim.deprecate """ - ContextMenuManager::add has changed to take a single object as its + `ContextMenuManager::add` has changed to take a single object as its argument. Please see - https://atom.io/docs/api/latest/ContextMenuManager for more info. + https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format for more info. """ - itemsBySelector = @convertLegacyItemsBySelector(itemsBySelector, devMode) + itemsBySelector = arguments[1] + devMode = arguments[2]?.devMode + + # Detect deprecated format for items object + for key, value of itemsBySelector + unless _.isArray(value) + Grim.deprecate """ + `ContextMenuManager::add` has changed to take a single object as its + argument. Please see + https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format for more info. + """ + itemsBySelector = @convertLegacyItemsBySelector(itemsBySelector, devMode) addedItemSets = [] for selector, items of itemsBySelector + validateSelector(selector) itemSet = new ContextMenuItemSet(selector, items) addedItemSets.push(itemSet) @itemSets.push(itemSet) @@ -130,6 +136,7 @@ class ContextMenuManager new Disposable => for itemSet in addedItemSets @itemSets.splice(@itemSets.indexOf(itemSet), 1) + return templateForElement: (target) -> @templateForEvent({target}) @@ -185,7 +192,7 @@ class ContextMenuManager menuTemplate = @templateForEvent(event) return unless menuTemplate?.length > 0 - remote.getCurrentWindow().emit('context-menu', menuTemplate) + atom.getCurrentWindow().emit('context-menu', menuTemplate) return clear: -> @@ -202,4 +209,4 @@ class ContextMenuManager class ContextMenuItemSet constructor: (@selector, @items) -> - @specificity = (SpecificityCache[@selector] ?= specificity(@selector)) + @specificity = calculateSpecificity(@selector) diff --git a/src/cursor.coffee b/src/cursor.coffee index 7126eded1..b6b07dbf2 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -1,8 +1,8 @@ {Point, Range} = require 'text-buffer' -{Model} = require 'theorist' {Emitter} = require 'event-kit' _ = require 'underscore-plus' Grim = require 'grim' +Model = require './model' # Extended: The `Cursor` class represents the little blinking line identifying # where text can be inserted. @@ -15,7 +15,6 @@ class Cursor extends Model bufferPosition: null goalColumn: null visible: true - needsAutoscroll: null # Instantiated by a {TextEditor} constructor: ({@editor, @marker, id}) -> @@ -30,10 +29,6 @@ class Cursor extends Model {textChanged} = e return if oldHeadScreenPosition.isEqual(newHeadScreenPosition) - # Supports old editor view - @needsAutoscroll ?= @isLastCursor() and !textChanged - @autoscroll() if @editor.manageScrollPosition and @isLastCursor() and textChanged - @goalColumn = null movedEvent = @@ -44,16 +39,15 @@ class Cursor extends Model textChanged: textChanged cursor: this - @emit 'moved', movedEvent + @emit 'moved', movedEvent if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-position', movedEvent @editor.cursorMoved(movedEvent) @marker.onDidDestroy => @destroyed = true @editor.removeCursor(this) - @emit 'destroyed' + @emit 'destroyed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-destroy' @emitter.dispose() - @needsAutoscroll = true destroy: -> @marker.destroy() @@ -95,13 +89,13 @@ class Cursor extends Model @emitter.on 'did-change-visibility', callback on: (eventName) -> + return unless Grim.includeDeprecatedAPIs + switch eventName when 'moved' Grim.deprecate("Use Cursor::onDidChangePosition instead") when 'destroyed' Grim.deprecate("Use Cursor::onDidDestroy instead") - when 'destroyed' - Grim.deprecate("Use Cursor::onDidDestroy instead") else Grim.deprecate("::on is no longer supported. Use the event subscription methods instead") super @@ -128,8 +122,9 @@ class Cursor extends Model # # * `bufferPosition` {Array} of two numbers: the buffer row, and the buffer column. # * `options` (optional) {Object} with the following keys: - # * `autoscroll` A Boolean which, if `true`, scrolls the {TextEditor} to wherever - # the cursor moves to. + # * `autoscroll` {Boolean} indicating whether to autoscroll to the new + # position. Defaults to `true` if this is the most recently added cursor, + # `false` otherwise. setBufferPosition: (bufferPosition, options={}) -> @changePosition options, => @marker.setHeadBufferPosition(bufferPosition, options) @@ -161,7 +156,7 @@ class Cursor extends Model # Public: Returns whether the cursor is at the start of a line. isAtBeginningOfLine: -> - @getBufferPosition().column == 0 + @getBufferPosition().column is 0 # Public: Returns whether the cursor is on the line return character. isAtEndOfLine: -> @@ -215,7 +210,7 @@ class Cursor extends Model isInsideWord: (options) -> {row, column} = @getBufferPosition() range = [[row, column], [row, Infinity]] - @editor.getTextInBufferRange(range).search(options?.wordRegex ? @wordRegExp()) == 0 + @editor.getTextInBufferRange(range).search(options?.wordRegex ? @wordRegExp()) is 0 # Public: Returns the indentation level of the current line. getIndentLevel: -> @@ -229,9 +224,6 @@ class Cursor extends Model # Returns a {ScopeDescriptor} getScopeDescriptor: -> @editor.scopeDescriptorForBufferPosition(@getBufferPosition()) - getScopes: -> - Grim.deprecate 'Use Cursor::getScopeDescriptor() instead' - @getScopeDescriptor().getScopesArray() # Public: Returns true if this cursor has no non-whitespace characters before # its current position. @@ -251,7 +243,7 @@ class Cursor extends Model # # Returns a {Boolean}. isLastCursor: -> - this == @editor.getLastCursor() + this is @editor.getLastCursor() ### Section: Moving the Cursor @@ -266,9 +258,9 @@ class Cursor extends Model moveUp: (rowCount=1, {moveToEndOfSelection}={}) -> range = @marker.getScreenRange() if moveToEndOfSelection and not range.isEmpty() - { row, column } = range.start + {row, column} = range.start else - { row, column } = @getScreenPosition() + {row, column} = @getScreenPosition() column = @goalColumn if @goalColumn? @setScreenPosition({row: row - rowCount, column: column}, skipSoftWrapIndentation: true) @@ -283,9 +275,9 @@ class Cursor extends Model moveDown: (rowCount=1, {moveToEndOfSelection}={}) -> range = @marker.getScreenRange() if moveToEndOfSelection and not range.isEmpty() - { row, column } = range.end + {row, column} = range.end else - { row, column } = @getScreenPosition() + {row, column} = @getScreenPosition() column = @goalColumn if @goalColumn? @setScreenPosition({row: row + rowCount, column: column}, skipSoftWrapIndentation: true) @@ -310,7 +302,7 @@ class Cursor extends Model columnCount-- # subtract 1 for the row move column = column - columnCount - @setScreenPosition({row, column}) + @setScreenPosition({row, column}, clip: 'backward') # Public: Moves the cursor right one screen column. # @@ -323,7 +315,7 @@ class Cursor extends Model if moveToEndOfSelection and not range.isEmpty() @setScreenPosition(range.end) else - { row, column } = @getScreenPosition() + {row, column} = @getScreenPosition() maxLines = @editor.getScreenLineCount() rowLength = @editor.lineTextForScreenRow(row).length columnsRemainingInLine = rowLength - column @@ -337,11 +329,11 @@ class Cursor extends Model columnsRemainingInLine = rowLength column = column + columnCount - @setScreenPosition({row, column}, skipAtomicTokens: true, wrapBeyondNewlines: true, wrapAtSoftNewlines: true) + @setScreenPosition({row, column}, clip: 'forward', wrapBeyondNewlines: true, wrapAtSoftNewlines: true) # Public: Moves the cursor to the top of the buffer. moveToTop: -> - @setBufferPosition([0,0]) + @setBufferPosition([0, 0]) # Public: Moves the cursor to the bottom of the buffer. moveToBottom: -> @@ -498,10 +490,6 @@ class Cursor extends Model endOfWordPosition or currentBufferPosition - getMoveNextWordBoundaryBufferPosition: (options) -> - Grim.deprecate 'Use `::getNextWordBoundaryBufferPosition(options)` instead' - @getNextWordBoundaryBufferPosition(options) - # Public: Retrieves the buffer position of where the current word starts. # # * `options` (optional) An {Object} with the following keys: @@ -613,10 +601,9 @@ class Cursor extends Model # Public: Sets whether the cursor is visible. setVisible: (visible) -> - if @visible != visible + if @visible isnt visible @visible = visible - @needsAutoscroll ?= true if @visible and @isLastCursor() - @emit 'visibility-changed', @visible + @emit 'visibility-changed', @visible if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-visibility', @visible # Public: Returns the visibility of the cursor. @@ -643,11 +630,10 @@ class Cursor extends Model # Public: Prevents this cursor from causing scrolling. clearAutoscroll: -> - @needsAutoscroll = null # Public: Deselects the current selection. - clearSelection: -> - @selection?.clear() + clearSelection: (options) -> + @selection?.clear(options) # Public: Get the RegExp used by the cursor to determine what a "word" is. # @@ -689,12 +675,9 @@ class Cursor extends Model ### changePosition: (options, fn) -> - @clearSelection() - @needsAutoscroll = options.autoscroll ? @isLastCursor() + @clearSelection(autoscroll: false) fn() - if @needsAutoscroll - @emit 'autoscrolled' # Support legacy editor - @autoscroll() if @needsAutoscroll and @editor.manageScrollPosition # Support react editor view + @autoscroll() if options.autoscroll ? @isLastCursor() getPixelRect: -> @editor.pixelRectForScreenRange(@getScreenRange()) @@ -715,20 +698,29 @@ class Cursor extends Model position = new Point(row, column - 1) @editor.scanInBufferRange /^\n*$/g, scanRange, ({range, stop}) -> - if !range.start.isEqual(start) + unless range.start.isEqual(start) position = range.start stop() - @editor.screenPositionForBufferPosition(position) + position getBeginningOfPreviousParagraphBufferPosition: -> start = @getBufferPosition() {row, column} = start - scanRange = [[row-1, column], [0,0]] + scanRange = [[row-1, column], [0, 0]] position = new Point(0, 0) - zero = new Point(0,0) + zero = new Point(0, 0) @editor.backwardsScanInBufferRange /^\n*$/g, scanRange, ({range, stop}) -> - if !range.start.isEqual(zero) + unless range.start.isEqual(zero) position = range.start stop() - @editor.screenPositionForBufferPosition(position) + position + +if Grim.includeDeprecatedAPIs + Cursor::getScopes = -> + Grim.deprecate 'Use Cursor::getScopeDescriptor() instead' + @getScopeDescriptor().getScopesArray() + + Cursor::getMoveNextWordBoundaryBufferPosition = (options) -> + Grim.deprecate 'Use `::getNextWordBoundaryBufferPosition(options)` instead' + @getNextWordBoundaryBufferPosition(options) diff --git a/src/cursors-component.coffee b/src/cursors-component.coffee index f4f5d749f..2de4f1ede 100644 --- a/src/cursors-component.coffee +++ b/src/cursors-component.coffee @@ -7,6 +7,9 @@ class CursorsComponent @domNode = document.createElement('div') @domNode.classList.add('cursors') + getDomNode: -> + @domNode + updateSync: (state) -> newState = state.content @oldState ?= {cursors: {}} @@ -35,6 +38,8 @@ class CursorsComponent @domNode.appendChild(cursorNode) @updateCursorNode(id, cursorState) + return + updateCursorNode: (id, newCursorState) -> cursorNode = @cursorNodesById[id] oldCursorState = (@oldState.cursors[id] ?= {}) diff --git a/src/custom-event-mixin.coffee b/src/custom-event-mixin.coffee index 1a3bb4d88..12785c89c 100644 --- a/src/custom-event-mixin.coffee +++ b/src/custom-event-mixin.coffee @@ -7,9 +7,11 @@ CustomEventMixin = for name, listeners in @customEventListeners for listener in listeners @getDOMNode().removeEventListener(name, listener) + return addCustomEventListeners: (customEventListeners) -> for name, listener of customEventListeners @customEventListeners[name] ?= [] @customEventListeners[name].push(listener) @getDOMNode().addEventListener(name, listener) + return diff --git a/src/custom-gutter-component.coffee b/src/custom-gutter-component.coffee new file mode 100644 index 000000000..39f5a80a1 --- /dev/null +++ b/src/custom-gutter-component.coffee @@ -0,0 +1,110 @@ +{setDimensionsAndBackground} = require './gutter-component-helpers' + +# This class represents a gutter other than the 'line-numbers' gutter. +# The contents of this gutter may be specified by Decorations. + +module.exports = +class CustomGutterComponent + + constructor: ({@gutter}) -> + @decorationNodesById = {} + @decorationItemsById = {} + @visible = true + + @domNode = atom.views.getView(@gutter) + @decorationsNode = @domNode.firstChild + # Clear the contents in case the domNode is being reused. + @decorationsNode.innerHTML = '' + + getDomNode: -> + @domNode + + hideNode: -> + if @visible + @domNode.style.display = 'none' + @visible = false + + showNode: -> + if not @visible + @domNode.style.removeProperty('display') + @visible = true + + # `state` is a subset of the TextEditorPresenter state that is specific + # to this line number gutter. + updateSync: (state) -> + @oldDimensionsAndBackgroundState ?= {} + setDimensionsAndBackground(@oldDimensionsAndBackgroundState, state.styles, @decorationsNode) + + @oldDecorationPositionState ?= {} + decorationState = state.content + + updatedDecorationIds = new Set + for decorationId, decorationInfo of decorationState + updatedDecorationIds.add(decorationId) + existingDecoration = @decorationNodesById[decorationId] + if existingDecoration + @updateDecorationNode(existingDecoration, decorationId, decorationInfo) + else + newNode = @buildDecorationNode(decorationId, decorationInfo) + @decorationNodesById[decorationId] = newNode + @decorationsNode.appendChild(newNode) + + for decorationId, decorationNode of @decorationNodesById + if not updatedDecorationIds.has(decorationId) + decorationNode.remove() + delete @decorationNodesById[decorationId] + delete @decorationItemsById[decorationId] + delete @oldDecorationPositionState[decorationId] + + ### + Section: Private Methods + ### + + # Builds and returns an HTMLElement to represent the specified decoration. + buildDecorationNode: (decorationId, decorationInfo) -> + @oldDecorationPositionState[decorationId] = {} + newNode = document.createElement('div') + newNode.style.position = 'absolute' + @updateDecorationNode(newNode, decorationId, decorationInfo) + newNode + + # Updates the existing HTMLNode with the new decoration info. Attempts to + # minimize changes to the DOM. + updateDecorationNode: (node, decorationId, newDecorationInfo) -> + oldPositionState = @oldDecorationPositionState[decorationId] + + if oldPositionState.top isnt newDecorationInfo.top + 'px' + node.style.top = newDecorationInfo.top + 'px' + oldPositionState.top = newDecorationInfo.top + 'px' + + if oldPositionState.height isnt newDecorationInfo.height + 'px' + node.style.height = newDecorationInfo.height + 'px' + oldPositionState.height = newDecorationInfo.height + 'px' + + if newDecorationInfo.class and not node.classList.contains(newDecorationInfo.class) + node.className = 'decoration' + node.classList.add(newDecorationInfo.class) + else if not newDecorationInfo.class + node.className = 'decoration' + + @setDecorationItem(newDecorationInfo.item, newDecorationInfo.height, decorationId, node) + + # Sets the decorationItem on the decorationNode. + # If `decorationItem` is undefined, the decorationNode's child item will be cleared. + setDecorationItem: (newItem, decorationHeight, decorationId, decorationNode) -> + if newItem isnt @decorationItemsById[decorationId] + while decorationNode.firstChild + decorationNode.removeChild(decorationNode.firstChild) + delete @decorationItemsById[decorationId] + + if newItem + # `item` should be either an HTMLElement or a space-pen View. + newItemNode = null + if newItem instanceof HTMLElement + newItemNode = newItem + else + newItemNode = newItem.element + + newItemNode.style.height = decorationHeight + 'px' + decorationNode.appendChild(newItemNode) + @decorationItemsById[decorationId] = newItem diff --git a/src/decoration.coffee b/src/decoration.coffee index fdaaa285d..d8f0b5edf 100644 --- a/src/decoration.coffee +++ b/src/decoration.coffee @@ -1,11 +1,17 @@ _ = require 'underscore-plus' -EmitterMixin = require('emissary').Emitter {Emitter} = require 'event-kit' Grim = require 'grim' idCounter = 0 nextId = -> idCounter++ +# Applies changes to a decorationsParam {Object} to make it possible to +# differentiate decorations on custom gutters versus the line-number gutter. +translateDecorationParamsOldToNew = (decorationParams) -> + if decorationParams.type is 'line-number' + decorationParams.gutterName = 'line-number' + decorationParams + # Essential: Represents a decoration that follows a {Marker}. A decoration is # basically a visual representation of a marker. It allows you to add CSS # classes to line numbers in the gutter, lines, and add selection-line regions @@ -20,7 +26,7 @@ nextId = -> idCounter++ # decoration = editor.decorateMarker(marker, {type: 'line', class: 'my-line-class'}) # ``` # -# Best practice for destorying the decoration is by destroying the {Marker}. +# Best practice for destroying the decoration is by destroying the {Marker}. # # ```coffee # marker.destroy() @@ -30,7 +36,6 @@ nextId = -> idCounter++ # the marker. module.exports = class Decoration - EmitterMixin.includeInto(this) # Private: Check if the `decorationProperties.type` matches `type` # @@ -40,23 +45,32 @@ class Decoration # 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) - type in decorationProperties.type + return true if type in decorationProperties.type + if type is 'gutter' + return true if 'line-number' in decorationProperties.type + return false else - type is decorationProperties.type + if type is 'gutter' + return true if decorationProperties.type in ['gutter', 'line-number'] + else + type is decorationProperties.type ### Section: Construction and Destruction ### - constructor: (@marker, @displayBuffer, @properties) -> + constructor: (@marker, @displayBuffer, properties) -> @emitter = new Emitter @id = nextId() + @setProperties properties @properties.id = @id @flashQueue = null @destroyed = false - @markerDestroyDisposable = @marker.onDidDestroy => @destroy() # Essential: Destroy this marker. @@ -68,7 +82,7 @@ class Decoration @markerDestroyDisposable.dispose() @markerDestroyDisposable = null @destroyed = true - @emit 'destroyed' + @emit 'destroyed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-destroy' @emitter.dispose() @@ -124,9 +138,6 @@ class Decoration # Essential: Returns the {Decoration}'s properties. getProperties: -> @properties - getParams: -> - Grim.deprecate 'Use Decoration::getProperties instead' - @getProperties() # Essential: Update the marker with new Properties. Allows you to change the decoration's class. # @@ -140,13 +151,12 @@ class Decoration setProperties: (newProperties) -> return if @destroyed oldProperties = @properties - @properties = newProperties + @properties = translateDecorationParamsOldToNew(newProperties) @properties.id = @id - @emit 'updated', {oldParams: oldProperties, newParams: newProperties} + if newProperties.type? + @displayBuffer.decorationDidChangeType(this) + @emit 'updated', {oldParams: oldProperties, newParams: newProperties} if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-properties', {oldProperties, newProperties} - update: (newProperties) -> - Grim.deprecate 'Use Decoration::setProperties instead' - @setProperties(newProperties) ### Section: Private methods @@ -155,7 +165,7 @@ class Decoration matchesPattern: (decorationPattern) -> return false unless decorationPattern? for key, value of decorationPattern - return false if @properties[key] != value + return false if @properties[key] isnt value true onDidFlash: (callback) -> @@ -165,14 +175,18 @@ class Decoration flashObject = {class: klass, duration} @flashQueue ?= [] @flashQueue.push(flashObject) - @emit 'flash' + @emit 'flash' if Grim.includeDeprecatedAPIs @emitter.emit 'did-flash' consumeNextFlash: -> return @flashQueue.shift() if @flashQueue?.length > 0 null - on: (eventName) -> +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(Decoration) + + Decoration::on = (eventName) -> switch eventName when 'updated' Grim.deprecate 'Use Decoration::onDidChangeProperties instead' @@ -184,3 +198,11 @@ class Decoration Grim.deprecate 'Decoration::on is deprecated. Use event subscription methods instead.' EmitterMixin::on.apply(this, arguments) + + Decoration::getParams = -> + Grim.deprecate 'Use Decoration::getProperties instead' + @getProperties() + + Decoration::update = (newProperties) -> + Grim.deprecate 'Use Decoration::setProperties instead' + @setProperties(newProperties) diff --git a/src/default-directory-provider.coffee b/src/default-directory-provider.coffee index 7ecdbd354..da2d17593 100644 --- a/src/default-directory-provider.coffee +++ b/src/default-directory-provider.coffee @@ -1,6 +1,7 @@ {Directory} = require 'pathwatcher' fs = require 'fs-plus' path = require 'path' +url = require 'url' module.exports = class DefaultDirectoryProvider @@ -14,14 +15,22 @@ class DefaultDirectoryProvider # * {Directory} if the given URI is compatible with this provider. # * `null` if the given URI is not compatibile with this provider. directoryForURISync: (uri) -> - projectPath = path.normalize(uri) - - directoryPath = if fs.isDirectorySync(projectPath) - projectPath + normalizedPath = path.normalize(uri) + {protocol} = url.parse(uri) + directoryPath = if protocol? + uri + else if not fs.isDirectorySync(normalizedPath) and fs.isDirectorySync(path.dirname(normalizedPath)) + path.dirname(normalizedPath) else - path.dirname(projectPath) + normalizedPath - new Directory(directoryPath) + # TODO: Stop normalizing the path in pathwatcher's Directory. + directory = new Directory(directoryPath) + if protocol? + directory.path = directoryPath + if fs.isCaseInsensitive() + directory.lowerCasePath = directoryPath.toLowerCase() + directory # Public: Create a Directory that corresponds to the specified URI. # diff --git a/src/default-directory-searcher.coffee b/src/default-directory-searcher.coffee new file mode 100644 index 000000000..ebe3a35f9 --- /dev/null +++ b/src/default-directory-searcher.coffee @@ -0,0 +1,95 @@ +Task = require './task' + +# Public: Searches local files for lines matching a specified regex. +# +# Implements thenable so it can be used with `Promise.all()`. +class DirectorySearch + constructor: (rootPaths, regex, options) -> + scanHandlerOptions = + ignoreCase: regex.ignoreCase + inclusions: options.inclusions + includeHidden: options.includeHidden + excludeVcsIgnores: options.excludeVcsIgnores + exclusions: options.exclusions + follow: options.follow + @task = new Task(require.resolve('./scan-handler')) + @task.on 'scan:result-found', options.didMatch + @task.on 'scan:file-error', options.didError + @task.on 'scan:paths-searched', options.didSearchPaths + @promise = new Promise (resolve, reject) => + @task.on('task:cancelled', reject) + @task.start(rootPaths, regex.source, scanHandlerOptions, resolve) + + # Public: Implementation of `then()` to satisfy the *thenable* contract. + # This makes it possible to use a `DirectorySearch` with `Promise.all()`. + # + # Returns `Promise`. + then: (args...) -> + @promise.then.apply(@promise, args) + + # Public: Cancels the search. + cancel: -> + # This will cause @promise to reject. + @task.cancel() + null + + +# Default provider for the `atom.directory-searcher` service. +module.exports = +class DefaultDirectorySearcher + # Public: Determines whether this object supports search for a `Directory`. + # + # * `directory` {Directory} whose search needs might be supported by this object. + # + # Returns a `boolean` indicating whether this object can search this `Directory`. + canSearchDirectory: (directory) -> true + + # Public: Performs a text search for files in the specified `Directory`, subject to the + # specified parameters. + # + # Results are streamed back to the caller by invoking methods on the specified `options`, + # such as `didMatch` and `didError`. + # + # * `directories` {Array} of {Directory} objects to search, all of which have been accepted by + # this searcher's `canSearchDirectory()` predicate. + # * `regex` {RegExp} to search with. + # * `options` {Object} with the following properties: + # * `didMatch` {Function} call with a search result structured as follows: + # * `searchResult` {Object} with the following keys: + # * `filePath` {String} absolute path to the matching file. + # * `matches` {Array} with object elements with the following keys: + # * `lineText` {String} The full text of the matching line (without a line terminator character). + # * `lineTextOffset` {Number} (This always seems to be 0?) + # * `matchText` {String} The text that matched the `regex` used for the search. + # * `range` {Range} Identifies the matching region in the file. (Likely as an array of numeric arrays.) + # * `didError` {Function} call with an Error if there is a problem during the search. + # * `didSearchPaths` {Function} periodically call with the number of paths searched thus far. + # * `inclusions` {Array} of glob patterns (as strings) to search within. Note that this + # array may be empty, indicating that all files should be searched. + # + # Each item in the array is a file/directory pattern, e.g., `src` to search in the "src" + # directory or `*.js` to search all JavaScript files. In practice, this often comes from the + # comma-delimited list of patterns in the bottom text input of the ProjectFindView dialog. + # * `ignoreHidden` {boolean} whether to ignore hidden files. + # * `excludeVcsIgnores` {boolean} whether to exclude VCS ignored paths. + # * `exclusions` {Array} similar to inclusions + # * `follow` {boolean} whether symlinks should be followed. + # + # Returns a *thenable* `DirectorySearch` that includes a `cancel()` method. If `cancel()` is + # invoked before the `DirectorySearch` is determined, it will resolve the `DirectorySearch`. + search: (directories, regex, options) -> + rootPaths = directories.map (directory) -> directory.getPath() + isCancelled = false + directorySearch = new DirectorySearch(rootPaths, regex, options) + promise = new Promise (resolve, reject) -> + directorySearch.then resolve, -> + if isCancelled + resolve() + else + reject() + return { + then: promise.then.bind(promise) + cancel: -> + isCancelled = true + directorySearch.cancel() + } diff --git a/src/deprecated-packages.coffee b/src/deprecated-packages.coffee new file mode 100644 index 000000000..2eeea435c --- /dev/null +++ b/src/deprecated-packages.coffee @@ -0,0 +1,43 @@ +semver = require 'semver' + +deprecatedPackages = require('../package.json')?._deprecatedPackages ? {} +ranges = {} + +exports.getDeprecatedPackageMetadata = (name) -> + metadata = null + if deprecatedPackages.hasOwnProperty(name) + metadata = deprecatedPackages[name] + Object.freeze(metadata) if metadata + metadata + +exports.isDeprecatedPackage = (name, version) -> + return false unless deprecatedPackages.hasOwnProperty(name) + + deprecatedVersionRange = deprecatedPackages[name].version + return true unless deprecatedVersionRange + + semver.valid(version) and satisfies(version, deprecatedVersionRange) + +satisfies = (version, rawRange) -> + unless parsedRange = ranges[rawRange] + parsedRange = new Range(rawRange) + ranges[rawRange] = parsedRange + parsedRange.test(version) + +# Extend semver.Range to memoize matched versions for speed +class Range extends semver.Range + constructor: -> + super + @matchedVersions = new Set() + @unmatchedVersions = new Set() + + test: (version) -> + return true if @matchedVersions.has(version) + return false if @unmatchedVersions.has(version) + + matches = super + if matches + @matchedVersions.add(version) + else + @unmatchedVersions.add(version) + matches diff --git a/src/deserializer-manager.coffee b/src/deserializer-manager.coffee index 50becb31a..fab84539c 100644 --- a/src/deserializer-manager.coffee +++ b/src/deserializer-manager.coffee @@ -35,10 +35,7 @@ class DeserializerManager @deserializers[deserializer.name] = deserializer for deserializer in deserializers new Disposable => delete @deserializers[deserializer.name] for deserializer in deserializers - - remove: (classes...) -> - Grim.deprecate("Call .dispose() on the Disposable return from ::add instead") - delete @deserializers[name] for {name} in classes + return # Public: Deserialize the state and params. # @@ -63,3 +60,9 @@ class DeserializerManager name = state.get?('deserializer') ? state.deserializer @deserializers[name] + +if Grim.includeDeprecatedAPIs + DeserializerManager::remove = (classes...) -> + Grim.deprecate("Call .dispose() on the Disposable return from ::add instead") + delete @deserializers[name] for {name} in classes + return diff --git a/src/display-buffer.coffee b/src/display-buffer.coffee index e09f1580c..179795db5 100644 --- a/src/display-buffer.coffee +++ b/src/display-buffer.coffee @@ -1,16 +1,15 @@ _ = require 'underscore-plus' -EmitterMixin = require('emissary').Emitter Serializable = require 'serializable' -{Model} = require 'theorist' {CompositeDisposable, Emitter} = require 'event-kit' {Point, Range} = require 'text-buffer' +Grim = require 'grim' TokenizedBuffer = require './tokenized-buffer' RowMap = require './row-map' Fold = require './fold' +Model = require './model' Token = require './token' Decoration = require './decoration' Marker = require './marker' -Grim = require 'grim' class BufferToScreenConversionError extends Error constructor: (@message, @metadata) -> @@ -21,42 +20,32 @@ module.exports = class DisplayBuffer extends Model Serializable.includeInto(this) - @properties - manageScrollPosition: false - softWrapped: null - editorWidthInChars: null - lineHeightInPixels: null - defaultCharWidth: null - height: null - width: null - scrollTop: 0 - scrollLeft: 0 - scrollWidth: 0 - verticalScrollbarWidth: 15 - horizontalScrollbarHeight: 15 - verticalScrollMargin: 2 horizontalScrollMargin: 6 scopedCharacterWidthsChangeCount: 0 - constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer, @invisibles}={}) -> + constructor: ({tabLength, @editorWidthInChars, @tokenizedBuffer, buffer, ignoreInvisibles, @largeFileMode}={}) -> super @emitter = new Emitter + @disposables = new CompositeDisposable - @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer, @invisibles}) + @tokenizedBuffer ?= new TokenizedBuffer({tabLength, buffer, ignoreInvisibles, @largeFileMode}) @buffer = @tokenizedBuffer.buffer @charWidthsByScope = {} @markers = {} @foldsByMarkerId = {} @decorationsById = {} @decorationsByMarkerId = {} - @subscribe @tokenizedBuffer.observeGrammar @subscribeToScopedConfigSettings - @subscribe @tokenizedBuffer.onDidChange @handleTokenizedBufferChange - @subscribe @buffer.onDidUpdateMarkers @handleBufferMarkersUpdated - @subscribe @buffer.onDidCreateMarker @handleBufferMarkerCreated + @overlayDecorationsById = {} + @disposables.add @tokenizedBuffer.observeGrammar @subscribeToScopedConfigSettings + @disposables.add @tokenizedBuffer.onDidChange @handleTokenizedBufferChange + @disposables.add @buffer.onDidCreateMarker @handleBufferMarkerCreated + @disposables.add @buffer.onDidUpdateMarkers => @emitter.emit 'did-update-markers' + @foldMarkerAttributes = Object.freeze({class: 'fold', displayBufferId: @id}) + folds = (new Fold(this, marker) for marker in @buffer.findMarkers(@getFoldMarkerAttributes())) @updateAllScreenLines() - @createFoldForMarker(marker) for marker in @buffer.findMarkers(@getFoldMarkerAttributes()) + @decorateFold(fold) for fold in folds subscribeToScopedConfigSettings: => @scopedConfigSubscriptions?.dispose() @@ -69,12 +58,17 @@ class DisplayBuffer extends Model scrollPastEnd: atom.config.get('editor.scrollPastEnd', scope: scopeDescriptor) softWrap: atom.config.get('editor.softWrap', scope: scopeDescriptor) softWrapAtPreferredLineLength: atom.config.get('editor.softWrapAtPreferredLineLength', scope: scopeDescriptor) + softWrapHangingIndent: atom.config.get('editor.softWrapHangingIndent', scope: scopeDescriptor) preferredLineLength: atom.config.get('editor.preferredLineLength', scope: scopeDescriptor) subscriptions.add atom.config.onDidChange 'editor.softWrap', scope: scopeDescriptor, ({newValue}) => @configSettings.softWrap = newValue @updateWrappedScreenLines() + subscriptions.add atom.config.onDidChange 'editor.softWrapHangingIndent', scope: scopeDescriptor, ({newValue}) => + @configSettings.softWrapHangingIndent = newValue + @updateWrappedScreenLines() + subscriptions.add atom.config.onDidChange 'editor.softWrapAtPreferredLineLength', scope: scopeDescriptor, ({newValue}) => @configSettings.softWrapAtPreferredLineLength = newValue @updateWrappedScreenLines() if @isSoftWrapped() @@ -95,14 +89,14 @@ class DisplayBuffer extends Model scrollTop: @scrollTop scrollLeft: @scrollLeft tokenizedBuffer: @tokenizedBuffer.serialize() - invisibles: _.clone(@invisibles) + largeFileMode: @largeFileMode deserializeParams: (params) -> params.tokenizedBuffer = TokenizedBuffer.deserialize(params.tokenizedBuffer) params copy: -> - newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength(), @invisibles}) + newDisplayBuffer = new DisplayBuffer({@buffer, tabLength: @getTabLength(), @largeFileMode}) newDisplayBuffer.setScrollTop(@getScrollTop()) newDisplayBuffer.setScrollLeft(@getScrollLeft()) @@ -131,6 +125,20 @@ class DisplayBuffer extends Model onDidChangeCharacterWidths: (callback) -> @emitter.on 'did-change-character-widths', callback + onDidChangeScrollTop: (callback) -> + @emitter.on 'did-change-scroll-top', callback + + onDidChangeScrollLeft: (callback) -> + @emitter.on 'did-change-scroll-left', callback + + observeScrollTop: (callback) -> + callback(@scrollTop) + @onDidChangeScrollTop(callback) + + observeScrollLeft: (callback) -> + callback(@scrollLeft) + @onDidChangeScrollLeft(callback) + observeDecorations: (callback) -> callback(decoration) for decoration in @getDecorations() @onDidAddDecoration(callback) @@ -147,40 +155,13 @@ class DisplayBuffer extends Model onDidUpdateMarkers: (callback) -> @emitter.on 'did-update-markers', callback - on: (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use DisplayBuffer::onDidChange instead") - when 'grammar-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeGrammar instead") - when 'soft-wrap-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeSoftWrap instead") - when 'character-widths-changed' - Grim.deprecate("Use DisplayBuffer::onDidChangeCharacterWidths instead") - when 'decoration-added' - Grim.deprecate("Use DisplayBuffer::onDidAddDecoration instead") - when 'decoration-removed' - Grim.deprecate("Use DisplayBuffer::onDidRemoveDecoration instead") - when 'decoration-changed' - Grim.deprecate("Use decoration.getMarker().onDidChange() instead") - when 'decoration-updated' - Grim.deprecate("Use Decoration::onDidChangeProperties instead") - when 'marker-created' - Grim.deprecate("Use Decoration::onDidCreateMarker instead") - when 'markers-updated' - Grim.deprecate("Use Decoration::onDidUpdateMarkers instead") - else - Grim.deprecate("DisplayBuffer::on is deprecated. Use event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) - emitDidChange: (eventProperties, refreshMarkers=true) -> - if refreshMarkers - @pauseMarkerChangeEvents() - @refreshMarkerScreenPositions() - @emit 'changed', eventProperties + @emit 'changed', eventProperties if Grim.includeDeprecatedAPIs @emitter.emit 'did-change', eventProperties - @resumeMarkerChangeEvents() + if refreshMarkers + @refreshMarkerScreenPositions() + @emit 'markers-updated' if Grim.includeDeprecatedAPIs + @emitter.emit 'did-update-markers' updateWrappedScreenLines: -> start = 0 @@ -188,19 +169,29 @@ class DisplayBuffer extends Model @updateAllScreenLines() screenDelta = @getLastRow() - end bufferDelta = 0 - @emitDidChange({ start, end, screenDelta, bufferDelta }) + @emitDidChange({start, end, screenDelta, bufferDelta}) # Sets the visibility of the tokenized buffer. # # visible - A {Boolean} indicating of the tokenized buffer is shown setVisible: (visible) -> @tokenizedBuffer.setVisible(visible) - getVerticalScrollMargin: -> @verticalScrollMargin + getVerticalScrollMargin: -> Math.min(@verticalScrollMargin, (@getHeight() - @getLineHeightInPixels()) / 2) setVerticalScrollMargin: (@verticalScrollMargin) -> @verticalScrollMargin - getHorizontalScrollMargin: -> @horizontalScrollMargin + getVerticalScrollMarginInPixels: -> + scrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeightInPixels() + maxScrollMarginInPixels = (@getHeight() - @getLineHeightInPixels()) / 2 + Math.min(scrollMarginInPixels, maxScrollMarginInPixels) + + getHorizontalScrollMargin: -> Math.min(@horizontalScrollMargin, (@getWidth() - @getDefaultCharWidth()) / 2) setHorizontalScrollMargin: (@horizontalScrollMargin) -> @horizontalScrollMargin + getHorizontalScrollMarginInPixels: -> + scrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() + maxScrollMarginInPixels = (@getWidth() - @getDefaultCharWidth()) / 2 + Math.min(scrollMarginInPixels, maxScrollMarginInPixels) + getHorizontalScrollbarHeight: -> @horizontalScrollbarHeight setHorizontalScrollbarHeight: (@horizontalScrollbarHeight) -> @horizontalScrollbarHeight @@ -263,26 +254,27 @@ class DisplayBuffer extends Model getScrollTop: -> @scrollTop setScrollTop: (scrollTop) -> - if @manageScrollPosition - @scrollTop = Math.round(Math.max(0, Math.min(@getMaxScrollTop(), scrollTop))) - else - @scrollTop = Math.round(scrollTop) + scrollTop = Math.round(Math.max(0, Math.min(@getMaxScrollTop(), scrollTop))) + unless scrollTop is @scrollTop + @scrollTop = scrollTop + @emitter.emit 'did-change-scroll-top', @scrollTop + @scrollTop getMaxScrollTop: -> @getScrollHeight() - @getClientHeight() - getScrollBottom: -> @scrollTop + @height + getScrollBottom: -> @scrollTop + @getClientHeight() setScrollBottom: (scrollBottom) -> @setScrollTop(scrollBottom - @getClientHeight()) @getScrollBottom() getScrollLeft: -> @scrollLeft setScrollLeft: (scrollLeft) -> - if @manageScrollPosition - @scrollLeft = Math.round(Math.max(0, Math.min(@getScrollWidth() - @getClientWidth(), scrollLeft))) - @scrollLeft - else - @scrollLeft = Math.round(scrollLeft) + scrollLeft = Math.round(Math.max(0, Math.min(@getScrollWidth() - @getClientWidth(), scrollLeft))) + unless scrollLeft is @scrollLeft + @scrollLeft = scrollLeft + @emitter.emit 'did-change-scroll-left', @scrollLeft + @scrollLeft getMaxScrollLeft: -> @getScrollWidth() - @getClientWidth() @@ -329,7 +321,7 @@ class DisplayBuffer extends Model characterWidthsChanged: -> @computeScrollWidth() - @emit 'character-widths-changed', @scopedCharacterWidthsChangeCount + @emit 'character-widths-changed', @scopedCharacterWidthsChangeCount if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-character-widths', @scopedCharacterWidthsChangeCount clearScopedCharWidths: -> @@ -348,12 +340,13 @@ class DisplayBuffer extends Model getScrollWidth: -> @scrollWidth + # Returns an {Array} of two numbers representing the first and the last visible rows. getVisibleRowRange: -> return [0, 0] unless @getLineHeightInPixels() > 0 - heightInLines = Math.ceil(@getHeight() / @getLineHeightInPixels()) + 1 startRow = Math.floor(@getScrollTop() / @getLineHeightInPixels()) - endRow = Math.min(@getLineCount(), startRow + heightInLines) + endRow = Math.ceil((@getScrollTop() + @getHeight()) / @getLineHeightInPixels()) - 1 + endRow = Math.min(@getLineCount(), endRow) [startRow, endRow] @@ -366,15 +359,16 @@ class DisplayBuffer extends Model @intersectsVisibleRowRange(start.row, end.row + 1) scrollToScreenRange: (screenRange, options) -> - verticalScrollMarginInPixels = @getVerticalScrollMargin() * @getLineHeightInPixels() - horizontalScrollMarginInPixels = @getHorizontalScrollMargin() * @getDefaultCharWidth() + verticalScrollMarginInPixels = @getVerticalScrollMarginInPixels() + horizontalScrollMarginInPixels = @getHorizontalScrollMarginInPixels() - {top, left, height, width} = @pixelRectForScreenRange(screenRange) - bottom = top + height - right = left + width + {top, left} = @pixelRectForScreenRange(new Range(screenRange.start, screenRange.start)) + {top: endTop, left: endLeft, height: endHeight} = @pixelRectForScreenRange(new Range(screenRange.end, screenRange.end)) + bottom = endTop + endHeight + right = endLeft if options?.center - desiredScrollCenter = top + height / 2 + desiredScrollCenter = (top + bottom) / 2 unless @getScrollTop() < desiredScrollCenter < @getScrollBottom() desiredScrollTop = desiredScrollCenter - @getHeight() / 2 desiredScrollBottom = desiredScrollCenter + @getHeight() / 2 @@ -385,15 +379,26 @@ class DisplayBuffer extends Model desiredScrollLeft = left - horizontalScrollMarginInPixels desiredScrollRight = right + horizontalScrollMarginInPixels - if desiredScrollTop < @getScrollTop() - @setScrollTop(desiredScrollTop) - else if desiredScrollBottom > @getScrollBottom() - @setScrollBottom(desiredScrollBottom) + if options?.reversed ? true + if desiredScrollBottom > @getScrollBottom() + @setScrollBottom(desiredScrollBottom) + if desiredScrollTop < @getScrollTop() + @setScrollTop(desiredScrollTop) - if desiredScrollLeft < @getScrollLeft() - @setScrollLeft(desiredScrollLeft) - else if desiredScrollRight > @getScrollRight() - @setScrollRight(desiredScrollRight) + if desiredScrollRight > @getScrollRight() + @setScrollRight(desiredScrollRight) + if desiredScrollLeft < @getScrollLeft() + @setScrollLeft(desiredScrollLeft) + else + if desiredScrollTop < @getScrollTop() + @setScrollTop(desiredScrollTop) + if desiredScrollBottom > @getScrollBottom() + @setScrollBottom(desiredScrollBottom) + + if desiredScrollLeft < @getScrollLeft() + @setScrollLeft(desiredScrollLeft) + if desiredScrollRight > @getScrollRight() + @setScrollRight(desiredScrollRight) scrollToScreenPosition: (screenPosition, options) -> @scrollToScreenRange(new Range(screenPosition, screenPosition), options) @@ -426,22 +431,25 @@ class DisplayBuffer extends Model setTabLength: (tabLength) -> @tokenizedBuffer.setTabLength(tabLength) - setInvisibles: (@invisibles) -> - @tokenizedBuffer.setInvisibles(@invisibles) + setIgnoreInvisibles: (ignoreInvisibles) -> + @tokenizedBuffer.setIgnoreInvisibles(ignoreInvisibles) setSoftWrapped: (softWrapped) -> if softWrapped isnt @softWrapped @softWrapped = softWrapped @updateWrappedScreenLines() softWrapped = @isSoftWrapped() - @emit 'soft-wrap-changed', softWrapped + @emit 'soft-wrap-changed', softWrapped if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-soft-wrapped', softWrapped softWrapped else @isSoftWrapped() isSoftWrapped: -> - @softWrapped ? @configSettings.softWrap ? false + if @largeFileMode + false + else + @softWrapped ? @configSettings.softWrap ? false # Set the number of characters that fit horizontally in the editor. # @@ -474,7 +482,14 @@ class DisplayBuffer extends Model # # Returns {TokenizedLine} tokenizedLineForScreenRow: (screenRow) -> - @screenLines[screenRow] + if @largeFileMode + if line = @tokenizedBuffer.tokenizedLineForRow(screenRow) + if line.text.length > @maxLineLength + @maxLineLength = line.text.length + @longestScreenRow = screenRow + line + else + @screenLines[screenRow] # Gets the screen lines for the given screen row range. # @@ -483,13 +498,19 @@ class DisplayBuffer extends Model # # Returns an {Array} of {TokenizedLine}s. tokenizedLinesForScreenRows: (startRow, endRow) -> - @screenLines[startRow..endRow] + if @largeFileMode + @tokenizedBuffer.tokenizedLinesForRows(startRow, endRow) + else + @screenLines[startRow..endRow] # Gets all the screen lines. # # Returns an {Array} of {TokenizedLine}s. getTokenizedLines: -> - new Array(@screenLines...) + if @largeFileMode + @tokenizedBuffer.tokenizedLinesForRows(0, @getLastRow()) + else + new Array(@screenLines...) indentLevelForLine: (line) -> @tokenizedBuffer.indentLevelForLine(line) @@ -502,8 +523,11 @@ class DisplayBuffer extends Model # # Returns an {Array} of buffer rows as {Numbers}s. bufferRowsForScreenRows: (startScreenRow, endScreenRow) -> - for screenRow in [startScreenRow..endScreenRow] - @rowMap.bufferRowRangeForScreenRow(screenRow)[0] + if @largeFileMode + [startScreenRow..endScreenRow] + else + for screenRow in [startScreenRow..endScreenRow] + @rowMap.bufferRowRangeForScreenRow(screenRow)[0] # Creates a new fold between two row numbers. # @@ -512,10 +536,11 @@ class DisplayBuffer extends Model # # Returns the new {Fold}. createFold: (startRow, endRow) -> - foldMarker = - @findFoldMarker({startRow, endRow}) ? - @buffer.markRange([[startRow, 0], [endRow, Infinity]], @getFoldMarkerAttributes()) - @foldForMarker(foldMarker) + unless @largeFileMode + foldMarker = + @findFoldMarker({startRow, endRow}) ? + @buffer.markRange([[startRow, 0], [endRow, Infinity]], @getFoldMarkerAttributes()) + @foldForMarker(foldMarker) isFoldedAtBufferRow: (bufferRow) -> @largestFoldContainingBufferRow(bufferRow)? @@ -532,6 +557,7 @@ class DisplayBuffer extends Model # bufferRow - The buffer row {Number} to check against unfoldBufferRow: (bufferRow) -> fold.destroy() for fold in @foldsContainingBufferRow(bufferRow) + return # Given a buffer row, this returns the largest fold that starts there. # @@ -578,9 +604,17 @@ class DisplayBuffer extends Model # Returns the folds in the given row range (exclusive of end row) that are # not contained by any other folds. outermostFoldsInBufferRowRange: (startRow, endRow) -> - @findFoldMarkers(containedInRange: [[startRow, 0], [endRow, 0]]) - .map (marker) => @foldForMarker(marker) - .filter (fold) -> not fold.isInsideLargerFold() + folds = [] + lastFoldEndRow = -1 + + for marker in @findFoldMarkers(intersectsRowRange: [startRow, endRow]) + range = marker.getRange() + if range.start.row > lastFoldEndRow + lastFoldEndRow = range.end.row + if startRow <= range.start.row <= range.end.row < endRow + folds.push(@foldForMarker(marker)) + + folds # Public: Given a buffer row, this returns folds that include it. # @@ -598,10 +632,16 @@ class DisplayBuffer extends Model # # Returns a {Number}. screenRowForBufferRow: (bufferRow) -> - @rowMap.screenRowRangeForBufferRow(bufferRow)[0] + if @largeFileMode + bufferRow + else + @rowMap.screenRowRangeForBufferRow(bufferRow)[0] lastScreenRowForBufferRow: (bufferRow) -> - @rowMap.screenRowRangeForBufferRow(bufferRow)[1] - 1 + if @largeFileMode + bufferRow + else + @rowMap.screenRowRangeForBufferRow(bufferRow)[1] - 1 # Given a screen row, this converts it into a buffer row. # @@ -609,7 +649,10 @@ class DisplayBuffer extends Model # # Returns a {Number}. bufferRowForScreenRow: (screenRow) -> - @rowMap.bufferRowRangeForScreenRow(screenRow)[0] + if @largeFileMode + screenRow + else + @rowMap.bufferRowRangeForScreenRow(screenRow)[0] # Given a buffer range, this converts it into a screen position. # @@ -648,16 +691,19 @@ class DisplayBuffer extends Model top = targetRow * @lineHeightInPixels left = 0 column = 0 - for token in @tokenizedLineForScreenRow(targetRow).tokens - charWidths = @getScopedCharWidths(token.scopes) + + iterator = @tokenizedLineForScreenRow(targetRow).getTokenIterator() + while iterator.next() + charWidths = @getScopedCharWidths(iterator.getScopes()) valueIndex = 0 - while valueIndex < token.value.length - if token.hasPairedCharacter - char = token.value.substr(valueIndex, 2) + value = iterator.getText() + while valueIndex < value.length + if iterator.isPairedCharacter() + char = value charLength = 2 valueIndex += 2 else - char = token.value[valueIndex] + char = value[valueIndex] charLength = 1 valueIndex++ @@ -671,22 +717,26 @@ class DisplayBuffer extends Model targetLeft = pixelPosition.left defaultCharWidth = @defaultCharWidth row = Math.floor(targetTop / @getLineHeightInPixels()) + targetLeft = 0 if row < 0 targetLeft = Infinity if row > @getLastRow() row = Math.min(row, @getLastRow()) row = Math.max(0, row) left = 0 column = 0 - for token in @tokenizedLineForScreenRow(row).tokens - charWidths = @getScopedCharWidths(token.scopes) + + iterator = @tokenizedLineForScreenRow(row).getTokenIterator() + while iterator.next() + charWidths = @getScopedCharWidths(iterator.getScopes()) + value = iterator.getText() valueIndex = 0 - while valueIndex < token.value.length - if token.hasPairedCharacter - char = token.value.substr(valueIndex, 2) + while valueIndex < value.length + if iterator.isPairedCharacter() + char = value charLength = 2 valueIndex += 2 else - char = token.value[valueIndex] + char = value[valueIndex] charLength = 1 valueIndex++ @@ -704,7 +754,10 @@ class DisplayBuffer extends Model # # Returns a {Number}. getLineCount: -> - @screenLines.length + if @largeFileMode + @tokenizedBuffer.getLineCount() + else + @screenLines.length # Gets the number of the last screen line. # @@ -736,10 +789,10 @@ class DisplayBuffer extends Model screenPositionForBufferPosition: (bufferPosition, options) -> throw new Error("This TextEditor has been destroyed") if @isDestroyed() - { row, column } = @buffer.clipPosition(bufferPosition) + {row, column} = @buffer.clipPosition(bufferPosition) [startScreenRow, endScreenRow] = @rowMap.screenRowRangeForBufferRow(row) for screenRow in [startScreenRow...endScreenRow] - screenLine = @screenLines[screenRow] + screenLine = @tokenizedLineForScreenRow(screenRow) unless screenLine? throw new BufferToScreenConversionError "No screen line exists when converting buffer row to screen row", @@ -770,9 +823,9 @@ class DisplayBuffer extends Model # # Returns a {Point}. bufferPositionForScreenPosition: (screenPosition, options) -> - { row, column } = @clipScreenPosition(Point.fromObject(screenPosition), options) + {row, column} = @clipScreenPosition(Point.fromObject(screenPosition), options) [bufferRow] = @rowMap.bufferRowRangeForScreenRow(row) - new Point(bufferRow, @screenLines[row].bufferColumnForScreenColumn(column)) + new Point(bufferRow, @tokenizedLineForScreenRow(row).bufferColumnForScreenColumn(column)) # Retrieves the grammar's token scopeDescriptor for a buffer position. # @@ -824,8 +877,8 @@ class DisplayBuffer extends Model # # Returns the new, clipped {Point}. Note that this could be the same as `position` if no clipping was performed. clipScreenPosition: (screenPosition, options={}) -> - { wrapBeyondNewlines, wrapAtSoftNewlines, skipSoftWrapIndentation } = options - { row, column } = Point.fromObject(screenPosition) + {wrapBeyondNewlines, wrapAtSoftNewlines, skipSoftWrapIndentation} = options + {row, column} = Point.fromObject(screenPosition) if row < 0 row = 0 @@ -836,13 +889,13 @@ class DisplayBuffer extends Model else if column < 0 column = 0 - screenLine = @screenLines[row] + screenLine = @tokenizedLineForScreenRow(row) maxScreenColumn = screenLine.getMaxScreenColumn() if screenLine.isSoftWrapped() and column >= maxScreenColumn if wrapAtSoftNewlines row++ - column = @screenLines[row].clipScreenColumn(0) + column = @tokenizedLineForScreenRow(row).clipScreenColumn(0) else column = screenLine.clipScreenColumn(maxScreenColumn - 1) else if screenLine.isColumnInsideSoftWrapIndentation(column) @@ -850,7 +903,7 @@ class DisplayBuffer extends Model column = screenLine.clipScreenColumn(0) else row-- - column = @screenLines[row].getMaxScreenColumn() - 1 + column = @tokenizedLineForScreenRow(row).getMaxScreenColumn() - 1 else if wrapBeyondNewlines and column > maxScreenColumn and row < @getLastRow() row++ column = 0 @@ -858,6 +911,18 @@ class DisplayBuffer extends Model column = screenLine.clipScreenColumn(column, options) new Point(row, column) + # Clip the start and end of the given range to valid positions on screen. + # See {::clipScreenPosition} for more information. + # + # * `range` The {Range} to clip. + # * `options` (optional) See {::clipScreenPosition} `options`. + # Returns a {Range}. + clipScreenRange: (range, options) -> + start = @clipScreenPosition(range.start, options) + end = @clipScreenPosition(range.end, options) + + new Range(start, end) + # Calculates a {Range} representing the start of the {TextBuffer} until the end. # # Returns a {Range}. @@ -870,7 +935,7 @@ class DisplayBuffer extends Model getDecorations: (propertyFilter) -> allDecorations = [] for markerId, decorations of @decorationsByMarkerId - allDecorations = allDecorations.concat(decorations) if decorations? + allDecorations.push(decorations...) if decorations? if propertyFilter? allDecorations = allDecorations.filter (decoration) -> for key, value of propertyFilter @@ -888,7 +953,16 @@ class DisplayBuffer extends Model @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('highlight') getOverlayDecorations: (propertyFilter) -> - @getDecorations(propertyFilter).filter (decoration) -> decoration.isType('overlay') + result = [] + for id, decoration of @overlayDecorationsById + result.push(decoration) + if propertyFilter? + result.filter (decoration) -> + for key, value of propertyFilter + return false unless decoration.properties[key] is value + true + else + result decorationsForScreenRowRange: (startScreenRow, endScreenRow) -> decorationsByMarkerId = {} @@ -900,11 +974,15 @@ class DisplayBuffer extends Model decorateMarker: (marker, decorationParams) -> marker = @getMarker(marker.id) decoration = new Decoration(marker, this, decorationParams) - @subscribe decoration.onDidDestroy => @removeDecoration(decoration) + decorationDestroyedDisposable = decoration.onDidDestroy => + @removeDecoration(decoration) + @disposables.remove(decorationDestroyedDisposable) + @disposables.add(decorationDestroyedDisposable) @decorationsByMarkerId[marker.id] ?= [] @decorationsByMarkerId[marker.id].push(decoration) + @overlayDecorationsById[decoration.id] = decoration if decoration.isType('overlay') @decorationsById[decoration.id] = decoration - @emit 'decoration-added', decoration + @emit 'decoration-added', decoration if Grim.includeDeprecatedAPIs @emitter.emit 'did-add-decoration', decoration decoration @@ -916,9 +994,13 @@ class DisplayBuffer extends Model if index > -1 decorations.splice(index, 1) delete @decorationsById[decoration.id] - @emit 'decoration-removed', decoration + @emit 'decoration-removed', decoration if Grim.includeDeprecatedAPIs @emitter.emit 'did-remove-decoration', decoration delete @decorationsByMarkerId[marker.id] if decorations.length is 0 + delete @overlayDecorationsById[decoration.id] + + decorationsForMarkerId: (markerId) -> + @decorationsByMarkerId[markerId] # Retrieves a {Marker} based on its id. # @@ -1059,41 +1141,40 @@ class DisplayBuffer extends Model findFoldMarkers: (attributes) -> @buffer.findMarkers(@getFoldMarkerAttributes(attributes)) - getFoldMarkerAttributes: (attributes={}) -> - _.extend(attributes, class: 'fold', displayBufferId: @id) - - pauseMarkerChangeEvents: -> - marker.pauseChangeEvents() for marker in @getMarkers() - - resumeMarkerChangeEvents: -> - marker.resumeChangeEvents() for marker in @getMarkers() - @emit 'markers-updated' - @emitter.emit 'did-update-markers' + getFoldMarkerAttributes: (attributes) -> + if attributes + _.extend(attributes, @foldMarkerAttributes) + else + @foldMarkerAttributes refreshMarkerScreenPositions: -> for marker in @getMarkers() marker.notifyObservers(textChanged: false) + return destroyed: -> - marker.unsubscribe() for id, marker of @markers + marker.disposables.dispose() for id, marker of @markers @scopedConfigSubscriptions.dispose() - @unsubscribe() + @disposables.dispose() @tokenizedBuffer.destroy() logLines: (start=0, end=@getLastRow()) -> for row in [start..end] line = @tokenizedLineForScreenRow(row).text console.log row, @bufferRowForScreenRow(row), line, line.length + return getRootScopeDescriptor: -> @tokenizedBuffer.rootScopeDescriptor handleTokenizedBufferChange: (tokenizedBufferChange) => {start, end, delta, bufferChange} = tokenizedBufferChange - @updateScreenLines(start, end + 1, delta, delayChangeEvent: bufferChange?) - @setScrollTop(Math.min(@getScrollTop(), @getMaxScrollTop())) if @manageScrollPosition and delta < 0 + @updateScreenLines(start, end + 1, delta, refreshMarkers: false) + @setScrollTop(Math.min(@getScrollTop(), @getMaxScrollTop())) if delta < 0 updateScreenLines: (startBufferRow, endBufferRow, bufferDelta=0, options={}) -> + return if @largeFileMode + startBufferRow = @rowMap.bufferRowRangeForBufferRow(startBufferRow)[0] endBufferRow = @rowMap.bufferRowRangeForBufferRow(endBufferRow - 1)[1] startScreenRow = @rowMap.screenRowRangeForBufferRow(startBufferRow)[0] @@ -1101,7 +1182,7 @@ class DisplayBuffer extends Model {screenLines, regions} = @buildScreenLines(startBufferRow, endBufferRow + bufferDelta) screenDelta = screenLines.length - (endScreenRow - startScreenRow) - @screenLines[startScreenRow...endScreenRow] = screenLines + _.spliceWithArray(@screenLines, startScreenRow, endScreenRow - startScreenRow, screenLines, 10000) @rowMap.spliceRegions(startBufferRow, endBufferRow - startBufferRow, regions) @findMaxLineLength(startScreenRow, endScreenRow, screenLines, screenDelta) @@ -1113,22 +1194,22 @@ class DisplayBuffer extends Model screenDelta: screenDelta bufferDelta: bufferDelta - if options.delayChangeEvent - @pauseMarkerChangeEvents() - @pendingChangeEvent = changeEvent - else - @emitDidChange(changeEvent, options.refreshMarkers) + @emitDidChange(changeEvent, options.refreshMarkers) buildScreenLines: (startBufferRow, endBufferRow) -> screenLines = [] regions = [] rectangularRegion = null + foldsByStartRow = {} + for fold in @outermostFoldsInBufferRowRange(startBufferRow, endBufferRow) + foldsByStartRow[fold.getStartRow()] = fold + bufferRow = startBufferRow while bufferRow < endBufferRow tokenizedLine = @tokenizedBuffer.tokenizedLineForRow(bufferRow) - if fold = @largestFoldStartingAtBufferRow(bufferRow) + if fold = foldsByStartRow[bufferRow] foldLine = tokenizedLine.copy() foldLine.fold = fold screenLines.push(foldLine) @@ -1144,7 +1225,10 @@ class DisplayBuffer extends Model softWraps = 0 if @isSoftWrapped() while wrapScreenColumn = tokenizedLine.findWrapColumn(@getSoftWrapColumn()) - [wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt(wrapScreenColumn) + [wrappedLine, tokenizedLine] = tokenizedLine.softWrapAt( + wrapScreenColumn, + @configSettings.softWrapHangingIndent + ) break if wrappedLine.hasOnlySoftWrapIndentation() screenLines.push(wrappedLine) softWraps++ @@ -1194,22 +1278,81 @@ class DisplayBuffer extends Model @scrollWidth += 1 unless @isSoftWrapped() @setScrollLeft(Math.min(@getScrollLeft(), @getMaxScrollLeft())) - handleBufferMarkersUpdated: => - if event = @pendingChangeEvent - @pendingChangeEvent = null - @emitDidChange(event, false) - handleBufferMarkerCreated: (textBufferMarker) => - @createFoldForMarker(textBufferMarker) if textBufferMarker.matchesParams(@getFoldMarkerAttributes()) + if textBufferMarker.matchesParams(@getFoldMarkerAttributes()) + fold = new Fold(this, textBufferMarker) + fold.updateDisplayBuffer() + @decorateFold(fold) + if marker = @getMarker(textBufferMarker.id) # The marker might have been removed in some other handler called before # this one. Only emit when the marker still exists. - @emit 'marker-created', marker + @emit 'marker-created', marker if Grim.includeDeprecatedAPIs @emitter.emit 'did-create-marker', marker - createFoldForMarker: (marker) -> - @decorateMarker(marker, type: 'line-number', class: 'folded') - new Fold(this, marker) + decorateFold: (fold) -> + @decorateMarker(fold.marker, type: 'line-number', class: 'folded') foldForMarker: (marker) -> @foldsByMarkerId[marker.id] + + decorationDidChangeType: (decoration) -> + if decoration.isType('overlay') + @overlayDecorationsById[decoration.id] = decoration + else + delete @overlayDecorationsById[decoration.id] + +if Grim.includeDeprecatedAPIs + DisplayBuffer.properties + softWrapped: null + editorWidthInChars: null + lineHeightInPixels: null + defaultCharWidth: null + height: null + width: null + scrollTop: 0 + scrollLeft: 0 + scrollWidth: 0 + verticalScrollbarWidth: 15 + horizontalScrollbarHeight: 15 + + EmitterMixin = require('emissary').Emitter + + DisplayBuffer::on = (eventName) -> + switch eventName + when 'changed' + Grim.deprecate("Use DisplayBuffer::onDidChange instead") + when 'grammar-changed' + Grim.deprecate("Use DisplayBuffer::onDidChangeGrammar instead") + when 'soft-wrap-changed' + Grim.deprecate("Use DisplayBuffer::onDidChangeSoftWrap instead") + when 'character-widths-changed' + Grim.deprecate("Use DisplayBuffer::onDidChangeCharacterWidths instead") + when 'decoration-added' + Grim.deprecate("Use DisplayBuffer::onDidAddDecoration instead") + when 'decoration-removed' + Grim.deprecate("Use DisplayBuffer::onDidRemoveDecoration instead") + when 'decoration-changed' + Grim.deprecate("Use decoration.getMarker().onDidChange() instead") + when 'decoration-updated' + Grim.deprecate("Use Decoration::onDidChangeProperties instead") + when 'marker-created' + Grim.deprecate("Use Decoration::onDidCreateMarker instead") + when 'markers-updated' + Grim.deprecate("Use Decoration::onDidUpdateMarkers instead") + else + Grim.deprecate("DisplayBuffer::on is deprecated. Use event subscription methods instead.") + + EmitterMixin::on.apply(this, arguments) +else + DisplayBuffer::softWrapped = null + DisplayBuffer::editorWidthInChars = null + DisplayBuffer::lineHeightInPixels = null + DisplayBuffer::defaultCharWidth = null + DisplayBuffer::height = null + DisplayBuffer::width = null + DisplayBuffer::scrollTop = 0 + DisplayBuffer::scrollLeft = 0 + DisplayBuffer::scrollWidth = 0 + DisplayBuffer::verticalScrollbarWidth = 15 + DisplayBuffer::horizontalScrollbarHeight = 15 diff --git a/src/fold.coffee b/src/fold.coffee index 4b413bd12..051be9f4c 100644 --- a/src/fold.coffee +++ b/src/fold.coffee @@ -13,7 +13,6 @@ class Fold constructor: (@displayBuffer, @marker) -> @id = @marker.id @displayBuffer.foldsByMarkerId[@marker.id] = this - @updateDisplayBuffer() @marker.onDidDestroy => @destroyed() @marker.onDidChange ({isValid}) => @destroy() unless isValid diff --git a/src/git-repository.coffee b/src/git-repository.coffee index d523b562f..09ae42dcf 100644 --- a/src/git-repository.coffee +++ b/src/git-repository.coffee @@ -1,11 +1,10 @@ {basename, join} = require 'path' _ = require 'underscore-plus' -EmitterMixin = require('emissary').Emitter {Emitter, Disposable, CompositeDisposable} = require 'event-kit' fs = require 'fs-plus' GitUtils = require 'git-utils' -{deprecate} = require 'grim' +{includeDeprecatedAPIs, deprecate} = require 'grim' Task = require './task' @@ -43,8 +42,6 @@ Task = require './task' # ``` module.exports = class GitRepository - EmitterMixin.includeInto(this) - @exists: (path) -> if git = @open(path) git.destroy() @@ -96,7 +93,8 @@ class GitRepository @subscriptions.add new Disposable(-> window.removeEventListener 'focus', onWindowFocus) if @project? - @subscriptions.add @project.eachBuffer (buffer) => @subscribeToBuffer(buffer) + @project.getBuffers().forEach (buffer) => @subscribeToBuffer(buffer) + @subscriptions.add @project.onDidAddBuffer (buffer) => @subscribeToBuffer(buffer) # Public: Destroy this {GitRepository} object. # @@ -122,6 +120,10 @@ class GitRepository # Public: Invoke the given callback when this GitRepository's destroy() method # is invoked. + # + # * `callback` {Function} + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> @emitter.on 'did-destroy', callback @@ -154,20 +156,16 @@ class GitRepository onDidChangeStatuses: (callback) -> @emitter.on 'did-change-statuses', callback - on: (eventName) -> - switch eventName - when 'status-changed' - deprecate 'Use GitRepository::onDidChangeStatus instead' - when 'statuses-changed' - deprecate 'Use GitRepository::onDidChangeStatuses instead' - else - deprecate 'GitRepository::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - ### Section: Repository Details ### + # Public: A {String} indicating the type of version control system used by + # this repository. + # + # Returns `"git"`. + getType: -> 'git' + # Public: Returns the {String} path of the repository. getPath: -> @path ?= fs.absolute(@getRepo().getPath()) @@ -245,9 +243,6 @@ class GitRepository # * `path` (optional) {String} path in the repository to get this information # for, only needed if the repository has submodules. getOriginURL: (path) -> @getConfigValue('remote.origin.url', path) - getOriginUrl: (path) -> - deprecate 'Use ::getOriginURL instead.' - @getOriginURL(path) # Public: Returns the upstream branch for the current HEAD, or null if there # is no upstream branch for the current HEAD. @@ -282,14 +277,24 @@ class GitRepository ### # Public: Returns true if the given path is modified. + # + # * `path` The {String} path to check. + # + # Returns a {Boolean} that's true if the `path` is modified. isPathModified: (path) -> @isStatusModified(@getPathStatus(path)) # Public: Returns true if the given path is new. + # + # * `path` The {String} path to check. + # + # Returns a {Boolean} that's true if the `path` is new. isPathNew: (path) -> @isStatusNew(@getPathStatus(path)) # Public: Is the given path ignored? # - # Returns a {Boolean}. + # * `path` The {String} path to check. + # + # Returns a {Boolean} that's true if the `path` is ignored. isPathIgnored: (path) -> @getRepo().isIgnored(@relativize(path)) # Public: Get the status of a directory in the repository's working directory. @@ -322,7 +327,7 @@ class GitRepository else delete @statuses[relativePath] if currentPathStatus isnt pathStatus - @emit 'status-changed', path, pathStatus + @emit 'status-changed', path, pathStatus if includeDeprecatedAPIs @emitter.emit 'did-change-status', {path, pathStatus} pathStatus @@ -336,9 +341,17 @@ class GitRepository @statuses[@relativize(path)] # Public: Returns true if the given status indicates modification. + # + # * `status` A {Number} representing the status. + # + # Returns a {Boolean} that's true if the `status` indicates modification. isStatusModified: (status) -> @getRepo().isStatusModified(status) # Public: Returns true if the given status indicates a new path. + # + # * `status` A {Number} representing the status. + # + # Returns a {Boolean} that's true if the `status` indicates a new path. isStatusNew: (status) -> @getRepo().isStatusNew(status) ### @@ -483,5 +496,23 @@ class GitRepository submoduleRepo.upstream = submodules[submodulePath]?.upstream ? {ahead: 0, behind: 0} unless statusesUnchanged - @emit 'statuses-changed' + @emit 'statuses-changed' if includeDeprecatedAPIs @emitter.emit 'did-change-statuses' + +if includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(GitRepository) + + GitRepository::on = (eventName) -> + switch eventName + when 'status-changed' + deprecate 'Use GitRepository::onDidChangeStatus instead' + when 'statuses-changed' + deprecate 'Use GitRepository::onDidChangeStatuses instead' + else + deprecate 'GitRepository::on is deprecated. Use event subscription methods instead.' + EmitterMixin::on.apply(this, arguments) + + GitRepository::getOriginUrl = (path) -> + deprecate 'Use ::getOriginURL instead.' + @getOriginURL(path) diff --git a/src/grammar-registry.coffee b/src/grammar-registry.coffee index acdc863bc..7b1ef823f 100644 --- a/src/grammar-registry.coffee +++ b/src/grammar-registry.coffee @@ -1,13 +1,6 @@ -_ = require 'underscore-plus' -{deprecate} = require 'grim' -{specificity} = require 'clear-cut' -{Subscriber} = require 'emissary' +{Emitter} = require 'event-kit' +{includeDeprecatedAPIs, deprecate} = require 'grim' FirstMate = require 'first-mate' -{ScopeSelector} = FirstMate -ScopedPropertyStore = require 'scoped-property-store' -PropertyAccessors = require 'property-accessors' - -{$, $$} = require './space-pen-extensions' Token = require './token' # Extended: Syntax class holding the grammars used for tokenizing. @@ -18,16 +11,12 @@ Token = require './token' # language-specific comment regexes. See {::getProperty} for more details. module.exports = class GrammarRegistry extends FirstMate.GrammarRegistry - PropertyAccessors.includeInto(this) - Subscriber.includeInto(this) - @deserialize: ({grammarOverridesByPath}) -> grammarRegistry = new GrammarRegistry() grammarRegistry.grammarOverridesByPath = grammarOverridesByPath grammarRegistry atom.deserializers.add(this) - atom.deserializers.add(name: 'Syntax', deserialize: @deserialize) # Support old serialization constructor: -> super(maxTokensPerLine: 100) @@ -46,26 +35,50 @@ class GrammarRegistry extends FirstMate.GrammarRegistry # * `fileContents` A {String} of text for the file path. # # Returns a {Grammar}, never null. - selectGrammar: (filePath, fileContents) -> super + selectGrammar: (filePath, fileContents) -> + bestMatch = null + highestScore = -Infinity + for grammar in @grammars + score = grammar.getScore(filePath, fileContents) + if score > highestScore or not bestMatch? + bestMatch = grammar + highestScore = score + else if score is highestScore and bestMatch?.bundledPackage + bestMatch = grammar unless grammar.bundledPackage + bestMatch + + clearObservers: -> + @off() if includeDeprecatedAPIs + @emitter = new Emitter + +if includeDeprecatedAPIs + PropertyAccessors = require 'property-accessors' + PropertyAccessors.includeInto(GrammarRegistry) + + {Subscriber} = require 'emissary' + Subscriber.includeInto(GrammarRegistry) + + # Support old serialization + atom.deserializers.add(name: 'Syntax', deserialize: GrammarRegistry.deserialize) # Deprecated: Used by settings-view to display snippets for packages - @::accessor 'propertyStore', -> + GrammarRegistry::accessor 'propertyStore', -> deprecate("Do not use this. Use a public method on Config") atom.config.scopedSettingsStore - addProperties: (args...) -> - args.unshift(null) if args.length == 2 + GrammarRegistry::addProperties = (args...) -> + args.unshift(null) if args.length is 2 deprecate 'Consider using atom.config.set() instead. A direct (but private) replacement is available at atom.config.addScopedSettings().' atom.config.addScopedSettings(args...) - removeProperties: (name) -> + GrammarRegistry::removeProperties = (name) -> deprecate 'atom.config.addScopedSettings() now returns a disposable you can call .dispose() on' atom.config.scopedSettingsStore.removeProperties(name) - getProperty: (scope, keyPath) -> + GrammarRegistry::getProperty = (scope, keyPath) -> deprecate 'A direct (but private) replacement is available at atom.config.getRawScopedValue().' atom.config.getRawScopedValue(scope, keyPath) - propertiesForScope: (scope, keyPath) -> + GrammarRegistry::propertiesForScope = (scope, keyPath) -> deprecate 'Use atom.config.getAll instead.' atom.config.settingsForScopeDescriptor(scope, keyPath) diff --git a/src/gutter-component-helpers.coffee b/src/gutter-component-helpers.coffee new file mode 100644 index 000000000..f3a94c5b4 --- /dev/null +++ b/src/gutter-component-helpers.coffee @@ -0,0 +1,28 @@ +# Helper methods shared among GutterComponent classes. + +module.exports = + createGutterView: (gutterModel) -> + domNode = document.createElement('div') + domNode.classList.add('gutter') + domNode.setAttribute('gutter-name', gutterModel.name) + childNode = document.createElement('div') + if gutterModel.name is 'line-number' + childNode.classList.add('line-numbers') + else + childNode.classList.add('custom-decorations') + domNode.appendChild(childNode) + domNode + + # Sets scrollHeight, scrollTop, and backgroundColor on the given domNode. + setDimensionsAndBackground: (oldState, newState, domNode) -> + if newState.scrollHeight isnt oldState.scrollHeight + domNode.style.height = newState.scrollHeight + 'px' + oldState.scrollHeight = newState.scrollHeight + + if newState.scrollTop isnt oldState.scrollTop + domNode.style['-webkit-transform'] = "translate3d(0px, #{-newState.scrollTop}px, 0px)" + oldState.scrollTop = newState.scrollTop + + if newState.backgroundColor isnt oldState.backgroundColor + domNode.style.backgroundColor = newState.backgroundColor + oldState.backgroundColor = newState.backgroundColor diff --git a/src/gutter-container-component.coffee b/src/gutter-container-component.coffee new file mode 100644 index 000000000..09ab43f24 --- /dev/null +++ b/src/gutter-container-component.coffee @@ -0,0 +1,111 @@ +_ = require 'underscore-plus' +CustomGutterComponent = require './custom-gutter-component' +LineNumberGutterComponent = require './line-number-gutter-component' + +# The GutterContainerComponent manages the GutterComponents of a particular +# TextEditorComponent. + +module.exports = +class GutterContainerComponent + constructor: ({@onLineNumberGutterMouseDown, @editor}) -> + # An array of objects of the form: {name: {String}, component: {Object}} + @gutterComponents = [] + @gutterComponentsByGutterName = {} + @lineNumberGutterComponent = null + + @domNode = document.createElement('div') + @domNode.classList.add('gutter-container') + @domNode.style.display = 'flex' + + destroy: -> + for {name, component} in @gutterComponents + component.destroy?() + return + + getDomNode: -> + @domNode + + getLineNumberGutterComponent: -> + @lineNumberGutterComponent + + updateSync: (state) -> + # The GutterContainerComponent expects the gutters to be sorted in the order + # they should appear. + newState = state.gutters + + newGutterComponents = [] + newGutterComponentsByGutterName = {} + for {gutter, visible, styles, content} in newState + gutterComponent = @gutterComponentsByGutterName[gutter.name] + if not gutterComponent + if gutter.name is 'line-number' + gutterComponent = new LineNumberGutterComponent({onMouseDown: @onLineNumberGutterMouseDown, @editor, gutter}) + @lineNumberGutterComponent = gutterComponent + else + gutterComponent = new CustomGutterComponent({gutter}) + + if visible then gutterComponent.showNode() else gutterComponent.hideNode() + # Pass the gutter only the state that it needs. + if gutter.name is 'line-number' + # For ease of use in the line number gutter component, set the shared + # 'styles' as a field under the 'content'. + gutterSubstate = _.clone(content) + gutterSubstate.styles = styles + else + # Custom gutter 'content' is keyed on gutter name, so we cannot set + # 'styles' as a subfield directly under it. + gutterSubstate = {content, styles} + gutterComponent.updateSync(gutterSubstate) + + newGutterComponents.push({ + name: gutter.name, + component: gutterComponent, + }) + newGutterComponentsByGutterName[gutter.name] = gutterComponent + + @reorderGutters(newGutterComponents, newGutterComponentsByGutterName) + + @gutterComponents = newGutterComponents + @gutterComponentsByGutterName = newGutterComponentsByGutterName + + ### + Section: Private Methods + ### + + reorderGutters: (newGutterComponents, newGutterComponentsByGutterName) -> + # First, insert new gutters into the DOM. + indexInOldGutters = 0 + oldGuttersLength = @gutterComponents.length + + for gutterComponentDescription in newGutterComponents + gutterComponent = gutterComponentDescription.component + gutterName = gutterComponentDescription.name + + if @gutterComponentsByGutterName[gutterName] + # If the gutter existed previously, we first try to move the cursor to + # the point at which it occurs in the previous gutters. + matchingGutterFound = false + while indexInOldGutters < oldGuttersLength + existingGutterComponentDescription = @gutterComponents[indexInOldGutters] + existingGutterComponent = existingGutterComponentDescription.component + indexInOldGutters++ + if existingGutterComponent is gutterComponent + matchingGutterFound = true + break + if not matchingGutterFound + # If we've reached this point, the gutter previously existed, but its + # position has moved. Remove it from the DOM and re-insert it. + gutterComponent.getDomNode().remove() + @domNode.appendChild(gutterComponent.getDomNode()) + + else + if indexInOldGutters is oldGuttersLength + @domNode.appendChild(gutterComponent.getDomNode()) + else + @domNode.insertBefore(gutterComponent.getDomNode(), @domNode.children[indexInOldGutters]) + + # Remove any gutters that were not present in the new gutters state. + for gutterComponentDescription in @gutterComponents + if not newGutterComponentsByGutterName[gutterComponentDescription.name] + gutterComponent = gutterComponentDescription.component + gutterComponent.getDomNode().remove() diff --git a/src/gutter-container.coffee b/src/gutter-container.coffee new file mode 100644 index 000000000..1be52d519 --- /dev/null +++ b/src/gutter-container.coffee @@ -0,0 +1,96 @@ +{Emitter} = require 'event-kit' +Gutter = require './gutter' + +# This class encapsulates the logic for adding and modifying a set of gutters. + +module.exports = +class GutterContainer + + # * `textEditor` The {TextEditor} to which this {GutterContainer} belongs. + constructor: (textEditor) -> + @gutters = [] + @textEditor = textEditor + @emitter = new Emitter + + destroy: -> + @gutters = null + @emitter.dispose() + + # Creates and returns a {Gutter}. + # * `options` An {Object} with the following fields: + # * `name` (required) A unique {String} to identify this gutter. + # * `priority` (optional) A {Number} that determines stacking order between + # gutters. Lower priority items are forced closer to the edges of the + # window. (default: -100) + # * `visible` (optional) {Boolean} specifying whether the gutter is visible + # initially after being created. (default: true) + addGutter: (options) -> + options = options ? {} + gutterName = options.name + if gutterName is null + throw new Error('A name is required to create a gutter.') + if @gutterWithName(gutterName) + throw new Error('Tried to create a gutter with a name that is already in use.') + newGutter = new Gutter(this, options) + + inserted = false + # Insert the gutter into the gutters array, sorted in ascending order by 'priority'. + # This could be optimized, but there are unlikely to be many gutters. + for i in [0...@gutters.length] + if @gutters[i].priority >= newGutter.priority + @gutters.splice(i, 0, newGutter) + inserted = true + break + if not inserted + @gutters.push newGutter + @emitter.emit 'did-add-gutter', newGutter + return newGutter + + getGutters: -> + @gutters.slice() + + gutterWithName: (name) -> + for gutter in @gutters + if gutter.name is name then return gutter + null + + ### + Section: Event Subscription + ### + + # See {TextEditor::observeGutters} for details. + observeGutters: (callback) -> + callback(gutter) for gutter in @getGutters() + @onDidAddGutter callback + + # See {TextEditor::onDidAddGutter} for details. + onDidAddGutter: (callback) -> + @emitter.on 'did-add-gutter', callback + + # See {TextEditor::onDidRemoveGutter} for details. + onDidRemoveGutter: (callback) -> + @emitter.on 'did-remove-gutter', callback + + ### + Section: Private Methods + ### + + # Processes the destruction of the gutter. Throws an error if this gutter is + # not within this gutterContainer. + removeGutter: (gutter) -> + index = @gutters.indexOf(gutter) + if index > -1 + @gutters.splice(index, 1) + @emitter.emit 'did-remove-gutter', gutter.name + else + throw new Error 'The given gutter cannot be removed because it is not ' + + 'within this GutterContainer.' + + # The public interface is Gutter::decorateMarker or TextEditor::decorateMarker. + addGutterDecoration: (gutter, marker, options) -> + if gutter.name is 'line-number' + options.type = 'line-number' + else + options.type = 'gutter' + options.gutterName = gutter.name + @textEditor.decorateMarker(marker, options) diff --git a/src/gutter.coffee b/src/gutter.coffee new file mode 100644 index 000000000..cb5c36e9c --- /dev/null +++ b/src/gutter.coffee @@ -0,0 +1,69 @@ +{Emitter} = require 'event-kit' + +# Public: This class represents a gutter within a TextEditor. + +DefaultPriority = -100 + +module.exports = +class Gutter + # * `gutterContainer` The {GutterContainer} object to which this gutter belongs. + # * `options` An {Object} with the following fields: + # * `name` (required) A unique {String} to identify this gutter. + # * `priority` (optional) A {Number} that determines stacking order between + # gutters. Lower priority items are forced closer to the edges of the + # window. (default: -100) + # * `visible` (optional) {Boolean} specifying whether the gutter is visible + # initially after being created. (default: true) + constructor: (gutterContainer, options) -> + @gutterContainer = gutterContainer + @name = options?.name + @priority = options?.priority ? DefaultPriority + @visible = options?.visible ? true + + @emitter = new Emitter + + destroy: -> + if @name is 'line-number' + throw new Error('The line-number gutter cannot be destroyed.') + else + @gutterContainer.removeGutter(this) + @emitter.emit 'did-destroy' + @emitter.dispose() + + hide: -> + if @visible + @visible = false + @emitter.emit 'did-change-visible', this + + show: -> + if not @visible + @visible = true + @emitter.emit 'did-change-visible', this + + isVisible: -> + @visible + + # * `marker` (required) A Marker object. + # * `options` (optional) An object with the following fields: + # * `class` (optional) + # * `item` (optional) A model {Object} with a corresponding view registered, + # or an {HTMLElement}. + decorateMarker: (marker, options) -> + @gutterContainer.addGutterDecoration(this, marker, options) + + # Calls your `callback` when the {Gutter}'s' visibility changes. + # + # * `callback` {Function} + # * `gutter` The {Gutter} whose visibility changed. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeVisible: (callback) -> + @emitter.on 'did-change-visible', callback + + # Calls your `callback` when the {Gutter} is destroyed + # + # * `callback` {Function} + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy: (callback) -> + @emitter.on 'did-destroy', callback diff --git a/src/highlights-component.coffee b/src/highlights-component.coffee index 3bd5197fe..5e364f90c 100644 --- a/src/highlights-component.coffee +++ b/src/highlights-component.coffee @@ -12,13 +12,11 @@ class HighlightsComponent @domNode = document.createElement('div') @domNode.classList.add('highlights') - if atom.config.get('editor.useShadowDOM') - insertionPoint = document.createElement('content') - insertionPoint.setAttribute('select', '.underlayer') - @domNode.appendChild(insertionPoint) + getDomNode: -> + @domNode updateSync: (state) -> - newState = state.content.highlights + newState = state.highlights @oldState ?= {} # remove highlights @@ -39,6 +37,8 @@ class HighlightsComponent @domNode.appendChild(highlightNode) @updateHighlightNode(id, highlightState) + return + updateHighlightNode: (id, newHighlightState) -> highlightNode = @highlightNodesById[id] oldHighlightState = (@oldState[id] ?= {regions: [], flashCount: 0}) @@ -92,6 +92,8 @@ class HighlightsComponent else regionNode.style[property] = '' + return + flashHighlightNodeIfRequested: (id, newHighlightState) -> oldHighlightState = @oldState[id] return unless newHighlightState.flashCount > oldHighlightState.flashCount diff --git a/src/input-component.coffee b/src/input-component.coffee index f6f8917f6..88c1cf480 100644 --- a/src/input-component.coffee +++ b/src/input-component.coffee @@ -7,6 +7,9 @@ class InputComponent @domNode.style['-webkit-transform'] = 'translateZ(0)' @domNode.addEventListener 'paste', (event) -> event.preventDefault() + getDomNode: -> + @domNode + updateSync: (state) -> @oldState ?= {} newState = state.hiddenInput diff --git a/src/keymap-extensions.coffee b/src/keymap-extensions.coffee index 821abbc0c..7ffd3d7ef 100644 --- a/src/keymap-extensions.coffee +++ b/src/keymap-extensions.coffee @@ -3,13 +3,23 @@ path = require 'path' KeymapManager = require 'atom-keymap' CSON = require 'season' {jQuery} = require 'space-pen' +Grim = require 'grim' + +bundledKeymaps = require('../package.json')?._atomKeymaps KeymapManager::onDidLoadBundledKeymaps = (callback) -> @emitter.on 'did-load-bundled-keymaps', callback KeymapManager::loadBundledKeymaps = -> - @loadKeymap(path.join(@resourcePath, 'keymaps')) - @emit 'bundled-keymaps-loaded' + keymapsPath = path.join(@resourcePath, 'keymaps') + if bundledKeymaps? + for keymapName, keymap of bundledKeymaps + keymapPath = path.join(keymapsPath, keymapName) + @add(keymapPath, keymap) + else + @loadKeymap(keymapsPath) + + @emit 'bundled-keymaps-loaded' if Grim.includeDeprecatedAPIs @emitter.emit 'did-load-bundled-keymaps' KeymapManager::getUserKeymapPath = -> @@ -50,7 +60,7 @@ KeymapManager::subscribeToFileReadFailure = -> else error.message - atom.notifications.addError(message, {detail: detail, dismissable: true}) + atom.notifications.addError(message, {detail, dismissable: true}) # This enables command handlers registered via jQuery to call # `.abortKeyBinding()` on the `jQuery.Event` object passed to the handler. diff --git a/src/language-mode.coffee b/src/language-mode.coffee index b7a52280b..d99e69477 100644 --- a/src/language-mode.coffee +++ b/src/language-mode.coffee @@ -1,14 +1,10 @@ {Range} = require 'text-buffer' _ = require 'underscore-plus' {OnigRegExp} = require 'oniguruma' -{Emitter, Subscriber} = require 'emissary' ScopeDescriptor = require './scope-descriptor' module.exports = class LanguageMode - Emitter.includeInto(this) - Subscriber.includeInto(this) - # Sets up a `LanguageMode` for the given {TextEditor}. # # editor - The {TextEditor} to associate with @@ -16,7 +12,6 @@ class LanguageMode {@buffer} = @editor destroy: -> - @unsubscribe() toggleLineCommentForBufferRow: (row) -> @toggleLineCommentsForBufferRows(row, row) @@ -29,14 +24,8 @@ class LanguageMode # endRow - The row {Number} to end at toggleLineCommentsForBufferRows: (start, end) -> scope = @editor.scopeDescriptorForBufferPosition([start, 0]) - commentStartEntry = atom.config.getAll('editor.commentStart', {scope})[0] - - return unless commentStartEntry? - - commentEndEntry = _.find atom.config.getAll('editor.commentEnd', {scope}), (entry) -> - entry.scopeSelector is commentStartEntry.scopeSelector - commentStartString = commentStartEntry?.value - commentEndString = commentEndEntry?.value + {commentStartString, commentEndString} = @commentStartAndEndStringsForScope(scope) + return unless commentStartString? buffer = @editor.buffer commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') @@ -67,7 +56,7 @@ class LanguageMode allBlank = true allBlankOrCommented = true - for row in [start..end] + for row in [start..end] by 1 line = buffer.lineForRow(row) blank = line?.match(/^\s*$/) @@ -77,7 +66,7 @@ class LanguageMode shouldUncomment = allBlankOrCommented and not allBlank if shouldUncomment - for row in [start..end] + for row in [start..end] by 1 if match = commentStartRegex.searchSync(buffer.lineForRow(row)) columnStart = match[1].length columnEnd = columnStart + match[2].length @@ -90,7 +79,7 @@ class LanguageMode indentString = @editor.buildIndentString(indent) tabLength = @editor.getTabLength() indentRegex = new RegExp("(\t|[ ]{#{tabLength}}){#{Math.floor(indent)}}") - for row in [start..end] + for row in [start..end] by 1 line = buffer.lineForRow(row) if indentLength = line.match(indentRegex)?[0].length buffer.insert([row, indentLength], commentStartString) @@ -100,28 +89,31 @@ class LanguageMode # Folds all the foldable lines in the buffer. foldAll: -> - for currentRow in [0..@buffer.getLastRow()] + for currentRow in [0..@buffer.getLastRow()] by 1 [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] continue unless startRow? @editor.createFold(startRow, endRow) + return # Unfolds all the foldable lines in the buffer. unfoldAll: -> - for row in [@buffer.getLastRow()..0] + for row in [@buffer.getLastRow()..0] by -1 fold.destroy() for fold in @editor.displayBuffer.foldsStartingAtBufferRow(row) + return # Fold all comment and code blocks at a given indentLevel # # indentLevel - A {Number} indicating indentLevel; 0 based. foldAllAtIndentLevel: (indentLevel) -> @unfoldAll() - for currentRow in [0..@buffer.getLastRow()] + for currentRow in [0..@buffer.getLastRow()] by 1 [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] continue unless startRow? # assumption: startRow will always be the min indent level for the entire range - if @editor.indentationForBufferRow(startRow) == indentLevel + if @editor.indentationForBufferRow(startRow) is indentLevel @editor.createFold(startRow, endRow) + return # Given a buffer row, creates a fold at it. # @@ -129,7 +121,7 @@ class LanguageMode # # Returns the new {Fold}. foldBufferRow: (bufferRow) -> - for currentRow in [bufferRow..0] + for currentRow in [bufferRow..0] by -1 [startRow, endRow] = @rowRangeForFoldAtBufferRow(currentRow) ? [] continue unless startRow? and startRow <= bufferRow <= endRow fold = @editor.displayBuffer.largestFoldStartingAtBufferRow(startRow) @@ -153,13 +145,13 @@ class LanguageMode endRow = bufferRow if bufferRow > 0 - for currentRow in [bufferRow-1..0] + for currentRow in [bufferRow-1..0] by -1 break if @buffer.isRowBlank(currentRow) break unless @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(currentRow).isComment() startRow = currentRow if bufferRow < @buffer.getLastRow() - for currentRow in [bufferRow+1..@buffer.getLastRow()] + for currentRow in [bufferRow+1..@buffer.getLastRow()] by 1 break if @buffer.isRowBlank(currentRow) break unless @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(currentRow).isComment() endRow = currentRow @@ -171,11 +163,11 @@ class LanguageMode startIndentLevel = @editor.indentationForBufferRow(bufferRow) scopeDescriptor = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) - for row in [(bufferRow + 1)..@editor.getLastBufferRow()] + for row in [(bufferRow + 1)..@editor.getLastBufferRow()] by 1 continue if @editor.isBufferRowBlank(row) indentation = @editor.indentationForBufferRow(row) if indentation <= startIndentLevel - includeRowInFold = indentation == startIndentLevel and @foldEndRegexForScopeDescriptor(scopeDescriptor)?.searchSync(@editor.lineTextForBufferRow(row)) + includeRowInFold = indentation is startIndentLevel and @foldEndRegexForScopeDescriptor(scopeDescriptor)?.searchSync(@editor.lineTextForBufferRow(row)) foldEndRow = row if includeRowInFold break @@ -192,11 +184,24 @@ class LanguageMode return false unless 0 <= bufferRow <= @editor.getLastBufferRow() @editor.displayBuffer.tokenizedBuffer.tokenizedLineForRow(bufferRow).isComment() - # Find a row range for a 'paragraph' around specified bufferRow. - # Right now, a paragraph is a block of text bounded by and empty line or a - # block of text that is not the same type (comments next to source code). + # Find a row range for a 'paragraph' around specified bufferRow. A paragraph + # is a block of text bounded by and empty line or a block of text that is not + # the same type (comments next to source code). rowRangeForParagraphAtBufferRow: (bufferRow) -> - return unless /\w/.test(@editor.lineTextForBufferRow(bufferRow)) + scope = @editor.scopeDescriptorForBufferPosition([bufferRow, 0]) + {commentStartString, commentEndString} = @commentStartAndEndStringsForScope(scope) + commentStartRegex = null + if commentStartString? and not commentEndString? + commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?') + commentStartRegex = new OnigRegExp("^(\\s*)(#{commentStartRegexString})") + + filterCommentStart = (line) -> + if commentStartRegex? + matches = commentStartRegex.searchSync(line) + line = line.substring(matches[0].end) if matches?.length + line + + return unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(bufferRow))) if @isLineCommentedAtBufferRow(bufferRow) isOriginalRowComment = true @@ -208,15 +213,15 @@ class LanguageMode startRow = bufferRow while startRow > firstRow - break if @isLineCommentedAtBufferRow(startRow - 1) != isOriginalRowComment - break unless /\w/.test(@editor.lineTextForBufferRow(startRow - 1)) + break if @isLineCommentedAtBufferRow(startRow - 1) isnt isOriginalRowComment + break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(startRow - 1))) startRow-- endRow = bufferRow lastRow = @editor.getLastBufferRow() while endRow < lastRow - break if @isLineCommentedAtBufferRow(endRow + 1) != isOriginalRowComment - break unless /\w/.test(@editor.lineTextForBufferRow(endRow + 1)) + break if @isLineCommentedAtBufferRow(endRow + 1) isnt isOriginalRowComment + break unless /\S/.test(filterCommentStart(@editor.lineTextForBufferRow(endRow + 1))) endRow++ new Range([startRow, 0], [endRow, @editor.lineTextForBufferRow(endRow).length]) @@ -237,8 +242,9 @@ class LanguageMode @suggestedIndentForTokenizedLineAtBufferRow(bufferRow, tokenizedLine, options) suggestedIndentForTokenizedLineAtBufferRow: (bufferRow, tokenizedLine, options) -> - scopes = tokenizedLine.tokens[0].scopes - scopeDescriptor = new ScopeDescriptor({scopes}) + iterator = tokenizedLine.getTokenIterator() + iterator.next() + scopeDescriptor = new ScopeDescriptor(scopes: iterator.getScopes()) currentIndentLevel = @editor.indentationForBufferRow(bufferRow) return currentIndentLevel unless increaseIndentRegex = @increaseIndentRegexForScopeDescriptor(scopeDescriptor) @@ -255,7 +261,8 @@ class LanguageMode desiredIndentLevel += 1 if increaseIndentRegex.testSync(precedingLine) and not @editor.isBufferRowCommented(precedingRow) return desiredIndentLevel unless decreaseIndentRegex = @decreaseIndentRegexForScopeDescriptor(scopeDescriptor) - desiredIndentLevel -= 1 if decreaseIndentRegex.testSync(tokenizedLine.text) + line = @buffer.lineForRow(bufferRow) + desiredIndentLevel -= 1 if decreaseIndentRegex.testSync(line) Math.max(desiredIndentLevel, 0) @@ -266,7 +273,7 @@ class LanguageMode # # Returns a {Number} of the indent level of the block of lines. minIndentLevelForRowRange: (startRow, endRow) -> - indents = (@editor.indentationForBufferRow(row) for row in [startRow..endRow] when not @editor.isBufferRowBlank(row)) + indents = (@editor.indentationForBufferRow(row) for row in [startRow..endRow] by 1 when not @editor.isBufferRowBlank(row)) indents = [0] unless indents.length Math.min(indents...) @@ -275,7 +282,8 @@ class LanguageMode # startRow - The row {Number} to start at # endRow - The row {Number} to end at autoIndentBufferRows: (startRow, endRow) -> - @autoIndentBufferRow(row) for row in [startRow..endRow] + @autoIndentBufferRow(row) for row in [startRow..endRow] by 1 + return # Given a buffer row, this indents it. # @@ -320,3 +328,11 @@ class LanguageMode foldEndRegexForScopeDescriptor: (scopeDescriptor) -> @getRegexForProperty(scopeDescriptor, 'editor.foldEndPattern') + + commentStartAndEndStringsForScope: (scope) -> + commentStartEntry = atom.config.getAll('editor.commentStart', {scope})[0] + commentEndEntry = _.find atom.config.getAll('editor.commentEnd', {scope}), (entry) -> + entry.scopeSelector is commentStartEntry.scopeSelector + commentStartString = commentStartEntry?.value + commentEndString = commentEndEntry?.value + {commentStartString, commentEndString} diff --git a/src/line-number-gutter-component.coffee b/src/line-number-gutter-component.coffee new file mode 100644 index 000000000..b6a5b81f6 --- /dev/null +++ b/src/line-number-gutter-component.coffee @@ -0,0 +1,97 @@ +TiledComponent = require './tiled-component' +LineNumbersTileComponent = require './line-numbers-tile-component' +WrapperDiv = document.createElement('div') +DummyLineNumberComponent = LineNumbersTileComponent.createDummy() + +module.exports = +class LineNumberGutterComponent extends TiledComponent + dummyLineNumberNode: null + + constructor: ({@onMouseDown, @editor, @gutter}) -> + @visible = true + + @domNode = atom.views.getView(@gutter) + @lineNumbersNode = @domNode.firstChild + @lineNumbersNode.innerHTML = '' + + @domNode.addEventListener 'click', @onClick + @domNode.addEventListener 'mousedown', @onMouseDown + + destroy: -> + @domNode.removeEventListener 'click', @onClick + @domNode.removeEventListener 'mousedown', @onMouseDown + + getDomNode: -> + @domNode + + hideNode: -> + if @visible + @domNode.style.display = 'none' + @visible = false + + showNode: -> + if not @visible + @domNode.style.removeProperty('display') + @visible = true + + buildEmptyState: -> + { + tiles: {} + styles: {} + } + + getNewState: (state) -> state + + getTilesNode: -> @lineNumbersNode + + beforeUpdateSync: (state) -> + @appendDummyLineNumber() unless @dummyLineNumberNode? + + if @newState.styles.scrollHeight isnt @oldState.styles.scrollHeight + @lineNumbersNode.style.height = @newState.styles.scrollHeight + 'px' + @oldState.scrollHeight = @newState.scrollHeight + + if @newState.styles.backgroundColor isnt @oldState.styles.backgroundColor + @lineNumbersNode.style.backgroundColor = @newState.styles.backgroundColor + @oldState.styles.backgroundColor = @newState.styles.backgroundColor + + if @newState.maxLineNumberDigits isnt @oldState.maxLineNumberDigits + @updateDummyLineNumber() + @oldState.styles = {} + @oldState.maxLineNumberDigits = @newState.maxLineNumberDigits + + buildComponentForTile: (id) -> new LineNumbersTileComponent({id}) + + ### + Section: Private Methods + ### + + # This dummy line number element holds the gutter to the appropriate width, + # since the real line numbers are absolutely positioned for performance reasons. + appendDummyLineNumber: -> + DummyLineNumberComponent.newState = @newState + WrapperDiv.innerHTML = DummyLineNumberComponent.buildLineNumberHTML({bufferRow: -1}) + @dummyLineNumberNode = WrapperDiv.children[0] + @lineNumbersNode.appendChild(@dummyLineNumberNode) + + updateDummyLineNumber: -> + DummyLineNumberComponent.newState = @newState + @dummyLineNumberNode.innerHTML = DummyLineNumberComponent.buildLineNumberInnerHTML(0, false) + + onMouseDown: (event) => + {target} = event + lineNumber = target.parentNode + + unless target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') + @onMouseDown(event) + + onClick: (event) => + {target} = event + lineNumber = target.parentNode + + if target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') + bufferRow = parseInt(lineNumber.getAttribute('data-buffer-row')) + if lineNumber.classList.contains('folded') + @editor.unfoldBufferRow(bufferRow) + else + @editor.foldBufferRow(bufferRow) diff --git a/src/gutter-component.coffee b/src/line-numbers-tile-component.coffee similarity index 53% rename from src/gutter-component.coffee rename to src/line-numbers-tile-component.coffee index 6eb11ff1f..cf58c54f3 100644 --- a/src/gutter-component.coffee +++ b/src/line-numbers-tile-component.coffee @@ -1,88 +1,87 @@ _ = require 'underscore-plus' - WrapperDiv = document.createElement('div') module.exports = -class GutterComponent - dummyLineNumberNode: null +class LineNumbersTileComponent + @createDummy: -> + new LineNumbersTileComponent({id: -1}) - constructor: ({@presenter, @onMouseDown, @editor}) -> + constructor: ({@id}) -> @lineNumberNodesById = {} + @domNode = document.createElement("div") + @domNode.classList.add("tile") + @domNode.style.position = "absolute" + @domNode.style.display = "block" + @domNode.style.top = 0 # Cover the space occupied by a dummy lineNumber - @domNode = document.createElement('div') - @domNode.classList.add('gutter') - @lineNumbersNode = document.createElement('div') - @lineNumbersNode.classList.add('line-numbers') - @domNode.appendChild(@lineNumbersNode) - - @domNode.addEventListener 'click', @onClick - @domNode.addEventListener 'mousedown', @onMouseDown + getDomNode: -> + @domNode updateSync: (state) -> - @newState = state.gutter - @oldState ?= {lineNumbers: {}} + @newState = state + unless @oldState + @oldState = {tiles: {}, styles: {}} + @oldState.tiles[@id] = {lineNumbers: {}} - @appendDummyLineNumber() unless @dummyLineNumberNode? + @newTileState = @newState.tiles[@id] + @oldTileState = @oldState.tiles[@id] - if @newState.scrollHeight isnt @oldState.scrollHeight - @lineNumbersNode.style.height = @newState.scrollHeight + 'px' - @oldState.scrollHeight = @newState.scrollHeight + if @newTileState.display isnt @oldTileState.display + @domNode.style.display = @newTileState.display + @oldTileState.display = @newTileState.display - if @newState.scrollTop isnt @oldState.scrollTop - @lineNumbersNode.style['-webkit-transform'] = "translate3d(0px, #{-@newState.scrollTop}px, 0px)" - @oldState.scrollTop = @newState.scrollTop + if @newState.styles.backgroundColor isnt @oldState.styles.backgroundColor + @domNode.style.backgroundColor = @newState.styles.backgroundColor + @oldState.styles.backgroundColor = @newState.styles.backgroundColor - if @newState.backgroundColor isnt @oldState.backgroundColor - @lineNumbersNode.style.backgroundColor = @newState.backgroundColor - @oldState.backgroundColor = @newState.backgroundColor + if @newTileState.height isnt @oldTileState.height + @domNode.style.height = @newTileState.height + 'px' + @oldTileState.height = @newTileState.height + + if @newTileState.top isnt @oldTileState.top + @domNode.style['-webkit-transform'] = "translate3d(0, #{@newTileState.top}px, 0px)" + @oldTileState.top = @newTileState.top if @newState.maxLineNumberDigits isnt @oldState.maxLineNumberDigits - @updateDummyLineNumber() node.remove() for id, node of @lineNumberNodesById - @oldState = {maxLineNumberDigits: @newState.maxLineNumberDigits, lineNumbers: {}} + @oldState.tiles[@id] = {lineNumbers: {}} + @oldTileState = @oldState.tiles[@id] @lineNumberNodesById = {} + @oldState.maxLineNumberDigits = @newState.maxLineNumberDigits @updateLineNumbers() - # This dummy line number element holds the gutter to the appropriate width, - # since the real line numbers are absolutely positioned for performance reasons. - appendDummyLineNumber: -> - WrapperDiv.innerHTML = @buildLineNumberHTML({bufferRow: -1}) - @dummyLineNumberNode = WrapperDiv.children[0] - @lineNumbersNode.appendChild(@dummyLineNumberNode) - - updateDummyLineNumber: -> - @dummyLineNumberNode.innerHTML = @buildLineNumberInnerHTML(0, false) - updateLineNumbers: -> newLineNumberIds = null newLineNumbersHTML = null - for id, lineNumberState of @newState.lineNumbers - if @oldState.lineNumbers.hasOwnProperty(id) + for id, lineNumberState of @oldTileState.lineNumbers + unless @newTileState.lineNumbers.hasOwnProperty(id) + @lineNumberNodesById[id].remove() + delete @lineNumberNodesById[id] + delete @oldTileState.lineNumbers[id] + + for id, lineNumberState of @newTileState.lineNumbers + if @oldTileState.lineNumbers.hasOwnProperty(id) @updateLineNumberNode(id, lineNumberState) else newLineNumberIds ?= [] newLineNumbersHTML ?= "" newLineNumberIds.push(id) newLineNumbersHTML += @buildLineNumberHTML(lineNumberState) - @oldState.lineNumbers[id] = _.clone(lineNumberState) + @oldTileState.lineNumbers[id] = _.clone(lineNumberState) if newLineNumberIds? WrapperDiv.innerHTML = newLineNumbersHTML newLineNumberNodes = _.toArray(WrapperDiv.children) - node = @lineNumbersNode + node = @domNode for id, i in newLineNumberIds lineNumberNode = newLineNumberNodes[i] @lineNumberNodesById[id] = lineNumberNode node.appendChild(lineNumberNode) - for id, lineNumberState of @oldState.lineNumbers - unless @newState.lineNumbers.hasOwnProperty(id) - @lineNumberNodesById[id].remove() - delete @lineNumberNodesById[id] - delete @oldState.lineNumbers[id] + return buildLineNumberHTML: (lineNumberState) -> {screenRow, bufferRow, softWrapped, top, decorationClasses} = lineNumberState @@ -108,7 +107,7 @@ class GutterComponent padding + lineNumber + iconHTML updateLineNumberNode: (lineNumberId, newLineNumberState) -> - oldLineNumberState = @oldState.lineNumbers[lineNumberId] + oldLineNumberState = @oldTileState.lineNumbers[lineNumberId] node = @lineNumberNodesById[lineNumberId] unless oldLineNumberState.foldable is newLineNumberState.foldable and _.isEqual(oldLineNumberState.decorationClasses, newLineNumberState.decorationClasses) @@ -129,25 +128,7 @@ class GutterComponent className lineNumberNodeForScreenRow: (screenRow) -> - for id, lineNumberState of @oldState.lineNumbers + for id, lineNumberState of @oldTileState.lineNumbers if lineNumberState.screenRow is screenRow return @lineNumberNodesById[id] null - - onMouseDown: (event) => - {target} = event - lineNumber = target.parentNode - - unless target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') - @onMouseDown(event) - - onClick: (event) => - {target} = event - lineNumber = target.parentNode - - if target.classList.contains('icon-right') and lineNumber.classList.contains('foldable') - bufferRow = parseInt(lineNumber.getAttribute('data-buffer-row')) - if lineNumber.classList.contains('folded') - @editor.unfoldBufferRow(bufferRow) - else - @editor.foldBufferRow(bufferRow) diff --git a/src/lines-component.coffee b/src/lines-component.coffee index fa4ab23b0..70a428b57 100644 --- a/src/lines-component.coffee +++ b/src/lines-component.coffee @@ -1,69 +1,43 @@ -_ = require 'underscore-plus' -{toArray} = require 'underscore-plus' {$$} = require 'space-pen' CursorsComponent = require './cursors-component' -HighlightsComponent = require './highlights-component' -OverlayManager = require './overlay-manager' +LinesTileComponent = require './lines-tile-component' +TiledComponent = require './tiled-component' DummyLineNode = $$(-> @div className: 'line', style: 'position: absolute; visibility: hidden;', => @span 'x')[0] -AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} -WrapperDiv = document.createElement('div') - -cloneObject = (object) -> - clone = {} - clone[key] = value for key, value of object - clone module.exports = -class LinesComponent +class LinesComponent extends TiledComponent placeholderTextDiv: null constructor: ({@presenter, @hostElement, @useShadowDOM, visible}) -> - @measuredLines = new Set - @lineNodesByLineId = {} - @screenRowsByLineId = {} - @lineIdsByScreenRow = {} - @renderedDecorationsByLineId = {} - @domNode = document.createElement('div') @domNode.classList.add('lines') - @cursorsComponent = new CursorsComponent(@presenter) - @domNode.appendChild(@cursorsComponent.domNode) - - @highlightsComponent = new HighlightsComponent(@presenter) - @domNode.appendChild(@highlightsComponent.domNode) + @cursorsComponent = new CursorsComponent + @domNode.appendChild(@cursorsComponent.getDomNode()) if @useShadowDOM insertionPoint = document.createElement('content') insertionPoint.setAttribute('select', '.overlayer') @domNode.appendChild(insertionPoint) - insertionPoint = document.createElement('content') - insertionPoint.setAttribute('select', 'atom-overlay') - @overlayManager = new OverlayManager(@presenter, @hostElement) - @domNode.appendChild(insertionPoint) - else - @overlayManager = new OverlayManager(@presenter, @domNode) + getDomNode: -> + @domNode - updateSync: (state) -> - @newState = state.content - @oldState ?= {lines: {}} + shouldRecreateAllTilesOnUpdate: -> + @oldState.indentGuidesVisible isnt @newState.indentGuidesVisible + beforeUpdateSync: (state) -> if @newState.scrollHeight isnt @oldState.scrollHeight @domNode.style.height = @newState.scrollHeight + 'px' @oldState.scrollHeight = @newState.scrollHeight - if @newState.scrollTop isnt @oldState.scrollTop or @newState.scrollLeft isnt @oldState.scrollLeft - @domNode.style['-webkit-transform'] = "translate3d(#{-@newState.scrollLeft}px, #{-@newState.scrollTop}px, 0px)" - @oldState.scrollTop = @newState.scrollTop - @oldState.scrollLeft = @newState.scrollLeft - if @newState.backgroundColor isnt @oldState.backgroundColor @domNode.style.backgroundColor = @newState.backgroundColor @oldState.backgroundColor = @newState.backgroundColor + afterUpdateSync: (state) -> if @newState.placeholderText isnt @oldState.placeholderText @placeholderTextDiv?.remove() if @newState.placeholderText? @@ -72,188 +46,23 @@ class LinesComponent @placeholderTextDiv.textContent = @newState.placeholderText @domNode.appendChild(@placeholderTextDiv) - @removeLineNodes() unless @oldState.indentGuidesVisible is @newState.indentGuidesVisible - @updateLineNodes() - - if @newState.scrollWidth isnt @oldState.scrollWidth - @domNode.style.width = @newState.scrollWidth + 'px' - @oldState.scrollWidth = @newState.scrollWidth + if @newState.width isnt @oldState.width + @domNode.style.width = @newState.width + 'px' + @oldState.width = @newState.width @cursorsComponent.updateSync(state) - @highlightsComponent.updateSync(state) - - @overlayManager?.render(state) @oldState.indentGuidesVisible = @newState.indentGuidesVisible - @oldState.scrollWidth = @newState.scrollWidth - removeLineNodes: -> - @removeLineNode(id) for id of @oldState.lines + buildComponentForTile: (id) -> new LinesTileComponent({id, @presenter}) - removeLineNode: (id) -> - @lineNodesByLineId[id].remove() - delete @lineNodesByLineId[id] - delete @lineIdsByScreenRow[@screenRowsByLineId[id]] - delete @screenRowsByLineId[id] - delete @oldState.lines[id] + buildEmptyState: -> + {tiles: {}} - updateLineNodes: -> - for id of @oldState.lines - unless @newState.lines.hasOwnProperty(id) - @removeLineNode(id) + getNewState: (state) -> + state.content - newLineIds = null - newLinesHTML = null - - for id, lineState of @newState.lines - if @oldState.lines.hasOwnProperty(id) - @updateLineNode(id) - else - newLineIds ?= [] - newLinesHTML ?= "" - newLineIds.push(id) - newLinesHTML += @buildLineHTML(id) - @screenRowsByLineId[id] = lineState.screenRow - @lineIdsByScreenRow[lineState.screenRow] = id - @oldState.lines[id] = cloneObject(lineState) - - return unless newLineIds? - - WrapperDiv.innerHTML = newLinesHTML - newLineNodes = _.toArray(WrapperDiv.children) - for id, i in newLineIds - lineNode = newLineNodes[i] - @lineNodesByLineId[id] = lineNode - @domNode.appendChild(lineNode) - - buildLineHTML: (id) -> - {scrollWidth} = @newState - {screenRow, tokens, text, top, lineEnding, fold, isSoftWrapped, indentLevel, decorationClasses} = @newState.lines[id] - - classes = '' - if decorationClasses? - for decorationClass in decorationClasses - classes += decorationClass + ' ' - classes += 'line' - - lineHTML = "
" - - if text is "" - lineHTML += @buildEmptyLineInnerHTML(id) - else - lineHTML += @buildLineInnerHTML(id) - - lineHTML += '' if fold - lineHTML += "
" - lineHTML - - buildEmptyLineInnerHTML: (id) -> - {indentGuidesVisible} = @newState - {indentLevel, tabLength, endOfLineInvisibles} = @newState.lines[id] - - if indentGuidesVisible and indentLevel > 0 - invisibleIndex = 0 - lineHTML = '' - for i in [0...indentLevel] - lineHTML += "" - for j in [0...tabLength] - if invisible = endOfLineInvisibles?[invisibleIndex++] - lineHTML += "#{invisible}" - else - lineHTML += ' ' - lineHTML += "" - - while invisibleIndex < endOfLineInvisibles?.length - lineHTML += "#{endOfLineInvisibles[invisibleIndex++]}" - - lineHTML - else - @buildEndOfLineHTML(id) or ' ' - - buildLineInnerHTML: (id) -> - {indentGuidesVisible} = @newState - {tokens, text, isOnlyWhitespace} = @newState.lines[id] - innerHTML = "" - - scopeStack = [] - for token in tokens - innerHTML += @updateScopeStack(scopeStack, token.scopes) - hasIndentGuide = indentGuidesVisible and (token.hasLeadingWhitespace() or (token.hasTrailingWhitespace() and isOnlyWhitespace)) - innerHTML += token.getValueAsHtml({hasIndentGuide}) - - innerHTML += @popScope(scopeStack) while scopeStack.length > 0 - innerHTML += @buildEndOfLineHTML(id) - innerHTML - - buildEndOfLineHTML: (id) -> - {endOfLineInvisibles} = @newState.lines[id] - - html = '' - if endOfLineInvisibles? - for invisible in endOfLineInvisibles - html += "#{invisible}" - html - - updateScopeStack: (scopeStack, desiredScopeDescriptor) -> - html = "" - - # Find a common prefix - for scope, i in desiredScopeDescriptor - break unless scopeStack[i] is desiredScopeDescriptor[i] - - # Pop scopeDescriptor until we're at the common prefx - until scopeStack.length is i - html += @popScope(scopeStack) - - # Push onto common prefix until scopeStack equals desiredScopeDescriptor - for j in [i...desiredScopeDescriptor.length] - html += @pushScope(scopeStack, desiredScopeDescriptor[j]) - - html - - popScope: (scopeStack) -> - scopeStack.pop() - "" - - pushScope: (scopeStack, scope) -> - scopeStack.push(scope) - "" - - updateLineNode: (id) -> - oldLineState = @oldState.lines[id] - newLineState = @newState.lines[id] - - lineNode = @lineNodesByLineId[id] - - if @newState.scrollWidth isnt @oldState.scrollWidth - lineNode.style.width = @newState.scrollWidth + 'px' - - newDecorationClasses = newLineState.decorationClasses - oldDecorationClasses = oldLineState.decorationClasses - - if oldDecorationClasses? - for decorationClass in oldDecorationClasses - unless newDecorationClasses? and decorationClass in newDecorationClasses - lineNode.classList.remove(decorationClass) - - if newDecorationClasses? - for decorationClass in newDecorationClasses - unless oldDecorationClasses? and decorationClass in oldDecorationClasses - lineNode.classList.add(decorationClass) - - oldLineState.decorationClasses = newLineState.decorationClasses - - if newLineState.top isnt oldLineState.top - lineNode.style.top = newLineState.top + 'px' - oldLineState.top = newLineState.cop - - if newLineState.screenRow isnt oldLineState.screenRow - lineNode.dataset.screenRow = newLineState.screenRow - oldLineState.screenRow = newLineState.screenRow - @lineIdsByScreenRow[newLineState.screenRow] = id - - lineNodeForScreenRow: (screenRow) -> - @lineNodesByLineId[@lineIdsByScreenRow[screenRow]] + getTilesNode: -> @domNode measureLineHeightAndDefaultCharWidth: -> @domNode.appendChild(DummyLineNode) @@ -272,56 +81,13 @@ class LinesComponent measureCharactersInNewLines: -> @presenter.batchCharacterMeasurement => - for id, lineState of @oldState.lines - unless @measuredLines.has(id) - lineNode = @lineNodesByLineId[id] - @measureCharactersInLine(id, lineState, lineNode) + for id, component of @componentsByTileId + component.measureCharactersInNewLines() + return - measureCharactersInLine: (lineId, tokenizedLine, lineNode) -> - rangeForMeasurement = null - iterator = null - charIndex = 0 - - for {value, scopes, hasPairedCharacter} in tokenizedLine.tokens - charWidths = @presenter.getScopedCharacterWidths(scopes) - - valueIndex = 0 - while valueIndex < value.length - if hasPairedCharacter - char = value.substr(valueIndex, 2) - charLength = 2 - valueIndex += 2 - else - char = value[valueIndex] - charLength = 1 - valueIndex++ - - continue if char is '\0' - - unless charWidths[char]? - unless textNode? - rangeForMeasurement ?= document.createRange() - iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) - textNode = iterator.nextNode() - textNodeIndex = 0 - nextTextNodeIndex = textNode.textContent.length - - while nextTextNodeIndex <= charIndex - textNode = iterator.nextNode() - textNodeIndex = nextTextNodeIndex - nextTextNodeIndex = textNodeIndex + textNode.textContent.length - - i = charIndex - textNodeIndex - rangeForMeasurement.setStart(textNode, i) - rangeForMeasurement.setEnd(textNode, i + charLength) - charWidth = rangeForMeasurement.getBoundingClientRect().width - @presenter.setScopedCharacterWidth(scopes, char, charWidth) - - charIndex += charLength - - @measuredLines.add(lineId) - clearScopedCharWidths: -> - @measuredLines.clear() + for id, component of @componentsByTileId + component.clearMeasurements() + @presenter.clearScopedCharacterWidths() diff --git a/src/lines-tile-component.coffee b/src/lines-tile-component.coffee new file mode 100644 index 000000000..5fdadf212 --- /dev/null +++ b/src/lines-tile-component.coffee @@ -0,0 +1,368 @@ +_ = require 'underscore-plus' + +HighlightsComponent = require './highlights-component' +TokenIterator = require './token-iterator' +AcceptFilter = {acceptNode: -> NodeFilter.FILTER_ACCEPT} +WrapperDiv = document.createElement('div') +TokenTextEscapeRegex = /[&"'<>]/g +MaxTokenLength = 20000 + +cloneObject = (object) -> + clone = {} + clone[key] = value for key, value of object + clone + +module.exports = +class LinesTileComponent + constructor: ({@presenter, @id}) -> + @tokenIterator = new TokenIterator + @measuredLines = new Set + @lineNodesByLineId = {} + @screenRowsByLineId = {} + @lineIdsByScreenRow = {} + @domNode = document.createElement("div") + @domNode.classList.add("tile") + @domNode.style.position = "absolute" + @domNode.style.display = "block" + + @highlightsComponent = new HighlightsComponent + @domNode.appendChild(@highlightsComponent.getDomNode()) + + getDomNode: -> + @domNode + + updateSync: (state) -> + @newState = state + unless @oldState + @oldState = {tiles: {}} + @oldState.tiles[@id] = {lines: {}} + + @newTileState = @newState.tiles[@id] + @oldTileState = @oldState.tiles[@id] + + if @newState.backgroundColor isnt @oldState.backgroundColor + @domNode.style.backgroundColor = @newState.backgroundColor + @oldState.backgroundColor = @newState.backgroundColor + + if @newTileState.display isnt @oldTileState.display + @domNode.style.display = @newTileState.display + @oldTileState.display = @newTileState.display + + if @newTileState.height isnt @oldTileState.height + @domNode.style.height = @newTileState.height + 'px' + @oldTileState.height = @newTileState.height + + if @newState.width isnt @oldState.width + @domNode.style.width = @newState.width + 'px' + @oldTileState.width = @newTileState.width + + if @newTileState.top isnt @oldTileState.top or @newTileState.left isnt @oldTileState.left + @domNode.style['-webkit-transform'] = "translate3d(#{@newTileState.left}px, #{@newTileState.top}px, 0px)" + @oldTileState.top = @newTileState.top + @oldTileState.left = @newTileState.left + + @removeLineNodes() unless @oldState.indentGuidesVisible is @newState.indentGuidesVisible + @updateLineNodes() + + @highlightsComponent.updateSync(@newTileState) + + @oldState.indentGuidesVisible = @newState.indentGuidesVisible + + removeLineNodes: -> + @removeLineNode(id) for id of @oldTileState.lines + return + + removeLineNode: (id) -> + @lineNodesByLineId[id].remove() + delete @lineNodesByLineId[id] + delete @lineIdsByScreenRow[@screenRowsByLineId[id]] + delete @screenRowsByLineId[id] + delete @oldTileState.lines[id] + + updateLineNodes: -> + for id of @oldTileState.lines + unless @newTileState.lines.hasOwnProperty(id) + @removeLineNode(id) + + newLineIds = null + newLinesHTML = null + + for id, lineState of @newTileState.lines + if @oldTileState.lines.hasOwnProperty(id) + @updateLineNode(id) + else + newLineIds ?= [] + newLinesHTML ?= "" + newLineIds.push(id) + newLinesHTML += @buildLineHTML(id) + @screenRowsByLineId[id] = lineState.screenRow + @lineIdsByScreenRow[lineState.screenRow] = id + @oldTileState.lines[id] = cloneObject(lineState) + + return unless newLineIds? + + WrapperDiv.innerHTML = newLinesHTML + newLineNodes = _.toArray(WrapperDiv.children) + for id, i in newLineIds + lineNode = newLineNodes[i] + @lineNodesByLineId[id] = lineNode + @domNode.appendChild(lineNode) + + return + + buildLineHTML: (id) -> + {width} = @newState + {screenRow, tokens, text, top, lineEnding, fold, isSoftWrapped, indentLevel, decorationClasses} = @newTileState.lines[id] + + classes = '' + if decorationClasses? + for decorationClass in decorationClasses + classes += decorationClass + ' ' + classes += 'line' + + lineHTML = "
" + + if text is "" + lineHTML += @buildEmptyLineInnerHTML(id) + else + lineHTML += @buildLineInnerHTML(id) + + lineHTML += '' if fold + lineHTML += "
" + lineHTML + + buildEmptyLineInnerHTML: (id) -> + {indentGuidesVisible} = @newState + {indentLevel, tabLength, endOfLineInvisibles} = @newTileState.lines[id] + + if indentGuidesVisible and indentLevel > 0 + invisibleIndex = 0 + lineHTML = '' + for i in [0...indentLevel] + lineHTML += "" + for j in [0...tabLength] + if invisible = endOfLineInvisibles?[invisibleIndex++] + lineHTML += "#{invisible}" + else + lineHTML += ' ' + lineHTML += "" + + while invisibleIndex < endOfLineInvisibles?.length + lineHTML += "#{endOfLineInvisibles[invisibleIndex++]}" + + lineHTML + else + @buildEndOfLineHTML(id) or ' ' + + buildLineInnerHTML: (id) -> + lineState = @newTileState.lines[id] + {firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, invisibles} = lineState + lineIsWhitespaceOnly = firstTrailingWhitespaceIndex is 0 + + innerHTML = "" + @tokenIterator.reset(lineState) + + while @tokenIterator.next() + for scope in @tokenIterator.getScopeEnds() + innerHTML += "
" + + for scope in @tokenIterator.getScopeStarts() + innerHTML += "" + + tokenStart = @tokenIterator.getScreenStart() + tokenEnd = @tokenIterator.getScreenEnd() + tokenText = @tokenIterator.getText() + isHardTab = @tokenIterator.isHardTab() + + if hasLeadingWhitespace = tokenStart < firstNonWhitespaceIndex + tokenFirstNonWhitespaceIndex = firstNonWhitespaceIndex - tokenStart + else + tokenFirstNonWhitespaceIndex = null + + if hasTrailingWhitespace = tokenEnd > firstTrailingWhitespaceIndex + tokenFirstTrailingWhitespaceIndex = Math.max(0, firstTrailingWhitespaceIndex - tokenStart) + else + tokenFirstTrailingWhitespaceIndex = null + + hasIndentGuide = + @newState.indentGuidesVisible and + (hasLeadingWhitespace or lineIsWhitespaceOnly) + + hasInvisibleCharacters = + (invisibles?.tab and isHardTab) or + (invisibles?.space and (hasLeadingWhitespace or hasTrailingWhitespace)) + + innerHTML += @buildTokenHTML(tokenText, isHardTab, tokenFirstNonWhitespaceIndex, tokenFirstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) + + for scope in @tokenIterator.getScopeEnds() + innerHTML += "" + + for scope in @tokenIterator.getScopes() + innerHTML += "" + + innerHTML += @buildEndOfLineHTML(id) + innerHTML + + buildTokenHTML: (tokenText, isHardTab, firstNonWhitespaceIndex, firstTrailingWhitespaceIndex, hasIndentGuide, hasInvisibleCharacters) -> + if isHardTab + classes = 'hard-tab' + classes += ' leading-whitespace' if firstNonWhitespaceIndex? + classes += ' trailing-whitespace' if firstTrailingWhitespaceIndex? + classes += ' indent-guide' if hasIndentGuide + classes += ' invisible-character' if hasInvisibleCharacters + return "#{@escapeTokenText(tokenText)}" + else + startIndex = 0 + endIndex = tokenText.length + + leadingHtml = '' + trailingHtml = '' + + if firstNonWhitespaceIndex? + leadingWhitespace = tokenText.substring(0, firstNonWhitespaceIndex) + + classes = 'leading-whitespace' + classes += ' indent-guide' if hasIndentGuide + classes += ' invisible-character' if hasInvisibleCharacters + + leadingHtml = "#{leadingWhitespace}" + startIndex = firstNonWhitespaceIndex + + if firstTrailingWhitespaceIndex? + tokenIsOnlyWhitespace = firstTrailingWhitespaceIndex is 0 + trailingWhitespace = tokenText.substring(firstTrailingWhitespaceIndex) + + classes = 'trailing-whitespace' + classes += ' indent-guide' if hasIndentGuide and not firstNonWhitespaceIndex? and tokenIsOnlyWhitespace + classes += ' invisible-character' if hasInvisibleCharacters + + trailingHtml = "#{trailingWhitespace}" + + endIndex = firstTrailingWhitespaceIndex + + html = leadingHtml + if tokenText.length > MaxTokenLength + while startIndex < endIndex + html += "" + @escapeTokenText(tokenText, startIndex, startIndex + MaxTokenLength) + "" + startIndex += MaxTokenLength + else + html += @escapeTokenText(tokenText, startIndex, endIndex) + + html += trailingHtml + html + + escapeTokenText: (tokenText, startIndex, endIndex) -> + if startIndex? and endIndex? and startIndex > 0 or endIndex < tokenText.length + tokenText = tokenText.slice(startIndex, endIndex) + tokenText.replace(TokenTextEscapeRegex, @escapeTokenTextReplace) + + escapeTokenTextReplace: (match) -> + switch match + when '&' then '&' + when '"' then '"' + when "'" then ''' + when '<' then '<' + when '>' then '>' + else match + + buildEndOfLineHTML: (id) -> + {endOfLineInvisibles} = @newTileState.lines[id] + + html = '' + if endOfLineInvisibles? + for invisible in endOfLineInvisibles + html += "#{invisible}" + html + + updateLineNode: (id) -> + oldLineState = @oldTileState.lines[id] + newLineState = @newTileState.lines[id] + + lineNode = @lineNodesByLineId[id] + + if @newState.width isnt @oldState.width + lineNode.style.width = @newState.width + 'px' + + newDecorationClasses = newLineState.decorationClasses + oldDecorationClasses = oldLineState.decorationClasses + + if oldDecorationClasses? + for decorationClass in oldDecorationClasses + unless newDecorationClasses? and decorationClass in newDecorationClasses + lineNode.classList.remove(decorationClass) + + if newDecorationClasses? + for decorationClass in newDecorationClasses + unless oldDecorationClasses? and decorationClass in oldDecorationClasses + lineNode.classList.add(decorationClass) + + oldLineState.decorationClasses = newLineState.decorationClasses + + if newLineState.top isnt oldLineState.top + lineNode.style.top = newLineState.top + 'px' + oldLineState.top = newLineState.top + + if newLineState.screenRow isnt oldLineState.screenRow + lineNode.dataset.screenRow = newLineState.screenRow + oldLineState.screenRow = newLineState.screenRow + @lineIdsByScreenRow[newLineState.screenRow] = id + + lineNodeForScreenRow: (screenRow) -> + @lineNodesByLineId[@lineIdsByScreenRow[screenRow]] + + measureCharactersInNewLines: -> + for id, lineState of @oldTileState.lines + unless @measuredLines.has(id) + lineNode = @lineNodesByLineId[id] + @measureCharactersInLine(id, lineState, lineNode) + return + + measureCharactersInLine: (lineId, tokenizedLine, lineNode) -> + rangeForMeasurement = null + iterator = null + charIndex = 0 + + @tokenIterator.reset(tokenizedLine) + while @tokenIterator.next() + scopes = @tokenIterator.getScopes() + text = @tokenIterator.getText() + charWidths = @presenter.getScopedCharacterWidths(scopes) + + textIndex = 0 + while textIndex < text.length + if @tokenIterator.isPairedCharacter() + char = text + charLength = 2 + textIndex += 2 + else + char = text[textIndex] + charLength = 1 + textIndex++ + + continue if char is '\0' + + unless charWidths[char]? + unless textNode? + rangeForMeasurement ?= document.createRange() + iterator = document.createNodeIterator(lineNode, NodeFilter.SHOW_TEXT, AcceptFilter) + textNode = iterator.nextNode() + textNodeIndex = 0 + nextTextNodeIndex = textNode.textContent.length + + while nextTextNodeIndex <= charIndex + textNode = iterator.nextNode() + textNodeIndex = nextTextNodeIndex + nextTextNodeIndex = textNodeIndex + textNode.textContent.length + + i = charIndex - textNodeIndex + rangeForMeasurement.setStart(textNode, i) + rangeForMeasurement.setEnd(textNode, i + charLength) + charWidth = rangeForMeasurement.getBoundingClientRect().width + @presenter.setScopedCharacterWidth(scopes, char, charWidth) + + charIndex += charLength + + @measuredLines.add(lineId) + + clearMeasurements: -> + @measuredLines.clear() diff --git a/src/marker-observation-window.coffee b/src/marker-observation-window.coffee new file mode 100644 index 000000000..aa7b71f69 --- /dev/null +++ b/src/marker-observation-window.coffee @@ -0,0 +1,12 @@ +module.exports = +class MarkerObservationWindow + constructor: (@displayBuffer, @bufferWindow) -> + + setScreenRange: (range) -> + @bufferWindow.setRange(@displayBuffer.bufferRangeForScreenRange(range)) + + setBufferRange: (range) -> + @bufferWindow.setRange(range) + + destroy: -> + @bufferWindow.destroy() diff --git a/src/marker.coffee b/src/marker.coffee index 98e3f0265..536edfb63 100644 --- a/src/marker.coffee +++ b/src/marker.coffee @@ -1,8 +1,5 @@ -{Range} = require 'text-buffer' _ = require 'underscore-plus' -{Subscriber} = require 'emissary' -EmitterMixin = require('emissary').Emitter -{Emitter} = require 'event-kit' +{CompositeDisposable, Emitter} = require 'event-kit' Grim = require 'grim' # Essential: Represents a buffer annotation that remains logically stationary @@ -45,16 +42,13 @@ Grim = require 'grim' # See {TextEditor::markBufferRange} for usage. module.exports = class Marker - EmitterMixin.includeInto(this) - Subscriber.includeInto(this) - bufferMarkerSubscription: null oldHeadBufferPosition: null oldHeadScreenPosition: null oldTailBufferPosition: null oldTailScreenPosition: null wasValid: true - deferredChangeEvents: null + hasChangeObservers: false ### Section: Construction and Destruction @@ -62,21 +56,16 @@ class Marker constructor: ({@bufferMarker, @displayBuffer}) -> @emitter = new Emitter + @disposables = new CompositeDisposable @id = @bufferMarker.id - @oldHeadBufferPosition = @getHeadBufferPosition() - @oldHeadScreenPosition = @getHeadScreenPosition() - @oldTailBufferPosition = @getTailBufferPosition() - @oldTailScreenPosition = @getTailScreenPosition() - @wasValid = @isValid() - @subscribe @bufferMarker.onDidDestroy => @destroyed() - @subscribe @bufferMarker.onDidChange (event) => @notifyObservers(event) + @disposables.add @bufferMarker.onDidDestroy => @destroyed() # Essential: Destroys the marker, causing it to emit the 'destroyed' event. Once # destroyed, a marker cannot be restored by undo/redo operations. destroy: -> @bufferMarker.destroy() - @unsubscribe() + @disposables.dispose() # Essential: Creates and returns a new {Marker} with the same properties as this # marker. @@ -108,6 +97,14 @@ class Marker # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChange: (callback) -> + unless @hasChangeObservers + @oldHeadBufferPosition = @getHeadBufferPosition() + @oldHeadScreenPosition = @getHeadScreenPosition() + @oldTailBufferPosition = @getTailBufferPosition() + @oldTailScreenPosition = @getTailScreenPosition() + @wasValid = @isValid() + @disposables.add @bufferMarker.onDidChange (event) => @notifyObservers(event) + @hasChangeObservers = true @emitter.on 'did-change', callback # Essential: Invoke the given callback when the marker is destroyed. @@ -118,15 +115,6 @@ class Marker onDidDestroy: (callback) -> @emitter.on 'did-destroy', callback - on: (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use Marker::onDidChange instead") - when 'destroyed' - Grim.deprecate("Use Marker::onDidDestroy instead") - - EmitterMixin::on.apply(this, arguments) - ### Section: Marker Details ### @@ -159,9 +147,6 @@ class Marker # the marker. getProperties: -> @bufferMarker.getProperties() - getAttributes: -> - Grim.deprecate 'Use Marker::getProperties instead' - @getProperties() # Essential: Merges an {Object} containing new properties into the marker's # existing properties. @@ -169,16 +154,10 @@ class Marker # * `properties` {Object} setProperties: (properties) -> @bufferMarker.setProperties(properties) - setAttributes: (properties) -> - Grim.deprecate 'Use Marker::getProperties instead' - @setProperties(properties) matchesProperties: (attributes) -> attributes = @displayBuffer.translateToBufferMarkerParams(attributes) @bufferMarker.matchesParams(attributes) - matchesAttributes: (attributes) -> - Grim.deprecate 'Use Marker::matchesProperties instead' - @matchesProperties(attributes) ### Section: Comparing to other markers @@ -284,7 +263,6 @@ class Marker # * `screenPosition` The new {Point} to use # * `properties` (optional) {Object} properties to associate with the marker. setHeadScreenPosition: (screenPosition, properties) -> - screenPosition = @displayBuffer.clipScreenPosition(screenPosition, properties) @setHeadBufferPosition(@displayBuffer.bufferPositionForScreenPosition(screenPosition, properties)) # Extended: Retrieves the buffer position of the marker's tail. @@ -311,7 +289,6 @@ class Marker # * `screenPosition` The new {Point} to use # * `properties` (optional) {Object} properties to associate with the marker. setTailScreenPosition: (screenPosition, options) -> - screenPosition = @displayBuffer.clipScreenPosition(screenPosition, options) @setTailBufferPosition(@displayBuffer.bufferPositionForScreenPosition(screenPosition, options)) # Extended: Returns a {Boolean} indicating whether the marker has a tail. @@ -344,7 +321,7 @@ class Marker destroyed: -> delete @displayBuffer.markers[@id] - @emit 'destroyed' + @emit 'destroyed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-destroy' @emitter.dispose() @@ -357,11 +334,11 @@ class Marker newTailScreenPosition = @getTailScreenPosition() isValid = @isValid() - return if _.isEqual(isValid, @wasValid) and - _.isEqual(newHeadBufferPosition, @oldHeadBufferPosition) and - _.isEqual(newHeadScreenPosition, @oldHeadScreenPosition) and - _.isEqual(newTailBufferPosition, @oldTailBufferPosition) and - _.isEqual(newTailScreenPosition, @oldTailScreenPosition) + return if isValid is @wasValid and + newHeadBufferPosition.isEqual(@oldHeadBufferPosition) and + newHeadScreenPosition.isEqual(@oldHeadScreenPosition) and + newTailBufferPosition.isEqual(@oldTailBufferPosition) and + newTailScreenPosition.isEqual(@oldTailScreenPosition) changeEvent = { @oldHeadScreenPosition, newHeadScreenPosition, @@ -372,28 +349,41 @@ class Marker isValid } - if @deferredChangeEvents? - @deferredChangeEvents.push(changeEvent) - else - @emit 'changed', changeEvent - @emitter.emit 'did-change', changeEvent - @oldHeadBufferPosition = newHeadBufferPosition @oldHeadScreenPosition = newHeadScreenPosition @oldTailBufferPosition = newTailBufferPosition @oldTailScreenPosition = newTailScreenPosition @wasValid = isValid - pauseChangeEvents: -> - @deferredChangeEvents = [] - - resumeChangeEvents: -> - if deferredChangeEvents = @deferredChangeEvents - @deferredChangeEvents = null - - for event in deferredChangeEvents - @emit 'changed', event - @emitter.emit 'did-change', event + @emit 'changed', changeEvent if Grim.includeDeprecatedAPIs + @emitter.emit 'did-change', changeEvent getPixelRange: -> @displayBuffer.pixelRangeForScreenRange(@getScreenRange(), false) + +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(Marker) + + Marker::on = (eventName) -> + switch eventName + when 'changed' + Grim.deprecate("Use Marker::onDidChange instead") + when 'destroyed' + Grim.deprecate("Use Marker::onDidDestroy instead") + else + Grim.deprecate("Marker::on is deprecated. Use documented event subscription methods instead.") + + EmitterMixin::on.apply(this, arguments) + + Marker::getAttributes = -> + Grim.deprecate 'Use Marker::getProperties instead' + @getProperties() + + Marker::setAttributes = (properties) -> + Grim.deprecate 'Use Marker::setProperties instead' + @setProperties(properties) + + Marker::matchesAttributes = (attributes) -> + Grim.deprecate 'Use Marker::matchesProperties instead' + @matchesProperties(attributes) diff --git a/src/menu-helpers.coffee b/src/menu-helpers.coffee index a91523b82..aa346200c 100644 --- a/src/menu-helpers.coffee +++ b/src/menu-helpers.coffee @@ -17,6 +17,8 @@ merge = (menu, item, itemSpecificity=Infinity) -> else unless item.type is 'separator' and _.last(menu)?.type is 'separator' menu.push(item) + return + unmerge = (menu, item) -> matchingItemIndex = findMatchingItemIndex(menu, item) matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1 diff --git a/src/menu-manager.coffee b/src/menu-manager.coffee index 1991cba95..b138f2da2 100644 --- a/src/menu-manager.coffee +++ b/src/menu-manager.coffee @@ -8,6 +8,8 @@ fs = require 'fs-plus' MenuHelpers = require './menu-helpers' +platformMenu = require('../package.json')?._atomMenu?.menu + # Extended: Provides a registry for menu items that you'd like to appear in the # application menu. # @@ -61,6 +63,7 @@ class MenuManager @pendingUpdateOperation = null @template = [] atom.keymaps.onDidLoadBundledKeymaps => @loadPlatformItems() + atom.keymaps.onDidReloadKeymap => @update() atom.packages.onDidActivateInitialPackages => @sortPackagesMenu() # Public: Adds the given items to the application menu. @@ -137,17 +140,29 @@ class MenuManager update: -> clearImmediate(@pendingUpdateOperation) if @pendingUpdateOperation? @pendingUpdateOperation = setImmediate => - keystrokesByCommand = {} + includedBindings = [] + unsetKeystrokes = new Set + for binding in atom.keymaps.getKeyBindings() when @includeSelector(binding.selector) + includedBindings.push(binding) + if binding.command is 'unset!' + unsetKeystrokes.add(binding.keystrokes) + + keystrokesByCommand = {} + for binding in includedBindings when not unsetKeystrokes.has(binding.keystrokes) keystrokesByCommand[binding.command] ?= [] keystrokesByCommand[binding.command].unshift binding.keystrokes + @sendToBrowserProcess(@template, keystrokesByCommand) loadPlatformItems: -> - menusDirPath = path.join(@resourcePath, 'menus') - platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) - {menu} = CSON.readFileSync(platformMenuPath) - @add(menu) + if platformMenu? + @add(platformMenu) + else + menusDirPath = path.join(@resourcePath, 'menus') + platformMenuPath = fs.resolve(menusDirPath, process.platform, ['cson', 'json']) + {menu} = CSON.readFileSync(platformMenuPath) + @add(menu) # Merges an item in a submenu aware way such that new items are always # appended to the bottom of existing menus where possible. @@ -164,7 +179,7 @@ class MenuManager filtered = {} for key, bindings of keystrokesByCommand for binding in bindings - continue if binding.indexOf(' ') != -1 + continue if binding.indexOf(' ') isnt -1 filtered[key] ?= [] filtered[key].push(binding) diff --git a/src/model.coffee b/src/model.coffee new file mode 100644 index 000000000..7b38c0eef --- /dev/null +++ b/src/model.coffee @@ -0,0 +1,34 @@ +Grim = require 'grim' +if Grim.includeDeprecatedAPIs + module.exports = require('theorist').Model + return + +PropertyAccessors = require 'property-accessors' + +nextInstanceId = 1 + +module.exports = +class Model + PropertyAccessors.includeInto(this) + + @resetNextInstanceId: -> nextInstanceId = 1 + + alive: true + + constructor: (params) -> + @assignId(params?.id) + + assignId: (id) -> + @id ?= id ? nextInstanceId++ + + @::advisedAccessor 'id', + set: (id) -> nextInstanceId = id + 1 if id >= nextInstanceId + + destroy: -> + return unless @isAlive() + @alive = false + @destroyed?() + + isAlive: -> @alive + + isDestroyed: -> not @isAlive() diff --git a/src/module-cache.coffee b/src/module-cache.coffee index b4d51dd51..e9245cf40 100644 --- a/src/module-cache.coffee +++ b/src/module-cache.coffee @@ -200,7 +200,7 @@ registerBuiltins = (devMode) -> cache.builtins.atom = atomCoffeePath if fs.isFileSync(atomCoffeePath) cache.builtins.atom ?= path.join(cache.resourcePath, 'exports', 'atom.js') - atomShellRoot = path.join(process.resourcesPath, 'atom') + atomShellRoot = path.join(process.resourcesPath, 'atom.asar') commonRoot = path.join(atomShellRoot, 'common', 'api', 'lib') commonBuiltins = ['callbacks-registry', 'clipboard', 'crash-reporter', 'screen', 'shell'] diff --git a/src/notification-manager.coffee b/src/notification-manager.coffee index f811a501f..f4ebd97db 100644 --- a/src/notification-manager.coffee +++ b/src/notification-manager.coffee @@ -1,8 +1,8 @@ {Emitter, Disposable} = require 'event-kit' Notification = require '../src/notification' -# Experimental: Allows messaging the user. This will likely change, dont use -# quite yet! +# Public: A notification manager used to create {Notification}s to be shown +# to the user. module.exports = class NotificationManager constructor: -> @@ -13,6 +13,12 @@ class NotificationManager Section: Events ### + # Public: Invoke the given callback after a notification has been added. + # + # * `callback` {Function} to be called after the notification is added. + # * `notification` The {Notification} that was added. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidAddNotification: (callback) -> @emitter.on 'did-add-notification', callback @@ -20,18 +26,43 @@ class NotificationManager Section: Adding Notifications ### + # Public: Add a success notification. + # + # * `message` A {String} message + # * `options` An options {Object} with optional keys such as: + # * `detail` A {String} with additional details about the notification addSuccess: (message, options) -> @addNotification(new Notification('success', message, options)) + # Public: Add an informational notification. + # + # * `message` A {String} message + # * `options` An options {Object} with optional keys such as: + # * `detail` A {String} with additional details about the notification addInfo: (message, options) -> @addNotification(new Notification('info', message, options)) + # Public: Add a warning notification. + # + # * `message` A {String} message + # * `options` An options {Object} with optional keys such as: + # * `detail` A {String} with additional details about the notification addWarning: (message, options) -> @addNotification(new Notification('warning', message, options)) + # Public: Add an error notification. + # + # * `message` A {String} message + # * `options` An options {Object} with optional keys such as: + # * `detail` A {String} with additional details about the notification addError: (message, options) -> @addNotification(new Notification('error', message, options)) + # Public: Add a fatal error notification. + # + # * `message` A {String} message + # * `options` An options {Object} with optional keys such as: + # * `detail` A {String} with additional details about the notification addFatalError: (message, options) -> @addNotification(new Notification('fatal', message, options)) @@ -47,7 +78,10 @@ class NotificationManager Section: Getting Notifications ### - getNotifications: -> @notifications + # Public: Get all the notifications. + # + # Returns an {Array} of {Notifications}s. + getNotifications: -> @notifications.slice() ### Section: Managing Notifications diff --git a/src/notification.coffee b/src/notification.coffee index 0ca82e724..9dfffc59a 100644 --- a/src/notification.coffee +++ b/src/notification.coffee @@ -1,6 +1,6 @@ {Emitter} = require 'event-kit' -# Experimental: This will likely change, do not use. +# Public: A notification to the user containing a message and type. module.exports = class Notification constructor: (@type, @message, @options={}) -> @@ -18,8 +18,10 @@ class Notification getOptions: -> @options + # Public: Retrieves the {String} type. getType: -> @type + # Public: Retrieves the {String} message. getMessage: -> @message getTimestamp: -> @timestamp @@ -27,9 +29,9 @@ class Notification getDetail: -> @options.detail isEqual: (other) -> - @getMessage() == other.getMessage() \ - and @getType() == other.getType() \ - and @getDetail() == other.getDetail() + @getMessage() is other.getMessage() \ + and @getType() is other.getType() \ + and @getDetail() is other.getDetail() dismiss: -> return unless @isDismissable() and not @isDismissed() diff --git a/src/overlay-manager.coffee b/src/overlay-manager.coffee index c8b5da0e8..21a484fbe 100644 --- a/src/overlay-manager.coffee +++ b/src/overlay-manager.coffee @@ -1,39 +1,44 @@ module.exports = class OverlayManager constructor: (@presenter, @container) -> - @overlayNodesById = {} + @overlaysById = {} render: (state) -> - for decorationId, {pixelPosition, item} of state.content.overlays - @renderOverlay(state, decorationId, item, pixelPosition) + for decorationId, overlay of state.content.overlays + if @shouldUpdateOverlay(decorationId, overlay) + @renderOverlay(state, decorationId, overlay) - for id, overlayNode of @overlayNodesById + for id, {overlayNode} of @overlaysById unless state.content.overlays.hasOwnProperty(id) - delete @overlayNodesById[id] + delete @overlaysById[id] overlayNode.remove() - return + shouldUpdateOverlay: (decorationId, overlay) -> + cachedOverlay = @overlaysById[decorationId] + return true unless cachedOverlay? + cachedOverlay.pixelPosition?.top isnt overlay.pixelPosition?.top or + cachedOverlay.pixelPosition?.left isnt overlay.pixelPosition?.left - renderOverlay: (state, decorationId, item, pixelPosition) -> - item = atom.views.getView(item) - unless overlayNode = @overlayNodesById[decorationId] - overlayNode = @overlayNodesById[decorationId] = document.createElement('atom-overlay') - overlayNode.appendChild(item) + measureOverlays: -> + for decorationId, {itemView} of @overlaysById + @measureOverlay(decorationId, itemView) + + measureOverlay: (decorationId, itemView) -> + contentMargin = parseInt(getComputedStyle(itemView)['margin-left']) ? 0 + @presenter.setOverlayDimensions(decorationId, itemView.offsetWidth, itemView.offsetHeight, contentMargin) + + renderOverlay: (state, decorationId, {item, pixelPosition}) -> + itemView = atom.views.getView(item) + cachedOverlay = @overlaysById[decorationId] + unless overlayNode = cachedOverlay?.overlayNode + overlayNode = document.createElement('atom-overlay') @container.appendChild(overlayNode) + @overlaysById[decorationId] = cachedOverlay = {overlayNode, itemView} - itemWidth = item.offsetWidth - itemHeight = item.offsetHeight + # The same node may be used in more than one overlay. This steals the node + # back if it has been displayed in another overlay. + overlayNode.appendChild(itemView) if overlayNode.childNodes.length is 0 - - {scrollTop, scrollLeft} = state.content - - left = pixelPosition.left - if left + itemWidth - scrollLeft > @presenter.contentFrameWidth and left - itemWidth >= scrollLeft - left -= itemWidth - - top = pixelPosition.top + @presenter.lineHeight - if top + itemHeight - scrollTop > @presenter.height and top - itemHeight - @presenter.lineHeight >= scrollTop - top -= itemHeight + @presenter.lineHeight - - overlayNode.style.top = top + 'px' - overlayNode.style.left = left + 'px' + cachedOverlay.pixelPosition = pixelPosition + overlayNode.style.top = pixelPosition.top + 'px' + overlayNode.style.left = pixelPosition.left + 'px' diff --git a/src/package-manager.coffee b/src/package-manager.coffee index bcd7dc28e..aeb67ea3b 100644 --- a/src/package-manager.coffee +++ b/src/package-manager.coffee @@ -1,7 +1,6 @@ path = require 'path' _ = require 'underscore-plus' -EmitterMixin = require('emissary').Emitter {Emitter} = require 'event-kit' fs = require 'fs-plus' Q = require 'q' @@ -10,6 +9,7 @@ Grim = require 'grim' ServiceHub = require 'service-hub' Package = require './package' ThemePackage = require './theme-package' +{isDeprecatedPackage, getDeprecatedPackageMetadata} = require './deprecated-packages' # Extended: Package manager for coordinating the lifecycle of Atom packages. # @@ -28,8 +28,6 @@ ThemePackage = require './theme-package' # settings and also by calling `enablePackage()/disablePackage()`. module.exports = class PackageManager - EmitterMixin.includeInto(this) - constructor: ({configDirPath, @devMode, safeMode, @resourcePath}) -> @emitter = new Emitter @packageDirPaths = [] @@ -57,11 +55,6 @@ class PackageManager # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidLoadInitialPackages: (callback) -> @emitter.on 'did-load-initial-packages', callback - @emitter.on 'did-load-all', callback # TODO: Remove once deprecated pre-1.0 APIs are gone - - onDidLoadAll: (callback) -> - Grim.deprecate("Use `::onDidLoadInitialPackages` instead.") - @onDidLoadInitialPackages(callback) # Public: Invoke the given callback when all packages have been activated. # @@ -70,11 +63,6 @@ class PackageManager # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidActivateInitialPackages: (callback) -> @emitter.on 'did-activate-initial-packages', callback - @emitter.on 'did-activate-all', callback # TODO: Remove once deprecated pre-1.0 APIs are gone - - onDidActivateAll: (callback) -> - Grim.deprecate("Use `::onDidActivateInitialPackages` instead.") - @onDidActivateInitialPackages(callback) # Public: Invoke the given callback when a package is activated. # @@ -112,16 +100,6 @@ class PackageManager onDidUnloadPackage: (callback) -> @emitter.on 'did-unload-package', callback - on: (eventName) -> - switch eventName - when 'loaded' - Grim.deprecate 'Use PackageManager::onDidLoadInitialPackages instead' - when 'activated' - Grim.deprecate 'Use PackageManager::onDidActivateInitialPackages instead' - else - Grim.deprecate 'PackageManager::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - ### Section: Package system data ### @@ -134,7 +112,7 @@ class PackageManager commandName = 'apm' commandName += '.cmd' if process.platform is 'win32' - apmRoot = path.resolve(__dirname, '..', 'apm') + apmRoot = path.join(process.resourcesPath, 'app', 'apm') @apmPath = path.join(apmRoot, 'bin', commandName) unless fs.isFileSync(@apmPath) @apmPath = path.join(apmRoot, 'node_modules', 'atom-package-manager', 'bin', commandName) @@ -172,6 +150,12 @@ class PackageManager isBundledPackage: (name) -> @getPackageDependencies().hasOwnProperty(name) + isDeprecatedPackage: (name, version) -> + isDeprecatedPackage(name, version) + + getDeprecatedPackageMetadata: (name) -> + getDeprecatedPackageMetadata(name) + ### Section: Enabling and disabling packages ### @@ -299,8 +283,7 @@ class PackageManager getPackageDependencies: -> unless @packageDependencies? try - metadataPath = path.join(@resourcePath, 'package.json') - {@packageDependencies} = JSON.parse(fs.readFileSync(metadataPath)) ? {} + @packageDependencies = require('../package.json')?.packageDependencies @packageDependencies ?= {} @packageDependencies @@ -327,11 +310,18 @@ class PackageManager # of the first package isn't skewed by being the first to require atom require '../exports/atom' + # TODO: remove after a few atom versions. + @uninstallAutocompletePlus() + packagePaths = @getAvailablePackagePaths() + + # TODO: remove after a few atom versions. + @migrateSublimeTabsSettings(packagePaths) + packagePaths = packagePaths.filter (packagePath) => not @isPackageDisabled(path.basename(packagePath)) packagePaths = _.uniq packagePaths, (packagePath) -> path.basename(packagePath) @loadPackage(packagePath) for packagePath in packagePaths - @emit 'loaded' + @emit 'loaded' if Grim.includeDeprecatedAPIs @emitter.emit 'did-load-initial-packages' loadPackage: (nameOrPath) -> @@ -343,16 +333,23 @@ class PackageManager try metadata = Package.loadMetadata(packagePath) ? {} - if metadata.theme - pack = new ThemePackage(packagePath, metadata) - else - pack = new Package(packagePath, metadata) - pack.load() - @loadedPackages[pack.name] = pack - @emitter.emit 'did-load-package', pack - return pack catch error - console.warn "Failed to load package.json '#{path.basename(packagePath)}'", error.stack ? error + @handleMetadataError(error, packagePath) + return null + + unless @isBundledPackage(metadata.name) or Grim.includeDeprecatedAPIs + if @isDeprecatedPackage(metadata.name, metadata.version) + console.warn "Could not load #{metadata.name}@#{metadata.version} because it uses deprecated APIs that have been removed." + return null + + if metadata.theme + pack = new ThemePackage(packagePath, metadata) + else + pack = new Package(packagePath, metadata) + pack.load() + @loadedPackages[pack.name] = pack + @emitter.emit 'did-load-package', pack + return pack else console.warn "Could not resolve '#{nameOrPath}' to a package path" null @@ -378,7 +375,7 @@ class PackageManager packages = @getLoadedPackagesForTypes(types) promises = promises.concat(activator.activatePackages(packages)) Q.all(promises).then => - @emit 'activated' + @emit 'activated' if Grim.includeDeprecatedAPIs @emitter.emit 'did-activate-initial-packages' # another type of package manager can handle other package types. @@ -392,6 +389,7 @@ class PackageManager for pack in packages promise = @activatePackage(pack.name) promises.push(promise) unless pack.hasActivationCommands() + return @observeDisabledPackages() promises @@ -411,6 +409,7 @@ class PackageManager deactivatePackages: -> atom.config.transact => @deactivatePackage(pack.name) for pack in @getLoadedPackages() + return @unobserveDisabledPackages() # Deactivate the package with the given name @@ -421,3 +420,73 @@ class PackageManager pack.deactivate() delete @activePackages[pack.name] @emitter.emit 'did-deactivate-package', pack + + handleMetadataError: (error, packagePath) -> + metadataPath = path.join(packagePath, 'package.json') + detail = "#{error.message} in #{metadataPath}" + stack = "#{error.stack}\n at #{metadataPath}:1:1" + message = "Failed to load the #{path.basename(packagePath)} package" + atom.notifications.addError(message, {stack, detail, dismissable: true}) + + # TODO: remove these autocomplete-plus specific helpers after a few versions. + uninstallAutocompletePlus: -> + packageDir = null + devDir = path.join("dev", "packages") + for packageDirPath in @packageDirPaths + if not packageDirPath.endsWith(devDir) + packageDir = packageDirPath + break + + if packageDir? + dirsToRemove = [ + path.join(packageDir, 'autocomplete-plus') + path.join(packageDir, 'autocomplete-atom-api') + path.join(packageDir, 'autocomplete-css') + path.join(packageDir, 'autocomplete-html') + path.join(packageDir, 'autocomplete-snippets') + ] + for dirToRemove in dirsToRemove + @uninstallDirectory(dirToRemove) + return + + # TODO: remove this after a few versions + migrateSublimeTabsSettings: (packagePaths) -> + return if Grim.includeDeprecatedAPIs + for packagePath in packagePaths when path.basename(packagePath) is 'sublime-tabs' + atom.config.removeAtKeyPath('core.disabledPackages', 'tree-view') + atom.config.removeAtKeyPath('core.disabledPackages', 'tabs') + return + + uninstallDirectory: (directory) -> + symlinkPromise = new Promise (resolve) -> + fs.isSymbolicLink directory, (isSymLink) -> resolve(isSymLink) + + dirPromise = new Promise (resolve) -> + fs.isDirectory directory, (isDir) -> resolve(isDir) + + Promise.all([symlinkPromise, dirPromise]).then (values) -> + [isSymLink, isDir] = values + if not isSymLink and isDir + fs.remove directory, -> + +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(PackageManager) + + PackageManager::on = (eventName) -> + switch eventName + when 'loaded' + Grim.deprecate 'Use PackageManager::onDidLoadInitialPackages instead' + when 'activated' + Grim.deprecate 'Use PackageManager::onDidActivateInitialPackages instead' + else + Grim.deprecate 'PackageManager::on is deprecated. Use event subscription methods instead.' + EmitterMixin::on.apply(this, arguments) + + PackageManager::onDidLoadAll = (callback) -> + Grim.deprecate("Use `::onDidLoadInitialPackages` instead.") + @onDidLoadInitialPackages(callback) + + PackageManager::onDidActivateAll = (callback) -> + Grim.deprecate("Use `::onDidActivateInitialPackages` instead.") + @onDidActivateInitialPackages(callback) diff --git a/src/package.coffee b/src/package.coffee index d8ba4a1d8..3de79ff51 100644 --- a/src/package.coffee +++ b/src/package.coffee @@ -1,28 +1,23 @@ path = require 'path' +normalizePackageData = null _ = require 'underscore-plus' async = require 'async' CSON = require 'season' fs = require 'fs-plus' -EmitterMixin = require('emissary').Emitter {Emitter, CompositeDisposable} = require 'event-kit' Q = require 'q' -{deprecate} = require 'grim' +{includeDeprecatedAPIs, deprecate} = require 'grim' ModuleCache = require './module-cache' ScopedProperties = require './scoped-properties' -try - packagesCache = require('../package.json')?._atomPackages ? {} -catch error - packagesCache = {} +packagesCache = require('../package.json')?._atomPackages ? {} # Loads and activates a package's main module and resources such as # stylesheets, keymaps, grammar, editor properties, and menus. module.exports = class Package - EmitterMixin.includeInto(this) - @isBundledPackagePath: (packagePath) -> if atom.packages.devMode return false unless atom.packages.resourcePath.startsWith("#{process.resourcesPath}#{path.sep}") @@ -30,6 +25,13 @@ class Package @resourcePathWithTrailingSlash ?= "#{atom.packages.resourcePath}#{path.sep}" packagePath?.startsWith(@resourcePathWithTrailingSlash) + @normalizeMetadata: (metadata) -> + unless metadata?._id + normalizePackageData ?= require 'normalize-package-data' + normalizePackageData(metadata) + if metadata.repository?.type is 'git' and typeof metadata.repository.url is 'string' + metadata.repository.url = metadata.repository.url.replace(/^git\+/, '') + @loadMetadata: (packagePath, ignoreErrors=false) -> packageName = path.basename(packagePath) if @isBundledPackagePath(packagePath) @@ -38,16 +40,19 @@ class Package if metadataPath = CSON.resolve(path.join(packagePath, 'package')) try metadata = CSON.readFileSync(metadataPath) + @normalizeMetadata(metadata) catch error throw error unless ignoreErrors - metadata ?= {} - metadata.name = packageName - if metadata.stylesheetMain? + metadata ?= {} + unless typeof metadata.name is 'string' and metadata.name.length > 0 + metadata.name = packageName + + if includeDeprecatedAPIs and metadata.stylesheetMain? deprecate("Use the `mainStyleSheet` key instead of `stylesheetMain` in the `package.json` of `#{packageName}`", {packageName}) metadata.mainStyleSheet = metadata.stylesheetMain - if metadata.stylesheets? + if includeDeprecatedAPIs and metadata.stylesheets? deprecate("Use the `styleSheets` key instead of `stylesheets` in the `package.json` of `#{packageName}`", {packageName}) metadata.styleSheets = metadata.stylesheets @@ -87,14 +92,6 @@ class Package onDidDeactivate: (callback) -> @emitter.on 'did-deactivate', callback - on: (eventName) -> - switch eventName - when 'deactivated' - deprecate 'Use Package::onDidDeactivate instead' - else - deprecate 'Package::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - ### Section: Instance Methods ### @@ -126,9 +123,8 @@ class Package @loadStylesheets() @settingsPromise = @loadSettings() @requireMainModule() unless @hasActivationCommands() - catch error - console.warn "Failed to load package named '#{@name}'", error.stack ? error + @handleError("Failed to load the #{@name} package", error) this reset: -> @@ -144,11 +140,14 @@ class Package unless @activationDeferred? @activationDeferred = Q.defer() @measure 'activateTime', => - @activateResources() - if @hasActivationCommands() - @subscribeToActivationCommands() - else - @activateNow() + try + @activateResources() + if @hasActivationCommands() + @subscribeToActivationCommands() + else + @activateNow() + catch error + @handleError("Failed to activate the #{@name} package", error) Q.all([@grammarsPromise, @settingsPromise, @activationDeferred.promise]) @@ -160,8 +159,8 @@ class Package @mainModule.activate?(atom.packages.getPackageState(@name) ? {}) @mainActivated = true @activateServices() - catch e - console.warn "Failed to activate package named '#{@name}'", e.stack + catch error + @handleError("Failed to activate the #{@name} package", error) @activationDeferred?.resolve() @@ -173,9 +172,9 @@ class Package if @mainModule.config? and typeof @mainModule.config is 'object' atom.config.setSchema @name, {type: 'object', properties: @mainModule.config} else if @mainModule.configDefaults? and typeof @mainModule.configDefaults is 'object' - deprecate """Use a config schema instead. See the configuration section - of https://atom.io/docs/latest/creating-a-package and - https://atom.io/docs/api/latest/Config for more details""" + deprecate("""Use a config schema instead. See the configuration section + of https://atom.io/docs/latest/hacking-atom-package-word-count and + https://atom.io/docs/api/latest/Config for more details""", {packageName: @name}) atom.config.setDefaults(@name, @mainModule.configDefaults) @mainModule.activateConfig?() @configActivated = true @@ -200,7 +199,28 @@ class Package activateResources: -> @activationDisposables = new CompositeDisposable @activationDisposables.add(atom.keymaps.add(keymapPath, map)) for [keymapPath, map] in @keymaps - @activationDisposables.add(atom.contextMenu.add(map['context-menu'])) for [menuPath, map] in @menus when map['context-menu']? + + for [menuPath, map] in @menus when map['context-menu']? + try + itemsBySelector = map['context-menu'] + + # Detect deprecated format for items object + for key, value of itemsBySelector + unless _.isArray(value) + deprecate(""" + The context menu CSON format has changed. Please see + https://atom.io/docs/api/latest/ContextMenuManager#context-menu-cson-format + for more info. + """, {packageName: @name}) + itemsBySelector = atom.contextMenu.convertLegacyItemsBySelector(itemsBySelector) + + @activationDisposables.add(atom.contextMenu.add(itemsBySelector)) + catch error + if error.code is 'EBADSELECTOR' + error.message += " in #{menuPath}" + error.stack += "\n at #{menuPath}:1:1" + throw error + @activationDisposables.add(atom.menu.add(map['menu'])) for [menuPath, map] in @menus when map['menu']? unless @grammarsActivated @@ -212,24 +232,31 @@ class Package activateServices: -> for name, {versions} of @metadata.providedServices + servicesByVersion = {} for version, methodName of versions - @activationDisposables.add atom.packages.serviceHub.provide(name, version, @mainModule[methodName]()) + if typeof @mainModule[methodName] is 'function' + servicesByVersion[version] = @mainModule[methodName]() + @activationDisposables.add atom.packages.serviceHub.provide(name, servicesByVersion) for name, {versions} of @metadata.consumedServices for version, methodName of versions - @activationDisposables.add atom.packages.serviceHub.consume(name, version, @mainModule[methodName].bind(@mainModule)) + if typeof @mainModule[methodName] is 'function' + @activationDisposables.add atom.packages.serviceHub.consume(name, version, @mainModule[methodName].bind(@mainModule)) + return loadKeymaps: -> if @bundledPackage and packagesCache[@name]? @keymaps = (["#{atom.packages.resourcePath}#{path.sep}#{keymapPath}", keymapObject] for keymapPath, keymapObject of packagesCache[@name].keymaps) else @keymaps = @getKeymapPaths().map (keymapPath) -> [keymapPath, CSON.readFileSync(keymapPath) ? {}] + return loadMenus: -> if @bundledPackage and packagesCache[@name]? @menus = (["#{atom.packages.resourcePath}#{path.sep}#{menuPath}", menuObject] for menuPath, menuObject of packagesCache[@name].menus) else @menus = @getMenuPaths().map (menuPath) -> [menuPath, CSON.readFileSync(menuPath) ? {}] + return getKeymapPaths: -> keymapsDirPath = path.join(@path, 'keymaps') @@ -276,6 +303,7 @@ class Package try grammar = atom.grammars.readGrammarSync(grammarPath) grammar.packageName = @name + grammar.bundledPackage = @bundledPackage @grammars.push(grammar) grammar.activate() catch error @@ -290,17 +318,23 @@ class Package loadGrammar = (grammarPath, callback) => atom.grammars.readGrammar grammarPath, (error, grammar) => if error? - console.warn("Failed to load grammar: #{grammarPath}", error.stack ? error) + detail = "#{error.message} in #{grammarPath}" + stack = "#{error.stack}\n at #{grammarPath}:1:1" + atom.notifications.addFatalError("Failed to load a #{@name} package grammar", {stack, detail, dismissable: true}) else grammar.packageName = @name + grammar.bundledPackage = @bundledPackage @grammars.push(grammar) grammar.activate() if @grammarsActivated callback() deferred = Q.defer() grammarsDirPath = path.join(@path, 'grammars') - fs.list grammarsDirPath, ['json', 'cson'], (error, grammarPaths=[]) -> - async.each grammarPaths, loadGrammar, -> deferred.resolve() + fs.exists grammarsDirPath, (grammarsDirExists) -> + return deferred.resolve() unless grammarsDirExists + + fs.list grammarsDirPath, ['json', 'cson'], (error, grammarPaths=[]) -> + async.each grammarPaths, loadGrammar, -> deferred.resolve() deferred.promise loadSettings: -> @@ -309,7 +343,9 @@ class Package loadSettingsFile = (settingsPath, callback) => ScopedProperties.load settingsPath, (error, settings) => if error? - console.warn("Failed to load package settings: #{settingsPath}", error.stack ? error) + detail = "#{error.message} in #{settingsPath}" + stack = "#{error.stack}\n at #{settingsPath}:1:1" + atom.notifications.addFatalError("Failed to load the #{@name} package settings", {stack, detail, dismissable: true}) else @settings.push(settings) settings.activate() if @settingsActivated @@ -323,8 +359,11 @@ class Package else settingsDirPath = path.join(@path, 'settings') - fs.list settingsDirPath, ['json', 'cson'], (error, settingsPaths=[]) -> - async.each settingsPaths, loadSettingsFile, -> deferred.resolve() + fs.exists settingsDirPath, (settingsDirExists) -> + return deferred.resolve() unless settingsDirExists + + fs.list settingsDirPath, ['json', 'cson'], (error, settingsPaths=[]) -> + async.each settingsPaths, loadSettingsFile, -> deferred.resolve() deferred.promise serialize: -> @@ -345,7 +384,7 @@ class Package @mainModule?.deactivate?() catch e console.error "Error deactivating package '#{@name}'", e.stack - @emit 'deactivated' + @emit 'deactivated' if includeDeprecatedAPIs @emitter.emit 'did-deactivate' deactivateConfig: -> @@ -363,14 +402,19 @@ class Package reloadStylesheets: -> oldSheets = _.clone(@stylesheets) - @loadStylesheets() + + try + @loadStylesheets() + catch error + @handleError("Failed to reload the #{@name} package stylesheets", error) + @stylesheetDisposables?.dispose() @stylesheetDisposables = new CompositeDisposable @stylesheetsActivated = false @activateStylesheets() requireMainModule: -> - return @mainModule if @mainModule? + return @mainModule if @mainModuleRequired unless @isCompatible() console.warn """ Failed to require the main module of '#{@name}' because it requires an incompatible native module. @@ -378,7 +422,9 @@ class Package """ return mainModulePath = @getMainModulePath() - @mainModule = require(mainModulePath) if fs.isFileSync(mainModulePath) + if fs.isFileSync(mainModulePath) + @mainModuleRequired = true + @mainModule = require(mainModulePath) getMainModulePath: -> return @mainModulePath if @resolvedMainModulePath @@ -409,7 +455,15 @@ class Package do (selector, command) => # Add dummy command so it appears in menu. # The real command will be registered on package activation - @activationCommandSubscriptions.add atom.commands.add selector, command, -> + try + @activationCommandSubscriptions.add atom.commands.add selector, command, -> + catch error + if error.code is 'EBADSELECTOR' + metadataPath = path.join(@path, 'package.json') + error.message += " in #{metadataPath}" + error.stack += "\n at #{metadataPath}:1:1" + throw error + @activationCommandSubscriptions.add atom.commands.onWillDispatch (event) => return unless event.type is command currentTarget = event.target @@ -419,6 +473,8 @@ class Package @activateNow() break currentTarget = currentTarget.parentElement + return + return getActivationCommands: -> return @activationCommands if @activationCommands? @@ -434,7 +490,7 @@ class Package @activationCommands[selector].push(commands...) if @metadata.activationEvents? - deprecate """ + deprecate(""" Use `activationCommands` instead of `activationEvents` in your package.json Commands should be grouped by selector as follows: ```json @@ -443,7 +499,7 @@ class Package "atom-text-editor": ["foo:quux"] } ``` - """ + """, {packageName: @name}) if _.isArray(@metadata.activationEvents) for eventName in @metadata.activationEvents @activationCommands['atom-workspace'] ?= [] @@ -477,6 +533,7 @@ class Package for modulePath in fs.listSync(nodeModulesPath) nativeModulePaths.push(modulePath) if @isNativeModule(modulePath) traversePath(path.join(modulePath, 'node_modules')) + return traversePath(path.join(@path, 'node_modules')) nativeModulePaths @@ -528,3 +585,37 @@ class Package @compatible = @incompatibleModules.length is 0 else @compatible = true + + handleError: (message, error) -> + if error.filename and error.location and (error instanceof SyntaxError) + location = "#{error.filename}:#{error.location.first_line + 1}:#{error.location.first_column + 1}" + detail = "#{error.message} in #{location}" + stack = """ + SyntaxError: #{error.message} + at #{location} + """ + else if error.less and error.filename and error.column? and error.line? + # Less errors + location = "#{error.filename}:#{error.line}:#{error.column}" + detail = "#{error.message} in #{location}" + stack = """ + LessError: #{error.message} + at #{location} + """ + else + detail = error.message + stack = error.stack ? error + + atom.notifications.addFatalError(message, {stack, detail, dismissable: true}) + +if includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(Package) + + Package::on = (eventName) -> + switch eventName + when 'deactivated' + deprecate 'Use Package::onDidDeactivate instead' + else + deprecate 'Package::on is deprecated. Use event subscription methods instead.' + EmitterMixin::on.apply(this, arguments) diff --git a/src/pane-axis-element.coffee b/src/pane-axis-element.coffee index ba0cb4b19..f9dd15697 100644 --- a/src/pane-axis-element.coffee +++ b/src/pane-axis-element.coffee @@ -1,5 +1,6 @@ {CompositeDisposable} = require 'event-kit' {callAttachHooks} = require './space-pen-extensions' +PaneResizeHandleElement = require './pane-resize-handle-element' class PaneAxisElement extends HTMLElement createdCallback: -> @@ -12,6 +13,7 @@ class PaneAxisElement extends HTMLElement @subscriptions.add @model.onDidAddChild(@childAdded.bind(this)) @subscriptions.add @model.onDidRemoveChild(@childRemoved.bind(this)) @subscriptions.add @model.onDidReplaceChild(@childReplaced.bind(this)) + @subscriptions.add @model.observeFlexScale(@flexScaleChanged.bind(this)) @childAdded({child, index}) for child, index in @model.getChildren() @@ -22,21 +24,43 @@ class PaneAxisElement extends HTMLElement @classList.add('vertical', 'pane-column') this + isPaneResizeHandleElement: (element) -> + element?.nodeName.toLowerCase() is 'atom-pane-resize-handle' + childAdded: ({child, index}) -> view = atom.views.getView(child) - @insertBefore(view, @children[index]) + @insertBefore(view, @children[index * 2]) + + prevElement = view.previousSibling + # if previous element is not pane resize element, then insert new resize element + if prevElement? and not @isPaneResizeHandleElement(prevElement) + resizeHandle = document.createElement('atom-pane-resize-handle') + @insertBefore(resizeHandle, view) + + nextElement = view.nextSibling + # if next element isnot resize element, then insert new resize element + if nextElement? and not @isPaneResizeHandleElement(nextElement) + resizeHandle = document.createElement('atom-pane-resize-handle') + @insertBefore(resizeHandle, nextElement) + callAttachHooks(view) # for backward compatibility with SpacePen views childRemoved: ({child}) -> view = atom.views.getView(child) + siblingView = view.previousSibling + # make sure next sibling view is pane resize view + if siblingView? and @isPaneResizeHandleElement(siblingView) + siblingView.remove() view.remove() - childReplaced: ({index, oldChild, newChild}) -> + childReplaced: ({index, oldChild, newChild}) -> focusedElement = document.activeElement if @hasFocus() @childRemoved({child: oldChild, index}) @childAdded({child: newChild, index}) focusedElement?.focus() if document.activeElement is document.body + flexScaleChanged: (flexScale) -> @style.flexGrow = flexScale + hasFocus: -> this is document.activeElement or @contains(document.activeElement) diff --git a/src/pane-axis.coffee b/src/pane-axis.coffee index a9eb0757b..1fba48d37 100644 --- a/src/pane-axis.coffee +++ b/src/pane-axis.coffee @@ -1,7 +1,7 @@ -{Model} = require 'theorist' {Emitter, CompositeDisposable} = require 'event-kit' {flatten} = require 'underscore-plus' Serializable = require 'serializable' +Model = require './model' module.exports = class PaneAxis extends Model @@ -12,13 +12,14 @@ class PaneAxis extends Model container: null orientation: null - constructor: ({@container, @orientation, children}) -> + constructor: ({@container, @orientation, children, flexScale}={}) -> @emitter = new Emitter @subscriptionsByChild = new WeakMap @subscriptions = new CompositeDisposable @children = [] if children? @addChild(child) for child in children + @flexScale = flexScale ? 1 deserializeParams: (params) -> {container} = params @@ -28,6 +29,13 @@ class PaneAxis extends Model serializeParams: -> children: @children.map (child) -> child.serialize() orientation: @orientation + flexScale: @flexScale + + getFlexScale: -> @flexScale + + setFlexScale: (@flexScale) -> + @emitter.emit 'did-change-flex-scale', @flexScale + @flexScale getParent: -> @parent @@ -59,6 +67,13 @@ class PaneAxis extends Model onDidDestroy: (fn) -> @emitter.on 'did-destroy', fn + onDidChangeFlexScale: (fn) -> + @emitter.on 'did-change-flex-scale', fn + + observeFlexScale: (fn) -> + fn(@flexScale) + @onDidChangeFlexScale(fn) + addChild: (child, index=@children.length) -> child.setParent(this) child.setContainer(@container) @@ -68,6 +83,16 @@ class PaneAxis extends Model @children.splice(index, 0, child) @emitter.emit 'did-add-child', {child, index} + adjustFlexScale: -> + # get current total flex scale of children + total = 0 + total += child.getFlexScale() for child in @children + + needTotal = @children.length + # set every child's flex scale by the ratio + for child in @children + child.setFlexScale(needTotal * child.getFlexScale() / total) + removeChild: (child, replacing=false) -> index = @children.indexOf(child) throw new Error("Removing non-existent child") if index is -1 @@ -75,6 +100,7 @@ class PaneAxis extends Model @unsubscribeFromChild(child) @children.splice(index, 1) + @adjustFlexScale() @emitter.emit 'did-remove-child', {child, index} @reparentLastChild() if not replacing and @children.length < 2 @@ -98,7 +124,9 @@ class PaneAxis extends Model @addChild(newChild, index + 1) reparentLastChild: -> - @parent.replaceChild(this, @children[0]) + lastChild = @children[0] + lastChild.setFlexScale(@flexScale) + @parent.replaceChild(this, lastChild) @destroy() subscribeToChild: (child) -> diff --git a/src/pane-container-element.coffee b/src/pane-container-element.coffee index 65ee9ecea..94b008255 100644 --- a/src/pane-container-element.coffee +++ b/src/pane-container-element.coffee @@ -1,4 +1,5 @@ {CompositeDisposable} = require 'event-kit' +Grim = require 'grim' {callAttachHooks} = require './space-pen-extensions' PaneContainerView = null _ = require 'underscore-plus' @@ -8,12 +9,14 @@ class PaneContainerElement extends HTMLElement createdCallback: -> @subscriptions = new CompositeDisposable @classList.add 'panes' - PaneContainerView ?= require './pane-container-view' - @__spacePenView = new PaneContainerView(this) + + if Grim.includeDeprecatedAPIs + PaneContainerView ?= require './pane-container-view' + @__spacePenView = new PaneContainerView(this) initialize: (@model) -> @subscriptions.add @model.observeRoot(@rootChanged.bind(this)) - @__spacePenView.setModel(@model) + @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs this rootChanged: (root) -> diff --git a/src/pane-container.coffee b/src/pane-container.coffee index 14fa25a44..26ef22cac 100644 --- a/src/pane-container.coffee +++ b/src/pane-container.coffee @@ -1,7 +1,10 @@ {find, flatten} = require 'underscore-plus' -{Model} = require 'theorist' +Grim = require 'grim' {Emitter, CompositeDisposable} = require 'event-kit' Serializable = require 'serializable' +{createGutterView} = require './gutter-component-helpers' +Gutter = require './gutter' +Model = require './model' Pane = require './pane' PaneElement = require './pane-element' PaneContainerElement = require './pane-container-element' @@ -18,19 +21,14 @@ class PaneContainer extends Model @version: 1 - @properties - activePane: null - root: null - @behavior 'activePaneItem', -> - @$activePane - .switch((activePane) -> activePane?.$activeItem) - .distinctUntilChanged() - constructor: (params) -> super + unless Grim.includeDeprecatedAPIs + @activePane = params?.activePane + @emitter = new Emitter @subscriptions = new CompositeDisposable @@ -64,6 +62,7 @@ class PaneContainer extends Model new PaneElement().initialize(model) atom.views.addViewProvider TextEditor, (model) -> new TextEditorElement().initialize(model) + atom.views.addViewProvider(Gutter, createGutterView) onDidChangeRoot: (fn) -> @emitter.on 'did-change-root', fn @@ -151,6 +150,7 @@ class PaneContainer extends Model saveAll: -> pane.saveItems() for pane in @getPanes() + return confirmClose: (options) -> allSaved = true @@ -186,6 +186,7 @@ class PaneContainer extends Model destroyEmptyPanes: -> pane.destroy() for pane in @getPanes() when pane.items.length is 0 + return willDestroyPaneItem: (event) -> @emitter.emit 'will-destroy-pane-item', event @@ -234,3 +235,12 @@ class PaneContainer extends Model removedPaneItem: (item) -> @itemRegistry.removeItem(item) + +if Grim.includeDeprecatedAPIs + PaneContainer.properties + activePane: null + + PaneContainer.behavior 'activePaneItem', -> + @$activePane + .switch((activePane) -> activePane?.$activeItem) + .distinctUntilChanged() diff --git a/src/pane-element.coffee b/src/pane-element.coffee index f626e9fad..15990bcc2 100644 --- a/src/pane-element.coffee +++ b/src/pane-element.coffee @@ -1,6 +1,8 @@ +path = require 'path' {CompositeDisposable} = require 'event-kit' +Grim = require 'grim' {$, callAttachHooks, callRemoveHooks} = require './space-pen-extensions' -PaneView = require './pane-view' +PaneView = null class PaneElement extends HTMLElement attached: false @@ -12,7 +14,7 @@ class PaneElement extends HTMLElement @initializeContent() @subscribeToDOMEvents() - @createSpacePenShim() + @createSpacePenShim() if Grim.includeDeprecatedAPIs attachedCallback: -> @attached = true @@ -37,10 +39,24 @@ class PaneElement extends HTMLElement handleBlur = (event) => @model.blur() unless @contains(event.relatedTarget) + handleDragOver = (event) -> + event.preventDefault() + event.stopPropagation() + + handleDrop = (event) => + event.preventDefault() + event.stopPropagation() + @getModel().activate() + pathsToOpen = Array::map.call event.dataTransfer.files, (file) -> file.path + atom.open({pathsToOpen}) if pathsToOpen.length > 0 + @addEventListener 'focus', handleFocus, true @addEventListener 'blur', handleBlur, true + @addEventListener 'dragover', handleDragOver + @addEventListener 'drop', handleDrop createSpacePenShim: -> + PaneView ?= require './pane-view' @__spacePenView = new PaneView(this) initialize: (@model) -> @@ -49,7 +65,9 @@ class PaneElement extends HTMLElement @subscriptions.add @model.observeActiveItem(@activeItemChanged.bind(this)) @subscriptions.add @model.onDidRemoveItem(@itemRemoved.bind(this)) @subscriptions.add @model.onDidDestroy(@paneDestroyed.bind(this)) - @__spacePenView.setModel(@model) + @subscriptions.add @model.observeFlexScale(@flexScaleChanged.bind(this)) + + @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs this getModel: -> @model @@ -64,11 +82,18 @@ class PaneElement extends HTMLElement @classList.remove('active') activeItemChanged: (item) -> + delete @dataset.activeItemName + delete @dataset.activeItemPath + return unless item? hasFocus = @hasFocus() itemView = atom.views.getView(item) + if itemPath = item.getPath?() + @dataset.activeItemName = path.basename(itemPath) + @dataset.activeItemPath = itemPath + unless @itemViews.contains(itemView) @itemViews.appendChild(itemView) callAttachHooks(itemView) @@ -102,6 +127,9 @@ class PaneElement extends HTMLElement paneDestroyed: -> @subscriptions.dispose() + flexScaleChanged: (flexScale) -> + @style.flexGrow = flexScale + getActiveView: -> atom.views.getView(@model.getActiveItem()) hasFocus: -> diff --git a/src/pane-resize-handle-element.coffee b/src/pane-resize-handle-element.coffee new file mode 100644 index 000000000..836dead52 --- /dev/null +++ b/src/pane-resize-handle-element.coffee @@ -0,0 +1,68 @@ +class PaneResizeHandleElement extends HTMLElement + createdCallback: -> + @resizePane = @resizePane.bind(this) + @resizeStopped = @resizeStopped.bind(this) + @subscribeToDOMEvents() + + subscribeToDOMEvents: -> + @addEventListener 'dblclick', @resizeToFitContent.bind(this) + @addEventListener 'mousedown', @resizeStarted.bind(this) + + attachedCallback: -> + @isHorizontal = @parentElement.classList.contains("horizontal") + @classList.add if @isHorizontal then 'horizontal' else 'vertical' + + detachedCallback: -> + @resizeStopped() + + resizeToFitContent: -> + # clear flex-grow css style of both pane + @previousSibling?.model.setFlexScale(1) + @nextSibling?.model.setFlexScale(1) + + resizeStarted: (e) -> + e.stopPropagation() + document.addEventListener 'mousemove', @resizePane + document.addEventListener 'mouseup', @resizeStopped + + resizeStopped: -> + document.removeEventListener 'mousemove', @resizePane + document.removeEventListener 'mouseup', @resizeStopped + + calcRatio: (ratio1, ratio2, total) -> + allRatio = ratio1 + ratio2 + [total * ratio1 / allRatio, total * ratio2 / allRatio] + + setFlexGrow: (prevSize, nextSize) -> + @prevModel = @previousSibling.model + @nextModel = @nextSibling.model + totalScale = @prevModel.getFlexScale() + @nextModel.getFlexScale() + flexGrows = @calcRatio(prevSize, nextSize, totalScale) + @prevModel.setFlexScale flexGrows[0] + @nextModel.setFlexScale flexGrows[1] + + fixInRange: (val, minValue, maxValue) -> + Math.min(Math.max(val, minValue), maxValue) + + resizePane: ({clientX, clientY, which}) -> + return @resizeStopped() unless which is 1 + return @resizeStopped() unless @previousSibling? and @nextSibling? + + if @isHorizontal + totalWidth = @previousSibling.clientWidth + @nextSibling.clientWidth + #get the left and right width after move the resize view + leftWidth = clientX - @previousSibling.getBoundingClientRect().left + leftWidth = @fixInRange(leftWidth, 0, totalWidth) + rightWidth = totalWidth - leftWidth + # set the flex grow by the ratio of left width and right width + # to change pane width + @setFlexGrow(leftWidth, rightWidth) + else + totalHeight = @previousSibling.clientHeight + @nextSibling.clientHeight + topHeight = clientY - @previousSibling.getBoundingClientRect().top + topHeight = @fixInRange(topHeight, 0, totalHeight) + bottomHeight = totalHeight - topHeight + @setFlexGrow(topHeight, bottomHeight) + +module.exports = PaneResizeHandleElement = +document.registerElement 'atom-pane-resize-handle', prototype: PaneResizeHandleElement.prototype diff --git a/src/pane-view.coffee b/src/pane-view.coffee index 03ca52558..775514ca2 100644 --- a/src/pane-view.coffee +++ b/src/pane-view.coffee @@ -116,19 +116,15 @@ class PaneView extends View if item.onDidChangeTitle? disposable = item.onDidChangeTitle(@activeItemTitleChanged) - deprecate 'Please return a Disposable object from your ::onDidChangeTitle method!' unless disposable?.dispose? @activeItemDisposables.add(disposable) if disposable?.dispose? else if item.on? - deprecate 'If you would like your pane item to support title change behavior, please implement a ::onDidChangeTitle() method. ::on methods for items are no longer supported. If not, ignore this message.' disposable = item.on('title-changed', @activeItemTitleChanged) @activeItemDisposables.add(disposable) if disposable?.dispose? if item.onDidChangeModified? disposable = item.onDidChangeModified(@activeItemModifiedChanged) - deprecate 'Please return a Disposable object from your ::onDidChangeModified method!' unless disposable?.dispose? @activeItemDisposables.add(disposable) if disposable?.dispose? else if item.on? - deprecate 'If you would like your pane item to support modified behavior, please implement a ::onDidChangeModified() method. If not, ignore this message. ::on methods for items are no longer supported.' item.on('modified-status-changed', @activeItemModifiedChanged) @activeItemDisposables.add(disposable) if disposable?.dispose? diff --git a/src/pane.coffee b/src/pane.coffee index 15f6fa0da..477239bdd 100644 --- a/src/pane.coffee +++ b/src/pane.coffee @@ -1,11 +1,10 @@ {find, compact, extend, last} = require 'underscore-plus' -{Model} = require 'theorist' {Emitter} = require 'event-kit' Serializable = require 'serializable' Grim = require 'grim' +Model = require './model' PaneAxis = require './pane-axis' TextEditor = require './text-editor' -PaneView = null # Extended: A container for presenting content in the center of the workspace. # Panes can contain multiple items, one of which is *active* at a given time. @@ -16,40 +15,33 @@ class Pane extends Model atom.deserializers.add(this) Serializable.includeInto(this) - @properties - container: undefined - activeItem: undefined - focused: false - - # Public: Only one pane is considered *active* at a time. A pane is activated - # when it is focused, and when focus returns to the pane container after - # moving to another element such as a panel, it returns to the active pane. - @behavior 'active', -> - @$container - .switch((container) -> container?.$activePane) - .map((activePane) => activePane is this) - .distinctUntilChanged() - constructor: (params) -> super + unless Grim.includeDeprecatedAPIs + @container = params?.container + @activeItem = params?.activeItem + @emitter = new Emitter + @itemSubscriptions = new WeakMap @items = [] @addItems(compact(params?.items ? [])) @setActiveItem(@items[0]) unless @getActiveItem()? + @setFlexScale(params?.flexScale ? 1) # Called by the Serializable mixin during serialization. serializeParams: -> if typeof @activeItem?.getURI is 'function' activeItemURI = @activeItem.getURI() - else if typeof @activeItem?.getUri is 'function' + else if Grim.includeDeprecatedAPIs and typeof @activeItem?.getUri is 'function' activeItemURI = @activeItem.getUri() id: @id items: compact(@items.map((item) -> item.serialize?())) activeItemURI: activeItemURI focused: @focused + flexScale: @flexScale # Called by the Serializable mixin during deserialization. deserializeParams: (params) -> @@ -59,7 +51,7 @@ class Pane extends Model params.activeItem = find params.items, (item) -> if typeof item.getURI is 'function' itemURI = item.getURI() - else if typeof item.getUri is 'function' + else if Grim.includeDeprecatedAPIs and typeof item.getUri is 'function' itemURI = item.getUri() itemURI is activeItemURI @@ -76,10 +68,36 @@ class Pane extends Model @container = container container.didAddPane({pane: this}) + setFlexScale: (@flexScale) -> + @emitter.emit 'did-change-flex-scale', @flexScale + @flexScale + + getFlexScale: -> @flexScale ### Section: Event Subscription ### + # Public: Invoke the given callback when the pane resize + # + # the callback will be invoked when pane's flexScale property changes + # + # * `callback` {Function} to be called when the pane is resized + # + # Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. + onDidChangeFlexScale: (callback) -> + @emitter.on 'did-change-flex-scale', callback + + # Public: Invoke the given callback with all current and future items. + # + # * `callback` {Function} to be called with current and future items. + # * `item` An item that is present in {::getItems} at the time of + # subscription or that is added at some later time. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeFlexScale: (callback) -> + callback(@flexScale) + @onDidChangeFlexScale(callback) + # Public: Invoke the given callback when the pane is activated. # # The given callback will be invoked whenever {::activate} is called on the @@ -202,39 +220,6 @@ class Pane extends Model onWillDestroyItem: (callback) -> @emitter.on 'will-destroy-item', callback - on: (eventName) -> - switch eventName - when 'activated' - Grim.deprecate("Use Pane::onDidActivate instead") - when 'destroyed' - Grim.deprecate("Use Pane::onDidDestroy instead") - when 'item-added' - Grim.deprecate("Use Pane::onDidAddItem instead") - when 'item-removed' - Grim.deprecate("Use Pane::onDidRemoveItem instead") - when 'item-moved' - Grim.deprecate("Use Pane::onDidMoveItem instead") - when 'before-item-destroyed' - Grim.deprecate("Use Pane::onWillDestroyItem instead") - else - Grim.deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") - super - - behavior: (behaviorName) -> - switch behaviorName - when 'active' - Grim.deprecate("The $active behavior property is deprecated. Use ::observeActive or ::onDidChangeActive instead.") - when 'container' - Grim.deprecate("The $container behavior property is deprecated.") - when 'activeItem' - Grim.deprecate("The $activeItem behavior property is deprecated. Use ::observeActiveItem or ::onDidChangeActiveItem instead.") - when 'focused' - Grim.deprecate("The $focused behavior property is deprecated.") - else - Grim.deprecate("Pane::behavior is deprecated. Use event subscription methods instead.") - - super - # Called by the view layer to indicate that the pane has gained focus. focus: -> @focused = true @@ -249,6 +234,10 @@ class Pane extends Model getPanes: -> [this] + unsubscribeFromItem: (item) -> + @itemSubscriptions.get(item)?.dispose() + @itemSubscriptions.delete(item) + ### Section: Items ### @@ -340,11 +329,13 @@ class Pane extends Model addItem: (item, index=@getActiveItemIndex() + 1) -> return if item in @items - if typeof item.on is 'function' + if typeof item.onDidDestroy is 'function' + @itemSubscriptions.set item, item.onDidDestroy => @removeItem(item, true) + else if Grim.includeDeprecatedAPIs and typeof item.on is 'function' @subscribe item, 'destroyed', => @removeItem(item, true) @items.splice(index, 0, item) - @emit 'item-added', item, index + @emit 'item-added', item, index if Grim.includeDeprecatedAPIs @emitter.emit 'did-add-item', {item, index} @setActiveItem(item) unless @getActiveItem()? item @@ -367,8 +358,9 @@ class Pane extends Model index = @items.indexOf(item) return if index is -1 - if typeof item.on is 'function' + if Grim.includeDeprecatedAPIs and typeof item.on is 'function' @unsubscribe item + @unsubscribeFromItem(item) if item is @activeItem if @items.length is 1 @@ -378,7 +370,7 @@ class Pane extends Model else @activatePreviousItem() @items.splice(index, 1) - @emit 'item-removed', item, index, destroyed + @emit 'item-removed', item, index, destroyed if Grim.includeDeprecatedAPIs @emitter.emit 'did-remove-item', {item, index, destroyed} @container?.didDestroyPaneItem({item, index, pane: this}) if destroyed @destroy() if @items.length is 0 and atom.config.get('core.destroyEmptyPanes') @@ -391,7 +383,7 @@ class Pane extends Model oldIndex = @items.indexOf(item) @items.splice(oldIndex, 1) @items.splice(newIndex, 0, item) - @emit 'item-moved', item, newIndex + @emit 'item-moved', item, newIndex if Grim.includeDeprecatedAPIs @emitter.emit 'did-move-item', {item, oldIndex, newIndex} # Public: Move the given item to the given index on another pane. @@ -419,7 +411,7 @@ class Pane extends Model destroyItem: (item) -> index = @items.indexOf(item) if index isnt -1 - @emit 'before-item-destroyed', item + @emit 'before-item-destroyed', item if Grim.includeDeprecatedAPIs @emitter.emit 'will-destroy-item', {item, index} @container?.willDestroyPaneItem({item, index, pane: this}) if @promptToSaveItem(item) @@ -432,10 +424,12 @@ class Pane extends Model # Public: Destroy all items. destroyItems: -> @destroyItem(item) for item in @getItems() + return # Public: Destroy all items except for the active item. destroyInactiveItems: -> @destroyItem(item) for item in @getItems() when item isnt @activeItem + return promptToSaveItem: (item, options={}) -> return true unless item.shouldPromptToSave?(options) @@ -498,8 +492,9 @@ class Pane extends Model saveItemAs: (item, nextAction) -> return unless item?.saveAs? - itemPath = item.getPath?() - newItemPath = atom.showSaveDialogSync(itemPath) + saveOptions = item.getSaveDialogOptions?() ? {} + saveOptions.defaultPath ?= item.getPath() + newItemPath = atom.showSaveDialogSync(saveOptions) if newItemPath try item.saveAs(newItemPath) @@ -510,6 +505,7 @@ class Pane extends Model # Public: Save all items. saveItems: -> @saveItem(item) for item in @getItems() + return # Public: Return the first item that matches the given URI or undefined if # none exists. @@ -524,10 +520,6 @@ class Pane extends Model itemUri is uri - itemForUri: (uri) -> - Grim.deprecate("Use `::itemForURI` instead.") - @itemForURI(uri) - # Public: Activate the first item that matches the given URI. # # Returns a {Boolean} indicating whether an item matching the URI was found. @@ -538,10 +530,6 @@ class Pane extends Model else false - activateItemForUri: (uri) -> - Grim.deprecate("Use `::activateItemForURI` instead.") - @activateItemForURI(uri) - copyActiveItem: -> if @activeItem? @activeItem.copy?() ? atom.deserializers.deserialize(@activeItem.serialize()) @@ -561,7 +549,7 @@ class Pane extends Model throw new Error("Pane has been destroyed") if @isDestroyed() @container?.setActivePane(this) - @emit 'activated' + @emit 'activated' if Grim.includeDeprecatedAPIs @emitter.emit 'did-activate' # Public: Close the pane and destroy all its items. @@ -632,7 +620,8 @@ class Pane extends Model params.items.push(@copyActiveItem()) if @parent.orientation isnt orientation - @parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this]})) + @parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this], @flexScale})) + @setFlexScale(1) newPane = new @constructor(params) switch side @@ -675,7 +664,7 @@ class Pane extends Model true handleSaveError: (error) -> - if error.message.endsWith('is a directory') + if error.code is 'EISDIR' or error.message.endsWith('is a directory') atom.notifications.addWarning("Unable to save file: #{error.message}") else if error.code is 'EACCES' and error.path? atom.notifications.addWarning("Unable to save file: Permission denied '#{error.path}'") @@ -683,8 +672,69 @@ class Pane extends Model atom.notifications.addWarning("Unable to save file '#{error.path}'", detail: error.message) else if error.code is 'EROFS' and error.path? atom.notifications.addWarning("Unable to save file: Read-only file system '#{error.path}'") + else if error.code is 'ENOSPC' and error.path? + atom.notifications.addWarning("Unable to save file: No space left on device '#{error.path}'") + else if error.code is 'ENXIO' and error.path? + atom.notifications.addWarning("Unable to save file: No such device or address '#{error.path}'") else if errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) fileName = errorMatch[1] atom.notifications.addWarning("Unable to save file: A directory in the path '#{fileName}' could not be written to") else throw error + +if Grim.includeDeprecatedAPIs + Pane.properties + container: undefined + activeItem: undefined + focused: false + + Pane.behavior 'active', -> + @$container + .switch((container) -> container?.$activePane) + .map((activePane) => activePane is this) + .distinctUntilChanged() + + Pane::on = (eventName) -> + switch eventName + when 'activated' + Grim.deprecate("Use Pane::onDidActivate instead") + when 'destroyed' + Grim.deprecate("Use Pane::onDidDestroy instead") + when 'item-added' + Grim.deprecate("Use Pane::onDidAddItem instead") + when 'item-removed' + Grim.deprecate("Use Pane::onDidRemoveItem instead") + when 'item-moved' + Grim.deprecate("Use Pane::onDidMoveItem instead") + when 'before-item-destroyed' + Grim.deprecate("Use Pane::onWillDestroyItem instead") + else + Grim.deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") + super + + Pane::behavior = (behaviorName) -> + switch behaviorName + when 'active' + Grim.deprecate("The $active behavior property is deprecated. Use ::observeActive or ::onDidChangeActive instead.") + when 'container' + Grim.deprecate("The $container behavior property is deprecated.") + when 'activeItem' + Grim.deprecate("The $activeItem behavior property is deprecated. Use ::observeActiveItem or ::onDidChangeActiveItem instead.") + when 'focused' + Grim.deprecate("The $focused behavior property is deprecated.") + else + Grim.deprecate("Pane::behavior is deprecated. Use event subscription methods instead.") + + super + + Pane::itemForUri = (uri) -> + Grim.deprecate("Use `::itemForURI` instead.") + @itemForURI(uri) + + Pane::activateItemForUri = (uri) -> + Grim.deprecate("Use `::activateItemForURI` instead.") + @activateItemForURI(uri) +else + Pane::container = undefined + Pane::activeItem = undefined + Pane::focused = undefined diff --git a/src/project.coffee b/src/project.coffee index c92462ead..b21e74076 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -4,15 +4,14 @@ url = require 'url' _ = require 'underscore-plus' fs = require 'fs-plus' Q = require 'q' -{deprecate} = require 'grim' -{Model} = require 'theorist' -{Subscriber} = require 'emissary' +{includeDeprecatedAPIs, deprecate} = require 'grim' {Emitter} = require 'event-kit' -DefaultDirectoryProvider = require './default-directory-provider' Serializable = require 'serializable' TextBuffer = require 'text-buffer' Grim = require 'grim' +DefaultDirectoryProvider = require './default-directory-provider' +Model = require './model' TextEditor = require './text-editor' Task = require './task' GitRepositoryProvider = require './git-repository-provider' @@ -25,12 +24,6 @@ class Project extends Model atom.deserializers.add(this) Serializable.includeInto(this) - @pathForRepositoryUrl: (repoUrl) -> - deprecate '::pathForRepositoryUrl will be removed. Please remove from your code.' - [repoName] = url.parse(repoUrl).path.split('/')[-1..] - repoName = repoName.replace(/\.git$/, '') - path.join(atom.config.get('core.projectHome'), repoName) - ### Section: Construction and Destruction ### @@ -53,7 +46,7 @@ class Project extends Model # to either a {Repository} or null. Ideally, the {Directory} would be used # as the key; however, there can be multiple {Directory} objects created for # the same real path, so it is not a good key. - @repositoryPromisesByPath = new Map(); + @repositoryPromisesByPath = new Map() # Note that the GitRepositoryProvider is registered synchronously so that # it is available immediately on startup. @@ -73,7 +66,9 @@ class Project extends Model @subscribeToBuffer(buffer) for buffer in @buffers - Grim.deprecate("Pass 'paths' array instead of 'path' to project constructor") if path? + if Grim.includeDeprecatedAPIs and path? + Grim.deprecate("Pass 'paths' array instead of 'path' to project constructor") + paths ?= _.compact([path]) @setPaths(paths) @@ -83,6 +78,7 @@ class Project extends Model destroyUnretainedBuffers: -> buffer.destroy() for buffer in @getBuffers() when not buffer.isRetained() + return ### Section: Serialization @@ -109,13 +105,17 @@ class Project extends Model 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 - on: (eventName) -> - if eventName is 'path-changed' - Grim.deprecate("Use Project::onDidChangePaths instead") - super + onDidAddBuffer: (callback) -> + @emitter.on 'did-add-buffer', callback ### Section: Accessing the git repository @@ -128,13 +128,10 @@ class Project extends Model # Prefer the following, which evaluates to a {Promise} that resolves to an # {Array} of {Repository} objects: # ``` - # Promise.all(project.getDirectories().map( - # project.repositoryForDirectory.bind(project))) + # Promise.all(atom.project.getDirectories().map( + # atom.project.repositoryForDirectory.bind(atom.project))) # ``` getRepositories: -> @repositories - getRepo: -> - Grim.deprecate("Use ::getRepositories instead") - @getRepositories()[0] # Public: Get the repository for a given directory asynchronously. # @@ -168,28 +165,23 @@ class Project extends Model # Public: Get an {Array} of {String}s containing the paths of the project's # directories. getPaths: -> rootDirectory.getPath() for rootDirectory in @rootDirectories - getPath: -> - Grim.deprecate("Use ::getPaths instead") - @getPaths()[0] # Public: Set the paths of the project's directories. # # * `projectPaths` {Array} of {String} paths. setPaths: (projectPaths) -> - rootDirectory.off() for rootDirectory in @rootDirectories + if includeDeprecatedAPIs + rootDirectory.off() for rootDirectory in @rootDirectories + repository?.destroy() for repository in @repositories @rootDirectories = [] @repositories = [] @addPath(projectPath, emitEvent: false) for projectPath in projectPaths - @emit "path-changed" + @emit "path-changed" if includeDeprecatedAPIs @emitter.emit 'did-change-paths', projectPaths - setPath: (path) -> - Grim.deprecate("Use ::setPaths instead") - @setPaths([path]) - # Public: Add a path to the project's list of root paths # # * `projectPath` {String} The path to the directory to add. @@ -214,14 +206,16 @@ class Project extends Model @repositories.push(repo ? null) unless options?.emitEvent is false - @emit "path-changed" + @emit "path-changed" if includeDeprecatedAPIs @emitter.emit 'did-change-paths', @getPaths() # Public: remove a path from the project's list of root paths. # # * `projectPath` {String} The path to remove. removePath: (projectPath) -> - projectPath = path.normalize(projectPath) + # The projectPath may be a URI, in which case it should not be normalized. + unless projectPath in @getPaths() + projectPath = path.normalize(projectPath) indexToRemove = null for directory, i in @rootDirectories @@ -232,9 +226,9 @@ class Project extends Model if indexToRemove? [removedDirectory] = @rootDirectories.splice(indexToRemove, 1) [removedRepository] = @repositories.splice(indexToRemove, 1) - removedDirectory.off() + removedDirectory.off() if includeDeprecatedAPIs removedRepository?.destroy() unless removedRepository in @repositories - @emit "path-changed" + @emit "path-changed" if includeDeprecatedAPIs @emitter.emit "did-change-paths", @getPaths() true else @@ -243,13 +237,6 @@ class Project extends Model # Public: Get an {Array} of {Directory}s associated with this project. getDirectories: -> @rootDirectories - getRootDirectory: -> - Grim.deprecate("Use ::getDirectories instead") - @getDirectories()[0] - - resolve: (uri) -> - Grim.deprecate("Use `Project::getDirectories()[0]?.resolve()` instead") - @resolvePath(uri) resolvePath: (uri) -> return unless uri @@ -280,7 +267,6 @@ class Project extends Model # * `relativePath` {String} The relative path from the project directory to # the given path. relativizePath: (fullPath) -> - return fullPath if fullPath?.match(/[A-Za-z0-9+-.]+:\/\//) # leave path alone if it has a scheme for rootDirectory in @rootDirectories relativePath = rootDirectory.relativize(fullPath) return [rootDirectory.getPath(), relativePath] unless relativePath is fullPath @@ -316,18 +302,6 @@ class Project extends Model contains: (pathToCheck) -> @rootDirectories.some (dir) -> dir.contains(pathToCheck) - ### - Section: Searching and Replacing - ### - - scan: (regex, options={}, iterator) -> - Grim.deprecate("Use atom.workspace.scan instead of atom.project.scan") - atom.workspace.scan(regex, options, iterator) - - replace: (regex, replacementText, filePaths, iterator) -> - Grim.deprecate("Use atom.workspace.replace instead of atom.project.replace") - atom.workspace.replace(regex, replacementText, filePaths, iterator) - ### Section: Private ### @@ -349,14 +323,22 @@ class Project extends Model # allow ENOENT errors to create an editor for paths that dont exist throw error unless error.code is 'ENOENT' - @bufferForPath(filePath).then (buffer) => - @buildEditorForBuffer(buffer, options) + absoluteFilePath = @resolvePath(filePath) - # Deprecated - openSync: (filePath, options={}) -> - deprecate("Use Project::open instead") - filePath = @resolvePath(filePath) - @buildEditorForBuffer(@bufferForPathSync(filePath), options) + fileSize = fs.getSizeSync(absoluteFilePath) + + if fileSize >= 20 * 1048576 # 20MB + choice = atom.confirm + message: 'Atom will be unresponsive during the loading of very large files.' + detailedMessage: "Do you still want to load this file?" + buttons: ["Proceed", "Cancel"] + if choice is 1 + error = new Error + error.code = 'CANCELLED' + throw error + + @bufferForPath(absoluteFilePath).then (buffer) => + @buildEditorForBuffer(buffer, _.extend({fileSize}, options)) # Retrieves all the {TextBuffer}s in the project; that is, the # buffers for all open files. @@ -370,7 +352,7 @@ class Project extends Model @findBufferForPath(@resolvePath(filePath))?.isModified() findBufferForPath: (filePath) -> - _.find @buffers, (buffer) -> buffer.getPath() == filePath + _.find @buffers, (buffer) -> buffer.getPath() is filePath # Only to be used in specs bufferForPathSync: (filePath) -> @@ -386,8 +368,7 @@ class Project extends Model # * `filePath` A {String} representing a path. If `null`, an "Untitled" buffer is created. # # Returns a promise that resolves to the {TextBuffer}. - bufferForPath: (filePath) -> - absoluteFilePath = @resolvePath(filePath) + bufferForPath: (absoluteFilePath) -> existingBuffer = @findBufferForPath(absoluteFilePath) if absoluteFilePath Q(existingBuffer ? @buildBuffer(absoluteFilePath)) @@ -408,11 +389,6 @@ class Project extends Model # # Returns a promise that resolves to the {TextBuffer}. buildBuffer: (absoluteFilePath) -> - if fs.getSizeSync(absoluteFilePath) >= 2 * 1048576 # 2MB - error = new Error("Atom can only handle files < 2MB for now.") - error.code = 'EFILETOOLARGE' - throw error - buffer = new TextBuffer({filePath: absoluteFilePath}) @addBuffer(buffer) buffer.load() @@ -426,7 +402,8 @@ class Project extends Model addBufferAtIndex: (buffer, index, options={}) -> @buffers.splice(index, 0, buffer) @subscribeToBuffer(buffer) - @emit 'buffer-created', buffer + @emit 'buffer-created', buffer if includeDeprecatedAPIs + @emitter.emit 'did-add-buffer', buffer buffer # Removes a {TextBuffer} association from the project. @@ -441,7 +418,8 @@ class Project extends Model buffer?.destroy() buildEditorForBuffer: (buffer, editorOptions) -> - editor = new TextEditor(_.extend({buffer, registerEditor: true}, editorOptions)) + largeFileMode = editorOptions.fileSize >= 2 * 1048576 # 2MB + editor = new TextEditor(_.extend({buffer, largeFileMode, registerEditor: true}, editorOptions)) editor eachBuffer: (args...) -> @@ -465,22 +443,65 @@ class Project extends Model detail: error.message dismissable: true - # Deprecated: delegate - registerOpener: (opener) -> - deprecate("Use Workspace::addOpener instead") - atom.workspace.registerOpener(opener) +if includeDeprecatedAPIs + Project.pathForRepositoryUrl = (repoUrl) -> + deprecate '::pathForRepositoryUrl will be removed. Please remove from your code.' + [repoName] = url.parse(repoUrl).path.split('/')[-1..] + repoName = repoName.replace(/\.git$/, '') + path.join(atom.config.get('core.projectHome'), repoName) - # Deprecated: delegate - unregisterOpener: (opener) -> + Project::registerOpener = (opener) -> + deprecate("Use Workspace::addOpener instead") + atom.workspace.addOpener(opener) + + Project::unregisterOpener = (opener) -> deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") atom.workspace.unregisterOpener(opener) - # Deprecated: delegate - eachEditor: (callback) -> - deprecate("Use Workspace::eachEditor instead") - atom.workspace.eachEditor(callback) + Project::eachEditor = (callback) -> + deprecate("Use Workspace::observeTextEditors instead") + atom.workspace.observeTextEditors(callback) - # Deprecated: delegate - getEditors: -> - deprecate("Use Workspace::getEditors instead") - atom.workspace.getEditors() + Project::getEditors = -> + deprecate("Use Workspace::getTextEditors instead") + atom.workspace.getTextEditors() + + Project::on = (eventName) -> + if eventName is 'path-changed' + Grim.deprecate("Use Project::onDidChangePaths instead") + else + Grim.deprecate("Project::on is deprecated. Use documented event subscription methods instead.") + super + + Project::getRepo = -> + Grim.deprecate("Use ::getRepositories instead") + @getRepositories()[0] + + Project::getPath = -> + Grim.deprecate("Use ::getPaths instead") + @getPaths()[0] + + Project::setPath = (path) -> + Grim.deprecate("Use ::setPaths instead") + @setPaths([path]) + + Project::getRootDirectory = -> + Grim.deprecate("Use ::getDirectories instead") + @getDirectories()[0] + + Project::resolve = (uri) -> + Grim.deprecate("Use `Project::getDirectories()[0]?.resolve()` instead") + @resolvePath(uri) + + Project::scan = (regex, options={}, iterator) -> + Grim.deprecate("Use atom.workspace.scan instead of atom.project.scan") + atom.workspace.scan(regex, options, iterator) + + Project::replace = (regex, replacementText, filePaths, iterator) -> + Grim.deprecate("Use atom.workspace.replace instead of atom.project.replace") + atom.workspace.replace(regex, replacementText, filePaths, iterator) + + Project::openSync = (filePath, options={}) -> + deprecate("Use Project::open instead") + filePath = @resolvePath(filePath) + @buildEditorForBuffer(@bufferForPathSync(filePath), options) diff --git a/src/row-map.coffee b/src/row-map.coffee index fd5dfb2ea..5510c1421 100644 --- a/src/row-map.coffee +++ b/src/row-map.coffee @@ -112,6 +112,7 @@ class RowMap @regions.splice index - 1, 2, bufferRows: leftRegion.bufferRows + rightRegion.bufferRows screenRows: leftRegion.screenRows + rightRegion.screenRows + return # Public: Returns an array of strings describing the map's regions. inspect: -> diff --git a/src/safe-clipboard.coffee b/src/safe-clipboard.coffee new file mode 100644 index 000000000..8301f9d54 --- /dev/null +++ b/src/safe-clipboard.coffee @@ -0,0 +1,6 @@ +# Using clipboard in renderer process is not safe on Linux. +module.exports = + if process.platform is 'linux' and process.type is 'renderer' + require('remote').require('clipboard') + else + require('clipboard') diff --git a/src/scan-handler.coffee b/src/scan-handler.coffee index 71917cc6e..74e15d930 100644 --- a/src/scan-handler.coffee +++ b/src/scan-handler.coffee @@ -34,7 +34,7 @@ module.exports = (rootPaths, regexSource, options) -> scanner.on 'path-found', -> pathsSearched++ - if pathsSearched % PATHS_COUNTER_SEARCHED_CHUNK == 0 + if pathsSearched % PATHS_COUNTER_SEARCHED_CHUNK is 0 emit('scan:paths-searched', pathsSearched) search regex, scanner, searcher, -> diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee index 5035810d6..7940cc630 100644 --- a/src/scope-descriptor.coffee +++ b/src/scope-descriptor.coffee @@ -15,7 +15,7 @@ # specific position in the buffer. # * {Cursor::getScopeDescriptor} to get a cursor's descriptor based on position. # -# See the [scopes and scope descriptor guide](https://atom.io/docs/latest/advanced/scopes-and-scope-descriptors) +# See the [scopes and scope descriptor guide](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) # for more information. module.exports = class ScopeDescriptor @@ -44,3 +44,6 @@ class ScopeDescriptor scope = ".#{scope}" unless scope[0] is '.' scope .join(' ') + + toString: -> + @getScopeChain() diff --git a/src/scrollbar-component.coffee b/src/scrollbar-component.coffee index 3a63a33ed..45cfbd240 100644 --- a/src/scrollbar-component.coffee +++ b/src/scrollbar-component.coffee @@ -12,6 +12,9 @@ class ScrollbarComponent @domNode.addEventListener 'scroll', @onScrollCallback + getDomNode: -> + @domNode + updateSync: (state) -> @oldState ?= {} switch @orientation diff --git a/src/scrollbar-corner-component.coffee b/src/scrollbar-corner-component.coffee index c0fbdfd60..bc059f12c 100644 --- a/src/scrollbar-corner-component.coffee +++ b/src/scrollbar-corner-component.coffee @@ -1,12 +1,15 @@ module.exports = class ScrollbarCornerComponent - constructor: () -> + constructor: -> @domNode = document.createElement('div') @domNode.classList.add('scrollbar-corner') @contentNode = document.createElement('div') @domNode.appendChild(@contentNode) + getDomNode: -> + @domNode + updateSync: (state) -> @oldState ?= {} @newState ?= {} diff --git a/src/selection.coffee b/src/selection.coffee index a1385dffb..0fcecbffe 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -1,8 +1,8 @@ {Point, Range} = require 'text-buffer' -{Model} = require 'theorist' -{pick} = require 'underscore-plus' +{pick} = _ = require 'underscore-plus' {Emitter} = require 'event-kit' Grim = require 'grim' +Model = require './model' NonWhitespaceRegExp = /\S/ @@ -14,7 +14,6 @@ class Selection extends Model editor: null initialScreenRange: null wordwise: false - needsAutoscroll: null constructor: ({@cursor, @marker, @editor, id}) -> @emitter = new Emitter @@ -28,13 +27,16 @@ class Selection extends Model unless @editor.isDestroyed() @destroyed = true @editor.removeSelection(this) - @emit 'destroyed' + @emit 'destroyed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-destroy' @emitter.dispose() destroy: -> @marker.destroy() + isLastSelection: -> + this is @editor.getLastSelection() + ### Section: Event Subscription ### @@ -61,16 +63,6 @@ class Selection extends Model onDidDestroy: (callback) -> @emitter.on 'did-destroy', callback - on: (eventName) -> - switch eventName - when 'screen-range-changed' - Grim.deprecate("Use Selection::onDidChangeRange instead. Call ::getScreenRange() yourself in your callback if you need the range.") - when 'destroyed' - Grim.deprecate("Use Selection::onDidDestroy instead.") - - super - - ### Section: Managing the selection range ### @@ -92,21 +84,22 @@ class Selection extends Model # Public: Modifies the buffer {Range} for the selection. # - # * `screenRange` The new {Range} to select. + # * `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` if `true`, the {TextEditor} scrolls to the new selection. + # * `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) - @needsAutoscroll = options.autoscroll options.reversed ?= @isReversed() - @editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds + @editor.destroyFoldsContainingBufferRange(bufferRange) unless options.preserveFolds @modifySelection => needsFlash = options.flash delete options.flash if options.flash? - @cursor.needsAutoscroll = false if @needsAutoscroll? @marker.setBufferRange(bufferRange, options) - @autoscroll() if @needsAutoscroll and @editor.manageScrollPosition + @autoscroll() if options?.autoscroll ? @isLastSelection() @decoration.flash('flash', @editor.selectionFlashDuration) if needsFlash # Public: Returns the starting and ending buffer rows the selection is @@ -117,7 +110,7 @@ class Selection extends Model range = @getBufferRange() start = range.start.row end = range.end.row - end = Math.max(start, end - 1) if range.end.column == 0 + end = Math.max(start, end - 1) if range.end.column is 0 [start, end] getTailScreenPosition: -> @@ -182,9 +175,15 @@ class Selection extends Model ### # Public: Clears the selection, moving the marker to the head. - clear: -> - @marker.setProperties(goalBufferRange: null) + # + # * `options` (optional) {Object} with the following keys: + # * `autoscroll` {Boolean} indicating whether to autoscroll to the new + # range. Defaults to `true` if this is the most recently added selection, + # `false` otherwise. + clear: (options) -> + @marker.setProperties(goalScreenRange: null) @marker.clearTail() unless @retainSelection + @autoscroll() if options?.autoscroll ? @isLastSelection() @finalize() # Public: Selects the text from the current cursor position to a given screen @@ -365,7 +364,6 @@ class Selection extends Model @editor.unfoldBufferRow(oldBufferRange.end.row) wasReversed = @isReversed() @clear() - @cursor.needsAutoscroll = @cursor.isLastCursor() autoIndentFirstLine = false precedingText = @editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start]) @@ -376,7 +374,7 @@ class Selection extends Model indentAdjustment = @editor.indentLevelForLine(precedingText) - options.indentBasis @adjustIndent(remainingLines, indentAdjustment) - if options.autoIndent and not NonWhitespaceRegExp.test(precedingText) + if options.autoIndent and not NonWhitespaceRegExp.test(precedingText) and remainingLines.length > 0 autoIndentFirstLine = true firstLine = precedingText + firstInsertedLine desiredIndentLevel = @editor.languageMode.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine) @@ -391,12 +389,12 @@ class Selection extends Model if options.select @setBufferRange(newBufferRange, reversed: wasReversed) else - @cursor.setBufferPosition(newBufferRange.end, skipAtomicTokens: true) if wasReversed + @cursor.setBufferPosition(newBufferRange.end, clip: 'forward') if wasReversed if autoIndentFirstLine @editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel) - if options.autoIndentNewline and text == '\n' + if options.autoIndentNewline and text is '\n' currentIndentation = @editor.indentationForBufferRow(newBufferRange.start.row) @editor.autoIndentBufferRow(newBufferRange.end.row, preserveLeadingWhitespace: true, skipBlankLines: false) if @editor.indentationForBufferRow(newBufferRange.end.row) < currentIndentation @@ -404,6 +402,8 @@ class Selection extends Model else if options.autoDecreaseIndent and NonWhitespaceRegExp.test(text) @editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row) + @autoscroll() if @isLastSelection() + newBufferRange # Public: Removes the first character before the selection if the selection @@ -412,15 +412,19 @@ class Selection extends Model @selectLeft() if @isEmpty() and not @editor.isFoldedAtScreenRow(@cursor.getScreenRow()) @deleteSelectedText() - # Deprecated: Use {::deleteToBeginningOfWord} instead. - backspaceToBeginningOfWord: -> - deprecate("Use Selection::deleteToBeginningOfWord() instead") - @deleteToBeginningOfWord() + # 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() - # Deprecated: Use {::deleteToBeginningOfLine} instead. - backspaceToBeginningOfLine: -> - deprecate("Use Selection::deleteToBeginningOfLine() instead") - @deleteToBeginningOfLine() + # 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. @@ -556,6 +560,7 @@ class Selection extends Model 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. @@ -630,14 +635,15 @@ class Selection extends Model # of levels. Leaves the first line unchanged. adjustIndent: (lines, indentAdjustment) -> for line, i in lines - if indentAdjustment == 0 or line is '' + 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)) + lines[i] = line.replace(/^(\t+| +)/, @editor.buildIndentString(indentLevel)) + return # Indent the current line(s). # @@ -648,8 +654,8 @@ class Selection extends Model # * `options` (optional) {Object} with the keys: # * `autoIndent` If `true`, the line is indented to an automatically-inferred # level. Otherwise, {TextEditor::getTabText} is inserted. - indent: ({ autoIndent }={}) -> - { row, column } = @cursor.getBufferPosition() + indent: ({autoIndent}={}) -> + {row, column} = @cursor.getBufferPosition() if @isEmpty() @cursor.skipLeadingWhitespace() @@ -668,7 +674,8 @@ class Selection extends Model indentSelectedRows: -> [start, end] = @getBufferRowRange() for row in [start..end] - @editor.buffer.insert([row, 0], @editor.getTabText()) unless @editor.buffer.lineLengthForRow(row) == 0 + @editor.buffer.insert([row, 0], @editor.getTabText()) unless @editor.buffer.lineLengthForRow(row) is 0 + return ### Section: Managing multiple selections @@ -676,53 +683,59 @@ class Selection extends Model # Public: Moves the selection down one row. addSelectionBelow: -> - range = (@getGoalBufferRange() ? @getBufferRange()).copy() + range = (@getGoalScreenRange() ? @getScreenRange()).copy() nextRow = range.end.row + 1 - for row in [nextRow..@editor.getLastBufferRow()] + for row in [nextRow..@editor.getLastScreenRow()] range.start.row = row range.end.row = row - clippedRange = @editor.clipBufferRange(range) + clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) if range.isEmpty() continue if range.end.column > 0 and clippedRange.end.column is 0 else continue if clippedRange.isEmpty() - @editor.addSelectionForBufferRange(range, goalBufferRange: range) + @editor.addSelectionForScreenRange(clippedRange, goalScreenRange: range) break + return + # Public: Moves the selection up one row. addSelectionAbove: -> - range = (@getGoalBufferRange() ? @getBufferRange()).copy() + range = (@getGoalScreenRange() ? @getScreenRange()).copy() previousRow = range.end.row - 1 for row in [previousRow..0] range.start.row = row range.end.row = row - clippedRange = @editor.clipBufferRange(range) + clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true) if range.isEmpty() continue if range.end.column > 0 and clippedRange.end.column is 0 else continue if clippedRange.isEmpty() - @editor.addSelectionForBufferRange(range, goalBufferRange: range) + @editor.addSelectionForScreenRange(clippedRange, goalScreenRange: range) break + return + # Public: Combines the given selection into this selection and then destroys # the given selection. # # * `otherSelection` A {Selection} to merge with. # * `options` (optional) {Object} options matching those found in {::setBufferRange}. merge: (otherSelection, options) -> - myGoalBufferRange = @getGoalBufferRange() - otherGoalBufferRange = otherSelection.getGoalBufferRange() - if myGoalBufferRange? and otherGoalBufferRange? - options.goalBufferRange = myGoalBufferRange.union(otherGoalBufferRange) + myGoalScreenRange = @getGoalScreenRange() + otherGoalScreenRange = otherSelection.getGoalScreenRange() + + if myGoalScreenRange? and otherGoalScreenRange? + options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange) else - options.goalBufferRange = myGoalBufferRange ? otherGoalBufferRange - @setBufferRange(@getBufferRange().union(otherSelection.getBufferRange()), options) + options.goalScreenRange = myGoalScreenRange ? otherGoalScreenRange + + @setBufferRange(@getBufferRange().union(otherSelection.getBufferRange()), _.extend(autoscroll: false, options)) otherSelection.destroy() ### @@ -753,7 +766,7 @@ class Selection extends Model newScreenRange: @getScreenRange() selection: this - @emit 'screen-range-changed', @getScreenRange() # old event + @emit 'screen-range-changed', @getScreenRange() if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-range' @editor.selectionRangeChanged(eventObject) @@ -764,10 +777,12 @@ class Selection extends Model @linewise = false autoscroll: -> - @editor.scrollToScreenRange(@getScreenRange()) + if @marker.hasTail() + @editor.scrollToScreenRange(@getScreenRange(), reversed: @isReversed()) + else + @cursor.autoscroll() clearAutoscroll: -> - @needsAutoscroll = null modifySelection: (fn) -> @retainSelection = true @@ -783,6 +798,28 @@ class Selection extends Model plantTail: -> @marker.plantTail() - getGoalBufferRange: -> - if goalBufferRange = @marker.getProperties().goalBufferRange - Range.fromObject(goalBufferRange) + getGoalScreenRange: -> + if goalScreenRange = @marker.getProperties().goalScreenRange + Range.fromObject(goalScreenRange) + +if Grim.includeDeprecatedAPIs + Selection::on = (eventName) -> + switch eventName + when 'screen-range-changed' + Grim.deprecate("Use Selection::onDidChangeRange instead. Call ::getScreenRange() yourself in your callback if you need the range.") + when 'destroyed' + Grim.deprecate("Use Selection::onDidDestroy instead.") + else + Grim.deprecate("Selection::on is deprecated. Use documented event subscription methods instead.") + + super + + # Deprecated: Use {::deleteToBeginningOfWord} instead. + Selection::backspaceToBeginningOfWord = -> + deprecate("Use Selection::deleteToBeginningOfWord() instead") + @deleteToBeginningOfWord() + + # Deprecated: Use {::deleteToBeginningOfLine} instead. + Selection::backspaceToBeginningOfLine = -> + deprecate("Use Selection::deleteToBeginningOfLine() instead") + @deleteToBeginningOfLine() diff --git a/src/special-token-symbols.coffee b/src/special-token-symbols.coffee new file mode 100644 index 000000000..06884b85f --- /dev/null +++ b/src/special-token-symbols.coffee @@ -0,0 +1,6 @@ +module.exports = { + SoftTab: Symbol('SoftTab') + HardTab: Symbol('HardTab') + PairedCharacter: Symbol('PairedCharacter') + SoftWrapIndent: Symbol('SoftWrapIndent') +} diff --git a/src/storage-folder.coffee b/src/storage-folder.coffee new file mode 100644 index 000000000..bf969dee2 --- /dev/null +++ b/src/storage-folder.coffee @@ -0,0 +1,27 @@ +path = require "path" +fs = require "fs-plus" + +module.exports = +class StorageFolder + constructor: (containingPath) -> + @path = path.join(containingPath, "storage") + + store: (name, object) -> + fs.writeFileSync(@pathForKey(name), JSON.stringify(object), 'utf8') + + load: (name) -> + statePath = @pathForKey(name) + try + stateString = fs.readFileSync(statePath, 'utf8') + catch error + unless error.code is 'ENOENT' + console.warn "Error reading state file: #{statePath}", error.stack, error + return undefined + + try + JSON.parse(stateString) + catch error + console.warn "Error parsing state file: #{statePath}", error.stack, error + + pathForKey: (name) -> path.join(@getPath(), name) + getPath: -> @path diff --git a/src/style-manager.coffee b/src/style-manager.coffee index c891223b9..cfe86b3fe 100644 --- a/src/style-manager.coffee +++ b/src/style-manager.coffee @@ -152,6 +152,8 @@ class StyleManager for styleElement in styleElementsToRestore @addStyleElement(styleElement) unless styleElement in existingStyleElements + return + ### Section: Paths ### diff --git a/src/styles-element.coffee b/src/styles-element.coffee index d333a2e45..fc3b888cf 100644 --- a/src/styles-element.coffee +++ b/src/styles-element.coffee @@ -46,6 +46,7 @@ class StylesElement extends HTMLElement @styleElementRemoved(child) for child in Array::slice.call(@children) @context = @getAttribute('context') @styleElementAdded(styleElement) for styleElement in atom.styles.getStyleElements() + return styleElementAdded: (styleElement) -> return unless @styleElementMatchesContext(styleElement) diff --git a/src/task.coffee b/src/task.coffee index 9572494b8..34c943c6a 100644 --- a/src/task.coffee +++ b/src/task.coffee @@ -83,7 +83,7 @@ class Task taskPath = taskPath.replace(/\\/g, "\\\\") env = _.extend({}, process.env, {taskPath, userAgent: navigator.userAgent}) - @childProcess = fork '--eval', [bootstrap], {env, cwd: __dirname} + @childProcess = fork '--eval', [bootstrap], {env, silent: true} @on "task:log", -> console.log(arguments...) @on "task:warn", -> console.warn(arguments...) @@ -101,6 +101,12 @@ class Task @childProcess.on 'message', ({event, args}) => @emit(event, args...) if @childProcess? + # Catch the errors that happened before task-bootstrap. + @childProcess.stdout.removeAllListeners() + @childProcess.stdout.on 'data', (data) -> console.log data.toString() + @childProcess.stderr.removeAllListeners() + @childProcess.stderr.on 'data', (data) -> console.error data.toString() + # Public: Starts the task. # # Throws an error if this task has already been terminated or if sending a @@ -144,10 +150,18 @@ class Task # # No more events are emitted once this method is called. terminate: -> - return unless @childProcess? + return false unless @childProcess? @childProcess.removeAllListeners() + @childProcess.stdout.removeAllListeners() + @childProcess.stderr.removeAllListeners() @childProcess.kill() @childProcess = null - undefined + true + + cancel: -> + didForcefullyTerminate = @terminate() + if didForcefullyTerminate + @emit('task:cancelled') + didForcefullyTerminate diff --git a/src/text-editor-component.coffee b/src/text-editor-component.coffee index 9536bcaad..45591c195 100644 --- a/src/text-editor-component.coffee +++ b/src/text-editor-component.coffee @@ -6,18 +6,19 @@ grim = require 'grim' ipc = require 'ipc' TextEditorPresenter = require './text-editor-presenter' -GutterComponent = require './gutter-component' +GutterContainerComponent = require './gutter-container-component' InputComponent = require './input-component' LinesComponent = require './lines-component' ScrollbarComponent = require './scrollbar-component' ScrollbarCornerComponent = require './scrollbar-corner-component' +OverlayManager = require './overlay-manager' module.exports = class TextEditorComponent scrollSensitivity: 0.4 cursorBlinkPeriod: 800 cursorBlinkResumeDelay: 100 - lineOverdrawMargin: 15 + tileSize: 12 pendingScrollTop: null pendingScrollLeft: null @@ -35,11 +36,10 @@ class TextEditorComponent gutterComponent: null mounted: true - constructor: ({@editor, @hostElement, @rootElement, @stylesElement, @useShadowDOM, lineOverdrawMargin}) -> - @lineOverdrawMargin = lineOverdrawMargin if lineOverdrawMargin? + constructor: ({@editor, @hostElement, @rootElement, @stylesElement, @useShadowDOM, tileSize}) -> + @tileSize = tileSize if tileSize? @disposables = new CompositeDisposable - @editor.manageScrollPosition = true @observeConfig() @setScrollSensitivity(atom.config.get('editor.scrollSensitivity')) @@ -47,7 +47,7 @@ class TextEditorComponent model: @editor scrollTop: @editor.getScrollTop() scrollLeft: @editor.getScrollLeft() - lineOverdrawMargin: lineOverdrawMargin + tileSize: tileSize cursorBlinkPeriod: @cursorBlinkPeriod cursorBlinkResumeDelay: @cursorBlinkResumeDelay stoppedScrollingDelay: 200 @@ -57,29 +57,35 @@ class TextEditorComponent @domNode = document.createElement('div') if @useShadowDOM @domNode.classList.add('editor-contents--private') + + insertionPoint = document.createElement('content') + insertionPoint.setAttribute('select', 'atom-overlay') + @domNode.appendChild(insertionPoint) + @overlayManager = new OverlayManager(@presenter, @hostElement) else @domNode.classList.add('editor-contents') + @overlayManager = new OverlayManager(@presenter, @domNode) @scrollViewNode = document.createElement('div') @scrollViewNode.classList.add('scroll-view') @domNode.appendChild(@scrollViewNode) - @mountGutterComponent() if @presenter.getState().gutter.visible + @mountGutterContainerComponent() if @presenter.getState().gutters.length @hiddenInputComponent = new InputComponent - @scrollViewNode.appendChild(@hiddenInputComponent.domNode) + @scrollViewNode.appendChild(@hiddenInputComponent.getDomNode()) @linesComponent = new LinesComponent({@presenter, @hostElement, @useShadowDOM}) - @scrollViewNode.appendChild(@linesComponent.domNode) + @scrollViewNode.appendChild(@linesComponent.getDomNode()) @horizontalScrollbarComponent = new ScrollbarComponent({orientation: 'horizontal', onScroll: @onHorizontalScroll}) - @scrollViewNode.appendChild(@horizontalScrollbarComponent.domNode) + @scrollViewNode.appendChild(@horizontalScrollbarComponent.getDomNode()) @verticalScrollbarComponent = new ScrollbarComponent({orientation: 'vertical', onScroll: @onVerticalScroll}) - @domNode.appendChild(@verticalScrollbarComponent.domNode) + @domNode.appendChild(@verticalScrollbarComponent.getDomNode()) @scrollbarCornerComponent = new ScrollbarCornerComponent - @domNode.appendChild(@scrollbarCornerComponent.domNode) + @domNode.appendChild(@scrollbarCornerComponent.getDomNode()) @observeEditor() @listenForDOMEvents() @@ -89,7 +95,7 @@ class TextEditorComponent @disposables.add @stylesElement.onDidRemoveStyleElement @onStylesheetsChanged unless atom.themes.isInitialLoadComplete() @disposables.add atom.themes.onDidChangeActiveThemes @onAllThemesLoaded - @disposables.add scrollbarStyle.changes.onValue @refreshScrollbars + @disposables.add scrollbarStyle.onDidChangePreferredScrollbarStyle @refreshScrollbars @disposables.add atom.views.pollDocument(@pollDOM) @@ -100,8 +106,12 @@ class TextEditorComponent @mounted = false @disposables.dispose() @presenter.destroy() + @gutterContainerComponent?.destroy() window.removeEventListener 'resize', @requestHeightAndWidthMeasurement + getDomNode: -> + @domNode + updateSync: -> @oldState ?= {} @newState = @presenter.getState() @@ -111,7 +121,7 @@ class TextEditorComponent @cursorMoved = false @selectionChanged = false - if @editor.getLastSelection()? and !@editor.getLastSelection().isEmpty() + if @editor.getLastSelection()? and not @editor.getLastSelection().isEmpty() @domNode.classList.add('has-selection') else @domNode.classList.remove('has-selection') @@ -128,12 +138,12 @@ class TextEditorComponent else @domNode.style.height = '' - if @newState.gutter.visible - @mountGutterComponent() unless @gutterComponent? - @gutterComponent.updateSync(@newState) + if @newState.gutters.length + @mountGutterContainerComponent() unless @gutterContainerComponent? + @gutterContainerComponent.updateSync(@newState) else - @gutterComponent?.domNode?.remove() - @gutterComponent = null + @gutterContainerComponent?.getDomNode()?.remove() + @gutterContainerComponent = null @hiddenInputComponent.updateSync(@newState) @linesComponent.updateSync(@newState) @@ -141,26 +151,31 @@ class TextEditorComponent @verticalScrollbarComponent.updateSync(@newState) @scrollbarCornerComponent.updateSync(@newState) + @overlayManager?.render(@newState) + if @editor.isAlive() @updateParentViewFocusedClassIfNeeded() @updateParentViewMiniClass() - @hostElement.__spacePenView.trigger 'cursor:moved' if cursorMoved - @hostElement.__spacePenView.trigger 'selection:changed' if selectionChanged - @hostElement.__spacePenView.trigger 'editor:display-updated' + if grim.includeDeprecatedAPIs + @hostElement.__spacePenView.trigger 'cursor:moved' if cursorMoved + @hostElement.__spacePenView.trigger 'selection:changed' if selectionChanged + @hostElement.__spacePenView.trigger 'editor:display-updated' readAfterUpdateSync: => @linesComponent.measureCharactersInNewLines() if @isVisible() and not @newState.content.scrollingVertically + @overlayManager?.measureOverlays() - mountGutterComponent: -> - @gutterComponent = new GutterComponent({@editor, onMouseDown: @onGutterMouseDown}) - @domNode.insertBefore(@gutterComponent.domNode, @domNode.firstChild) + mountGutterContainerComponent: -> + @gutterContainerComponent = new GutterContainerComponent({@editor, @onLineNumberGutterMouseDown}) + @domNode.insertBefore(@gutterContainerComponent.getDomNode(), @domNode.firstChild) becameVisible: -> @updatesPaused = true @measureScrollbars() if @measureScrollbarsWhenShown @sampleFontStyling() @sampleBackgroundColors() - @measureHeightAndWidth() + @measureWindowSize() + @measureDimensions() @measureLineHeightAndDefaultCharWidth() if @measureLineHeightAndDefaultCharWidthWhenShown @remeasureCharacterWidths() if @remeasureCharacterWidthsWhenShown @editor.setVisible(true) @@ -181,7 +196,7 @@ class TextEditorComponent @updateRequested = true atom.views.updateDocument => @updateRequested = false - @updateSync() if @editor.isAlive() + @updateSync() if @canUpdate() atom.views.readDocument(@readAfterUpdateSync) canUpdate: -> @@ -271,7 +286,7 @@ class TextEditorComponent focused: -> if @mounted @presenter.setFocused(true) - @hiddenInputComponent.domNode.focus() + @hiddenInputComponent.getDomNode().focus() blurred: -> if @mounted @@ -295,8 +310,7 @@ class TextEditorComponent selectedLength = inputNode.selectionEnd - inputNode.selectionStart @editor.selectLeft() if selectedLength is 1 - insertedRange = @editor.transact atom.config.get('editor.undoGroupingInterval'), => - @editor.insertText(event.data) + insertedRange = @editor.insertText(event.data, groupUndo: true) inputNode.value = event.data if insertedRange onVerticalScroll: (scrollTop) => @@ -335,15 +349,15 @@ class TextEditorComponent if Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY) # Scrolling horizontally - previousScrollLeft = @editor.getScrollLeft() + previousScrollLeft = @presenter.getScrollLeft() @presenter.setScrollLeft(previousScrollLeft - Math.round(wheelDeltaX * @scrollSensitivity)) - event.preventDefault() unless previousScrollLeft is @editor.getScrollLeft() + event.preventDefault() unless previousScrollLeft is @presenter.getScrollLeft() else # Scrolling vertically @presenter.setMouseWheelScreenRow(@screenRowForNode(event.target)) - previousScrollTop = @presenter.scrollTop + previousScrollTop = @presenter.getScrollTop() @presenter.setScrollTop(previousScrollTop - Math.round(wheelDeltaY * @scrollSensitivity)) - event.preventDefault() unless previousScrollTop is @editor.getScrollTop() + event.preventDefault() unless previousScrollTop is @presenter.getScrollTop() onScrollViewScroll: => if @mounted @@ -379,7 +393,11 @@ class TextEditorComponent if shiftKey @editor.selectToScreenPosition(screenPosition) else if metaKey or (ctrlKey and process.platform isnt 'darwin') - @editor.addCursorAtScreenPosition(screenPosition) + cursorAtScreenPosition = @editor.getCursorAtScreenPosition(screenPosition) + if cursorAtScreenPosition and @editor.hasMultipleCursors() + cursorAtScreenPosition.destroy() + else + @editor.addCursorAtScreenPosition(screenPosition) else @editor.setCursorScreenPosition(screenPosition) when 2 @@ -390,7 +408,7 @@ class TextEditorComponent @handleDragUntilMouseUp event, (screenPosition) => @editor.selectToScreenPosition(screenPosition) - onGutterMouseDown: (event) => + onLineNumberGutterMouseDown: (event) => return unless event.button is 0 # only handle the left mouse button {shiftKey, metaKey, ctrlKey} = event @@ -404,29 +422,33 @@ class TextEditorComponent onGutterClick: (event) => clickedRow = @screenPositionForMouseEvent(event).row + clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) - @editor.setSelectedScreenRange([[clickedRow, 0], [clickedRow + 1, 0]], preserveFolds: true) + @editor.setSelectedBufferRange([[clickedBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) @handleDragUntilMouseUp event, (screenPosition) => dragRow = screenPosition.row - if dragRow < clickedRow # dragging up - @editor.setSelectedScreenRange([[dragRow, 0], [clickedRow + 1, 0]], preserveFolds: true) + dragBufferRow = @editor.bufferRowForScreenRow(dragRow) + if dragBufferRow < clickedBufferRow # dragging up + @editor.setSelectedBufferRange([[dragBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) else - @editor.setSelectedScreenRange([[clickedRow, 0], [dragRow + 1, 0]], preserveFolds: true) + @editor.setSelectedBufferRange([[clickedBufferRow, 0], [dragBufferRow + 1, 0]], preserveFolds: true) onGutterMetaClick: (event) => clickedRow = @screenPositionForMouseEvent(event).row + clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) - bufferRange = @editor.bufferRangeForScreenRange([[clickedRow, 0], [clickedRow + 1, 0]]) + bufferRange = new Range([clickedBufferRow, 0], [clickedBufferRow + 1, 0]) rowSelection = @editor.addSelectionForBufferRange(bufferRange, preserveFolds: true) @handleDragUntilMouseUp event, (screenPosition) => dragRow = screenPosition.row + dragBufferRow = @editor.bufferRowForScreenRow(dragRow) - if dragRow < clickedRow # dragging up - rowSelection.setScreenRange([[dragRow, 0], [clickedRow + 1, 0]], preserveFolds: true) + if dragBufferRow < clickedBufferRow # dragging up + rowSelection.setBufferRange([[dragBufferRow, 0], [clickedBufferRow + 1, 0]], preserveFolds: true) else - rowSelection.setScreenRange([[clickedRow, 0], [dragRow + 1, 0]], preserveFolds: true) + rowSelection.setBufferRange([[clickedBufferRow, 0], [dragBufferRow + 1, 0]], preserveFolds: true) # After updating the selected screen range, merge overlapping selections @editor.mergeIntersectingSelections(preserveFolds: true) @@ -439,19 +461,23 @@ class TextEditorComponent onGutterShiftClick: (event) => clickedRow = @screenPositionForMouseEvent(event).row + clickedBufferRow = @editor.bufferRowForScreenRow(clickedRow) tailPosition = @editor.getLastSelection().getTailScreenPosition() + tailBufferPosition = @editor.bufferPositionForScreenPosition(tailPosition) if clickedRow < tailPosition.row - @editor.selectToScreenPosition([clickedRow, 0]) + @editor.selectToBufferPosition([clickedBufferRow, 0]) else - @editor.selectToScreenPosition([clickedRow + 1, 0]) + @editor.selectToBufferPosition([clickedBufferRow + 1, 0]) @handleDragUntilMouseUp event, (screenPosition) => dragRow = screenPosition.row + dragBufferRow = @editor.bufferRowForScreenRow(dragRow) if dragRow < tailPosition.row # dragging up - @editor.setSelectedScreenRange([[dragRow, 0], tailPosition], preserveFolds: true) + @editor.setSelectedBufferRange([[dragBufferRow, 0], tailBufferPosition], preserveFolds: true) else - @editor.setSelectedScreenRange([tailPosition, [dragRow + 1, 0]], preserveFolds: true) + @editor.setSelectedBufferRange([tailBufferPosition, [dragBufferRow + 1, 0]], preserveFolds: true) + onStylesheetsChanged: (styleElement) => return unless @performedInitialMeasurement @@ -537,7 +563,7 @@ class TextEditorComponent pasteSelectionClipboard = (event) => if event?.which is 2 and process.platform is 'linux' - if selection = require('clipboard').readText('selection') + if selection = require('./safe-clipboard').readText('selection') @editor.insertText(selection) window.addEventListener('mousemove', onMouseMove) @@ -549,8 +575,9 @@ class TextEditorComponent pollDOM: => unless @checkForVisibilityChange() @sampleBackgroundColors() - @measureHeightAndWidth() + @measureDimensions() @sampleFontStyling() + @overlayManager?.measureOverlays() checkForVisibilityChange: -> if @isVisible() @@ -568,13 +595,14 @@ class TextEditorComponent @heightAndWidthMeasurementRequested = true requestAnimationFrame => @heightAndWidthMeasurementRequested = false - @measureHeightAndWidth() + @measureDimensions() + @measureWindowSize() # Measure explicitly-styled height and width and relay them to the model. If # these values aren't explicitly styled, we assume the editor is unconstrained # and use the scrollHeight / scrollWidth as its height and width in # calculations. - measureHeightAndWidth: -> + measureDimensions: -> return unless @mounted {position} = getComputedStyle(@hostElement) @@ -595,6 +623,17 @@ class TextEditorComponent if clientWidth > 0 @presenter.setContentFrameWidth(clientWidth) + @presenter.setGutterWidth(@gutterContainerComponent?.getDomNode().offsetWidth ? 0) + @presenter.setBoundingClientRect(@hostElement.getBoundingClientRect()) + + measureWindowSize: -> + return unless @mounted + + # FIXME: on Ubuntu (via xvfb) `window.innerWidth` reports an incorrect value + # when window gets resized through `atom.setWindowDimensions({width: + # windowWidth, height: windowHeight})`. + @presenter.setWindowSize(window.innerWidth, window.innerHeight) + sampleFontStyling: => oldFontSize = @fontSize oldFontFamily = @fontFamily @@ -613,8 +652,9 @@ class TextEditorComponent @presenter.setBackgroundColor(backgroundColor) - if @gutterComponent? - gutterBackgroundColor = getComputedStyle(@gutterComponent.domNode).backgroundColor + lineNumberGutter = @gutterContainerComponent?.getLineNumberGutterComponent() + if lineNumberGutter + gutterBackgroundColor = getComputedStyle(lineNumberGutter.getDomNode()).backgroundColor @presenter.setGutterBackgroundColor(gutterBackgroundColor) measureLineHeightAndDefaultCharWidth: -> @@ -634,7 +674,7 @@ class TextEditorComponent measureScrollbars: -> @measureScrollbarsWhenShown = false - cornerNode = @scrollbarCornerComponent.domNode + cornerNode = @scrollbarCornerComponent.getDomNode() originalDisplayValue = cornerNode.style.display cornerNode.style.display = 'block' @@ -660,9 +700,9 @@ class TextEditorComponent @measureScrollbarsWhenShown = true return - verticalNode = @verticalScrollbarComponent.domNode - horizontalNode = @horizontalScrollbarComponent.domNode - cornerNode = @scrollbarCornerComponent.domNode + verticalNode = @verticalScrollbarComponent.getDomNode() + horizontalNode = @horizontalScrollbarComponent.getDomNode() + cornerNode = @scrollbarCornerComponent.getDomNode() originalVerticalDisplayValue = verticalNode.style.display originalHorizontalDisplayValue = horizontalNode.style.display @@ -689,9 +729,18 @@ class TextEditorComponent consolidateSelections: (e) -> e.abortKeyBinding() unless @editor.consolidateSelections() - lineNodeForScreenRow: (screenRow) -> @linesComponent.lineNodeForScreenRow(screenRow) + lineNodeForScreenRow: (screenRow) -> + tileRow = @presenter.tileForRow(screenRow) + tileComponent = @linesComponent.getComponentForTile(tileRow) - lineNumberNodeForScreenRow: (screenRow) -> @gutterComponent.lineNumberNodeForScreenRow(screenRow) + tileComponent?.lineNodeForScreenRow(screenRow) + + lineNumberNodeForScreenRow: (screenRow) -> + tileRow = @presenter.tileForRow(screenRow) + gutterComponent = @gutterContainerComponent.getLineNumberGutterComponent() + tileComponent = gutterComponent.getComponentForTile(tileRow) + + tileComponent?.lineNumberNodeForScreenRow(screenRow) screenRowForNode: (node) -> while node? @@ -721,15 +770,6 @@ class TextEditorComponent setShowIndentGuide: (showIndentGuide) -> atom.config.set("editor.showIndentGuide", showIndentGuide) - # Deprecated - setInvisibles: (invisibles={}) -> - grim.deprecate "Use config.set('editor.invisibles', invisibles) instead" - atom.config.set('editor.invisibles', invisibles) - - # Deprecated - setShowInvisibles: (showInvisibles) -> - atom.config.set('editor.showInvisibles', showInvisibles) - setScrollSensitivity: (scrollSensitivity) => if scrollSensitivity = parseInt(scrollSensitivity) @scrollSensitivity = Math.abs(scrollSensitivity) / 100 @@ -741,9 +781,9 @@ class TextEditorComponent pixelPositionForMouseEvent: (event) -> {clientX, clientY} = event - linesClientRect = @linesComponent.domNode.getBoundingClientRect() - top = clientY - linesClientRect.top - left = clientX - linesClientRect.left + linesClientRect = @linesComponent.getDomNode().getBoundingClientRect() + top = clientY - linesClientRect.top + @presenter.scrollTop + left = clientX - linesClientRect.left + @presenter.scrollLeft {top, left} getModel: -> @@ -762,3 +802,12 @@ class TextEditorComponent updateParentViewMiniClass: -> @hostElement.classList.toggle('mini', @editor.isMini()) @rootElement.classList.toggle('mini', @editor.isMini()) + +if grim.includeDeprecatedAPIs + TextEditorComponent::setInvisibles = (invisibles={}) -> + grim.deprecate "Use config.set('editor.invisibles', invisibles) instead" + atom.config.set('editor.invisibles', invisibles) + + TextEditorComponent::setShowInvisibles = (showInvisibles) -> + grim.deprecate "Use config.set('editor.showInvisibles', showInvisibles) instead" + atom.config.set('editor.showInvisibles', showInvisibles) diff --git a/src/text-editor-element.coffee b/src/text-editor-element.coffee index c9201bab5..4c63f4ec0 100644 --- a/src/text-editor-element.coffee +++ b/src/text-editor-element.coffee @@ -3,6 +3,7 @@ Path = require 'path' {defaults} = require 'underscore-plus' TextBuffer = require 'text-buffer' +Grim = require 'grim' TextEditor = require './text-editor' TextEditorComponent = require './text-editor-component' TextEditorView = null @@ -14,13 +15,14 @@ class TextEditorElement extends HTMLElement componentDescriptor: null component: null attached: false - lineOverdrawMargin: null + tileSize: null focusOnAttach: false + hasTiledRendering: true createdCallback: -> @emitter = new Emitter @initializeContent() - @createSpacePenShim() + @createSpacePenShim() if Grim.includeDeprecatedAPIs @addEventListener 'focus', @focused.bind(this) @addEventListener 'blur', @blurred.bind(this) @@ -86,7 +88,7 @@ class TextEditorElement extends HTMLElement @model.onDidChangeGrammar => @addGrammarScopeAttribute() @model.onDidChangeEncoding => @addEncodingAttribute() @model.onDidDestroy => @unmountComponent() - @__spacePenView.setModel(@model) + @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs @model getModel: -> @@ -99,7 +101,7 @@ class TextEditorElement extends HTMLElement tabLength: 2 softTabs: true mini: @hasAttribute('mini') - gutterVisible: not @hasAttribute('gutter-hidden') + lineNumberGutterVisible: not @hasAttribute('gutter-hidden') placeholderText: @getAttribute('placeholder-text') )) @@ -109,15 +111,15 @@ class TextEditorElement extends HTMLElement rootElement: @rootElement stylesElement: @stylesElement editor: @model - lineOverdrawMargin: @lineOverdrawMargin + tileSize: @tileSize useShadowDOM: @useShadowDOM ) - @rootElement.appendChild(@component.domNode) + @rootElement.appendChild(@component.getDomNode()) if @useShadowDOM @shadowRoot.addEventListener('blur', @shadowRootBlurred.bind(this), true) else - inputNode = @component.hiddenInputComponent.domNode + inputNode = @component.hiddenInputComponent.getDomNode() inputNode.addEventListener 'focus', @focused.bind(this) inputNode.addEventListener 'blur', => @dispatchEvent(new FocusEvent('blur', bubbles: false)) @@ -125,7 +127,7 @@ class TextEditorElement extends HTMLElement callRemoveHooks(this) if @component? @component.destroy() - @component.domNode.remove() + @component.getDomNode().remove() @component = null focused: -> @@ -133,7 +135,7 @@ class TextEditorElement extends HTMLElement blurred: (event) -> unless @useShadowDOM - if event.relatedTarget is @component.hiddenInputComponent.domNode + if event.relatedTarget is @component.hiddenInputComponent.getDomNode() event.stopImmediatePropagation() return @@ -243,8 +245,9 @@ atom.commands.add 'atom-text-editor', stopEventPropagation( 'core:move-right': -> @moveRight() 'core:select-left': -> @selectLeft() 'core:select-right': -> @selectRight() + 'core:select-up': -> @selectUp() + 'core:select-down': -> @selectDown() 'core:select-all': -> @selectAll() - 'editor:move-to-previous-word': -> @moveToPreviousWord() 'editor:select-word': -> @selectWordsContainingCursors() 'editor:consolidate-selections': (event) -> event.abortKeyBinding() unless @consolidateSelections() 'editor:move-to-beginning-of-next-paragraph': -> @moveToBeginningOfNextParagraph() @@ -282,6 +285,8 @@ atom.commands.add 'atom-text-editor', stopEventPropagationAndGroupUndo( 'core:cut': -> @cutSelectedText() 'core:copy': -> @copySelectedText() 'core:paste': -> @pasteText() + 'editor:delete-to-previous-word-boundary': -> @deleteToPreviousWordBoundary() + 'editor:delete-to-next-word-boundary': -> @deleteToNextWordBoundary() 'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord() 'editor:delete-to-beginning-of-line': -> @deleteToBeginningOfLine() 'editor:delete-to-end-of-line': -> @deleteToEndOfLine() @@ -302,8 +307,6 @@ atom.commands.add 'atom-text-editor:not([mini])', stopEventPropagation( 'core:move-to-bottom': -> @moveToBottom() 'core:page-up': -> @pageUp() 'core:page-down': -> @pageDown() - 'core:select-up': -> @selectUp() - 'core:select-down': -> @selectDown() 'core:select-to-top': -> @selectToTop() 'core:select-to-bottom': -> @selectToBottom() 'core:select-page-up': -> @selectPageUp() diff --git a/src/text-editor-presenter.coffee b/src/text-editor-presenter.coffee index 70eef98a9..933ddd4aa 100644 --- a/src/text-editor-presenter.coffee +++ b/src/text-editor-presenter.coffee @@ -1,6 +1,7 @@ -{CompositeDisposable, Emitter} = require 'event-kit' +{CompositeDisposable, Disposable, Emitter} = require 'event-kit' {Point, Range} = require 'text-buffer' _ = require 'underscore-plus' +Decoration = require './decoration' module.exports = class TextEditorPresenter @@ -9,18 +10,26 @@ class TextEditorPresenter stoppedScrollingTimeoutId: null mouseWheelScreenRow: null scopedCharacterWidthsChangeCount: 0 + overlayDimensions: {} constructor: (params) -> - {@model, @autoHeight, @explicitHeight, @contentFrameWidth, @scrollTop, @scrollLeft} = params + {@model, @autoHeight, @explicitHeight, @contentFrameWidth, @scrollTop, @scrollLeft, @boundingClientRect, @windowWidth, @windowHeight, @gutterWidth} = params {horizontalScrollbarHeight, verticalScrollbarWidth} = params - {@lineHeight, @baseCharacterWidth, @lineOverdrawMargin, @backgroundColor, @gutterBackgroundColor} = params + {@lineHeight, @baseCharacterWidth, @backgroundColor, @gutterBackgroundColor, @tileSize} = params {@cursorBlinkPeriod, @cursorBlinkResumeDelay, @stoppedScrollingDelay, @focused} = params @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight @measuredVerticalScrollbarWidth = verticalScrollbarWidth + @gutterWidth ?= 0 + @tileSize ?= 12 @disposables = new CompositeDisposable @emitter = new Emitter + @visibleHighlights = {} @characterWidthsByScope = {} + @rangesByDecorationId = {} + @lineDecorationsByScreenRow = {} + @lineNumberDecorationsByScreenRow = {} + @customGutterDecorationsByGutterNameAndScreenRow = {} @transferMeasurementsToModel() @observeModel() @observeConfig() @@ -53,22 +62,16 @@ class TextEditorPresenter isBatching: -> @updating is false - # Private: Executes `fn` if `isBatching()` is false, otherwise sets `@[flagName]` to `true` for later processing. In either cases, it calls `emitDidUpdateState`. - # * `flagName` {String} name of a property of this presenter - # * `fn` {Function} to call when not batching. - batch: (flagName, fn) -> - if @isBatching() - @[flagName] = true - else - fn.apply(this) - - @emitDidUpdateState() - # Public: Gets this presenter's state, updating it just in time before returning from this function. # Returns a state {Object}, useful for rendering to screen. getState: -> @updating = true + @updateContentDimensions() + @updateScrollbarDimensions() + @updateStartRow() + @updateEndRow() + @updateFocusedState() if @shouldUpdateFocusedState @updateHeightState() if @shouldUpdateHeightState @updateVerticalScrollState() if @shouldUpdateVerticalScrollState @@ -77,12 +80,19 @@ class TextEditorPresenter @updateHiddenInputState() if @shouldUpdateHiddenInputState @updateContentState() if @shouldUpdateContentState @updateDecorations() if @shouldUpdateDecorations - @updateLinesState() if @shouldUpdateLinesState + @updateTilesState() if @shouldUpdateLinesState or @shouldUpdateLineNumbersState @updateCursorsState() if @shouldUpdateCursorsState @updateOverlaysState() if @shouldUpdateOverlaysState - @updateGutterState() if @shouldUpdateGutterState - @updateLineNumbersState() if @shouldUpdateLineNumbersState + @updateLineNumberGutterState() if @shouldUpdateLineNumberGutterState + @updateGutterOrderState() if @shouldUpdateGutterOrderState + @updateCustomGutterDecorationState() if @shouldUpdateCustomGutterDecorationState + @updating = false + @resetTrackedUpdates() + + @state + + resetTrackedUpdates: -> @shouldUpdateFocusedState = false @shouldUpdateHeightState = false @shouldUpdateVerticalScrollState = false @@ -94,44 +104,69 @@ class TextEditorPresenter @shouldUpdateLinesState = false @shouldUpdateCursorsState = false @shouldUpdateOverlaysState = false - @shouldUpdateGutterState = false + @shouldUpdateLineNumberGutterState = false @shouldUpdateLineNumbersState = false - - @updating = false - - @state + @shouldUpdateGutterOrderState = false + @shouldUpdateCustomGutterDecorationState = false observeModel: -> @disposables.add @model.onDidChange => @updateContentDimensions() @updateEndRow() - @updateHeightState() - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateScrollbarsState() - @updateContentState() - @updateDecorations() - @updateLinesState() - @updateGutterState() - @updateLineNumbersState() + @shouldUpdateHeightState = true + @shouldUpdateVerticalScrollState = true + @shouldUpdateHorizontalScrollState = true + @shouldUpdateScrollbarsState = true + @shouldUpdateContentState = true + @shouldUpdateDecorations = true + @shouldUpdateCursorsState = true + @shouldUpdateLinesState = true + @shouldUpdateLineNumberGutterState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateGutterOrderState = true + @shouldUpdateCustomGutterDecorationState = true + @emitDidUpdateState() + + @model.onDidUpdateMarkers => + @shouldUpdateLinesState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateDecorations = true + @shouldUpdateOverlaysState = true + @shouldUpdateCustomGutterDecorationState = true + @emitDidUpdateState() + @disposables.add @model.onDidChangeGrammar(@didChangeGrammar.bind(this)) - @disposables.add @model.onDidChangePlaceholderText(@updateContentState.bind(this)) + @disposables.add @model.onDidChangePlaceholderText => + @shouldUpdateContentState = true + @emitDidUpdateState() + @disposables.add @model.onDidChangeMini => + @shouldUpdateScrollbarsState = true + @shouldUpdateContentState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateLineNumberGutterState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateGutterOrderState = true + @shouldUpdateCustomGutterDecorationState = true @updateScrollbarDimensions() - @updateScrollbarsState() - @updateContentState() - @updateDecorations() - @updateLinesState() - @updateGutterState() - @updateLineNumbersState() - @disposables.add @model.onDidChangeGutterVisible => - @updateGutterState() + @updateCommonGutterState() + @emitDidUpdateState() + + @disposables.add @model.onDidChangeLineNumberGutterVisible => + @shouldUpdateLineNumberGutterState = true + @shouldUpdateGutterOrderState = true + @updateCommonGutterState() + @emitDidUpdateState() + @disposables.add @model.onDidAddDecoration(@didAddDecoration.bind(this)) @disposables.add @model.onDidAddCursor(@didAddCursor.bind(this)) @disposables.add @model.onDidChangeScrollTop(@setScrollTop.bind(this)) @disposables.add @model.onDidChangeScrollLeft(@setScrollLeft.bind(this)) @observeDecoration(decoration) for decoration in @model.getDecorations() @observeCursor(cursor) for cursor in @model.getCursors() + @disposables.add @model.onDidAddGutter(@didAddGutter.bind(this)) + return observeConfig: -> configParams = {scope: @model.getRootScopeDescriptor()} @@ -149,20 +184,32 @@ class TextEditorPresenter @configDisposables.add atom.config.onDidChange 'editor.showIndentGuide', configParams, ({newValue}) => @showIndentGuide = newValue - @updateContentState() + @shouldUpdateContentState = true + + @emitDidUpdateState() @configDisposables.add atom.config.onDidChange 'editor.scrollPastEnd', configParams, ({newValue}) => @scrollPastEnd = newValue + @shouldUpdateVerticalScrollState = true + @shouldUpdateScrollbarsState = true @updateScrollHeight() - @updateVerticalScrollState() - @updateScrollbarsState() + + @emitDidUpdateState() @configDisposables.add atom.config.onDidChange 'editor.showLineNumbers', configParams, ({newValue}) => @showLineNumbers = newValue - @updateGutterState() + @shouldUpdateLineNumberGutterState = true + @shouldUpdateGutterOrderState = true + @updateCommonGutterState() + + @emitDidUpdateState() didChangeGrammar: -> @observeConfig() - @updateContentState() - @updateGutterState() + @shouldUpdateContentState = true + @shouldUpdateLineNumberGutterState = true + @shouldUpdateGutterOrderState = true + @updateCommonGutterState() + + @emitDidUpdateState() buildState: -> @state = @@ -172,14 +219,22 @@ class TextEditorPresenter content: scrollingVertically: false cursorsVisible: false - lines: {} + tiles: {} highlights: {} overlays: {} - gutter: - lineNumbers: {} + gutters: [] + # Shared state that is copied into ``@state.gutters`. + @sharedGutterStyles = {} + @customGutterDecorations = {} + @lineNumberGutter = + tiles: {} + @updateState() updateState: -> + @shouldUpdateLinesState = true + @shouldUpdateLineNumbersState = true + @updateContentDimensions() @updateScrollbarDimensions() @updateStartRow() @@ -193,38 +248,42 @@ class TextEditorPresenter @updateHiddenInputState() @updateContentState() @updateDecorations() - @updateLinesState() + @updateTilesState() @updateCursorsState() @updateOverlaysState() - @updateGutterState() - @updateLineNumbersState() + @updateLineNumberGutterState() + @updateCommonGutterState() + @updateGutterOrderState() + @updateCustomGutterDecorationState() - updateFocusedState: -> @batch "shouldUpdateFocusedState", -> + @resetTrackedUpdates() + + updateFocusedState: -> @state.focused = @focused - updateHeightState: -> @batch "shouldUpdateHeightState", -> + updateHeightState: -> if @autoHeight @state.height = @contentHeight else @state.height = null - updateVerticalScrollState: -> @batch "shouldUpdateVerticalScrollState", -> + updateVerticalScrollState: -> @state.content.scrollHeight = @scrollHeight - @state.gutter.scrollHeight = @scrollHeight + @sharedGutterStyles.scrollHeight = @scrollHeight @state.verticalScrollbar.scrollHeight = @scrollHeight @state.content.scrollTop = @scrollTop - @state.gutter.scrollTop = @scrollTop + @sharedGutterStyles.scrollTop = @scrollTop @state.verticalScrollbar.scrollTop = @scrollTop - updateHorizontalScrollState: -> @batch "shouldUpdateHorizontalScrollState", -> + updateHorizontalScrollState: -> @state.content.scrollWidth = @scrollWidth @state.horizontalScrollbar.scrollWidth = @scrollWidth @state.content.scrollLeft = @scrollLeft @state.horizontalScrollbar.scrollLeft = @scrollLeft - updateScrollbarsState: -> @batch "shouldUpdateScrollbarsState", -> + updateScrollbarsState: -> @state.horizontalScrollbar.visible = @horizontalScrollbarHeight > 0 @state.horizontalScrollbar.height = @measuredHorizontalScrollbarHeight @state.horizontalScrollbar.right = @verticalScrollbarWidth @@ -233,14 +292,12 @@ class TextEditorPresenter @state.verticalScrollbar.width = @measuredVerticalScrollbarWidth @state.verticalScrollbar.bottom = @horizontalScrollbarHeight - updateHiddenInputState: -> @batch "shouldUpdateHiddenInputState", -> + updateHiddenInputState: -> return unless lastCursor = @model.getLastCursor() {top, left, height, width} = @pixelRectForScreenRange(lastCursor.getScreenRange()) if @focused - top -= @scrollTop - left -= @scrollLeft @state.hiddenInput.top = Math.max(Math.min(top, @clientHeight - height), 0) @state.hiddenInput.left = Math.max(Math.min(left, @clientWidth - width), 0) else @@ -250,60 +307,105 @@ class TextEditorPresenter @state.hiddenInput.height = height @state.hiddenInput.width = Math.max(width, 2) - updateContentState: -> @batch "shouldUpdateContentState", -> + updateContentState: -> + @state.content.width = Math.max(@contentWidth + @verticalScrollbarWidth, @contentFrameWidth) @state.content.scrollWidth = @scrollWidth @state.content.scrollLeft = @scrollLeft @state.content.indentGuidesVisible = not @model.isMini() and @showIndentGuide @state.content.backgroundColor = if @model.isMini() then null else @backgroundColor @state.content.placeholderText = if @model.isEmpty() then @model.getPlaceholderText() else null - updateLinesState: -> @batch "shouldUpdateLinesState", -> + tileForRow: (row) -> + row - (row % @tileSize) + + getStartTileRow: -> + Math.max(0, @tileForRow(@startRow)) + + getEndTileRow: -> + Math.min( + @tileForRow(@model.getScreenLineCount()), @tileForRow(@endRow) + ) + + updateTilesState: -> return unless @startRow? and @endRow? and @lineHeight? + visibleTiles = {} + for startRow in [@getStartTileRow()..@getEndTileRow()] by @tileSize + endRow = Math.min(@model.getScreenLineCount(), startRow + @tileSize) + + tile = @state.content.tiles[startRow] ?= {} + tile.top = startRow * @lineHeight - @scrollTop + tile.left = -@scrollLeft + tile.height = @tileSize * @lineHeight + tile.display = "block" + tile.highlights ?= {} + + gutterTile = @lineNumberGutter.tiles[startRow] ?= {} + gutterTile.top = startRow * @lineHeight - @scrollTop + gutterTile.height = @tileSize * @lineHeight + gutterTile.display = "block" + + @updateLinesState(tile, startRow, endRow) if @shouldUpdateLinesState + @updateLineNumbersState(gutterTile, startRow, endRow) if @shouldUpdateLineNumbersState + + visibleTiles[startRow] = true + + if @mouseWheelScreenRow? and @model.tokenizedLineForScreenRow(@mouseWheelScreenRow)? + mouseWheelTile = @tileForRow(@mouseWheelScreenRow) + + unless visibleTiles[mouseWheelTile]? + @lineNumberGutter.tiles[mouseWheelTile].display = "none" + @state.content.tiles[mouseWheelTile].display = "none" + visibleTiles[mouseWheelTile] = true + + for id, tile of @state.content.tiles + continue if visibleTiles.hasOwnProperty(id) + + delete @state.content.tiles[id] + delete @lineNumberGutter.tiles[id] + + updateLinesState: (tileState, startRow, endRow) -> + tileState.lines ?= {} visibleLineIds = {} - row = @startRow - while row < @endRow + row = startRow + while row < endRow line = @model.tokenizedLineForScreenRow(row) unless line? throw new Error("No line exists for row #{row}. Last screen row: #{@model.getLastScreenRow()}") visibleLineIds[line.id] = true - if @state.content.lines.hasOwnProperty(line.id) - @updateLineState(row, line) + if tileState.lines.hasOwnProperty(line.id) + lineState = tileState.lines[line.id] + lineState.screenRow = row + lineState.top = (row - startRow) * @lineHeight + lineState.decorationClasses = @lineDecorationClassesForRow(row) else - @buildLineState(row, line) + tileState.lines[line.id] = + screenRow: row + text: line.text + openScopes: line.openScopes + tags: line.tags + specialTokens: line.specialTokens + firstNonWhitespaceIndex: line.firstNonWhitespaceIndex + firstTrailingWhitespaceIndex: line.firstTrailingWhitespaceIndex + invisibles: line.invisibles + endOfLineInvisibles: line.endOfLineInvisibles + isOnlyWhitespace: line.isOnlyWhitespace() + indentLevel: line.indentLevel + tabLength: line.tabLength + fold: line.fold + top: (row - startRow) * @lineHeight + decorationClasses: @lineDecorationClassesForRow(row) row++ - if @mouseWheelScreenRow? - if preservedLine = @model.tokenizedLineForScreenRow(@mouseWheelScreenRow) - visibleLineIds[preservedLine.id] = true + for id, line of tileState.lines + delete tileState.lines[id] unless visibleLineIds.hasOwnProperty(id) + return - for id, line of @state.content.lines - unless visibleLineIds.hasOwnProperty(id) - delete @state.content.lines[id] - - updateLineState: (row, line) -> - lineState = @state.content.lines[line.id] - lineState.screenRow = row - lineState.top = row * @lineHeight - lineState.decorationClasses = @lineDecorationClassesForRow(row) - - buildLineState: (row, line) -> - @state.content.lines[line.id] = - screenRow: row - text: line.text - tokens: line.tokens - isOnlyWhitespace: line.isOnlyWhitespace() - endOfLineInvisibles: line.endOfLineInvisibles - indentLevel: line.indentLevel - tabLength: line.tabLength - fold: line.fold - top: row * @lineHeight - decorationClasses: @lineDecorationClassesForRow(row) - - updateCursorsState: -> @batch "shouldUpdateCursorsState", -> + updateCursorsState: -> @state.content.cursors = {} @updateCursorState(cursor) for cursor in @model.cursors # using property directly to avoid allocation + return updateCursorState: (cursor, destroyOnly = false) -> delete @state.content.cursors[cursor.id] @@ -318,8 +420,8 @@ class TextEditorPresenter @emitDidUpdateState() - updateOverlaysState: -> @batch "shouldUpdateOverlaysState", -> - return unless @hasPixelRectRequirements() + updateOverlaysState: -> + return unless @hasOverlayPositionRequirements() visibleDecorationIds = {} @@ -332,36 +434,157 @@ class TextEditorPresenter else screenPosition = decoration.getMarker().getHeadScreenPosition() + pixelPosition = @pixelPositionForScreenPosition(screenPosition, true) + + top = pixelPosition.top + @lineHeight + left = pixelPosition.left + @gutterWidth + + if overlayDimensions = @overlayDimensions[decoration.id] + {itemWidth, itemHeight, contentMargin} = overlayDimensions + + rightDiff = left + @boundingClientRect.left + itemWidth + contentMargin - @windowWidth + left -= rightDiff if rightDiff > 0 + + leftDiff = left + @boundingClientRect.left + contentMargin + left -= leftDiff if leftDiff < 0 + + if top + @boundingClientRect.top + itemHeight > @windowHeight and top - (itemHeight + @lineHeight) >= 0 + top -= itemHeight + @lineHeight + + pixelPosition.top = top + pixelPosition.left = left + @state.content.overlays[decoration.id] ?= {item} - @state.content.overlays[decoration.id].pixelPosition = @pixelPositionForScreenPosition(screenPosition) + @state.content.overlays[decoration.id].pixelPosition = pixelPosition visibleDecorationIds[decoration.id] = true for id of @state.content.overlays delete @state.content.overlays[id] unless visibleDecorationIds[id] - updateGutterState: -> @batch "shouldUpdateGutterState", -> - @state.gutter.visible = not @model.isMini() and (@model.isGutterVisible() ? true) and @showLineNumbers - @state.gutter.maxLineNumberDigits = @model.getLineCount().toString().length - @state.gutter.backgroundColor = if @gutterBackgroundColor isnt "rgba(0, 0, 0, 0)" + for id of @overlayDimensions + delete @overlayDimensions[id] unless visibleDecorationIds[id] + + return + + updateLineNumberGutterState: -> + @lineNumberGutter.maxLineNumberDigits = @model.getLineCount().toString().length + + updateCommonGutterState: -> + @sharedGutterStyles.backgroundColor = if @gutterBackgroundColor isnt "rgba(0, 0, 0, 0)" @gutterBackgroundColor else @backgroundColor - updateLineNumbersState: -> @batch "shouldUpdateLineNumbersState", -> + didAddGutter: (gutter) -> + gutterDisposables = new CompositeDisposable + gutterDisposables.add gutter.onDidChangeVisible => + @shouldUpdateGutterOrderState = true + @shouldUpdateCustomGutterDecorationState = true + + @emitDidUpdateState() + gutterDisposables.add gutter.onDidDestroy => + @disposables.remove(gutterDisposables) + gutterDisposables.dispose() + @shouldUpdateGutterOrderState = true + + @emitDidUpdateState() + # It is not necessary to @updateCustomGutterDecorationState here. + # The destroyed gutter will be removed from the list of gutters in @state, + # and thus will be removed from the DOM. + @disposables.add(gutterDisposables) + @shouldUpdateGutterOrderState = true + @shouldUpdateCustomGutterDecorationState = true + + @emitDidUpdateState() + + updateGutterOrderState: -> + @state.gutters = [] + if @model.isMini() + return + for gutter in @model.getGutters() + isVisible = @gutterIsVisible(gutter) + if gutter.name is 'line-number' + content = @lineNumberGutter + else + @customGutterDecorations[gutter.name] ?= {} + content = @customGutterDecorations[gutter.name] + @state.gutters.push({ + gutter, + visible: isVisible, + styles: @sharedGutterStyles, + content, + }) + + # Updates the decoration state for the gutter with the given gutterName. + # @customGutterDecorations is an {Object}, with the form: + # * gutterName : { + # decoration.id : { + # top: # of pixels from top + # height: # of pixels height of this decoration + # item (optional): HTMLElement or space-pen View + # class (optional): {String} class + # } + # } + updateCustomGutterDecorationState: -> return unless @startRow? and @endRow? and @lineHeight? + if @model.isMini() + # Mini editors have no gutter decorations. + # We clear instead of reassigning to preserve the reference. + @clearAllCustomGutterDecorations() + + for gutter in @model.getGutters() + gutterName = gutter.name + gutterDecorations = @customGutterDecorations[gutterName] + if gutterDecorations + # Clear the gutter decorations; they are rebuilt. + # We clear instead of reassigning to preserve the reference. + @clearDecorationsForCustomGutterName(gutterName) + else + @customGutterDecorations[gutterName] = {} + return if not @gutterIsVisible(gutter) + + relevantDecorations = @customGutterDecorationsInRange(gutterName, @startRow, @endRow - 1) + relevantDecorations.forEach (decoration) => + decorationRange = decoration.getMarker().getScreenRange() + @customGutterDecorations[gutterName][decoration.id] = + top: @lineHeight * decorationRange.start.row + height: @lineHeight * decorationRange.getRowCount() + item: decoration.getProperties().item + class: decoration.getProperties().class + + clearAllCustomGutterDecorations: -> + allGutterNames = Object.keys(@customGutterDecorations) + for gutterName in allGutterNames + @clearDecorationsForCustomGutterName(gutterName) + + clearDecorationsForCustomGutterName: (gutterName) -> + gutterDecorations = @customGutterDecorations[gutterName] + if gutterDecorations + allDecorationIds = Object.keys(gutterDecorations) + for decorationId in allDecorationIds + delete gutterDecorations[decorationId] + + gutterIsVisible: (gutterModel) -> + isVisible = gutterModel.isVisible() + if gutterModel.name is 'line-number' + isVisible = isVisible and @showLineNumbers + isVisible + + updateLineNumbersState: (tileState, startRow, endRow) -> + tileState.lineNumbers ?= {} visibleLineNumberIds = {} - if @startRow > 0 - rowBeforeStartRow = @startRow - 1 + if startRow > 0 + rowBeforeStartRow = startRow - 1 lastBufferRow = @model.bufferRowForScreenRow(rowBeforeStartRow) wrapCount = rowBeforeStartRow - @model.screenRowForBufferRow(lastBufferRow) else lastBufferRow = null wrapCount = 0 - if @endRow > @startRow - for bufferRow, i in @model.bufferRowsForScreenRows(@startRow, @endRow - 1) + if endRow > startRow + for bufferRow, i in @model.bufferRowsForScreenRows(startRow, endRow - 1) if bufferRow is lastBufferRow wrapCount++ id = bufferRow + '-' + wrapCount @@ -372,28 +595,23 @@ class TextEditorPresenter lastBufferRow = bufferRow softWrapped = false - screenRow = @startRow + i - top = screenRow * @lineHeight + screenRow = startRow + i + top = (screenRow - startRow) * @lineHeight decorationClasses = @lineNumberDecorationClassesForRow(screenRow) foldable = @model.isFoldableAtScreenRow(screenRow) - @state.gutter.lineNumbers[id] = {screenRow, bufferRow, softWrapped, top, decorationClasses, foldable} + tileState.lineNumbers[id] = {screenRow, bufferRow, softWrapped, top, decorationClasses, foldable} visibleLineNumberIds[id] = true - if @mouseWheelScreenRow? - bufferRow = @model.bufferRowForScreenRow(@mouseWheelScreenRow) - wrapCount = @mouseWheelScreenRow - @model.screenRowForBufferRow(bufferRow) - id = bufferRow - id += '-' + wrapCount if wrapCount > 0 - visibleLineNumberIds[id] = true + for id of tileState.lineNumbers + delete tileState.lineNumbers[id] unless visibleLineNumberIds[id] - for id of @state.gutter.lineNumbers - delete @state.gutter.lineNumbers[id] unless visibleLineNumberIds[id] + return updateStartRow: -> return unless @scrollTop? and @lineHeight? - startRow = Math.floor(@scrollTop / @lineHeight) - @lineOverdrawMargin + startRow = Math.floor(@scrollTop / @lineHeight) @startRow = Math.max(0, startRow) updateEndRow: -> @@ -401,7 +619,7 @@ class TextEditorPresenter startRow = Math.max(0, Math.floor(@scrollTop / @lineHeight)) visibleLinesCount = Math.ceil(@height / @lineHeight) + 1 - endRow = startRow + visibleLinesCount + @lineOverdrawMargin + endRow = startRow + visibleLinesCount @endRow = Math.min(@model.getScreenLineCount(), endRow) updateScrollWidth: -> @@ -432,7 +650,9 @@ class TextEditorPresenter if @baseCharacterWidth? oldContentWidth = @contentWidth - @contentWidth = @pixelPositionForScreenPosition([@model.getLongestScreenRow(), Infinity]).left + clip = @model.tokenizedLineForScreenRow(@model.getLongestScreenRow())?.isSoftWrapped() + @contentWidth = @pixelPositionForScreenPosition([@model.getLongestScreenRow(), @model.getMaxScreenLineLength()], clip).left + @contentWidth += @scrollLeft @contentWidth += 1 unless @model.isSoftWrapped() # account for cursor width if @contentHeight isnt oldContentHeight @@ -538,6 +758,18 @@ class TextEditorPresenter decorationClasses.push(decoration.getProperties().class) decorationClasses + # Returns a {Set} of {Decoration}s on the given custom gutter from startRow to endRow (inclusive). + customGutterDecorationsInRange: (gutterName, startRow, endRow) -> + decorations = new Set + + return decorations if @model.isMini() or gutterName is 'line-number' or + not @customGutterDecorationsByGutterNameAndScreenRow[gutterName] + + for screenRow in [@startRow..@endRow - 1] + for id, decoration of @customGutterDecorationsByGutterNameAndScreenRow[gutterName][screenRow] + decorations.add(decoration) + decorations + getCursorBlinkPeriod: -> @cursorBlinkPeriod getCursorBlinkResumeDelay: -> @cursorBlinkResumeDelay @@ -549,8 +781,10 @@ class TextEditorPresenter @startBlinkingCursors() else @stopBlinkingCursors(false) - @updateFocusedState() - @updateHiddenInputState() + @shouldUpdateFocusedState = true + @shouldUpdateHiddenInputState = true + + @emitDidUpdateState() setScrollTop: (scrollTop) -> scrollTop = @constrainScrollTop(scrollTop) @@ -561,12 +795,19 @@ class TextEditorPresenter @updateStartRow() @updateEndRow() @didStartScrolling() - @updateVerticalScrollState() - @updateHiddenInputState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() - @updateLineNumbersState() + @shouldUpdateVerticalScrollState = true + @shouldUpdateHiddenInputState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateCursorsState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateCustomGutterDecorationState = true + @shouldUpdateOverlaysState = true + + @emitDidUpdateState() + + getScrollTop: -> + @scrollTop didStartScrolling: -> if @stoppedScrollingTimeoutId? @@ -580,10 +821,11 @@ class TextEditorPresenter @state.content.scrollingVertically = false if @mouseWheelScreenRow? @mouseWheelScreenRow = null - @updateLinesState() - @updateLineNumbersState() - else - @emitDidUpdateState() + @shouldUpdateLinesState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateCustomGutterDecorationState = true + + @emitDidUpdateState() setScrollLeft: (scrollLeft) -> scrollLeft = @constrainScrollLeft(scrollLeft) @@ -591,9 +833,17 @@ class TextEditorPresenter oldScrollLeft = @scrollLeft @scrollLeft = scrollLeft @model.setScrollLeft(scrollLeft) - @updateHorizontalScrollState() - @updateHiddenInputState() - @updateCursorsState() unless oldScrollLeft? + @shouldUpdateHorizontalScrollState = true + @shouldUpdateHiddenInputState = true + @shouldUpdateCursorsState = true + @shouldUpdateOverlaysState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + + @emitDidUpdateState() + + getScrollLeft: -> + @scrollLeft setHorizontalScrollbarHeight: (horizontalScrollbarHeight) -> unless @measuredHorizontalScrollbarHeight is horizontalScrollbarHeight @@ -601,10 +851,12 @@ class TextEditorPresenter @measuredHorizontalScrollbarHeight = horizontalScrollbarHeight @model.setHorizontalScrollbarHeight(horizontalScrollbarHeight) @updateScrollbarDimensions() - @updateScrollbarsState() - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateCursorsState() unless oldHorizontalScrollbarHeight? + @shouldUpdateScrollbarsState = true + @shouldUpdateVerticalScrollState = true + @shouldUpdateHorizontalScrollState = true + @shouldUpdateCursorsState = true unless oldHorizontalScrollbarHeight? + + @emitDidUpdateState() setVerticalScrollbarWidth: (verticalScrollbarWidth) -> unless @measuredVerticalScrollbarWidth is verticalScrollbarWidth @@ -612,27 +864,34 @@ class TextEditorPresenter @measuredVerticalScrollbarWidth = verticalScrollbarWidth @model.setVerticalScrollbarWidth(verticalScrollbarWidth) @updateScrollbarDimensions() - @updateScrollbarsState() - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateCursorsState() unless oldVerticalScrollbarWidth? + @shouldUpdateScrollbarsState = true + @shouldUpdateVerticalScrollState = true + @shouldUpdateHorizontalScrollState = true + @shouldUpdateCursorsState = true unless oldVerticalScrollbarWidth? + + @emitDidUpdateState() setAutoHeight: (autoHeight) -> unless @autoHeight is autoHeight @autoHeight = autoHeight - @updateHeightState() + @shouldUpdateHeightState = true + + @emitDidUpdateState() setExplicitHeight: (explicitHeight) -> unless @explicitHeight is explicitHeight @explicitHeight = explicitHeight @model.setHeight(explicitHeight) @updateHeight() - @updateVerticalScrollState() - @updateScrollbarsState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() - @updateLineNumbersState() + @shouldUpdateVerticalScrollState = true + @shouldUpdateScrollbarsState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateCursorsState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateCustomGutterDecorationState = true + + @emitDidUpdateState() updateHeight: -> height = @explicitHeight ? @contentHeight @@ -650,24 +909,61 @@ class TextEditorPresenter @model.setWidth(contentFrameWidth) @updateScrollbarDimensions() @updateClientWidth() - @updateVerticalScrollState() - @updateHorizontalScrollState() - @updateScrollbarsState() - @updateContentState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() unless oldContentFrameWidth? + @shouldUpdateVerticalScrollState = true + @shouldUpdateHorizontalScrollState = true + @shouldUpdateScrollbarsState = true + @shouldUpdateContentState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateCursorsState = true unless oldContentFrameWidth? + + @emitDidUpdateState() + + setBoundingClientRect: (boundingClientRect) -> + unless @clientRectsEqual(@boundingClientRect, boundingClientRect) + @boundingClientRect = boundingClientRect + @shouldUpdateOverlaysState = true + + @emitDidUpdateState() + + clientRectsEqual: (clientRectA, clientRectB) -> + clientRectA? and clientRectB? and + clientRectA.top is clientRectB.top and + clientRectA.left is clientRectB.left and + clientRectA.width is clientRectB.width and + clientRectA.height is clientRectB.height + + setWindowSize: (width, height) -> + if @windowWidth isnt width or @windowHeight isnt height + @windowWidth = width + @windowHeight = height + @shouldUpdateOverlaysState = true + + @emitDidUpdateState() setBackgroundColor: (backgroundColor) -> unless @backgroundColor is backgroundColor @backgroundColor = backgroundColor - @updateContentState() - @updateGutterState() + @shouldUpdateContentState = true + @shouldUpdateLineNumberGutterState = true + @updateCommonGutterState() + @shouldUpdateGutterOrderState = true + + @emitDidUpdateState() setGutterBackgroundColor: (gutterBackgroundColor) -> unless @gutterBackgroundColor is gutterBackgroundColor @gutterBackgroundColor = gutterBackgroundColor - @updateGutterState() + @shouldUpdateLineNumberGutterState = true + @updateCommonGutterState() + @shouldUpdateGutterOrderState = true + + @emitDidUpdateState() + + setGutterWidth: (gutterWidth) -> + if @gutterWidth isnt gutterWidth + @gutterWidth = gutterWidth + @updateOverlaysState() setLineHeight: (lineHeight) -> unless @lineHeight is lineHeight @@ -678,20 +974,23 @@ class TextEditorPresenter @updateHeight() @updateStartRow() @updateEndRow() - @updateHeightState() - @updateHorizontalScrollState() - @updateVerticalScrollState() - @updateScrollbarsState() - @updateHiddenInputState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() - @updateLineNumbersState() - @updateOverlaysState() + @shouldUpdateHeightState = true + @shouldUpdateHorizontalScrollState = true + @shouldUpdateVerticalScrollState = true + @shouldUpdateScrollbarsState = true + @shouldUpdateHiddenInputState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateCursorsState = true + @shouldUpdateLineNumbersState = true + @shouldUpdateCustomGutterDecorationState = true + @shouldUpdateOverlaysState = true - setMouseWheelScreenRow: (mouseWheelScreenRow) -> - unless @mouseWheelScreenRow is mouseWheelScreenRow - @mouseWheelScreenRow = mouseWheelScreenRow + @emitDidUpdateState() + + setMouseWheelScreenRow: (screenRow) -> + if @mouseWheelScreenRow isnt screenRow + @mouseWheelScreenRow = screenRow @didStartScrolling() setBaseCharacterWidth: (baseCharacterWidth) -> @@ -727,15 +1026,17 @@ class TextEditorPresenter characterWidthsChanged: -> @updateContentDimensions() - @updateHorizontalScrollState() - @updateVerticalScrollState() - @updateScrollbarsState() - @updateHiddenInputState() - @updateContentState() - @updateDecorations() - @updateLinesState() - @updateCursorsState() - @updateOverlaysState() + @shouldUpdateHorizontalScrollState = true + @shouldUpdateVerticalScrollState = true + @shouldUpdateScrollbarsState = true + @shouldUpdateHiddenInputState = true + @shouldUpdateContentState = true + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true + @shouldUpdateCursorsState = true + @shouldUpdateOverlaysState = true + + @emitDidUpdateState() clearScopedCharacterWidths: -> @characterWidthsByScope = {} @@ -755,29 +1056,38 @@ class TextEditorPresenter top = targetRow * @lineHeight left = 0 column = 0 - for token in @model.tokenizedLineForScreenRow(targetRow).tokens - characterWidths = @getScopedCharacterWidths(token.scopes) + + iterator = @model.tokenizedLineForScreenRow(targetRow).getTokenIterator() + while iterator.next() + characterWidths = @getScopedCharacterWidths(iterator.getScopes()) valueIndex = 0 - while valueIndex < token.value.length - if token.hasPairedCharacter - char = token.value.substr(valueIndex, 2) + text = iterator.getText() + while valueIndex < text.length + if iterator.isPairedCharacter() + char = text charLength = 2 valueIndex += 2 else - char = token.value[valueIndex] + char = text[valueIndex] charLength = 1 valueIndex++ - return {top, left} if column is targetColumn + break if column is targetColumn left += characterWidths[char] ? baseCharacterWidth unless char is '\0' column += charLength + + top -= @scrollTop + left -= @scrollLeft {top, left} hasPixelRectRequirements: -> @hasPixelPositionRequirements() and @scrollWidth? + hasOverlayPositionRequirements: -> + @hasPixelRectRequirements() and @boundingClientRect? and @windowWidth and @windowHeight + pixelRectForScreenRange: (screenRange) -> if screenRange.end.row > screenRange.start.row top = @pixelPositionForScreenPosition(screenRange.start).top @@ -793,99 +1103,99 @@ class TextEditorPresenter observeDecoration: (decoration) -> decorationDisposables = new CompositeDisposable - decorationDisposables.add decoration.getMarker().onDidChange(@decorationMarkerDidChange.bind(this, decoration)) if decoration.isType('highlight') - decorationDisposables.add decoration.onDidChangeProperties(@updateHighlightState.bind(this, decoration)) - decorationDisposables.add decoration.onDidFlash(@highlightDidFlash.bind(this, decoration)) + decorationDisposables.add decoration.onDidFlash => + @shouldUpdateDecorations = true + @emitDidUpdateState() + + decorationDisposables.add decoration.onDidChangeProperties (event) => + @decorationPropertiesDidChange(decoration, event) decorationDisposables.add decoration.onDidDestroy => @disposables.remove(decorationDisposables) decorationDisposables.dispose() @didDestroyDecoration(decoration) @disposables.add(decorationDisposables) - decorationMarkerDidChange: (decoration, change) -> - if decoration.isType('line') or decoration.isType('line-number') - return if change.textChanged - - intersectsVisibleRowRange = false - oldRange = new Range(change.oldTailScreenPosition, change.oldHeadScreenPosition) - newRange = new Range(change.newTailScreenPosition, change.newHeadScreenPosition) - - if oldRange.intersectsRowRange(@startRow, @endRow - 1) - @removeFromLineDecorationCaches(decoration, oldRange) - intersectsVisibleRowRange = true - - if newRange.intersectsRowRange(@startRow, @endRow - 1) - @addToLineDecorationCaches(decoration, newRange) - intersectsVisibleRowRange = true - - if intersectsVisibleRowRange - @updateLinesState() if decoration.isType('line') - @updateLineNumbersState() if decoration.isType('line-number') - - if decoration.isType('highlight') - return if change.textChanged - - @updateHighlightState(decoration) - - if decoration.isType('overlay') - @updateOverlaysState() + decorationPropertiesDidChange: (decoration, {oldProperties}) -> + @shouldUpdateDecorations = true + if decoration.isType('line') or decoration.isType('gutter') + if decoration.isType('line') or Decoration.isType(oldProperties, 'line') + @shouldUpdateLinesState = true + if decoration.isType('line-number') or Decoration.isType(oldProperties, 'line-number') + @shouldUpdateLineNumbersState = true + if (decoration.isType('gutter') and not decoration.isType('line-number')) or + (Decoration.isType(oldProperties, 'gutter') and not Decoration.isType(oldProperties, 'line-number')) + @shouldUpdateCustomGutterDecorationState = true + else if decoration.isType('overlay') + @shouldUpdateOverlaysState = true + @emitDidUpdateState() didDestroyDecoration: (decoration) -> - if decoration.isType('line') or decoration.isType('line-number') - @removeFromLineDecorationCaches(decoration, decoration.getMarker().getScreenRange()) - @updateLinesState() if decoration.isType('line') - @updateLineNumbersState() if decoration.isType('line-number') - if decoration.isType('highlight') - @updateHighlightState(decoration) + @shouldUpdateDecorations = true + if decoration.isType('line') or decoration.isType('gutter') + @shouldUpdateLinesState = true if decoration.isType('line') + if decoration.isType('line-number') + @shouldUpdateLineNumbersState = true + else if decoration.isType('gutter') + @shouldUpdateCustomGutterDecorationState = true if decoration.isType('overlay') - @updateOverlaysState() + @shouldUpdateOverlaysState = true - highlightDidFlash: (decoration) -> - flash = decoration.consumeNextFlash() - if decorationState = @state.content.highlights[decoration.id] - decorationState.flashCount++ - decorationState.flashClass = flash.class - decorationState.flashDuration = flash.duration - @emitDidUpdateState() + @emitDidUpdateState() didAddDecoration: (decoration) -> @observeDecoration(decoration) - if decoration.isType('line') or decoration.isType('line-number') - @addToLineDecorationCaches(decoration, decoration.getMarker().getScreenRange()) - @updateLinesState() if decoration.isType('line') - @updateLineNumbersState() if decoration.isType('line-number') + if decoration.isType('line') or decoration.isType('gutter') + @shouldUpdateDecorations = true + @shouldUpdateLinesState = true if decoration.isType('line') + if decoration.isType('line-number') + @shouldUpdateLineNumbersState = true + else if decoration.isType('gutter') + @shouldUpdateCustomGutterDecorationState = true else if decoration.isType('highlight') - @updateHighlightState(decoration) + @shouldUpdateDecorations = true else if decoration.isType('overlay') - @updateOverlaysState() + @shouldUpdateOverlaysState = true - updateDecorations: -> @batch "shouldUpdateDecorations", -> + @emitDidUpdateState() + + updateDecorations: -> + @rangesByDecorationId = {} @lineDecorationsByScreenRow = {} @lineNumberDecorationsByScreenRow = {} - @highlightDecorationsById = {} + @customGutterDecorationsByGutterNameAndScreenRow = {} + @visibleHighlights = {} - visibleHighlights = {} return unless 0 <= @startRow <= @endRow <= Infinity for markerId, decorations of @model.decorationsForScreenRowRange(@startRow, @endRow - 1) range = @model.getMarker(markerId).getScreenRange() for decoration in decorations - if decoration.isType('line') or decoration.isType('line-number') + if decoration.isType('line') or decoration.isType('gutter') @addToLineDecorationCaches(decoration, range) else if decoration.isType('highlight') - visibleHighlights[decoration.id] = @updateHighlightState(decoration) + @updateHighlightState(decoration) - for id of @state.content.highlights - unless visibleHighlights[id] - delete @state.content.highlights[id] + for tileId, tileState of @state.content.tiles + for id, highlight of tileState.highlights + delete tileState.highlights[id] unless @visibleHighlights[tileId]?[id]? + return - removeFromLineDecorationCaches: (decoration, range) -> - for row in [range.start.row..range.end.row] by 1 - delete @lineDecorationsByScreenRow[row]?[decoration.id] - delete @lineNumberDecorationsByScreenRow[row]?[decoration.id] + removeFromLineDecorationCaches: (decoration) -> + @removePropertiesFromLineDecorationCaches(decoration.id, decoration.getProperties()) + + removePropertiesFromLineDecorationCaches: (decorationId, decorationProperties) -> + if range = @rangesByDecorationId[decorationId] + delete @rangesByDecorationId[decorationId] + + gutterName = decorationProperties.gutterName + for row in [range.start.row..range.end.row] by 1 + delete @lineDecorationsByScreenRow[row]?[decorationId] + delete @lineNumberDecorationsByScreenRow[row]?[decorationId] + delete @customGutterDecorationsByGutterNameAndScreenRow[gutterName]?[row]?[decorationId] if gutterName + return addToLineDecorationCaches: (decoration, range) -> marker = decoration.getMarker() @@ -899,6 +1209,8 @@ class TextEditorPresenter return if properties.onlyEmpty omitLastRow = range.end.column is 0 + @rangesByDecorationId[decoration.id] = range + for row in [range.start.row..range.end.row] by 1 continue if properties.onlyHead and row isnt marker.getHeadScreenPosition().row continue if omitLastRow and row is range.end.row @@ -910,6 +1222,29 @@ class TextEditorPresenter if decoration.isType('line-number') @lineNumberDecorationsByScreenRow[row] ?= {} @lineNumberDecorationsByScreenRow[row][decoration.id] = decoration + else if decoration.isType('gutter') + gutterName = decoration.getProperties().gutterName + @customGutterDecorationsByGutterNameAndScreenRow[gutterName] ?= {} + @customGutterDecorationsByGutterNameAndScreenRow[gutterName][row] ?= {} + @customGutterDecorationsByGutterNameAndScreenRow[gutterName][row][decoration.id] = decoration + + return + + intersectRangeWithTile: (range, tileStartRow) -> + intersectingStartRow = Math.max(tileStartRow, range.start.row) + intersectingEndRow = Math.min(tileStartRow + @tileSize - 1, range.end.row) + intersectingRange = new Range( + new Point(intersectingStartRow, 0), + new Point(intersectingEndRow, Infinity) + ) + + if intersectingStartRow is range.start.row + intersectingRange.start.column = range.start.column + + if intersectingEndRow is range.end.row + intersectingRange.end.column = range.end.column + + intersectingRange updateHighlightState: (decoration) -> return unless @startRow? and @endRow? and @lineHeight? and @hasPixelPositionRequirements() @@ -919,8 +1254,6 @@ class TextEditorPresenter range = marker.getScreenRange() if decoration.isDestroyed() or not marker.isValid() or range.isEmpty() or not range.intersectsRowRange(@startRow, @endRow - 1) - delete @state.content.highlights[decoration.id] - @emitDidUpdateState() return if range.start.row < @startRow @@ -930,39 +1263,67 @@ class TextEditorPresenter range.end.row = @endRow range.end.column = 0 - if range.isEmpty() - delete @state.content.highlights[decoration.id] - @emitDidUpdateState() - return + return if range.isEmpty() - highlightState = @state.content.highlights[decoration.id] ?= { - flashCount: 0 - flashDuration: null - flashClass: null - } - highlightState.class = properties.class - highlightState.deprecatedRegionClass = properties.deprecatedRegionClass - highlightState.regions = @buildHighlightRegions(range) - @emitDidUpdateState() + flash = decoration.consumeNextFlash() + + startTile = @tileForRow(range.start.row) + endTile = @tileForRow(range.end.row) + + for tileStartRow in [startTile..endTile] by @tileSize + rangeWithinTile = @intersectRangeWithTile(range, tileStartRow) + + continue if rangeWithinTile.isEmpty() + + tileState = @state.content.tiles[tileStartRow] ?= {highlights: {}} + highlightState = tileState.highlights[decoration.id] ?= { + flashCount: 0 + flashDuration: null + flashClass: null + } + + if flash? + highlightState.flashCount++ + highlightState.flashClass = flash.class + highlightState.flashDuration = flash.duration + + highlightState.class = properties.class + highlightState.deprecatedRegionClass = properties.deprecatedRegionClass + highlightState.regions = @buildHighlightRegions(rangeWithinTile) + + for region in highlightState.regions + @repositionRegionWithinTile(region, tileStartRow) + + @visibleHighlights[tileStartRow] ?= {} + @visibleHighlights[tileStartRow][decoration.id] = true true + repositionRegionWithinTile: (region, tileStartRow) -> + region.top += @scrollTop - tileStartRow * @lineHeight + region.left += @scrollLeft + buildHighlightRegions: (screenRange) -> lineHeightInPixels = @lineHeight - startPixelPosition = @pixelPositionForScreenPosition(screenRange.start, true) - endPixelPosition = @pixelPositionForScreenPosition(screenRange.end, true) + startPixelPosition = @pixelPositionForScreenPosition(screenRange.start, false) + endPixelPosition = @pixelPositionForScreenPosition(screenRange.end, false) spannedRows = screenRange.end.row - screenRange.start.row + 1 + regions = [] + if spannedRows is 1 - [ + region = top: startPixelPosition.top height: lineHeightInPixels left: startPixelPosition.left - width: endPixelPosition.left - startPixelPosition.left - ] - else - regions = [] + if screenRange.end.column is Infinity + region.right = 0 + else + region.width = endPixelPosition.left - startPixelPosition.left + + regions.push(region) + else # First row, extending from selection start to the right side of screen regions.push( top: startPixelPosition.top @@ -982,21 +1343,42 @@ class TextEditorPresenter # Last row, extending from left side of screen to selection end if screenRange.end.column > 0 - regions.push( + region = top: endPixelPosition.top height: lineHeightInPixels left: 0 - width: endPixelPosition.left - ) - regions + if screenRange.end.column is Infinity + region.right = 0 + else + region.width = endPixelPosition.left + + regions.push(region) + + regions + + setOverlayDimensions: (decorationId, itemWidth, itemHeight, contentMargin) -> + @overlayDimensions[decorationId] ?= {} + overlayState = @overlayDimensions[decorationId] + dimensionsAreEqual = overlayState.itemWidth is itemWidth and + overlayState.itemHeight is itemHeight and + overlayState.contentMargin is contentMargin + unless dimensionsAreEqual + overlayState.itemWidth = itemWidth + overlayState.itemHeight = itemHeight + overlayState.contentMargin = contentMargin + @shouldUpdateOverlaysState = true + + @emitDidUpdateState() observeCursor: (cursor) -> didChangePositionDisposable = cursor.onDidChangePosition => - @updateHiddenInputState() if cursor.isLastCursor() + @shouldUpdateHiddenInputState = true if cursor.isLastCursor() @pauseCursorBlinking() @updateCursorState(cursor) + @emitDidUpdateState() + didChangeVisibilityDisposable = cursor.onDidChangeVisibility => @updateCursorState(cursor) @@ -1004,18 +1386,21 @@ class TextEditorPresenter @disposables.remove(didChangePositionDisposable) @disposables.remove(didChangeVisibilityDisposable) @disposables.remove(didDestroyDisposable) - @updateHiddenInputState() + @shouldUpdateHiddenInputState = true @updateCursorState(cursor, true) + @emitDidUpdateState() + @disposables.add(didChangePositionDisposable) @disposables.add(didChangeVisibilityDisposable) @disposables.add(didDestroyDisposable) didAddCursor: (cursor) -> @observeCursor(cursor) - @updateHiddenInputState() + @shouldUpdateHiddenInputState = true @pauseCursorBlinking() @updateCursorState(cursor) + @emitDidUpdateState() startBlinkingCursors: -> unless @toggleCursorBlinkHandle diff --git a/src/text-editor-view.coffee b/src/text-editor-view.coffee index e75b6609b..4d513c6f8 100644 --- a/src/text-editor-view.coffee +++ b/src/text-editor-view.coffee @@ -60,7 +60,7 @@ class TextEditorView extends View placeholderText: placeholderText element = new TextEditorElement - element.lineOverdrawMargin = props?.lineOverdrawMargin + element.tileSize = props?.tileSize element.setAttribute(name, value) for name, value of attributes if attributes? element.setModel(model) return element.__spacePenView @@ -120,14 +120,14 @@ class TextEditorView extends View getEditor: -> @model - Object.defineProperty @::, 'lineHeight', get: -> @model.getLineHeightInPixels() - Object.defineProperty @::, 'charWidth', get: -> @model.getDefaultCharWidth() - Object.defineProperty @::, 'firstRenderedScreenRow', get: -> @component.getRenderedRowRange()[0] - Object.defineProperty @::, 'lastRenderedScreenRow', get: -> @component.getRenderedRowRange()[1] - Object.defineProperty @::, 'active', get: -> @is(@getPaneView()?.activeView) - Object.defineProperty @::, 'isFocused', get: -> document.activeElement is @element or document.activeElement is @element.component?.hiddenInputComponent?.domNode - Object.defineProperty @::, 'mini', get: -> @model?.isMini() - Object.defineProperty @::, 'component', get: -> @element?.component + Object.defineProperty @prototype, 'lineHeight', get: -> @model.getLineHeightInPixels() + Object.defineProperty @prototype, 'charWidth', get: -> @model.getDefaultCharWidth() + Object.defineProperty @prototype, 'firstRenderedScreenRow', get: -> @component.getRenderedRowRange()[0] + Object.defineProperty @prototype, 'lastRenderedScreenRow', get: -> @component.getRenderedRowRange()[1] + Object.defineProperty @prototype, 'active', get: -> @is(@getPaneView()?.activeView) + Object.defineProperty @prototype, 'isFocused', get: -> document.activeElement is @element or document.activeElement is @element.component?.hiddenInputComponent?.getDomNode() + Object.defineProperty @prototype, 'mini', get: -> @model?.isMini() + Object.defineProperty @prototype, 'component', get: -> @element?.component afterAttach: (onDom) -> return unless onDom diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 6f010b0ea..dfb791ee6 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -2,17 +2,17 @@ _ = require 'underscore-plus' path = require 'path' Serializable = require 'serializable' Delegator = require 'delegato' -{deprecate} = require 'grim' -{Model} = require 'theorist' -EmitterMixin = require('emissary').Emitter +{includeDeprecatedAPIs, deprecate} = require 'grim' {CompositeDisposable, Emitter} = require 'event-kit' {Point, Range} = TextBuffer = require 'text-buffer' LanguageMode = require './language-mode' DisplayBuffer = require './display-buffer' Cursor = require './cursor' +Model = require './model' Selection = require './selection' TextMateScopeSelector = require('first-mate').ScopeSelector {Directory} = require "pathwatcher" +GutterContainer = require './gutter-container' # Public: This class represents all essential editing state for a single # {TextBuffer}, including cursor and selection positions, folds, and soft wraps. @@ -69,29 +69,25 @@ class TextEditor extends Model suppressSelectionMerging: false updateBatchDepth: 0 selectionFlashDuration: 500 + gutterContainer: null @delegatesMethods 'suggestedIndentForBufferRow', 'autoIndentBufferRow', 'autoIndentBufferRows', 'autoDecreaseIndentForBufferRow', 'toggleLineCommentForBufferRow', 'toggleLineCommentsForBufferRows', toProperty: 'languageMode' - @delegatesProperties '$lineHeightInPixels', '$defaultCharWidth', '$height', '$width', - '$verticalScrollbarWidth', '$horizontalScrollbarHeight', '$scrollTop', '$scrollLeft', - 'manageScrollPosition', toProperty: 'displayBuffer' - - constructor: ({@softTabs, initialLine, initialColumn, tabLength, softWrapped, @displayBuffer, buffer, registerEditor, suppressCursorCreation, @mini, @placeholderText, @gutterVisible}) -> + constructor: ({@softTabs, initialLine, initialColumn, tabLength, softWrapped, @displayBuffer, buffer, registerEditor, suppressCursorCreation, @mini, @placeholderText, lineNumberGutterVisible, largeFileMode}={}) -> super @emitter = new Emitter + @disposables = new CompositeDisposable @cursors = [] @selections = [] buffer ?= new TextBuffer - @displayBuffer ?= new DisplayBuffer({buffer, tabLength, softWrapped}) + @displayBuffer ?= new DisplayBuffer({buffer, tabLength, softWrapped, ignoreInvisibles: @mini, largeFileMode}) @buffer = @displayBuffer.buffer @softTabs = @usesSoftTabs() ? @softTabs ? atom.config.get('editor.softTabs') ? true - @updateInvisibles() - for marker in @findMarkers(@getSelectionMarkerAttributes()) marker.setProperties(preserveFolds: true) @addSelection(marker) @@ -108,13 +104,20 @@ class TextEditor extends Model @setEncoding(atom.config.get('core.fileEncoding', scope: @getRootScopeDescriptor())) - @subscribe @$scrollTop, (scrollTop) => - @emit 'scroll-top-changed', scrollTop + @disposables.add @displayBuffer.onDidChangeScrollTop (scrollTop) => + @emit 'scroll-top-changed', scrollTop if includeDeprecatedAPIs @emitter.emit 'did-change-scroll-top', scrollTop - @subscribe @$scrollLeft, (scrollLeft) => - @emit 'scroll-left-changed', scrollLeft + + @disposables.add @displayBuffer.onDidChangeScrollLeft (scrollLeft) => + @emit 'scroll-left-changed', scrollLeft if includeDeprecatedAPIs @emitter.emit 'did-change-scroll-left', scrollLeft + @gutterContainer = new GutterContainer(this) + @lineNumberGutter = @gutterContainer.addGutter + name: 'line-number' + priority: 0 + visible: lineNumberGutterVisible + atom.workspace?.editorAdded(this) if registerEditor serializeParams: -> @@ -125,65 +128,62 @@ class TextEditor extends Model displayBuffer: @displayBuffer.serialize() deserializeParams: (params) -> - params.displayBuffer = DisplayBuffer.deserialize(params.displayBuffer) + try + displayBuffer = DisplayBuffer.deserialize(params.displayBuffer) + catch error + if error.syscall is 'read' + return # Error reading the file, don't deserialize an editor for it + else + throw error + + params.displayBuffer = displayBuffer params.registerEditor = true params subscribeToBuffer: -> @buffer.retain() - @subscribe @buffer.onDidChangePath => + @disposables.add @buffer.onDidChangePath => unless atom.project.getPaths().length > 0 atom.project.setPaths([path.dirname(@getPath())]) - @emit "title-changed" + @emit "title-changed" if includeDeprecatedAPIs @emitter.emit 'did-change-title', @getTitle() - @emit "path-changed" + @emit "path-changed" if includeDeprecatedAPIs @emitter.emit 'did-change-path', @getPath() - @subscribe @buffer.onDidChangeEncoding => + @disposables.add @buffer.onDidChangeEncoding => @emitter.emit 'did-change-encoding', @getEncoding() - @subscribe @buffer.onDidDestroy => @destroy() + @disposables.add @buffer.onDidDestroy => @destroy() # TODO: remove these when we remove the deprecations. They are old events. - @subscribe @buffer.onDidStopChanging => @emit "contents-modified" - @subscribe @buffer.onDidConflict => @emit "contents-conflicted" - @subscribe @buffer.onDidChangeModified => @emit "modified-status-changed" + if includeDeprecatedAPIs + @subscribe @buffer.onDidStopChanging => @emit "contents-modified" + @subscribe @buffer.onDidConflict => @emit "contents-conflicted" + @subscribe @buffer.onDidChangeModified => @emit "modified-status-changed" @preserveCursorPositionOnBufferReload() subscribeToDisplayBuffer: -> - @subscribe @displayBuffer.onDidCreateMarker @handleMarkerCreated - @subscribe @displayBuffer.onDidUpdateMarkers => @mergeIntersectingSelections() - @subscribe @displayBuffer.onDidChangeGrammar => @handleGrammarChange() - @subscribe @displayBuffer.onDidTokenize => @handleTokenization() - @subscribe @displayBuffer.onDidChange (e) => - @emit 'screen-lines-changed', e + @disposables.add @displayBuffer.onDidCreateMarker @handleMarkerCreated + @disposables.add @displayBuffer.onDidChangeGrammar => @handleGrammarChange() + @disposables.add @displayBuffer.onDidTokenize => @handleTokenization() + @disposables.add @displayBuffer.onDidChange (e) => + @mergeIntersectingSelections() + @emit 'screen-lines-changed', e if includeDeprecatedAPIs @emitter.emit 'did-change', e # TODO: remove these when we remove the deprecations. Though, no one is likely using them - @subscribe @displayBuffer.onDidChangeSoftWrapped (softWrapped) => @emit 'soft-wrap-changed', softWrapped - @subscribe @displayBuffer.onDidAddDecoration (decoration) => @emit 'decoration-added', decoration - @subscribe @displayBuffer.onDidRemoveDecoration (decoration) => @emit 'decoration-removed', decoration - - @subscribeToScopedConfigSettings() - - subscribeToScopedConfigSettings: -> - @scopedConfigSubscriptions?.dispose() - @scopedConfigSubscriptions = subscriptions = new CompositeDisposable - - scopeDescriptor = @getRootScopeDescriptor() - - subscriptions.add atom.config.onDidChange 'editor.showInvisibles', scope: scopeDescriptor, => @updateInvisibles() - subscriptions.add atom.config.onDidChange 'editor.invisibles', scope: scopeDescriptor, => @updateInvisibles() - - getViewClass: -> - require './text-editor-view' + if includeDeprecatedAPIs + @subscribe @displayBuffer.onDidChangeSoftWrapped (softWrapped) => @emit 'soft-wrap-changed', softWrapped + @subscribe @displayBuffer.onDidAddDecoration (decoration) => @emit 'decoration-added', decoration + @subscribe @displayBuffer.onDidRemoveDecoration (decoration) => @emit 'decoration-removed', decoration destroyed: -> - @unsubscribe() - @scopedConfigSubscriptions.dispose() + @unsubscribe() if includeDeprecatedAPIs + @disposables.dispose() selection.destroy() for selection in @getSelections() @buffer.release() @displayBuffer.destroy() @languageMode.destroy() + @gutterContainer.destroy() @emitter.emit 'did-destroy' ### @@ -325,7 +325,7 @@ class TextEditor extends Model onWillInsertText: (callback) -> @emitter.on 'will-insert-text', callback - # Extended: Calls your `callback` adter text has been inserted. + # Extended: Calls your `callback` after text has been inserted. # # * `callback` {Function} # * `event` event {Object} @@ -457,73 +457,19 @@ class TextEditor extends Model onDidChangeScrollLeft: (callback) -> @emitter.on 'did-change-scroll-left', callback - on: (eventName) -> - switch eventName - when 'title-changed' - deprecate("Use TextEditor::onDidChangeTitle instead") - when 'path-changed' - deprecate("Use TextEditor::onDidChangePath instead") - when 'modified-status-changed' - deprecate("Use TextEditor::onDidChangeModified instead") - when 'soft-wrap-changed' - deprecate("Use TextEditor::onDidChangeSoftWrapped instead") - when 'grammar-changed' - deprecate("Use TextEditor::onDidChangeGrammar instead") - when 'character-widths-changed' - deprecate("Use TextEditor::onDidChangeCharacterWidths instead") - when 'contents-modified' - deprecate("Use TextEditor::onDidStopChanging instead") - when 'contents-conflicted' - deprecate("Use TextEditor::onDidConflict instead") + # TODO Remove once the tabs package no longer uses .on subscriptions + onDidChangeIcon: (callback) -> + @emitter.on 'did-change-icon', callback - when 'will-insert-text' - deprecate("Use TextEditor::onWillInsertText instead") - when 'did-insert-text' - deprecate("Use TextEditor::onDidInsertText instead") + onDidUpdateMarkers: (callback) -> + @displayBuffer.onDidUpdateMarkers(callback) - when 'cursor-added' - deprecate("Use TextEditor::onDidAddCursor instead") - when 'cursor-removed' - deprecate("Use TextEditor::onDidRemoveCursor instead") - when 'cursor-moved' - deprecate("Use TextEditor::onDidChangeCursorPosition instead") - - when 'selection-added' - deprecate("Use TextEditor::onDidAddSelection instead") - when 'selection-removed' - deprecate("Use TextEditor::onDidRemoveSelection instead") - when 'selection-screen-range-changed' - deprecate("Use TextEditor::onDidChangeSelectionRange instead") - - when 'decoration-added' - deprecate("Use TextEditor::onDidAddDecoration instead") - when 'decoration-removed' - deprecate("Use TextEditor::onDidRemoveDecoration instead") - when 'decoration-updated' - deprecate("Use Decoration::onDidChangeProperties instead. You will get the decoration back from `TextEditor::decorateMarker()`") - when 'decoration-changed' - deprecate("Use Marker::onDidChange instead. e.g. `editor::decorateMarker(...).getMarker().onDidChange()`") - - when 'screen-lines-changed' - deprecate("Use TextEditor::onDidChange instead") - - when 'scroll-top-changed' - deprecate("Use TextEditor::onDidChangeScrollTop instead") - when 'scroll-left-changed' - deprecate("Use TextEditor::onDidChangeScrollLeft instead") - - EmitterMixin::on.apply(this, arguments) - - # Retrieves the current {TextBuffer}. + # Public: Retrieves the current {TextBuffer}. getBuffer: -> @buffer # Retrieves the current buffer's URI. getURI: -> @buffer.getUri() - getUri: -> - deprecate("Use `::getURI` instead") - @getURI() - # Create an {TextEditor} with its initial state based on this object copy: -> displayBuffer = @displayBuffer.copy() @@ -539,7 +485,7 @@ class TextEditor extends Model setMini: (mini) -> if mini isnt @mini @mini = mini - @updateInvisibles() + @displayBuffer.setIgnoreInvisibles(@mini) @emitter.emit 'did-change-mini', @mini @mini @@ -548,16 +494,60 @@ class TextEditor extends Model onDidChangeMini: (callback) -> @emitter.on 'did-change-mini', callback - setGutterVisible: (gutterVisible) -> - unless gutterVisible is @gutterVisible - @gutterVisible = gutterVisible - @emitter.emit 'did-change-gutter-visible', @gutterVisible - @gutterVisible + setLineNumberGutterVisible: (lineNumberGutterVisible) -> + unless lineNumberGutterVisible is @lineNumberGutter.isVisible() + if lineNumberGutterVisible + @lineNumberGutter.show() + else + @lineNumberGutter.hide() + @emitter.emit 'did-change-line-number-gutter-visible', @lineNumberGutter.isVisible() + @lineNumberGutter.isVisible() - isGutterVisible: -> @gutterVisible ? true + isLineNumberGutterVisible: -> @lineNumberGutter.isVisible() - onDidChangeGutterVisible: (callback) -> - @emitter.on 'did-change-gutter-visible', callback + onDidChangeLineNumberGutterVisible: (callback) -> + @emitter.on 'did-change-line-number-gutter-visible', callback + + # Public: Creates and returns a {Gutter}. + # See {GutterContainer::addGutter} for more details. + addGutter: (options) -> + @gutterContainer.addGutter(options) + + # Public: Returns the {Array} of all gutters on this editor. + getGutters: -> + @gutterContainer.getGutters() + + # Public: Returns the {Gutter} with the given name, or null if it doesn't exist. + gutterWithName: (name) -> + @gutterContainer.gutterWithName(name) + + # Calls your `callback` when a {Gutter} is added to the editor. + # Immediately calls your callback for each existing gutter. + # + # * `callback` {Function} + # * `gutter` {Gutter} that currently exists/was added. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeGutters: (callback) -> + @gutterContainer.observeGutters callback + + # Calls your `callback` when a {Gutter} is added to the editor. + # + # * `callback` {Function} + # * `gutter` {Gutter} that was added. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddGutter: (callback) -> + @gutterContainer.onDidAddGutter callback + + # Calls your `callback` when a {Gutter} is removed from the editor. + # + # * `callback` {Function} + # * `name` The name of the {Gutter} that was removed. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveGutter: (callback) -> + @gutterContainer.onDidRemoveGutter callback # Set the number of characters that can be displayed horizontally in the # editor. @@ -648,6 +638,10 @@ class TextEditor extends Model else @isModified() and not @buffer.hasMultipleEditors() + # Returns an {Object} to configure dialog shown when this editor is saved + # via {Pane::saveItemAs}. + getSaveDialogOptions: -> {} + checkoutHeadRevision: -> if filePath = this.getPath() atom.project.repositoryForDirectory(new Directory(path.dirname(filePath))) @@ -691,9 +685,6 @@ class TextEditor extends Model # # * `bufferRow` A {Number} representing a zero-indexed buffer row. lineTextForBufferRow: (bufferRow) -> @buffer.lineForRow(bufferRow) - lineForBufferRow: (bufferRow) -> - deprecate 'Use TextEditor::lineTextForBufferRow(bufferRow) instead' - @lineTextForBufferRow(bufferRow) # Essential: Returns a {String} representing the contents of the line at the # given screen row. @@ -707,23 +698,9 @@ class TextEditor extends Model # # Returns {TokenizedLine} tokenizedLineForScreenRow: (screenRow) -> @displayBuffer.tokenizedLineForScreenRow(screenRow) - lineForScreenRow: (screenRow) -> - deprecate "TextEditor::tokenizedLineForScreenRow(bufferRow) is the new name. But it's private. Try to use TextEditor::lineTextForScreenRow instead" - @tokenizedLineForScreenRow(screenRow) # {Delegates to: DisplayBuffer.tokenizedLinesForScreenRows} tokenizedLinesForScreenRows: (start, end) -> @displayBuffer.tokenizedLinesForScreenRows(start, end) - linesForScreenRows: (start, end) -> - deprecate "Use TextEditor::tokenizedLinesForScreenRows instead" - @tokenizedLinesForScreenRows(start, end) - - # Returns a {Number} representing the line length for the given - # buffer row, exclusive of its line-ending character(s). - # - # * `row` A {Number} indicating the buffer row. - lineLengthForBufferRow: (row) -> - deprecate "Use editor.lineTextForBufferRow(row).length instead" - @lineTextForBufferRow(row).length bufferRowForScreenRow: (row) -> @displayBuffer.bufferRowForScreenRow(row) @@ -772,6 +749,8 @@ class TextEditor extends Model ### # 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. @@ -791,23 +770,31 @@ class TextEditor extends Model # * `options` (optional) See {Selection::insertText}. # # Returns a {Range} when the text has been inserted - # Returns a {Bool} false when the text has not been inserted + # Returns a {Boolean} false when the text has not been inserted insertText: (text, options={}) -> willInsert = true cancel = -> willInsert = false willInsertEvent = {cancel, text} - @emit('will-insert-text', willInsertEvent) + @emit('will-insert-text', willInsertEvent) if includeDeprecatedAPIs @emitter.emit 'will-insert-text', willInsertEvent + groupingInterval = if options.groupUndo + atom.config.get('editor.undoGroupingInterval') + else + 0 + if willInsert options.autoIndentNewline ?= @shouldAutoIndent() options.autoDecreaseIndent ?= @shouldAutoIndent() - @mutateSelectedText (selection) => - range = selection.insertText(text, options) - didInsertEvent = {text, range} - @emit('did-insert-text', didInsertEvent) - @emitter.emit 'did-insert-text', didInsertEvent - range + @mutateSelectedText( + (selection) => + range = selection.insertText(text, options) + didInsertEvent = {text, range} + @emit('did-insert-text', didInsertEvent) if includeDeprecatedAPIs + @emitter.emit 'did-insert-text', didInsertEvent + range + , groupingInterval + ) else false @@ -833,9 +820,10 @@ class TextEditor extends Model # * `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) -> + mutateSelectedText: (fn, groupingInterval=0) -> @mergeIntersectingSelections => - @transact => fn(selection, index) for selection, index in @getSelections() + @transact groupingInterval, => + fn(selection, index) for selection, index in @getSelectionsOrderedByBufferPosition() # Move lines intersection the most recent selection up by one row in screen # coordinates. @@ -971,11 +959,7 @@ class TextEditor extends Model selection.setBufferRange(selectedBufferRange.translate([delta, 0])) for [foldStartRow, foldEndRow] in foldedRowRanges @createFold(foldStartRow + delta, foldEndRow + delta) - - # Deprecated: Use {::duplicateLines} instead. - duplicateLine: -> - deprecate("Use TextEditor::duplicateLines() instead") - @duplicateLines() + return replaceSelectedText: (options={}, fn) -> {selectWordIfEmpty} = options @@ -1006,6 +990,7 @@ class TextEditor extends Model while ++row < end.row @addSelectionForBufferRange([[row, 0], [row, Infinity]]) @addSelectionForBufferRange([[end.row, 0], [end.row, end.column]]) unless end.column is 0 + return # Extended: For each selection, transpose the selected text. # @@ -1027,14 +1012,14 @@ class TextEditor extends Model # 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() + @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() + @replaceSelectedText selectWordIfEmpty: true, (text) -> text.toLowerCase() # Extended: Toggle line comments for rows intersecting selections. # @@ -1083,6 +1068,16 @@ class TextEditor extends Model 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. @@ -1116,31 +1111,22 @@ class TextEditor extends Model # Extended: Delete all lines intersecting selections. deleteLine: -> + @mergeSelectionsOnSameRows() @mutateSelectedText (selection) -> selection.deleteLine() - # Deprecated: Use {::deleteToBeginningOfWord} instead. - backspaceToBeginningOfWord: -> - deprecate("Use TextEditor::deleteToBeginningOfWord() instead") - @deleteToBeginningOfWord() - - # Deprecated: Use {::deleteToBeginningOfLine} instead. - backspaceToBeginningOfLine: -> - deprecate("Use TextEditor::deleteToBeginningOfLine() instead") - @deleteToBeginningOfLine() - ### Section: History ### # Essential: Undo the last change. undo: -> - @getLastCursor().needsAutoscroll = true - @buffer.undo(this) + @buffer.undo() + @getLastSelection().autoscroll() # Essential: Redo the last change. redo: -> - @getLastCursor().needsAutoscroll = true @buffer.redo(this) + @getLastSelection().autoscroll() # Extended: Batch multiple operations as a single undo/redo step. # @@ -1287,6 +1273,14 @@ class TextEditor extends Model # Returns a {Point}. clipScreenPosition: (screenPosition, options) -> @displayBuffer.clipScreenPosition(screenPosition, options) + # Extended: Clip the start and end of the given range to valid positions on screen. + # See {::clipScreenPosition} for more information. + # + # * `range` The {Range} to clip. + # * `options` (optional) See {::clipScreenPosition} `options`. + # Returns a {Range}. + clipScreenRange: (range, options) -> @displayBuffer.clipScreenRange(range, options) + ### Section: Decorations ### @@ -1342,10 +1336,12 @@ class TextEditor extends Model # * `position` (optional) Only applicable to decorations of type `overlay`, # controls where the overlay view is positioned relative to the marker. # Values can be `'head'` (the default), or `'tail'`. + # * `gutterName` (optional) Only applicable to the `gutter` type. If provided, + # the decoration will be applied to the gutter with the specified name. # # Returns a {Decoration} object decorateMarker: (marker, decorationParams) -> - if decorationParams.type is 'gutter' + if includeDeprecatedAPIs and decorationParams.type is 'gutter' and not decorationParams.gutterName deprecate("Decorations of `type: 'gutter'` have been renamed to `type: 'line-number'`.") decorationParams.type = 'line-number' @displayBuffer.decorateMarker(marker, decorationParams) @@ -1381,11 +1377,6 @@ class TextEditor extends Model getLineDecorations: (propertyFilter) -> @displayBuffer.getLineDecorations(propertyFilter) - # Soft-deprecated (forgot to deprecated this pre 1.0) - getGutterDecorations: (propertyFilter) -> - deprecate("Use ::getLineNumberDecorations instead") - @getLineNumberDecorations(propertyFilter) - # Extended: Get all decorations of type 'line-number'. # # * `propertyFilter` (optional) An {Object} containing key value pairs that @@ -1416,6 +1407,9 @@ class TextEditor extends Model decorationForId: (id) -> @displayBuffer.decorationForId(id) + decorationsForMarkerId: (id) -> + @displayBuffer.decorationsForMarkerId(id) + ### Section: Markers ### @@ -1518,6 +1512,25 @@ class TextEditor extends Model findMarkers: (properties) -> @displayBuffer.findMarkers(properties) + # Extended: Observe changes in the set of markers that intersect a particular + # region of the editor. + # + # * `callback` A {Function} to call whenever one or more {Marker}s appears, + # disappears, or moves within the given region. + # * `event` An {Object} with the following keys: + # * `insert` A {Set} containing the ids of all markers that appeared + # in the range. + # * `update` A {Set} containing the ids of all markers that moved within + # the region. + # * `remove` A {Set} containing the ids of all markers that disappeared + # from the region. + # + # Returns a {MarkerObservationWindow}, which allows you to specify the region + # of interest by calling {MarkerObservationWindow::setBufferRange} or + # {MarkerObservationWindow::setScreenRange}. + observeMarkers: (callback) -> + @displayBuffer.observeMarkers(callback) + # Extended: Get the {Marker} for the given marker id. # # * `id` {Number} id of the marker @@ -1566,6 +1579,16 @@ class TextEditor extends Model 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) -> + for cursor in @cursors + return cursor if cursor.getScreenPosition().isEqual(position) + undefined + # Essential: Get the position of the most recently added cursor in screen # coordinates. # @@ -1579,13 +1602,6 @@ class TextEditor extends Model getCursorScreenPositions: -> cursor.getScreenPosition() for cursor in @getCursors() - # Get the row of the most recently added cursor in screen coordinates. - # - # Returns the screen row {Number}. - getCursorScreenRow: -> - deprecate('Use `editor.getCursorScreenPosition().row` instead') - @getCursorScreenPosition().row - # Essential: Move the cursor to the given position in screen coordinates. # # If there are multiple cursors, they will be consolidated to a single cursor. @@ -1602,8 +1618,9 @@ class TextEditor extends Model # * `bufferPosition` A {Point} or {Array} of `[row, column]` # # Returns a {Cursor}. - addCursorAtBufferPosition: (bufferPosition) -> + addCursorAtBufferPosition: (bufferPosition, options) -> @markBufferPosition(bufferPosition, @getSelectionMarkerAttributes()) + @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false @getLastSelection().cursor # Essential: Add a cursor at the position in screen coordinates. @@ -1611,8 +1628,9 @@ class TextEditor extends Model # * `screenPosition` A {Point} or {Array} of `[row, column]` # # Returns a {Cursor}. - addCursorAtScreenPosition: (screenPosition) -> + addCursorAtScreenPosition: (screenPosition, options) -> @markScreenPosition(screenPosition, @getSelectionMarkerAttributes()) + @getLastSelection().cursor.autoscroll() unless options?.autoscroll is false @getLastSelection().cursor # Essential: Returns {Boolean} indicating whether or not there are multiple cursors. @@ -1624,85 +1642,52 @@ class TextEditor extends Model # * `lineCount` (optional) {Number} number of lines to move moveUp: (lineCount) -> @moveCursors (cursor) -> cursor.moveUp(lineCount, moveToEndOfSelection: true) - moveCursorUp: (lineCount) -> - deprecate("Use TextEditor::moveUp() instead") - @moveUp(lineCount) # 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) - moveCursorDown: (lineCount) -> - deprecate("Use TextEditor::moveDown() instead") - @moveDown(lineCount) # 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) - moveCursorLeft: -> - deprecate("Use TextEditor::moveLeft() instead") - @moveLeft() # 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) - moveCursorRight: -> - deprecate("Use TextEditor::moveRight() instead") - @moveRight() # Essential: Move every cursor to the beginning of its line in buffer coordinates. moveToBeginningOfLine: -> @moveCursors (cursor) -> cursor.moveToBeginningOfLine() - moveCursorToBeginningOfLine: -> - deprecate("Use TextEditor::moveToBeginningOfLine() instead") - @moveToBeginningOfLine() # Essential: Move every cursor to the beginning of its line in screen coordinates. moveToBeginningOfScreenLine: -> @moveCursors (cursor) -> cursor.moveToBeginningOfScreenLine() - moveCursorToBeginningOfScreenLine: -> - deprecate("Use TextEditor::moveToBeginningOfScreenLine() instead") - @moveToBeginningOfScreenLine() # Essential: Move every cursor to the first non-whitespace character of its line. moveToFirstCharacterOfLine: -> @moveCursors (cursor) -> cursor.moveToFirstCharacterOfLine() - moveCursorToFirstCharacterOfLine: -> - deprecate("Use TextEditor::moveToFirstCharacterOfLine() instead") - @moveToFirstCharacterOfLine() # Essential: Move every cursor to the end of its line in buffer coordinates. moveToEndOfLine: -> @moveCursors (cursor) -> cursor.moveToEndOfLine() - moveCursorToEndOfLine: -> - deprecate("Use TextEditor::moveToEndOfLine() instead") - @moveToEndOfLine() # Essential: Move every cursor to the end of its line in screen coordinates. moveToEndOfScreenLine: -> @moveCursors (cursor) -> cursor.moveToEndOfScreenLine() - moveCursorToEndOfScreenLine: -> - deprecate("Use TextEditor::moveToEndOfScreenLine() instead") - @moveToEndOfScreenLine() # Essential: Move every cursor to the beginning of its surrounding word. moveToBeginningOfWord: -> @moveCursors (cursor) -> cursor.moveToBeginningOfWord() - moveCursorToBeginningOfWord: -> - deprecate("Use TextEditor::moveToBeginningOfWord() instead") - @moveToBeginningOfWord() # Essential: Move every cursor to the end of its surrounding word. moveToEndOfWord: -> @moveCursors (cursor) -> cursor.moveToEndOfWord() - moveCursorToEndOfWord: -> - deprecate("Use TextEditor::moveToEndOfWord() instead") - @moveToEndOfWord() # Cursor Extended @@ -1711,39 +1696,24 @@ class TextEditor extends Model # If there are multiple cursors, they will be merged into a single cursor. moveToTop: -> @moveCursors (cursor) -> cursor.moveToTop() - moveCursorToTop: -> - deprecate("Use TextEditor::moveToTop() instead") - @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() - moveCursorToBottom: -> - deprecate("Use TextEditor::moveToBottom() instead") - @moveToBottom() # Extended: Move every cursor to the beginning of the next word. moveToBeginningOfNextWord: -> @moveCursors (cursor) -> cursor.moveToBeginningOfNextWord() - moveCursorToBeginningOfNextWord: -> - deprecate("Use TextEditor::moveToBeginningOfNextWord() instead") - @moveToBeginningOfNextWord() # Extended: Move every cursor to the previous word boundary. moveToPreviousWordBoundary: -> @moveCursors (cursor) -> cursor.moveToPreviousWordBoundary() - moveCursorToPreviousWordBoundary: -> - deprecate("Use TextEditor::moveToPreviousWordBoundary() instead") - @moveToPreviousWordBoundary() # Extended: Move every cursor to the next word boundary. moveToNextWordBoundary: -> @moveCursors (cursor) -> cursor.moveToNextWordBoundary() - moveCursorToNextWordBoundary: -> - deprecate("Use TextEditor::moveToNextWordBoundary() instead") - @moveToNextWordBoundary() # Extended: Move every cursor to the previous subword boundary. moveToPreviousSubwordBoundary: -> @@ -1756,26 +1726,15 @@ class TextEditor extends Model # Extended: Move every cursor to the beginning of the next paragraph. moveToBeginningOfNextParagraph: -> @moveCursors (cursor) -> cursor.moveToBeginningOfNextParagraph() - moveCursorToBeginningOfNextParagraph: -> - deprecate("Use TextEditor::moveToBeginningOfNextParagraph() instead") - @moveToBeginningOfNextParagraph() # Extended: Move every cursor to the beginning of the previous paragraph. moveToBeginningOfPreviousParagraph: -> @moveCursors (cursor) -> cursor.moveToBeginningOfPreviousParagraph() - moveCursorToBeginningOfPreviousParagraph: -> - deprecate("Use TextEditor::moveToBeginningOfPreviousParagraph() instead") - @moveToBeginningOfPreviousParagraph() # Extended: Returns the most recently added {Cursor} getLastCursor: -> _.last(@cursors) - # Deprecated: - getCursor: -> - deprecate("Use TextEditor::getLastCursor() instead") - @getLastCursor() - # Extended: Returns the word surrounding the most recently added cursor. # # * `options` (optional) See {Cursor::getBeginningOfCurrentWordBufferPosition}. @@ -1784,7 +1743,7 @@ class TextEditor extends Model # Extended: Get an Array of all {Cursor}s. getCursors: -> - cursor for cursor in @cursors + @cursors.slice() # Extended: Get all {Cursors}s, ordered by their position in the buffer # instead of the order in which they were added. @@ -1800,14 +1759,14 @@ class TextEditor extends Model @decorateMarker(marker, type: 'line-number', class: 'cursor-line') @decorateMarker(marker, type: 'line-number', class: 'cursor-line-no-selection', onlyHead: true, onlyEmpty: true) @decorateMarker(marker, type: 'line', class: 'cursor-line', onlyEmpty: true) - @emit 'cursor-added', cursor + @emit 'cursor-added', cursor if includeDeprecatedAPIs @emitter.emit 'did-add-cursor', cursor cursor # Remove the given cursor from this editor. removeCursor: (cursor) -> _.remove(@cursors, cursor) - @emit 'cursor-removed', cursor + @emit 'cursor-removed', cursor if includeDeprecatedAPIs @emitter.emit 'did-remove-cursor', cursor moveCursors: (fn) -> @@ -1815,7 +1774,7 @@ class TextEditor extends Model @mergeCursors() cursorMoved: (event) -> - @emit 'cursor-moved', event + @emit 'cursor-moved', event if includeDeprecatedAPIs @emitter.emit 'did-change-cursor-position', event # Merge cursors that have the same screen position @@ -1827,12 +1786,13 @@ class TextEditor extends Model cursor.destroy() else positions[position] = true + return preserveCursorPositionOnBufferReload: -> cursorPosition = null - @subscribe @buffer.onWillReload => + @disposables.add @buffer.onWillReload => cursorPosition = @getCursorBufferPosition() - @subscribe @buffer.onDidReload => + @disposables.add @buffer.onDidReload => @setCursorBufferPosition(cursorPosition) if cursorPosition cursorPosition = null @@ -1891,6 +1851,7 @@ class TextEditor extends Model selections[i].setBufferRange(bufferRange, options) else @addSelectionForBufferRange(bufferRange, options) + return # Essential: Get the {Range} of the most recently added selection in screen # coordinates. @@ -1937,6 +1898,7 @@ class TextEditor extends Model selections[i].setScreenRange(screenRange, options) else @addSelectionForScreenRange(screenRange, options) + return # Essential: Add a selection for the given range in buffer coordinates. # @@ -1948,9 +1910,8 @@ class TextEditor extends Model # Returns the added {Selection}. addSelectionForBufferRange: (bufferRange, options={}) -> @markBufferRange(bufferRange, _.defaults(@getSelectionMarkerAttributes(), options)) - selection = @getLastSelection() - selection.autoscroll() if @manageScrollPosition - selection + @getLastSelection().autoscroll() unless options.autoscroll is false + @getLastSelection() # Essential: Add a selection for the given range in screen coordinates. # @@ -1962,9 +1923,8 @@ class TextEditor extends Model # Returns the added {Selection}. addSelectionForScreenRange: (screenRange, options={}) -> @markScreenRange(screenRange, _.defaults(@getSelectionMarkerAttributes(), options)) - selection = @getLastSelection() - selection.autoscroll() if @manageScrollPosition - selection + @getLastSelection().autoscroll() unless options.autoscroll is false + @getLastSelection() # Essential: Select from the current cursor position to the given position in # buffer coordinates. @@ -2100,16 +2060,10 @@ class TextEditor extends Model # This method merges selections on successive lines. selectLinesContainingCursors: -> @expandSelectionsForward (selection) -> selection.selectLine() - selectLine: -> - deprecate('Use TextEditor::selectLinesContainingCursors instead') - @selectLinesContainingCursors() # Essential: Select the word surrounding each cursor. selectWordsContainingCursors: -> @expandSelectionsForward (selection) -> selection.selectWord() - selectWord: -> - deprecate('Use TextEditor::selectWordsContainingCursors instead') - @selectWordsContainingCursors() # Selection Extended @@ -2165,20 +2119,11 @@ class TextEditor extends Model getLastSelection: -> _.last(@selections) - # Deprecated: - getSelection: (index) -> - if index? - deprecate("Use TextEditor::getSelections()[index] instead when getting a specific selection") - @getSelections()[index] - else - deprecate("Use TextEditor::getLastSelection() instead") - @getLastSelection() - # Extended: Get current {Selection}s. # # Returns: An {Array} of {Selection}s. getSelections: -> - selection for selection in @selections + @selections.slice() # Extended: Get all {Selection}s, ordered by their position in the buffer # instead of the order in which they were added. @@ -2225,15 +2170,18 @@ class TextEditor extends Model 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) @@ -2242,6 +2190,19 @@ class TextEditor extends Model # the function with merging suppressed, then merges intersecting selections # afterward. mergeIntersectingSelections: (args...) -> + @mergeSelections args..., (previousSelection, currentSelection) -> + exclusive = not currentSelection.isEmpty() and not previousSelection.isEmpty() + + previousSelection.intersectsWith(currentSelection, exclusive) + + mergeSelectionsOnSameRows: (args...) -> + @mergeSelections args..., (previousSelection, currentSelection) -> + screenRange = currentSelection.getScreenRange() + + previousSelection.intersectsScreenRowRange(screenRange.start.row, screenRange.end.row) + + mergeSelections: (args...) -> + mergePredicate = args.pop() fn = args.pop() if _.isFunction(_.last(args)) options = args.pop() ? {} @@ -2254,10 +2215,7 @@ class TextEditor extends Model reducer = (disjointSelections, selection) -> adjacentSelection = _.last(disjointSelections) - exclusive = not selection.isEmpty() and not adjacentSelection.isEmpty() - intersects = adjacentSelection.intersectsWith(selection, exclusive) - - if intersects + if mergePredicate(adjacentSelection, selection) adjacentSelection.merge(selection, options) disjointSelections else @@ -2275,32 +2233,33 @@ class TextEditor extends Model # Returns the new {Selection}. addSelection: (marker, options={}) -> unless marker.getProperties().preserveFolds - @destroyFoldsIntersectingBufferRange(marker.getBufferRange()) + @destroyFoldsContainingBufferRange(marker.getBufferRange()) cursor = @addCursor(marker) selection = new Selection(_.extend({editor: this, marker, cursor}, options)) @selections.push(selection) selectionBufferRange = selection.getBufferRange() @mergeIntersectingSelections(preserveFolds: marker.getProperties().preserveFolds) + if selection.destroyed for selection in @getSelections() if selection.intersectsBufferRange(selectionBufferRange) return selection else - @emit 'selection-added', selection + @emit 'selection-added', selection if includeDeprecatedAPIs @emitter.emit 'did-add-selection', selection selection # Remove the given selection. removeSelection: (selection) -> _.remove(@selections, selection) - @emit 'selection-removed', selection + @emit 'selection-removed', selection if includeDeprecatedAPIs @emitter.emit 'did-remove-selection', selection # Reduce one or more selections to a single empty selection based on the most # recently added cursor. - clearSelections: -> + clearSelections: (options) -> @consolidateSelections() - @getLastSelection().clear() + @getLastSelection().clear(options) # Reduce multiple selections to the most recently added selection. consolidateSelections: -> @@ -2313,7 +2272,7 @@ class TextEditor extends Model # Called by the selection selectionRangeChanged: (event) -> - @emit 'selection-screen-range-changed', event + @emit 'selection-screen-range-changed', event if includeDeprecatedAPIs @emitter.emit 'did-change-selection-range', event ### @@ -2437,9 +2396,6 @@ class TextEditor extends Model # # Returns a {Boolean}. isSoftWrapped: (softWrapped) -> @displayBuffer.isSoftWrapped() - getSoftWrapped: -> - deprecate("Use TextEditor::isSoftWrapped instead") - @displayBuffer.isSoftWrapped() # Essential: Enable or disable soft wrapping for this editor. # @@ -2447,17 +2403,11 @@ class TextEditor extends Model # # Returns a {Boolean}. setSoftWrapped: (softWrapped) -> @displayBuffer.setSoftWrapped(softWrapped) - setSoftWrap: (softWrapped) -> - deprecate("Use TextEditor::setSoftWrapped instead") - @setSoftWrapped(softWrapped) # Essential: Toggle soft wrapping for this editor # # Returns a {Boolean}. toggleSoftWrapped: -> @setSoftWrapped(not @isSoftWrapped()) - toggleSoftWrap: -> - deprecate("Use TextEditor::toggleSoftWrapped instead") - @toggleSoftWrapped() # Public: Gets the column at which column will soft wrap getSoftWrapColumn: -> @displayBuffer.getSoftWrapColumn() @@ -2582,9 +2532,6 @@ class TextEditor extends Model # Returns a {ScopeDescriptor}. scopeDescriptorForBufferPosition: (bufferPosition) -> @displayBuffer.scopeDescriptorForBufferPosition(bufferPosition) - scopesForBufferPosition: (bufferPosition) -> - deprecate 'Use ::scopeDescriptorForBufferPosition instead. The return value has changed! It now returns a `ScopeDescriptor`' - @scopeDescriptorForBufferPosition(bufferPosition).getScopesArray() # Extended: Get the range in buffer coordinates of all tokens surrounding the # cursor that match the given scope selector. @@ -2601,9 +2548,8 @@ class TextEditor extends Model # Extended: Determine if the given row is entirely a comment isBufferRowCommented: (bufferRow) -> if match = @lineTextForBufferRow(bufferRow).match(/\S/) - scopeDescriptor = @tokenForBufferPosition([bufferRow, match.index]).scopes @commentScopeSelector ?= new TextMateScopeSelector('comment.*') - @commentScopeSelector.matches(scopeDescriptor) + @commentScopeSelector.matches(@scopeDescriptorForBufferPosition([bufferRow, match.index]).scopes) logCursorScope: -> scopeDescriptor = @getLastCursor().getScopeDescriptor() @@ -2616,13 +2562,6 @@ class TextEditor extends Model # {Delegates to: DisplayBuffer.tokenForBufferPosition} tokenForBufferPosition: (bufferPosition) -> @displayBuffer.tokenForBufferPosition(bufferPosition) - scopesAtCursor: -> - deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' - @getLastCursor().getScopeDescriptor().getScopesArray() - getCursorScopes: -> - deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' - @scopesAtCursor() - ### Section: Clipboard Operations ### @@ -2630,7 +2569,7 @@ class TextEditor extends Model # Essential: For each selection, copy the selected text. copySelectedText: -> maintainClipboard = false - for selection in @getSelections() + for selection in @getSelectionsOrderedByBufferPosition() if selection.isEmpty() previousRange = selection.getBufferRange() selection.selectLine() @@ -2639,6 +2578,7 @@ class TextEditor extends Model else selection.copy(maintainClipboard, false) maintainClipboard = true + return # Essential: For each selection, cut the selected text. cutSelectedText: -> @@ -2675,7 +2615,7 @@ class TextEditor extends Model {cursor} = selection if indentBasis? containsNewlines = text.indexOf('\n') isnt -1 - if containsNewlines or !cursor.hasPrecedingCharactersOnLine() + if containsNewlines or not cursor.hasPrecedingCharactersOnLine() options.indentBasis ?= indentBasis if fullLine and selection.isEmpty() @@ -2733,6 +2673,7 @@ class TextEditor extends Model # Extended: For each selection, fold the rows it intersects. foldSelectedLines: -> selection.fold() for selection in @getSelections() + return # Extended: Fold all foldable lines. foldAll: -> @@ -2808,10 +2749,19 @@ class TextEditor extends Model destroyFoldWithId: (id) -> @displayBuffer.destroyFoldWithId(id) - # Remove any {Fold}s found that intersect the given buffer row. + # Remove any {Fold}s found that intersect the given buffer range. destroyFoldsIntersectingBufferRange: (bufferRange) -> - for row in [bufferRange.start.row..bufferRange.end.row] - @unfoldBufferRow(row) + @destroyFoldsContainingBufferRange(bufferRange) + + for row in [bufferRange.end.row..bufferRange.start.row] + fold.destroy() for fold in @displayBuffer.foldsStartingAtBufferRow(row) + + return + + # Remove any {Fold}s found that contain the given buffer range. + destroyFoldsContainingBufferRange: (bufferRange) -> + @unfoldBufferRow(bufferRange.start.row) + @unfoldBufferRow(bufferRange.end.row) # {Delegates to: DisplayBuffer.largestFoldContainingBufferRow} largestFoldContainingBufferRow: (bufferRow) -> @@ -2905,15 +2855,6 @@ class TextEditor extends Model shouldAutoIndentOnPaste: -> atom.config.get("editor.autoIndentOnPaste", scope: @getRootScopeDescriptor()) - shouldShowInvisibles: -> - not @mini and atom.config.get('editor.showInvisibles', scope: @getRootScopeDescriptor()) - - updateInvisibles: -> - if @shouldShowInvisibles() - @displayBuffer.setInvisibles(atom.config.get('editor.invisibles', scope: @getRootScopeDescriptor())) - else - @displayBuffer.setInvisibles(null) - ### Section: Event Handlers ### @@ -2922,10 +2863,8 @@ class TextEditor extends Model @softTabs = @usesSoftTabs() ? @softTabs handleGrammarChange: -> - @updateInvisibles() - @subscribeToScopedConfigSettings() @unfoldAll() - @emit 'grammar-changed' + @emit 'grammar-changed' if includeDeprecatedAPIs @emitter.emit 'did-change-grammar', @getGrammar() handleMarkerCreated: (marker) => @@ -3028,11 +2967,6 @@ class TextEditor extends Model pixelRectForScreenRange: (screenRange) -> @displayBuffer.pixelRectForScreenRange(screenRange) - # Deprecated: Call {::joinLines} instead. - joinLine: -> - deprecate("Use TextEditor::joinLines() instead") - @joinLines() - ### Section: Utility ### @@ -3041,3 +2975,232 @@ class TextEditor extends Model "" logScreenLines: (start, end) -> @displayBuffer.logLines(start, end) + +if includeDeprecatedAPIs + TextEditor.delegatesProperties '$lineHeightInPixels', '$defaultCharWidth', '$height', '$width', + '$verticalScrollbarWidth', '$horizontalScrollbarHeight', '$scrollTop', '$scrollLeft', + toProperty: 'displayBuffer' + + TextEditor::getViewClass = -> + require './text-editor-view' + + TextEditor::joinLine = -> + deprecate("Use TextEditor::joinLines() instead") + @joinLines() + + TextEditor::scopesAtCursor = -> + deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' + @getLastCursor().getScopeDescriptor().getScopesArray() + + TextEditor::getCursorScopes = -> + deprecate 'Use editor.getLastCursor().getScopeDescriptor() instead' + @scopesAtCursor() + + TextEditor::getUri = -> + deprecate("Use `::getURI` instead") + @getURI() + + TextEditor::lineForBufferRow = (bufferRow) -> + deprecate 'Use TextEditor::lineTextForBufferRow(bufferRow) instead' + @lineTextForBufferRow(bufferRow) + + TextEditor::lineForScreenRow = (screenRow) -> + deprecate "TextEditor::tokenizedLineForScreenRow(bufferRow) is the new name. But it's private. Try to use TextEditor::lineTextForScreenRow instead" + @tokenizedLineForScreenRow(screenRow) + + TextEditor::linesForScreenRows = (start, end) -> + deprecate "Use TextEditor::tokenizedLinesForScreenRows instead" + @tokenizedLinesForScreenRows(start, end) + + TextEditor::lineLengthForBufferRow = (row) -> + deprecate "Use editor.lineTextForBufferRow(row).length instead" + @lineTextForBufferRow(row).length + + TextEditor::duplicateLine = -> + deprecate("Use TextEditor::duplicateLines() instead") + @duplicateLines() + + TextEditor::scopesForBufferPosition = (bufferPosition) -> + deprecate 'Use ::scopeDescriptorForBufferPosition instead. The return value has changed! It now returns a `ScopeDescriptor`' + @scopeDescriptorForBufferPosition(bufferPosition).getScopesArray() + + TextEditor::toggleSoftWrap = -> + deprecate("Use TextEditor::toggleSoftWrapped instead") + @toggleSoftWrapped() + + TextEditor::setSoftWrap = (softWrapped) -> + deprecate("Use TextEditor::setSoftWrapped instead") + @setSoftWrapped(softWrapped) + + TextEditor::backspaceToBeginningOfWord = -> + deprecate("Use TextEditor::deleteToBeginningOfWord() instead") + @deleteToBeginningOfWord() + + TextEditor::backspaceToBeginningOfLine = -> + deprecate("Use TextEditor::deleteToBeginningOfLine() instead") + @deleteToBeginningOfLine() + + TextEditor::getGutterDecorations = (propertyFilter) -> + deprecate("Use ::getLineNumberDecorations instead") + @getLineNumberDecorations(propertyFilter) + + TextEditor::getCursorScreenRow = -> + deprecate('Use `editor.getCursorScreenPosition().row` instead') + @getCursorScreenPosition().row + + TextEditor::moveCursorUp = (lineCount) -> + deprecate("Use TextEditor::moveUp() instead") + @moveUp(lineCount) + + TextEditor::moveCursorDown = (lineCount) -> + deprecate("Use TextEditor::moveDown() instead") + @moveDown(lineCount) + + TextEditor::moveCursorLeft = -> + deprecate("Use TextEditor::moveLeft() instead") + @moveLeft() + + TextEditor::moveCursorRight = -> + deprecate("Use TextEditor::moveRight() instead") + @moveRight() + + TextEditor::moveCursorToBeginningOfLine = -> + deprecate("Use TextEditor::moveToBeginningOfLine() instead") + @moveToBeginningOfLine() + + TextEditor::moveCursorToBeginningOfScreenLine = -> + deprecate("Use TextEditor::moveToBeginningOfScreenLine() instead") + @moveToBeginningOfScreenLine() + + TextEditor::moveCursorToFirstCharacterOfLine = -> + deprecate("Use TextEditor::moveToFirstCharacterOfLine() instead") + @moveToFirstCharacterOfLine() + + TextEditor::moveCursorToEndOfLine = -> + deprecate("Use TextEditor::moveToEndOfLine() instead") + @moveToEndOfLine() + + TextEditor::moveCursorToEndOfScreenLine = -> + deprecate("Use TextEditor::moveToEndOfScreenLine() instead") + @moveToEndOfScreenLine() + + TextEditor::moveCursorToBeginningOfWord = -> + deprecate("Use TextEditor::moveToBeginningOfWord() instead") + @moveToBeginningOfWord() + + TextEditor::moveCursorToEndOfWord = -> + deprecate("Use TextEditor::moveToEndOfWord() instead") + @moveToEndOfWord() + + TextEditor::moveCursorToTop = -> + deprecate("Use TextEditor::moveToTop() instead") + @moveToTop() + + TextEditor::moveCursorToBottom = -> + deprecate("Use TextEditor::moveToBottom() instead") + @moveToBottom() + + TextEditor::moveCursorToBeginningOfNextWord = -> + deprecate("Use TextEditor::moveToBeginningOfNextWord() instead") + @moveToBeginningOfNextWord() + + TextEditor::moveCursorToPreviousWordBoundary = -> + deprecate("Use TextEditor::moveToPreviousWordBoundary() instead") + @moveToPreviousWordBoundary() + + TextEditor::moveCursorToNextWordBoundary = -> + deprecate("Use TextEditor::moveToNextWordBoundary() instead") + @moveToNextWordBoundary() + + TextEditor::moveCursorToBeginningOfNextParagraph = -> + deprecate("Use TextEditor::moveToBeginningOfNextParagraph() instead") + @moveToBeginningOfNextParagraph() + + TextEditor::moveCursorToBeginningOfPreviousParagraph = -> + deprecate("Use TextEditor::moveToBeginningOfPreviousParagraph() instead") + @moveToBeginningOfPreviousParagraph() + + TextEditor::getCursor = -> + deprecate("Use TextEditor::getLastCursor() instead") + @getLastCursor() + + TextEditor::selectLine = -> + deprecate('Use TextEditor::selectLinesContainingCursors instead') + @selectLinesContainingCursors() + + TextEditor::selectWord = -> + deprecate('Use TextEditor::selectWordsContainingCursors instead') + @selectWordsContainingCursors() + + TextEditor::getSelection = (index) -> + if index? + deprecate("Use TextEditor::getSelections()[index] instead when getting a specific selection") + @getSelections()[index] + else + deprecate("Use TextEditor::getLastSelection() instead") + @getLastSelection() + + TextEditor::getSoftWrapped = -> + deprecate("Use TextEditor::isSoftWrapped instead") + @displayBuffer.isSoftWrapped() + + EmitterMixin = require('emissary').Emitter + TextEditor::on = (eventName) -> + switch eventName + when 'title-changed' + deprecate("Use TextEditor::onDidChangeTitle instead") + when 'path-changed' + deprecate("Use TextEditor::onDidChangePath instead") + when 'modified-status-changed' + deprecate("Use TextEditor::onDidChangeModified instead") + when 'soft-wrap-changed' + deprecate("Use TextEditor::onDidChangeSoftWrapped instead") + when 'grammar-changed' + deprecate("Use TextEditor::onDidChangeGrammar instead") + when 'character-widths-changed' + deprecate("Use TextEditor::onDidChangeCharacterWidths instead") + when 'contents-modified' + deprecate("Use TextEditor::onDidStopChanging instead") + when 'contents-conflicted' + deprecate("Use TextEditor::onDidConflict instead") + + when 'will-insert-text' + deprecate("Use TextEditor::onWillInsertText instead") + when 'did-insert-text' + deprecate("Use TextEditor::onDidInsertText instead") + + when 'cursor-added' + deprecate("Use TextEditor::onDidAddCursor instead") + when 'cursor-removed' + deprecate("Use TextEditor::onDidRemoveCursor instead") + when 'cursor-moved' + deprecate("Use TextEditor::onDidChangeCursorPosition instead") + + when 'selection-added' + deprecate("Use TextEditor::onDidAddSelection instead") + when 'selection-removed' + deprecate("Use TextEditor::onDidRemoveSelection instead") + when 'selection-screen-range-changed' + deprecate("Use TextEditor::onDidChangeSelectionRange instead") + + when 'decoration-added' + deprecate("Use TextEditor::onDidAddDecoration instead") + when 'decoration-removed' + deprecate("Use TextEditor::onDidRemoveDecoration instead") + when 'decoration-updated' + deprecate("Use Decoration::onDidChangeProperties instead. You will get the decoration back from `TextEditor::decorateMarker()`") + when 'decoration-changed' + deprecate("Use Marker::onDidChange instead. e.g. `editor::decorateMarker(...).getMarker().onDidChange()`") + + when 'screen-lines-changed' + deprecate("Use TextEditor::onDidChange instead") + + when 'scroll-top-changed' + deprecate("Use TextEditor::onDidChangeScrollTop instead") + when 'scroll-left-changed' + deprecate("Use TextEditor::onDidChangeScrollLeft instead") + + else + deprecate("TextEditor::on is deprecated. Use documented event subscription methods instead.") + + EmitterMixin::on.apply(this, arguments) diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee index 9cf3537a1..01bb9652a 100644 --- a/src/theme-manager.coffee +++ b/src/theme-manager.coffee @@ -1,22 +1,16 @@ path = require 'path' - _ = require 'underscore-plus' -EmitterMixin = require('emissary').Emitter -{Emitter, Disposable} = require 'event-kit' +{Emitter, Disposable, CompositeDisposable} = require 'event-kit' {File} = require 'pathwatcher' fs = require 'fs-plus' Q = require 'q' Grim = require 'grim' -Package = require './package' - # Extended: Handles loading and activating available themes. # # An instance of this class is always available as the `atom.themes` global. module.exports = class ThemeManager - EmitterMixin.includeInto(this) - constructor: ({@packageManager, @resourcePath, @configDirPath, @safeMode}) -> @emitter = new Emitter @styleSheetDisposablesBySourcePath = {} @@ -33,24 +27,24 @@ class ThemeManager styleElementAdded: (styleElement) -> {sheet} = styleElement @sheetsByStyleElement.set(styleElement, sheet) - @emit 'stylesheet-added', sheet + @emit 'stylesheet-added', sheet if Grim.includeDeprecatedAPIs @emitter.emit 'did-add-stylesheet', sheet - @emit 'stylesheets-changed' + @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-stylesheets' styleElementRemoved: (styleElement) -> sheet = @sheetsByStyleElement.get(styleElement) - @emit 'stylesheet-removed', sheet + @emit 'stylesheet-removed', sheet if Grim.includeDeprecatedAPIs @emitter.emit 'did-remove-stylesheet', sheet - @emit 'stylesheets-changed' + @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-stylesheets' styleElementUpdated: ({sheet}) -> - @emit 'stylesheet-removed', sheet + @emit 'stylesheet-removed', sheet if Grim.includeDeprecatedAPIs @emitter.emit 'did-remove-stylesheet', sheet - @emit 'stylesheet-added', sheet + @emit 'stylesheet-added', sheet if Grim.includeDeprecatedAPIs @emitter.emit 'did-add-stylesheet', sheet - @emit 'stylesheets-changed' + @emit 'stylesheets-changed' if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-stylesheets' ### @@ -65,65 +59,6 @@ class ThemeManager @emitter.on 'did-change-active-themes', callback @emitter.on 'did-reload-all', callback # TODO: Remove once deprecated pre-1.0 APIs are gone - onDidReloadAll: (callback) -> - Grim.deprecate("Use `::onDidChangeActiveThemes` instead.") - @onDidChangeActiveThemes(callback) - - # Deprecated: Invoke `callback` when a stylesheet has been added to the dom. - # - # * `callback` {Function} - # * `stylesheet` {StyleSheet} the style node - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddStylesheet: (callback) -> - Grim.deprecate("Use atom.styles.onDidAddStyleElement instead") - @emitter.on 'did-add-stylesheet', callback - - # Deprecated: Invoke `callback` when a stylesheet has been removed from the dom. - # - # * `callback` {Function} - # * `stylesheet` {StyleSheet} the style node - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveStylesheet: (callback) -> - Grim.deprecate("Use atom.styles.onDidRemoveStyleElement instead") - @emitter.on 'did-remove-stylesheet', callback - - # Deprecated: Invoke `callback` when a stylesheet has been updated. - # - # * `callback` {Function} - # * `stylesheet` {StyleSheet} the style node - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidUpdateStylesheet: (callback) -> - Grim.deprecate("Use atom.styles.onDidUpdateStyleElement instead") - @emitter.on 'did-update-stylesheet', callback - - # Deprecated: Invoke `callback` when any stylesheet has been updated, added, or removed. - # - # * `callback` {Function} - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeStylesheets: (callback) -> - Grim.deprecate("Use atom.styles.onDidAdd/RemoveStyleElement instead") - @emitter.on 'did-change-stylesheets', callback - - on: (eventName) -> - switch eventName - when 'reloaded' - Grim.deprecate 'Use ThemeManager::onDidChangeActiveThemes instead' - when 'stylesheet-added' - Grim.deprecate 'Use ThemeManager::onDidAddStylesheet instead' - when 'stylesheet-removed' - Grim.deprecate 'Use ThemeManager::onDidRemoveStylesheet instead' - when 'stylesheet-updated' - Grim.deprecate 'Use ThemeManager::onDidUpdateStylesheet instead' - when 'stylesheets-changed' - Grim.deprecate 'Use ThemeManager::onDidChangeStylesheets instead' - else - Grim.deprecate 'ThemeManager::on is deprecated. Use event subscription methods instead.' - EmitterMixin::on.apply(this, arguments) - ### Section: Accessing Available Themes ### @@ -140,10 +75,6 @@ class ThemeManager getLoadedThemeNames: -> theme.name for theme in @getLoadedThemes() - getLoadedNames: -> - Grim.deprecate("Use `::getLoadedThemeNames` instead.") - @getLoadedThemeNames() - # Public: Get an array of all the loaded themes. getLoadedThemes: -> pack for pack in @packageManager.getLoadedPackages() when pack.isTheme() @@ -156,10 +87,6 @@ class ThemeManager getActiveThemeNames: -> theme.name for theme in @getActiveThemes() - getActiveNames: -> - Grim.deprecate("Use `::getActiveThemeNames` instead.") - @getActiveThemeNames() - # Public: Get an array of all the active themes. getActiveThemes: -> pack for pack in @packageManager.getActivePackages() when pack.isTheme() @@ -208,22 +135,10 @@ class ThemeManager # the first/top theme to override later themes in the stack. themeNames.reverse() - # Set the list of enabled themes. - # - # * `enabledThemeNames` An {Array} of {String} theme names. - setEnabledThemes: (enabledThemeNames) -> - Grim.deprecate("Use `atom.config.set('core.themes', arrayOfThemeNames)` instead") - atom.config.set('core.themes', enabledThemeNames) - ### Section: Private ### - # Returns the {String} path to the user's stylesheet under ~/.atom - getUserStylesheetPath: -> - Grim.deprecate("Call atom.styles.getUserStyleSheetPath() instead") - atom.styles.getUserStyleSheetPath() - # Resolve and apply the stylesheet specified by the path. # # This supports both CSS and Less stylsheets. @@ -241,7 +156,8 @@ class ThemeManager throw new Error("Could not find a file at path '#{stylesheetPath}'") unwatchUserStylesheet: -> - @userStylesheetFile?.off() + @userStylsheetSubscriptions?.dispose() + @userStylsheetSubscriptions = null @userStylesheetFile = null @userStyleSheetDisposable?.dispose() @userStyleSheetDisposable = null @@ -254,7 +170,11 @@ class ThemeManager try @userStylesheetFile = new File(userStylesheetPath) - @userStylesheetFile.on 'contents-changed moved removed', => @loadUserStylesheet() + @userStylsheetSubscriptions = new CompositeDisposable() + reloadStylesheet = => @loadUserStylesheet() + @userStylsheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet)) + @userStylsheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet)) + @userStylsheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet)) catch error message = """ Unable to watch path: `#{path.basename(userStylesheetPath)}`. Make sure @@ -313,7 +233,11 @@ class ThemeManager 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} @@ -357,7 +281,7 @@ class ThemeManager @loadUserStylesheet() @reloadBaseStylesheets() @initialLoadComplete = true - @emit 'reloaded' + @emit 'reloaded' if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-active-themes' deferred.resolve() @@ -401,3 +325,59 @@ class ThemeManager themePaths.push(path.join(themePath, 'styles')) themePaths.filter (themePath) -> fs.isDirectorySync(themePath) + +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + EmitterMixin.includeInto(ThemeManager) + + ThemeManager::on = (eventName) -> + switch eventName + when 'reloaded' + Grim.deprecate 'Use ThemeManager::onDidChangeActiveThemes instead' + when 'stylesheet-added' + Grim.deprecate 'Use ThemeManager::onDidAddStylesheet instead' + when 'stylesheet-removed' + Grim.deprecate 'Use ThemeManager::onDidRemoveStylesheet instead' + when 'stylesheet-updated' + Grim.deprecate 'Use ThemeManager::onDidUpdateStylesheet instead' + when 'stylesheets-changed' + Grim.deprecate 'Use ThemeManager::onDidChangeStylesheets instead' + else + Grim.deprecate 'ThemeManager::on is deprecated. Use event subscription methods instead.' + EmitterMixin::on.apply(this, arguments) + + ThemeManager::onDidReloadAll = (callback) -> + Grim.deprecate("Use `::onDidChangeActiveThemes` instead.") + @onDidChangeActiveThemes(callback) + + ThemeManager::onDidAddStylesheet = (callback) -> + Grim.deprecate("Use atom.styles.onDidAddStyleElement instead") + @emitter.on 'did-add-stylesheet', callback + + ThemeManager::onDidRemoveStylesheet = (callback) -> + Grim.deprecate("Use atom.styles.onDidRemoveStyleElement instead") + @emitter.on 'did-remove-stylesheet', callback + + ThemeManager::onDidUpdateStylesheet = (callback) -> + Grim.deprecate("Use atom.styles.onDidUpdateStyleElement instead") + @emitter.on 'did-update-stylesheet', callback + + ThemeManager::onDidChangeStylesheets = (callback) -> + Grim.deprecate("Use atom.styles.onDidAdd/RemoveStyleElement instead") + @emitter.on 'did-change-stylesheets', callback + + ThemeManager::getUserStylesheetPath = -> + Grim.deprecate("Call atom.styles.getUserStyleSheetPath() instead") + atom.styles.getUserStyleSheetPath() + + ThemeManager::getLoadedNames = -> + Grim.deprecate("Use `::getLoadedThemeNames` instead.") + @getLoadedThemeNames() + + ThemeManager::getActiveNames = -> + Grim.deprecate("Use `::getActiveThemeNames` instead.") + @getActiveThemeNames() + + ThemeManager::setEnabledThemes = (enabledThemeNames) -> + Grim.deprecate("Use `atom.config.set('core.themes', arrayOfThemeNames)` instead") + atom.config.set('core.themes', enabledThemeNames) diff --git a/src/theme-package.coffee b/src/theme-package.coffee index e1b87e783..f14750d2f 100644 --- a/src/theme-package.coffee +++ b/src/theme-package.coffee @@ -14,11 +14,7 @@ class ThemePackage extends Package atom.config.removeAtKeyPath('core.themes', @name) load: -> - @measure 'loadTime', => - try - @metadata ?= Package.loadMetadata(@path) - catch error - console.warn "Failed to load theme named '#{@name}'", error.stack ? error + @loadTime = 0 this activate: -> @@ -26,7 +22,10 @@ class ThemePackage extends Package @activationDeferred = Q.defer() @measure 'activateTime', => - @loadStylesheets() - @activateNow() + try + @loadStylesheets() + @activateNow() + catch error + @handleError("Failed to activate the #{@name} theme", error) @activationDeferred.promise diff --git a/src/tiled-component.coffee b/src/tiled-component.coffee new file mode 100644 index 000000000..33719dda5 --- /dev/null +++ b/src/tiled-component.coffee @@ -0,0 +1,51 @@ +cloneObject = (object) -> + clone = {} + clone[key] = value for key, value of object + clone + +module.exports = +class TiledComponent + updateSync: (state) -> + @newState = @getNewState(state) + @oldState ?= @buildEmptyState() + + @beforeUpdateSync?(state) + + @removeTileNodes() if @shouldRecreateAllTilesOnUpdate?() + @updateTileNodes() + + @afterUpdateSync?(state) + + removeTileNodes: -> + @removeTileNode(tileRow) for tileRow of @oldState.tiles + return + + removeTileNode: (tileRow) -> + node = @componentsByTileId[tileRow].getDomNode() + + node.remove() + delete @componentsByTileId[tileRow] + delete @oldState.tiles[tileRow] + + updateTileNodes: -> + @componentsByTileId ?= {} + + for tileRow of @oldState.tiles + unless @newState.tiles.hasOwnProperty(tileRow) + @removeTileNode(tileRow) + + for tileRow, tileState of @newState.tiles + if @oldState.tiles.hasOwnProperty(tileRow) + component = @componentsByTileId[tileRow] + else + component = @componentsByTileId[tileRow] = @buildComponentForTile(tileRow) + + @getTilesNode().appendChild(component.getDomNode()) + @oldState.tiles[tileRow] = cloneObject(tileState) + + component.updateSync(@newState) + + return + + getComponentForTile: (tileRow) -> + @componentsByTileId[tileRow] diff --git a/src/token-iterator.coffee b/src/token-iterator.coffee new file mode 100644 index 000000000..202b044ba --- /dev/null +++ b/src/token-iterator.coffee @@ -0,0 +1,83 @@ +{SoftTab, HardTab, PairedCharacter, SoftWrapIndent} = require './special-token-symbols' + +module.exports = +class TokenIterator + constructor: (line) -> + @reset(line) if line? + + reset: (@line) -> + @index = null + @bufferStart = @line.startBufferColumn + @bufferEnd = @bufferStart + @screenStart = 0 + @screenEnd = 0 + @scopes = @line.openScopes.map (id) -> atom.grammars.scopeForId(id) + @scopeStarts = @scopes.slice() + @scopeEnds = [] + this + + next: -> + {tags} = @line + + if @index? + @index++ + @scopeEnds.length = 0 + @scopeStarts.length = 0 + @bufferStart = @bufferEnd + @screenStart = @screenEnd + else + @index = 0 + + while @index < tags.length + tag = tags[@index] + if tag < 0 + if tag % 2 is 0 + @scopeEnds.push(atom.grammars.scopeForId(tag + 1)) + @scopes.pop() + else + scope = atom.grammars.scopeForId(tag) + @scopeStarts.push(scope) + @scopes.push(scope) + @index++ + else + if @isHardTab() + @screenEnd = @screenStart + tag + @bufferEnd = @bufferStart + 1 + else if @isSoftWrapIndentation() + @screenEnd = @screenStart + tag + @bufferEnd = @bufferStart + 0 + else + @screenEnd = @screenStart + tag + @bufferEnd = @bufferStart + tag + return true + + false + + getBufferStart: -> @bufferStart + getBufferEnd: -> @bufferEnd + + getScreenStart: -> @screenStart + getScreenEnd: -> @screenEnd + + getScopeStarts: -> @scopeStarts + getScopeEnds: -> @scopeEnds + + getScopes: -> @scopes + + getText: -> + @line.text.substring(@screenStart, @screenEnd) + + isSoftTab: -> + @line.specialTokens[@index] is SoftTab + + isHardTab: -> + @line.specialTokens[@index] is HardTab + + isSoftWrapIndentation: -> + @line.specialTokens[@index] is SoftWrapIndent + + isPairedCharacter: -> + @line.specialTokens[@index] is PairedCharacter + + isAtomic: -> + @isSoftTab() or @isHardTab() or @isSoftWrapIndentation() or @isPairedCharacter() diff --git a/src/token.coffee b/src/token.coffee index 778ea16e6..60e8194f8 100644 --- a/src/token.coffee +++ b/src/token.coffee @@ -1,14 +1,8 @@ _ = require 'underscore-plus' -{deprecate} = require 'grim' -textUtils = require './text-utils' -WhitespaceRegexesByTabLength = {} -EscapeRegex = /[&"'<>]/g StartDotRegex = /^\.?/ WhitespaceRegex = /\S/ -MaxTokenLength = 20000 - # Represents a single unit of text as selected by a grammar. module.exports = class Token @@ -21,138 +15,22 @@ class Token firstTrailingWhitespaceIndex: null hasInvisibleCharacters: false - constructor: ({@value, @scopes, @isAtomic, @bufferDelta, @isHardTab, @hasPairedCharacter, @isSoftWrapIndentation}) -> + constructor: (properties) -> + {@value, @scopes, @isAtomic, @isHardTab, @bufferDelta} = properties + {@hasInvisibleCharacters, @hasPairedCharacter, @isSoftWrapIndentation} = properties + @firstNonWhitespaceIndex = properties.firstNonWhitespaceIndex ? null + @firstTrailingWhitespaceIndex = properties.firstTrailingWhitespaceIndex ? null + @screenDelta = @value.length @bufferDelta ?= @screenDelta - @hasPairedCharacter ?= textUtils.hasPairedCharacter(@value) isEqual: (other) -> # TODO: scopes is deprecated. This is here for the sake of lang package tests - @value == other.value and _.isEqual(@scopes, other.scopes) and !!@isAtomic == !!other.isAtomic + @value is other.value and _.isEqual(@scopes, other.scopes) and !!@isAtomic is !!other.isAtomic isBracket: -> /^meta\.brace\b/.test(_.last(@scopes)) - splitAt: (splitIndex) -> - leftToken = new Token(value: @value.substring(0, splitIndex), scopes: @scopes) - rightToken = new Token(value: @value.substring(splitIndex), scopes: @scopes) - - if @firstNonWhitespaceIndex? - leftToken.firstNonWhitespaceIndex = Math.min(splitIndex, @firstNonWhitespaceIndex) - leftToken.hasInvisibleCharacters = @hasInvisibleCharacters - - if @firstNonWhitespaceIndex > splitIndex - rightToken.firstNonWhitespaceIndex = @firstNonWhitespaceIndex - splitIndex - rightToken.hasInvisibleCharacters = @hasInvisibleCharacters - - if @firstTrailingWhitespaceIndex? - rightToken.firstTrailingWhitespaceIndex = Math.max(0, @firstTrailingWhitespaceIndex - splitIndex) - rightToken.hasInvisibleCharacters = @hasInvisibleCharacters - - if @firstTrailingWhitespaceIndex < splitIndex - leftToken.firstTrailingWhitespaceIndex = @firstTrailingWhitespaceIndex - leftToken.hasInvisibleCharacters = @hasInvisibleCharacters - - [leftToken, rightToken] - - whitespaceRegexForTabLength: (tabLength) -> - WhitespaceRegexesByTabLength[tabLength] ?= new RegExp("([ ]{#{tabLength}})|(\t)|([^\t]+)", "g") - - breakOutAtomicTokens: (tabLength, breakOutLeadingSoftTabs, startColumn) -> - if @hasPairedCharacter - outputTokens = [] - column = startColumn - - for token in @breakOutPairedCharacters() - if token.isAtomic - outputTokens.push(token) - else - outputTokens.push(token.breakOutAtomicTokens(tabLength, breakOutLeadingSoftTabs, column)...) - breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs - column += token.value.length - - outputTokens - else - return [this] if @isAtomic - - if breakOutLeadingSoftTabs - return [this] unless /^[ ]|\t/.test(@value) - else - return [this] unless /\t/.test(@value) - - outputTokens = [] - regex = @whitespaceRegexForTabLength(tabLength) - column = startColumn - while match = regex.exec(@value) - [fullMatch, softTab, hardTab] = match - token = null - if softTab and breakOutLeadingSoftTabs - token = @buildSoftTabToken(tabLength) - else if hardTab - breakOutLeadingSoftTabs = false - token = @buildHardTabToken(tabLength, column) - else - breakOutLeadingSoftTabs = false - value = match[0] - token = new Token({value, @scopes}) - column += token.value.length - outputTokens.push(token) - - outputTokens - - breakOutPairedCharacters: -> - outputTokens = [] - index = 0 - nonPairStart = 0 - - while index < @value.length - if textUtils.isPairedCharacter(@value, index) - if nonPairStart isnt index - outputTokens.push(new Token({value: @value[nonPairStart...index], @scopes})) - outputTokens.push(@buildPairedCharacterToken(@value, index)) - index += 2 - nonPairStart = index - else - index++ - - if nonPairStart isnt index - outputTokens.push(new Token({value: @value[nonPairStart...index], @scopes})) - - outputTokens - - buildPairedCharacterToken: (value, index) -> - new Token( - value: value[index..index + 1] - scopes: @scopes - isAtomic: true - hasPairedCharacter: true - ) - - buildHardTabToken: (tabLength, column) -> - @buildTabToken(tabLength, true, column) - - buildSoftTabToken: (tabLength) -> - @buildTabToken(tabLength, false, 0) - - buildTabToken: (tabLength, isHardTab, column=0) -> - tabStop = tabLength - (column % tabLength) - new Token( - value: _.multiplyString(" ", tabStop) - scopes: @scopes - bufferDelta: if isHardTab then 1 else tabStop - isAtomic: true - isHardTab: isHardTab - ) - - buildSoftWrapIndentationToken: (length) -> - new Token( - value: _.multiplyString(" ", length), - scopes: @scopes, - bufferDelta: 0, - isAtomic: true, - isSoftWrapIndentation: true - ) - isOnlyWhitespace: -> not WhitespaceRegex.test(@value) @@ -162,72 +40,6 @@ class Token scopeClasses = scope.split('.') _.isSubset(targetClasses, scopeClasses) - getValueAsHtml: ({hasIndentGuide}) -> - if @isHardTab - classes = 'hard-tab' - classes += ' leading-whitespace' if @hasLeadingWhitespace() - classes += ' trailing-whitespace' if @hasTrailingWhitespace() - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if @hasInvisibleCharacters - html = "#{@escapeString(@value)}" - else - startIndex = 0 - endIndex = @value.length - - leadingHtml = '' - trailingHtml = '' - - if @hasLeadingWhitespace() - leadingWhitespace = @value.substring(0, @firstNonWhitespaceIndex) - - classes = 'leading-whitespace' - classes += ' indent-guide' if hasIndentGuide - classes += ' invisible-character' if @hasInvisibleCharacters - - leadingHtml = "#{leadingWhitespace}" - startIndex = @firstNonWhitespaceIndex - - if @hasTrailingWhitespace() - tokenIsOnlyWhitespace = @firstTrailingWhitespaceIndex is 0 - trailingWhitespace = @value.substring(@firstTrailingWhitespaceIndex) - - classes = 'trailing-whitespace' - classes += ' indent-guide' if hasIndentGuide and not @hasLeadingWhitespace() and tokenIsOnlyWhitespace - classes += ' invisible-character' if @hasInvisibleCharacters - - trailingHtml = "#{trailingWhitespace}" - - endIndex = @firstTrailingWhitespaceIndex - - html = leadingHtml - if @value.length > MaxTokenLength - while startIndex < endIndex - html += "" + @escapeString(@value, startIndex, startIndex + MaxTokenLength) + "" - startIndex += MaxTokenLength - else - html += @escapeString(@value, startIndex, endIndex) - - html += trailingHtml - html - - escapeString: (str, startIndex, endIndex) -> - strLength = str.length - - startIndex ?= 0 - endIndex ?= strLength - - str = str.slice(startIndex, endIndex) if startIndex > 0 or endIndex < strLength - str.replace(EscapeRegex, @escapeStringReplace) - - escapeStringReplace: (match) -> - switch match - when '&' then '&' - when '"' then '"' - when "'" then ''' - when '<' then '<' - when '>' then '>' - else match - hasLeadingWhitespace: -> @firstNonWhitespaceIndex? and @firstNonWhitespaceIndex > 0 diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 67e4deb87..c764c1a1f 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -1,10 +1,11 @@ _ = require 'underscore-plus' -{Model} = require 'theorist' -EmitterMixin = require('emissary').Emitter -{Emitter} = require 'event-kit' +{CompositeDisposable, Emitter} = require 'event-kit' {Point, Range} = require 'text-buffer' +{ScopeSelector} = require 'first-mate' Serializable = require 'serializable' +Model = require './model' TokenizedLine = require './tokenized-line' +TokenIterator = require './token-iterator' Token = require './token' ScopeDescriptor = require './scope-descriptor' Grim = require 'grim' @@ -13,31 +14,37 @@ module.exports = class TokenizedBuffer extends Model Serializable.includeInto(this) - @property 'tabLength' - grammar: null currentGrammarScore: null buffer: null + tabLength: null tokenizedLines: null chunkSize: 50 invalidRows: null visible: false + configSettings: null - constructor: ({@buffer, @tabLength, @invisibles}) -> + constructor: ({@buffer, @tabLength, @ignoreInvisibles, @largeFileMode}) -> @emitter = new Emitter + @disposables = new CompositeDisposable + @tokenIterator = new TokenIterator - @subscribe atom.grammars.onDidAddGrammar(@grammarAddedOrUpdated) - @subscribe atom.grammars.onDidUpdateGrammar(@grammarAddedOrUpdated) + @disposables.add atom.grammars.onDidAddGrammar(@grammarAddedOrUpdated) + @disposables.add atom.grammars.onDidUpdateGrammar(@grammarAddedOrUpdated) - @subscribe @buffer.preemptDidChange (e) => @handleBufferChange(e) - @subscribe @buffer.onDidChangePath (@bufferPath) => @reloadGrammar() + @disposables.add @buffer.preemptDidChange (e) => @handleBufferChange(e) + @disposables.add @buffer.onDidChangePath (@bufferPath) => @reloadGrammar() @reloadGrammar() + destroyed: -> + @disposables.dispose() + serializeParams: -> bufferPath: @buffer.getPath() tabLength: @tabLength - invisibles: _.clone(@invisibles) + ignoreInvisibles: @ignoreInvisibles + largeFileMode: @largeFileMode deserializeParams: (params) -> params.buffer = atom.project.bufferForPathSync(params.bufferPath) @@ -56,67 +63,72 @@ class TokenizedBuffer extends Model onDidTokenize: (callback) -> @emitter.on 'did-tokenize', callback - on: (eventName) -> - switch eventName - when 'changed' - Grim.deprecate("Use TokenizedBuffer::onDidChange instead") - when 'grammar-changed' - Grim.deprecate("Use TokenizedBuffer::onDidChangeGrammar instead") - when 'tokenized' - Grim.deprecate("Use TokenizedBuffer::onDidTokenize instead") - else - Grim.deprecate("TokenizedBuffer::on is deprecated. Use event subscription methods instead.") - - EmitterMixin::on.apply(this, arguments) - grammarAddedOrUpdated: (grammar) => if grammar.injectionSelector? @retokenizeLines() if @hasTokenForSelector(grammar.injectionSelector) else - newScore = grammar.getScore(@buffer.getPath(), @buffer.getText()) + newScore = grammar.getScore(@buffer.getPath(), @getGrammarSelectionContent()) @setGrammar(grammar, newScore) if newScore > @currentGrammarScore setGrammar: (grammar, score) -> return if grammar is @grammar - @unsubscribe(@grammar) if @grammar + @grammar = grammar @rootScopeDescriptor = new ScopeDescriptor(scopes: [@grammar.scopeName]) - @currentGrammarScore = score ? grammar.getScore(@buffer.getPath(), @buffer.getText()) - @subscribe @grammar.onDidUpdate => @retokenizeLines() + @currentGrammarScore = score ? grammar.getScore(@buffer.getPath(), @getGrammarSelectionContent()) - @configSettings = tabLength: atom.config.get('editor.tabLength', scope: @rootScopeDescriptor) + @grammarUpdateDisposable?.dispose() + @grammarUpdateDisposable = @grammar.onDidUpdate => @retokenizeLines() + @disposables.add(@grammarUpdateDisposable) - @grammarTabLengthSubscription?.dispose() - @grammarTabLengthSubscription = atom.config.onDidChange 'editor.tabLength', scope: @rootScopeDescriptor, ({newValue}) => + scopeOptions = {scope: @rootScopeDescriptor} + @configSettings = + tabLength: atom.config.get('editor.tabLength', scopeOptions) + invisibles: atom.config.get('editor.invisibles', scopeOptions) + showInvisibles: atom.config.get('editor.showInvisibles', scopeOptions) + + if @configSubscriptions? + @configSubscriptions.dispose() + @disposables.remove(@configSubscriptions) + @configSubscriptions = new CompositeDisposable + @configSubscriptions.add atom.config.onDidChange 'editor.tabLength', scopeOptions, ({newValue}) => @configSettings.tabLength = newValue @retokenizeLines() - @subscribe @grammarTabLengthSubscription + ['invisibles', 'showInvisibles'].forEach (key) => + @configSubscriptions.add atom.config.onDidChange "editor.#{key}", scopeOptions, ({newValue}) => + oldInvisibles = @getInvisiblesToShow() + @configSettings[key] = newValue + @retokenizeLines() unless _.isEqual(@getInvisiblesToShow(), oldInvisibles) + @disposables.add(@configSubscriptions) @retokenizeLines() - @emit 'grammar-changed', grammar + @emit 'grammar-changed', grammar if Grim.includeDeprecatedAPIs @emitter.emit 'did-change-grammar', grammar + getGrammarSelectionContent: -> + @buffer.getTextInRange([[0, 0], [10, 0]]) + reloadGrammar: -> - if grammar = atom.grammars.selectGrammar(@buffer.getPath(), @buffer.getText()) + if grammar = atom.grammars.selectGrammar(@buffer.getPath(), @getGrammarSelectionContent()) @setGrammar(grammar) else throw new Error("No grammar found for path: #{path}") hasTokenForSelector: (selector) -> - for {tokens} in @tokenizedLines - for token in tokens + for tokenizedLine in @tokenizedLines when tokenizedLine? + for token in tokenizedLine.tokens return true if selector.matches(token.scopes) false retokenizeLines: -> lastRow = @buffer.getLastRow() - @tokenizedLines = @buildPlaceholderTokenizedLinesForRows(0, lastRow) + @tokenizedLines = new Array(lastRow + 1) @invalidRows = [] @invalidateRow(0) @fullyTokenized = false event = {start: 0, end: lastRow, delta: 0} - @emit 'changed', event + @emit 'changed', event if Grim.includeDeprecatedAPIs @emitter.emit 'did-change', event setVisible: (@visible) -> @@ -131,19 +143,27 @@ class TokenizedBuffer extends Model @tabLength = tabLength @retokenizeLines() - setInvisibles: (invisibles) -> - unless _.isEqual(invisibles, @invisibles) - @invisibles = invisibles - @retokenizeLines() + setIgnoreInvisibles: (ignoreInvisibles) -> + if ignoreInvisibles isnt @ignoreInvisibles + @ignoreInvisibles = ignoreInvisibles + if @configSettings.showInvisibles and @configSettings.invisibles? + @retokenizeLines() tokenizeInBackground: -> return if not @visible or @pendingChunk or not @isAlive() + @pendingChunk = true _.defer => @pendingChunk = false @tokenizeNextChunk() if @isAlive() and @buffer.isAlive() tokenizeNextChunk: -> + # Short circuit null grammar which can just use the placeholder tokens + if @grammar is atom.grammars.nullGrammar and @firstInvalidRow()? + @invalidRows = [] + @markTokenizationComplete() + return + rowsRemaining = @chunkSize while @firstInvalidRow()? and rowsRemaining > 0 @@ -154,12 +174,12 @@ class TokenizedBuffer extends Model row = startRow loop previousStack = @stackForRow(row) - @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1)) - if --rowsRemaining == 0 + @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1), @openScopesForRow(row)) + if --rowsRemaining is 0 filledRegion = false endRow = row break - if row == lastRow or _.isEqual(@stackForRow(row), previousStack) + if row is lastRow or _.isEqual(@stackForRow(row), previousStack) filledRegion = true endRow = row break @@ -171,24 +191,30 @@ class TokenizedBuffer extends Model [startRow, endRow] = @updateFoldableStatus(startRow, endRow) event = {start: startRow, end: endRow, delta: 0} - @emit 'changed', event + @emit 'changed', event if Grim.includeDeprecatedAPIs @emitter.emit 'did-change', event if @firstInvalidRow()? @tokenizeInBackground() else - unless @fullyTokenized - @emit 'tokenized' - @emitter.emit 'did-tokenize' - @fullyTokenized = true + @markTokenizationComplete() + + markTokenizationComplete: -> + unless @fullyTokenized + @emit 'tokenized' if Grim.includeDeprecatedAPIs + @emitter.emit 'did-tokenize' + @fullyTokenized = true firstInvalidRow: -> @invalidRows[0] validateRow: (row) -> @invalidRows.shift() while @invalidRows[0] <= row + return invalidateRow: (row) -> + return if @largeFileMode + @invalidRows.push(row) @invalidRows.sort (a, b) -> a - b @tokenizeInBackground() @@ -210,7 +236,10 @@ class TokenizedBuffer extends Model @updateInvalidRows(start, end, delta) previousEndStack = @stackForRow(end) # used in spill detection below - newTokenizedLines = @buildTokenizedLinesForRows(start, end + delta, @stackForRow(start - 1)) + if @largeFileMode + newTokenizedLines = @buildPlaceholderTokenizedLinesForRows(start, end + delta) + else + newTokenizedLines = @buildTokenizedLinesForRows(start, end + delta, @stackForRow(start - 1), @openScopesForRow(start)) _.spliceWithArray(@tokenizedLines, start, end - start + 1, newTokenizedLines) start = @retokenizeWhitespaceRowsIfIndentLevelChanged(start - 1, -1) @@ -223,21 +252,23 @@ class TokenizedBuffer extends Model [start, end] = @updateFoldableStatus(start, end + delta) end -= delta - event = { start, end, delta, bufferChange: e } - @emit 'changed', event + event = {start, end, delta, bufferChange: e} + @emit 'changed', event if Grim.includeDeprecatedAPIs @emitter.emit 'did-change', event retokenizeWhitespaceRowsIfIndentLevelChanged: (row, increment) -> - line = @tokenizedLines[row] + line = @tokenizedLineForRow(row) if line?.isOnlyWhitespace() and @indentLevelForRow(row) isnt line.indentLevel while line?.isOnlyWhitespace() - @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1)) + @tokenizedLines[row] = @buildTokenizedLineForRow(row, @stackForRow(row - 1), @openScopesForRow(row)) row += increment - line = @tokenizedLines[row] + line = @tokenizedLineForRow(row) row - increment updateFoldableStatus: (startRow, endRow) -> + return [startRow, endRow] if @largeFileMode + scanStartRow = @buffer.previousNonBlankRow(startRow) ? startRow scanStartRow-- while scanStartRow > 0 and @tokenizedLineForRow(scanStartRow).isComment() scanEndRow = @buffer.nextNonBlankRow(endRow) ? endRow @@ -253,7 +284,10 @@ class TokenizedBuffer extends Model [startRow, endRow] isFoldableAtRow: (row) -> - @isFoldableCodeAtRow(row) or @isFoldableCommentAtRow(row) + if @largeFileMode + false + else + @isFoldableCodeAtRow(row) or @isFoldableCommentAtRow(row) # Returns a {Boolean} indicating whether the given buffer row starts # a a foldable row range due to the code's indentation patterns. @@ -273,16 +307,18 @@ class TokenizedBuffer extends Model @tokenizedLineForRow(row).isComment() and @tokenizedLineForRow(nextRow).isComment() - buildTokenizedLinesForRows: (startRow, endRow, startingStack) -> + buildTokenizedLinesForRows: (startRow, endRow, startingStack, startingopenScopes) -> ruleStack = startingStack + openScopes = startingopenScopes stopTokenizingAt = startRow + @chunkSize tokenizedLines = for row in [startRow..endRow] - if (ruleStack or row == 0) and row < stopTokenizingAt - screenLine = @buildTokenizedLineForRow(row, ruleStack) - ruleStack = screenLine.ruleStack + if (ruleStack or row is 0) and row < stopTokenizingAt + tokenizedLine = @buildTokenizedLineForRow(row, ruleStack, openScopes) + ruleStack = tokenizedLine.ruleStack + openScopes = @scopesFromTags(openScopes, tokenizedLine.tags) else - screenLine = @buildPlaceholderTokenizedLineForRow(row) - screenLine + tokenizedLine = @buildPlaceholderTokenizedLineForRow(row, openScopes) + tokenizedLine if endRow >= stopTokenizingAt @invalidateRow(stopTokenizingAt) @@ -291,32 +327,63 @@ class TokenizedBuffer extends Model tokenizedLines buildPlaceholderTokenizedLinesForRows: (startRow, endRow) -> - @buildPlaceholderTokenizedLineForRow(row) for row in [startRow..endRow] + @buildPlaceholderTokenizedLineForRow(row) for row in [startRow..endRow] by 1 buildPlaceholderTokenizedLineForRow: (row) -> - line = @buffer.lineForRow(row) - tokens = [new Token(value: line, scopes: [@grammar.scopeName])] + openScopes = [@grammar.startIdForScope(@grammar.scopeName)] + text = @buffer.lineForRow(row) + tags = [text.length] tabLength = @getTabLength() indentLevel = @indentLevelForRow(row) lineEnding = @buffer.lineEndingForRow(row) - new TokenizedLine({tokens, tabLength, indentLevel, @invisibles, lineEnding}) + new TokenizedLine({openScopes, text, tags, tabLength, indentLevel, invisibles: @getInvisiblesToShow(), lineEnding, @tokenIterator}) - buildTokenizedLineForRow: (row, ruleStack) -> - @buildTokenizedLineForRowWithText(row, @buffer.lineForRow(row), ruleStack) + buildTokenizedLineForRow: (row, ruleStack, openScopes) -> + @buildTokenizedLineForRowWithText(row, @buffer.lineForRow(row), ruleStack, openScopes) - buildTokenizedLineForRowWithText: (row, line, ruleStack = @stackForRow(row - 1)) -> + buildTokenizedLineForRowWithText: (row, text, ruleStack = @stackForRow(row - 1), openScopes = @openScopesForRow(row)) -> lineEnding = @buffer.lineEndingForRow(row) tabLength = @getTabLength() indentLevel = @indentLevelForRow(row) - {tokens, ruleStack} = @grammar.tokenizeLine(line, ruleStack, row is 0) - new TokenizedLine({tokens, ruleStack, tabLength, lineEnding, indentLevel, @invisibles}) + {tags, ruleStack} = @grammar.tokenizeLine(text, ruleStack, row is 0, false) + new TokenizedLine({openScopes, text, tags, ruleStack, tabLength, lineEnding, indentLevel, invisibles: @getInvisiblesToShow(), @tokenIterator}) + + getInvisiblesToShow: -> + if @configSettings.showInvisibles and not @ignoreInvisibles + @configSettings.invisibles + else + null tokenizedLineForRow: (bufferRow) -> - @tokenizedLines[bufferRow] + if 0 <= bufferRow < @tokenizedLines.length + @tokenizedLines[bufferRow] ?= @buildPlaceholderTokenizedLineForRow(bufferRow) + + tokenizedLinesForRows: (startRow, endRow) -> + for row in [startRow..endRow] by 1 + @tokenizedLineForRow(row) stackForRow: (bufferRow) -> @tokenizedLines[bufferRow]?.ruleStack + openScopesForRow: (bufferRow) -> + if bufferRow > 0 + precedingLine = @tokenizedLines[bufferRow - 1] + @scopesFromTags(precedingLine.openScopes, precedingLine.tags) + else + [] + + scopesFromTags: (startingScopes, tags) -> + scopes = startingScopes.slice() + for tag in tags when tag < 0 + if (tag % 2) is -1 + scopes.push(tag) + else + expectedScope = tag + 1 + poppedScope = scopes.pop() + unless poppedScope is expectedScope + throw new Error("Encountered an invalid scope end id. Popped #{poppedScope}, expected to pop #{expectedScope}.") + scopes + indentLevelForRow: (bufferRow) -> line = @buffer.lineForRow(bufferRow) indentLevel = 0 @@ -344,106 +411,86 @@ class TokenizedBuffer extends Model @indentLevelForLine(line) indentLevelForLine: (line) -> - if match = line.match(/^[\t ]+/) - leadingWhitespace = match[0] - tabCount = leadingWhitespace.match(/\t/g)?.length ? 0 - spaceCount = leadingWhitespace.match(/[ ]/g)?.length ? 0 - tabCount + (spaceCount / @getTabLength()) + if match = line.match(/^\t+/) + match[0].length + else if match = line.match(/^ +/) + match[0].length / @getTabLength() else 0 scopeDescriptorForPosition: (position) -> - new ScopeDescriptor(scopes: @tokenForPosition(position).scopes) + {row, column} = Point.fromObject(position) + + iterator = @tokenizedLines[row].getTokenIterator() + while iterator.next() + if iterator.getBufferEnd() > column + scopes = iterator.getScopes() + break + + # rebuild scope of last token if we iterated off the end + unless scopes? + scopes = iterator.getScopes() + scopes.push(iterator.getScopeEnds().reverse()...) + + new ScopeDescriptor({scopes}) tokenForPosition: (position) -> {row, column} = Point.fromObject(position) - @tokenizedLines[row].tokenAtBufferColumn(column) + @tokenizedLineForRow(row).tokenAtBufferColumn(column) tokenStartPositionForPosition: (position) -> {row, column} = Point.fromObject(position) - column = @tokenizedLines[row].tokenStartColumnForBufferColumn(column) + column = @tokenizedLineForRow(row).tokenStartColumnForBufferColumn(column) new Point(row, column) bufferRangeForScopeAtPosition: (selector, position) -> position = Point.fromObject(position) - tokenizedLine = @tokenizedLines[position.row] - startIndex = tokenizedLine.tokenIndexAtBufferColumn(position.column) - for index in [startIndex..0] - token = tokenizedLine.tokenAtIndex(index) - break unless token.matchesScopeSelector(selector) - firstToken = token + {openScopes, tags} = @tokenizedLineForRow(position.row) + scopes = openScopes.map (tag) -> atom.grammars.scopeForId(tag) - for index in [startIndex...tokenizedLine.getTokenCount()] - token = tokenizedLine.tokenAtIndex(index) - break unless token.matchesScopeSelector(selector) - lastToken = token + startColumn = 0 + for tag, tokenIndex in tags + if tag < 0 + if tag % 2 is -1 + scopes.push(atom.grammars.scopeForId(tag)) + else + scopes.pop() + else + endColumn = startColumn + tag + if endColumn > position.column + break + else + startColumn = endColumn - return unless firstToken? and lastToken? - startColumn = tokenizedLine.bufferColumnForToken(firstToken) - endColumn = tokenizedLine.bufferColumnForToken(lastToken) + lastToken.bufferDelta - new Range([position.row, startColumn], [position.row, endColumn]) + return unless selectorMatchesAnyScope(selector, scopes) - iterateTokensInBufferRange: (bufferRange, iterator) -> - bufferRange = Range.fromObject(bufferRange) - { start, end } = bufferRange + startScopes = scopes.slice() + for startTokenIndex in [(tokenIndex - 1)..0] by -1 + tag = tags[startTokenIndex] + if tag < 0 + if tag % 2 is -1 + startScopes.pop() + else + startScopes.push(atom.grammars.scopeForId(tag)) + else + break unless selectorMatchesAnyScope(selector, startScopes) + startColumn -= tag - keepLooping = true - stop = -> keepLooping = false + endScopes = scopes.slice() + for endTokenIndex in [(tokenIndex + 1)...tags.length] by 1 + tag = tags[endTokenIndex] + if tag < 0 + if tag % 2 is -1 + endScopes.push(atom.grammars.scopeForId(tag)) + else + endScopes.pop() + else + break unless selectorMatchesAnyScope(selector, endScopes) + endColumn += tag - for bufferRow in [start.row..end.row] - bufferColumn = 0 - for token in @tokenizedLines[bufferRow].tokens - startOfToken = new Point(bufferRow, bufferColumn) - iterator(token, startOfToken, { stop }) if bufferRange.containsPoint(startOfToken) - return unless keepLooping - bufferColumn += token.bufferDelta - - backwardsIterateTokensInBufferRange: (bufferRange, iterator) -> - bufferRange = Range.fromObject(bufferRange) - { start, end } = bufferRange - - keepLooping = true - stop = -> keepLooping = false - - for bufferRow in [end.row..start.row] - bufferColumn = @buffer.lineLengthForRow(bufferRow) - for token in new Array(@tokenizedLines[bufferRow].tokens...).reverse() - bufferColumn -= token.bufferDelta - startOfToken = new Point(bufferRow, bufferColumn) - iterator(token, startOfToken, { stop }) if bufferRange.containsPoint(startOfToken) - return unless keepLooping - - findOpeningBracket: (startBufferPosition) -> - range = [[0,0], startBufferPosition] - position = null - depth = 0 - @backwardsIterateTokensInBufferRange range, (token, startPosition, { stop }) -> - if token.isBracket() - if token.value == '}' - depth++ - else if token.value == '{' - depth-- - if depth == 0 - position = startPosition - stop() - position - - findClosingBracket: (startBufferPosition) -> - range = [startBufferPosition, @buffer.getEndPosition()] - position = null - depth = 0 - @iterateTokensInBufferRange range, (token, startPosition, { stop }) -> - if token.isBracket() - if token.value == '{' - depth++ - else if token.value == '}' - depth-- - if depth == 0 - position = startPosition - stop() - position + new Range(new Point(position.row, startColumn), new Point(position.row, endColumn)) # Gets the row number of the last line. # @@ -458,3 +505,26 @@ class TokenizedBuffer extends Model for row in [start..end] line = @tokenizedLineForRow(row).text console.log row, line, line.length + return + +if Grim.includeDeprecatedAPIs + EmitterMixin = require('emissary').Emitter + + TokenizedBuffer::on = (eventName) -> + switch eventName + when 'changed' + Grim.deprecate("Use TokenizedBuffer::onDidChange instead") + when 'grammar-changed' + Grim.deprecate("Use TokenizedBuffer::onDidChangeGrammar instead") + when 'tokenized' + Grim.deprecate("Use TokenizedBuffer::onDidTokenize instead") + else + Grim.deprecate("TokenizedBuffer::on is deprecated. Use event subscription methods instead.") + + EmitterMixin::on.apply(this, arguments) + +selectorMatchesAnyScope = (selector, scopes) -> + targetClasses = selector.replace(/^\./, '').split('.') + _.any scopes, (scope) -> + scopeClasses = scope.split('.') + _.isSubset(targetClasses, scopeClasses) diff --git a/src/tokenized-line.coffee b/src/tokenized-line.coffee index 3560c35a1..bd871fc4f 100644 --- a/src/tokenized-line.coffee +++ b/src/tokenized-line.coffee @@ -1,84 +1,306 @@ _ = require 'underscore-plus' {isPairedCharacter} = require './text-utils' +Token = require './token' +{SoftTab, HardTab, PairedCharacter, SoftWrapIndent} = require './special-token-symbols' NonWhitespaceRegex = /\S/ LeadingWhitespaceRegex = /^\s*/ TrailingWhitespaceRegex = /\s*$/ RepeatedSpaceRegex = /[ ]/g +CommentScopeRegex = /(\b|\.)comment/ +TabCharCode = 9 +SpaceCharCode = 32 +SpaceString = ' ' +TabStringsByLength = { + 1: ' ' + 2: ' ' + 3: ' ' + 4: ' ' +} + idCounter = 1 +getTabString = (length) -> + TabStringsByLength[length] ?= buildTabString(length) + +buildTabString = (length) -> + string = SpaceString + string += SpaceString for i in [1...length] by 1 + string + module.exports = class TokenizedLine endOfLineInvisibles: null lineIsWhitespaceOnly: false + firstNonWhitespaceIndex: 0 foldable: false - constructor: ({tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold, @tabLength, @indentLevel, @invisibles}) -> - @startBufferColumn ?= 0 - @tokens = @breakOutAtomicTokens(tokens) - @text = @buildText() - @bufferDelta = @buildBufferDelta() - @softWrapIndentationTokens = @getSoftWrapIndentationTokens() - @softWrapIndentationDelta = @buildSoftWrapIndentationDelta() - + constructor: (properties) -> @id = idCounter++ - @markLeadingAndTrailingWhitespaceTokens() - if @invisibles - @substituteInvisibleCharacters() - @buildEndOfLineInvisibles() if @lineEnding? - buildText: -> - text = "" - text += token.value for token in @tokens - text + return unless properties? - buildBufferDelta: -> - delta = 0 - delta += token.bufferDelta for token in @tokens - delta + @specialTokens = {} + {@openScopes, @text, @tags, @lineEnding, @ruleStack, @tokenIterator} = properties + {@startBufferColumn, @fold, @tabLength, @indentLevel, @invisibles} = properties + + @startBufferColumn ?= 0 + @bufferDelta = @text.length + + @transformContent() + @buildEndOfLineInvisibles() if @invisibles? and @lineEnding? + + transformContent: -> + text = '' + bufferColumn = 0 + screenColumn = 0 + tokenIndex = 0 + tokenOffset = 0 + firstNonWhitespaceColumn = null + lastNonWhitespaceColumn = null + + substringStart = 0 + substringEnd = 0 + + while bufferColumn < @text.length + # advance to next token if we've iterated over its length + if tokenOffset is @tags[tokenIndex] + tokenIndex++ + tokenOffset = 0 + + # advance to next token tag + tokenIndex++ while @tags[tokenIndex] < 0 + + charCode = @text.charCodeAt(bufferColumn) + + # split out unicode surrogate pairs + if isPairedCharacter(@text, bufferColumn) + prefix = tokenOffset + suffix = @tags[tokenIndex] - tokenOffset - 2 + + i = tokenIndex + @tags.splice(i, 1) + @tags.splice(i++, 0, prefix) if prefix > 0 + @tags.splice(i++, 0, 2) + @tags.splice(i, 0, suffix) if suffix > 0 + + firstNonWhitespaceColumn ?= screenColumn + lastNonWhitespaceColumn = screenColumn + 1 + + substringEnd += 2 + screenColumn += 2 + bufferColumn += 2 + + tokenIndex++ if prefix > 0 + @specialTokens[tokenIndex] = PairedCharacter + tokenIndex++ + tokenOffset = 0 + + # split out leading soft tabs + else if charCode is SpaceCharCode + if firstNonWhitespaceColumn? + substringEnd += 1 + else + if (screenColumn + 1) % @tabLength is 0 + suffix = @tags[tokenIndex] - @tabLength + if suffix >= 0 + @specialTokens[tokenIndex] = SoftTab + @tags.splice(tokenIndex, 1, @tabLength) + @tags.splice(tokenIndex + 1, 0, suffix) if suffix > 0 + + if @invisibles?.space + if substringEnd > substringStart + text += @text.substring(substringStart, substringEnd) + substringStart = substringEnd + text += @invisibles.space + substringStart += 1 + + substringEnd += 1 + + screenColumn++ + bufferColumn++ + tokenOffset++ + + # expand hard tabs to the next tab stop + else if charCode is TabCharCode + if substringEnd > substringStart + text += @text.substring(substringStart, substringEnd) + substringStart = substringEnd + + tabLength = @tabLength - (screenColumn % @tabLength) + if @invisibles?.tab + text += @invisibles.tab + text += getTabString(tabLength - 1) if tabLength > 1 + else + text += getTabString(tabLength) + + substringStart += 1 + substringEnd += 1 + + prefix = tokenOffset + suffix = @tags[tokenIndex] - tokenOffset - 1 + + i = tokenIndex + @tags.splice(i, 1) + @tags.splice(i++, 0, prefix) if prefix > 0 + @tags.splice(i++, 0, tabLength) + @tags.splice(i, 0, suffix) if suffix > 0 + + screenColumn += tabLength + bufferColumn++ + + tokenIndex++ if prefix > 0 + @specialTokens[tokenIndex] = HardTab + tokenIndex++ + tokenOffset = 0 + + # continue past any other character + else + firstNonWhitespaceColumn ?= screenColumn + lastNonWhitespaceColumn = screenColumn + + substringEnd += 1 + screenColumn++ + bufferColumn++ + tokenOffset++ + + if substringEnd > substringStart + unless substringStart is 0 and substringEnd is @text.length + text += @text.substring(substringStart, substringEnd) + @text = text + else + @text = text + + @firstNonWhitespaceIndex = firstNonWhitespaceColumn + if lastNonWhitespaceColumn? + if lastNonWhitespaceColumn + 1 < @text.length + @firstTrailingWhitespaceIndex = lastNonWhitespaceColumn + 1 + if @invisibles?.space + @text = + @text.substring(0, @firstTrailingWhitespaceIndex) + + @text.substring(@firstTrailingWhitespaceIndex) + .replace(RepeatedSpaceRegex, @invisibles.space) + else + @lineIsWhitespaceOnly = true + @firstTrailingWhitespaceIndex = 0 + + getTokenIterator: -> @tokenIterator.reset(this) + + Object.defineProperty @prototype, 'tokens', get: -> + iterator = @getTokenIterator() + tokens = [] + + while iterator.next() + properties = { + value: iterator.getText() + scopes: iterator.getScopes().slice() + isAtomic: iterator.isAtomic() + isHardTab: iterator.isHardTab() + hasPairedCharacter: iterator.isPairedCharacter() + isSoftWrapIndentation: iterator.isSoftWrapIndentation() + } + + if iterator.isHardTab() + properties.bufferDelta = 1 + properties.hasInvisibleCharacters = true if @invisibles?.tab + + if iterator.getScreenStart() < @firstNonWhitespaceIndex + properties.firstNonWhitespaceIndex = + Math.min(@firstNonWhitespaceIndex, iterator.getScreenEnd()) - iterator.getScreenStart() + properties.hasInvisibleCharacters = true if @invisibles?.space + + if @lineEnding? and iterator.getScreenEnd() > @firstTrailingWhitespaceIndex + properties.firstTrailingWhitespaceIndex = + Math.max(0, @firstTrailingWhitespaceIndex - iterator.getScreenStart()) + properties.hasInvisibleCharacters = true if @invisibles?.space + + tokens.push(new Token(properties)) + + tokens copy: -> - new TokenizedLine({@tokens, @lineEnding, @ruleStack, @startBufferColumn, @fold}) + copy = new TokenizedLine + copy.tokenIterator = @tokenIterator + copy.openScopes = @openScopes + copy.text = @text + copy.tags = @tags + copy.specialTokens = @specialTokens + copy.startBufferColumn = @startBufferColumn + copy.bufferDelta = @bufferDelta + copy.ruleStack = @ruleStack + copy.lineEnding = @lineEnding + copy.invisibles = @invisibles + copy.endOfLineInvisibles = @endOfLineInvisibles + copy.indentLevel = @indentLevel + copy.tabLength = @tabLength + copy.firstNonWhitespaceIndex = @firstNonWhitespaceIndex + copy.firstTrailingWhitespaceIndex = @firstTrailingWhitespaceIndex + copy.fold = @fold + copy + # This clips a given screen column to a valid column that's within the line + # and not in the middle of any atomic tokens. + # + # column - A {Number} representing the column to clip + # options - A hash with the key clip. Valid values for this key: + # 'closest' (default): clip to the closest edge of an atomic token. + # 'forward': clip to the forward edge. + # 'backward': clip to the backward edge. + # + # Returns a {Number} representing the clipped column. clipScreenColumn: (column, options={}) -> - return 0 if @tokens.length == 0 + return 0 if @tags.length is 0 - { skipAtomicTokens } = options + {clip} = options column = Math.min(column, @getMaxScreenColumn()) tokenStartColumn = 0 - for token in @tokens - break if tokenStartColumn + token.screenDelta > column - tokenStartColumn += token.screenDelta - if @isColumnInsideSoftWrapIndentation(tokenStartColumn) - @softWrapIndentationDelta - else if token.isAtomic and tokenStartColumn < column - if skipAtomicTokens - tokenStartColumn + token.screenDelta - else - tokenStartColumn + iterator = @getTokenIterator() + while iterator.next() + break if iterator.getScreenEnd() > column + + if iterator.isSoftWrapIndentation() + iterator.next() while iterator.isSoftWrapIndentation() + iterator.getScreenStart() + else if iterator.isAtomic() and iterator.getScreenStart() < column + if clip is 'forward' + iterator.getScreenEnd() + else if clip is 'backward' + iterator.getScreenStart() + else #'closest' + if column > ((iterator.getScreenStart() + iterator.getScreenEnd()) / 2) + iterator.getScreenEnd() + else + iterator.getScreenStart() else column - screenColumnForBufferColumn: (bufferColumn, options) -> - bufferColumn = bufferColumn - @startBufferColumn - screenColumn = 0 - currentBufferColumn = 0 - for token in @tokens - break if currentBufferColumn > bufferColumn - screenColumn += token.screenDelta - currentBufferColumn += token.bufferDelta - @clipScreenColumn(screenColumn + (bufferColumn - currentBufferColumn)) + screenColumnForBufferColumn: (targetBufferColumn, options) -> + iterator = @getTokenIterator() + while iterator.next() + tokenBufferStart = iterator.getBufferStart() + tokenBufferEnd = iterator.getBufferEnd() + if tokenBufferStart <= targetBufferColumn < tokenBufferEnd + overshoot = targetBufferColumn - tokenBufferStart + return Math.min( + iterator.getScreenStart() + overshoot, + iterator.getScreenEnd() + ) + iterator.getScreenEnd() - bufferColumnForScreenColumn: (screenColumn, options) -> - bufferColumn = @startBufferColumn - currentScreenColumn = 0 - for token in @tokens - break if currentScreenColumn + token.screenDelta > screenColumn - bufferColumn += token.bufferDelta - currentScreenColumn += token.screenDelta - bufferColumn + (screenColumn - currentScreenColumn) + bufferColumnForScreenColumn: (targetScreenColumn) -> + iterator = @getTokenIterator() + while iterator.next() + tokenScreenStart = iterator.getScreenStart() + tokenScreenEnd = iterator.getScreenEnd() + if tokenScreenStart <= targetScreenColumn < tokenScreenEnd + overshoot = targetScreenColumn - tokenScreenStart + return Math.min( + iterator.getBufferStart() + overshoot, + iterator.getBufferEnd() + ) + iterator.getBufferEnd() getMaxScreenColumn: -> if @fold @@ -96,6 +318,7 @@ class TokenizedLine # Returns a {Number} representing the `line` position where the wrap would take place. # Returns `null` if a wrap wouldn't occur. findWrapColumn: (maxColumn) -> + return unless maxColumn? return unless @text.length > maxColumn if /\s/.test(@text[maxColumn]) @@ -106,85 +329,133 @@ class TokenizedLine return @text.length else # search backward for the start of the word on the boundary - for column in [maxColumn..0] when @isColumnOutsideSoftWrapIndentation(column) + for column in [maxColumn..@firstNonWhitespaceIndex] return column + 1 if /\s/.test(@text[column]) return maxColumn - # Calculates how many trailing spaces in this line's indentation cannot fit in a single tab. - # - # Returns a {Number} representing the odd indentation spaces in this line. - getOddIndentationSpaces: -> - oddIndentLevel = @indentLevel - Math.floor(@indentLevel) - Math.round(@tabLength * oddIndentLevel) + softWrapAt: (column, hangingIndent) -> + return [null, this] if column is 0 - buildSoftWrapIndentationTokens: (token) -> - indentTokens = [0...Math.floor(@indentLevel)].map => - token.buildSoftWrapIndentationToken(@tabLength) + leftText = @text.substring(0, column) + rightText = @text.substring(column) - if @getOddIndentationSpaces() - indentTokens.concat( - token.buildSoftWrapIndentationToken @getOddIndentationSpaces() - ) - else - indentTokens + leftTags = [] + rightTags = [] - softWrapAt: (column) -> - return [new TokenizedLine([], '', [0, 0], [0, 0]), this] if column == 0 + leftSpecialTokens = {} + rightSpecialTokens = {} - rightTokens = new Array(@tokens...) - leftTokens = [] - leftTextLength = 0 - while leftTextLength < column - if leftTextLength + rightTokens[0].value.length > column - rightTokens[0..0] = rightTokens[0].splitAt(column - leftTextLength) - nextToken = rightTokens.shift() - leftTextLength += nextToken.value.length - leftTokens.push nextToken + rightOpenScopes = @openScopes.slice() - indentationTokens = @buildSoftWrapIndentationTokens(leftTokens[0]) + screenColumn = 0 + + for tag, index in @tags + # tag represents a token + if tag >= 0 + # token ends before the soft wrap column + if screenColumn + tag <= column + if specialToken = @specialTokens[index] + leftSpecialTokens[index] = specialToken + leftTags.push(tag) + screenColumn += tag + + # token starts before and ends after the split column + else if screenColumn <= column + leftSuffix = column - screenColumn + rightPrefix = screenColumn + tag - column + + leftTags.push(leftSuffix) if leftSuffix > 0 + + softWrapIndent = @indentLevel * @tabLength + (hangingIndent ? 0) + for i in [0...softWrapIndent] by 1 + rightText = ' ' + rightText + remainingSoftWrapIndent = softWrapIndent + while remainingSoftWrapIndent > 0 + indentToken = Math.min(remainingSoftWrapIndent, @tabLength) + rightSpecialTokens[rightTags.length] = SoftWrapIndent + rightTags.push(indentToken) + remainingSoftWrapIndent -= indentToken + + rightTags.push(rightPrefix) if rightPrefix > 0 + + screenColumn += tag + + # token is after split column + else + if specialToken = @specialTokens[index] + rightSpecialTokens[rightTags.length] = specialToken + rightTags.push(tag) + + # tag represents the start or end of a scop + else if (tag % 2) is -1 + if screenColumn < column + leftTags.push(tag) + rightOpenScopes.push(tag) + else + rightTags.push(tag) + else + if screenColumn < column + leftTags.push(tag) + rightOpenScopes.pop() + else + rightTags.push(tag) + + splitBufferColumn = @bufferColumnForScreenColumn(column) + + leftFragment = new TokenizedLine + leftFragment.tokenIterator = @tokenIterator + leftFragment.openScopes = @openScopes + leftFragment.text = leftText + leftFragment.tags = leftTags + leftFragment.specialTokens = leftSpecialTokens + leftFragment.startBufferColumn = @startBufferColumn + leftFragment.bufferDelta = splitBufferColumn - @startBufferColumn + leftFragment.ruleStack = @ruleStack + leftFragment.invisibles = @invisibles + leftFragment.lineEnding = null + leftFragment.indentLevel = @indentLevel + leftFragment.tabLength = @tabLength + leftFragment.firstNonWhitespaceIndex = Math.min(column, @firstNonWhitespaceIndex) + leftFragment.firstTrailingWhitespaceIndex = Math.min(column, @firstTrailingWhitespaceIndex) + + rightFragment = new TokenizedLine + rightFragment.tokenIterator = @tokenIterator + rightFragment.openScopes = rightOpenScopes + rightFragment.text = rightText + rightFragment.tags = rightTags + rightFragment.specialTokens = rightSpecialTokens + rightFragment.startBufferColumn = splitBufferColumn + rightFragment.bufferDelta = @startBufferColumn + @bufferDelta - splitBufferColumn + rightFragment.ruleStack = @ruleStack + rightFragment.invisibles = @invisibles + rightFragment.lineEnding = @lineEnding + rightFragment.indentLevel = @indentLevel + rightFragment.tabLength = @tabLength + rightFragment.endOfLineInvisibles = @endOfLineInvisibles + rightFragment.firstNonWhitespaceIndex = Math.max(softWrapIndent, @firstNonWhitespaceIndex - column + softWrapIndent) + rightFragment.firstTrailingWhitespaceIndex = Math.max(softWrapIndent, @firstTrailingWhitespaceIndex - column + softWrapIndent) - leftFragment = new TokenizedLine( - tokens: leftTokens - startBufferColumn: @startBufferColumn - ruleStack: @ruleStack - invisibles: @invisibles - lineEnding: null, - indentLevel: @indentLevel, - tabLength: @tabLength - ) - rightFragment = new TokenizedLine( - tokens: indentationTokens.concat(rightTokens) - startBufferColumn: @bufferColumnForScreenColumn(column) - ruleStack: @ruleStack - invisibles: @invisibles - lineEnding: @lineEnding, - indentLevel: @indentLevel, - tabLength: @tabLength - ) [leftFragment, rightFragment] isSoftWrapped: -> @lineEnding is null - isColumnOutsideSoftWrapIndentation: (column) -> - return true if @softWrapIndentationTokens.length == 0 + isColumnInsideSoftWrapIndentation: (targetColumn) -> + targetColumn < @getSoftWrapIndentationDelta() - column > @softWrapIndentationDelta - - isColumnInsideSoftWrapIndentation: (column) -> - return false if @softWrapIndentationTokens.length == 0 - - column < @softWrapIndentationDelta - - getSoftWrapIndentationTokens: -> - _.select(@tokens, (token) -> token.isSoftWrapIndentation) - - buildSoftWrapIndentationDelta: -> - _.reduce @softWrapIndentationTokens, ((acc, token) -> acc + token.screenDelta), 0 + getSoftWrapIndentationDelta: -> + delta = 0 + for tag, index in @tags + if tag >= 0 + if @specialTokens[index] is SoftWrapIndent + delta += tag + else + break + delta hasOnlySoftWrapIndentation: -> - @tokens.length == @softWrapIndentationTokens.length + @getSoftWrapIndentationDelta() is @text.length tokenAtBufferColumn: (bufferColumn) -> @tokens[@tokenIndexAtBufferColumn(bufferColumn)] @@ -204,57 +475,6 @@ class TokenizedLine delta = nextDelta delta - breakOutAtomicTokens: (inputTokens) -> - outputTokens = [] - breakOutLeadingSoftTabs = true - column = @startBufferColumn - for token in inputTokens - newTokens = token.breakOutAtomicTokens(@tabLength, breakOutLeadingSoftTabs, column) - column += newToken.value.length for newToken in newTokens - outputTokens.push(newTokens...) - breakOutLeadingSoftTabs = token.isOnlyWhitespace() if breakOutLeadingSoftTabs - outputTokens - - markLeadingAndTrailingWhitespaceTokens: -> - firstNonWhitespaceIndex = @text.search(NonWhitespaceRegex) - if firstNonWhitespaceIndex > 0 and isPairedCharacter(@text, firstNonWhitespaceIndex - 1) - firstNonWhitespaceIndex-- - firstTrailingWhitespaceIndex = @text.search(TrailingWhitespaceRegex) - @lineIsWhitespaceOnly = firstTrailingWhitespaceIndex is 0 - index = 0 - for token in @tokens - if index < firstNonWhitespaceIndex - token.firstNonWhitespaceIndex = Math.min(index + token.value.length, firstNonWhitespaceIndex - index) - # Only the *last* segment of a soft-wrapped line can have trailing whitespace - if @lineEnding? and (index + token.value.length > firstTrailingWhitespaceIndex) - token.firstTrailingWhitespaceIndex = Math.max(0, firstTrailingWhitespaceIndex - index) - index += token.value.length - - substituteInvisibleCharacters: -> - invisibles = @invisibles - changedText = false - - for token, i in @tokens - if token.isHardTab - if invisibles.tab - token.value = invisibles.tab + token.value.substring(invisibles.tab.length) - token.hasInvisibleCharacters = true - changedText = true - else - if invisibles.space - if token.hasLeadingWhitespace() and not token.isSoftWrapIndentation - token.value = token.value.replace LeadingWhitespaceRegex, (leadingWhitespace) -> - leadingWhitespace.replace RepeatedSpaceRegex, invisibles.space - token.hasInvisibleCharacters = true - changedText = true - if token.hasTrailingWhitespace() - token.value = token.value.replace TrailingWhitespaceRegex, (leadingWhitespace) -> - leadingWhitespace.replace RepeatedSpaceRegex, invisibles.space - token.hasInvisibleCharacters = true - changedText = true - - @text = @buildText() if changedText - buildEndOfLineInvisibles: -> @endOfLineInvisibles = [] {cr, eol} = @invisibles @@ -267,11 +487,13 @@ class TokenizedLine @endOfLineInvisibles.push(eol) if eol isComment: -> - for token in @tokens - continue if token.scopes.length is 1 - continue if token.isOnlyWhitespace() - for scope in token.scopes - return true if _.contains(scope.split('.'), 'comment') + iterator = @getTokenIterator() + while iterator.next() + scopes = iterator.getScopes() + continue if scopes.length is 1 + continue unless NonWhitespaceRegex.test(iterator.getText()) + for scope in scopes + return true if CommentScopeRegex.test(scope) break false @@ -282,40 +504,6 @@ class TokenizedLine @tokens[index] getTokenCount: -> - @tokens.length - - bufferColumnForToken: (targetToken) -> - column = 0 - for token in @tokens - return column if token is targetToken - column += token.bufferDelta - - getScopeTree: -> - return @scopeTree if @scopeTree? - - scopeStack = [] - for token in @tokens - @updateScopeStack(scopeStack, token.scopes) - _.last(scopeStack).children.push(token) - - @scopeTree = scopeStack[0] - @updateScopeStack(scopeStack, []) - @scopeTree - - updateScopeStack: (scopeStack, desiredScopeDescriptor) -> - # Find a common prefix - for scope, i in desiredScopeDescriptor - break unless scopeStack[i]?.scope is desiredScopeDescriptor[i] - - # Pop scopeDescriptor until we're at the common prefx - until scopeStack.length is i - poppedScope = scopeStack.pop() - _.last(scopeStack)?.children.push(poppedScope) - - # Push onto common prefix until scopeStack equals desiredScopeDescriptor - for j in [i...desiredScopeDescriptor.length] - scopeStack.push(new Scope(desiredScopeDescriptor[j])) - -class Scope - constructor: (@scope) -> - @children = [] + count = 0 + count++ for tag in @tags when tag >= 0 + count diff --git a/src/tooltip-manager.coffee b/src/tooltip-manager.coffee index afde69db9..3c9777123 100644 --- a/src/tooltip-manager.coffee +++ b/src/tooltip-manager.coffee @@ -57,9 +57,10 @@ class TooltipManager # Essential: Add a tooltip to the given element. # # * `target` An `HTMLElement` - # * `options` See http://getbootstrap.com/javascript/#tooltips for a full list - # of options. You can also supply the following additional options: - # * `title` {String} Text in the tip. + # * `options` See http://getbootstrap.com/javascript/#tooltips-options for a + # full list of options. You can also supply the following additional options: + # * `title` A {String} or {Function} to use for the text in the tip. If + # given a function, `this` will be set to the `target` element. # * `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. @@ -87,8 +88,9 @@ class TooltipManager new Disposable -> tooltip = $target.data('bs.tooltip') - tooltip.leave(currentTarget: target) - tooltip.hide() + if tooltip? + tooltip.leave(currentTarget: target) + tooltip.hide() $target.tooltip('destroy') humanizeKeystrokes = (keystroke) -> diff --git a/src/typescript.coffee b/src/typescript.coffee new file mode 100644 index 000000000..3a54941f3 --- /dev/null +++ b/src/typescript.coffee @@ -0,0 +1,106 @@ +### +Cache for source code transpiled by TypeScript. + +Inspired by https://github.com/atom/atom/blob/7a719d585db96ff7d2977db9067e1d9d4d0adf1a/src/babel.coffee +### + +crypto = require 'crypto' +fs = require 'fs-plus' +path = require 'path' +tss = null # Defer until used + +stats = + hits: 0 + misses: 0 + +defaultOptions = + target: 1 # ES5 + module: 'commonjs' + sourceMap: true + +createTypeScriptVersionAndOptionsDigest = (version, options) -> + shasum = crypto.createHash('sha1') + # Include the version of typescript in the hash. + shasum.update('typescript', 'utf8') + shasum.update('\0', 'utf8') + shasum.update(version, 'utf8') + shasum.update('\0', 'utf8') + shasum.update(JSON.stringify(options)) + shasum.digest('hex') + +cacheDir = null +jsCacheDir = null + +getCachePath = (sourceCode) -> + digest = crypto.createHash('sha1').update(sourceCode, 'utf8').digest('hex') + + unless jsCacheDir? + tssVersion = require('typescript-simple/package.json').version + jsCacheDir = path.join(cacheDir, createTypeScriptVersionAndOptionsDigest(tssVersion, defaultOptions)) + + path.join(jsCacheDir, "#{digest}.js") + +getCachedJavaScript = (cachePath) -> + if fs.isFileSync(cachePath) + try + cachedJavaScript = fs.readFileSync(cachePath, 'utf8') + stats.hits++ + return cachedJavaScript + null + +# Returns the TypeScript options that should be used to transpile filePath. +createOptions = (filePath) -> + options = filename: filePath + for key, value of defaultOptions + options[key] = value + options + +transpile = (sourceCode, filePath, cachePath) -> + options = createOptions(filePath) + unless tss? + {TypeScriptSimple} = require 'typescript-simple' + tss = new TypeScriptSimple(options, false) + js = tss.compile(sourceCode, filePath) + stats.misses++ + + try + fs.writeFileSync(cachePath, js) + + js + +# Function that obeys the contract of an entry in the require.extensions map. +# Returns the transpiled version of the JavaScript code at filePath, which is +# either generated on the fly or pulled from cache. +loadFile = (module, filePath) -> + sourceCode = fs.readFileSync(filePath, 'utf8') + cachePath = getCachePath(sourceCode) + js = getCachedJavaScript(cachePath) ? transpile(sourceCode, filePath, cachePath) + module._compile(js, filePath) + +register = -> + Object.defineProperty(require.extensions, '.ts', { + enumerable: true + writable: false + value: loadFile + }) + +setCacheDirectory = (newCacheDir) -> + if cacheDir isnt newCacheDir + cacheDir = newCacheDir + jsCacheDir = null + +module.exports = + register: register + setCacheDirectory: setCacheDirectory + getCacheMisses: -> stats.misses + getCacheHits: -> stats.hits + + # Visible for testing. + createTypeScriptVersionAndOptionsDigest: createTypeScriptVersionAndOptionsDigest + + addPathToCache: (filePath) -> + return if path.extname(filePath) isnt '.ts' + + sourceCode = fs.readFileSync(filePath, 'utf8') + cachePath = getCachePath(sourceCode) + transpile(sourceCode, filePath, cachePath) diff --git a/src/view-registry.coffee b/src/view-registry.coffee index 4dbb5594e..24c53bd57 100644 --- a/src/view-registry.coffee +++ b/src/view-registry.coffee @@ -44,6 +44,7 @@ module.exports = class ViewRegistry documentPollingInterval: 200 documentUpdateRequested: false + documentReadInProgress: false performDocumentPollAfterUpdate: false pollIntervalHandle: null @@ -161,7 +162,7 @@ class ViewRegistry updateDocument: (fn) -> @documentWriters.push(fn) - @requestDocumentUpdate() + @requestDocumentUpdate() unless @documentReadInProgress new Disposable => @documentWriters = @documentWriters.filter (writer) -> writer isnt fn @@ -178,11 +179,15 @@ class ViewRegistry @documentPollers = @documentPollers.filter (poller) -> poller isnt fn @stopPollingDocument() if @documentPollers.length is 0 + pollAfterNextUpdate: -> + @performDocumentPollAfterUpdate = true + clearDocumentRequests: -> @documentReaders = [] @documentWriters = [] @documentPollers = [] @documentUpdateRequested = false + @stopPollingDocument() requestDocumentUpdate: -> unless @documentUpdateRequested @@ -192,8 +197,15 @@ class ViewRegistry performDocumentUpdate: => @documentUpdateRequested = false writer() while writer = @documentWriters.shift() + + @documentReadInProgress = true reader() while reader = @documentReaders.shift() @performDocumentPoll() if @performDocumentPollAfterUpdate + @performDocumentPollAfterUpdate = false + @documentReadInProgress = false + + # process updates requested as a result of reads + writer() while writer = @documentWriters.shift() startPollingDocument: -> @pollIntervalHandle = window.setInterval(@performDocumentPoll, @documentPollingInterval) @@ -205,6 +217,5 @@ class ViewRegistry if @documentUpdateRequested @performDocumentPollAfterUpdate = true else - @performDocumentPollAfterUpdate = false poller() for poller in @documentPollers return diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index 1384f0c4a..dc75b2016 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -1,6 +1,5 @@ path = require 'path' {$} = require './space-pen-extensions' -_ = require 'underscore-plus' {Disposable} = require 'event-kit' ipc = require 'ipc' shell = require 'shell' @@ -24,14 +23,16 @@ class WindowEventHandler if pathToOpen? and needsProjectPaths if fs.existsSync(pathToOpen) atom.project.addPath(pathToOpen) + else if fs.existsSync(path.dirname(pathToOpen)) + atom.project.addPath(path.dirname(pathToOpen)) else - dirToOpen = path.dirname(pathToOpen) - if fs.existsSync(dirToOpen) - atom.project.addPath(dirToOpen) + atom.project.addPath(pathToOpen) unless fs.isDirectorySync(pathToOpen) atom.workspace?.open(pathToOpen, {initialLine, initialColumn}) + return + when 'update-available' atom.updateAvailable(detail) @@ -63,7 +64,10 @@ class WindowEventHandler atom.storeDefaultWindowDimensions() atom.storeWindowDimensions() - atom.unloadEditorWindow() if confirmed + if confirmed + atom.unloadEditorWindow() + else + ipc.send('cancel-window-close') confirmed @@ -83,7 +87,7 @@ class WindowEventHandler if process.platform in ['win32', 'linux'] @subscribeToCommand $(window), 'window:toggle-menu-bar', -> - atom.config.set('core.autoHideMenuBar', !atom.config.get('core.autoHideMenuBar')) + atom.config.set('core.autoHideMenuBar', not atom.config.get('core.autoHideMenuBar')) @subscribeToCommand $(document), 'core:focus-next', @focusNext @@ -126,6 +130,7 @@ class WindowEventHandler bindCommandToAction('core:undo', 'undo:') bindCommandToAction('core:redo', 'redo:') bindCommandToAction('core:select-all', 'selectAll:') + bindCommandToAction('core:cut', 'cut:') onKeydown: (event) -> atom.keymaps.handleKeyboardEvent(event) @@ -134,12 +139,11 @@ class WindowEventHandler onDrop: (event) -> event.preventDefault() event.stopPropagation() - pathsToOpen = _.pluck(event.dataTransfer.files, 'path') - atom.open({pathsToOpen}) if pathsToOpen.length > 0 onDragOver: (event) -> event.preventDefault() event.stopPropagation() + event.dataTransfer.dropEffect = 'none' openLink: ({target, currentTarget}) -> location = target?.getAttribute('href') or currentTarget?.getAttribute('href') @@ -156,6 +160,7 @@ class WindowEventHandler continue unless tabIndex >= 0 callback(element, tabIndex) + return focusNext: => focusedTabIndex = parseInt($(':focus').attr('tabindex')) or -Infinity diff --git a/src/workspace-element.coffee b/src/workspace-element.coffee index 425d920d8..028a6e561 100644 --- a/src/workspace-element.coffee +++ b/src/workspace-element.coffee @@ -16,10 +16,10 @@ class WorkspaceElement extends HTMLElement @initializeContent() @observeScrollbarStyle() @observeTextEditorFontConfig() - @createSpacePenShim() + @createSpacePenShim() if Grim.includeDeprecatedAPIs attachedCallback: -> - callAttachHooks(this) + callAttachHooks(this) if Grim.includeDeprecatedAPIs @focus() detachedCallback: -> @@ -44,7 +44,7 @@ class WorkspaceElement extends HTMLElement @appendChild(@horizontalAxis) observeScrollbarStyle: -> - @subscriptions.add scrollbarStyle.onValue (style) => + @subscriptions.add scrollbarStyle.observePreferredScrollbarStyle (style) => switch style when 'legacy' @classList.remove('scrollbars-visible-when-scrolling') @@ -82,7 +82,7 @@ class WorkspaceElement extends HTMLElement @appendChild(@panelContainers.modal) - @__spacePenView.setModel(@model) + @__spacePenView.setModel(@model) if Grim.includeDeprecatedAPIs this getModel: -> @model @@ -113,7 +113,10 @@ class WorkspaceElement extends HTMLElement focusPaneViewOnRight: -> @paneContainer.focusPaneViewOnRight() runPackageSpecs: -> - [projectPath] = atom.project.getPaths() + if activePath = atom.workspace.getActivePaneItem()?.getPath?() + [projectPath] = atom.project.relativizePath(activePath) + else + [projectPath] = atom.project.getPaths() ipc.send('run-package-specs', path.join(projectPath, 'spec')) if projectPath atom.commands.add 'atom-workspace', @@ -123,6 +126,7 @@ atom.commands.add 'atom-workspace', 'application:about': -> ipc.send('command', 'application:about') 'application:run-all-specs': -> ipc.send('command', 'application:run-all-specs') 'application:run-benchmarks': -> ipc.send('command', 'application:run-benchmarks') + 'application:show-preferences': -> ipc.send('command', 'application:show-settings') 'application:show-settings': -> ipc.send('command', 'application:show-settings') 'application:quit': -> ipc.send('command', 'application:quit') 'application:hide': -> ipc.send('command', 'application:hide') @@ -136,6 +140,7 @@ atom.commands.add 'atom-workspace', 'application:open-folder': -> ipc.send('command', 'application:open-folder') 'application:open-dev': -> ipc.send('command', 'application:open-dev') 'application:open-safe': -> ipc.send('command', 'application:open-safe') + 'application:add-project-folder': -> atom.addProjectFolder() 'application:minimize': -> ipc.send('command', 'application:minimize') 'application:zoom': -> ipc.send('command', 'application:zoom') 'application:bring-all-windows-to-front': -> ipc.send('command', 'application:bring-all-windows-to-front') @@ -153,9 +158,9 @@ atom.commands.add 'atom-workspace', 'window:focus-pane-on-left': -> @focusPaneViewOnLeft() 'window:focus-pane-on-right': -> @focusPaneViewOnRight() 'window:save-all': -> @getModel().saveAll() - 'window:toggle-invisibles': -> atom.config.toggle("editor.showInvisibles") + 'window:toggle-invisibles': -> atom.config.set("editor.showInvisibles", not atom.config.get("editor.showInvisibles")) 'window:log-deprecation-warnings': -> Grim.logDeprecations() - 'window:toggle-auto-indent': -> atom.config.toggle("editor.autoIndent") + 'window:toggle-auto-indent': -> atom.config.set("editor.autoIndent", not atom.config.get("editor.autoIndent")) 'pane:reopen-closed-item': -> @getModel().reopenItem() 'core:close': -> @getModel().destroyActivePaneItemOrEmptyPane() 'core:save': -> @getModel().saveActivePaneItem() diff --git a/src/workspace-view.coffee b/src/workspace-view.coffee index addfe63c5..6fe9f6848 100644 --- a/src/workspace-view.coffee +++ b/src/workspace-view.coffee @@ -4,7 +4,6 @@ Q = require 'q' _ = require 'underscore-plus' Delegator = require 'delegato' {deprecate, logDeprecationWarnings} = require 'grim' -scrollbarStyle = require 'scrollbar-style' {$, $$, View} = require './space-pen-extensions' fs = require 'fs-plus' Workspace = require './workspace' @@ -222,7 +221,6 @@ class WorkspaceView extends View for editorElement in @panes.element.querySelectorAll('atom-pane > .item-views > atom-text-editor') $(editorElement).view() - ### Section: Deprecated ### diff --git a/src/workspace.coffee b/src/workspace.coffee index 49f84f9b8..564152df6 100644 --- a/src/workspace.coffee +++ b/src/workspace.coffee @@ -1,14 +1,14 @@ -{deprecate} = require 'grim' +{includeDeprecatedAPIs, deprecate} = require 'grim' _ = require 'underscore-plus' path = require 'path' {join} = path -{Model} = require 'theorist' Q = require 'q' Serializable = require 'serializable' {Emitter, Disposable, CompositeDisposable} = require 'event-kit' Grim = require 'grim' fs = require 'fs-plus' -StackTraceParser = require 'stacktrace-parser' +DefaultDirectorySearcher = require './default-directory-searcher' +Model = require './model' TextEditor = require './text-editor' PaneContainer = require './pane-container' Pane = require './pane' @@ -23,8 +23,8 @@ Task = require './task' # An instance of this class is available via the `atom.workspace` global. # # Interact with this object to open files, be notified of current and future -# editors, and manipulate panes. To add panels, you'll need to use the -# {WorkspaceView} class for now until we establish APIs at the model layer. +# editors, and manipulate panes. To add panels, use {Workspace::addTopPanel} +# and friends. # # * `editor` {TextEditor} the new editor # @@ -33,30 +33,27 @@ class Workspace extends Model atom.deserializers.add(this) Serializable.includeInto(this) - Object.defineProperty @::, 'activePaneItem', - get: -> - Grim.deprecate "Use ::getActivePaneItem() instead of the ::activePaneItem property" - @getActivePaneItem() - - Object.defineProperty @::, 'activePane', - get: -> - Grim.deprecate "Use ::getActivePane() instead of the ::activePane property" - @getActivePane() - - @properties - paneContainer: null - fullScreen: false - destroyedItemURIs: -> [] - constructor: (params) -> super + unless Grim.includeDeprecatedAPIs + @paneContainer = params?.paneContainer + @fullScreen = params?.fullScreen ? false + @destroyedItemURIs = params?.destroyedItemURIs ? [] + @emitter = new Emitter @openers = [] @paneContainer ?= new PaneContainer() @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) + @directorySearchers = [] + @defaultDirectorySearcher = new DefaultDirectorySearcher() + atom.packages.serviceHub.consume( + 'atom.directory-searcher', + '^0.1.0', + (provider) => @directorySearchers.unshift(provider)) + @panelContainers = top: new PanelContainer({location: 'top'}) left: new PanelContainer({location: 'left'}) @@ -86,6 +83,8 @@ class Workspace extends Model atom.views.addViewProvider Panel, (model) -> new PanelElement().initialize(model) + @subscribeToFontSize() + # Called by the Serializable mixin during deserialization deserializeParams: (params) -> for packageName in params.packagesWithActiveGrammars ? [] @@ -110,6 +109,7 @@ class Workspace extends Model packageNames.push(packageName) for scopeName in includedGrammarScopes ? [] addGrammar(atom.grammars.grammarForScopeName(scopeName)) + return editors = @getTextEditors() addGrammar(editor.getGrammar()) for editor in editors @@ -121,7 +121,7 @@ class Workspace extends Model _.uniq(packageNames) editorAdded: (editor) -> - @emit 'editor-created', editor + @emit 'editor-created', editor if includeDeprecatedAPIs installShellCommands: -> require('./command-installer').installShellCommandsInteractively() @@ -341,39 +341,16 @@ class Workspace extends Model @onDidAddPaneItem ({item, pane, index}) -> callback({textEditor: item, pane, index}) if item instanceof TextEditor - eachEditor: (callback) -> - deprecate("Use Workspace::observeTextEditors instead") - - callback(editor) for editor in @getEditors() - @subscribe this, 'editor-created', (editor) -> callback(editor) - - getEditors: -> - deprecate("Use Workspace::getTextEditors instead") - - editors = [] - for pane in @paneContainer.getPanes() - editors.push(item) for item in pane.getItems() when item instanceof TextEditor - - editors - - on: (eventName) -> - switch eventName - when 'editor-created' - deprecate("Use Workspace::onDidAddTextEditor or Workspace::observeTextEditors instead.") - when 'uri-opened' - deprecate("Use Workspace::onDidOpen or Workspace::onDidAddPaneItem instead. https://atom.io/docs/api/latest/Workspace#instance-onDidOpen") - else - deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") - - super - ### Section: Opening ### - # Essential: Open a given a URI in Atom asynchronously. + # Essential: Opens the given URI in Atom asynchronously. + # If the URI is already open, the existing item for that URI will be + # activated. If no URI is given, or no registered opener can open + # the URI, a new empty {TextEditor} will be created. # - # * `uri` A {String} containing a URI. + # * `uri` (optional) A {String} containing a URI. # * `options` (optional) {Object} # * `initialLine` A {Number} indicating which row to move the cursor to # initially. Defaults to `0`. @@ -408,7 +385,7 @@ class Workspace extends Model # Open Atom's license in the active pane. openLicense: -> - @open(join(atom.getLoadSettings().resourcePath, 'LICENSE.md')) + @open(path.join(process.resourcesPath, 'LICENSE.md')) # Synchronously open the given URI in the active pane. **Only use this method # in specs. Calling this in production code will block the UI thread and @@ -424,7 +401,7 @@ class Workspace extends Model # the containing pane. Defaults to `true`. openSync: (uri='', options={}) -> # TODO: Remove deprecated changeFocus option - if options.changeFocus? + if includeDeprecatedAPIs and options.changeFocus? deprecate("The `changeFocus` option has been renamed to `activatePane`") options.activatePane = options.changeFocus delete options.changeFocus @@ -435,7 +412,7 @@ class Workspace extends Model uri = atom.project.resolvePath(uri) item = @getActivePane().itemForURI(uri) if uri - item ?= opener(uri, options) for opener in @getOpeners() when !item + item ?= opener(uri, options) for opener in @getOpeners() when not item item ?= atom.project.openSync(uri, {initialLine, initialColumn}) @getActivePane().activateItem(item) @@ -445,7 +422,7 @@ class Workspace extends Model openURIInPane: (uri, pane, options={}) -> # TODO: Remove deprecated changeFocus option - if options.changeFocus? + if includeDeprecatedAPIs and options.changeFocus? deprecate("The `changeFocus` option has been renamed to `activatePane`") options.activatePane = options.changeFocus delete options.changeFocus @@ -454,21 +431,22 @@ class Workspace extends Model if uri? item = pane.itemForURI(uri) - item ?= opener(uri, options) for opener in @getOpeners() when !item + item ?= opener(uri, options) for opener in @getOpeners() when not item try item ?= atom.project.open(uri, options) catch error switch error.code - when 'EFILETOOLARGE' - atom.notifications.addWarning("#{error.message} Large file support is being tracked at [atom/atom#307](https://github.com/atom/atom/issues/307).") + when 'CANCELLED' + return Q() when 'EACCES' atom.notifications.addWarning("Permission denied '#{error.path}'") + return Q() when 'EPERM', 'EBUSY' atom.notifications.addWarning("Unable to open '#{error.path}'", detail: error.message) + return Q() else throw error - return Q() Q(item) .then (item) => @@ -481,7 +459,7 @@ class Workspace extends Model if options.initialLine? or options.initialColumn? item.setCursorBufferPosition?([options.initialLine, options.initialColumn]) index = pane.getActiveItemIndex() - @emit "uri-opened" + @emit "uri-opened" if includeDeprecatedAPIs @emitter.emit 'did-open', {uri, pane, item, index} item @@ -495,12 +473,6 @@ class Workspace extends Model else Q() - # Deprecated - reopenItemSync: -> - deprecate("Use Workspace::reopenItem instead") - if uri = @destroyedItemURIs.pop() - @openSync(uri) - # Public: Register an opener for a uri. # # An {TextEditor} will be used if no openers return a value. @@ -518,56 +490,28 @@ class Workspace extends Model # Returns a {Disposable} on which `.dispose()` can be called to remove the # opener. addOpener: (opener) -> - packageName = @getCallingPackageName() + if includeDeprecatedAPIs + packageName = @getCallingPackageName() - wrappedOpener = (uri, options) -> - item = opener(uri, options) - if item? and typeof item.getUri is 'function' and typeof item.getURI isnt 'function' - Grim.deprecate("Pane item with class `#{item.constructor.name}` should implement `::getURI` instead of `::getUri`.", {packageName}) - item + wrappedOpener = (uri, options) -> + item = opener(uri, options) + if item? and typeof item.getUri is 'function' and typeof item.getURI isnt 'function' + Grim.deprecate("Pane item with class `#{item.constructor.name}` should implement `::getURI` instead of `::getUri`.", {packageName}) + if item? and typeof item.on is 'function' and typeof item.onDidChangeTitle isnt 'function' + Grim.deprecate("If you would like your pane item with class `#{item.constructor.name}` to support title change behavior, please implement a `::onDidChangeTitle()` method. `::on` methods for items are no longer supported. If not, ignore this message.", {packageName}) + if item? and typeof item.on is 'function' and typeof item.onDidChangeModified isnt 'function' + Grim.deprecate("If you would like your pane item with class `#{item.constructor.name}` to support modified behavior, please implement a `::onDidChangeModified()` method. If not, ignore this message. `::on` methods for items are no longer supported.", {packageName}) + item - @openers.push(wrappedOpener) - new Disposable => _.remove(@openers, wrappedOpener) - - registerOpener: (opener) -> - Grim.deprecate("Call Workspace::addOpener instead") - @addOpener(opener) - - unregisterOpener: (opener) -> - Grim.deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") - _.remove(@openers, opener) + @openers.push(wrappedOpener) + new Disposable => _.remove(@openers, wrappedOpener) + else + @openers.push(opener) + new Disposable => _.remove(@openers, opener) getOpeners: -> @openers - getCallingPackageName: -> - error = new Error - Error.captureStackTrace(error) - stack = StackTraceParser.parse(error.stack) - - packagePaths = @getPackagePathsByPackageName() - - for i in [0...stack.length] - stackFramePath = stack[i].file - - # Empty when it was run from the dev console - return unless stackFramePath - - for packageName, packagePath of packagePaths - continue if stackFramePath is 'node.js' - relativePath = path.relative(packagePath, stackFramePath) - return packageName unless /^\.\./.test(relativePath) - return - - getPackagePathsByPackageName: -> - packagePathsByPackageName = {} - for pack in atom.packages.getLoadedPackages() - packagePath = pack.path - if packagePath.indexOf('.atom/dev/packages') > -1 or packagePath.indexOf('.atom/packages') > -1 - packagePath = fs.realpathSync(packagePath) - packagePathsByPackageName[pack.name] = packagePath - packagePathsByPackageName - ### Section: Pane Items ### @@ -598,11 +542,6 @@ class Workspace extends Model activeItem = @getActivePaneItem() activeItem if activeItem instanceof TextEditor - # Deprecated - getActiveEditor: -> - Grim.deprecate "Call ::getActiveTextEditor instead" - @getActivePane()?.getActiveEditor() - # Save all pane items. saveAll: -> @paneContainer.saveAll() @@ -666,10 +605,6 @@ class Workspace extends Model paneForURI: (uri) -> @paneContainer.paneForURI(uri) - paneForUri: (uri) -> - deprecate("Use ::paneForURI instead.") - @paneForURI(uri) - # Extended: Get the {Pane} containing the given item. # # * `item` Item the returned pane contains. @@ -695,9 +630,14 @@ class Workspace extends Model fontSize = atom.config.get("editor.fontSize") atom.config.set("editor.fontSize", fontSize - 1) if fontSize > 1 - # Restore to a default editor font size. + # Restore to the window's original editor font size. resetFontSize: -> - atom.config.unset("editor.fontSize") + if @originalFontSize + atom.config.set("editor.fontSize", @originalFontSize) + + subscribeToFontSize: -> + atom.config.onDidChange 'editor.fontSize', ({oldValue}) => + @originalFontSize ?= oldValue # Removes the item's uri from the list of potential items to reopen. itemOpened: (item) -> @@ -860,36 +800,65 @@ class Workspace extends Model # * `regex` {RegExp} to search with. # * `options` (optional) {Object} (default: {}) # * `paths` An {Array} of glob patterns to search within + # * `onPathsSearched` (optional) {Function} # * `iterator` {Function} callback on each file found # - # Returns a `Promise`. + # Returns a `Promise` with a `cancel()` method that will cancel all + # of the underlying searches that were started as part of this scan. scan: (regex, options={}, iterator) -> if _.isFunction(options) iterator = options options = {} - deferred = Q.defer() - - searchOptions = - ignoreCase: regex.ignoreCase - inclusions: options.paths - includeHidden: true - excludeVcsIgnores: atom.config.get('core.excludeVcsIgnoredPaths') - exclusions: atom.config.get('core.ignoredNames') - follow: atom.config.get('core.followSymlinks') - - task = Task.once require.resolve('./scan-handler'), atom.project.getPaths(), regex.source, searchOptions, -> - deferred.resolve() - - task.on 'scan:result-found', (result) -> - iterator(result) unless atom.project.isPathModified(result.filePath) - - task.on 'scan:file-error', (error) -> - iterator(null, error) + # Find a searcher for every Directory in the project. Each searcher that is matched + # will be associated with an Array of Directory objects in the Map. + directoriesForSearcher = new Map() + for directory in atom.project.getDirectories() + searcher = @defaultDirectorySearcher + for directorySearcher in @directorySearchers + if directorySearcher.canSearchDirectory(directory) + searcher = directorySearcher + break + directories = directoriesForSearcher.get(searcher) + unless directories + directories = [] + directoriesForSearcher.set(searcher, directories) + directories.push(directory) + # Define the onPathsSearched callback. if _.isFunction(options.onPathsSearched) - task.on 'scan:paths-searched', (numberOfPathsSearched) -> - options.onPathsSearched(numberOfPathsSearched) + # Maintain a map of directories to the number of search results. When notified of a new count, + # replace the entry in the map and update the total. + onPathsSearchedOption = options.onPathsSearched + totalNumberOfPathsSearched = 0 + numberOfPathsSearchedForSearcher = new Map() + onPathsSearched = (searcher, numberOfPathsSearched) -> + oldValue = numberOfPathsSearchedForSearcher.get(searcher) + if oldValue + totalNumberOfPathsSearched -= oldValue + numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched) + totalNumberOfPathsSearched += numberOfPathsSearched + onPathsSearchedOption(totalNumberOfPathsSearched) + else + onPathsSearched = -> + + # Kick off all of the searches and unify them into one Promise. + allSearches = [] + directoriesForSearcher.forEach (directories, searcher) -> + searchOptions = + inclusions: options.paths or [] + includeHidden: true + excludeVcsIgnores: atom.config.get('core.excludeVcsIgnoredPaths') + exclusions: atom.config.get('core.ignoredNames') + follow: atom.config.get('core.followSymlinks') + didMatch: (result) -> + iterator(result) unless atom.project.isPathModified(result.filePath) + didError: (error) -> + iterator(null, error) + didSearchPaths: (count) -> onPathsSearched(searcher, count) + directorySearcher = searcher.search(directories, regex, searchOptions) + allSearches.push(directorySearcher) + searchPromise = Promise.all(allSearches) for buffer in atom.project.getBuffers() when buffer.isModified() filePath = buffer.getPath() @@ -898,11 +867,31 @@ class Workspace extends Model buffer.scan regex, (match) -> matches.push match iterator {filePath, matches} if matches.length > 0 - promise = deferred.promise - promise.cancel = -> - task.terminate() - deferred.resolve('cancelled') - promise + # Make sure the Promise that is returned to the client is cancelable. To be consistent + # with the existing behavior, instead of cancel() rejecting the promise, it should + # resolve it with the special value 'cancelled'. At least the built-in find-and-replace + # package relies on this behavior. + isCancelled = false + cancellablePromise = new Promise (resolve, reject) -> + onSuccess = -> + if isCancelled + resolve('cancelled') + else + resolve(null) + searchPromise.then(onSuccess, reject) + cancellablePromise.cancel = -> + isCancelled = true + # Note that cancelling all of the members of allSearches will cause all of the searches + # to resolve, which causes searchPromise to resolve, which is ultimately what causes + # cancellablePromise to resolve. + promise.cancel() for promise in allSearches + + # Although this method claims to return a `Promise`, the `ResultsPaneView.onSearch()` + # method in the find-and-replace package expects the object returned by this method to have a + # `done()` method. Include a done() method until find-and-replace can be updated. + cancellablePromise.done = (onSuccessOrFailure) -> + cancellablePromise.then(onSuccessOrFailure, onSuccessOrFailure) + cancellablePromise # Public: Performs a replace across all the specified files in the project. # @@ -919,8 +908,8 @@ class Workspace extends Model openPaths = (buffer.getPath() for buffer in atom.project.getBuffers()) outOfProcessPaths = _.difference(filePaths, openPaths) - inProcessFinished = !openPaths.length - outOfProcessFinished = !outOfProcessPaths.length + inProcessFinished = not openPaths.length + outOfProcessFinished = not outOfProcessPaths.length checkFinished = -> deferred.resolve() if outOfProcessFinished and inProcessFinished @@ -944,3 +933,96 @@ class Workspace extends Model checkFinished() deferred.promise + +if includeDeprecatedAPIs + Workspace.properties + paneContainer: null + fullScreen: false + destroyedItemURIs: -> [] + + Object.defineProperty Workspace::, 'activePaneItem', + get: -> + Grim.deprecate "Use ::getActivePaneItem() instead of the ::activePaneItem property" + @getActivePaneItem() + + Object.defineProperty Workspace::, 'activePane', + get: -> + Grim.deprecate "Use ::getActivePane() instead of the ::activePane property" + @getActivePane() + + StackTraceParser = require 'stacktrace-parser' + + Workspace::getCallingPackageName = -> + error = new Error + Error.captureStackTrace(error) + stack = StackTraceParser.parse(error.stack) + + packagePaths = @getPackagePathsByPackageName() + + for i in [0...stack.length] + stackFramePath = stack[i].file + + # Empty when it was run from the dev console + return unless stackFramePath + + for packageName, packagePath of packagePaths + continue if stackFramePath is 'node.js' + relativePath = path.relative(packagePath, stackFramePath) + return packageName unless /^\.\./.test(relativePath) + return + + Workspace::getPackagePathsByPackageName = -> + packagePathsByPackageName = {} + for pack in atom.packages.getLoadedPackages() + packagePath = pack.path + if packagePath.indexOf('.atom/dev/packages') > -1 or packagePath.indexOf('.atom/packages') > -1 + packagePath = fs.realpathSync(packagePath) + packagePathsByPackageName[pack.name] = packagePath + packagePathsByPackageName + + Workspace::eachEditor = (callback) -> + deprecate("Use Workspace::observeTextEditors instead") + + callback(editor) for editor in @getEditors() + @subscribe this, 'editor-created', (editor) -> callback(editor) + + Workspace::getEditors = -> + deprecate("Use Workspace::getTextEditors instead") + + editors = [] + for pane in @paneContainer.getPanes() + editors.push(item) for item in pane.getItems() when item instanceof TextEditor + + editors + + Workspace::on = (eventName) -> + switch eventName + when 'editor-created' + deprecate("Use Workspace::onDidAddTextEditor or Workspace::observeTextEditors instead.") + when 'uri-opened' + deprecate("Use Workspace::onDidOpen or Workspace::onDidAddPaneItem instead. https://atom.io/docs/api/latest/Workspace#instance-onDidOpen") + else + deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.") + + super + + Workspace::reopenItemSync = -> + deprecate("Use Workspace::reopenItem instead") + if uri = @destroyedItemURIs.pop() + @openSync(uri) + + Workspace::registerOpener = (opener) -> + Grim.deprecate("Call Workspace::addOpener instead") + @addOpener(opener) + + Workspace::unregisterOpener = (opener) -> + Grim.deprecate("Call .dispose() on the Disposable returned from ::addOpener instead") + _.remove(@openers, opener) + + Workspace::getActiveEditor = -> + Grim.deprecate "Call ::getActiveTextEditor instead" + @getActivePane()?.getActiveEditor() + + Workspace::paneForUri = (uri) -> + deprecate("Use ::paneForURI instead.") + @paneForURI(uri) diff --git a/static/atom.less b/static/atom.less index b45d7b29a..1acb0f54b 100644 --- a/static/atom.less +++ b/static/atom.less @@ -25,5 +25,6 @@ @import "text-editor-light"; @import "select-list"; @import "syntax"; +@import "text"; @import "utilities"; @import "octicons"; diff --git a/static/bootstrap-overrides.less b/static/bootstrap-overrides.less index 1ac35ab79..115f672f2 100644 --- a/static/bootstrap-overrides.less +++ b/static/bootstrap-overrides.less @@ -21,3 +21,21 @@ h5, h6 { font-family: inherit; // inherit from themes } + +body { + font-family: inherit; // inherit from html + font-size: inherit; // inherit from html +} + +// Latest Bootstrap specifies the font properties again instead of inheriting +.tooltip { + font-family: @font-family; + font-size: @font-size; +} + +// disable some styling, will be styled in themes +kbd { + color: inherit; + background-color: none; + box-shadow: none; +} diff --git a/static/index.html b/static/index.html index 5559058dc..5fcb30ad2 100644 --- a/static/index.html +++ b/static/index.html @@ -1,10 +1,7 @@ - + - - - diff --git a/static/index.js b/static/index.js index b502c1b42..8fe71a6a9 100644 --- a/static/index.js +++ b/static/index.js @@ -1,71 +1,102 @@ +(function() { + var fs = require('fs'); var path = require('path'); +var loadSettings = null; +var loadSettingsError = null; + window.onload = function() { try { var startTime = Date.now(); + process.on('unhandledRejection', function(error, promise) { + console.error('Unhandled promise rejection %o with error: %o', promise, error); + }); + // Ensure ATOM_HOME is always set before anything else is required setupAtomHome(); - var cacheDir = path.join(process.env.ATOM_HOME, 'compile-cache'); - // Use separate compile cache when sudo'ing as root to avoid permission issues - if (process.env.USER === 'root' && process.env.SUDO_USER && process.env.SUDO_USER !== process.env.USER) { - cacheDir = path.join(cacheDir, 'root'); - } - - // Skip "?loadSettings=". - var rawLoadSettings = decodeURIComponent(location.search.substr(14)); - var loadSettings; - try { - loadSettings = JSON.parse(rawLoadSettings); - } catch (error) { - console.error("Failed to parse load settings: " + rawLoadSettings); - throw error; - } - // Normalize to make sure drive letter case is consistent on Windows process.resourcesPath = path.normalize(process.resourcesPath); + if (loadSettingsError) { + throw loadSettingsError; + } + var devMode = loadSettings.devMode || !loadSettings.resourcePath.startsWith(process.resourcesPath + path.sep); - setupCoffeeCache(cacheDir); + if (devMode) { + setupDeprecatedPackages(); + } - ModuleCache = require('../src/module-cache'); - ModuleCache.register(loadSettings); - ModuleCache.add(loadSettings.resourcePath); - - // Start the crash reporter before anything else. - require('crash-reporter').start({ - productName: 'Atom', - companyName: 'GitHub', - // By explicitly passing the app version here, we could save the call - // of "require('remote').require('app').getVersion()". - extra: {_version: loadSettings.appVersion} - }); - - setupVmCompatibility(); - setupCsonCache(cacheDir); - setupSourceMapCache(cacheDir); - setupBabel(cacheDir); - - require(loadSettings.bootstrapScript); - require('ipc').sendChannel('window-command', 'window:loaded'); - - if (global.atom) { - global.atom.loadTime = Date.now() - startTime; - console.log('Window load time: ' + global.atom.getWindowLoadTime() + 'ms'); + if (loadSettings.profileStartup) { + profileStartup(loadSettings, Date.now() - startTime); + } else { + setupWindow(loadSettings); + setLoadTime(Date.now() - startTime); } } catch (error) { - var currentWindow = require('remote').getCurrentWindow(); - currentWindow.setSize(800, 600); - currentWindow.center(); - currentWindow.show(); - currentWindow.openDevTools(); - console.error(error.stack || error); + handleSetupError(error); } } +var getCacheDirectory = function() { + var cacheDir = path.join(process.env.ATOM_HOME, 'compile-cache'); + // Use separate compile cache when sudo'ing as root to avoid permission issues + if (process.env.USER === 'root' && process.env.SUDO_USER && process.env.SUDO_USER !== process.env.USER) { + cacheDir = path.join(cacheDir, 'root'); + } + return cacheDir; +} + +var setLoadTime = function(loadTime) { + if (global.atom) { + global.atom.loadTime = loadTime; + console.log('Window load time: ' + global.atom.getWindowLoadTime() + 'ms'); + } +} + +var handleSetupError = function(error) { + var currentWindow = require('remote').getCurrentWindow(); + currentWindow.setSize(800, 600); + currentWindow.center(); + currentWindow.show(); + currentWindow.openDevTools(); + console.error(error.stack || error); +} + +var setupWindow = function(loadSettings) { + var cacheDir = getCacheDirectory(); + + setupCoffeeCache(cacheDir); + + ModuleCache = require('../src/module-cache'); + ModuleCache.register(loadSettings); + ModuleCache.add(loadSettings.resourcePath); + + // Only include deprecated APIs when running core spec + require('grim').includeDeprecatedAPIs = isRunningCoreSpecs(loadSettings); + + // Start the crash reporter before anything else. + require('crash-reporter').start({ + productName: 'Atom', + companyName: 'GitHub', + // By explicitly passing the app version here, we could save the call + // of "require('remote').require('app').getVersion()". + extra: {_version: loadSettings.appVersion} + }); + + setupVmCompatibility(); + setupCsonCache(cacheDir); + setupSourceMapCache(cacheDir); + setupBabel(cacheDir); + setupTypeScript(cacheDir); + + require(loadSettings.bootstrapScript); + require('ipc').sendChannel('window-command', 'window:loaded'); +} + var setupCoffeeCache = function(cacheDir) { var CoffeeCache = require('coffee-cash'); CoffeeCache.setCacheDirectory(path.join(cacheDir, 'coffee')); @@ -96,6 +127,12 @@ var setupBabel = function(cacheDir) { babel.register(); } +var setupTypeScript = function(cacheDir) { + var typescript = require('../src/typescript'); + typescript.setCacheDirectory(path.join(cacheDir, 'typescript')); + typescript.register(); +} + var setupCsonCache = function(cacheDir) { require('season').setCacheDir(path.join(cacheDir, 'cson')); } @@ -106,6 +143,92 @@ var setupSourceMapCache = function(cacheDir) { var setupVmCompatibility = function() { var vm = require('vm'); - if (!vm.Script.createContext) + if (!vm.Script.createContext) { vm.Script.createContext = vm.createContext; + } } + +var setupDeprecatedPackages = function() { + var metadata = require('../package.json'); + if (!metadata._deprecatedPackages) { + try { + metadata._deprecatedPackages = require('../build/deprecated-packages.json'); + } catch(requireError) { + console.error('Failed to setup deprecated packages list', requireError.stack); + } + } +} + +var profileStartup = function(loadSettings, initialTime) { + var profile = function() { + console.profile('startup'); + try { + var startTime = Date.now() + setupWindow(loadSettings); + setLoadTime(Date.now() - startTime + initialTime); + } catch (error) { + handleSetupError(error); + } finally { + console.profileEnd('startup'); + console.log("Switch to the Profiles tab to view the created startup profile") + } + }; + + var currentWindow = require('remote').getCurrentWindow(); + if (currentWindow.devToolsWebContents) { + profile(); + } else { + currentWindow.openDevTools(); + currentWindow.once('devtools-opened', function() { + setTimeout(profile, 100); + }); + } +} + +var parseLoadSettings = function() { + var rawLoadSettings = decodeURIComponent(location.hash.substr(1)); + try { + loadSettings = JSON.parse(rawLoadSettings); + } catch (error) { + console.error("Failed to parse load settings: " + rawLoadSettings); + loadSettingsError = error; + } +} + +var setupWindowBackground = function() { + if (loadSettings && loadSettings.isSpec) { + return; + } + + var backgroundColor = window.localStorage.getItem('atom:window-background-color'); + if (!backgroundColor) { + return; + } + + var backgroundStylesheet = document.createElement('style'); + backgroundStylesheet.type = 'text/css'; + backgroundStylesheet.innerText = 'html, body { background: ' + backgroundColor + '; }'; + document.head.appendChild(backgroundStylesheet); + + // Remove once the page loads + window.addEventListener("load", function loadWindow() { + window.removeEventListener("load", loadWindow, false); + setTimeout(function() { + backgroundStylesheet.remove(); + backgroundStylesheet = null; + }, 1000); + }, false); +} + +var isRunningCoreSpecs = function(loadSettings) { + return !!(loadSettings && + loadSettings.isSpec && + loadSettings.specDirectory && + loadSettings.resourcePath && + path.dirname(loadSettings.specDirectory) === loadSettings.resourcePath); +} + +parseLoadSettings(); +setupWindowBackground(); + +})(); diff --git a/static/panes.less b/static/panes.less index cb8bb6565..a1a649e54 100644 --- a/static/panes.less +++ b/static/panes.less @@ -4,19 +4,42 @@ // settings-view, the archive-view, the image-view. Etc. Basically a non- // editor resource with a tab. atom-pane-container { + position: relative; display: -webkit-flex; -webkit-flex: 1; - atom-pane-axis.vertical { + atom-pane-axis { display: -webkit-flex; -webkit-flex: 1; + + & > atom-pane-resize-handle { + position: absolute; + z-index: 3; + } + } + + atom-pane-axis.vertical { -webkit-flex-direction: column; + + & > atom-pane-resize-handle { + width: 100%; + height: 8px; + margin-top: -4px; + cursor: row-resize; + border-bottom: none; + } } atom-pane-axis.horizontal { - display: -webkit-flex; - -webkit-flex: 1; -webkit-flex-direction: row; + + & > atom-pane-resize-handle { + width: 8px; + height: 100%; + margin-left: -4px; + cursor: col-resize; + border-right: none; + } } atom-pane { @@ -24,7 +47,7 @@ atom-pane-container { display: -webkit-flex; -webkit-flex: 1; -webkit-flex-direction: column; - overflow: hidden; + overflow: visible; .item-views { -webkit-flex: 1; @@ -48,3 +71,20 @@ atom-pane-container { } } } + +// Windows doesn't have row- and col-resize cursors +.platform-win32 { + atom-pane-container { + atom-pane-axis.vertical { + & > atom-pane-resize-handle { + cursor: ns-resize; + } + } + + atom-pane-axis.horizontal { + & > atom-pane-resize-handle { + cursor: ew-resize; + } + } + } +} diff --git a/static/text-editor-light.less b/static/text-editor-light.less index 3ba4e8313..819fc565f 100644 --- a/static/text-editor-light.less +++ b/static/text-editor-light.less @@ -4,8 +4,7 @@ atom-text-editor { display: block; - font-family: Inconsolata, Monaco, Consolas, 'Courier New', Courier; - line-height: 1.3; + font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace; } atom-text-editor[mini] { @@ -100,15 +99,6 @@ atom-text-editor { min-width: 0; } - .underlayer { - position: absolute; - z-index: -2; - top: 0; - bottom: 0; - left: 0; - right: 0; - } - .highlight { background: none; padding: 0; diff --git a/static/text-editor-shadow.less b/static/text-editor-shadow.less index 63d27de7f..cb63b45b0 100644 --- a/static/text-editor-shadow.less +++ b/static/text-editor-shadow.less @@ -9,7 +9,6 @@ .editor-contents--private { width: 100%; - overflow: hidden; cursor: text; display: -webkit-flex; -webkit-user-select: none; @@ -82,15 +81,6 @@ min-width: 0; } -.underlayer { - position: absolute; - z-index: -2; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - .highlight { background: none; padding: 0; diff --git a/static/text.less b/static/text.less new file mode 100644 index 000000000..a189fbf9a --- /dev/null +++ b/static/text.less @@ -0,0 +1,39 @@ +@import "ui-variables"; + +.text-bits (@type) { + @text-color-name: "text-color-@{type}"; + @bg-color-name: "background-color-@{type}"; + + @text-color: @@text-color-name; + @bg-color: @@bg-color-name; + + code { + color: @text-color; + background: fadeout(@bg-color, 80%); + } + + a, a code { + text-decoration: underline; + color: darken(@text-color, 10%); + + &:hover { + color: darken(@text-color, 15%); + } + } +} + +.text-info { + .text-bits(info); +} + +.text-success { + .text-bits(success); +} + +.text-warning { + .text-bits(warning); +} + +.text-error { + .text-bits(error); +} diff --git a/static/variables/syntax-variables.less b/static/variables/syntax-variables.less index 565341f7e..23b8d994e 100644 --- a/static/variables/syntax-variables.less +++ b/static/variables/syntax-variables.less @@ -28,3 +28,17 @@ @syntax-color-modified: orange; @syntax-color-removed: red; @syntax-color-renamed: blue; + +// For language entity colors +@syntax-color-variable: #DF6A73; +@syntax-color-constant: #DF6A73; +@syntax-color-property: #DF6A73; +@syntax-color-value: #D29B67; +@syntax-color-function: #61AEEF; +@syntax-color-method: @syntax-color-function; +@syntax-color-class: #E5C17C; +@syntax-color-keyword: #a431c4; +@syntax-color-tag: #b72424; +@syntax-color-attribute: #87400d; +@syntax-color-import: #97C378; +@syntax-color-snippet: #97C378; diff --git a/static/workspace-view.less b/static/workspace-view.less index 9f988bf46..668a30777 100644 --- a/static/workspace-view.less +++ b/static/workspace-view.less @@ -3,13 +3,16 @@ @font-face { .octicon-font(); } +html { + font-family: @font-family; + font-size: @font-size; +} + html, body { width: 100%; height: 100%; overflow: hidden; - font-family: @font-family; - font-size: @font-size; } atom-workspace {