diff --git a/.travis.yml b/.travis.yml index f9cfc4d24..62040612a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ matrix: include: - os: linux dist: trusty - env: NODE_VERSION=6.9.4 DISPLAY=:99.0 CXX=g++-6 CC=gcc-6 + env: NODE_VERSION=6.9.4 DISPLAY=:99.0 CC=clang CXX=clang++ npm_config_clang=1 sudo: required @@ -22,7 +22,7 @@ install: - source /tmp/.nvm/nvm.sh - nvm install $NODE_VERSION - nvm use --delete-prefix $NODE_VERSION - - npm install -g npm + - npm install -g npm@5.3.0 - script/build --create-debian-package --create-rpm-package --compress-artifacts script: @@ -51,11 +51,9 @@ addons: - out/atom-amd64.tar.gz target_paths: travis-artifacts/$TRAVIS_BUILD_ID apt: - sources: - - ubuntu-toolchain-r-test packages: - - gcc-6 - - g++-6 + - build-essential + - clang-3.3 - fakeroot - git - libsecret-1-dev diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f74c9b3ab..e6ee13d47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -197,6 +197,19 @@ Both issue lists are sorted by total number of comments. While not perfect, numb If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](http://flight-manual.atom.io) is free and available online. You can find the source to the manual in [atom/flight-manual.atom.io](https://github.com/atom/flight-manual.atom.io). +#### Local development + +All packages can be developed locally, by checking out the corresponding repository and registering the package to Atom with `apm`: + +``` +$ git clone url-to-git-repository +$ cd path-to-package/ +$ apm link -d +$ atom -d . +``` + +By running Atom with the `-d` flag, you signal it to run with development packages installed. `apm link` makes sure that your local repository is loaded by Atom. + ### Pull Requests * Fill in [the required template](PULL_REQUEST_TEMPLATE.md) diff --git a/README.md b/README.md index 74a5b4cee..c39634de5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![macOS Build Status](https://circleci.com/gh/atom/atom/tree/master.svg?style=shield)](https://circleci.com/gh/atom/atom) [![Linux Build Status](https://travis-ci.org/atom/atom.svg?branch=master)](https://travis-ci.org/atom/atom) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/1tkktwh654w07eim?svg=true)](https://ci.appveyor.com/project/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/) +[![Join the Atom Community on Slack](https://atom-slack.herokuapp.com/badge.svg)](https://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. @@ -16,14 +16,14 @@ By participating, you are expected to uphold this code. Please report unacceptab ## Documentation -If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](http://flight-manual.atom.io) is free and available online. You can find the source to the manual in [atom/flight-manual.atom.io](https://github.com/atom/flight-manual.atom.io). +If you want to read about using Atom or developing packages in Atom, the [Atom Flight Manual](https://flight-manual.atom.io) is free and available online. You can find the source to the manual in [atom/flight-manual.atom.io](https://github.com/atom/flight-manual.atom.io). The [API reference](https://atom.io/docs/api) for developing packages is also documented on Atom.io. ## Installing ### Prerequisites -- [Git](https://git-scm.com/) +- [Git](https://git-scm.com) ### macOS @@ -40,7 +40,7 @@ Atom will automatically update when a new release is available. You can also download `atom-windows.zip` (32-bit) or `atom-x64-windows.zip` (64-bit) from the [releases page](https://github.com/atom/atom/releases/latest). The `.zip` version will not automatically update. -Using [chocolatey](https://chocolatey.org/)? Run `cinst Atom` to install the latest version of Atom. +Using [Chocolatey](https://chocolatey.org)? Run `cinst Atom` to install the latest version of Atom. ### Debian Linux (Ubuntu) diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..d908b3fff --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,11 @@ +# Atom Support + +If you're looking for support for Atom there are a lot of options, check out: + +* User Documentation — [The Atom Flight Manual](http://flight-manual.atom.io) +* Developer Documentation — [Atom API Documentation](https://atom.io/docs/api/latest) +* FAQ — [The Atom FAQ on Discuss](https://discuss.atom.io/c/faq) +* Message Board — [Discuss, the official Atom and Electron message board](https://discuss.atom.io) +* Chat — [Join the Atom Slack team](http://atom-slack.herokuapp.com/) + +On Discuss and in the Atom Slack team, there are a bunch of helpful community members that should be willing to point you in the right direction. diff --git a/apm/package.json b/apm/package.json index cb756f217..7117147f1 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.18.2" + "atom-package-manager": "1.18.4" } } diff --git a/appveyor.yml b/appveyor.yml index 79f4f0cbd..5195d81cc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -18,31 +18,44 @@ platform: environment: global: ATOM_DEV_RESOURCE_PATH: c:\projects\atom + TEST_JUNIT_XML_ROOT: c:\projects\junit-test-results + NODE_VERSION: 6.9.4 matrix: - - NODE_VERSION: 6.8.0 + - TASK: test + - TASK: installer matrix: fast_finish: true install: + - IF NOT EXIST %TEST_JUNIT_XML_ROOT% MKDIR %TEST_JUNIT_XML_ROOT% - SET PATH=C:\Program Files\Atom\resources\cli;%PATH% - ps: Install-Product node $env:NODE_VERSION $env:PLATFORM - - npm install -g npm + - npm install -g npm@5.3.0 build_script: - - IF NOT EXIST C:\sqtemp MKDIR C:\sqtemp - - SET SQUIRREL_TEMP=C:\sqtemp - CD %APPVEYOR_BUILD_FOLDER% - - IF [%APPVEYOR_REPO_BRANCH:~-9%]==[-releases] ( - script\build.cmd --code-sign --create-windows-installer --compress-artifacts + - IF NOT EXIST C:\tmp MKDIR C:\tmp + - SET SQUIRREL_TEMP=C:\tmp + - IF [%TASK%]==[installer] ( + IF [%APPVEYOR_REPO_BRANCH:~-9%]==[-releases] ( + script\build.cmd --code-sign --compress-artifacts --create-windows-installer + ) ELSE ( + ECHO Skipping installer and Atom build on non-release branch + ) ) ELSE ( + ECHO Skipping installer build on non-installer build matrix row && script\build.cmd --code-sign --compress-artifacts ) test_script: - - script\lint.cmd - - script\test.cmd + - IF [%TASK%]==[test] ( + script\lint.cmd && + script\test.cmd + ) ELSE ( + ECHO Skipping tests on installer build matrix row + ) deploy: off artifacts: @@ -58,10 +71,17 @@ artifacts: name: atom-full.nupkg cache: - - '%APPVEYOR_BUILD_FOLDER%\script\node_modules' - - '%APPVEYOR_BUILD_FOLDER%\apm\node_modules' - - '%APPVEYOR_BUILD_FOLDER%\node_modules' - '%APPVEYOR_BUILD_FOLDER%\electron' - '%USERPROFILE%\.atom\.apm' - '%USERPROFILE%\.atom\compile-cache' - - '%USERPROFILE%\.atom\snapshot-cache' + +on_finish: + - ps: | + $wc = New-Object 'System.Net.WebClient' + $endpoint = "https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)" + Write-Output "Searching for JUnit XML output beneath $($env:TEST_JUNIT_XML_ROOT)" + Get-ChildItem -Path $env:TEST_JUNIT_XML_ROOT -Recurse -File -Name -Include "*.xml" | ForEach-Object { + $full = "$($env:TEST_JUNIT_XML_ROOT)\$($_)" + Write-Output "Uploading JUnit XML file $($full)" + $wc.UploadFile($endpoint, $full) + } diff --git a/atom.sh b/atom.sh index 6b0e94430..b36938bc5 100755 --- a/atom.sh +++ b/atom.sh @@ -31,6 +31,9 @@ while getopts ":wtfvh-:" opt; do foreground|benchmark|benchmark-test|test) EXPECT_OUTPUT=1 ;; + enable-electron-logging) + export ELECTRON_ENABLE_LOGGING=1 + ;; esac ;; w) @@ -50,10 +53,6 @@ if [ $REDIRECT_STDERR ]; then exec 2> /dev/null fi -if [ $EXPECT_OUTPUT ]; then - export ELECTRON_ENABLE_LOGGING=1 -fi - if [ $OS == 'Mac' ]; then if [ -L "$0" ]; then SCRIPT="$(readlink "$0")" diff --git a/circle.yml b/circle.yml index 5890c45e9..b5791e7ad 100644 --- a/circle.yml +++ b/circle.yml @@ -3,6 +3,7 @@ machine: XCODE_SCHEME: test XCODE_WORKSPACE: test XCODE_PROJECT: test + TEST_JUNIT_XML_ROOT: ${CIRCLE_TEST_REPORTS} xcode: version: 7.3 @@ -18,7 +19,7 @@ dependencies: - curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.3/install.sh | bash - nvm install 6.9.4 - nvm use 6.9.4 - - npm install -g npm + - npm install -g npm@5.3.0 override: - script/build --code-sign --compress-artifacts diff --git a/docs/build-instructions/windows.md b/docs/build-instructions/windows.md index 93d540b0c..a6c327ec8 100644 --- a/docs/build-instructions/windows.md +++ b/docs/build-instructions/windows.md @@ -2,7 +2,7 @@ ## Requirements -* Node.js 6.x or later (the architecture of node available to the build system will determine whether you build 32-bit or 64-bit Atom) +* Node.js 6.9.4 or later (the architecture of node available to the build system will determine whether you build 32-bit or 64-bit Atom) * Python v2.7.x * The python.exe must be available at `%SystemDrive%\Python27\python.exe`. If it is installed elsewhere create a symbolic link to the directory containing the python.exe using: `mklink /d %SystemDrive%\Python27 D:\elsewhere\Python27` * 7zip (7z.exe available from the command line) - for creating distribution zip files diff --git a/docs/native-profiling.md b/docs/native-profiling.md index 58a164982..afac6b4ab 100644 --- a/docs/native-profiling.md +++ b/docs/native-profiling.md @@ -6,7 +6,7 @@ * Open the dev tools with `alt-cmd-i` * Evaluate `process.versions.electron` in the console. * Based on this version, download the appropriate Electron symbols from the [releases](https://github.com/atom/electron/releases) page. - * The file name should look like `electron-v0.X.Y-darwin-x64-dsym.zip`. + * The file name should look like `electron-v1.X.Y-darwin-x64-dsym.zip`. * Decompress these symbols in your `~/Downloads` directory. * Now create a time profile in Instruments. * Open `Instruments.app`. diff --git a/dot-atom/keymap.cson b/dot-atom/keymap.cson index 01ac18cdc..bfbadea95 100644 --- a/dot-atom/keymap.cson +++ b/dot-atom/keymap.cson @@ -18,7 +18,7 @@ # 'ctrl-p': 'core:move-down' # # You can find more information about keymaps in these guides: -# * http://flight-manual.atom.io/using-atom/sections/basic-customization/#_customizing_keybindings +# * http://flight-manual.atom.io/using-atom/sections/basic-customization/#customizing-keybindings # * http://flight-manual.atom.io/behind-atom/sections/keymaps-in-depth/ # # If you're having trouble with your keybindings not working, try the @@ -29,4 +29,4 @@ # This file uses CoffeeScript Object Notation (CSON). # If you are unfamiliar with CSON, you can read more about it in the # Atom Flight Manual: -# http://flight-manual.atom.io/using-atom/sections/basic-customization/#_cson +# http://flight-manual.atom.io/using-atom/sections/basic-customization/#configuring-with-cson diff --git a/exports/atom.js b/exports/atom.js index 9ad4f60c2..d7ca55909 100644 --- a/exports/atom.js +++ b/exports/atom.js @@ -7,6 +7,7 @@ import BufferedNodeProcess from '../src/buffered-node-process' import BufferedProcess from '../src/buffered-process' import GitRepository from '../src/git-repository' import Notification from '../src/notification' +import {watchPath} from '../src/path-watcher' const atomExport = { BufferedNodeProcess, @@ -20,7 +21,8 @@ const atomExport = { Directory, Emitter, Disposable, - CompositeDisposable + CompositeDisposable, + watchPath } // Shell integration is required by both Squirrel and Settings-View diff --git a/package.json b/package.json index adf768751..30b09d9ab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "1.20.0-dev", + "version": "1.21.0-dev", "description": "A hackable text editor for the 21st Century.", "main": "./src/main-process/main.js", "repository": { @@ -14,8 +14,9 @@ "license": "MIT", "electronVersion": "1.6.9", "dependencies": { + "@atom/source-map-support": "^0.3.4", "async": "0.2.6", - "atom-keymap": "8.1.2", + "atom-keymap": "8.2.3", "atom-select-list": "^0.1.0", "atom-ui": "0.4.1", "babel-core": "5.8.38", @@ -38,6 +39,7 @@ "glob": "^7.1.1", "grim": "1.5.0", "jasmine-json": "~0.0", + "jasmine-reporters": "1.1.0", "jasmine-tagged": "^1.1.4", "key-path-helpers": "^0.4.0", "less-cache": "1.1.0", @@ -45,8 +47,11 @@ "marked": "^0.3.6", "minimatch": "^3.0.3", "mocha": "2.5.1", + "mocha-junit-reporter": "^1.13.0", + "mocha-multi-reporters": "^1.1.4", "mock-spawn": "^0.2.6", "normalize-package-data": "^2.0.0", + "nsfw": "^1.0.15", "nslog": "^3", "oniguruma": "6.2.1", "pathwatcher": "7.1.0", @@ -63,9 +68,8 @@ "semver": "^4.3.3", "service-hub": "^0.7.4", "sinon": "1.17.4", - "@atom/source-map-support": "^0.3.4", "temp": "^0.8.3", - "text-buffer": "13.0.1", + "text-buffer": "13.0.9", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -78,91 +82,91 @@ "atom-light-ui": "0.46.0", "base16-tomorrow-dark-theme": "1.5.0", "base16-tomorrow-light-theme": "1.5.0", - "one-dark-ui": "1.10.5", - "one-light-ui": "1.10.5", - "one-dark-syntax": "1.7.1", - "one-light-syntax": "1.7.1", + "one-dark-ui": "1.10.6", + "one-light-ui": "1.10.6", + "one-dark-syntax": "1.8.0", + "one-light-syntax": "1.8.0", "solarized-dark-syntax": "1.1.2", "solarized-light-syntax": "1.1.2", "about": "1.7.6", "archive-view": "0.63.3", - "autocomplete-atom-api": "0.10.1", - "autocomplete-css": "0.16.2", + "autocomplete-atom-api": "0.10.2", + "autocomplete-css": "0.17.2", "autocomplete-html": "0.8.0", - "autocomplete-plus": "2.35.5", + "autocomplete-plus": "2.35.7", "autocomplete-snippets": "1.11.0", "autoflow": "0.29.0", "autosave": "0.24.3", "background-tips": "0.27.1", "bookmarks": "0.44.4", - "bracket-matcher": "0.86.0", + "bracket-matcher": "0.87.3", "command-palette": "0.40.4", "dalek": "0.2.1", "deprecation-cop": "0.56.7", "dev-live-reload": "0.47.1", "encoding-selector": "0.23.4", "exception-reporting": "0.41.4", - "find-and-replace": "0.208.3", + "find-and-replace": "0.209.5", "fuzzy-finder": "1.5.8", - "github": "0.3.4", + "github": "0.4.2", "git-diff": "1.3.6", "go-to-line": "0.32.1", "grammar-selector": "0.49.5", - "image-view": "0.61.2", + "image-view": "0.62.3", "incompatible-packages": "0.27.3", "keybinding-resolver": "0.38.0", - "line-ending-selector": "0.7.2", + "line-ending-selector": "0.7.3", "link": "0.31.3", - "markdown-preview": "0.159.12", - "metrics": "1.2.5", + "markdown-preview": "0.159.13", + "metrics": "1.2.6", "notifications": "0.67.2", "open-on-github": "1.2.1", "package-generator": "1.1.1", - "settings-view": "0.250.0", + "settings-view": "0.251.4", "snippets": "1.1.4", - "spell-check": "0.71.4", + "spell-check": "0.72.0", "status-bar": "1.8.11", "styleguide": "0.49.6", - "symbols-view": "0.116.1", + "symbols-view": "0.117.0", "tabs": "0.106.2", "timecop": "0.36.0", - "tree-view": "0.217.2", + "tree-view": "0.217.6", "update-package-dependencies": "0.12.0", - "welcome": "0.36.4", - "whitespace": "0.37.1", + "welcome": "0.36.5", + "whitespace": "0.37.2", "wrap-guide": "0.40.2", "language-c": "0.58.1", - "language-clojure": "0.22.3", - "language-coffee-script": "0.48.7", + "language-clojure": "0.22.4", + "language-coffee-script": "0.48.9", "language-csharp": "0.14.2", - "language-css": "0.42.2", - "language-gfm": "0.89.1", + "language-css": "0.42.4", + "language-gfm": "0.90.0", "language-git": "0.19.1", - "language-go": "0.44.1", + "language-go": "0.44.2", "language-html": "0.47.3", - "language-hyperlink": "0.16.1", - "language-java": "0.27.2", - "language-javascript": "0.126.1", + "language-hyperlink": "0.16.2", + "language-java": "0.27.3", + "language-javascript": "0.127.2", "language-json": "0.19.1", - "language-less": "0.32.0", + "language-less": "0.33.0", "language-make": "0.22.3", "language-mustache": "0.14.1", "language-objective-c": "0.15.1", "language-perl": "0.37.0", - "language-php": "0.39.0", + "language-php": "0.41.0", "language-property-list": "0.9.1", - "language-python": "0.45.3", - "language-ruby": "0.71.1", + "language-python": "0.45.4", + "language-ruby": "0.71.3", "language-ruby-on-rails": "0.25.2", - "language-sass": "0.59.0", - "language-shellscript": "0.25.1", + "language-sass": "0.61.0", + "language-shellscript": "0.25.2", "language-source": "0.9.0", - "language-sql": "0.25.6", + "language-sql": "0.25.8", "language-text": "0.7.3", - "language-todo": "0.29.1", + "language-todo": "0.29.2", "language-toml": "0.18.1", - "language-xml": "0.35.1", - "language-yaml": "0.30.0" + "language-xml": "0.35.2", + "language-yaml": "0.30.1" }, "private": true, "scripts": { diff --git a/resources/win/atom.cmd b/resources/win/atom.cmd index 43ec8ebe3..07b9933cb 100644 --- a/resources/win/atom.cmd +++ b/resources/win/atom.cmd @@ -3,18 +3,20 @@ SET EXPECT_OUTPUT= SET WAIT= SET PSARGS=%* +SET ELECTRON_ENABLE_LOGGING= FOR %%a IN (%*) DO ( - IF /I "%%a"=="-f" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--foreground" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="-h" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--help" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="-t" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--test" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--benchmark" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--benchmark-test" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="-v" SET EXPECT_OUTPUT=YES - IF /I "%%a"=="--version" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="-f" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--foreground" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="-h" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--help" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="-t" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--test" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--benchmark" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--benchmark-test" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="-v" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--version" SET EXPECT_OUTPUT=YES + IF /I "%%a"=="--enable-electron-logging" SET ELECTRON_ENABLE_LOGGING=YES IF /I "%%a"=="-w" ( SET EXPECT_OUTPUT=YES SET WAIT=YES @@ -26,7 +28,6 @@ FOR %%a IN (%*) DO ( ) IF "%EXPECT_OUTPUT%"=="YES" ( - SET ELECTRON_ENABLE_LOGGING=YES IF "%WAIT%"=="YES" ( powershell -noexit "Start-Process -FilePath \"%~dp0\..\..\atom.exe\" -ArgumentList \"--pid=$pid $env:PSARGS\" ; wait-event" exit 0 diff --git a/script/bootstrap b/script/bootstrap index 6b83aa3ea..430d7959a 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -2,6 +2,7 @@ 'use strict' +const childProcess = require('child_process') const CONFIG = require('./config') const cleanDependencies = require('./lib/clean-dependencies') const deleteMsbuildFromPath = require('./lib/delete-msbuild-from-path') @@ -26,6 +27,11 @@ if (process.platform === 'win32') deleteMsbuildFromPath() installScriptDependencies() installApm() +childProcess.execFileSync( + CONFIG.getApmBinPath(), + ['--version'], + {stdio: 'inherit'} +) runApmInstall(CONFIG.repositoryRootPath) dependenciesFingerprint.write() diff --git a/script/build b/script/build index 21561353b..acc54cdac 100755 --- a/script/build +++ b/script/build @@ -10,10 +10,12 @@ require('./bootstrap') require('coffee-script/register') require('colors') +const path = require('path') const yargs = require('yargs') const argv = yargs .usage('Usage: $0 [options]') .help('help') + .describe('existing-binaries', 'Use existing Atom binaries (skip clean/transpile/cache)') .describe('code-sign', 'Code-sign executables (macOS and Windows only)') .describe('create-windows-installer', 'Create installer (Windows only)') .describe('create-debian-package', 'Create .deb package (Linux only)') @@ -52,50 +54,67 @@ process.on('unhandledRejection', function (e) { process.exit(1) }) -checkChromedriverVersion() -cleanOutputDirectory() -copyAssets() -transpilePackagesWithCustomTranspilerPaths() -transpileBabelPaths() -transpileCoffeeScriptPaths() -transpileCsonPaths() -transpilePegJsPaths() -generateModuleCache() -prebuildLessCache() -generateMetadata() -generateAPIDocs() -dumpSymbols() +const CONFIG = require('./config') +let binariesPromise = Promise.resolve() + +if (!argv.existingBinaries) { + checkChromedriverVersion() + cleanOutputDirectory() + copyAssets() + transpilePackagesWithCustomTranspilerPaths() + transpileBabelPaths() + transpileCoffeeScriptPaths() + transpileCsonPaths() + transpilePegJsPaths() + generateModuleCache() + prebuildLessCache() + generateMetadata() + generateAPIDocs() + binariesPromise = dumpSymbols() +} + +binariesPromise .then(packageApplication) .then(packagedAppPath => generateStartupSnapshot(packagedAppPath).then(() => packagedAppPath)) .then(packagedAppPath => { - if (process.platform === 'darwin') { - if (argv.codeSign) { - codeSignOnMac(packagedAppPath) - } else { - console.log('Skipping code-signing. Specify the --code-sign option to perform code-signing'.gray) - } - } else if (process.platform === 'win32') { - if (argv.createWindowsInstaller) { - return createWindowsInstaller(packagedAppPath, argv.codeSign).then(() => packagedAppPath) - } else { - console.log('Skipping creating installer. Specify the --create-windows-installer option to create a Squirrel-based Windows installer.'.gray) + switch (process.platform) { + case 'darwin': { if (argv.codeSign) { - codeSignOnWindows(packagedAppPath) + codeSignOnMac(packagedAppPath) } else { console.log('Skipping code-signing. Specify the --code-sign option to perform code-signing'.gray) } } - } else if (process.platform === 'linux') { - if (argv.createDebianPackage) { - createDebianPackage(packagedAppPath) - } else { - console.log('Skipping creating debian package. Specify the --create-debian-package option to create it.'.gray) + case 'win32': { + if (argv.codeSign) { + const executablesToSign = [ path.join(packagedAppPath, 'Atom.exe') ] + if (argv.createWindowsInstaller) { + executablesToSign.push(path.join(__dirname, 'node_modules', 'electron-winstaller', 'vendor', 'Update.exe')) + } + codeSignOnWindows(executablesToSign) + } else { + console.log('Skipping code-signing. Specify the --code-sign option to perform code-signing'.gray) + } + if (argv.createWindowsInstaller) { + return createWindowsInstaller(packagedAppPath) + .then(() => argv.codeSign && codeSignOnWindows([ path.join(CONFIG.buildOutputPath, 'AtomSetup.exe') ])) + .then(() => packagedAppPath) + } else { + console.log('Skipping creating installer. Specify the --create-windows-installer option to create a Squirrel-based Windows installer.'.gray) + } } + case 'linux': { + if (argv.createDebianPackage) { + createDebianPackage(packagedAppPath) + } else { + console.log('Skipping creating debian package. Specify the --create-debian-package option to create it.'.gray) + } - if (argv.createRpmPackage) { - createRpmPackage(packagedAppPath) - } else { - console.log('Skipping creating rpm package. Specify the --create-rpm-package option to create it.'.gray) + if (argv.createRpmPackage) { + createRpmPackage(packagedAppPath) + } else { + console.log('Skipping creating rpm package. Specify the --create-rpm-package option to create it.'.gray) + } } } diff --git a/script/create-installer.cmd b/script/create-installer.cmd new file mode 100644 index 000000000..0354f0bac --- /dev/null +++ b/script/create-installer.cmd @@ -0,0 +1,6 @@ +@ECHO OFF +IF NOT EXIST C:\sqtemp MKDIR C:\sqtemp +SET SQUIRREL_TEMP=C:\sqtemp +del script\package-lock.json /q +del apm\package-lock.json /q +script\build.cmd --existing-binaries --code-sign --create-windows-installer diff --git a/script/deprecated-packages.json b/script/deprecated-packages.json index 12638967e..dc97e3734 100644 --- a/script/deprecated-packages.json +++ b/script/deprecated-packages.json @@ -875,10 +875,6 @@ "hasDeprecations": true, "latestHasDeprecations": false }, - "language-typescript": { - "hasAlternative": true, - "alternative": "atom-typescript" - }, "laravel-facades": { "version": "<=1.0.0", "hasDeprecations": true, diff --git a/script/lib/code-sign-on-windows.js b/script/lib/code-sign-on-windows.js index a4137c726..a73ea90be 100644 --- a/script/lib/code-sign-on-windows.js +++ b/script/lib/code-sign-on-windows.js @@ -4,10 +4,7 @@ const os = require('os') const path = require('path') const {spawnSync} = require('child_process') -// This is only used when specifying --code-sign WITHOUT --create-windows-installer -// as Squirrel has to take care of code-signing in order to correctly wrap during the building of setup - -module.exports = function (packagedAppPath) { +module.exports = function (filesToSign) { if (!process.env.ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL && !process.env.ATOM_WIN_CODE_SIGNING_CERT_PATH) { console.log('Skipping code signing because the ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL environment variable is not defined'.gray) return @@ -19,25 +16,26 @@ module.exports = function (packagedAppPath) { downloadFileFromGithub(process.env.ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL, certPath) } try { - console.log(`Code-signing application at ${packagedAppPath}`) - signFile(path.join(packagedAppPath, 'atom.exe')) + for (const fileToSign of filesToSign) { + console.log(`Code-signing executable at ${fileToSign}`) + signFile(fileToSign) + } } finally { if (!process.env.ATOM_WIN_CODE_SIGNING_CERT_PATH) { - console.log(`Deleting certificate at ${certPath}`) fs.removeSync(certPath) } } - function signFile (filePath) { + function signFile (fileToSign) { const signCommand = path.resolve(__dirname, '..', 'node_modules', 'electron-winstaller', 'vendor', 'signtool.exe') - const args = [ // Changing any of these should also be done in create-windows-installer.js + const args = [ 'sign', `/f ${certPath}`, // Signing cert file `/p ${process.env.ATOM_WIN_CODE_SIGNING_CERT_PASSWORD}`, // Signing cert password '/fd sha256', // File digest algorithm '/tr http://timestamp.digicert.com', // Time stamp server '/td sha256', // Times stamp algorithm - `"${filePath}"` + `"${fileToSign}"` ] const result = spawnSync(signCommand, args, {stdio: 'inherit', shell: true}) if (result.status !== 0) { diff --git a/script/lib/create-windows-installer.js b/script/lib/create-windows-installer.js index e8494b06d..ddc46d484 100644 --- a/script/lib/create-windows-installer.js +++ b/script/lib/create-windows-installer.js @@ -1,16 +1,13 @@ 'use strict' -const downloadFileFromGithub = require('./download-file-from-github') const electronInstaller = require('electron-winstaller') const fs = require('fs-extra') const glob = require('glob') -const os = require('os') const path = require('path') -const spawnSync = require('./spawn-sync') const CONFIG = require('../config') -module.exports = (packagedAppPath, codeSign) => { +module.exports = (packagedAppPath) => { const archSuffix = process.arch === 'ia32' ? '' : '-' + process.arch const options = { appDirectory: packagedAppPath, @@ -23,32 +20,7 @@ module.exports = (packagedAppPath, codeSign) => { setupIcon: path.join(CONFIG.repositoryRootPath, 'resources', 'app-icons', CONFIG.channel, 'atom.ico') } - const signing = codeSign && (process.env.ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL || process.env.ATOM_WIN_CODE_SIGNING_CERT_PATH) - let certPath = process.env.ATOM_WIN_CODE_SIGNING_CERT_PATH - - if (signing) { - if (!certPath) { - certPath = path.join(os.tmpdir(), 'win.p12') - downloadFileFromGithub(process.env.ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL, certPath) - } - - var signParams = [] // Changing any of these should also be done in code-sign-on-windows.js - signParams.push(`/f ${certPath}`) // Signing cert file - signParams.push(`/p ${process.env.ATOM_WIN_CODE_SIGNING_CERT_PASSWORD}`) // Signing cert password - signParams.push('/fd sha256') // File digest algorithm - signParams.push('/tr http://timestamp.digicert.com') // Time stamp server - signParams.push('/td sha256') // Times stamp algorithm - options.signWithParams = signParams.join(' ') - } else { - console.log('Skipping code-signing. Specify the --code-sign option and provide a ATOM_WIN_CODE_SIGNING_CERT_DOWNLOAD_URL environment variable to perform code-signing'.gray) - } - const cleanUp = () => { - if (fs.existsSync(certPath) && !process.env.ATOM_WIN_CODE_SIGNING_CERT_PATH) { - console.log(`Deleting certificate at ${certPath}`) - fs.removeSync(certPath) - } - for (let nupkgPath of glob.sync(`${CONFIG.buildOutputPath}/*.nupkg`)) { if (!nupkgPath.includes(CONFIG.appMetadata.version)) { console.log(`Deleting downloaded nupkg for previous version at ${nupkgPath} to prevent it from being stored as an artifact`) @@ -57,24 +29,8 @@ module.exports = (packagedAppPath, codeSign) => { } } - // Squirrel signs its own copy of the executables but we need them for the portable ZIP - const extractSignedExes = () => { - if (signing) { - for (let nupkgPath of glob.sync(`${CONFIG.buildOutputPath}/*-full.nupkg`)) { - if (nupkgPath.includes(CONFIG.appMetadata.version)) { - nupkgPath = path.resolve(nupkgPath) // Switch from forward-slash notation - console.log(`Extracting signed executables from ${nupkgPath} for use in portable zip`) - spawnSync('7z.exe', ['e', nupkgPath, 'lib\\net45\\*.exe', '-aoa', `-o${packagedAppPath}`]) - spawnSync(process.env.COMSPEC, ['/c', 'move', '/y', path.join(packagedAppPath, 'squirrel.exe'), path.join(packagedAppPath, 'update.exe')]) - return - } - } - } - } - console.log(`Creating Windows Installer for ${packagedAppPath}`) return electronInstaller.createWindowsInstaller(options) - .then(extractSignedExes) .then(cleanUp, error => { cleanUp() return Promise.reject(error) diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 9ea3abf93..471bd1201 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -39,6 +39,7 @@ module.exports = function (packagedAppPath) { relativePath === path.join('..', 'node_modules', 'decompress-zip', 'lib', 'decompress-zip.js') || relativePath === path.join('..', 'node_modules', 'debug', 'node.js') || relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') || + relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') || relativePath === path.join('..', 'node_modules', 'git-utils', 'lib', 'git.js') || relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') || relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') || diff --git a/script/package.json b/script/package.json index e5c09c286..454d561aa 100644 --- a/script/package.json +++ b/script/package.json @@ -9,20 +9,20 @@ "csslint": "1.0.2", "donna": "1.0.16", "electron-chromedriver": "~1.6", - "electron-link": "0.1.0", + "electron-link": "0.1.1", "electron-mksnapshot": "~1.6", "electron-packager": "7.3.0", "electron-winstaller": "2.6.2", "fs-extra": "0.30.0", "glob": "7.0.3", - "joanna": "0.0.8", + "joanna": "0.0.9", "klaw-sync": "^1.1.2", "legal-eagle": "0.14.0", "lodash.template": "4.4.0", "minidump": "0.9.0", "mkdirp": "0.5.1", "normalize-package-data": "2.3.5", - "npm": "3.10.5", + "npm": "5.3.0", "passwd-user": "2.1.0", "pegjs": "0.9.0", "runas": "3.1.1", diff --git a/script/test b/script/test index fde922e94..2f22b1e0a 100755 --- a/script/test +++ b/script/test @@ -32,18 +32,29 @@ if (process.platform === 'darwin') { throw new Error('Running tests on this platform is not supported.') } +function prepareEnv (suiteName) { + const env = Object.assign({}, process.env) + + if (process.env.TEST_JUNIT_XML_ROOT) { + // Tell Jasmine to output this suite's results as a JUnit XML file to a subdirectory of the root, so that a + // CI system can interpret it. + const outputPath = path.join(process.env.TEST_JUNIT_XML_ROOT, suiteName, 'test-results.xml') + env.TEST_JUNIT_XML_PATH = outputPath + } + + return env +} + function runCoreMainProcessTests (callback) { const testPath = path.join(CONFIG.repositoryRootPath, 'spec', 'main-process') const testArguments = [ '--resource-path', resourcePath, '--test', '--main-process', testPath ] + const testEnv = Object.assign({}, prepareEnv('core-main-process'), {ATOM_GITHUB_INLINE_GIT_EXEC: 'true'}) console.log('Executing core main process tests'.bold.green) - const cp = childProcess.spawn(executablePath, testArguments, { - stdio: 'inherit', - env: Object.assign({}, process.env, {ATOM_GITHUB_INLINE_GIT_EXEC: 'true'}) - }) + const cp = childProcess.spawn(executablePath, testArguments, {stdio: 'inherit', env: testEnv}) cp.on('error', error => { callback(error) }) cp.on('close', exitCode => { callback(null, exitCode) }) } @@ -54,9 +65,10 @@ function runCoreRenderProcessTests (callback) { '--resource-path', resourcePath, '--test', testPath ] + const testEnv = prepareEnv('core-render-process') console.log('Executing core render process tests'.bold.green) - const cp = childProcess.spawn(executablePath, testArguments, {stdio: 'inherit'}) + const cp = childProcess.spawn(executablePath, testArguments, {stdio: 'inherit', env: testEnv}) cp.on('error', error => { callback(error) }) cp.on('close', exitCode => { callback(null, exitCode) }) } @@ -87,10 +99,10 @@ for (let packageName in CONFIG.appMetadata.packageDependencies) { '--resource-path', resourcePath, '--test', testFolder ] + const testEnv = prepareEnv(`bundled-package-${packageName}`) const pkgJsonPath = path.join(repositoryPackagePath, 'package.json') const nodeModulesPath = path.join(repositoryPackagePath, 'node_modules') - const nodeModulesBackupPath = path.join(repositoryPackagePath, 'node_modules.bak') let finalize = () => null if (require(pkgJsonPath).atomTestRunner) { console.log(`Installing test runner dependencies for ${packageName}`.bold.green) @@ -105,7 +117,7 @@ for (let packageName in CONFIG.appMetadata.packageDependencies) { } else { console.log(`Executing ${packageName} tests`.bold.green) } - const cp = childProcess.spawn(executablePath, testArguments) + const cp = childProcess.spawn(executablePath, testArguments, {env: testEnv}) let stderrOutput = '' cp.stderr.on('data', data => { stderrOutput += data }) cp.stdout.on('data', data => { stderrOutput += data }) @@ -127,21 +139,27 @@ for (let packageName in CONFIG.appMetadata.packageDependencies) { function runBenchmarkTests (callback) { const benchmarksPath = path.join(CONFIG.repositoryRootPath, 'benchmarks') const testArguments = ['--benchmark-test', benchmarksPath] + const testEnv = prepareEnv('benchmark') console.log('Executing benchmark tests'.bold.green) - const cp = childProcess.spawn(executablePath, testArguments, {stdio: 'inherit'}) + const cp = childProcess.spawn(executablePath, testArguments, {stdio: 'inherit', env: testEnv}) cp.on('error', error => { callback(error) }) cp.on('close', exitCode => { callback(null, exitCode) }) } let testSuitesToRun = testSuitesForPlatform(process.platform) -function testSuitesForPlatform(platform) { - switch(platform) { - case 'darwin': return [runCoreMainProcessTests, runCoreRenderProcessTests, runBenchmarkTests].concat(packageTestSuites) - case 'win32': return (process.arch === 'x64') ? [runCoreMainProcessTests, runCoreRenderProcessTests] : [runCoreMainProcessTests] - case 'linux': return [runCoreMainProcessTests] - default: return [] +function testSuitesForPlatform (platform) { + switch (platform) { + case 'darwin': + return [runCoreMainProcessTests, runCoreRenderProcessTests, runBenchmarkTests].concat(packageTestSuites) + case 'win32': + return (process.arch === 'x64') ? [runCoreMainProcessTests, runCoreRenderProcessTests] : [runCoreMainProcessTests] + case 'linux': + return [runCoreMainProcessTests] + default: + console.log(`Unrecognized platform: ${platform}`) + return [] } } diff --git a/spec/async-spec-helpers.js b/spec/async-spec-helpers.js index 8bc36c913..56550cd9f 100644 --- a/spec/async-spec-helpers.js +++ b/spec/async-spec-helpers.js @@ -20,6 +20,11 @@ export function afterEach (fn) { ['it', 'fit', 'ffit', 'fffit'].forEach(function (name) { module.exports[name] = function (description, fn) { + if (fn === undefined) { + global[name](description) + return + } + global[name](description, function () { const result = fn() if (result instanceof Promise) { @@ -29,7 +34,7 @@ export function afterEach (fn) { } }) -export async function conditionPromise (condition) { +export async function conditionPromise (condition) { const startTime = Date.now() while (true) { @@ -40,7 +45,7 @@ export async function conditionPromise (condition) { } if (Date.now() - startTime > 5000) { - throw new Error("Timed out waiting on condition") + throw new Error('Timed out waiting on condition') } } } @@ -72,3 +77,27 @@ export function emitterEventPromise (emitter, event, timeout = 15000) { }) }) } + +export function promisify (original) { + return function (...args) { + return new Promise((resolve, reject) => { + args.push((err, ...results) => { + if (err) { + reject(err) + } else { + resolve(...results) + } + }) + + return original(...args) + }) + } +} + +export function promisifySome (obj, fnNames) { + const result = {} + for (const fnName of fnNames) { + result[fnName] = promisify(obj[fnName]) + } + return result +} diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index 5d269a3bb..8a3e4e0fb 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -6,7 +6,8 @@ StorageFolder = require '../src/storage-folder' describe "AtomEnvironment", -> afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() describe 'window sizing methods', -> describe '::getPosition and ::setPosition', -> diff --git a/spec/atom-paths-spec.js b/spec/atom-paths-spec.js index 3e2da4760..f4bbbf2b7 100644 --- a/spec/atom-paths-spec.js +++ b/spec/atom-paths-spec.js @@ -86,7 +86,11 @@ describe("AtomPaths", () => { afterEach(() => { delete process.env.ATOM_HOME fs.removeSync(electronUserDataPath) - temp.cleanupSync() + try { + temp.cleanupSync() + } catch (e) { + // Ignore + } app.setPath('userData', defaultElectronUserDataPath) }) diff --git a/spec/babel-spec.coffee b/spec/babel-spec.coffee index 070ad7a0b..400e5c03e 100644 --- a/spec/babel-spec.coffee +++ b/spec/babel-spec.coffee @@ -19,7 +19,8 @@ describe "Babel transpiler support", -> afterEach -> CompileCache.setCacheDirectory(originalCacheDir) - temp.cleanupSync() + try + temp.cleanupSync() describe 'when a .js file starts with /** @babel */;', -> it "transpiles it using babel", -> diff --git a/spec/command-installer-spec.coffee b/spec/command-installer-spec.coffee index f0994fc08..dfd25a1df 100644 --- a/spec/command-installer-spec.coffee +++ b/spec/command-installer-spec.coffee @@ -21,7 +21,8 @@ describe "CommandInstaller on #darwin", -> spyOn(CommandInstaller::, 'getInstallDirectory').andReturn(installationPath) afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() it "shows an error dialog when installing commands interactively fails", -> appDelegate = jasmine.createSpyObj("appDelegate", ["confirm"]) diff --git a/spec/compile-cache-spec.coffee b/spec/compile-cache-spec.coffee index 13db6a055..084f87e70 100644 --- a/spec/compile-cache-spec.coffee +++ b/spec/compile-cache-spec.coffee @@ -23,7 +23,8 @@ describe 'CompileCache', -> afterEach -> CompileCache.setAtomHomeDirectory(process.env.ATOM_HOME) CSON.setCacheDir(CompileCache.getCacheDirectory()) - temp.cleanupSync() + try + temp.cleanupSync() describe 'addPathToCache(filePath, atomHome)', -> describe 'when the given file is plain javascript', -> diff --git a/spec/default-directory-provider-spec.coffee b/spec/default-directory-provider-spec.coffee index bf23195cf..35e40618c 100644 --- a/spec/default-directory-provider-spec.coffee +++ b/spec/default-directory-provider-spec.coffee @@ -10,7 +10,8 @@ describe "DefaultDirectoryProvider", -> tmp = temp.mkdirSync('atom-spec-default-dir-provider') afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() describe ".directoryForURISync(uri)", -> it "returns a Directory with a path that matches the uri", -> diff --git a/spec/dock-spec.js b/spec/dock-spec.js index e554094f2..d4db460ae 100644 --- a/spec/dock-spec.js +++ b/spec/dock-spec.js @@ -9,12 +9,15 @@ describe('Dock', () => { it('opens the dock and activates its active pane', () => { jasmine.attachToDOM(atom.workspace.getElement()) const dock = atom.workspace.getLeftDock() + const didChangeVisibleSpy = jasmine.createSpy() + dock.onDidChangeVisible(didChangeVisibleSpy) expect(dock.isVisible()).toBe(false) expect(document.activeElement).toBe(atom.workspace.getCenter().getActivePane().getElement()) dock.activate() expect(dock.isVisible()).toBe(true) expect(document.activeElement).toBe(dock.getActivePane().getElement()) + expect(didChangeVisibleSpy).toHaveBeenCalledWith(true) }) }) @@ -22,17 +25,24 @@ describe('Dock', () => { it('transfers focus back to the active center pane if the dock had focus', () => { jasmine.attachToDOM(atom.workspace.getElement()) const dock = atom.workspace.getLeftDock() + const didChangeVisibleSpy = jasmine.createSpy() + dock.onDidChangeVisible(didChangeVisibleSpy) + dock.activate() expect(document.activeElement).toBe(dock.getActivePane().getElement()) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(true) dock.hide() expect(document.activeElement).toBe(atom.workspace.getCenter().getActivePane().getElement()) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(false) dock.activate() expect(document.activeElement).toBe(dock.getActivePane().getElement()) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(true) dock.toggle() expect(document.activeElement).toBe(atom.workspace.getCenter().getActivePane().getElement()) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(false) // Don't change focus if the dock was not focused in the first place const modalElement = document.createElement('div') @@ -43,9 +53,11 @@ describe('Dock', () => { dock.show() expect(document.activeElement).toBe(modalElement) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(true) dock.hide() expect(document.activeElement).toBe(modalElement) + expect(didChangeVisibleSpy.mostRecentCall.args[0]).toBe(false) }) }) diff --git a/spec/git-repository-provider-spec.coffee b/spec/git-repository-provider-spec.coffee index 6c6a7b4b9..16ccf8938 100644 --- a/spec/git-repository-provider-spec.coffee +++ b/spec/git-repository-provider-spec.coffee @@ -12,7 +12,8 @@ describe "GitRepositoryProvider", -> provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm) afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() describe ".repositoryForDirectory(directory)", -> describe "when specified a Directory with a Git repository", -> diff --git a/spec/grammars-spec.coffee b/spec/grammars-spec.coffee index 47198a124..7d4754397 100644 --- a/spec/grammars-spec.coffee +++ b/spec/grammars-spec.coffee @@ -24,7 +24,8 @@ describe "the `grammars` global", -> afterEach -> atom.packages.deactivatePackages() atom.packages.unloadPackages() - temp.cleanupSync() + try + temp.cleanupSync() describe ".selectGrammar(filePath)", -> it "always returns a grammar", -> diff --git a/spec/jasmine-test-runner.coffee b/spec/jasmine-test-runner.coffee index dcfe4448e..e6c594cef 100644 --- a/spec/jasmine-test-runner.coffee +++ b/spec/jasmine-test-runner.coffee @@ -7,6 +7,10 @@ module.exports = ({logFile, headless, testPaths, buildAtomEnvironment}) -> window[key] = value for key, value of require '../vendor/jasmine' require 'jasmine-tagged' + if process.env.TEST_JUNIT_XML_PATH + require 'jasmine-reporters' + jasmine.getEnv().addReporter new jasmine.JUnitXmlReporter(process.env.TEST_JUNIT_XML_PATH, true, true) + # Allow document.title to be assigned in specs without screwing up spec window title documentTitle = null Object.defineProperty document, 'title', diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js index d4a913859..62fae82b3 100644 --- a/spec/main-process/atom-application.test.js +++ b/spec/main-process/atom-application.test.js @@ -14,10 +14,11 @@ const ATOM_RESOURCE_PATH = path.resolve(__dirname, '..', '..') describe('AtomApplication', function () { this.timeout(60 * 1000) - let originalAppQuit, originalAtomHome, atomApplicationsToDestroy + let originalAppQuit, originalShowMessageBox, originalAtomHome, atomApplicationsToDestroy beforeEach(function () { originalAppQuit = electron.app.quit + originalShowMessageBox = electron.dialog.showMessageBox mockElectronAppQuit() originalAtomHome = process.env.ATOM_HOME process.env.ATOM_HOME = makeTempDir('atom-home') @@ -39,6 +40,7 @@ describe('AtomApplication', function () { } await clearElectronSession() electron.app.quit = originalAppQuit + electron.dialog.showMessageBox = originalShowMessageBox }) describe('launch', function () { @@ -462,20 +464,42 @@ describe('AtomApplication', function () { }) }) - describe('before quitting', function () { - it('waits until all the windows have saved their state and then quits', async function () { - const dirAPath = makeTempDir("a") - const dirBPath = makeTempDir("b") - const atomApplication = buildAtomApplication() - const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'file-a')])) - await focusWindow(window1) - const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')])) - await focusWindow(window2) - electron.app.quit() - assert(!electron.app.hasQuitted()) - await Promise.all([window1.lastPrepareToUnloadPromise, window2.lastPrepareToUnloadPromise]) - assert(electron.app.hasQuitted()) + it('waits until all the windows have saved their state before quitting', async function () { + const dirAPath = makeTempDir("a") + const dirBPath = makeTempDir("b") + const atomApplication = buildAtomApplication() + const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'file-a')])) + await focusWindow(window1) + const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')])) + await focusWindow(window2) + electron.app.quit() + assert(!electron.app.hasQuitted()) + await Promise.all([window1.lastPrepareToUnloadPromise, window2.lastPrepareToUnloadPromise]) + assert(electron.app.hasQuitted()) + }) + + it('prevents quitting if user cancels when prompted to save an item', async () => { + const atomApplication = buildAtomApplication() + const window1 = atomApplication.launch(parseCommandLine([])) + const window2 = atomApplication.launch(parseCommandLine([])) + await Promise.all([window1.loadedPromise, window2.loadedPromise]) + await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + atom.workspace.getActiveTextEditor().insertText('unsaved text') + sendBackToMainProcess() }) + + // Choosing "Cancel" + mockElectronShowMessageBox({choice: 1}) + electron.app.quit() + await atomApplication.lastBeforeQuitPromise + assert(!electron.app.hasQuitted()) + assert.equal(electron.app.quit.callCount, 1) // Ensure choosing "Cancel" doesn't try to quit the electron app more than once (regression) + + // Choosing "Don't save" + mockElectronShowMessageBox({choice: 2}) + electron.app.quit() + await atomApplication.lastBeforeQuitPromise + assert(electron.app.hasQuitted()) }) function buildAtomApplication () { @@ -496,6 +520,12 @@ describe('AtomApplication', function () { function mockElectronAppQuit () { let quitted = false electron.app.quit = function () { + if (electron.app.quit.callCount) { + electron.app.quit.callCount++ + } else { + electron.app.quit.callCount = 1 + } + let shouldQuit = true electron.app.emit('before-quit', {preventDefault: () => { shouldQuit = false }}) if (shouldQuit) { @@ -507,8 +537,15 @@ describe('AtomApplication', function () { } } + function mockElectronShowMessageBox ({choice}) { + electron.dialog.showMessageBox = function () { + return choice + } + } + function makeTempDir (name) { - return fs.realpathSync(require('temp').mkdirSync(name)) + const temp = require('temp').track() + return fs.realpathSync(temp.mkdirSync(name)) } let channelIdCounter = 0 diff --git a/spec/main-process/file-recovery-service.test.js b/spec/main-process/file-recovery-service.test.js index 862b7f428..618c30ab0 100644 --- a/spec/main-process/file-recovery-service.test.js +++ b/spec/main-process/file-recovery-service.test.js @@ -16,7 +16,11 @@ describe("FileRecoveryService", () => { }) afterEach(() => { - temp.cleanupSync() + try { + temp.cleanupSync() + } catch (e) { + // Ignore + } }) describe("when no crash happens during a save", () => { diff --git a/spec/main-process/mocha-test-runner.js b/spec/main-process/mocha-test-runner.js index 0fe5a5125..433727c56 100644 --- a/spec/main-process/mocha-test-runner.js +++ b/spec/main-process/mocha-test-runner.js @@ -7,7 +7,23 @@ import {assert} from 'chai' export default function (testPaths) { global.assert = assert - const mocha = new Mocha({reporter: 'spec'}) + let reporterOptions = { + reporterEnabled: 'list' + } + + if (process.env.TEST_JUNIT_XML_PATH) { + reporterOptions = { + reporterEnabled: 'list, mocha-junit-reporter', + mochaJunitReporterReporterOptions: { + mochaFile: process.env.TEST_JUNIT_XML_PATH + } + } + } + + const mocha = new Mocha({ + reporter: 'mocha-multi-reporters', + reporterOptions + }) for (let testPath of testPaths) { if (fs.isDirectorySync(testPath)) { for (let testFilePath of fs.listTreeSync(testPath)) { diff --git a/spec/module-cache-spec.coffee b/spec/module-cache-spec.coffee index 1627ec776..693fd6634 100644 --- a/spec/module-cache-spec.coffee +++ b/spec/module-cache-spec.coffee @@ -9,7 +9,8 @@ describe 'ModuleCache', -> spyOn(Module, '_findPath').andCallThrough() afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() it 'resolves Electron module paths without hitting the filesystem', -> builtins = ModuleCache.cache.builtins diff --git a/spec/native-watcher-registry-spec.js b/spec/native-watcher-registry-spec.js new file mode 100644 index 000000000..bc657f496 --- /dev/null +++ b/spec/native-watcher-registry-spec.js @@ -0,0 +1,362 @@ +/** @babel */ + +import {it, beforeEach} from './async-spec-helpers' + +import path from 'path' +import {Emitter} from 'event-kit' + +import {NativeWatcherRegistry} from '../src/native-watcher-registry' + +function findRootDirectory () { + let current = process.cwd() + while (true) { + let next = path.resolve(current, '..') + if (next === current) { + return next + } else { + current = next + } + } +} +const ROOT = findRootDirectory() + +function absolute (...parts) { + const candidate = path.join(...parts) + return path.isAbsolute(candidate) ? candidate : path.join(ROOT, candidate) +} + +function parts (fullPath) { + return fullPath.split(path.sep).filter(part => part.length > 0) +} + +class MockWatcher { + constructor (normalizedPath) { + this.normalizedPath = normalizedPath + this.native = null + } + + getNormalizedPathPromise () { + return Promise.resolve(this.normalizedPath) + } + + attachToNative (native, nativePath) { + if (this.normalizedPath.startsWith(nativePath)) { + if (this.native) { + this.native.attached = this.native.attached.filter(each => each !== this) + } + this.native = native + this.native.attached.push(this) + } + } +} + +class MockNative { + constructor (name) { + this.name = name + this.attached = [] + this.disposed = false + this.stopped = false + + this.emitter = new Emitter() + } + + reattachTo (newNative, nativePath) { + for (const watcher of this.attached) { + watcher.attachToNative(newNative, nativePath) + } + } + + onWillStop (callback) { + return this.emitter.on('will-stop', callback) + } + + dispose () { + this.disposed = true + } + + stop () { + this.stopped = true + this.emitter.emit('will-stop') + } +} + +describe('NativeWatcherRegistry', function () { + let createNative, registry + + beforeEach(function () { + registry = new NativeWatcherRegistry(normalizedPath => createNative(normalizedPath)) + }) + + it('attaches a Watcher to a newly created NativeWatcher for a new directory', async function () { + const watcher = new MockWatcher(absolute('some', 'path')) + const NATIVE = new MockNative('created') + createNative = () => NATIVE + + await registry.attach(watcher) + + expect(watcher.native).toBe(NATIVE) + }) + + it('reuses an existing NativeWatcher on the same directory', async function () { + const EXISTING = new MockNative('existing') + const existingPath = absolute('existing', 'path') + let firstTime = true + createNative = () => { + if (firstTime) { + firstTime = false + return EXISTING + } + + return new MockNative('nope') + } + await registry.attach(new MockWatcher(existingPath)) + + const watcher = new MockWatcher(existingPath) + await registry.attach(watcher) + + expect(watcher.native).toBe(EXISTING) + }) + + it('attaches to an existing NativeWatcher on a parent directory', async function () { + const EXISTING = new MockNative('existing') + const parentDir = absolute('existing', 'path') + const subDir = path.join(parentDir, 'sub', 'directory') + let firstTime = true + createNative = () => { + if (firstTime) { + firstTime = false + return EXISTING + } + + return new MockNative('nope') + } + await registry.attach(new MockWatcher(parentDir)) + + const watcher = new MockWatcher(subDir) + await registry.attach(watcher) + + expect(watcher.native).toBe(EXISTING) + }) + + it('adopts Watchers from NativeWatchers on child directories', async function () { + const parentDir = absolute('existing', 'path') + const childDir0 = path.join(parentDir, 'child', 'directory', 'zero') + const childDir1 = path.join(parentDir, 'child', 'directory', 'one') + const otherDir = absolute('another', 'path') + + const CHILD0 = new MockNative('existing0') + const CHILD1 = new MockNative('existing1') + const OTHER = new MockNative('existing2') + const PARENT = new MockNative('parent') + + createNative = dir => { + if (dir === childDir0) { + return CHILD0 + } else if (dir === childDir1) { + return CHILD1 + } else if (dir === otherDir) { + return OTHER + } else if (dir === parentDir) { + return PARENT + } else { + throw new Error(`Unexpected path: ${dir}`) + } + } + + const watcher0 = new MockWatcher(childDir0) + await registry.attach(watcher0) + + const watcher1 = new MockWatcher(childDir1) + await registry.attach(watcher1) + + const watcher2 = new MockWatcher(otherDir) + await registry.attach(watcher2) + + expect(watcher0.native).toBe(CHILD0) + expect(watcher1.native).toBe(CHILD1) + expect(watcher2.native).toBe(OTHER) + + // Consolidate all three watchers beneath the same native watcher on the parent directory + const watcher = new MockWatcher(parentDir) + await registry.attach(watcher) + + expect(watcher.native).toBe(PARENT) + + expect(watcher0.native).toBe(PARENT) + expect(CHILD0.stopped).toBe(true) + expect(CHILD0.disposed).toBe(true) + + expect(watcher1.native).toBe(PARENT) + expect(CHILD1.stopped).toBe(true) + expect(CHILD1.disposed).toBe(true) + + expect(watcher2.native).toBe(OTHER) + expect(OTHER.stopped).toBe(false) + expect(OTHER.disposed).toBe(false) + }) + + describe('removing NativeWatchers', function () { + it('happens when they stop', async function () { + const STOPPED = new MockNative('stopped') + const RUNNING = new MockNative('running') + + const stoppedPath = absolute('watcher', 'that', 'will', 'be', 'stopped') + const stoppedPathParts = stoppedPath.split(path.sep).filter(part => part.length > 0) + const runningPath = absolute('watcher', 'that', 'will', 'continue', 'to', 'exist') + const runningPathParts = runningPath.split(path.sep).filter(part => part.length > 0) + + createNative = dir => { + if (dir === stoppedPath) { + return STOPPED + } else if (dir === runningPath) { + return RUNNING + } else { + throw new Error(`Unexpected path: ${dir}`) + } + } + + const stoppedWatcher = new MockWatcher(stoppedPath) + await registry.attach(stoppedWatcher) + + const runningWatcher = new MockWatcher(runningPath) + await registry.attach(runningWatcher) + + STOPPED.stop() + + const runningNode = registry.tree.root.lookup(runningPathParts).when({ + parent: node => node, + missing: () => false, + children: () => false + }) + expect(runningNode).toBeTruthy() + expect(runningNode.getNativeWatcher()).toBe(RUNNING) + + const stoppedNode = registry.tree.root.lookup(stoppedPathParts).when({ + parent: () => false, + missing: () => true, + children: () => false + }) + expect(stoppedNode).toBe(true) + }) + + it('reassigns new child watchers when a parent watcher is stopped', async function () { + const CHILD0 = new MockNative('child0') + const CHILD1 = new MockNative('child1') + const PARENT = new MockNative('parent') + + const parentDir = absolute('parent') + const childDir0 = path.join(parentDir, 'child0') + const childDir1 = path.join(parentDir, 'child1') + + createNative = dir => { + if (dir === parentDir) { + return PARENT + } else if (dir === childDir0) { + return CHILD0 + } else if (dir === childDir1) { + return CHILD1 + } else { + throw new Error(`Unexpected directory ${dir}`) + } + } + + const parentWatcher = new MockWatcher(parentDir) + const childWatcher0 = new MockWatcher(childDir0) + const childWatcher1 = new MockWatcher(childDir1) + + await registry.attach(parentWatcher) + await Promise.all([ + registry.attach(childWatcher0), + registry.attach(childWatcher1) + ]) + + // All three watchers should share the parent watcher's native watcher. + expect(parentWatcher.native).toBe(PARENT) + expect(childWatcher0.native).toBe(PARENT) + expect(childWatcher1.native).toBe(PARENT) + + // Stopping the parent should detach and recreate the child watchers. + PARENT.stop() + + expect(childWatcher0.native).toBe(CHILD0) + expect(childWatcher1.native).toBe(CHILD1) + + expect(registry.tree.root.lookup(parts(parentDir)).when({ + parent: () => false, + missing: () => false, + children: () => true + })).toBe(true) + + expect(registry.tree.root.lookup(parts(childDir0)).when({ + parent: () => true, + missing: () => false, + children: () => false + })).toBe(true) + + expect(registry.tree.root.lookup(parts(childDir1)).when({ + parent: () => true, + missing: () => false, + children: () => false + })).toBe(true) + }) + + it('consolidates children when splitting a parent watcher', async function () { + const CHILD0 = new MockNative('child0') + const PARENT = new MockNative('parent') + + const parentDir = absolute('parent') + const childDir0 = path.join(parentDir, 'child0') + const childDir1 = path.join(parentDir, 'child0', 'child1') + + createNative = dir => { + if (dir === parentDir) { + return PARENT + } else if (dir === childDir0) { + return CHILD0 + } else { + throw new Error(`Unexpected directory ${dir}`) + } + } + + const parentWatcher = new MockWatcher(parentDir) + const childWatcher0 = new MockWatcher(childDir0) + const childWatcher1 = new MockWatcher(childDir1) + + await registry.attach(parentWatcher) + await Promise.all([ + registry.attach(childWatcher0), + registry.attach(childWatcher1) + ]) + + // All three watchers should share the parent watcher's native watcher. + expect(parentWatcher.native).toBe(PARENT) + expect(childWatcher0.native).toBe(PARENT) + expect(childWatcher1.native).toBe(PARENT) + + // Stopping the parent should detach and create the child watchers. Both child watchers should + // share the same native watcher. + PARENT.stop() + + expect(childWatcher0.native).toBe(CHILD0) + expect(childWatcher1.native).toBe(CHILD0) + + expect(registry.tree.root.lookup(parts(parentDir)).when({ + parent: () => false, + missing: () => false, + children: () => true + })).toBe(true) + + expect(registry.tree.root.lookup(parts(childDir0)).when({ + parent: () => true, + missing: () => false, + children: () => false + })).toBe(true) + + expect(registry.tree.root.lookup(parts(childDir1)).when({ + parent: () => true, + missing: () => false, + children: () => false + })).toBe(true) + }) + }) +}) diff --git a/spec/package-manager-spec.coffee b/spec/package-manager-spec.coffee index 62cc067e9..2d448ac7c 100644 --- a/spec/package-manager-spec.coffee +++ b/spec/package-manager-spec.coffee @@ -17,7 +17,8 @@ describe "PackageManager", -> spyOn(ModuleCache, 'add') afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() describe "::getApmPath()", -> it "returns the path to the apm command", -> diff --git a/spec/pane-spec.js b/spec/pane-spec.js index c36abbf6a..68e93c38f 100644 --- a/spec/pane-spec.js +++ b/spec/pane-spec.js @@ -551,10 +551,11 @@ describe('Pane', () => { itemURI = 'test' confirm.andReturn(0) - await pane.destroyItem(item1) + const success = await pane.destroyItem(item1) expect(item1.save).toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) + expect(success).toBe(true) }) }) @@ -565,11 +566,12 @@ describe('Pane', () => { showSaveDialog.andReturn('/selected/path') confirm.andReturn(0) - await pane.destroyItem(item1) + const success = await pane.destroyItem(item1) expect(showSaveDialog).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalledWith('/selected/path') expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) + expect(success).toBe(true) }) }) }) @@ -578,10 +580,11 @@ describe('Pane', () => { it('removes and destroys the item without saving it', async () => { confirm.andReturn(2) - await pane.destroyItem(item1) + const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) + expect(success).toBe(true); }) }) @@ -589,19 +592,21 @@ describe('Pane', () => { it('does not save, remove, or destroy the item', async () => { confirm.andReturn(1) - await pane.destroyItem(item1) + const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(true) expect(item1.isDestroyed()).toBe(false) + expect(success).toBe(false) }) }) describe('when force=true', () => { it('destroys the item immediately', async () => { - await pane.destroyItem(item1, true) + const success = await pane.destroyItem(item1, true) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) + expect(success).toBe(true) }) }) }) @@ -630,18 +635,20 @@ describe('Pane', () => { }) describe('when passed a permanent dock item', () => { - it("doesn't destroy the item", () => { + it("doesn't destroy the item", async () => { spyOn(item1, 'isPermanentDockItem').andReturn(true) - pane.destroyItem(item1) + const success = await pane.destroyItem(item1) expect(pane.getItems().includes(item1)).toBe(true) expect(item1.isDestroyed()).toBe(false) + expect(success).toBe(false); }) - it('destroy the item if force=true', () => { + it('destroy the item if force=true', async () => { spyOn(item1, 'isPermanentDockItem').andReturn(true) - pane.destroyItem(item1, true) + const success = await pane.destroyItem(item1, true) expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) + expect(success).toBe(true) }) }) }) diff --git a/spec/path-watcher-spec.js b/spec/path-watcher-spec.js new file mode 100644 index 000000000..a805886e3 --- /dev/null +++ b/spec/path-watcher-spec.js @@ -0,0 +1,186 @@ +/** @babel */ + +import {it, beforeEach, afterEach, promisifySome} from './async-spec-helpers' +import tempCb from 'temp' +import fsCb from 'fs-plus' +import path from 'path' + +import {CompositeDisposable} from 'event-kit' +import {watchPath, stopAllWatchers} from '../src/path-watcher' + +tempCb.track() + +const fs = promisifySome(fsCb, ['writeFile', 'mkdir', 'symlink', 'appendFile', 'realpath']) +const temp = promisifySome(tempCb, ['mkdir']) + +describe('watchPath', function () { + let subs + + beforeEach(function () { + subs = new CompositeDisposable() + }) + + afterEach(async function () { + subs.dispose() + await stopAllWatchers() + }) + + function waitForChanges (watcher, ...fileNames) { + const waiting = new Set(fileNames) + let fired = false + const relevantEvents = [] + + return new Promise(resolve => { + const sub = watcher.onDidChange(events => { + for (const event of events) { + if (waiting.delete(event.path)) { + relevantEvents.push(event) + } + } + + if (!fired && waiting.size === 0) { + fired = true + resolve(relevantEvents) + sub.dispose() + } + }) + }) + } + + describe('watchPath()', function () { + it('resolves getStartPromise() when the watcher begins listening', async function () { + const rootDir = await temp.mkdir('atom-fsmanager-test-') + + const watcher = watchPath(rootDir, {}, () => {}) + await watcher.getStartPromise() + }) + + it('reuses an existing native watcher and resolves getStartPromise immediately if attached to a running watcher', async function () { + const rootDir = await temp.mkdir('atom-fsmanager-test-') + + const watcher0 = watchPath(rootDir, {}, () => {}) + await watcher0.getStartPromise() + + const watcher1 = watchPath(rootDir, {}, () => {}) + await watcher1.getStartPromise() + + expect(watcher0.native).toBe(watcher1.native) + }) + + it("reuses existing native watchers even while they're still starting", async function () { + const rootDir = await temp.mkdir('atom-fsmanager-test-') + + const watcher0 = watchPath(rootDir, {}, () => {}) + await watcher0.getAttachedPromise() + expect(watcher0.native.isRunning()).toBe(false) + + const watcher1 = watchPath(rootDir, {}, () => {}) + await watcher1.getAttachedPromise() + + expect(watcher0.native).toBe(watcher1.native) + + await Promise.all([watcher0.getStartPromise(), watcher1.getStartPromise()]) + }) + + it("doesn't attach new watchers to a native watcher that's stopping", async function () { + const rootDir = await temp.mkdir('atom-fsmanager-test-') + + const watcher0 = watchPath(rootDir, {}, () => {}) + await watcher0.getStartPromise() + const native0 = watcher0.native + + watcher0.dispose() + + const watcher1 = watchPath(rootDir, {}, () => {}) + + expect(watcher1.native).not.toBe(native0) + }) + + it('reuses an existing native watcher on a parent directory and filters events', async function () { + const rootDir = await temp.mkdir('atom-fsmanager-test-').then(fs.realpath) + const rootFile = path.join(rootDir, 'rootfile.txt') + const subDir = path.join(rootDir, 'subdir') + const subFile = path.join(subDir, 'subfile.txt') + + await fs.mkdir(subDir) + + // Keep the watchers alive with an undisposed subscription + const rootWatcher = watchPath(rootDir, {}, () => {}) + const childWatcher = watchPath(subDir, {}, () => {}) + + await Promise.all([ + rootWatcher.getStartPromise(), + childWatcher.getStartPromise() + ]) + + expect(rootWatcher.native).toBe(childWatcher.native) + expect(rootWatcher.native.isRunning()).toBe(true) + + const firstChanges = Promise.all([ + waitForChanges(rootWatcher, subFile), + waitForChanges(childWatcher, subFile) + ]) + + await fs.writeFile(subFile, 'subfile\n', {encoding: 'utf8'}) + await firstChanges + + const nextRootEvent = waitForChanges(rootWatcher, rootFile) + await fs.writeFile(rootFile, 'rootfile\n', {encoding: 'utf8'}) + + await nextRootEvent + }) + + it('adopts existing child watchers and filters events appropriately to them', async function () { + const parentDir = await temp.mkdir('atom-fsmanager-test-').then(fs.realpath) + + // Create the directory tree + const rootFile = path.join(parentDir, 'rootfile.txt') + const subDir0 = path.join(parentDir, 'subdir0') + const subFile0 = path.join(subDir0, 'subfile0.txt') + const subDir1 = path.join(parentDir, 'subdir1') + const subFile1 = path.join(subDir1, 'subfile1.txt') + + await fs.mkdir(subDir0) + await fs.mkdir(subDir1) + await Promise.all([ + fs.writeFile(rootFile, 'rootfile\n', {encoding: 'utf8'}), + fs.writeFile(subFile0, 'subfile 0\n', {encoding: 'utf8'}), + fs.writeFile(subFile1, 'subfile 1\n', {encoding: 'utf8'}) + ]) + + // Begin the child watchers and keep them alive + const subWatcher0 = watchPath(subDir0, {}, () => {}) + const subWatcherChanges0 = waitForChanges(subWatcher0, subFile0) + + const subWatcher1 = watchPath(subDir1, {}, () => {}) + const subWatcherChanges1 = waitForChanges(subWatcher1, subFile1) + + await Promise.all( + [subWatcher0, subWatcher1].map(watcher => watcher.getStartPromise()) + ) + expect(subWatcher0.native).not.toBe(subWatcher1.native) + + // Create the parent watcher + const parentWatcher = watchPath(parentDir, {}, () => {}) + const parentWatcherChanges = waitForChanges(parentWatcher, rootFile, subFile0, subFile1) + + await parentWatcher.getStartPromise() + + expect(subWatcher0.native).toBe(parentWatcher.native) + expect(subWatcher1.native).toBe(parentWatcher.native) + + // Ensure events are filtered correctly + await Promise.all([ + fs.appendFile(rootFile, 'change\n', {encoding: 'utf8'}), + fs.appendFile(subFile0, 'change\n', {encoding: 'utf8'}), + fs.appendFile(subFile1, 'change\n', {encoding: 'utf8'}) + ]) + + await Promise.all([ + subWatcherChanges0, + subWatcherChanges1, + parentWatcherChanges + ]) + }) + }) +}) diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee index a1a1dc189..059208cbd 100644 --- a/spec/project-spec.coffee +++ b/spec/project-spec.coffee @@ -4,6 +4,7 @@ Project = require '../src/project' fs = require 'fs-plus' path = require 'path' {Directory} = require 'pathwatcher' +{stopAllWatchers} = require '../src/path-watcher' GitRepository = require '../src/git-repository' describe "Project", -> @@ -13,9 +14,6 @@ describe "Project", -> # Wait for project's service consumers to be asynchronously added waits(1) - afterEach -> - temp.cleanupSync() - describe "serialization", -> deserializedProject = null @@ -548,6 +546,59 @@ describe "Project", -> atom.project.removePath(ftpURI) expect(atom.project.getPaths()).toEqual [] + describe ".onDidChangeFiles()", -> + sub = [] + events = [] + checkCallback = -> + + beforeEach -> + sub = atom.project.onDidChangeFiles (incoming) -> + events.push incoming... + checkCallback() + + afterEach -> + sub.dispose() + + waitForEvents = (paths) -> + remaining = new Set(fs.realpathSync(p) for p in paths) + new Promise (resolve, reject) -> + checkCallback = -> + remaining.delete(event.path) for event in events + resolve() if remaining.size is 0 + + expire = -> + checkCallback = -> + console.error "Paths not seen:", Array.from(remaining) + reject(new Error('Expired before all expected events were delivered.')) + + checkCallback() + setTimeout expire, 2000 + + it "reports filesystem changes within project paths", -> + dirOne = temp.mkdirSync('atom-spec-project-one') + fileOne = path.join(dirOne, 'file-one.txt') + fileTwo = path.join(dirOne, 'file-two.txt') + dirTwo = temp.mkdirSync('atom-spec-project-two') + fileThree = path.join(dirTwo, 'file-three.txt') + + # Ensure that all preexisting watchers are stopped + waitsForPromise -> stopAllWatchers() + + runs -> atom.project.setPaths([dirOne]) + waitsForPromise -> atom.project.watchersByPath[dirOne].getStartPromise() + + runs -> + expect(atom.project.watchersByPath[dirTwo]).toEqual undefined + + fs.writeFileSync fileThree, "three\n" + fs.writeFileSync fileTwo, "two\n" + fs.writeFileSync fileOne, "one\n" + + waitsForPromise -> waitForEvents [fileOne, fileTwo] + + runs -> + expect(events.some (event) -> event.path is fileThree).toBeFalsy() + describe ".onDidAddBuffer()", -> it "invokes the callback with added text buffers", -> buffers = [] diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 2379cc650..eec8ce5fb 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -64,8 +64,8 @@ beforeEach -> atom.project.setPaths([specProjectPath]) window.resetTimeouts() - spyOn(Date, 'now').andCallFake -> window.now spyOn(_._, "now").andCallFake -> window.now + spyOn(Date, 'now').andCallFake(-> window.now) spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout @@ -188,8 +188,6 @@ jasmine.useRealClock = -> jasmine.useMockClock = -> spyOn(window, 'setInterval').andCallFake(fakeSetInterval) spyOn(window, 'clearInterval').andCallFake(fakeClearInterval) - spyOn(Date, 'now').andCallFake(-> window.now) - addCustomMatchers = (spec) -> spec.addMatchers diff --git a/spec/squirrel-update-spec.coffee b/spec/squirrel-update-spec.coffee index 2838be297..fe0fa7479 100644 --- a/spec/squirrel-update-spec.coffee +++ b/spec/squirrel-update-spec.coffee @@ -37,7 +37,8 @@ describe "Windows Squirrel Update", -> WinShell.folderBackgroundContextMenu = new FakeShellOption() afterEach -> - temp.cleanupSync() + try + temp.cleanupSync() it "quits the app on all squirrel events", -> app = quit: jasmine.createSpy('quit') diff --git a/spec/style-manager-spec.js b/spec/style-manager-spec.js index e6b8acae6..641c93709 100644 --- a/spec/style-manager-spec.js +++ b/spec/style-manager-spec.js @@ -15,7 +15,11 @@ describe('StyleManager', () => { }) afterEach(() => { - temp.cleanupSync() + try { + temp.cleanupSync() + } catch (e) { + // Do nothing + } }) describe('::addStyleSheet(source, params)', () => { diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 7c211ef29..4c0108b33 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -119,6 +119,44 @@ describe('TextEditorComponent', () => { } }) + it('re-renders lines when their height changes', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) + element.style.height = 4 * component.measurements.lineHeight + 'px' + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(9) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) + + element.style.lineHeight = '2.0' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(6) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(6) + + element.style.lineHeight = '0.7' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(12) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(12) + + element.style.lineHeight = '0.05' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(13) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(13) + + element.style.lineHeight = '0' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(13) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(13) + + element.style.lineHeight = '1' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(element.querySelectorAll('.line-number:not(.dummy)').length).toBe(9) + expect(element.querySelectorAll('.line:not(.dummy)').length).toBe(9) + }) + it('makes the content at least as tall as the scroll container client height', async () => { const {component, element, editor} = buildComponent({text: 'a', height: 100}) expect(component.refs.content.offsetHeight).toBe(100) @@ -411,20 +449,15 @@ describe('TextEditorComponent', () => { await component.getNextUpdatePromise() const [cursor1, cursor2] = element.querySelectorAll('.cursor') - expect(getComputedStyle(cursor1).opacity).toBe('1') - expect(getComputedStyle(cursor2).opacity).toBe('1') - - await conditionPromise(() => - getComputedStyle(cursor1).opacity === '0' && getComputedStyle(cursor2).opacity === '0' - ) - await conditionPromise(() => getComputedStyle(cursor1).opacity === '1' && getComputedStyle(cursor2).opacity === '1' ) - await conditionPromise(() => getComputedStyle(cursor1).opacity === '0' && getComputedStyle(cursor2).opacity === '0' ) + await conditionPromise(() => + getComputedStyle(cursor1).opacity === '1' && getComputedStyle(cursor2).opacity === '1' + ) editor.moveRight() await component.getNextUpdatePromise() @@ -667,19 +700,38 @@ describe('TextEditorComponent', () => { expect(element.classList.contains('has-selection')).toBe(false) }) - it('assigns a buffer-row to each line number as a data field', async () => { + it('assigns buffer-row and screen-row to each line number as data fields', async () => { const {editor, element, component} = buildComponent() editor.setSoftWrapped(true) await component.getNextUpdatePromise() await setEditorWidthInCharacters(component, 40) + { + const bufferRows = Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map((e) => e.dataset.bufferRow) + const screenRows = Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map((e) => e.dataset.screenRow) + expect(bufferRows).toEqual([ + '0', '1', '2', '3', '3', '4', '5', '6', '6', '6', + '7', '8', '8', '8', '9', '10', '11', '11', '12' + ]) + expect(screenRows).toEqual([ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '10', '11', '12', '13', '14', '15', '16', '17', '18' + ]) + } - expect( - Array.from(element.querySelectorAll('.line-number:not(.dummy)')) - .map((element) => element.dataset.bufferRow) - ).toEqual([ - '0', '1', '2', '3', '3', '4', '5', '6', '6', '6', - '7', '8', '8', '8', '9', '10', '11', '11', '12' - ]) + editor.getBuffer().insert([2, 0], '\n') + await component.getNextUpdatePromise() + { + const bufferRows = Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map((e) => e.dataset.bufferRow) + const screenRows = Array.from(element.querySelectorAll('.line-number:not(.dummy)')).map((e) => e.dataset.screenRow) + expect(bufferRows).toEqual([ + '0', '1', '2', '3', '4', '4', '5', '6', '7', '7', + '7', '8', '9', '9', '9', '10', '11', '12', '12', '13' + ]) + expect(screenRows).toEqual([ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '10', '11', '12', '13', '14', '15', '16', '17', '18', '19' + ]) + } }) it('does not blow away class names added to the element by packages when changing the class name', async () => { @@ -1429,27 +1481,6 @@ describe('TextEditorComponent', () => { expect(highlights[0].classList.contains('b')).toBe(false) expect(highlights[1].classList.contains('b')).toBe(false) - // Flash existing highlight - decoration.flash('c', 100) - await component.getNextUpdatePromise() - expect(highlights[0].classList.contains('c')).toBe(true) - expect(highlights[1].classList.contains('c')).toBe(true) - - // Add second flash class - decoration.flash('d', 100) - await component.getNextUpdatePromise() - expect(highlights[0].classList.contains('c')).toBe(true) - expect(highlights[1].classList.contains('c')).toBe(true) - expect(highlights[0].classList.contains('d')).toBe(true) - expect(highlights[1].classList.contains('d')).toBe(true) - - await conditionPromise(() => - !highlights[0].classList.contains('c') && - !highlights[1].classList.contains('c') && - !highlights[0].classList.contains('d') && - !highlights[1].classList.contains('d') - ) - // Flashing the same class again before the first flash completes // removes the flash class and adds it back on the next frame to ensure // CSS transitions apply to the second flash. @@ -1474,6 +1505,27 @@ describe('TextEditorComponent', () => { ) }) + it("flashing a highlight decoration doesn't unflash other highlight decorations", async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 3, height: 200}) + const marker = editor.markScreenRange([[2, 4], [3, 4]]) + const decoration = editor.decorateMarker(marker, {type: 'highlight', class: 'a'}) + + // Flash one class + decoration.flash('c', 1000) + await component.getNextUpdatePromise() + const highlights = element.querySelectorAll('.highlight.a') + expect(highlights[0].classList.contains('c')).toBe(true) + expect(highlights[1].classList.contains('c')).toBe(true) + + // Flash another class while the previously-flashed class is still highlighted + decoration.flash('d', 100) + await component.getNextUpdatePromise() + expect(highlights[0].classList.contains('c')).toBe(true) + expect(highlights[1].classList.contains('c')).toBe(true) + expect(highlights[0].classList.contains('d')).toBe(true) + expect(highlights[1].classList.contains('d')).toBe(true) + }) + it('supports layer decorations', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 12}) const markerLayer = editor.addMarkerLayer() @@ -1510,6 +1562,27 @@ describe('TextEditorComponent', () => { await setScrollTop(component, component.getLineHeight() * 3) expect(element.querySelectorAll('.highlight.a').length).toBe(0) }) + + it('does not move existing highlights when adding or removing other highlight decorations (regression)', async () => { + const {component, element, editor} = buildComponent() + + const marker1 = editor.markScreenRange([[1, 6], [1, 10]]) + editor.decorateMarker(marker1, {type: 'highlight', class: 'a'}) + await component.getNextUpdatePromise() + const marker1Region = element.querySelector('.highlight.a') + expect(Array.from(marker1Region.parentElement.children).indexOf(marker1Region)).toBe(0) + + const marker2 = editor.markScreenRange([[1, 2], [1, 4]]) + editor.decorateMarker(marker2, {type: 'highlight', class: 'b'}) + await component.getNextUpdatePromise() + const marker2Region = element.querySelector('.highlight.b') + expect(Array.from(marker1Region.parentElement.children).indexOf(marker1Region)).toBe(0) + expect(Array.from(marker2Region.parentElement.children).indexOf(marker2Region)).toBe(1) + + marker2.destroy() + await component.getNextUpdatePromise() + expect(Array.from(marker1Region.parentElement.children).indexOf(marker1Region)).toBe(0) + }) }) describe('overlay decorations', () => { @@ -1718,6 +1791,8 @@ describe('TextEditorComponent', () => { const marker3 = editor.markScreenRange([[9, 0], [12, 0]]) const decorationElement1 = document.createElement('div') const decorationElement2 = document.createElement('div') + // Packages may adopt this class name for decorations to be styled the same as line numbers + decorationElement2.className = 'line-number' const decoration1 = gutterA.decorateMarker(marker1, {class: 'a'}) const decoration2 = gutterA.decorateMarker(marker2, {class: 'b', item: decorationElement1}) @@ -1727,29 +1802,41 @@ describe('TextEditorComponent', () => { let [decorationNode1, decorationNode2] = gutterA.getElement().firstChild.children const [decorationNode3] = gutterB.getElement().firstChild.children - expect(decorationNode1.className).toBe('a') + expect(decorationNode1.className).toBe('decoration a') expect(decorationNode1.getBoundingClientRect().top).toBe(clientTopForLine(component, 2)) expect(decorationNode1.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 5)) expect(decorationNode1.firstChild).toBeNull() - expect(decorationNode2.className).toBe('b') + expect(decorationNode2.className).toBe('decoration b') expect(decorationNode2.getBoundingClientRect().top).toBe(clientTopForLine(component, 6)) expect(decorationNode2.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 8)) expect(decorationNode2.firstChild).toBe(decorationElement1) + expect(decorationElement1.offsetHeight).toBe(decorationNode2.offsetHeight) + expect(decorationElement1.offsetWidth).toBe(decorationNode2.offsetWidth) - expect(decorationNode3.className).toBe('') + expect(decorationNode3.className).toBe('decoration') expect(decorationNode3.getBoundingClientRect().top).toBe(clientTopForLine(component, 9)) expect(decorationNode3.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 12) + component.getLineHeight()) expect(decorationNode3.firstChild).toBe(decorationElement2) + expect(decorationElement2.offsetHeight).toBe(decorationNode3.offsetHeight) + expect(decorationElement2.offsetWidth).toBe(decorationNode3.offsetWidth) + + // Inline styled height is updated when line height changes + element.style.fontSize = parseInt(getComputedStyle(element).fontSize) + 10 + 'px' + TextEditor.didUpdateStyles() + await component.getNextUpdatePromise() + expect(decorationElement1.offsetHeight).toBe(decorationNode2.offsetHeight) + expect(decorationElement2.offsetHeight).toBe(decorationNode3.offsetHeight) decoration1.setProperties({type: 'gutter', gutterName: 'a', class: 'c', item: decorationElement1}) - decoration2.setProperties({type: 'gutter', gutterName: 'a', item: decorationElement2}) + decoration2.setProperties({type: 'gutter', gutterName: 'a'}) decoration3.destroy() await component.getNextUpdatePromise() - expect(decorationNode1.className).toBe('c') + expect(decorationNode1.className).toBe('decoration c') expect(decorationNode1.firstChild).toBe(decorationElement1) - expect(decorationNode2.className).toBe('') - expect(decorationNode2.firstChild).toBe(decorationElement2) + expect(decorationElement1.offsetHeight).toBe(decorationNode1.offsetHeight) + expect(decorationNode2.className).toBe('decoration') + expect(decorationNode2.firstChild).toBeNull() expect(gutterB.getElement().firstChild.children.length).toBe(0) }) }) @@ -1941,8 +2028,6 @@ describe('TextEditorComponent', () => { item3.style.margin = '10px' item2.style.height = '33px' item2.style.margin = '0px' - component.invalidateBlockDecorationDimensions(decoration2) - component.invalidateBlockDecorationDimensions(decoration3) await component.getNextUpdatePromise() expect(component.getRenderedStartRow()).toBe(0) expect(component.getRenderedEndRow()).toBe(9) @@ -1973,7 +2058,6 @@ describe('TextEditorComponent', () => { item3.style.wordWrap = 'break-word' const contentWidthInCharacters = Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth()) item3.textContent = 'x'.repeat(contentWidthInCharacters * 2) - component.invalidateBlockDecorationDimensions(decoration3) await component.getNextUpdatePromise() // make the editor wider, so that the decoration doesn't wrap anymore. @@ -2035,6 +2119,31 @@ describe('TextEditorComponent', () => { expect(item6.previousSibling).toBe(lineNodeForScreenRow(component, 12)) }) + it('measures block decorations correctly when they are added before the component width has been updated', async () => { + { + const {editor, component, element} = buildComponent({autoHeight: false, width: 500, attach: false}) + const marker = editor.markScreenPosition([0, 0]) + const item = document.createElement('div') + item.textContent = 'block decoration' + const decoration = editor.decorateMarker(marker, {type: 'block', item}) + + jasmine.attachToDOM(element) + assertLinesAreAlignedWithLineNumbers(component) + } + + { + const {editor, component, element} = buildComponent({autoHeight: false, width: 800}) + const marker = editor.markScreenPosition([0, 0]) + const item = document.createElement('div') + item.textContent = 'block decoration that could wrap many times' + const decoration = editor.decorateMarker(marker, {type: 'block', item}) + + element.style.width = '50px' + await component.getNextUpdatePromise() + assertLinesAreAlignedWithLineNumbers(component) + } + }) + it('bases the width of the block decoration measurement area on the editor scroll width', async () => { const {component, element} = buildComponent({autoHeight: false, width: 150}) expect(component.refs.blockDecorationMeasurementArea.offsetWidth).toBe(component.getScrollWidth()) @@ -2044,6 +2153,39 @@ describe('TextEditorComponent', () => { expect(component.refs.blockDecorationMeasurementArea.offsetWidth).toBe(component.getScrollWidth()) }) + it('does not change the cursor position when clicking on a block decoration', async () => { + const {editor, component} = buildComponent() + + const decorationElement = document.createElement('div') + decorationElement.textContent = 'Parent' + const childElement = document.createElement('div') + childElement.textContent = 'Child' + decorationElement.appendChild(childElement) + const marker = editor.markScreenPosition([4, 0]) + editor.decorateMarker(marker, {type: 'block', item: decorationElement}) + await component.getNextUpdatePromise() + + const decorationElementClientRect = decorationElement.getBoundingClientRect() + component.didMouseDownOnContent({ + target: decorationElement, + detail: 1, + button: 0, + clientX: decorationElementClientRect.left, + clientY: decorationElementClientRect.top + }) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + + const childElementClientRect = childElement.getBoundingClientRect() + component.didMouseDownOnContent({ + target: childElement, + detail: 1, + button: 0, + clientX: childElementClientRect.left, + clientY: childElementClientRect.top + }) + expect(editor.getCursorScreenPosition()).toEqual([0, 0]) + }) + function createBlockDecorationAtScreenRow(editor, screenRow, {height, margin, position}) { const marker = editor.markScreenPosition([screenRow, 0], {invalidate: 'never'}) const item = document.createElement('div') @@ -2371,10 +2513,13 @@ describe('TextEditorComponent', () => { ctrlKey: true }) ) - expect(editor.getCursorScreenPositions()).toEqual([[1, 4]]) + expect(editor.getSelectedScreenRanges()).toEqual([ + [[1, 16], [1, 16]] + ]) // ctrl-click adds cursors on platforms *other* than macOS component.props.platform = 'win32' + editor.setCursorScreenPosition([1, 4]) component.didMouseDownOnContent( Object.assign(clientPositionForCharacter(component, 1, 16), { detail: 1, @@ -2963,199 +3108,433 @@ describe('TextEditorComponent', () => { }) }) + describe('paste event', () => { + it("prevents the browser's default processing for the event on Linux", () => { + const {component} = buildComponent({platform: 'linux'}) + const event = { preventDefault: () => {} } + spyOn(event, 'preventDefault') + + component.didPaste(event) + expect(event.preventDefault).toHaveBeenCalled() + }) + }) + describe('keyboard input', () => { - it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => { - const {editor, component, element} = buildComponent({text: ''}) - editor.insertText('x') - editor.setCursorBufferPosition([0, 1]) + describe('on Chrome 56', () => { + it('handles inserted accented characters via the press-and-hold menu on macOS correctly', async () => { + const {editor, component, element} = buildComponent({text: '', chromeVersion: 56}) + editor.insertText('x') + editor.setCursorBufferPosition([0, 1]) - // Simulate holding the A key to open the press-and-hold menu, - // then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Escape'}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Escape'}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by typing a number. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'Digit2'}) - component.didKeyup({code: 'Digit2'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by typing a number. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Digit2'}) + component.didKeyup({code: 'Digit2'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // then selecting an alternative by clicking on it. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by clicking on it. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then selecting one of them with Enter. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Enter'}) - component.didCompositionUpdate({data: 'á'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Enter'}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then selecting one of them with Enter. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.getHiddenInput().value = 'à' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Enter'}) + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.getHiddenInput()}) + component.didKeyup({code: 'Enter'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xá') - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xaa') - editor.undo() - expect(editor.getText()).toBe('x') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, - // cycling through the alternatives with the arrows, then closing it via ESC. - component.didKeydown({code: 'KeyO'}) - component.didKeypress({code: 'KeyO'}) - component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyO'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xoà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xoá') - component.didKeydown({code: 'Escape'}) - component.didCompositionUpdate({data: 'a'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) - component.didKeyup({code: 'Escape'}) - expect(editor.getText()).toBe('xoa') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.getHiddenInput().value = 'à' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.getHiddenInput().value = 'a' + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') - // Simulate holding the A key to open the press-and-hold menu, - // cycling through the alternatives with the arrows, then closing it by changing focus. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeydown({code: 'KeyA'}) - component.didKeydown({code: 'KeyA'}) - component.didKeyup({code: 'KeyA'}) - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionStart({data: ''}) - component.didCompositionUpdate({data: 'à'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xà') - component.didKeydown({code: 'ArrowRight'}) - component.didCompositionUpdate({data: 'á'}) - component.didKeyup({code: 'ArrowRight'}) - expect(editor.getText()).toBe('xá') - component.didCompositionUpdate({data: 'á'}) - component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) - component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) - expect(editor.getText()).toBe('xá') - // Ensure another "a" can be typed correctly. - component.didKeydown({code: 'KeyA'}) - component.didKeypress({code: 'KeyA'}) - component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) - component.didKeyup({code: 'KeyA'}) - expect(editor.getText()).toBe('xáa') - editor.undo() - expect(editor.getText()).toBe('x') + // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyO'}) + component.didKeypress({code: 'KeyO'}) + component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyO'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.getHiddenInput().value = 'à' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xoà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xoá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.getHiddenInput().value = 'a' + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xoa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it by changing focus. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.getHiddenInput().value = 'à' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didKeyup({code: 'ArrowRight'}) + await getNextTickPromise() + expect(editor.getText()).toBe('xá') + component.didCompositionUpdate({data: 'á'}) + component.getHiddenInput().value = 'á' + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) + await getNextTickPromise() + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + }) + }) + + describe('on other versions of Chrome', () => { + it('handles inserted accented characters via the press-and-hold menu on macOS correctly', () => { + const {editor, component, element} = buildComponent({text: '', chromeVersion: 57}) + editor.insertText('x') + editor.setCursorBufferPosition([0, 1]) + + // Simulate holding the A key to open the press-and-hold menu, + // then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Escape'}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by typing a number. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'Digit2'}) + component.didKeyup({code: 'Digit2'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // then selecting an alternative by clicking on it. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then selecting one of them with Enter. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Enter'}) + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Enter'}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xaa') + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate pressing the O key and holding the A key to open the press-and-hold menu right before releasing the O key, + // cycling through the alternatives with the arrows, then closing it via ESC. + component.didKeydown({code: 'KeyO'}) + component.didKeypress({code: 'KeyO'}) + component.didTextInput({data: 'o', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyO'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xoá') + component.didKeydown({code: 'Escape'}) + component.didCompositionUpdate({data: 'a'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'a', target: component.refs.cursorsAndInput.refs.hiddenInput}) + component.didKeyup({code: 'Escape'}) + expect(editor.getText()).toBe('xoa') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + editor.undo() + expect(editor.getText()).toBe('x') + + // Simulate holding the A key to open the press-and-hold menu, + // cycling through the alternatives with the arrows, then closing it by changing focus. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeydown({code: 'KeyA'}) + component.didKeydown({code: 'KeyA'}) + component.didKeyup({code: 'KeyA'}) + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionStart({data: ''}) + component.didCompositionUpdate({data: 'à'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xà') + component.didKeydown({code: 'ArrowRight'}) + component.didCompositionUpdate({data: 'á'}) + component.didKeyup({code: 'ArrowRight'}) + expect(editor.getText()).toBe('xá') + component.didCompositionUpdate({data: 'á'}) + component.didTextInput({data: 'á', stopPropagation: () => {}, preventDefault: () => {}}) + component.didCompositionEnd({data: 'á', target: component.refs.cursorsAndInput.refs.hiddenInput}) + expect(editor.getText()).toBe('xá') + // Ensure another "a" can be typed correctly. + component.didKeydown({code: 'KeyA'}) + component.didKeypress({code: 'KeyA'}) + component.didTextInput({data: 'a', stopPropagation: () => {}, preventDefault: () => {}}) + component.didKeyup({code: 'KeyA'}) + expect(editor.getText()).toBe('xáa') + editor.undo() + expect(editor.getText()).toBe('x') + }) }) }) @@ -3314,6 +3693,24 @@ describe('TextEditorComponent', () => { expect(top).toBe(clientTopForLine(referenceComponent, 12) - referenceContentRect.top) expect(left).toBe(clientLeftForCharacter(referenceComponent, 12, 1) - referenceContentRect.left) } + + // Measuring a currently rendered line while an autoscroll that causes + // that line to go off-screen is in progress. + { + editor.setCursorScreenPosition([10, 0]) + const {top, left} = component.pixelPositionForScreenPosition({row: 3, column: 5}) + expect(top).toBe(clientTopForLine(referenceComponent, 3) - referenceContentRect.top) + expect(left).toBe(clientLeftForCharacter(referenceComponent, 3, 5) - referenceContentRect.left) + } + }) + + it('does not get the component into an inconsistent state when the model has unflushed changes (regression)', async () => { + const {component, element, editor} = buildComponent({rowsPerTile: 2, autoHeight: false, text: ''}) + await setEditorHeightInLines(component, 10) + + const updatePromise = editor.getBuffer().append("hi\n") + component.screenPositionForPixelPosition({top: 800, left: 1}) + await updatePromise }) }) @@ -3351,6 +3748,16 @@ describe('TextEditorComponent', () => { pixelPosition.left += component.getBaseCharacterWidth() / 3 expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([12, 1]) } + + // Measuring a currently rendered line while an autoscroll that causes + // that line to go off-screen is in progress. + { + const pixelPosition = referenceComponent.pixelPositionForScreenPosition({row: 3, column: 4}) + pixelPosition.top += component.getLineHeight() / 3 + pixelPosition.left += component.getBaseCharacterWidth() / 3 + editor.setCursorBufferPosition([10, 0]) + expect(component.screenPositionForPixelPosition(pixelPosition)).toEqual([3, 4]) + } }) }) @@ -3452,6 +3859,7 @@ function buildComponent (params = {}) { rowsPerTile: params.rowsPerTile, updatedSynchronously: params.updatedSynchronously || false, platform: params.platform, + chromeVersion: params.chromeVersion, mouseWheelScrollSensitivity: params.mouseWheelScrollSensitivity }) const {element} = component @@ -3585,3 +3993,7 @@ function getElementHeight (element) { bottomRuler.remove() return height } + +function getNextTickPromise () { + return new Promise((resolve) => process.nextTick(resolve)) +} diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index c92c6f144..6b8844b17 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -9,6 +9,10 @@ describe('TextEditorElement', () => { beforeEach(() => { jasmineContent = document.body.querySelector('#jasmine-content') + // Force scrollbars to be visible regardless of local system configuration + const scrollbarStyle = document.createElement('style') + scrollbarStyle.textContent = '::-webkit-scrollbar { -webkit-appearance: none }' + jasmine.attachToDOM(scrollbarStyle) }) function buildTextEditorElement (options = {}) { @@ -199,6 +203,49 @@ describe('TextEditorElement', () => { expect(document.activeElement).toBe(element.querySelector('input')) }) }) + + describe('if focused when invisible due to a zero height and width', () => { + it('focuses the hidden input and does not throw an exception', () => { + const parentElement = document.createElement('div') + parentElement.style.position = 'absolute' + parentElement.style.width = '0px' + parentElement.style.height = '0px' + + const element = buildTextEditorElement({attach: false}) + parentElement.appendChild(element) + jasmineContent.appendChild(parentElement) + + element.focus() + expect(document.activeElement).toBe(element.component.getHiddenInput()) + }) + }) + }) + + describe('::setModel', () => { + describe('when the element does not have an editor yet', () => { + it('uses the supplied one', () => { + const element = buildTextEditorElement({attach: false}) + const editor = new TextEditor() + element.setModel(editor) + jasmine.attachToDOM(element) + expect(editor.element).toBe(element) + expect(element.getModel()).toBe(editor) + }) + }) + + describe('when the element already has an editor', () => { + it('unbinds it and then swaps it with the supplied one', async () => { + const element = buildTextEditorElement({attach: true}) + const previousEditor = element.getModel() + expect(previousEditor.element).toBe(element) + + const newEditor = new TextEditor() + element.setModel(newEditor) + expect(previousEditor.element).not.toBe(element) + expect(newEditor.element).toBe(element) + expect(element.getModel()).toBe(newEditor) + }) + }) }) describe('::onDidAttach and ::onDidDetach', () => diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee index 42ed9be6d..5d2912f5b 100644 --- a/spec/theme-manager-spec.coffee +++ b/spec/theme-manager-spec.coffee @@ -9,7 +9,8 @@ describe "atom.themes", -> afterEach -> atom.themes.deactivateThemes() - temp.cleanupSync() + try + temp.cleanupSync() describe "theme getters and setters", -> beforeEach -> diff --git a/spec/title-bar-spec.coffee b/spec/title-bar-spec.coffee deleted file mode 100644 index 85bcba7e6..000000000 --- a/spec/title-bar-spec.coffee +++ /dev/null @@ -1,29 +0,0 @@ -TitleBar = require '../src/title-bar' - -describe "TitleBar", -> - it "updates the title based on document.title when the active pane item changes", -> - titleBar = new TitleBar({ - workspace: atom.workspace, - themes: atom.themes, - applicationDelegate: atom.applicationDelegate, - }) - - expect(titleBar.element.querySelector('.title').textContent).toBe document.title - initialTitle = document.title - - atom.workspace.getActivePane().activateItem({ - getTitle: -> 'Test Title' - }) - - expect(document.title).not.toBe(initialTitle) - expect(titleBar.element.querySelector('.title').textContent).toBe document.title - - it "can update the sheet offset for the current window based on its height", -> - titleBar = new TitleBar({ - workspace: atom.workspace, - themes: atom.themes, - applicationDelegate: atom.applicationDelegate, - }) - expect(-> - titleBar.updateWindowSheetOffset() - ).not.toThrow() diff --git a/spec/title-bar-spec.js b/spec/title-bar-spec.js new file mode 100644 index 000000000..b219a5819 --- /dev/null +++ b/spec/title-bar-spec.js @@ -0,0 +1,57 @@ +const TitleBar = require('../src/title-bar') +const temp = require('temp').track() + +describe('TitleBar', () => { + it('updates its title when document.title changes', () => { + const titleBar = new TitleBar({ + workspace: atom.workspace, + themes: atom.themes, + applicationDelegate: atom.applicationDelegate + }) + expect(titleBar.element.querySelector('.title').textContent).toBe(document.title) + + const paneItem = new FakePaneItem('Title 1') + atom.workspace.getActivePane().activateItem(paneItem) + expect(document.title).toMatch('Title 1') + expect(titleBar.element.querySelector('.title').textContent).toBe(document.title) + + paneItem.setTitle('Title 2') + expect(document.title).toMatch('Title 2') + expect(titleBar.element.querySelector('.title').textContent).toBe(document.title) + + atom.project.setPaths([temp.mkdirSync('project-1')]) + expect(document.title).toMatch('project-1') + expect(titleBar.element.querySelector('.title').textContent).toBe(document.title) + }) + + it('can update the sheet offset for the current window based on its height', () => { + const titleBar = new TitleBar({ + workspace: atom.workspace, + themes: atom.themes, + applicationDelegate: atom.applicationDelegate + }) + expect(() => titleBar.updateWindowSheetOffset()).not.toThrow() + }) +}) + +class FakePaneItem { + constructor (title) { + this.title = title + } + + getTitle () { + return this.title + } + + onDidChangeTitle (callback) { + this.didChangeTitleCallback = callback + return { + dispose: () => { this.didChangeTitleCallback = null } + } + } + + setTitle (title) { + this.title = title + if (this.didChangeTitleCallback) this.didChangeTitleCallback(title) + } +} diff --git a/spec/tokenized-buffer-spec.coffee b/spec/tokenized-buffer-spec.coffee index 5b1863b35..07e7e80e6 100644 --- a/spec/tokenized-buffer-spec.coffee +++ b/spec/tokenized-buffer-spec.coffee @@ -184,7 +184,7 @@ describe "TokenizedBuffer", -> it "schedules the invalidated lines to be tokenized in the background", -> buffer.insert([5, 30], '/* */') buffer.setTextInRange([[2, 0], [3, 0]], '/*') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.js'] + expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js'] expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js'] advanceClock() @@ -214,7 +214,7 @@ describe "TokenizedBuffer", -> it "schedules the invalidated lines to be tokenized in the background", -> buffer.insert([5, 30], '/* */') buffer.insert([2, 0], '/*\nabcde\nabcder') - expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.js'] + expect(tokenizedBuffer.tokenizedLines[2].tokens[0].scopes).toEqual ['source.js', 'comment.block.js', 'punctuation.definition.comment.begin.js'] expect(tokenizedBuffer.tokenizedLines[3].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] expect(tokenizedBuffer.tokenizedLines[4].tokens[0].scopes).toEqual ['source.js', 'comment.block.js'] expect(tokenizedBuffer.tokenizedLines[5].tokens[0].scopes).toEqual ['source.js'] @@ -596,10 +596,10 @@ describe "TokenizedBuffer", -> {position: Point(0, 9), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} {position: Point(0, 10), closeTags: [], openTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"]} {position: Point(0, 11), closeTags: ["syntax--constant syntax--numeric syntax--decimal syntax--js"], openTags: []} - {position: Point(0, 12), closeTags: [], openTags: ["syntax--comment syntax--block syntax--js", "syntax--punctuation syntax--definition syntax--comment syntax--js"]} - {position: Point(0, 14), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js"], openTags: []} - {position: Point(1, 5), closeTags: [], openTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js"]} - {position: Point(1, 7), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--js", "syntax--comment syntax--block syntax--js"], openTags: ["syntax--storage syntax--type syntax--var syntax--js"]} + {position: Point(0, 12), closeTags: [], openTags: ["syntax--comment syntax--block syntax--js", "syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js"]} + {position: Point(0, 14), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--begin syntax--js"], openTags: []} + {position: Point(1, 5), closeTags: [], openTags: ["syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js"]} + {position: Point(1, 7), closeTags: ["syntax--punctuation syntax--definition syntax--comment syntax--end syntax--js", "syntax--comment syntax--block syntax--js"], openTags: ["syntax--storage syntax--type syntax--var syntax--js"]} {position: Point(1, 10), closeTags: ["syntax--storage syntax--type syntax--var syntax--js"], openTags: []} {position: Point(1, 15), closeTags: [], openTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"]} {position: Point(1, 16), closeTags: ["syntax--keyword syntax--operator syntax--assignment syntax--js"], openTags: []} diff --git a/spec/update-process-env-spec.js b/spec/update-process-env-spec.js index f730ae632..e5a1cfd9c 100644 --- a/spec/update-process-env-spec.js +++ b/spec/update-process-env-spec.js @@ -28,7 +28,11 @@ describe('updateProcessEnv(launchEnv)', function () { } process.env = originalProcessEnv process.platform = originalProcessPlatform - temp.cleanupSync() + try { + temp.cleanupSync() + } catch (e) { + // Do nothing + } }) describe('when the launch environment appears to come from a shell', function () { diff --git a/spec/workspace-element-spec.js b/spec/workspace-element-spec.js index aa5430c88..2e37d9903 100644 --- a/spec/workspace-element-spec.js +++ b/spec/workspace-element-spec.js @@ -9,7 +9,13 @@ const {Disposable} = require('event-kit') const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') describe('WorkspaceElement', () => { - afterEach(() => { temp.cleanupSync() }) + afterEach(() => { + try { + temp.cleanupSync() + } catch (e) { + // Do nothing + } + }) describe('when the workspace element is focused', () => { it('transfers focus to the active pane', () => { diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index 0fe1d7afa..476a4ba5b 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -25,7 +25,13 @@ describe('Workspace', () => { waitsForPromise(() => atom.workspace.itemLocationStore.clear()) }) - afterEach(() => temp.cleanupSync()) + afterEach(() => { + try { + temp.cleanupSync() + } catch (e) { + // Do nothing + } + }) function simulateReload() { waitsForPromise(() => { @@ -1582,6 +1588,7 @@ i = /test/; #FIXME\ expect(atom2.grammars.getGrammars().map(grammar => grammar.name).sort()).toEqual([ 'CoffeeScript', 'CoffeeScript (Literate)', + 'JSDoc', 'JavaScript', 'Null Grammar', 'Regular Expression Replacement (JavaScript)', @@ -2387,6 +2394,22 @@ i = /test/; #FIXME\ expect(results[0].replacements).toBe(6) }) }) + + it('does not discard the multiline flag', () => { + const filePath = path.join(projectDir, 'sample.js') + fs.copyFileSync(path.join(fixturesDir, 'sample.js'), filePath) + + const results = [] + waitsForPromise(() => + atom.workspace.replace(/;$/gmi, 'items', [filePath], result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(1) + expect(results[0].filePath).toBe(filePath) + expect(results[0].replacements).toBe(8) + }) + }) }) describe('when a buffer is already open', () => { diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee index fdb7119e1..78ea42087 100644 --- a/src/application-delegate.coffee +++ b/src/application-delegate.coffee @@ -143,6 +143,7 @@ class ApplicationDelegate message: message detail: detailedMessage buttons: buttonLabels + normalizeAccessKeys: true }) if _.isArray(buttons) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index f35ed39d8..b37acddd1 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -938,8 +938,8 @@ class AtomEnvironment extends Model "Would you like to add the #{nouns} to this window, permanently discarding the saved state, " + "or open the #{nouns} in a new window, restoring the saved state?" buttons: [ - 'Open in new window and recover state' - 'Add to this window and discard state' + '&Open in new window and recover state' + '&Add to this window and discard state' ] if btn is 0 @open diff --git a/src/config-schema.js b/src/config-schema.js index 39f058555..fb0164766 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -308,6 +308,21 @@ const configSchema = { description: 'Warn before opening files larger than this number of megabytes.', type: 'number', default: 40 + }, + fileSystemWatcher: { + description: 'Choose the underlying implementation used to watch for filesystem changes. Emulating changes will miss any events caused by applications other than Atom, but may help prevent crashes or freezes.', + type: 'string', + default: 'native', + enum: [ + { + value: 'native', + description: 'Native operating system APIs' + }, + { + value: 'atom', + description: 'Emulated with Atom events' + } + ] } } }, diff --git a/src/cursor.coffee b/src/cursor.coffee index 74922ff51..128fe7ff5 100644 --- a/src/cursor.coffee +++ b/src/cursor.coffee @@ -49,7 +49,7 @@ class Cursor extends Model # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback ### Section: Managing Cursor Position diff --git a/src/decoration.coffee b/src/decoration.coffee index 7be15d9f5..f18733f6e 100644 --- a/src/decoration.coffee +++ b/src/decoration.coffee @@ -105,7 +105,7 @@ class Decoration # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback ### Section: Decoration Details diff --git a/src/dock.js b/src/dock.js index 68a34464b..30284f884 100644 --- a/src/dock.js +++ b/src/dock.js @@ -1,7 +1,7 @@ 'use strict' const _ = require('underscore-plus') -const {CompositeDisposable} = require('event-kit') +const {CompositeDisposable, Emitter} = require('event-kit') const PaneContainer = require('./pane-container') const TextEditor = require('./text-editor') const Grim = require('grim') @@ -35,7 +35,8 @@ module.exports = class Dock { this.notificationManager = params.notificationManager this.viewRegistry = params.viewRegistry this.didActivate = params.didActivate - this.didHide = params.didHide + + this.emitter = new Emitter() this.paneContainer = new PaneContainer({ location: this.location, @@ -53,6 +54,7 @@ module.exports = class Dock { } this.subscriptions = new CompositeDisposable( + this.emitter, this.paneContainer.onDidActivatePane(() => { this.show() this.didActivate(this) @@ -135,14 +137,12 @@ module.exports = class Dock { setState (newState) { const prevState = this.state const nextState = Object.assign({}, prevState, newState) - let didHide = false // Update the `shouldAnimate` state. This needs to be written to the DOM before updating the // class that changes the animated property. Normally we'd have to defer the class change a // frame to ensure the property is animated (or not) appropriately, however we luck out in this // case because the drag start always happens before the item is dragged into the toggle button. if (nextState.visible !== prevState.visible) { - didHide = !nextState.visible // Never animate toggling visiblity... nextState.shouldAnimate = false } else if (!nextState.visible && nextState.draggingItem && !prevState.draggingItem) { @@ -152,7 +152,11 @@ module.exports = class Dock { this.state = nextState this.render(this.state) - if (didHide) this.didHide(this) + + const {visible} = this.state + if (visible !== prevState.visible) { + this.emitter.emit('did-change-visible', visible) + } } render (state) { @@ -379,12 +383,31 @@ module.exports = class Dock { }) } - // PaneContainer-delegating methods - /* Section: Event Subscription */ + // Essential: Invoke the given callback when the visibility of the dock changes. + // + // * `callback` {Function} to be called when the visibility changes. + // * `visible` {Boolean} Is the dock now visible? + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeVisible (callback) { + return this.emitter.on('did-change-visible', callback) + } + + // Essential: Invoke the given callback with the current and all future visibilities of the dock. + // + // * `callback` {Function} to be called when the visibility changes. + // * `visible` {Boolean} Is the dock now visible? + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeVisible (callback) { + callback(this.isVisible()) + return this.onDidChangeVisible(callback) + } + // Essential: Invoke the given callback with all current and future panes items // in the dock. // diff --git a/src/git-repository.coffee b/src/git-repository.coffee index f80152737..c7105baef 100644 --- a/src/git-repository.coffee +++ b/src/git-repository.coffee @@ -129,7 +129,7 @@ class GitRepository # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback ### Section: Event Subscription diff --git a/src/gutter.coffee b/src/gutter.coffee index 6b39398dd..4521eeeb2 100644 --- a/src/gutter.coffee +++ b/src/gutter.coffee @@ -48,7 +48,7 @@ class Gutter # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback ### Section: Visibility diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index 944f2783c..dcc7c6513 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -120,7 +120,9 @@ class AtomApplication Promise.all(windowsClosePromises).then(=> @disposable.dispose()) launch: (options) -> - if options.pathsToOpen?.length > 0 or options.urlsToOpen?.length > 0 or options.test or options.benchmark or options.benchmarkTest + if options.test or options.benchmark or options.benchmarkTest + @openWithOptions(options) + else if options.pathsToOpen?.length > 0 or options.urlsToOpen?.length > 0 if @config.get('core.restorePreviousWindowsOnStart') is 'always' @loadState(_.deepClone(options)) @openWithOptions(options) @@ -267,10 +269,19 @@ class AtomApplication @openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) @disposable.add ipcHelpers.on app, 'before-quit', (event) => - unless @quitting + resolveBeforeQuitPromise = null + @lastBeforeQuitPromise = new Promise((resolve) -> resolveBeforeQuitPromise = resolve) + if @quitting + resolveBeforeQuitPromise() + else event.preventDefault() @quitting = true - Promise.all(@windows.map((window) -> window.prepareToUnload())).then(-> app.quit()) + windowUnloadPromises = @windows.map((window) -> window.prepareToUnload()) + Promise.all(windowUnloadPromises).then((windowUnloadedResults) -> + didUnloadAllWindows = windowUnloadedResults.every((didUnloadWindow) -> didUnloadWindow) + app.quit() if didUnloadAllWindows + resolveBeforeQuitPromise() + ) @disposable.add ipcHelpers.on app, 'will-quit', => @killAllProcesses() diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index b0b516b10..f20239e66 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -54,14 +54,14 @@ class AtomWindow @browserWindow = new BrowserWindow(options) @handleEvents() - loadSettings = Object.assign({}, settings) - loadSettings.appVersion = app.getVersion() - loadSettings.resourcePath = @resourcePath - loadSettings.devMode ?= false - loadSettings.safeMode ?= false - loadSettings.atomHome = process.env.ATOM_HOME - loadSettings.clearWindowState ?= false - loadSettings.initialPaths ?= + @loadSettings = Object.assign({}, settings) + @loadSettings.appVersion = app.getVersion() + @loadSettings.resourcePath = @resourcePath + @loadSettings.devMode ?= false + @loadSettings.safeMode ?= false + @loadSettings.atomHome = process.env.ATOM_HOME + @loadSettings.clearWindowState ?= false + @loadSettings.initialPaths ?= for {pathToOpen} in locationsToOpen when pathToOpen stat = fs.statSyncNoException(pathToOpen) or null if stat?.isDirectory() @@ -72,17 +72,17 @@ class AtomWindow parentDirectory else pathToOpen - loadSettings.initialPaths.sort() + @loadSettings.initialPaths.sort() # Only send to the first non-spec window created if @constructor.includeShellLoadTime and not @isSpec @constructor.includeShellLoadTime = false - loadSettings.shellLoadTime ?= Date.now() - global.shellStartTime + @loadSettings.shellLoadTime ?= Date.now() - global.shellStartTime - @representedDirectoryPaths = loadSettings.initialPaths - @env = loadSettings.env if loadSettings.env? + @representedDirectoryPaths = @loadSettings.initialPaths + @env = @loadSettings.env if @loadSettings.env? - @browserWindow.loadSettingsJSON = JSON.stringify(loadSettings) + @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) @browserWindow.on 'window:loaded', => @disableZoom() @@ -309,6 +309,8 @@ class AtomWindow setRepresentedDirectoryPaths: (@representedDirectoryPaths) -> @representedDirectoryPaths.sort() + @loadSettings.initialPaths = @representedDirectoryPaths + @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) @atomApplication.saveState() copy: -> @browserWindow.copy() diff --git a/src/main-process/parse-command-line.js b/src/main-process/parse-command-line.js index 3f9f2523a..7531e609b 100644 --- a/src/main-process/parse-command-line.js +++ b/src/main-process/parse-command-line.js @@ -55,6 +55,7 @@ module.exports = function parseCommandLine (processArgs) { options.string('socket-path') options.string('user-data-dir') options.boolean('clear-window-state').describe('clear-window-state', 'Delete all Atom environment state.') + options.boolean('enable-electron-logging').describe('enable-electron-logging', 'Enable low-level logging messages from Electron.') const args = options.argv diff --git a/src/main-process/start.js b/src/main-process/start.js index fae78a07e..9670e67b6 100644 --- a/src/main-process/start.js +++ b/src/main-process/start.js @@ -1,7 +1,7 @@ const {app} = require('electron') const nslog = require('nslog') const path = require('path') -const temp = require('temp') +const temp = require('temp').track() const parseCommandLine = require('./parse-command-line') const startCrashReporter = require('../crash-reporter-start') const atomPaths = require('../atom-paths') diff --git a/src/native-watcher-registry.js b/src/native-watcher-registry.js new file mode 100644 index 000000000..a779f78f5 --- /dev/null +++ b/src/native-watcher-registry.js @@ -0,0 +1,436 @@ +/** @babel */ + +const path = require('path') + +// Private: re-join the segments split from an absolute path to form another absolute path. +function absolute (...parts) { + const candidate = path.join(...parts) + return path.isAbsolute(candidate) ? candidate : path.join(path.sep, candidate) +} + +// Private: Map userland filesystem watcher subscriptions efficiently to deliver filesystem change notifications to +// each watcher with the most efficient coverage of native watchers. +// +// * If two watchers subscribe to the same directory, use a single native watcher for each. +// * Re-use a native watcher watching a parent directory for a watcher on a child directory. If the parent directory +// watcher is removed, it will be split into child watchers. +// * If any child directories already being watched, stop and replace them with a watcher on the parent directory. +// +// Uses a trie whose structure mirrors the directory structure. +class RegistryTree { + + // Private: Construct a tree with no native watchers. + // + // * `basePathSegments` the position of this tree's root relative to the filesystem's root as an {Array} of directory + // names. + // * `createNative` {Function} used to construct new native watchers. It should accept an absolute path as an argument + // and return a new {NativeWatcher}. + constructor (basePathSegments, createNative) { + this.basePathSegments = basePathSegments + this.root = new RegistryNode() + this.createNative = createNative + } + + // Private: Identify the native watcher that should be used to produce events at a watched path, creating a new one + // if necessary. + // + // * `pathSegments` the path to watch represented as an {Array} of directory names relative to this {RegistryTree}'s + // root. + // * `attachToNative` {Function} invoked with the appropriate native watcher and the absolute path to its watch root. + add (pathSegments, attachToNative) { + const absolutePathSegments = this.basePathSegments.concat(pathSegments) + const absolutePath = absolute(...absolutePathSegments) + + const attachToNew = (childPaths) => { + const native = this.createNative(absolutePath) + const leaf = new RegistryWatcherNode(native, absolutePathSegments, childPaths) + this.root = this.root.insert(pathSegments, leaf) + + const sub = native.onWillStop(() => { + sub.dispose() + this.root = this.root.remove(pathSegments, this.createNative) || new RegistryNode() + }) + + attachToNative(native, absolutePath) + return native + } + + this.root.lookup(pathSegments).when({ + parent: (parent, remaining) => { + // An existing NativeWatcher is watching the same directory or a parent directory of the requested path. + // Attach this Watcher to it as a filtering watcher and record it as a dependent child path. + const native = parent.getNativeWatcher() + parent.addChildPath(remaining) + attachToNative(native, absolute(...parent.getAbsolutePathSegments())) + }, + children: children => { + // One or more NativeWatchers exist on child directories of the requested path. Create a new native watcher + // on the parent directory, note the subscribed child paths, and cleanly stop the child native watchers. + const newNative = attachToNew(children.map(child => child.path)) + + for (let i = 0; i < children.length; i++) { + const childNode = children[i].node + const childNative = childNode.getNativeWatcher() + childNative.reattachTo(newNative, absolutePath) + childNative.dispose() + childNative.stop() + } + }, + missing: () => attachToNew([]) + }) + } + + // Private: Access the root node of the tree. + getRoot () { + return this.root + } + + // Private: Return a {String} representation of this tree's structure for diagnostics and testing. + print () { + return this.root.print() + } + +} + +// Private: Non-leaf node in a {RegistryTree} used by the {NativeWatcherRegistry} to cover the allocated {Watcher} +// instances with the most efficient set of {NativeWatcher} instances possible. Each {RegistryNode} maps to a directory +// in the filesystem tree. +class RegistryNode { + + // Private: Construct a new, empty node representing a node with no watchers. + constructor () { + this.children = {} + } + + // Private: Recursively discover any existing watchers corresponding to a path. + // + // * `pathSegments` filesystem path of a new {Watcher} already split into an Array of directory names. + // + // Returns: A {ParentResult} if the exact requested directory or a parent directory is being watched, a + // {ChildrenResult} if one or more child paths are being watched, or a {MissingResult} if no relevant watchers + // exist. + lookup (pathSegments) { + if (pathSegments.length === 0) { + return new ChildrenResult(this.leaves([])) + } + + const child = this.children[pathSegments[0]] + if (child === undefined) { + return new MissingResult(this) + } + + return child.lookup(pathSegments.slice(1)) + } + + // Private: Insert a new {RegistryWatcherNode} into the tree, creating new intermediate {RegistryNode} instances as + // needed. Any existing children of the watched directory are removed. + // + // * `pathSegments` filesystem path of the new {Watcher}, already split into an Array of directory names. + // * `leaf` initialized {RegistryWatcherNode} to insert + // + // Returns: The root of a new tree with the {RegistryWatcherNode} inserted at the correct location. Callers should + // replace their node references with the returned value. + insert (pathSegments, leaf) { + if (pathSegments.length === 0) { + return leaf + } + + const pathKey = pathSegments[0] + let child = this.children[pathKey] + if (child === undefined) { + child = new RegistryNode() + } + this.children[pathKey] = child.insert(pathSegments.slice(1), leaf) + return this + } + + // Private: Remove a {RegistryWatcherNode} by its exact watched directory. + // + // * `pathSegments` absolute pre-split filesystem path of the node to remove. + // * `createSplitNative` callback to be invoked with each child path segment {Array} if the {RegistryWatcherNode} + // is split into child watchers rather than removed outright. See {RegistryWatcherNode.remove}. + // + // Returns: The root of a new tree with the {RegistryWatcherNode} removed. Callers should replace their node + // references with the returned value. + remove (pathSegments, createSplitNative) { + if (pathSegments.length === 0) { + // Attempt to remove a path with child watchers. Do nothing. + return this + } + + const pathKey = pathSegments[0] + const child = this.children[pathKey] + if (child === undefined) { + // Attempt to remove a path that isn't watched. Do nothing. + return this + } + + // Recurse + const newChild = child.remove(pathSegments.slice(1), createSplitNative) + if (newChild === null) { + delete this.children[pathKey] + } else { + this.children[pathKey] = newChild + } + + // Remove this node if all of its children have been removed + return Object.keys(this.children).length === 0 ? null : this + } + + // Private: Discover all {RegistryWatcherNode} instances beneath this tree node and the child paths + // that they are watching. + // + // * `prefix` {Array} of intermediate path segments to prepend to the resulting child paths. + // + // Returns: A possibly empty {Array} of `{node, path}` objects describing {RegistryWatcherNode} + // instances beneath this node. + leaves (prefix) { + const results = [] + for (const p of Object.keys(this.children)) { + results.push(...this.children[p].leaves(prefix.concat([p]))) + } + return results + } + + // Private: Return a {String} representation of this subtree for diagnostics and testing. + print (indent = 0) { + let spaces = '' + for (let i = 0; i < indent; i++) { + spaces += ' ' + } + + let result = '' + for (const p of Object.keys(this.children)) { + result += `${spaces}${p}\n${this.children[p].print(indent + 2)}` + } + return result + } +} + +// Private: Leaf node within a {NativeWatcherRegistry} tree. Represents a directory that is covered by a +// {NativeWatcher}. +class RegistryWatcherNode { + + // Private: Allocate a new node to track a {NativeWatcher}. + // + // * `nativeWatcher` An existing {NativeWatcher} instance. + // * `absolutePathSegments` The absolute path to this {NativeWatcher}'s directory as an {Array} of + // path segments. + // * `childPaths` {Array} of child directories that are currently the responsibility of this + // {NativeWatcher}, if any. Directories are represented as arrays of the path segments between this + // node's directory and the watched child path. + constructor (nativeWatcher, absolutePathSegments, childPaths) { + this.nativeWatcher = nativeWatcher + this.absolutePathSegments = absolutePathSegments + + // Store child paths as joined strings so they work as Set members. + this.childPaths = new Set() + for (let i = 0; i < childPaths.length; i++) { + this.childPaths.add(path.join(...childPaths[i])) + } + } + + // Private: Assume responsibility for a new child path. If this node is removed, it will instead + // split into a subtree with a new {RegistryWatcherNode} for each child path. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the watched + // child directory. + addChildPath (childPathSegments) { + this.childPaths.add(path.join(...childPathSegments)) + } + + // Private: Stop assuming responsbility for a previously assigned child path. If this node is + // removed, the named child path will no longer be allocated a {RegistryWatcherNode}. + // + // * `childPathSegments` the {Array} of path segments between this node's directory and the no longer + // watched child directory. + removeChildPath (childPathSegments) { + this.childPaths.delete(path.join(...childPathSegments)) + } + + // Private: Accessor for the {NativeWatcher}. + getNativeWatcher () { + return this.nativeWatcher + } + + // Private: Return the absolute path watched by this {NativeWatcher} as an {Array} of directory names. + getAbsolutePathSegments () { + return this.absolutePathSegments + } + + // Private: Identify how this watcher relates to a request to watch a directory tree. + // + // * `pathSegments` filesystem path of a new {Watcher} already split into an Array of directory names. + // + // Returns: A {ParentResult} referencing this node. + lookup (pathSegments) { + return new ParentResult(this, pathSegments) + } + + // Private: Remove this leaf node if the watcher's exact path matches. If this node is covering additional + // {Watcher} instances on child paths, it will be split into a subtree. + // + // * `pathSegments` filesystem path of the node to remove. + // * `createSplitNative` callback invoked with each {Array} of absolute child path segments to create a native + // watcher on a subtree of this node. + // + // Returns: If `pathSegments` match this watcher's path exactly, returns `null` if this node has no `childPaths` + // or a new {RegistryNode} on a newly allocated subtree if it did. If `pathSegments` does not match the watcher's + // path, it's an attempt to remove a subnode that doesn't exist, so the remove call has no effect and returns + // `this` unaltered. + remove (pathSegments, createSplitNative) { + if (pathSegments.length !== 0) { + return this + } else if (this.childPaths.size > 0) { + let newSubTree = new RegistryTree(this.absolutePathSegments, createSplitNative) + + for (const childPath of this.childPaths) { + const childPathSegments = childPath.split(path.sep) + newSubTree.add(childPathSegments, (native, attachmentPath) => { + this.nativeWatcher.reattachTo(native, attachmentPath) + }) + } + + return newSubTree.getRoot() + } else { + return null + } + } + + // Private: Discover this {RegistryWatcherNode} instance. + // + // * `prefix` {Array} of intermediate path segments to prepend to the resulting child paths. + // + // Returns: An {Array} containing a `{node, path}` object describing this node. + leaves (prefix) { + return [{node: this, path: prefix}] + } + + // Private: Return a {String} representation of this watcher for diagnostics and testing. Indicates the number of + // child paths that this node's {NativeWatcher} is responsible for. + print (indent = 0) { + let result = '' + for (let i = 0; i < indent; i++) { + result += ' ' + } + result += '[watcher' + if (this.childPaths.size > 0) { + result += ` +${this.childPaths.size}` + } + result += ']\n' + + return result + } +} + +// Private: A {RegisteryNode} traversal result that's returned when neither a directory, its children, nor its parents +// are present in the tree. +class MissingResult { + + // Private: Instantiate a new {MissingResult}. + // + // * `lastParent` the final succesfully traversed {RegistryNode}. + constructor (lastParent) { + this.lastParent = lastParent + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `missing` key that maps to a callback to be invoked when no results were returned + // by {RegistryNode.lookup}. The callback will be called with the last parent node that was encountered during the + // traversal. + // + // Returns: the result of the `actions` callback. + when (actions) { + return actions.missing(this.lastParent) + } +} + +// Private: A {RegistryNode.lookup} traversal result that's returned when a parent or an exact match of the requested +// directory is being watched by an existing {RegistryWatcherNode}. +class ParentResult { + + // Private: Instantiate a new {ParentResult}. + // + // * `parent` the {RegistryWatcherNode} that was discovered. + // * `remainingPathSegments` an {Array} of the directories that lie between the leaf node's watched directory and + // the requested directory. This will be empty for exact matches. + constructor (parent, remainingPathSegments) { + this.parent = parent + this.remainingPathSegments = remainingPathSegments + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `parent` key that maps to a callback to be invoked when a parent of a requested + // requested directory is returned by a {RegistryNode.lookup} call. The callback will be called with the + // {RegistryWatcherNode} instance and an {Array} of the {String} path segments that separate the parent node + // and the requested directory. + // + // Returns: the result of the `actions` callback. + when (actions) { + return actions.parent(this.parent, this.remainingPathSegments) + } +} + +// Private: A {RegistryNode.lookup} traversal result that's returned when one or more children of the requested +// directory are already being watched. +class ChildrenResult { + + // Private: Instantiate a new {ChildrenResult}. + // + // * `children` {Array} of the {RegistryWatcherNode} instances that were discovered. + constructor (children) { + this.children = children + } + + // Private: Dispatch within a map of callback actions. + // + // * `actions` {Object} containing a `children` key that maps to a callback to be invoked when a parent of a requested + // requested directory is returned by a {RegistryNode.lookup} call. The callback will be called with the + // {RegistryWatcherNode} instance. + // + // Returns: the result of the `actions` callback. + when (actions) { + return actions.children(this.children) + } +} + +// Private: Track the directories being monitored by native filesystem watchers. Minimize the number of native watchers +// allocated to receive events for a desired set of directories by: +// +// 1. Subscribing to the same underlying {NativeWatcher} when watching the same directory multiple times. +// 2. Subscribing to an existing {NativeWatcher} on a parent of a desired directory. +// 3. Replacing multiple {NativeWatcher} instances on child directories with a single new {NativeWatcher} on the +// parent. +class NativeWatcherRegistry { + + // Private: Instantiate an empty registry. + // + // * `createNative` {Function} that will be called with a normalized filesystem path to create a new native + // filesystem watcher. + constructor (createNative) { + this.tree = new RegistryTree([], createNative) + } + + // Private: Attach a watcher to a directory, assigning it a {NativeWatcher}. If a suitable {NativeWatcher} already + // exists, it will be attached to the new {Watcher} with an appropriate subpath configuration. Otherwise, the + // `createWatcher` callback will be invoked to create a new {NativeWatcher}, which will be registered in the tree + // and attached to the watcher. + // + // If any pre-existing child watchers are removed as a result of this operation, {NativeWatcher.onWillReattach} will + // be broadcast on each with the new parent watcher as an event payload to give child watchers a chance to attach to + // the new watcher. + // + // * `watcher` an unattached {Watcher}. + async attach (watcher) { + const normalizedDirectory = await watcher.getNormalizedPathPromise() + const pathSegments = normalizedDirectory.split(path.sep).filter(segment => segment.length > 0) + + this.tree.add(pathSegments, (native, nativePath) => { + watcher.attachToNative(native, nativePath) + }) + } +} + +module.exports = {NativeWatcherRegistry} diff --git a/src/pane-axis.coffee b/src/pane-axis.coffee index 31c5e1664..c8fcc4108 100644 --- a/src/pane-axis.coffee +++ b/src/pane-axis.coffee @@ -69,7 +69,7 @@ class PaneAxis extends Model @emitter.on 'did-replace-child', fn onDidDestroy: (fn) -> - @emitter.on 'did-destroy', fn + @emitter.once 'did-destroy', fn onDidChangeFlexScale: (fn) -> @emitter.on 'did-change-flex-scale', fn diff --git a/src/pane.coffee b/src/pane.coffee index 9c0440e0a..dc9173992 100644 --- a/src/pane.coffee +++ b/src/pane.coffee @@ -170,7 +170,7 @@ class Pane # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback # Public: Invoke the given callback when the value of the {::isActive} # property changes. @@ -621,12 +621,15 @@ class Pane destroyItem: (item, force) -> index = @items.indexOf(item) if index isnt -1 - return false if not force and @getContainer()?.getLocation() isnt 'center' and item.isPermanentDockItem?() + if not force and @getContainer()?.getLocation() isnt 'center' and item.isPermanentDockItem?() + return Promise.resolve(false) + @emitter.emit 'will-destroy-item', {item, index} @container?.willDestroyPaneItem({item, index, pane: this}) if force or not item?.shouldPromptToSave?() @removeItem(item, false) item.destroy?() + Promise.resolve(true) else @promptToSaveItem(item).then (result) => if result @@ -637,7 +640,7 @@ class Pane # Public: Destroy all items. destroyItems: -> Promise.all( - @getItems().map(@destroyItem.bind(this)) + @getItems().map((item) => @destroyItem(item)) ) # Public: Destroy all items except for the active item. @@ -645,7 +648,7 @@ class Pane Promise.all( @getItems() .filter((item) => item isnt @activeItem) - .map(@destroyItem.bind(this)) + .map((item) => @destroyItem(item)) ) promptToSaveItem: (item, options={}) -> @@ -662,7 +665,7 @@ class Pane chosen = @applicationDelegate.confirm message: message detailedMessage: "Your changes will be lost if you close this item without saving." - buttons: [saveButtonText, "Cancel", "Don't Save"] + buttons: [saveButtonText, "Cancel", "&Don't Save"] switch chosen when 0 new Promise (resolve) -> @@ -950,7 +953,7 @@ class Pane # Returns a {Promise} that resolves once the pane is either closed, or the # closing has been cancelled. close: -> - Promise.all(@getItems().map(@promptToSaveItem.bind(this))).then (results) => + Promise.all(@getItems().map((item) => @promptToSaveItem(item))).then (results) => @destroy() unless results.includes(false) handleSaveError: (error, item) -> @@ -989,4 +992,4 @@ promisify = (callback) -> try Promise.resolve(callback()) catch error - Promise.reject(error) \ No newline at end of file + Promise.reject(error) diff --git a/src/panel-container.js b/src/panel-container.js index 65dd89f7a..6d5fb7398 100644 --- a/src/panel-container.js +++ b/src/panel-container.js @@ -40,7 +40,7 @@ module.exports = class PanelContainer { } onDidDestroy (callback) { - return this.emitter.on('did-destroy', callback) + return this.emitter.once('did-destroy', callback) } /* diff --git a/src/panel.js b/src/panel.js index c9beba72a..49b037be0 100644 --- a/src/panel.js +++ b/src/panel.js @@ -66,7 +66,7 @@ class Panel { // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy (callback) { - return this.emitter.on('did-destroy', callback) + return this.emitter.once('did-destroy', callback) } /* diff --git a/src/path-watcher.js b/src/path-watcher.js new file mode 100644 index 000000000..001b17818 --- /dev/null +++ b/src/path-watcher.js @@ -0,0 +1,641 @@ +/** @babel */ + +const fs = require('fs') +const path = require('path') + +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') +const nsfw = require('nsfw') +const {NativeWatcherRegistry} = require('./native-watcher-registry') + +// Private: Associate native watcher action flags with descriptive String equivalents. +const ACTION_MAP = new Map([ + [nsfw.actions.MODIFIED, 'modified'], + [nsfw.actions.CREATED, 'created'], + [nsfw.actions.DELETED, 'deleted'], + [nsfw.actions.RENAMED, 'renamed'] +]) + +// Private: Possible states of a {NativeWatcher}. +const WATCHER_STATE = { + STOPPED: Symbol('stopped'), + STARTING: Symbol('starting'), + RUNNING: Symbol('running'), + STOPPING: Symbol('stopping') +} + +// Private: Emulate a "filesystem watcher" by subscribing to Atom events like buffers being saved. This will miss +// any changes made to files outside of Atom, but it also has no overhead. +class AtomBackend { + async start (rootPath, eventCallback, errorCallback) { + const getRealPath = givenPath => { + return new Promise(resolve => { + fs.realpath(givenPath, (err, resolvedPath) => { + err ? resolve(null) : resolve(resolvedPath) + }) + }) + } + + this.subs = new CompositeDisposable() + + this.subs.add(atom.workspace.observeTextEditors(async editor => { + let realPath = await getRealPath(editor.getPath()) + if (!realPath || !realPath.startsWith(rootPath)) { + return + } + + const announce = (action, oldPath) => { + const payload = {action, path: realPath} + if (oldPath) payload.oldPath = oldPath + eventCallback([payload]) + } + + const buffer = editor.getBuffer() + + this.subs.add(buffer.onDidConflict(() => announce('modified'))) + this.subs.add(buffer.onDidReload(() => announce('modified'))) + this.subs.add(buffer.onDidSave(event => { + if (event.path === realPath) { + announce('modified') + } else { + const oldPath = realPath + realPath = event.path + announce('renamed', oldPath) + } + })) + + this.subs.add(buffer.onDidDelete(() => announce('deleted'))) + + this.subs.add(buffer.onDidChangePath(newPath => { + if (newPath !== realPath) { + const oldPath = realPath + realPath = newPath + announce('renamed', oldPath) + } + })) + })) + + // Giant-ass brittle hack to hook files (and eventually directories) created from the TreeView. + const treeViewPackage = await atom.packages.getLoadedPackage('tree-view') + if (!treeViewPackage) return + await treeViewPackage.activationPromise + const treeViewModule = treeViewPackage.mainModule + if (!treeViewModule) return + const treeView = treeViewModule.getTreeViewInstance() + + const isOpenInEditor = async eventPath => { + const openPaths = await Promise.all( + atom.workspace.getTextEditors().map(editor => getRealPath(editor.getPath())) + ) + return openPaths.includes(eventPath) + } + + this.subs.add(treeView.onFileCreated(async event => { + const realPath = await getRealPath(event.path) + if (!realPath) return + + eventCallback([{action: 'added', path: realPath}]) + })) + + this.subs.add(treeView.onEntryDeleted(async event => { + const realPath = await getRealPath(event.path) + if (!realPath || isOpenInEditor(realPath)) return + + eventCallback([{action: 'deleted', path: realPath}]) + })) + + this.subs.add(treeView.onEntryMoved(async event => { + const [realNewPath, realOldPath] = await Promise.all([ + getRealPath(event.newPath), + getRealPath(event.initialPath) + ]) + if (!realNewPath || !realOldPath || isOpenInEditor(realNewPath) || isOpenInEditor(realOldPath)) return + + eventCallback([{action: 'renamed', path: realNewPath, oldPath: realOldPath}]) + })) + } + + async stop () { + this.subs && this.subs.dispose() + } +} + +// Private: Implement a native watcher by translating events from an NSFW watcher. +class NSFWBackend { + async start (rootPath, eventCallback, errorCallback) { + const handler = events => { + eventCallback(events.map(event => { + const action = ACTION_MAP.get(event.action) || `unexpected (${event.action})` + const payload = {action} + + if (event.file) { + payload.path = path.join(event.directory, event.file) + } else { + payload.oldPath = path.join(event.directory, event.oldFile) + payload.path = path.join(event.directory, event.newFile) + } + + return payload + })) + } + + this.watcher = await nsfw( + rootPath, + handler, + {debounceMS: 100, errorCallback} + ) + + await this.watcher.start() + } + + stop () { + return this.watcher.stop() + } +} + +// Private: Map configuration settings from the feature flag to backend implementations. +const BACKENDS = { + atom: AtomBackend, + native: NSFWBackend +} + +// Private: the backend implementation to fall back to if the config setting is invalid. +const DEFAULT_BACKEND = BACKENDS.nsfw + +// Private: Interface with and normalize events from a native OS filesystem watcher. +class NativeWatcher { + + // Private: Initialize a native watcher on a path. + // + // Events will not be produced until {start()} is called. + constructor (normalizedPath) { + this.normalizedPath = normalizedPath + this.emitter = new Emitter() + this.subs = new CompositeDisposable() + + this.backend = null + this.state = WATCHER_STATE.STOPPED + + this.onEvents = this.onEvents.bind(this) + this.onError = this.onError.bind(this) + + this.subs.add(atom.config.onDidChange('core.fileSystemWatcher', async () => { + if (this.state === WATCHER_STATE.STARTING) { + // Wait for this watcher to finish starting. + await new Promise(resolve => { + const sub = this.onDidStart(() => { + sub.dispose() + resolve() + }) + }) + } + + // Re-read the config setting in case it's changed again while we were waiting for the watcher + // to start. + const Backend = this.getCurrentBackend() + if (this.state === WATCHER_STATE.RUNNING && !(this.backend instanceof Backend)) { + await this.stop() + await this.start() + } + })) + } + + // Private: Read the `core.fileSystemWatcher` setting to determine the filesystem backend to use. + getCurrentBackend () { + const setting = atom.config.get('core.fileSystemWatcher') + return BACKENDS[setting] || DEFAULT_BACKEND + } + + // Private: Begin watching for filesystem events. + // + // Has no effect if the watcher has already been started. + async start () { + if (this.state !== WATCHER_STATE.STOPPED) { + return + } + this.state = WATCHER_STATE.STARTING + + const Backend = this.getCurrentBackend() + + this.backend = new Backend() + await this.backend.start(this.normalizedPath, this.onEvents, this.onError) + + this.state = WATCHER_STATE.RUNNING + this.emitter.emit('did-start') + } + + // Private: Return true if the underlying watcher is actively listening for filesystem events. + isRunning () { + return this.state === WATCHER_STATE.RUNNING + } + + // Private: Register a callback to be invoked when the filesystem watcher has been initialized. + // + // Returns: A {Disposable} to revoke the subscription. + onDidStart (callback) { + return this.emitter.on('did-start', callback) + } + + // Private: Register a callback to be invoked with normalized filesystem events as they arrive. Starts the watcher + // automatically if it is not already running. The watcher will be stopped automatically when all subscribers + // dispose their subscriptions. + // + // Returns: A {Disposable} to revoke the subscription. + onDidChange (callback) { + this.start() + + const sub = this.emitter.on('did-change', callback) + return new Disposable(() => { + sub.dispose() + if (this.emitter.listenerCountForEventName('did-change') === 0) { + this.stop() + } + }) + } + + // Private: Register a callback to be invoked when a {Watcher} should attach to a different {NativeWatcher}. + // + // Returns: A {Disposable} to revoke the subscription. + onShouldDetach (callback) { + return this.emitter.on('should-detach', callback) + } + + // Private: Register a callback to be invoked when a {NativeWatcher} is about to be stopped. + // + // Returns: A {Disposable} to revoke the subscription. + onWillStop (callback) { + return this.emitter.on('will-stop', callback) + } + + // Private: Register a callback to be invoked when the filesystem watcher has been stopped. + // + // Returns: A {Disposable} to revoke the subscription. + onDidStop (callback) { + return this.emitter.on('did-stop', callback) + } + + // Private: Register a callback to be invoked with any errors reported from the watcher. + // + // Returns: A {Disposable} to revoke the subscription. + onDidError (callback) { + return this.emitter.on('did-error', callback) + } + + // Private: Broadcast an `onShouldDetach` event to prompt any {Watcher} instances bound here to attach to a new + // {NativeWatcher} instead. + // + // * `replacement` the new {NativeWatcher} instance that a live {Watcher} instance should reattach to instead. + // * `watchedPath` absolute path watched by the new {NativeWatcher}. + reattachTo (replacement, watchedPath) { + this.emitter.emit('should-detach', {replacement, watchedPath}) + } + + // Private: Stop the native watcher and release any operating system resources associated with it. + // + // Has no effect if the watcher is not running. + async stop () { + if (this.state !== WATCHER_STATE.RUNNING) { + return + } + this.state = WATCHER_STATE.STOPPING + this.emitter.emit('will-stop') + + await this.backend.stop() + this.state = WATCHER_STATE.STOPPED + + this.emitter.emit('did-stop') + } + + // Private: Detach any event subscribers. + dispose () { + this.emitter.dispose() + } + + // Private: Callback function invoked by the native watcher when a debounced group of filesystem events arrive. + // Normalize and re-broadcast them to any subscribers. + // + // * `events` An Array of filesystem events. + onEvents (events) { + this.emitter.emit('did-change', events) + } + + // Private: Callback function invoked by the native watcher when an error occurs. + // + // * `err` The native filesystem error. + onError (err) { + this.emitter.emit('did-error', err) + } +} + +// Extended: Manage a subscription to filesystem events that occur beneath a root directory. Construct these by +// calling `watchPath`. To watch for events within active project directories, use {Project::onDidChangeFiles} +// instead. +// +// Multiple PathWatchers may be backed by a single native watcher to conserve operation system resources. +// +// Call {::dispose} to stop receiving events and, if possible, release underlying resources. A PathWatcher may be +// added to a {CompositeDisposable} to manage its lifetime along with other {Disposable} resources like event +// subscriptions. +// +// ```js +// const {watchPath} = require('atom') +// +// const disposable = watchPath('/var/log', {}, events => { +// console.log(`Received batch of ${events.length} events.`) +// for (const event of events) { +// // "created", "modified", "deleted", "renamed" +// console.log(`Event action: ${event.action}`) +// +// // absolute path to the filesystem entry that was touched +// console.log(`Event path: ${event.path}`) +// +// if (event.action === 'renamed') { +// console.log(`.. renamed from: ${event.oldPath}`) +// } +// } +// }) +// +// // Immediately stop receiving filesystem events. If this is the last +// // watcher, asynchronously release any OS resources required to +// // subscribe to these events. +// disposable.dispose() +// ``` +// +// `watchPath` accepts the following arguments: +// +// `rootPath` {String} specifies the absolute path to the root of the filesystem content to watch. +// +// `options` Control the watcher's behavior. Currently a placeholder. +// +// `eventCallback` {Function} to be called each time a batch of filesystem events is observed. Each event object has +// the keys: `action`, a {String} describing the filesystem action that occurred, one of `"created"`, `"modified"`, +// `"deleted"`, or `"renamed"`; `path`, a {String} containing the absolute path to the filesystem entry that was acted +// upon; for rename events only, `oldPath`, a {String} containing the filesystem entry's former absolute path. +class PathWatcher { + + // Private: Instantiate a new PathWatcher. Call {watchPath} instead. + // + // * `nativeWatcherRegistry` {NativeWatcherRegistry} used to find and consolidate redundant watchers. + // * `watchedPath` {String} containing the absolute path to the root of the watched filesystem tree. + // * `options` See {watchPath} for options. + // + constructor (nativeWatcherRegistry, watchedPath, options) { + this.watchedPath = watchedPath + this.nativeWatcherRegistry = nativeWatcherRegistry + + this.normalizedPath = null + this.native = null + this.changeCallbacks = new Map() + + this.normalizedPathPromise = new Promise((resolve, reject) => { + fs.realpath(watchedPath, (err, real) => { + if (err) { + reject(err) + return + } + + this.normalizedPath = real + resolve(real) + }) + }) + + this.attachedPromise = new Promise(resolve => { + this.resolveAttachedPromise = resolve + }) + this.startPromise = new Promise(resolve => { + this.resolveStartPromise = resolve + }) + + this.emitter = new Emitter() + this.subs = new CompositeDisposable() + } + + // Private: Return a {Promise} that will resolve with the normalized root path. + getNormalizedPathPromise () { + return this.normalizedPathPromise + } + + // Private: Return a {Promise} that will resolve the first time that this watcher is attached to a native watcher. + getAttachedPromise () { + return this.attachedPromise + } + + // Extended: Return a {Promise} that will resolve when the underlying native watcher is ready to begin sending events. + // When testing filesystem watchers, it's important to await this promise before making filesystem changes that you + // intend to assert about because there will be a delay between the instantiation of the watcher and the activation + // of the underlying OS resources that feed it events. + // + // ```js + // const {watchPath} = require('atom') + // const ROOT = path.join(__dirname, 'fixtures') + // const FILE = path.join(ROOT, 'filename.txt') + // + // describe('something', function () { + // it("doesn't miss events", async function () { + // const watcher = watchPath(ROOT, {}, events => {}) + // await watcher.getStartPromise() + // fs.writeFile(FILE, 'contents\n', err => { + // // The watcher is listening and the event should be + // // received asyncronously + // } + // }) + // }) + // ``` + getStartPromise () { + return this.startPromise + } + + // Private: Attach another {Function} to be called with each batch of filesystem events. See {watchPath} for the + // spec of the callback's argument. + // + // * `callback` {Function} to be called with each batch of filesystem events. + // + // Returns a {Disposable} that will stop the underlying watcher when all callbacks mapped to it have been disposed. + onDidChange (callback) { + if (this.native) { + const sub = this.native.onDidChange(events => this.onNativeEvents(events, callback)) + this.changeCallbacks.set(callback, sub) + + this.native.start() + } else { + // Attach to a new native listener and retry + this.nativeWatcherRegistry.attach(this).then(() => { + this.onDidChange(callback) + }) + } + + return new Disposable(() => { + const sub = this.changeCallbacks.get(callback) + this.changeCallbacks.delete(callback) + sub.dispose() + }) + } + + // Extended: Invoke a {Function} when any errors related to this watcher are reported. + // + // * `callback` {Function} to be called when an error occurs. + // * `err` An {Error} describing the failure condition. + // + // Returns a {Disposable}. + onDidError (callback) { + return this.emitter.on('did-error', callback) + } + + // Private: Wire this watcher to an operating system-level native watcher implementation. + attachToNative (native) { + this.subs.dispose() + this.native = native + + if (native.isRunning()) { + this.resolveStartPromise() + } else { + this.subs.add(native.onDidStart(() => { + this.resolveStartPromise() + })) + } + + // Transfer any native event subscriptions to the new NativeWatcher. + for (const [callback, formerSub] of this.changeCallbacks) { + const newSub = native.onDidChange(events => this.onNativeEvents(events, callback)) + this.changeCallbacks.set(callback, newSub) + formerSub.dispose() + } + + this.subs.add(native.onDidError(err => { + this.emitter.emit('did-error', err) + })) + + this.subs.add(native.onShouldDetach(({replacement, watchedPath}) => { + if (replacement !== native && this.normalizedPath.startsWith(watchedPath)) { + this.attachToNative(replacement) + } + })) + + this.subs.add(native.onWillStop(() => { + this.subs.dispose() + this.native = null + })) + + this.resolveAttachedPromise() + } + + // Private: Invoked when the attached native watcher creates a batch of native filesystem events. The native watcher's + // events may include events for paths above this watcher's root path, so filter them to only include the relevant + // ones, then re-broadcast them to our subscribers. + onNativeEvents (events, callback) { + const filtered = events.filter(event => event.path.startsWith(this.normalizedPath)) + + if (filtered.length > 0) { + callback(filtered) + } + } + + // Extended: Unsubscribe all subscribers from filesystem events. Native resources will be release asynchronously, + // but this watcher will stop broadcasting events immediately. + dispose () { + for (const sub of this.changeCallbacks.values()) { + sub.dispose() + } + + this.emitter.dispose() + this.subs.dispose() + } +} + +// Private: Globally tracked state used to de-duplicate related [PathWatchers]{PathWatcher}. +class PathWatcherManager { + + // Private: Access or lazily initialize the singleton manager instance. + // + // Returns the one and only {PathWatcherManager}. + static instance () { + if (!PathWatcherManager.theManager) { + PathWatcherManager.theManager = new PathWatcherManager() + } + return PathWatcherManager.theManager + } + + // Private: Initialize global {PathWatcher} state. + constructor () { + this.live = new Set() + this.nativeRegistry = new NativeWatcherRegistry( + normalizedPath => { + const nativeWatcher = new NativeWatcher(normalizedPath) + + this.live.add(nativeWatcher) + const sub = nativeWatcher.onWillStop(() => { + this.live.delete(nativeWatcher) + sub.dispose() + }) + + return nativeWatcher + } + ) + } + + // Private: Create a {PathWatcher} tied to this global state. See {watchPath} for detailed arguments. + createWatcher (rootPath, options, eventCallback) { + const watcher = new PathWatcher(this.nativeRegistry, rootPath, options) + watcher.onDidChange(eventCallback) + return watcher + } + + // Private: Stop all living watchers. + // + // Returns a {Promise} that resolves when all native watcher resources are disposed. + stopAllWatchers () { + return Promise.all( + Array.from(this.live, watcher => watcher.stop()) + ) + } +} + +// Extended: Invoke a callback with each filesystem event that occurs beneath a specified path. If you only need to +// watch events within the project's root paths, use {Project::onDidChangeFiles} instead. +// +// watchPath handles the efficient re-use of operating system resources across living watchers. Watching the same path +// more than once, or the child of a watched path, will re-use the existing native watcher. +// +// * `rootPath` {String} specifies the absolute path to the root of the filesystem content to watch. +// * `options` Control the watcher's behavior. +// * `eventCallback` {Function} or other callable to be called each time a batch of filesystem events is observed. +// * `events` {Array} of objects that describe the events that have occurred. +// * `action` {String} describing the filesystem action that occurred. One of `"created"`, `"modified"`, +// `"deleted"`, or `"renamed"`. +// * `path` {String} containing the absolute path to the filesystem entry that was acted upon. +// * `oldPath` For rename events, {String} containing the filesystem entry's former absolute path. +// +// Returns a {PathWatcher}. Note that every {PathWatcher} is a {Disposable}, so they can be managed by +// [CompositeDisposables]{CompositeDisposable} if desired. +// +// ```js +// const {watchPath} = require('atom') +// +// const disposable = watchPath('/var/log', {}, events => { +// console.log(`Received batch of ${events.length} events.`) +// for (const event of events) { +// // "created", "modified", "deleted", "renamed" +// console.log(`Event action: ${event.action}`) +// // absolute path to the filesystem entry that was touched +// console.log(`Event path: ${event.path}`) +// if (event.action === 'renamed') { +// console.log(`.. renamed from: ${event.oldPath}`) +// } +// } +// }) +// +// // Immediately stop receiving filesystem events. If this is the last watcher, asynchronously release any OS +// // resources required to subscribe to these events. +// disposable.dispose() +// ``` +// +function watchPath (rootPath, options, eventCallback) { + return PathWatcherManager.instance().createWatcher(rootPath, options, eventCallback) +} + +// Private: Return a Promise that resolves when all {NativeWatcher} instances associated with a FileSystemManager +// have stopped listening. This is useful for `afterEach()` blocks in unit tests. +function stopAllWatchers () { + return PathWatcherManager.instance().stopAllWatchers() +} + +module.exports = {watchPath, stopAllWatchers} diff --git a/src/project.coffee b/src/project.coffee index bf497e1db..4564e37bb 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -4,6 +4,7 @@ _ = require 'underscore-plus' fs = require 'fs-plus' {Emitter, Disposable} = require 'event-kit' TextBuffer = require 'text-buffer' +{watchPath} = require('./path-watcher') DefaultDirectoryProvider = require './default-directory-provider' Model = require './model' @@ -28,11 +29,13 @@ class Project extends Model @repositoryPromisesByPath = new Map() @repositoryProviders = [new GitRepositoryProvider(this, config)] @loadPromisesByPath = {} + @watchersByPath = {} @consumeServices(packageManager) destroyed: -> buffer.destroy() for buffer in @buffers.slice() repository?.destroy() for repository in @repositories.slice() + watcher.dispose() for _, watcher in @watchersByPath @rootDirectories = [] @repositories = [] @@ -114,6 +117,43 @@ class Project extends Model callback(buffer) for buffer in @getBuffers() @onDidAddBuffer callback + # Extended: Invoke a callback when a filesystem change occurs within any open + # project path. + # + # ```js + # const disposable = atom.project.onDidChangeFiles(events => { + # for (const event of events) { + # // "created", "modified", "deleted", or "renamed" + # console.log(`Event action: ${event.type}`) + # + # // absolute path to the filesystem entry that was touched + # console.log(`Event path: ${event.path}`) + # + # if (event.type === 'renamed') { + # console.log(`.. renamed from: ${event.oldPath}`) + # } + # } + # } + # + # disposable.dispose() + # ``` + # + # To watch paths outside of open projects, use the `watchPaths` function instead; see {PathWatcher}. + # + # * `callback` {Function} to be called with batches of filesystem events reported by + # the operating system. + # * `events` An {Array} of objects that describe a batch of filesystem events. + # * `type` {String} describing the filesystem action that occurred. One of `"created"`, + # `"modified"`, `"deleted"`, or `"renamed"`. + # * `path` {String} containing the absolute path to the filesystem entry + # that was acted upon. + # * `oldPath` For rename events, {String} containing the filesystem entry's + # former absolute path. + # + # Returns a {Disposable} to manage this event subscription. + onDidChangeFiles: (callback) -> + @emitter.on 'did-change-files', callback + ### Section: Accessing the git repository ### @@ -172,6 +212,9 @@ class Project extends Model @rootDirectories = [] @repositories = [] + watcher.dispose() for _, watcher in @watchersByPath + @watchersByPath = {} + @addPath(projectPath, emitEvent: false) for projectPath in projectPaths @emitter.emit 'did-change-paths', projectPaths @@ -186,6 +229,11 @@ class Project extends Model return if existingDirectory.getPath() is directory.getPath() @rootDirectories.push(directory) + @watchersByPath[directory.getPath()] = watchPath directory.getPath(), {}, (events) => + @emitter.emit 'did-change-files', events + + for root, watcher in @watchersByPath + watcher.dispose() unless @rootDirectoryies.includes root repo = null for provider in @repositoryProviders @@ -220,6 +268,7 @@ class Project extends Model [removedDirectory] = @rootDirectories.splice(indexToRemove, 1) [removedRepository] = @repositories.splice(indexToRemove, 1) removedRepository?.destroy() unless removedRepository in @repositories + @watchersByPath[projectPath]?.dispose() @emitter.emit "did-change-paths", @getPaths() true else diff --git a/src/selection.coffee b/src/selection.coffee index 935a15b13..e361d0b5c 100644 --- a/src/selection.coffee +++ b/src/selection.coffee @@ -54,7 +54,7 @@ class Selection extends Model # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback ### Section: Managing the selection range diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 4e52c5d08..2370774e3 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -70,6 +70,7 @@ class TextEditorComponent { this.updateSync = this.updateSync.bind(this) this.didBlurHiddenInput = this.didBlurHiddenInput.bind(this) this.didFocusHiddenInput = this.didFocusHiddenInput.bind(this) + this.didPaste = this.didPaste.bind(this) this.didTextInput = this.didTextInput.bind(this) this.didKeydown = this.didKeydown.bind(this) this.didKeyup = this.didKeyup.bind(this) @@ -109,11 +110,14 @@ class TextEditorComponent { this.cursorsBlinking = false this.cursorsBlinkedOff = false this.nextUpdateOnlyBlinksCursors = null - this.extraLinesToMeasure = null - this.extraRenderedScreenLines = null + this.linesToMeasure = new Map() + this.extraRenderedScreenLines = new Map() this.horizontalPositionsToMeasure = new Map() // Keys are rows with positions we want to measure, values are arrays of columns to measure this.horizontalPixelPositionsByScreenLineId = new Map() // Values are maps from column to horiontal pixel positions this.blockDecorationsToMeasure = new Set() + this.blockDecorationsByElement = new WeakMap() + this.heightsByBlockDecoration = new WeakMap() + this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this)) this.lineNodesByScreenLineId = new Map() this.textNodesByScreenLineId = new Map() this.overlayComponents = new Set() @@ -172,6 +176,10 @@ class TextEditorComponent { } update (props) { + if (props.model !== this.props.model) { + this.props.model.component = null + props.model.component = this + } this.props = props this.scheduleUpdate() } @@ -245,20 +253,17 @@ class TextEditorComponent { this.measureBlockDecorations() - this.measuredContent = false this.updateSyncBeforeMeasuringContent() if (useScheduler === true) { const scheduler = etch.getScheduler() scheduler.readDocument(() => { this.measureContentDuringUpdateSync() - this.measuredContent = true scheduler.updateDocument(() => { this.updateSyncAfterMeasuringContent() }) }) } else { this.measureContentDuringUpdateSync() - this.measuredContent = true this.updateSyncAfterMeasuringContent() } } @@ -312,11 +317,17 @@ class TextEditorComponent { } }) + if (this.resizeBlockDecorationMeasurementsArea) { + this.resizeBlockDecorationMeasurementsArea = false + this.refs.blockDecorationMeasurementArea.style.width = this.getScrollWidth() + 'px' + } + this.blockDecorationsToMeasure.forEach((decoration) => { const {item} = decoration.getProperties() const decorationElement = TextEditor.viewForItem(item) const {previousSibling, nextSibling} = decorationElement const height = nextSibling.getBoundingClientRect().top - previousSibling.getBoundingClientRect().bottom + this.heightsByBlockDecoration.set(decoration, height) this.lineTopIndex.resizeBlock(decoration, height) }) @@ -329,6 +340,7 @@ class TextEditorComponent { } updateSyncBeforeMeasuringContent () { + this.measuredContent = false this.derivedDimensionsCache = {} this.updateModelSoftWrapColumn() if (this.pendingAutoscroll) { @@ -343,6 +355,7 @@ class TextEditorComponent { this.queryLineNumbersToRender() this.queryGuttersToRender() this.queryDecorationsToRender() + this.queryExtraScreenLinesToRender() this.shouldRenderDummyScrollbars = !this.remeasureScrollbars etch.updateSync(this) this.updateClassList() @@ -357,8 +370,6 @@ class TextEditorComponent { } const wasHorizontalScrollbarVisible = this.isHorizontalScrollbarVisible() - this.extraRenderedScreenLines = this.extraLinesToMeasure - this.extraLinesToMeasure = null this.measureLongestLineWidth() this.measureHorizontalPositions() this.updateAbsolutePositionedDecorations() @@ -372,6 +383,8 @@ class TextEditorComponent { } this.pendingAutoscroll = null } + + this.measuredContent = true } updateSyncAfterMeasuringContent () { @@ -539,6 +552,7 @@ class TextEditorComponent { ] } else { children = [ + this.renderCursorsAndInput(), this.renderBlockDecorationMeasurementArea(), this.renderCharacterMeasurementLine() ] @@ -592,21 +606,19 @@ class TextEditorComponent { }) } - if (this.extraLinesToMeasure) { - this.extraLinesToMeasure.forEach((screenLine, screenRow) => { - if (screenRow < startRow || screenRow >= endRow) { - tileNodes.push($(LineComponent, { - key: 'extra-' + screenLine.id, - screenLine, - screenRow, - displayLayer, - nodePool: this.lineNodesPool, - lineNodesByScreenLineId, - textNodesByScreenLineId - })) - } - }) - } + this.extraRenderedScreenLines.forEach((screenLine, screenRow) => { + if (screenRow < startRow || screenRow >= endRow) { + tileNodes.push($(LineComponent, { + key: 'extra-' + screenLine.id, + screenLine, + screenRow, + displayLayer, + nodePool: this.lineNodesPool, + lineNodesByScreenLineId, + textNodesByScreenLineId + })) + } + }) return $.div({ key: 'lineTiles', @@ -629,6 +641,7 @@ class TextEditorComponent { didBlurHiddenInput: this.didBlurHiddenInput, didFocusHiddenInput: this.didFocusHiddenInput, didTextInput: this.didTextInput, + didPaste: this.didPaste, didKeydown: this.didKeydown, didKeyup: this.didKeyup, didKeypress: this.didKeypress, @@ -815,12 +828,22 @@ class TextEditorComponent { const longestLineRow = model.getApproximateLongestScreenRow() const longestLine = model.screenLineForScreenRow(longestLineRow) if (longestLine !== this.previousLongestLine) { - this.requestExtraLineToMeasure(longestLineRow, longestLine) + this.requestLineToMeasure(longestLineRow, longestLine) this.longestLineToMeasure = longestLine this.previousLongestLine = longestLine } } + queryExtraScreenLinesToRender () { + this.extraRenderedScreenLines.clear() + this.linesToMeasure.forEach((screenLine, row) => { + if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { + this.extraRenderedScreenLines.set(row, screenLine) + } + }) + this.linesToMeasure.clear() + } + queryLineNumbersToRender () { const {model} = this.props if (!model.isLineNumberGutterVisible()) return @@ -836,6 +859,7 @@ class TextEditorComponent { const renderedRowCount = this.getRenderedRowCount() const bufferRows = model.bufferRowsForScreenRows(startRow, endRow) + const screenRows = new Array(renderedRowCount) const keys = new Array(renderedRowCount) const foldableFlags = new Array(renderedRowCount) const softWrappedFlags = new Array(renderedRowCount) @@ -862,6 +886,7 @@ class TextEditorComponent { foldableFlags[i] = false } + screenRows[i] = row previousBufferRow = bufferRow } @@ -869,6 +894,7 @@ class TextEditorComponent { bufferRows.pop() this.lineNumbersToRender.bufferRows = bufferRows + this.lineNumbersToRender.screenRows = screenRows this.lineNumbersToRender.keys = keys this.lineNumbersToRender.foldableFlags = foldableFlags this.lineNumbersToRender.softWrappedFlags = softWrappedFlags @@ -888,7 +914,7 @@ class TextEditorComponent { renderedScreenLineForRow (row) { return ( this.renderedScreenLines[row - this.getRenderedStartRow()] || - (this.extraRenderedScreenLines ? this.extraRenderedScreenLines.get(row) : null) + this.extraRenderedScreenLines.get(row) ) } @@ -1105,7 +1131,7 @@ class TextEditorComponent { const height = this.pixelPositionBeforeBlocksForRow(screenRange.end.row + 1) - top decorations.push({ - className: decoration.class, + className: 'decoration' + (decoration.class ? ' ' + decoration.class : ''), element: TextEditor.viewForItem(decoration.item), top, height @@ -1391,6 +1417,7 @@ class TextEditorComponent { if (!this.hasInitialMeasurements) this.measureDimensions() this.visible = true this.props.model.setVisible(true) + this.resizeBlockDecorationMeasurementsArea = true this.updateSync() this.flushPendingLogicalScrollPosition() } @@ -1423,16 +1450,14 @@ class TextEditorComponent { this.scheduleUpdate() } - const {hiddenInput} = this.refs.cursorsAndInput.refs - hiddenInput.focus() + this.getHiddenInput().focus() } // Called by TextEditorElement so that this function is always the first // listener to be fired, even if other listeners are bound before creating // the component. didBlur (event) { - const {cursorsAndInput} = this.refs - if (cursorsAndInput && event.relatedTarget === cursorsAndInput.refs.hiddenInput) { + if (event.relatedTarget === this.getHiddenInput()) { event.stopImmediatePropagation() } } @@ -1533,7 +1558,21 @@ class TextEditorComponent { } } + didPaste (event) { + // On Linux, Chromium translates a middle-button mouse click into a + // mousedown event *and* a paste event. Since Atom supports the middle mouse + // click as a way of closing a tab, we only want the mousedown event, not + // the paste event. And since we don't use the `paste` event for any + // behavior in Atom, we can no-op the event to eliminate this issue. + // See https://github.com/atom/atom/pull/15183#issue-248432413. + if (this.getPlatform() === 'linux') event.preventDefault() + } + didTextInput (event) { + // Workaround for Chromium not preventing composition events when + // preventDefault is called on the keydown event that precipitated them. + if (this.lastKeydown && this.lastKeydown.defaultPrevented) return + if (!this.isInputEnabled()) return event.stopPropagation() @@ -1590,7 +1629,6 @@ class TextEditorComponent { didKeypress (event) { this.lastKeydownBeforeKeypress = this.lastKeydown - this.lastKeydown = null // This cancels the accented character behavior if we type a key normally // with the menu open. @@ -1600,7 +1638,6 @@ class TextEditorComponent { didKeyup (event) { if (this.lastKeydownBeforeKeypress && this.lastKeydownBeforeKeypress.code === event.code) { this.lastKeydownBeforeKeypress = null - this.lastKeydown = null } } @@ -1615,6 +1652,10 @@ class TextEditorComponent { // 4. compositionend fired // 5. textInput fired; event.data == the completion string didCompositionStart () { + if (this.getChromeVersion() === 56) { + this.getHiddenInput().value = '' + } + this.compositionCheckpoint = this.props.model.createCheckpoint() if (this.accentedCharacterMenuIsOpen) { this.props.model.selectLeft() @@ -1622,7 +1663,20 @@ class TextEditorComponent { } didCompositionUpdate (event) { - this.props.model.insertText(event.data, {select: true}) + // Workaround for Chromium not preventing composition events when + // preventDefault is called on the keydown event that precipitated them. + if (this.lastKeydown && this.lastKeydown.defaultPrevented) return + + if (this.getChromeVersion() === 56) { + process.nextTick(() => { + if (this.compositionCheckpoint != null) { + const previewText = this.getHiddenInput().value + this.props.model.insertText(previewText, {select: true}) + } + }) + } else { + this.props.model.insertText(event.data, {select: true}) + } } didCompositionEnd (event) { @@ -1632,10 +1686,37 @@ class TextEditorComponent { didMouseDownOnContent (event) { const {model} = this.props const {target, button, detail, ctrlKey, shiftKey, metaKey} = event + const platform = this.getPlatform() + + // Ignore clicks on block decorations. + if (target) { + let element = target + while (element && element !== this.element) { + if (this.blockDecorationsByElement.has(element)) { + return + } + + element = element.parentElement + } + } + + // On Linux, position the cursor on middle mouse button click. A + // textInput event with the contents of the selection clipboard will be + // dispatched by the browser automatically on mouseup. + if (platform === 'linux' && button === 1) { + const selection = clipboard.readText('selection') + const screenPosition = this.screenPositionForMouseEvent(event) + model.setCursorScreenPosition(screenPosition, {autoscroll: false}) + model.insertText(selection) + return + } // Only handle mousedown events for left mouse button (or the middle mouse // button on Linux where it pastes the selection clipboard). - if (!(button === 0 || (this.getPlatform() === 'linux' && button === 1))) return + if (button !== 0) return + + // Ctrl-click brings up the context menu on macOS + if (platform === 'darwin' && ctrlKey) return const screenPosition = this.screenPositionForMouseEvent(event) @@ -1645,15 +1726,7 @@ class TextEditorComponent { return } - // Handle middle mouse button only on Linux (paste clipboard) - if (this.getPlatform() === 'linux' && button === 1) { - const selection = clipboard.readText('selection') - model.setCursorScreenPosition(screenPosition, {autoscroll: false}) - model.insertText(selection) - return - } - - const addOrRemoveSelection = metaKey || (ctrlKey && this.getPlatform() !== 'darwin') + const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin') switch (detail) { case 1: @@ -2010,7 +2083,7 @@ class TextEditorComponent { } measureCharacterDimensions () { - this.measurements.lineHeight = this.refs.characterMeasurementLine.getBoundingClientRect().height + this.measurements.lineHeight = Math.max(1, this.refs.characterMeasurementLine.getBoundingClientRect().height) this.measurements.baseCharacterWidth = this.refs.normalWidthCharacterSpan.getBoundingClientRect().width this.measurements.doubleWidthCharacterWidth = this.refs.doubleWidthCharacterSpan.getBoundingClientRect().width this.measurements.halfWidthCharacterWidth = this.refs.halfWidthCharacterSpan.getBoundingClientRect().width @@ -2089,29 +2162,24 @@ class TextEditorComponent { } } - requestExtraLineToMeasure (row, screenLine) { - if (!this.extraLinesToMeasure) this.extraLinesToMeasure = new Map() - this.extraLinesToMeasure.set(row, screenLine) + requestLineToMeasure (row, screenLine) { + this.linesToMeasure.set(row, screenLine) } requestHorizontalMeasurement (row, column) { if (column === 0) return - if (row < this.getRenderedStartRow() || row >= this.getRenderedEndRow()) { - const screenLine = this.props.model.screenLineForScreenRow(row) - if (screenLine) { - this.requestExtraLineToMeasure(row, screenLine) - } else { - return - } - } + const screenLine = this.props.model.screenLineForScreenRow(row) + if (screenLine) { + this.requestLineToMeasure(row, screenLine) - let columns = this.horizontalPositionsToMeasure.get(row) - if (columns == null) { - columns = [] - this.horizontalPositionsToMeasure.set(row, columns) + let columns = this.horizontalPositionsToMeasure.get(row) + if (columns == null) { + columns = [] + this.horizontalPositionsToMeasure.set(row, columns) + } + columns.push(column) } - columns.push(column) } measureHorizontalPositions () { @@ -2224,7 +2292,7 @@ class TextEditorComponent { let screenLine = this.renderedScreenLineForRow(row) if (!screenLine) { - this.requestExtraLineToMeasure(row, model.screenLineForScreenRow(row)) + this.requestLineToMeasure(row, model.screenLineForScreenRow(row)) this.updateSyncBeforeMeasuringContent() this.measureContentDuringUpdateSync() screenLine = this.renderedScreenLineForRow(row) @@ -2341,11 +2409,14 @@ class TextEditorComponent { didAddBlockDecoration (decoration) { const marker = decoration.getMarker() - const {position} = decoration.getProperties() + const {item, position} = decoration.getProperties() + const element = TextEditor.viewForItem(item) const row = marker.getHeadScreenPosition().row this.lineTopIndex.insertBlock(decoration, row, 0, position === 'after') this.blockDecorationsToMeasure.add(decoration) + this.blockDecorationsByElement.set(element, decoration) + this.blockDecorationResizeObserver.observe(element) const didUpdateDisposable = marker.bufferMarker.onDidChange((e) => { if (!e.textChanged) { @@ -2355,6 +2426,9 @@ class TextEditorComponent { }) const didDestroyDisposable = decoration.onDidDestroy(() => { this.blockDecorationsToMeasure.delete(decoration) + this.heightsByBlockDecoration.delete(decoration) + this.blockDecorationsByElement.delete(element) + this.blockDecorationResizeObserver.unobserve(element) this.lineTopIndex.removeBlock(decoration) didUpdateDisposable.dispose() didDestroyDisposable.dispose() @@ -2362,6 +2436,19 @@ class TextEditorComponent { }) } + didResizeBlockDecorations (entries) { + if (!this.visible) return + + for (let i = 0; i < entries.length; i++) { + const {target, contentRect} = entries[i] + const decoration = this.blockDecorationsByElement.get(target) + const previousHeight = this.heightsByBlockDecoration.get(decoration) + if (this.element.contains(target) && contentRect.height !== previousHeight) { + this.invalidateBlockDecorationDimensions(decoration) + } + } + } + invalidateBlockDecorationDimensions (decoration) { this.blockDecorationsToMeasure.add(decoration) this.scheduleUpdate() @@ -2753,9 +2840,17 @@ class TextEditorComponent { return this.props.inputEnabled != null ? this.props.inputEnabled : true } + getHiddenInput () { + return this.refs.cursorsAndInput.refs.hiddenInput + } + getPlatform () { return this.props.platform || process.platform } + + getChromeVersion () { + return this.props.chromeVersion || parseInt(process.versions.chrome) + } } class DummyScrollbarComponent { @@ -2796,20 +2891,22 @@ class DummyScrollbarComponent { outerStyle.bottom = 0 outerStyle.left = 0 outerStyle.right = right + 'px' - outerStyle.height = '20px' + outerStyle.height = '15px' outerStyle.overflowY = 'hidden' outerStyle.overflowX = this.props.forceScrollbarVisible ? 'scroll' : 'auto' - innerStyle.height = '20px' + outerStyle.cursor = 'default' + innerStyle.height = '15px' innerStyle.width = (this.props.scrollWidth || 0) + 'px' } else { let bottom = (this.props.horizontalScrollbarHeight || 0) outerStyle.right = 0 outerStyle.top = 0 outerStyle.bottom = bottom + 'px' - outerStyle.width = '20px' + outerStyle.width = '15px' outerStyle.overflowX = 'hidden' outerStyle.overflowY = this.props.forceScrollbarVisible ? 'scroll' : 'auto' - innerStyle.width = '20px' + outerStyle.cursor = 'default' + innerStyle.width = '15px' innerStyle.height = (this.props.scrollHeight || 0) + 'px' } @@ -2915,7 +3012,7 @@ class GutterContainerComponent { if (!isLineNumberGutterVisible) return null if (hasInitialMeasurements) { - const {maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags} = lineNumbersToRender + const {maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags} = lineNumbersToRender return $(LineNumberGutterComponent, { ref: 'lineNumberGutter', element: gutter.getElement(), @@ -2926,6 +3023,7 @@ class GutterContainerComponent { maxDigits: maxDigits, keys: keys, bufferRows: bufferRows, + screenRows: screenRows, softWrappedFlags: softWrappedFlags, foldableFlags: foldableFlags, decorations: decorationsToRender.lineNumbers, @@ -2967,7 +3065,7 @@ class LineNumberGutterComponent { render () { const { rootComponent, showLineNumbers, height, width, lineHeight, startRow, endRow, rowsPerTile, - maxDigits, keys, bufferRows, softWrappedFlags, foldableFlags, decorations + maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations } = this.props let children = null @@ -2984,6 +3082,7 @@ class LineNumberGutterComponent { const softWrapped = softWrappedFlags[j] const foldable = foldableFlags[j] const bufferRow = bufferRows[j] + const screenRow = screenRows[j] let className = 'line-number' if (foldable) className = className + ' foldable' @@ -3002,6 +3101,7 @@ class LineNumberGutterComponent { className, width, bufferRow, + screenRow, number, nodePool: this.nodePool } @@ -3115,12 +3215,13 @@ class LineNumberGutterComponent { class LineNumberComponent { constructor (props) { - const {className, width, marginTop, bufferRow, number, nodePool} = props + const {className, width, marginTop, bufferRow, screenRow, number, nodePool} = props this.props = props const style = {width: width + 'px'} if (marginTop != null) style.marginTop = marginTop + 'px' this.element = nodePool.getElement('DIV', className, style) this.element.dataset.bufferRow = bufferRow + this.element.dataset.screenRow = screenRow if (number) this.element.appendChild(nodePool.getTextNode(number)) this.element.appendChild(nodePool.getElement('DIV', 'icon-right', null)) } @@ -3131,8 +3232,10 @@ class LineNumberComponent { } update (props) { - const {nodePool, className, width, marginTop, number} = props + const {nodePool, className, width, marginTop, bufferRow, screenRow, number} = props + if (this.props.bufferRow !== bufferRow) this.element.dataset.bufferRow = bufferRow + if (this.props.screenRow !== screenRow) this.element.dataset.screenRow = screenRow if (this.props.className !== className) this.element.className = className if (this.props.width !== width) this.element.style.width = width + 'px' if (this.props.marginTop !== marginTop) { @@ -3217,7 +3320,10 @@ class CustomGutterDecorationComponent { this.element.style.top = top + 'px' this.element.style.height = height + 'px' if (className != null) this.element.className = className - if (element != null) this.element.appendChild(element) + if (element != null) { + this.element.appendChild(element) + element.style.height = height + 'px' + } } update (newProps) { @@ -3225,11 +3331,17 @@ class CustomGutterDecorationComponent { this.props = newProps if (newProps.top !== oldProps.top) this.element.style.top = newProps.top + 'px' - if (newProps.height !== oldProps.height) this.element.style.height = newProps.height + 'px' + if (newProps.height !== oldProps.height) { + this.element.style.height = newProps.height + 'px' + if (newProps.element) newProps.element.style.height = newProps.height + 'px' + } if (newProps.className !== oldProps.className) this.element.className = newProps.className || '' if (newProps.element !== oldProps.element) { if (this.element.firstChild) this.element.firstChild.remove() - this.element.appendChild(newProps.element) + if (newProps.element != null) { + this.element.appendChild(newProps.element) + newProps.element.style.height = newProps.height + 'px' + } } } } @@ -3301,8 +3413,8 @@ class CursorsAndInputComponent { renderHiddenInput () { const { lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput, - didTextInput, didKeydown, didKeyup, didKeypress, didCompositionStart, - didCompositionUpdate, didCompositionEnd + didPaste, didTextInput, didKeydown, didKeyup, didKeypress, + didCompositionStart, didCompositionUpdate, didCompositionEnd } = this.props let top, left @@ -3321,6 +3433,7 @@ class CursorsAndInputComponent { on: { blur: didBlurHiddenInput, focus: didFocusHiddenInput, + paste: didPaste, textInput: didTextInput, keydown: didKeydown, keyup: didKeyup, @@ -3346,8 +3459,10 @@ class CursorsAndInputComponent { class LinesTileComponent { constructor (props) { + this.highlightComponentsByKey = new Map() this.props = props etch.initialize(this) + this.updateHighlights() this.createLines() this.updateBlockDecorations({}, props) } @@ -3361,13 +3476,22 @@ class LinesTileComponent { this.updateLines(oldProps, newProps) this.updateBlockDecorations(oldProps, newProps) } + this.updateHighlights() } } destroy () { + this.highlightComponentsByKey.forEach((highlightComponent) => { + highlightComponent.destroy() + }) + this.highlightComponentsByKey.clear() + for (let i = 0; i < this.lineComponents.length; i++) { this.lineComponents[i].destroy() } + this.lineComponents.length = 0 + + return etch.destroy(this) } render () { @@ -3385,34 +3509,12 @@ class LinesTileComponent { backgroundColor: 'inherit' } }, - this.renderHighlights() - // Lines and block decorations will be manually inserted here for efficiency - ) - } - - renderHighlights () { - const {top, lineHeight, highlightDecorations} = this.props - - let children = null - if (highlightDecorations) { - const decorationCount = highlightDecorations.length - children = new Array(decorationCount) - for (let i = 0; i < decorationCount; i++) { - const highlightProps = Object.assign( - {parentTileTop: top, lineHeight}, - highlightDecorations[i] - ) - children[i] = $(HighlightComponent, highlightProps) - highlightDecorations[i].flashRequested = false - } - } - - return $.div( - { + $.div({ + ref: 'highlights', className: 'highlights', - style: {contain: 'layout'} - }, - children + style: {layout: 'contain'} + }) + // Lines and block decorations will be manually inserted here for efficiency ) } @@ -3605,6 +3707,40 @@ class LinesTileComponent { } } + updateHighlights () { + const {top, lineHeight, highlightDecorations} = this.props + + const visibleHighlightDecorations = new Set() + if (highlightDecorations) { + for (let i = 0; i < highlightDecorations.length; i++) { + const highlightDecoration = highlightDecorations[i] + + const highlightProps = Object.assign( + {parentTileTop: top, lineHeight}, + highlightDecorations[i] + ) + let highlightComponent = this.highlightComponentsByKey.get(highlightDecoration.key) + if (highlightComponent) { + highlightComponent.update(highlightProps) + } else { + highlightComponent = new HighlightComponent(highlightProps) + this.refs.highlights.appendChild(highlightComponent.element) + this.highlightComponentsByKey.set(highlightDecoration.key, highlightComponent) + } + + highlightDecorations[i].flashRequested = false + visibleHighlightDecorations.add(highlightDecoration.key) + } + } + + this.highlightComponentsByKey.forEach((highlightComponent, key) => { + if (!visibleHighlightDecorations.has(key)) { + highlightComponent.destroy() + this.highlightComponentsByKey.delete(key) + } + }) + } + shouldUpdate (newProps) { const oldProps = this.props if (oldProps.top !== newProps.top) return true @@ -3805,6 +3941,17 @@ class HighlightComponent { if (this.props.flashRequested) this.performFlash() } + destroy () { + if (this.timeoutsByClassName) { + this.timeoutsByClassName.forEach((timeout) => { + window.clearTimeout(timeout) + }) + this.timeoutsByClassName.clear() + } + + return etch.destroy(this) + } + update (newProps) { this.props = newProps etch.updateSync(this) @@ -4034,7 +4181,6 @@ class NodePool { constructor () { this.elementsByType = {} this.textNodes = [] - this.stylesByNode = new WeakMap() } getElement (type, className, style) { @@ -4055,14 +4201,10 @@ class NodePool { if (element) { element.className = className - var existingStyle = this.stylesByNode.get(element) - if (existingStyle) { - for (var key in existingStyle) { - if (!style || !style[key]) element.style[key] = '' - } - } + element.styleMap.forEach((value, key) => { + if (!style || style[key] == null) element.style[key] = '' + }) if (style) Object.assign(element.style, style) - this.stylesByNode.set(element, style) while (element.firstChild) element.firstChild.remove() return element @@ -4070,7 +4212,6 @@ class NodePool { var newElement = document.createElement(type) if (className) newElement.className = className if (style) Object.assign(newElement.style, style) - this.stylesByNode.set(newElement, style) return newElement } } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index e809ac4c5..39abd05a0 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -647,7 +647,7 @@ class TextEditor extends Model # # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy: (callback) -> - @emitter.on 'did-destroy', callback + @emitter.once 'did-destroy', callback # Extended: Calls your `callback` when a {Cursor} is added to the editor. # Immediately calls your callback for each existing cursor. @@ -1582,6 +1582,8 @@ class TextEditor extends Model # undo history, no changes will be made to the buffer and this method will # return `false`. # + # * `checkpoint` The checkpoint to revert to. + # # Returns a {Boolean} indicating whether the operation succeeded. revertToCheckpoint: (checkpoint) -> @buffer.revertToCheckpoint(checkpoint) @@ -1591,6 +1593,8 @@ class TextEditor extends Model # If the given checkpoint is no longer present in the undo history, no # grouping will be performed and this method will return `false`. # + # * `checkpoint` The checkpoint from which to group changes. + # # Returns a {Boolean} indicating whether the operation succeeded. groupChangesSinceCheckpoint: (checkpoint) -> @buffer.groupChangesSinceCheckpoint(checkpoint) @@ -1787,8 +1791,13 @@ class TextEditor extends Model # spanned by the `DisplayMarker`. # * `line-number` Adds the given `class` to the line numbers overlapping # the rows spanned by the `DisplayMarker`. - # * `highlight` Creates a `.highlight` div with the nested class with up - # to 3 nested regions that fill the area spanned by the `DisplayMarker`. + # * `text` Injects spans into all text overlapping the marked range, + # then adds the given `class` or `style` properties to these spans. + # Use this to manipulate the foreground color or styling of text in + # a given range. + # * `highlight` Creates an absolutely-positioned `.highlight` div + # containing nested divs to cover the marked region. For example, this + # is used to implement selections. # * `overlay` Positions the view associated with the given item at the # head or tail of the given `DisplayMarker`, depending on the `position` # property. @@ -1804,9 +1813,10 @@ class TextEditor extends Model # or render artificial cursors that don't actually exist in the model # by passing a marker that isn't actually associated with a cursor. # * `class` This CSS class will be applied to the decorated line number, - # line, highlight, or overlay. + # line, text spans, highlight regions, cursors, or overlay. # * `style` An {Object} containing CSS style properties to apply to the - # relevant DOM node. Currently this only works with a `type` of `cursor`. + # relevant DOM node. Currently this only works with a `type` of `cursor` + # or `text`. # * `item` (optional) An {HTMLElement} or a model {Object} with a # corresponding view registered. Only applicable to the `gutter`, # `overlay` and `block` decoration types. @@ -3517,6 +3527,10 @@ class TextEditor extends Model else 1 + Object.defineProperty(@prototype, 'rowsPerPage', { + get: -> @getRowsPerPage() + }) + ### Section: Config ### diff --git a/src/title-bar.coffee b/src/title-bar.coffee deleted file mode 100644 index b81b08060..000000000 --- a/src/title-bar.coffee +++ /dev/null @@ -1,34 +0,0 @@ -module.exports = -class TitleBar - constructor: ({@workspace, @themes, @applicationDelegate}) -> - @element = document.createElement('div') - @element.classList.add('title-bar') - - @titleElement = document.createElement('div') - @titleElement.classList.add('title') - @element.appendChild(@titleElement) - - @element.addEventListener 'dblclick', @dblclickHandler - - @workspace.onDidChangeActivePaneItem => @updateTitle() - @themes.onDidChangeActiveThemes => @updateWindowSheetOffset() - - @updateTitle() - @updateWindowSheetOffset() - - dblclickHandler: => - # User preference deciding which action to take on a title bar double-click - switch @applicationDelegate.getUserDefault('AppleActionOnDoubleClick', 'string') - when 'Minimize' - @applicationDelegate.minimizeWindow() - when 'Maximize' - if @applicationDelegate.isWindowMaximized() - @applicationDelegate.unmaximizeWindow() - else - @applicationDelegate.maximizeWindow() - - updateTitle: -> - @titleElement.textContent = document.title - - updateWindowSheetOffset: -> - @applicationDelegate.getCurrentWindow().setSheetOffset(@element.offsetHeight) diff --git a/src/title-bar.js b/src/title-bar.js new file mode 100644 index 000000000..260266308 --- /dev/null +++ b/src/title-bar.js @@ -0,0 +1,47 @@ +module.exports = +class TitleBar { + constructor ({workspace, themes, applicationDelegate}) { + this.dblclickHandler = this.dblclickHandler.bind(this) + this.workspace = workspace + this.themes = themes + this.applicationDelegate = applicationDelegate + this.element = document.createElement('div') + this.element.classList.add('title-bar') + + this.titleElement = document.createElement('div') + this.titleElement.classList.add('title') + this.element.appendChild(this.titleElement) + + this.element.addEventListener('dblclick', this.dblclickHandler) + + this.workspace.onDidChangeWindowTitle(() => this.updateTitle()) + this.themes.onDidChangeActiveThemes(() => this.updateWindowSheetOffset()) + + this.updateTitle() + this.updateWindowSheetOffset() + } + + dblclickHandler () { + // User preference deciding which action to take on a title bar double-click + switch (this.applicationDelegate.getUserDefault('AppleActionOnDoubleClick', 'string')) { + case 'Minimize': + this.applicationDelegate.minimizeWindow() + break + case 'Maximize': + if (this.applicationDelegate.isWindowMaximized()) { + this.applicationDelegate.unmaximizeWindow() + } else { + this.applicationDelegate.maximizeWindow() + } + break + } + } + + updateTitle () { + this.titleElement.textContent = document.title + } + + updateWindowSheetOffset () { + this.applicationDelegate.getCurrentWindow().setSheetOffset(this.element.offsetHeight) + } +} diff --git a/src/workspace.js b/src/workspace.js index 85329a099..17c6b2a8b 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -184,7 +184,6 @@ module.exports = class Workspace extends Model { this.didChangeActivePaneOnPaneContainer = this.didChangeActivePaneOnPaneContainer.bind(this) this.didChangeActivePaneItemOnPaneContainer = this.didChangeActivePaneItemOnPaneContainer.bind(this) this.didActivatePaneContainer = this.didActivatePaneContainer.bind(this) - this.didHideDock = this.didHideDock.bind(this) this.enablePersistence = params.enablePersistence this.packageManager = params.packageManager @@ -270,7 +269,6 @@ module.exports = class Workspace extends Model { deserializerManager: this.deserializerManager, notificationManager: this.notificationManager, viewRegistry: this.viewRegistry, - didHide: this.didHideDock, didActivate: this.didActivatePaneContainer, didChangeActivePane: this.didChangeActivePaneOnPaneContainer, didChangeActivePaneItem: this.didChangeActivePaneItemOnPaneContainer, @@ -321,6 +319,7 @@ module.exports = class Workspace extends Model { this.subscribeToFontSize() this.subscribeToAddedItems() this.subscribeToMovedItems() + this.subscribeToDockToggling() } consumeServices ({serviceHub}) { @@ -484,14 +483,6 @@ module.exports = class Workspace extends Model { } } - didHideDock (dock) { - const {activeElement} = document - const dockElement = dock.getElement() - if (dockElement === activeElement || dockElement.contains(activeElement)) { - this.getCenter().activate() - } - } - setDraggingItem (draggingItem) { _.values(this.paneContainers).forEach(dock => { dock.setDraggingItem(draggingItem) @@ -513,6 +504,20 @@ module.exports = class Workspace extends Model { }) } + subscribeToDockToggling () { + const docks = [this.getLeftDock(), this.getRightDock(), this.getBottomDock()] + docks.forEach(dock => { + dock.onDidChangeVisible(visible => { + if (visible) return + const {activeElement} = document + const dockElement = dock.getElement() + if (dockElement === activeElement || dockElement.contains(activeElement)) { + this.getCenter().activate() + } + }) + }) + } + subscribeToMovedItems () { for (const paneContainer of this.getPaneContainers()) { paneContainer.observePanes(pane => { @@ -582,6 +587,7 @@ module.exports = class Workspace extends Model { document.title = titleParts.join(' \u2014 ') this.applicationDelegate.setRepresentedFilename(representedPath) + this.emitter.emit('did-change-window-title') } // On macOS, fades the application window's proxy icon when the current file @@ -606,7 +612,7 @@ module.exports = class Workspace extends Model { // editors in the workspace. // // * `callback` {Function} to be called with current and future text editors. - // * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + // * `editor` A {TextEditor} that is present in {::getTextEditors} at the time // of subscription or that is added at some later time. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. @@ -859,6 +865,10 @@ module.exports = class Workspace extends Model { return this.emitter.on('did-add-text-editor', callback) } + onDidChangeWindowTitle (callback) { + return this.emitter.on('did-change-window-title', callback) + } + /* Section: Opening */ @@ -1940,6 +1950,7 @@ module.exports = class Workspace extends Model { if (!outOfProcessFinished.length) { let flags = 'g' + if (regex.multiline) { flags += 'm' } if (regex.ignoreCase) { flags += 'i' } const task = Task.once( diff --git a/static/index.html b/static/index.html index 386481bb5..ab239dd5c 100644 --- a/static/index.html +++ b/static/index.html @@ -1,7 +1,7 @@ - + diff --git a/static/text-editor.less b/static/text-editor.less index 06518ac0f..ac40ffe65 100644 --- a/static/text-editor.less +++ b/static/text-editor.less @@ -20,6 +20,7 @@ atom-text-editor { min-width: 1em; box-sizing: border-box; background-color: inherit; + position: relative; } .gutter:hover { @@ -51,7 +52,6 @@ atom-text-editor { } .line-number { - width: min-content; padding-left: .5em; white-space: nowrap; opacity: 0.6;