diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 693e7358c7b..dceaecddb04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Atom is intentionally very modular. Nearly every non-editor UI element you inter ![atom-packages](https://cloud.githubusercontent.com/assets/69169/10472281/84fc9792-71d3-11e5-9fd1-19da717df079.png) -To get a sense for the packages that are bundled with Atom, you can go to Settings > Packages within Atom and take a look at the Core Packages section. +To get a sense for the packages that are bundled with Atom, you can go to `Settings` > `Packages` within Atom and take a look at the Core Packages section. Here's a list of the big ones: @@ -80,8 +80,8 @@ Here's a list of the big ones: * [autocomplete-plus](https://github.com/atom/autocomplete-plus) - autocompletions shown while typing. Some languages have additional packages for autocompletion functionality, such as [autocomplete-html](https://github.com/atom/autocomplete-html). * [git-diff](https://github.com/atom/git-diff) - Git change indicators shown in the editor's gutter. * [language-javascript](https://github.com/atom/language-javascript) - all bundled languages are packages too, and each one has a separate package `language-[name]`. Use these for feedback on syntax highlighting issues that only appear for a specific language. -* [one-dark-ui](https://github.com/atom/one-dark-ui) - the default UI styling for anything but the text editor. UI theme packages (i.e. packages with a `-ui` suffix) provide only styling and it's possible that a bundled package is responsible for a UI issue. There are other other bundled UI themes, such as [one-light-ui](https://github.com/atom/one-light-ui). -* [one-dark-syntax](https://github.com/atom/one-dark-syntax) - the default syntax highlighting styles applied for all languages. There are other other bundled syntax themes, such as [solarized-dark-syntax](https://github.com/atom/solarized-dark-syntax). You should use these packages for reporting issues that appear in many languages, but disappear if you change to another syntax theme. +* [one-dark-ui](https://github.com/atom/one-dark-ui) - the default UI styling for anything but the text editor. UI theme packages (i.e. packages with a `-ui` suffix) provide only styling and it's possible that a bundled package is responsible for a UI issue. There are other bundled UI themes, such as [one-light-ui](https://github.com/atom/one-light-ui). +* [one-dark-syntax](https://github.com/atom/one-dark-syntax) - the default syntax highlighting styles applied for all languages. There are other bundled syntax themes, such as [solarized-dark-syntax](https://github.com/atom/solarized-dark-syntax). You should use these packages for reporting issues that appear in many languages, but disappear if you change to another syntax theme. * [apm](https://github.com/atom/apm) - the `apm` command line tool (Atom Package Manager). You should use this repository for any contributions related to the `apm` tool and to publishing packages. * [atom.io](https://github.com/atom/atom.io) - the repository for feedback on the [Atom.io website](https://atom.io) and the [Atom.io package API](https://github.com/atom/atom/blob/master/docs/apm-rest-api.md) used by [apm](https://github.com/atom/apm). diff --git a/LICENSE.md b/LICENSE.md index 5bdf03cdecf..58684e68332 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2011-2017 GitHub Inc. +Copyright (c) 2011-2018 GitHub Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index a578c38ce18..a3356809dcd 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,20 @@ We must be able to understand the design of your change from this description. I +### Verification Process + + + ### Applicable Issues diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index 7161a847832..6d576f1020c 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -133,6 +133,8 @@ 'cmd-ctrl-left': 'editor:move-selection-left' 'cmd-ctrl-right': 'editor:move-selection-right' 'cmd-shift-V': 'editor:paste-without-reformatting' + 'alt-up': 'editor:select-larger-syntax-node' + 'alt-down': 'editor:select-smaller-syntax-node' # Emacs 'alt-f': 'editor:move-to-end-of-word' diff --git a/package.json b/package.json index 3330769cbb1..b5974667694 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "service-hub": "^0.7.4", "sinon": "1.17.4", "temp": "^0.8.3", - "text-buffer": "13.10.1", + "text-buffer": "13.11.0", + "tree-sitter": "^0.8.4", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -118,13 +119,13 @@ "keybinding-resolver": "0.38.1", "line-ending-selector": "0.7.5", "link": "0.31.4", - "markdown-preview": "0.159.18", + "markdown-preview": "0.159.20", "metrics": "1.2.6", "notifications": "0.70.2", "open-on-github": "1.3.1", "package-generator": "1.3.0", - "settings-view": "0.253.2", - "snippets": "1.2.0", + "settings-view": "0.253.4", + "snippets": "1.3.0", "spell-check": "0.72.5", "status-bar": "1.8.15", "styleguide": "0.49.10", @@ -136,18 +137,18 @@ "welcome": "0.36.6", "whitespace": "0.37.5", "wrap-guide": "0.40.3", - "language-c": "0.58.1", + "language-c": "0.59.0", "language-clojure": "0.22.5", "language-coffee-script": "0.49.3", "language-csharp": "0.14.4", "language-css": "0.42.8", "language-gfm": "0.90.3", "language-git": "0.19.1", - "language-go": "0.44.4", + "language-go": "0.45.0", "language-html": "0.48.5", "language-hyperlink": "0.16.3", "language-java": "0.27.6", - "language-javascript": "0.127.7", + "language-javascript": "0.128.0", "language-json": "0.19.1", "language-less": "0.34.1", "language-make": "0.22.3", @@ -156,17 +157,17 @@ "language-perl": "0.38.1", "language-php": "0.43.0", "language-property-list": "0.9.1", - "language-python": "0.45.6", + "language-python": "0.47.0", "language-ruby": "0.71.4", "language-ruby-on-rails": "0.25.3", "language-sass": "0.61.4", - "language-shellscript": "0.25.4", + "language-shellscript": "0.26.0", "language-source": "0.9.0", "language-sql": "0.25.9", "language-text": "0.7.3", "language-todo": "0.29.3", "language-toml": "0.18.1", - "language-typescript": "0.2.3", + "language-typescript": "0.3.0", "language-xml": "0.35.2", "language-yaml": "0.31.1" }, diff --git a/script/build b/script/build index acc54cdac0b..55cebe96d46 100755 --- a/script/build +++ b/script/build @@ -28,6 +28,7 @@ const argv = yargs const checkChromedriverVersion = require('./lib/check-chromedriver-version') const cleanOutputDirectory = require('./lib/clean-output-directory') +const cleanPackageLock = require('./lib/clean-package-lock') const codeSignOnMac = require('./lib/code-sign-on-mac') const codeSignOnWindows = require('./lib/code-sign-on-windows') const compressArtifacts = require('./lib/compress-artifacts') @@ -58,6 +59,7 @@ const CONFIG = require('./config') let binariesPromise = Promise.resolve() if (!argv.existingBinaries) { + cleanPackageLock() checkChromedriverVersion() cleanOutputDirectory() copyAssets() diff --git a/script/lib/clean-package-lock.js b/script/lib/clean-package-lock.js new file mode 100644 index 00000000000..01376c9c52e --- /dev/null +++ b/script/lib/clean-package-lock.js @@ -0,0 +1,18 @@ +// This module exports a function that deletes all `package-lock.json` files that do +// not exist under a `node_modules` directory. + +'use strict' + +const CONFIG = require('../config') +const fs = require('fs-extra') +const glob = require('glob') +const path = require('path') + +module.exports = function () { + console.log('Deleting problematic package-lock.json files') + let paths = glob.sync(path.join(CONFIG.repositoryRootPath, '**', 'package-lock.json'), {ignore: path.join('**', 'node_modules', '**')}) + + for (let path of paths) { + fs.unlinkSync(path) + } +} diff --git a/script/lib/generate-startup-snapshot.js b/script/lib/generate-startup-snapshot.js index 4c074a2b589..89812a76210 100644 --- a/script/lib/generate-startup-snapshot.js +++ b/script/lib/generate-startup-snapshot.js @@ -57,7 +57,9 @@ module.exports = function (packagedAppPath) { relativePath === path.join('..', 'node_modules', 'spelling-manager', 'node_modules', 'natural', 'lib', 'natural', 'index.js') || relativePath === path.join('..', 'node_modules', 'tar', 'tar.js') || relativePath === path.join('..', 'node_modules', 'temp', 'lib', 'temp.js') || - relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') + relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') || + relativePath === path.join('..', 'node_modules', 'tree-sitter', 'index.js') || + relativePath === path.join('..', 'node_modules', 'winreg', 'lib', 'registry.js') ) } }).then((snapshotScript) => { diff --git a/spec/atom-environment-spec.js b/spec/atom-environment-spec.js index 70ca9c30993..7dc07dafde4 100644 --- a/spec/atom-environment-spec.js +++ b/spec/atom-environment-spec.js @@ -1,4 +1,4 @@ -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {it, fit, ffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') const _ = require('underscore-plus') const path = require('path') const temp = require('temp').track() @@ -518,27 +518,31 @@ describe('AtomEnvironment', () => { }) }) - it('prompts the user to restore the state in a new window, discarding it and adding folder to current window', () => { - spyOn(atom, 'confirm').andReturn(1) + it('prompts the user to restore the state in a new window, discarding it and adding folder to current window', async () => { + jasmine.useRealClock() + spyOn(atom, 'confirm').andCallFake((options, callback) => callback(1)) spyOn(atom.project, 'addPath') spyOn(atom.workspace, 'open') const state = Symbol() atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename]) expect(atom.confirm).toHaveBeenCalled() - expect(atom.project.addPath.callCount).toBe(1) + await conditionPromise(() => atom.project.addPath.callCount === 1) + expect(atom.project.addPath).toHaveBeenCalledWith(__dirname) expect(atom.workspace.open.callCount).toBe(1) expect(atom.workspace.open).toHaveBeenCalledWith(__filename) }) - it('prompts the user to restore the state in a new window, opening a new window', () => { - spyOn(atom, 'confirm').andReturn(0) + it('prompts the user to restore the state in a new window, opening a new window', async () => { + jasmine.useRealClock() + spyOn(atom, 'confirm').andCallFake((options, callback) => callback(0)) spyOn(atom, 'open') const state = Symbol() atom.attemptRestoreProjectStateForPaths(state, [__dirname], [__filename]) expect(atom.confirm).toHaveBeenCalled() + await conditionPromise(() => atom.open.callCount === 1) expect(atom.open).toHaveBeenCalledWith({ pathsToOpen: [__dirname, __filename], newWindow: true, diff --git a/spec/command-installer-spec.js b/spec/command-installer-spec.js index a2ecb6743e2..6a2a31e77f2 100644 --- a/spec/command-installer-spec.js +++ b/spec/command-installer-spec.js @@ -35,9 +35,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Failed to install shell commands', - detailedMessage: 'an error' + detail: 'an error' }) appDelegate.confirm.reset() @@ -46,9 +46,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Failed to install shell commands', - detailedMessage: 'another error' + detail: 'another error' }) }) @@ -61,9 +61,9 @@ describe('CommandInstaller on #darwin', () => { installer.installShellCommandsInteractively() - expect(appDelegate.confirm).toHaveBeenCalledWith({ + expect(appDelegate.confirm.mostRecentCall.args[0]).toEqual({ message: 'Commands installed.', - detailedMessage: 'The shell commands `atom` and `apm` are installed.' + detail: 'The shell commands `atom` and `apm` are installed.' }) }) diff --git a/spec/command-registry-spec.js b/spec/command-registry-spec.js index a0ac86c0856..03ef0cc3486 100644 --- a/spec/command-registry-spec.js +++ b/spec/command-registry-spec.js @@ -1,5 +1,6 @@ const CommandRegistry = require('../src/command-registry'); const _ = require('underscore-plus'); +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers'); describe("CommandRegistry", () => { let registry, parent, child, grandchild; @@ -357,12 +358,41 @@ describe("CommandRegistry", () => { expect(called).toBe(true); }); - it("returns a boolean indicating whether any listeners matched the command", () => { + it("returns a promise if any listeners matched the command", () => { registry.add('.grandchild', 'command', () => {}); - expect(registry.dispatch(grandchild, 'command')).toBe(true); - expect(registry.dispatch(grandchild, 'bogus')).toBe(false); - expect(registry.dispatch(parent, 'command')).toBe(false); + expect(registry.dispatch(grandchild, 'command').constructor.name).toBe("Promise"); + expect(registry.dispatch(grandchild, 'bogus')).toBe(null); + expect(registry.dispatch(parent, 'command')).toBe(null); + }); + + it("returns a promise that resolves when the listeners resolve", async () => { + jasmine.useRealClock(); + registry.add('.grandchild', 'command', () => 1); + registry.add('.grandchild', 'command', () => Promise.resolve(2)); + registry.add('.grandchild', 'command', () => new Promise((resolve) => { + setTimeout(() => { resolve(3); }, 1); + })); + + const values = await registry.dispatch(grandchild, 'command'); + expect(values).toEqual([3, 2, 1]); + }); + + it("returns a promise that rejects when a listener is rejected", async () => { + jasmine.useRealClock(); + registry.add('.grandchild', 'command', () => 1); + registry.add('.grandchild', 'command', () => Promise.resolve(2)); + registry.add('.grandchild', 'command', () => new Promise((resolve, reject) => { + setTimeout(() => { reject(3); }, 1); + })); + + let value; + try { + value = await registry.dispatch(grandchild, 'command'); + } catch (err) { + value = err; + } + expect(value).toBe(3); }); }); diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index bcf50c2685c..090bc7a29fd 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -106,6 +106,15 @@ describe "Config", -> atom.config.set("foo.bar.baz", 1, scopeSelector: ".source.coffee", source: "some-package") expect(atom.config.get("foo.bar.baz", scope: [".source.coffee"])).toBe 100 + describe "when the first component of the scope descriptor matches a legacy scope alias", -> + it "falls back to properties defined for the legacy scope if no value is found for the original scope descriptor", -> + atom.config.addLegacyScopeAlias('javascript', '.source.js') + atom.config.set('foo', 100, scopeSelector: '.source.js') + atom.config.set('foo', 200, scopeSelector: 'javascript for_statement') + + expect(atom.config.get('foo', scope: ['javascript', 'for_statement', 'identifier'])).toBe(200) + expect(atom.config.get('foo', scope: ['javascript', 'function', 'identifier'])).toBe(100) + describe ".getAll(keyPath, {scope, sources, excludeSources})", -> it "reads all of the values for a given key-path", -> expect(atom.config.set("foo", 41)).toBe true @@ -130,6 +139,20 @@ describe "Config", -> {scopeSelector: '*', value: 40} ] + describe "when the first component of the scope descriptor matches a legacy scope alias", -> + it "includes the values defined for the legacy scope", -> + atom.config.addLegacyScopeAlias('javascript', '.source.js') + + expect(atom.config.set('foo', 41)).toBe true + expect(atom.config.set('foo', 42, scopeSelector: 'javascript')).toBe true + expect(atom.config.set('foo', 43, scopeSelector: '.source.js')).toBe true + + expect(atom.config.getAll('foo', scope: ['javascript'])).toEqual([ + {scopeSelector: 'javascript', value: 42}, + {scopeSelector: '.js.source', value: 43}, + {scopeSelector: '*', value: 41} + ]) + describe ".set(keyPath, value, {source, scopeSelector})", -> it "allows a key path's value to be written", -> expect(atom.config.set("foo.bar.baz", 42)).toBe true diff --git a/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js new file mode 100644 index 00000000000..028ee5135ac --- /dev/null +++ b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/fake-parser.js @@ -0,0 +1 @@ +exports.isFakeTreeSitterParser = true diff --git a/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson new file mode 100644 index 00000000000..5eb47345660 --- /dev/null +++ b/spec/fixtures/packages/package-with-tree-sitter-grammar/grammars/some-language.cson @@ -0,0 +1,14 @@ +name: 'Some Language' + +id: 'some-language' + +type: 'tree-sitter' + +parser: './fake-parser' + +fileTypes: [ + 'somelang' +] + +scopes: + 'class > identifier': 'entity.name.type.class' diff --git a/spec/grammar-registry-spec.js b/spec/grammar-registry-spec.js index c51ea03b91a..e6d815f8d0e 100644 --- a/spec/grammar-registry-spec.js +++ b/spec/grammar-registry-spec.js @@ -1,10 +1,13 @@ const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const dedent = require('dedent') const path = require('path') const fs = require('fs-plus') const temp = require('temp').track() const TextBuffer = require('text-buffer') const GrammarRegistry = require('../src/grammar-registry') +const TreeSitterGrammar = require('../src/tree-sitter-grammar') +const FirstMate = require('first-mate') describe('GrammarRegistry', () => { let grammarRegistry @@ -13,8 +16,8 @@ describe('GrammarRegistry', () => { grammarRegistry = new GrammarRegistry({config: atom.config}) }) - describe('.assignLanguageMode(buffer, languageName)', () => { - it('assigns to the buffer a language mode with the given language name', async () => { + describe('.assignLanguageMode(buffer, languageId)', () => { + it('assigns to the buffer a language mode with the given language id', async () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) @@ -34,7 +37,7 @@ describe('GrammarRegistry', () => { expect(buffer.getLanguageMode().getLanguageId()).toBe('source.css') }) - describe('when no languageName is passed', () => { + describe('when no languageId is passed', () => { it('makes the buffer use the null grammar', () => { grammarRegistry.loadGrammarSync(require.resolve('language-css/grammars/css.cson')) @@ -48,6 +51,36 @@ describe('GrammarRegistry', () => { }) }) + describe('.grammarForId(languageId)', () => { + it('converts the language id to a text-mate language id when `core.useTreeSitterParsers` is false', () => { + atom.config.set('core.useTreeSitterParsers', false) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.grammarForId('javascript') + expect(grammar instanceof FirstMate.Grammar).toBe(true) + expect(grammar.scopeName).toBe('source.js') + + grammarRegistry.removeGrammar(grammar) + expect(grammarRegistry.grammarForId('javascript')).toBe(undefined) + }) + + it('converts the language id to a tree-sitter language id when `core.useTreeSitterParsers` is true', () => { + atom.config.set('core.useTreeSitterParsers', true) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.grammarForId('source.js') + expect(grammar instanceof TreeSitterGrammar).toBe(true) + expect(grammar.id).toBe('javascript') + + grammarRegistry.removeGrammar(grammar) + expect(grammarRegistry.grammarForId('source.js') instanceof FirstMate.Grammar).toBe(true) + }) + }) + describe('.autoAssignLanguageMode(buffer)', () => { it('assigns to the buffer a language mode based on the best available grammar', () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) @@ -78,7 +111,9 @@ describe('GrammarRegistry', () => { expect(buffer.getLanguageMode().getLanguageId()).toBe('source.c') }) - it('updates the buffer\'s grammar when a more appropriate grammar is added for its path', async () => { + it('updates the buffer\'s grammar when a more appropriate text-mate grammar is added for its path', async () => { + atom.config.set('core.useTreeSitterParsers', false) + const buffer = new TextBuffer() expect(buffer.getLanguageMode().getLanguageId()).toBe(null) @@ -87,6 +122,25 @@ describe('GrammarRegistry', () => { grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('source.js') + }) + + it('updates the buffer\'s grammar when a more appropriate tree-sitter grammar is added for its path', async () => { + atom.config.set('core.useTreeSitterParsers', true) + + const buffer = new TextBuffer() + expect(buffer.getLanguageMode().getLanguageId()).toBe(null) + + buffer.setPath('test.js') + grammarRegistry.maintainLanguageMode(buffer) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('javascript') + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + expect(buffer.getLanguageMode().getLanguageId()).toBe('javascript') }) it('can be overridden by calling .assignLanguageMode', () => { @@ -226,6 +280,32 @@ describe('GrammarRegistry', () => { expect(atom.grammars.selectGrammar('/hu.git/config').name).toBe('Null Grammar') }) + describe('when the grammar has a contentRegExp field', () => { + it('favors grammars whose contentRegExp matches a prefix of the file\'s content', () => { + atom.grammars.addGrammar({ + id: 'javascript-1', + fileTypes: ['js'] + }) + atom.grammars.addGrammar({ + id: 'flow-javascript', + contentRegExp: new RegExp('//.*@flow'), + fileTypes: ['js'] + }) + atom.grammars.addGrammar({ + id: 'javascript-2', + fileTypes: ['js'] + }) + + const selectedGrammar = atom.grammars.selectGrammar('test.js', dedent` + // Copyright EvilCorp + // @flow + + module.exports = function () { return 1 + 1 } + `) + expect(selectedGrammar.id).toBe('flow-javascript') + }) + }) + it("uses the filePath's shebang line if the grammar cannot be determined by the extension or basename", async () => { await atom.packages.activatePackage('language-javascript') await atom.packages.activatePackage('language-ruby') @@ -335,14 +415,38 @@ describe('GrammarRegistry', () => { await atom.packages.activatePackage('language-javascript') expect(atom.grammars.selectGrammar('foo.rb', '#!/usr/bin/env node').scopeName).toBe('source.ruby') }) + + describe('tree-sitter vs text-mate', () => { + it('favors a text-mate grammar over a tree-sitter grammar when `core.useTreeSitterParsers` is false', () => { + atom.config.set('core.useTreeSitterParsers', false) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.selectGrammar('test.js') + expect(grammar.scopeName).toBe('source.js') + expect(grammar instanceof FirstMate.Grammar).toBe(true) + }) + + it('favors a tree-sitter grammar over a text-mate grammar when `core.useTreeSitterParsers` is true', () => { + atom.config.set('core.useTreeSitterParsers', true) + + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/javascript.cson')) + grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')) + + const grammar = grammarRegistry.selectGrammar('test.js') + expect(grammar.id).toBe('javascript') + expect(grammar instanceof TreeSitterGrammar).toBe(true) + }) + }) }) describe('.removeGrammar(grammar)', () => { it("removes the grammar, so it won't be returned by selectGrammar", async () => { - await atom.packages.activatePackage('language-javascript') - const grammar = atom.grammars.selectGrammar('foo.js') + await atom.packages.activatePackage('language-css') + const grammar = atom.grammars.selectGrammar('foo.css') atom.grammars.removeGrammar(grammar) - expect(atom.grammars.selectGrammar('foo.js').name).not.toBe(grammar.name) + expect(atom.grammars.selectGrammar('foo.css').name).not.toBe(grammar.name) }) }) diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js index 7c19efb9c31..90a51269220 100644 --- a/spec/main-process/atom-application.test.js +++ b/spec/main-process/atom-application.test.js @@ -1,14 +1,13 @@ -/** @babel */ - -import season from 'season' -import dedent from 'dedent' -import electron from 'electron' -import fs from 'fs-plus' -import path from 'path' -import sinon from 'sinon' -import AtomApplication from '../../src/main-process/atom-application' -import parseCommandLine from '../../src/main-process/parse-command-line' -import {timeoutPromise, conditionPromise, emitterEventPromise} from '../async-spec-helpers' +const temp = require('temp').track() +const season = require('season') +const dedent = require('dedent') +const electron = require('electron') +const fs = require('fs-plus') +const path = require('path') +const sinon = require('sinon') +const AtomApplication = require('../../src/main-process/atom-application') +const parseCommandLine = require('../../src/main-process/parse-command-line') +const {timeoutPromise, conditionPromise, emitterEventPromise} = require('../async-spec-helpers') const ATOM_RESOURCE_PATH = path.resolve(__dirname, '..', '..') @@ -17,7 +16,7 @@ describe('AtomApplication', function () { let originalAppQuit, originalShowMessageBox, originalAtomHome, atomApplicationsToDestroy - beforeEach(function () { + beforeEach(() => { originalAppQuit = electron.app.quit originalShowMessageBox = electron.dialog.showMessageBox mockElectronAppQuit() @@ -34,7 +33,7 @@ describe('AtomApplication', function () { atomApplicationsToDestroy = [] }) - afterEach(async function () { + afterEach(async () => { process.env.ATOM_HOME = originalAtomHome for (let atomApplication of atomApplicationsToDestroy) { await atomApplication.destroy() @@ -44,8 +43,8 @@ describe('AtomApplication', function () { electron.dialog.showMessageBox = originalShowMessageBox }) - describe('launch', function () { - it('can open to a specific line number of a file', async function () { + describe('launch', () => { + it('can open to a specific line number of a file', async () => { const filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -53,8 +52,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':3'])) await focusWindow(window) - const cursorRow = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const cursorRow = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getCursorBufferPosition().row) }) }) @@ -62,7 +61,7 @@ describe('AtomApplication', function () { assert.equal(cursorRow, 2) }) - it('can open to a specific line and column of a file', async function () { + it('can open to a specific line and column of a file', async () => { const filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -70,8 +69,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':2:2'])) await focusWindow(window) - const cursorPosition = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const cursorPosition = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getCursorBufferPosition()) }) }) @@ -79,7 +78,7 @@ describe('AtomApplication', function () { assert.deepEqual(cursorPosition, {row: 1, column: 1}) }) - it('removes all trailing whitespace and colons from the specified path', async function () { + it('removes all trailing whitespace and colons from the specified path', async () => { let filePath = path.join(makeTempDir(), 'new-file') fs.writeFileSync(filePath, '1\n2\n3\n4\n') const atomApplication = buildAtomApplication() @@ -87,8 +86,8 @@ describe('AtomApplication', function () { const window = atomApplication.launch(parseCommandLine([filePath + ':: '])) await focusWindow(window) - const openedPath = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + const openedPath = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -97,7 +96,7 @@ describe('AtomApplication', function () { }) if (process.platform === 'darwin' || process.platform === 'win32') { - it('positions new windows at an offset distance from the previous window', async function () { + it('positions new windows at an offset distance from the previous window', async () => { const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([makeTempDir()])) @@ -115,7 +114,7 @@ describe('AtomApplication', function () { }) } - it('reuses existing windows when opening paths, but not directories', async function () { + it('reuses existing windows when opening paths, but not directories', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirCPath = makeTempDir("c") @@ -127,8 +126,8 @@ describe('AtomApplication', function () { await emitterEventPromise(window1, 'window:locations-opened') await focusWindow(window1) - let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -139,8 +138,8 @@ describe('AtomApplication', function () { const reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath])) assert.equal(reusedWindow, window1) assert.deepEqual(atomApplication.getAllWindows(), [window1]) - activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { + activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + const subscription = atom.workspace.onDidChangeActivePaneItem(textEditor => { sendBackToMainProcess(textEditor.getPath()) subscription.dispose() }) @@ -156,7 +155,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window2), [dirCPath]) }) - it('adds folders to existing windows when the --add option is used', async function () { + it('adds folders to existing windows when the --add option is used', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirCPath = makeTempDir("c") @@ -167,8 +166,8 @@ describe('AtomApplication', function () { const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')])) await focusWindow(window1) - let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { sendBackToMainProcess(textEditor.getPath()) }) }) @@ -179,8 +178,8 @@ describe('AtomApplication', function () { let reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add'])) assert.equal(reusedWindow, window1) assert.deepEqual(atomApplication.getAllWindows(), [window1]) - activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { + activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + const subscription = atom.workspace.onDidChangeActivePaneItem(textEditor => { sendBackToMainProcess(textEditor.getPath()) subscription.dispose() }) @@ -198,14 +197,14 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirCPath, dirBPath]) }) - it('persists window state based on the project directories', async function () { + it('persists window state based on the project directories', async () => { const tempDirPath = makeTempDir() const atomApplication = buildAtomApplication() const nonExistentFilePath = path.join(tempDirPath, 'new-file') const window1 = atomApplication.launch(parseCommandLine([nonExistentFilePath])) - await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (textEditor) { + await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(textEditor => { textEditor.insertText('Hello World!') sendBackToMainProcess(null) }) @@ -217,7 +216,7 @@ describe('AtomApplication', function () { // Restore unsaved state when opening the directory itself const window2 = atomApplication.launch(parseCommandLine([tempDirPath])) await window2.loadedPromise - const window2Text = await evalInWebContents(window2.browserWindow.webContents, function (sendBackToMainProcess) { + const window2Text = await evalInWebContents(window2.browserWindow.webContents, sendBackToMainProcess => { const textEditor = atom.workspace.getActiveTextEditor() textEditor.moveToBottom() textEditor.insertText(' How are you?') @@ -231,13 +230,13 @@ describe('AtomApplication', function () { // Restore unsaved state when opening a path to a non-existent file in the directory const window3 = atomApplication.launch(parseCommandLine([path.join(tempDirPath, 'another-non-existent-file')])) await window3.loadedPromise - const window3Texts = await evalInWebContents(window3.browserWindow.webContents, function (sendBackToMainProcess, nonExistentFilePath) { + const window3Texts = await evalInWebContents(window3.browserWindow.webContents, (sendBackToMainProcess, nonExistentFilePath) => { sendBackToMainProcess(atom.workspace.getTextEditors().map(editor => editor.getText())) }) assert.include(window3Texts, 'Hello World! How are you?') }) - it('shows all directories in the tree view when multiple directory paths are passed to Atom', async function () { + it('shows all directories in the tree view when multiple directory paths are passed to Atom', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const dirBSubdirPath = path.join(dirBPath, 'c') @@ -250,7 +249,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirBPath]) }) - it('reuses windows with no project paths to open directories', async function () { + it('reuses windows with no project paths to open directories', async () => { const tempDirPath = makeTempDir() const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([])) @@ -261,18 +260,18 @@ describe('AtomApplication', function () { await conditionPromise(async () => (await getTreeViewRootDirectories(reusedWindow)).length > 0) }) - it('opens a new window with a single untitled buffer when launched with no path, even if windows already exist', async function () { + it('opens a new window with a single untitled buffer when launched with no path, even if windows already exist', async () => { const atomApplication = buildAtomApplication() const window1 = atomApplication.launch(parseCommandLine([])) await focusWindow(window1) - const window1EditorTitle = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const window1EditorTitle = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActiveTextEditor().getTitle()) }) assert.equal(window1EditorTitle, 'untitled') const window2 = atomApplication.openWithOptions(parseCommandLine([])) await focusWindow(window2) - const window2EditorTitle = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const window2EditorTitle = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActiveTextEditor().getTitle()) }) assert.equal(window2EditorTitle, 'untitled') @@ -280,7 +279,7 @@ describe('AtomApplication', function () { assert.deepEqual(atomApplication.getAllWindows(), [window2, window1]) }) - it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async function () { + it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async () => { const configPath = path.join(process.env.ATOM_HOME, 'config.cson') const config = season.readFileSync(configPath) if (!config['*'].core) config['*'].core = {} @@ -294,19 +293,19 @@ describe('AtomApplication', function () { // wait a bit just to make sure we don't pass due to querying the render process before it loads await timeoutPromise(1000) - const itemCount = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + const itemCount = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.workspace.getActivePane().getItems().length) }) assert.equal(itemCount, 0) }) - it('opens an empty text editor and loads its parent directory in the tree-view when launched with a new file path', async function () { + it('opens an empty text editor and loads its parent directory in the tree-view when launched with a new file path', async () => { const atomApplication = buildAtomApplication() const newFilePath = path.join(makeTempDir(), 'new-file') const window = atomApplication.launch(parseCommandLine([newFilePath])) await focusWindow(window) - const {editorTitle, editorText} = await evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { - atom.workspace.observeTextEditors(function (editor) { + const {editorTitle, editorText} = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { + atom.workspace.observeTextEditors(editor => { sendBackToMainProcess({editorTitle: editor.getTitle(), editorText: editor.getText()}) }) }) @@ -315,7 +314,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(window), [path.dirname(newFilePath)]) }) - it('adds a remote directory to the project when launched with a remote directory', async function () { + it('adds a remote directory to the project when launched with a remote directory', async () => { const packagePath = path.join(__dirname, '..', 'fixtures', 'packages', 'package-with-directory-provider') const packagesDirPath = path.join(process.env.ATOM_HOME, 'packages') fs.mkdirSync(packagesDirPath) @@ -338,13 +337,13 @@ describe('AtomApplication', function () { assert.deepEqual(directories, [{type: 'FakeRemoteDirectory', path: remotePath}]) function getProjectDirectories () { - return evalInWebContents(window.browserWindow.webContents, function (sendBackToMainProcess) { + return evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(atom.project.getDirectories().map(d => ({ type: d.constructor.name, path: d.getPath() }))) }) } }) - it('reopens any previously opened windows when launched with no path', async function () { + it('reopens any previously opened windows when launched with no path', async () => { if (process.platform === 'win32') return; // Test is too flakey on Windows const tempDirPath1 = makeTempDir() @@ -372,7 +371,7 @@ describe('AtomApplication', function () { assert.deepEqual(await getTreeViewRootDirectories(app2Window2), [tempDirPath2]) }) - it('does not reopen any previously opened windows when launched with no path and `core.restorePreviousWindowsOnStart` is no', async function () { + it('does not reopen any previously opened windows when launched with no path and `core.restorePreviousWindowsOnStart` is no', async () => { const atomApplication1 = buildAtomApplication() const app1Window1 = atomApplication1.launch(parseCommandLine([makeTempDir()])) await focusWindow(app1Window1) @@ -391,30 +390,136 @@ describe('AtomApplication', function () { assert.deepEqual(app2Window.representedDirectoryPaths, []) }) - describe('when closing the last window', function () { + describe('when the `--wait` flag is passed', () => { + let killedPids, atomApplication, onDidKillProcess + + beforeEach(() => { + killedPids = [] + onDidKillProcess = null + atomApplication = buildAtomApplication({ + killProcess (pid) { + killedPids.push(pid) + if (onDidKillProcess) onDidKillProcess() + } + }) + }) + + it('kills the specified pid after a newly-opened window is closed', async () => { + const window1 = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101'])) + await focusWindow(window1) + + const [window2] = atomApplication.launch(parseCommandLine(['--new-window', '--wait', '--pid', '102'])) + await focusWindow(window2) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window1.close() + await processKillPromise + assert.deepEqual(killedPids, [101]) + + processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window2.close() + await processKillPromise + assert.deepEqual(killedPids, [101, 102]) + }) + + it('kills the specified pid after a newly-opened file in an existing window is closed', async () => { + const window = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101'])) + await focusWindow(window) + + const filePath1 = temp.openSync('test').path + const filePath2 = temp.openSync('test').path + fs.writeFileSync(filePath1, 'File 1') + fs.writeFileSync(filePath2, 'File 2') + + const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '102', filePath1, filePath2])) + assert.equal(reusedWindow, window) + + const activeEditorPath = await evalInWebContents(window.browserWindow.webContents, send => { + const subscription = atom.workspace.onDidChangeActivePaneItem(editor => { + send(editor.getPath()) + subscription.dispose() + }) + }) + + assert([filePath1, filePath2].includes(activeEditorPath)) + assert.deepEqual(killedPids, []) + + await evalInWebContents(window.browserWindow.webContents, send => { + atom.workspace.getActivePaneItem().destroy() + send() + }) + await timeoutPromise(100) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + await evalInWebContents(window.browserWindow.webContents, send => { + atom.workspace.getActivePaneItem().destroy() + send() + }) + await processKillPromise + assert.deepEqual(killedPids, [102]) + + processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + window.close() + await processKillPromise + assert.deepEqual(killedPids, [102, 101]) + }) + + it('kills the specified pid after a newly-opened directory in an existing window is closed', async () => { + const window = atomApplication.launch(parseCommandLine([])) + await focusWindow(window) + + const dirPath1 = makeTempDir() + const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101', dirPath1])) + assert.equal(reusedWindow, window) + assert.deepEqual(await getTreeViewRootDirectories(window), [dirPath1]) + assert.deepEqual(killedPids, []) + + const dirPath2 = makeTempDir() + await evalInWebContents(window.browserWindow.webContents, (send, dirPath1, dirPath2) => { + atom.project.setPaths([dirPath1, dirPath2]) + send() + }, dirPath1, dirPath2) + await timeoutPromise(100) + assert.deepEqual(killedPids, []) + + let processKillPromise = new Promise(resolve => { onDidKillProcess = resolve }) + await evalInWebContents(window.browserWindow.webContents, (send, dirPath2) => { + atom.project.setPaths([dirPath2]) + send() + }, dirPath2) + await processKillPromise + assert.deepEqual(killedPids, [101]) + }) + }) + + describe('when closing the last window', () => { if (process.platform === 'linux' || process.platform === 'win32') { - it('quits the application', async function () { + it('quits the application', async () => { const atomApplication = buildAtomApplication() const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')])) await focusWindow(window) window.close() await window.closedPromise - assert(electron.app.hasQuitted()) + await atomApplication.lastBeforeQuitPromise + assert(electron.app.didQuit()) }) } else if (process.platform === 'darwin') { - it('leaves the application open', async function () { + it('leaves the application open', async () => { const atomApplication = buildAtomApplication() const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')])) await focusWindow(window) window.close() await window.closedPromise - assert(!electron.app.hasQuitted()) + await timeoutPromise(1000) + assert(!electron.app.didQuit()) }) } }) - describe('when adding or removing project folders', function () { - it('stores the window state immediately', async function () { + describe('when adding or removing project folders', () => { + it('stores the window state immediately', async () => { const dirA = makeTempDir() const dirB = makeTempDir() @@ -441,8 +546,8 @@ describe('AtomApplication', function () { }) }) - describe('when opening atom:// URLs', function () { - it('loads the urlMain file in a new window', async function () { + describe('when opening atom:// URLs', () => { + it('loads the urlMain file in a new window', async () => { const packagePath = path.join(__dirname, '..', 'fixtures', 'packages', 'package-with-url-main') const packagesDirPath = path.join(process.env.ATOM_HOME, 'packages') fs.mkdirSync(packagesDirPath) @@ -454,7 +559,7 @@ describe('AtomApplication', function () { let windows = atomApplication.launch(launchOptions) await windows[0].loadedPromise - let reached = await evalInWebContents(windows[0].browserWindow.webContents, function (sendBackToMainProcess) { + let reached = await evalInWebContents(windows[0].browserWindow.webContents, sendBackToMainProcess => { sendBackToMainProcess(global.reachedUrlMain) }) assert.equal(reached, true); @@ -488,7 +593,7 @@ describe('AtomApplication', function () { }) }) - it('waits until all the windows have saved their state before quitting', async function () { + it('waits until all the windows have saved their state before quitting', async () => { const dirAPath = makeTempDir("a") const dirBPath = makeTempDir("b") const atomApplication = buildAtomApplication() @@ -497,9 +602,12 @@ describe('AtomApplication', function () { const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')])) await focusWindow(window2) electron.app.quit() - assert(!electron.app.hasQuitted()) + await new Promise(process.nextTick) + assert(!electron.app.didQuit()) + await Promise.all([window1.lastPrepareToUnloadPromise, window2.lastPrepareToUnloadPromise]) - assert(electron.app.hasQuitted()) + await new Promise(process.nextTick) + assert(electron.app.didQuit()) }) it('prevents quitting if user cancels when prompted to save an item', async () => { @@ -507,30 +615,30 @@ describe('AtomApplication', function () { const window1 = atomApplication.launch(parseCommandLine([])) const window2 = atomApplication.launch(parseCommandLine([])) await Promise.all([window1.loadedPromise, window2.loadedPromise]) - await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { + await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => { atom.workspace.getActiveTextEditor().insertText('unsaved text') sendBackToMainProcess() }) // Choosing "Cancel" - mockElectronShowMessageBox({choice: 1}) + mockElectronShowMessageBox({response: 1}) electron.app.quit() await atomApplication.lastBeforeQuitPromise - assert(!electron.app.hasQuitted()) + assert(!electron.app.didQuit()) 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}) + mockElectronShowMessageBox({response: 2}) electron.app.quit() await atomApplication.lastBeforeQuitPromise - assert(electron.app.hasQuitted()) + assert(electron.app.didQuit()) }) - function buildAtomApplication () { - const atomApplication = new AtomApplication({ + function buildAtomApplication (params = {}) { + const atomApplication = new AtomApplication(Object.assign({ resourcePath: ATOM_RESOURCE_PATH, - atomHomeDirPath: process.env.ATOM_HOME - }) + atomHomeDirPath: process.env.ATOM_HOME, + }, params)) atomApplicationsToDestroy.push(atomApplication) return atomApplication } @@ -542,40 +650,34 @@ 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 didQuit = false - let shouldQuit = true - electron.app.emit('before-quit', {preventDefault: () => { shouldQuit = false }}) - if (shouldQuit) { - quitted = true - } - } - electron.app.hasQuitted = function () { - return quitted + electron.app.quit = function () { + this.quit.callCount++ + let defaultPrevented = false + this.emit('before-quit', {preventDefault() { defaultPrevented = true }}) + if (!defaultPrevented) didQuit = true } + + electron.app.quit.callCount = 0 + + electron.app.didQuit = () => didQuit } - function mockElectronShowMessageBox ({choice}) { - electron.dialog.showMessageBox = function () { - return choice + function mockElectronShowMessageBox ({response}) { + electron.dialog.showMessageBox = (window, options, callback) => { + callback(response) } } function makeTempDir (name) { - const temp = require('temp').track() return fs.realpathSync(temp.mkdirSync(name)) } let channelIdCounter = 0 function evalInWebContents (webContents, source, ...args) { const channelId = 'eval-result-' + channelIdCounter++ - return new Promise(function (resolve) { + return new Promise(resolve => { electron.ipcMain.on(channelId, receiveResult) function receiveResult (event, result) { @@ -587,13 +689,13 @@ describe('AtomApplication', function () { function sendBackToMainProcess (result) { require('electron').ipcRenderer.send('${channelId}', result) } - (${source})(sendBackToMainProcess) + (${source})(sendBackToMainProcess, ${args.map(JSON.stringify).join(', ')}) `) }) } function getTreeViewRootDirectories (atomWindow) { - return evalInWebContents(atomWindow.browserWindow.webContents, function (sendBackToMainProcess) { + return evalInWebContents(atomWindow.browserWindow.webContents, sendBackToMainProcess => { atom.workspace.getLeftDock().observeActivePaneItem((treeView) => { if (treeView) { sendBackToMainProcess( @@ -607,8 +709,8 @@ describe('AtomApplication', function () { } function clearElectronSession () { - return new Promise(function (resolve) { - electron.session.defaultSession.clearStorageData(function () { + return new Promise(resolve => { + electron.session.defaultSession.clearStorageData(() => { // Resolve promise on next tick, otherwise the process stalls. This // might be a bug in Electron, but it's probably fixed on the newer // versions. diff --git a/spec/package-manager-spec.js b/spec/package-manager-spec.js index 0b26bf8392f..b1ecf834d9a 100644 --- a/spec/package-manager-spec.js +++ b/spec/package-manager-spec.js @@ -1030,6 +1030,13 @@ describe('PackageManager', () => { expect(atom.grammars.selectGrammar('a.alot').name).toBe('Alot') expect(atom.grammars.selectGrammar('a.alittle').name).toBe('Alittle') }) + + it('loads any tree-sitter grammars defined in the package', async () => { + await atom.packages.activatePackage('package-with-tree-sitter-grammar') + const grammar = atom.grammars.selectGrammar('test.somelang') + expect(grammar.name).toBe('Some Language') + expect(grammar.languageModule.isFakeTreeSitterParser).toBe(true) + }) }) describe('scoped-property loading', () => { diff --git a/spec/pane-container-spec.js b/spec/pane-container-spec.js index 1918364f9eb..060808d0b61 100644 --- a/spec/pane-container-spec.js +++ b/spec/pane-container-spec.js @@ -5,7 +5,7 @@ describe('PaneContainer', () => { let confirm, params beforeEach(() => { - confirm = spyOn(atom.applicationDelegate, 'confirm').andReturn(0) + confirm = spyOn(atom.applicationDelegate, 'confirm').andCallFake((options, callback) => callback(0)) params = { location: 'center', config: atom.config, @@ -280,14 +280,14 @@ describe('PaneContainer', () => { }) it('returns true if the user saves all modified files when prompted', async () => { - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) const saved = await container.confirmClose() expect(confirm).toHaveBeenCalled() expect(saved).toBeTruthy() }) it('returns false if the user cancels saving any modified file', async () => { - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) const saved = await container.confirmClose() expect(confirm).toHaveBeenCalled() expect(saved).toBeFalsy() diff --git a/spec/pane-spec.js b/spec/pane-spec.js index e448f992ffe..8ef274c2ddd 100644 --- a/spec/pane-spec.js +++ b/spec/pane-spec.js @@ -3,7 +3,7 @@ const {Emitter} = require('event-kit') const Grim = require('grim') const Pane = require('../src/pane') const PaneContainer = require('../src/pane-container') -const {it, fit, ffit, fffit, beforeEach, timeoutPromise} = require('./async-spec-helpers') +const {it, fit, ffit, fffit, beforeEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') describe('Pane', () => { let confirm, showSaveDialog, deserializerDisposable @@ -564,7 +564,7 @@ describe('Pane', () => { describe('when the item has a uri', () => { it('saves the item before destroying it', async () => { itemURI = 'test' - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) expect(item1.save).toHaveBeenCalled() @@ -576,13 +576,17 @@ describe('Pane', () => { describe('when the item has no uri', () => { it('presents a save-as dialog, then saves the item with the given uri before removing and destroying it', async () => { + jasmine.useRealClock() + itemURI = null - showSaveDialog.andReturn('/selected/path') - confirm.andReturn(0) + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) + confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) - expect(showSaveDialog).toHaveBeenCalledWith({}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) + + await conditionPromise(() => item1.saveAs.callCount === 1) expect(item1.saveAs).toHaveBeenCalledWith('/selected/path') expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) @@ -593,7 +597,7 @@ describe('Pane', () => { describe("if the [Don't Save] option is selected", () => { it('removes and destroys the item without saving it', async () => { - confirm.andReturn(2) + confirm.andCallFake((options, callback) => callback(2)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() @@ -605,7 +609,7 @@ describe('Pane', () => { describe('if the [Cancel] option is selected', () => { it('does not save, remove, or destroy the item', async () => { - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() @@ -735,7 +739,7 @@ describe('Pane', () => { beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) - showSaveDialog.andReturn('/selected/path') + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the active item has a uri', () => { @@ -764,7 +768,7 @@ describe('Pane', () => { it('opens a save dialog and saves the current item as the selected path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') await pane.saveActiveItem() - expect(showSaveDialog).toHaveBeenCalledWith({}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) @@ -779,7 +783,7 @@ describe('Pane', () => { it('does nothing if the user cancels choosing a path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') - showSaveDialog.andReturn(undefined) + showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.saveActiveItem() expect(pane.getActiveItem().saveAs).not.toHaveBeenCalled() }) @@ -835,15 +839,19 @@ describe('Pane', () => { beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) - showSaveDialog.andReturn('/selected/path') + showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the current item has a saveAs method', () => { - it('opens the save dialog and calls saveAs on the item with the selected path', () => { + it('opens the save dialog and calls saveAs on the item with the selected path', async () => { + jasmine.useRealClock() + pane.getActiveItem().path = __filename pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') pane.saveActiveItemAs() - expect(showSaveDialog).toHaveBeenCalledWith({defaultPath: __filename}) + expect(showSaveDialog.mostRecentCall.args[0]).toEqual({defaultPath: __filename}) + + await conditionPromise(() => pane.getActiveItem().saveAs.callCount === 1) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) @@ -1210,7 +1218,7 @@ describe('Pane', () => { item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') - confirm.andReturn(0) + confirm.andCallFake((options, callback) => callback(0)) await pane.close() expect(confirm).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled() @@ -1225,7 +1233,7 @@ describe('Pane', () => { item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') - confirm.andReturn(1) + confirm.andCallFake((options, callback) => callback(1)) await pane.close() expect(confirm).toHaveBeenCalled() @@ -1240,8 +1248,8 @@ describe('Pane', () => { item1.shouldPromptToSave = () => true item1.saveAs = jasmine.createSpy('saveAs') - confirm.andReturn(0) - showSaveDialog.andReturn(undefined) + confirm.andCallFake((options, callback) => callback(0)) + showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() @@ -1270,12 +1278,12 @@ describe('Pane', () => { it('does not destroy the pane if save fails and user clicks cancel', async () => { let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ if (confirmations === 1) { - return 0 // click save + callback(0) // click save } else { - return 1 + callback(1) } }) // click cancel @@ -1290,17 +1298,17 @@ describe('Pane', () => { item1.saveAs = jasmine.createSpy('saveAs').andReturn(true) let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ - return 0 + callback(0) }) // save and then save as - showSaveDialog.andReturn('new/path') + showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(2) - expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalledWith({}) + expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) @@ -1315,20 +1323,21 @@ describe('Pane', () => { }) let confirmations = 0 - confirm.andCallFake(() => { + confirm.andCallFake((options, callback) => { confirmations++ if (confirmations < 3) { - return 0 // save, save as, save as + callback(0) // save, save as, save as + } else { + callback(2) // don't save } - return 2 - }) // don't save + }) - showSaveDialog.andReturn('new/path') + showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(3) - expect(atom.applicationDelegate.showSaveDialog).toHaveBeenCalledWith({}) + expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index b5ce2914bed..f4f1f356863 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -111,7 +111,8 @@ beforeEach -> new CompositeDisposable( @emitter.on("did-tokenize", callback), @onDidChangeGrammar => - if @buffer.getLanguageMode().tokenizeInBackground.originalValue + languageMode = @buffer.getLanguageMode() + if languageMode.tokenizeInBackground?.originalValue callback() ) diff --git a/spec/syntax-scope-map-spec.js b/spec/syntax-scope-map-spec.js new file mode 100644 index 00000000000..61b1bdc7d09 --- /dev/null +++ b/spec/syntax-scope-map-spec.js @@ -0,0 +1,77 @@ +const SyntaxScopeMap = require('../src/syntax-scope-map') + +describe('SyntaxScopeMap', () => { + it('can match immediate child selectors', () => { + const map = new SyntaxScopeMap({ + 'a > b > c': 'x', + 'b > c': 'y', + 'c': 'z' + }) + + expect(map.get(['a', 'b', 'c'], [0, 0, 0])).toBe('x') + expect(map.get(['d', 'b', 'c'], [0, 0, 0])).toBe('y') + expect(map.get(['d', 'e', 'c'], [0, 0, 0])).toBe('z') + expect(map.get(['e', 'c'], [0, 0, 0])).toBe('z') + expect(map.get(['c'], [0, 0, 0])).toBe('z') + expect(map.get(['d'], [0, 0, 0])).toBe(undefined) + }) + + it('can match :nth-child pseudo-selectors on leaves', () => { + const map = new SyntaxScopeMap({ + 'a > b': 'w', + 'a > b:nth-child(1)': 'x', + 'b': 'y', + 'b:nth-child(2)': 'z' + }) + + expect(map.get(['a', 'b'], [0, 0])).toBe('w') + expect(map.get(['a', 'b'], [0, 1])).toBe('x') + expect(map.get(['a', 'b'], [0, 2])).toBe('w') + expect(map.get(['b'], [0])).toBe('y') + expect(map.get(['b'], [1])).toBe('y') + expect(map.get(['b'], [2])).toBe('z') + }) + + it('can match :nth-child pseudo-selectors on interior nodes', () => { + const map = new SyntaxScopeMap({ + 'b:nth-child(1) > c': 'w', + 'a > b > c': 'x', + 'a > b:nth-child(2) > c': 'y' + }) + + expect(map.get(['b', 'c'], [0, 0])).toBe(undefined) + expect(map.get(['b', 'c'], [1, 0])).toBe('w') + expect(map.get(['a', 'b', 'c'], [1, 0, 0])).toBe('x') + expect(map.get(['a', 'b', 'c'], [1, 2, 0])).toBe('y') + }) + + it('allows anonymous tokens to be referred to by their string value', () => { + const map = new SyntaxScopeMap({ + '"b"': 'w', + 'a > "b"': 'x', + 'a > "b":nth-child(1)': 'y' + }) + + expect(map.get(['b'], [0], true)).toBe(undefined) + expect(map.get(['b'], [0], false)).toBe('w') + expect(map.get(['a', 'b'], [0, 0], false)).toBe('x') + expect(map.get(['a', 'b'], [0, 1], false)).toBe('y') + }) + + it('supports the wildcard selector', () => { + const map = new SyntaxScopeMap({ + '*': 'w', + 'a > *': 'x', + 'a > *:nth-child(1)': 'y', + 'a > *:nth-child(1) > b': 'z' + }) + + expect(map.get(['b'], [0])).toBe('w') + expect(map.get(['c'], [0])).toBe('w') + expect(map.get(['a', 'b'], [0, 0])).toBe('x') + expect(map.get(['a', 'b'], [0, 1])).toBe('y') + expect(map.get(['a', 'c'], [0, 1])).toBe('y') + expect(map.get(['a', 'c', 'b'], [0, 1, 1])).toBe('z') + expect(map.get(['a', 'c', 'b'], [0, 2, 1])).toBe('w') + }) +}) diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js index 0b888f47cef..97210dd413f 100644 --- a/spec/text-editor-component-spec.js +++ b/spec/text-editor-component-spec.js @@ -263,13 +263,13 @@ describe('TextEditorComponent', () => { it('keeps the number of tiles stable when the visible line count changes during vertical scrolling', async () => { const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false}) await setEditorHeightInLines(component, 5.5) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers await setScrollTop(component, 0.5 * component.getLineHeight()) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers await setScrollTop(component, 1 * component.getLineHeight()) - expect(component.refs.lineTiles.children.length).toBe(3 + 1) // account for cursors container + expect(component.refs.lineTiles.children.length).toBe(3 + 2) // account for cursors and highlights containers }) it('recycles tiles on resize', async () => { diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js index b7181fa91b3..7ffdf374de4 100644 --- a/spec/text-editor-element-spec.js +++ b/spec/text-editor-element-spec.js @@ -89,6 +89,22 @@ describe('TextEditorElement', () => { expect(element.getModel().getText()).toBe('testing') }) + describe('tabIndex', () => { + it('uses a default value of -1', () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.tabIndex).toBe(-1) + expect(element.querySelector('input').tabIndex).toBe(-1) + }) + + it('uses the custom value when given', () => { + jasmineContent.innerHTML = '' + const element = jasmineContent.firstChild + expect(element.tabIndex).toBe(-1) + expect(element.querySelector('input').tabIndex).toBe(42) + }) + }) + describe('when the model is assigned', () => it("adds the 'mini' attribute if .isMini() returns true on the model", async () => { const element = buildTextEditorElement() diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index 78daf510500..32883a01d71 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -2376,6 +2376,19 @@ describe('TextEditor', () => { ]) }) }) + + it('does not create a new selection if it would be fully contained within another selection', () => { + editor.setText('abc\ndef\nghi\njkl\nmno') + editor.setCursorBufferPosition([0, 1]) + + let addedSelectionCount = 0 + editor.onDidAddSelection(() => { addedSelectionCount++ }) + + editor.addSelectionBelow() + editor.addSelectionBelow() + editor.addSelectionBelow() + expect(addedSelectionCount).toBe(3) + }) }) describe('.addSelectionAbove()', () => { @@ -2498,6 +2511,19 @@ describe('TextEditor', () => { ]) }) }) + + it('does not create a new selection if it would be fully contained within another selection', () => { + editor.setText('abc\ndef\nghi\njkl\nmno') + editor.setCursorBufferPosition([4, 1]) + + let addedSelectionCount = 0 + editor.onDidAddSelection(() => { addedSelectionCount++ }) + + editor.addSelectionAbove() + editor.addSelectionAbove() + editor.addSelectionAbove() + expect(addedSelectionCount).toBe(3) + }) }) describe('.splitSelectionsIntoLines()', () => { @@ -5401,6 +5427,34 @@ describe('TextEditor', () => { expect(buffer.getLineCount()).toBe(count - 2) }) + it("restores cursor position for multiple cursors", () => { + const line = '0123456789'.repeat(8) + editor.setText((line + '\n').repeat(5)) + editor.setCursorScreenPosition([0, 5]) + editor.addCursorAtScreenPosition([2, 8]) + editor.deleteLine() + + const cursors = editor.getCursors() + expect(cursors.length).toBe(2) + expect(cursors[0].getScreenPosition()).toEqual([0, 5]) + expect(cursors[1].getScreenPosition()).toEqual([1, 8]) + }) + + it("restores cursor position for multiple selections", () => { + const line = '0123456789'.repeat(8) + editor.setText((line + '\n').repeat(5)) + editor.setSelectedBufferRanges([ + [[0, 5], [0, 8]], + [[2, 4], [2, 15]] + ]) + editor.deleteLine() + + const cursors = editor.getCursors() + expect(cursors.length).toBe(2) + expect(cursors[0].getScreenPosition()).toEqual([0, 5]) + expect(cursors[1].getScreenPosition()).toEqual([1, 4]) + }) + it('deletes a line only once when multiple selections are on the same line', () => { const line1 = buffer.lineForRow(1) const count = buffer.getLineCount() diff --git a/spec/tree-sitter-language-mode-spec.js b/spec/tree-sitter-language-mode-spec.js new file mode 100644 index 00000000000..ec38c1a067b --- /dev/null +++ b/spec/tree-sitter-language-mode-spec.js @@ -0,0 +1,560 @@ +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') + +const dedent = require('dedent') +const TextBuffer = require('text-buffer') +const {Point} = TextBuffer +const TextEditor = require('../src/text-editor') +const TreeSitterGrammar = require('../src/tree-sitter-grammar') +const TreeSitterLanguageMode = require('../src/tree-sitter-language-mode') + +const cGrammarPath = require.resolve('language-c/grammars/tree-sitter-c.cson') +const pythonGrammarPath = require.resolve('language-python/grammars/tree-sitter-python.cson') +const jsGrammarPath = require.resolve('language-javascript/grammars/tree-sitter-javascript.cson') + +describe('TreeSitterLanguageMode', () => { + let editor, buffer + + beforeEach(async () => { + editor = await atom.workspace.open('') + buffer = editor.getBuffer() + }) + + describe('highlighting', () => { + it('applies the most specific scope mapping to each node in the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'program': 'source', + 'call_expression > identifier': 'function', + 'property_identifier': 'property', + 'call_expression > member_expression > property_identifier': 'method' + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('aa.bbb = cc(d.eee());') + expectTokensToEqual(editor, [[ + {text: 'aa.', scopes: ['source']}, + {text: 'bbb', scopes: ['source', 'property']}, + {text: ' = ', scopes: ['source']}, + {text: 'cc', scopes: ['source', 'function']}, + {text: '(d.', scopes: ['source']}, + {text: 'eee', scopes: ['source', 'method']}, + {text: '());', scopes: ['source']} + ]]) + }) + + it('can start or end multiple scopes at the same position', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'program': 'source', + 'call_expression': 'call', + 'member_expression': 'member', + 'identifier': 'variable', + '"("': 'open-paren', + '")"': 'close-paren', + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('a = bb.ccc();') + expectTokensToEqual(editor, [[ + {text: 'a', scopes: ['source', 'variable']}, + {text: ' = ', scopes: ['source']}, + {text: 'bb', scopes: ['source', 'call', 'member', 'variable']}, + {text: '.ccc', scopes: ['source', 'call', 'member']}, + {text: '(', scopes: ['source', 'call', 'open-paren']}, + {text: ')', scopes: ['source', 'call', 'close-paren']}, + {text: ';', scopes: ['source']} + ]]) + }) + + it('can resume highlighting on a line that starts with whitespace', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: { + 'call_expression > member_expression > property_identifier': 'function', + 'property_identifier': 'member', + 'identifier': 'variable' + } + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText('a\n .b();') + expectTokensToEqual(editor, [ + [ + {text: 'a', scopes: ['variable']}, + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: '.', scopes: []}, + {text: 'b', scopes: ['function']}, + {text: '();', scopes: []} + ] + ]) + }) + + it('correctly skips over tokens with zero size', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-c', + scopes: { + 'primitive_type': 'type', + 'identifier': 'variable', + } + }) + + const languageMode = new TreeSitterLanguageMode({buffer, grammar}) + buffer.setLanguageMode(languageMode) + buffer.setText('int main() {\n int a\n int b;\n}'); + + editor.screenLineForScreenRow(0) + expect( + languageMode.document.rootNode.descendantForPosition(Point(1, 2), Point(1, 6)).toString() + ).toBe('(declaration (primitive_type) (identifier) (MISSING))') + + expectTokensToEqual(editor, [ + [ + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'main', scopes: ['variable']}, + {text: '() {', scopes: []} + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'a', scopes: ['variable']} + ], + [ + {text: ' ', scopes: ['whitespace']}, + {text: 'int', scopes: ['type']}, + {text: ' ', scopes: []}, + {text: 'b', scopes: ['variable']}, + {text: ';', scopes: []} + ], + [ + {text: '}', scopes: []} + ] + ]) + }) + }) + + describe('folding', () => { + beforeEach(() => { + editor.displayLayer.reset({foldCharacter: '…'}) + }) + + it('can fold nodes that start and end with specified tokens', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + { + start: {type: '{', index: 0}, + end: {type: '}', index: -1} + }, + { + start: {type: '(', index: 0}, + end: {type: ')', index: -1} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + module.exports = + class A { + getB (c, + d, + e) { + return this.f(g) + } + } + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(false) + expect(editor.isFoldableAtBufferRow(1)).toBe(true) + expect(editor.isFoldableAtBufferRow(2)).toBe(true) + expect(editor.isFoldableAtBufferRow(3)).toBe(false) + expect(editor.isFoldableAtBufferRow(4)).toBe(true) + expect(editor.isFoldableAtBufferRow(5)).toBe(false) + + editor.foldBufferRow(2) + expect(getDisplayText(editor)).toBe(dedent ` + module.exports = + class A { + getB (…) { + return this.f(g) + } + } + `) + + editor.foldBufferRow(4) + expect(getDisplayText(editor)).toBe(dedent ` + module.exports = + class A { + getB (…) {…} + } + `) + }) + + it('can fold nodes of specified types', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + // Start the fold after the first child (the opening tag) and end it at the last child + // (the closing tag). + { + type: 'jsx_element', + start: {index: 0}, + end: {index: -1} + }, + + // End the fold at the *second* to last child of the self-closing tag: the `/`. + { + type: 'jsx_self_closing_element', + start: {index: 1}, + end: {index: -2} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + const element1 = + + const element2 = + hello + world + + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(true) + expect(editor.isFoldableAtBufferRow(1)).toBe(false) + expect(editor.isFoldableAtBufferRow(2)).toBe(false) + expect(editor.isFoldableAtBufferRow(3)).toBe(false) + expect(editor.isFoldableAtBufferRow(4)).toBe(true) + expect(editor.isFoldableAtBufferRow(5)).toBe(false) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + const element1 = + + const element2 = + hello + world + + `) + + editor.foldBufferRow(4) + expect(getDisplayText(editor)).toBe(dedent ` + const element1 = + + const element2 = … + + `) + }) + + it('can fold entire nodes when no start or end parameters are specified', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + folds: [ + // By default, for a node with no children, folds are started at the *end* of the first + // line of a node, and ended at the *beginning* of the last line. + {type: 'comment'} + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + /** + * Important + */ + const x = 1 /* + Also important + */ + `) + + editor.screenLineForScreenRow(0) + + expect(editor.isFoldableAtBufferRow(0)).toBe(true) + expect(editor.isFoldableAtBufferRow(1)).toBe(false) + expect(editor.isFoldableAtBufferRow(2)).toBe(false) + expect(editor.isFoldableAtBufferRow(3)).toBe(true) + expect(editor.isFoldableAtBufferRow(4)).toBe(false) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + /**… */ + const x = 1 /* + Also important + */ + `) + + editor.foldBufferRow(3) + expect(getDisplayText(editor)).toBe(dedent ` + /**… */ + const x = 1 /*…*/ + `) + }) + + it('tries each folding strategy for a given node in the order specified', () => { + const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, { + parser: 'tree-sitter-c', + folds: [ + // If the #ifdef has an `#else` clause, then end the fold there. + { + type: ['preproc_ifdef', 'preproc_elif'], + start: {index: 1}, + end: {type: ['preproc_else', 'preproc_elif']} + }, + + // Otherwise, end the fold at the last child - the `#endif`. + { + type: 'preproc_ifdef', + start: {index: 1}, + end: {index: -1} + }, + + // When folding an `#else` clause, the fold extends to the end of the clause. + { + type: 'preproc_else', + start: {index: 0} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32 + + #include + const char *path_separator = "\\"; + + #elif defined MACOS + + #include + const char *path_separator = "/"; + + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.screenLineForScreenRow(0) + + editor.foldBufferRow(3) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS + + #include + const char *path_separator = "/"; + + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.foldBufferRow(8) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS… + #else + + #include + const char *path_separator = "/"; + + #endif + + #endif + `) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_… + #endif + `) + + editor.foldAllAtIndentLevel(1) + expect(getDisplayText(editor)).toBe(dedent ` + #ifndef FOO_H_ + #define FOO_H_ + + #ifdef _WIN32… + #elif defined MACOS… + #else… + + #endif + + #endif + `) + }) + + describe('when folding a node that ends with a line break', () => { + it('ends the fold at the end of the previous line', () => { + const grammar = new TreeSitterGrammar(atom.grammars, pythonGrammarPath, { + parser: 'tree-sitter-python', + folds: [ + { + type: 'function_definition', + start: {type: ':'} + } + ] + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText(dedent ` + def ab(): + print 'a' + print 'b' + + def cd(): + print 'c' + print 'd' + `) + + editor.screenLineForScreenRow(0) + + editor.foldBufferRow(0) + expect(getDisplayText(editor)).toBe(dedent ` + def ab():… + + def cd(): + print 'c' + print 'd' + `) + }) + }) + }) + + describe('.scopeDescriptorForPosition', () => { + it('returns a scope descriptor representing the given position in the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + id: 'javascript', + parser: 'tree-sitter-javascript' + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + + buffer.setText('foo({bar: baz});') + + editor.screenLineForScreenRow(0) + expect(editor.scopeDescriptorForBufferPosition({row: 0, column: 6}).getScopesArray()).toEqual([ + 'javascript', + 'program', + 'expression_statement', + 'call_expression', + 'arguments', + 'object', + 'pair', + 'property_identifier' + ]) + }) + }) + + describe('TextEditor.selectLargerSyntaxNode and .selectSmallerSyntaxNode', () => { + it('expands and contract the selection based on the syntax tree', () => { + const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { + parser: 'tree-sitter-javascript', + scopes: {'program': 'source'} + }) + + buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) + buffer.setText(dedent ` + function a (b, c, d) { + eee.f() + g() + } + `) + + editor.screenLineForScreenRow(0) + + editor.setCursorBufferPosition([1, 3]) + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f()') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') + editor.selectLargerSyntaxNode() + expect(editor.getSelectedText()).toBe('function a (b, c, d) {\n eee.f()\n g()\n}') + + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f()') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee.f') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedText()).toBe('eee') + editor.selectSmallerSyntaxNode() + expect(editor.getSelectedBufferRange()).toEqual([[1, 3], [1, 3]]) + }) + }) +}) + +function getDisplayText (editor) { + return editor.displayLayer.getText() +} + +function expectTokensToEqual (editor, expectedTokenLines) { + const lastRow = editor.getLastScreenRow() + + // Assert that the correct tokens are returned regardless of which row + // the highlighting iterator starts on. + for (let startRow = 0; startRow <= lastRow; startRow++) { + editor.displayLayer.clearSpatialIndex() + editor.displayLayer.getScreenLines(startRow, Infinity) + + const tokenLines = [] + for (let row = startRow; row <= lastRow; row++) { + tokenLines[row] = editor.tokensForScreenRow(row).map(({text, scopes}) => ({ + text, + scopes: scopes.map(scope => scope + .split(' ') + .map(className => className.slice('syntax--'.length)) + .join(' ')) + })) + } + + for (let row = startRow; row <= lastRow; row++) { + const tokenLine = tokenLines[row] + const expectedTokenLine = expectedTokenLines[row] + + expect(tokenLine.length).toEqual(expectedTokenLine.length) + for (let i = 0; i < tokenLine.length; i++) { + expect(tokenLine[i]).toEqual(expectedTokenLine[i], `Token ${i}, startRow: ${startRow}`) + } + } + } +} diff --git a/spec/window-event-handler-spec.js b/spec/window-event-handler-spec.js index a03e168fabe..2891aa2db2d 100644 --- a/spec/window-event-handler-spec.js +++ b/spec/window-event-handler-spec.js @@ -44,7 +44,15 @@ describe('WindowEventHandler', () => { }) ) }) - + + describe('resize event', () => + it('calls storeWindowDimensions', () => { + spyOn(atom, 'storeWindowDimensions') + window.dispatchEvent(new CustomEvent('resize')) + expect(atom.storeWindowDimensions).toHaveBeenCalled() + }) + ) + describe('window:close event', () => it('closes the window', () => { spyOn(atom, 'close') diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index 6bc3199bac4..4b115e594e8 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -10,7 +10,7 @@ const _ = require('underscore-plus') const fstream = require('fstream') const fs = require('fs-plus') const AtomEnvironment = require('../src/atom-environment') -const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const {it, fit, ffit, fffit, beforeEach, afterEach, conditionPromise} = require('./async-spec-helpers') describe('Workspace', () => { let workspace @@ -659,47 +659,42 @@ describe('Workspace', () => { }) }) - describe('when the file is over user-defined limit', () => { - const shouldPromptForFileOfSize = (size, shouldPrompt) => { + describe('when the file size is over the limit defined in `core.warnOnLargeFileLimit`', () => { + const shouldPromptForFileOfSize = async (size, shouldPrompt) => { spyOn(fs, 'getSizeSync').andReturn(size * 1048577) - atom.applicationDelegate.confirm.andCallFake(() => selectedButtonIndex) - atom.applicationDelegate.confirm() - var selectedButtonIndex = 1 // cancel - let editor = null - waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + let selectedButtonIndex = 1 // cancel + atom.applicationDelegate.confirm.andCallFake((options, callback) => callback(selectedButtonIndex)) + + let editor = await workspace.open('sample.js') if (shouldPrompt) { - runs(() => { - expect(editor).toBeUndefined() - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() + expect(editor).toBeUndefined() + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - atom.applicationDelegate.confirm.reset() - selectedButtonIndex = 0 - }) // open the file + atom.applicationDelegate.confirm.reset() + selectedButtonIndex = 0 // open the file - waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + editor = await workspace.open('sample.js') - runs(() => { - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - }) + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() } else { - runs(() => expect(editor).not.toBeUndefined()) + expect(editor).not.toBeUndefined() } } - it('prompts the user to make sure they want to open a file this big', () => { + it('prompts before opening the file', async () => { atom.config.set('core.warnOnLargeFileLimit', 20) - shouldPromptForFileOfSize(20, true) + await shouldPromptForFileOfSize(20, true) }) - it("doesn't prompt on files below the limit", () => { + it("doesn't prompt on files below the limit", async () => { atom.config.set('core.warnOnLargeFileLimit', 30) - shouldPromptForFileOfSize(20, false) + await shouldPromptForFileOfSize(20, false) }) - it('prompts for smaller files with a lower limit', () => { + it('prompts for smaller files with a lower limit', async () => { atom.config.set('core.warnOnLargeFileLimit', 5) - shouldPromptForFileOfSize(10, true) + await shouldPromptForFileOfSize(10, true) }) }) @@ -2809,29 +2804,30 @@ describe('Workspace', () => { describe('.checkoutHeadRevision()', () => { let editor = null - beforeEach(() => { + beforeEach(async () => { + jasmine.useRealClock() atom.config.set('editor.confirmCheckoutHeadRevision', false) - waitsForPromise(() => atom.workspace.open('sample-with-comments.js').then(o => { editor = o })) + editor = await atom.workspace.open('sample-with-comments.js') }) - it('reverts to the version of its file checked into the project repository', () => { + it('reverts to the version of its file checked into the project repository', async () => { editor.setCursorBufferPosition([0, 0]) editor.insertText('---\n') expect(editor.lineTextForBufferRow(0)).toBe('---') - waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + atom.workspace.checkoutHeadRevision(editor) - runs(() => expect(editor.lineTextForBufferRow(0)).toBe('')) + await conditionPromise(() => editor.lineTextForBufferRow(0) === '') }) describe("when there's no repository for the editor's file", () => { - it("doesn't do anything", () => { + it("doesn't do anything", async () => { editor = new TextEditor() editor.setText('stuff') atom.workspace.checkoutHeadRevision(editor) - waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + atom.workspace.checkoutHeadRevision(editor) }) }) }) diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee deleted file mode 100644 index 70b0f91bcde..00000000000 --- a/src/application-delegate.coffee +++ /dev/null @@ -1,293 +0,0 @@ -{ipcRenderer, remote, shell} = require 'electron' -ipcHelpers = require './ipc-helpers' -{Disposable} = require 'event-kit' -getWindowLoadSettings = require './get-window-load-settings' - -module.exports = -class ApplicationDelegate - getWindowLoadSettings: -> getWindowLoadSettings() - - open: (params) -> - ipcRenderer.send('open', params) - - pickFolder: (callback) -> - responseChannel = "atom-pick-folder-response" - ipcRenderer.on responseChannel, (event, path) -> - ipcRenderer.removeAllListeners(responseChannel) - callback(path) - ipcRenderer.send("pick-folder", responseChannel) - - getCurrentWindow: -> - remote.getCurrentWindow() - - closeWindow: -> - ipcHelpers.call('window-method', 'close') - - getTemporaryWindowState: -> - ipcHelpers.call('get-temporary-window-state').then (stateJSON) -> JSON.parse(stateJSON) - - setTemporaryWindowState: (state) -> - ipcHelpers.call('set-temporary-window-state', JSON.stringify(state)) - - getWindowSize: -> - [width, height] = remote.getCurrentWindow().getSize() - {width, height} - - setWindowSize: (width, height) -> - ipcHelpers.call('set-window-size', width, height) - - getWindowPosition: -> - [x, y] = remote.getCurrentWindow().getPosition() - {x, y} - - setWindowPosition: (x, y) -> - ipcHelpers.call('set-window-position', x, y) - - centerWindow: -> - ipcHelpers.call('center-window') - - focusWindow: -> - ipcHelpers.call('focus-window') - - showWindow: -> - ipcHelpers.call('show-window') - - hideWindow: -> - ipcHelpers.call('hide-window') - - reloadWindow: -> - ipcHelpers.call('window-method', 'reload') - - restartApplication: -> - ipcRenderer.send("restart-application") - - minimizeWindow: -> - ipcHelpers.call('window-method', 'minimize') - - isWindowMaximized: -> - remote.getCurrentWindow().isMaximized() - - maximizeWindow: -> - ipcHelpers.call('window-method', 'maximize') - - unmaximizeWindow: -> - ipcHelpers.call('window-method', 'unmaximize') - - isWindowFullScreen: -> - remote.getCurrentWindow().isFullScreen() - - setWindowFullScreen: (fullScreen=false) -> - ipcHelpers.call('window-method', 'setFullScreen', fullScreen) - - onDidEnterFullScreen: (callback) -> - ipcHelpers.on(ipcRenderer, 'did-enter-full-screen', callback) - - onDidLeaveFullScreen: (callback) -> - ipcHelpers.on(ipcRenderer, 'did-leave-full-screen', callback) - - openWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'openDevTools')) - - closeWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'closeDevTools')) - - toggleWindowDevTools: -> - # Defer DevTools interaction to the next tick, because using them during - # event handling causes some wrong input events to be triggered on - # `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). - new Promise(process.nextTick).then(-> ipcHelpers.call('window-method', 'toggleDevTools')) - - executeJavaScriptInWindowDevTools: (code) -> - ipcRenderer.send("execute-javascript-in-dev-tools", code) - - setWindowDocumentEdited: (edited) -> - ipcHelpers.call('window-method', 'setDocumentEdited', edited) - - setRepresentedFilename: (filename) -> - ipcHelpers.call('window-method', 'setRepresentedFilename', filename) - - addRecentDocument: (filename) -> - ipcRenderer.send("add-recent-document", filename) - - setRepresentedDirectoryPaths: (paths) -> - ipcHelpers.call('window-method', 'setRepresentedDirectoryPaths', paths) - - setAutoHideWindowMenuBar: (autoHide) -> - ipcHelpers.call('window-method', 'setAutoHideMenuBar', autoHide) - - setWindowMenuBarVisibility: (visible) -> - remote.getCurrentWindow().setMenuBarVisibility(visible) - - getPrimaryDisplayWorkAreaSize: -> - remote.screen.getPrimaryDisplay().workAreaSize - - getUserDefault: (key, type) -> - remote.systemPreferences.getUserDefault(key, type) - - confirm: ({message, detailedMessage, buttons}) -> - buttons ?= {} - if Array.isArray(buttons) - buttonLabels = buttons - else - buttonLabels = Object.keys(buttons) - - chosen = remote.dialog.showMessageBox(remote.getCurrentWindow(), { - type: 'info' - message: message - detail: detailedMessage - buttons: buttonLabels - normalizeAccessKeys: true - }) - - if Array.isArray(buttons) - chosen - else - callback = buttons[buttonLabels[chosen]] - callback?() - - showMessageDialog: (params) -> - - showSaveDialog: (params) -> - if typeof params is 'string' - params = {defaultPath: params} - @getCurrentWindow().showSaveDialog(params) - - playBeepSound: -> - shell.beep() - - onDidOpenLocations: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'open-locations' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateAvailable: (callback) -> - outerCallback = (event, message, detail) -> - # TODO: Yes, this is strange that `onUpdateAvailable` is listening for - # `did-begin-downloading-update`. We currently have no mechanism to know - # if there is an update, so begin of downloading is a good proxy. - callback(detail) if message is 'did-begin-downloading-update' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onDidBeginDownloadingUpdate: (callback) -> - @onUpdateAvailable(callback) - - onDidBeginCheckingForUpdate: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'checking-for-update' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onDidCompleteDownloadingUpdate: (callback) -> - outerCallback = (event, message, detail) -> - # TODO: We could rename this event to `did-complete-downloading-update` - callback(detail) if message is 'update-available' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateNotAvailable: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'update-not-available' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onUpdateError: (callback) -> - outerCallback = (event, message, detail) -> - callback(detail) if message is 'update-error' - - ipcRenderer.on('message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('message', outerCallback) - - onApplicationMenuCommand: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('command', outerCallback) - new Disposable -> - ipcRenderer.removeListener('command', outerCallback) - - onContextMenuCommand: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('context-command', outerCallback) - new Disposable -> - ipcRenderer.removeListener('context-command', outerCallback) - - onURIMessage: (callback) -> - outerCallback = (event, args...) -> - callback(args...) - - ipcRenderer.on('uri-message', outerCallback) - new Disposable -> - ipcRenderer.removeListener('uri-message', outerCallback) - - onDidRequestUnload: (callback) -> - outerCallback = (event, message) -> - callback(event).then (shouldUnload) -> - ipcRenderer.send('did-prepare-to-unload', shouldUnload) - - ipcRenderer.on('prepare-to-unload', outerCallback) - new Disposable -> - ipcRenderer.removeListener('prepare-to-unload', outerCallback) - - onDidChangeHistoryManager: (callback) -> - outerCallback = (event, message) -> - callback(event) - - ipcRenderer.on('did-change-history-manager', outerCallback) - new Disposable -> - ipcRenderer.removeListener('did-change-history-manager', outerCallback) - - didChangeHistoryManager: -> - ipcRenderer.send('did-change-history-manager') - - openExternal: (url) -> - shell.openExternal(url) - - checkForUpdate: -> - ipcRenderer.send('command', 'application:check-for-update') - - restartAndInstallUpdate: -> - ipcRenderer.send('command', 'application:install-update') - - getAutoUpdateManagerState: -> - ipcRenderer.sendSync('get-auto-update-manager-state') - - getAutoUpdateManagerErrorMessage: -> - ipcRenderer.sendSync('get-auto-update-manager-error') - - emitWillSavePath: (path) -> - ipcRenderer.sendSync('will-save-path', path) - - emitDidSavePath: (path) -> - ipcRenderer.sendSync('did-save-path', path) - - resolveProxy: (requestId, url) -> - ipcRenderer.send('resolve-proxy', requestId, url) - - onDidResolveProxy: (callback) -> - outerCallback = (event, requestId, proxy) -> - callback(requestId, proxy) - - ipcRenderer.on('did-resolve-proxy', outerCallback) - new Disposable -> - ipcRenderer.removeListener('did-resolve-proxy', outerCallback) diff --git a/src/application-delegate.js b/src/application-delegate.js new file mode 100644 index 00000000000..a6d7010780a --- /dev/null +++ b/src/application-delegate.js @@ -0,0 +1,374 @@ +const {ipcRenderer, remote, shell} = require('electron') +const ipcHelpers = require('./ipc-helpers') +const {Disposable} = require('event-kit') +const getWindowLoadSettings = require('./get-window-load-settings') + +module.exports = +class ApplicationDelegate { + getWindowLoadSettings () { return getWindowLoadSettings() } + + open (params) { + return ipcRenderer.send('open', params) + } + + pickFolder (callback) { + const responseChannel = 'atom-pick-folder-response' + ipcRenderer.on(responseChannel, function (event, path) { + ipcRenderer.removeAllListeners(responseChannel) + return callback(path) + }) + return ipcRenderer.send('pick-folder', responseChannel) + } + + getCurrentWindow () { + return remote.getCurrentWindow() + } + + closeWindow () { + return ipcHelpers.call('window-method', 'close') + } + + async getTemporaryWindowState () { + const stateJSON = await ipcHelpers.call('get-temporary-window-state') + return JSON.parse(stateJSON) + } + + setTemporaryWindowState (state) { + return ipcHelpers.call('set-temporary-window-state', JSON.stringify(state)) + } + + getWindowSize () { + const [width, height] = Array.from(remote.getCurrentWindow().getSize()) + return {width, height} + } + + setWindowSize (width, height) { + return ipcHelpers.call('set-window-size', width, height) + } + + getWindowPosition () { + const [x, y] = Array.from(remote.getCurrentWindow().getPosition()) + return {x, y} + } + + setWindowPosition (x, y) { + return ipcHelpers.call('set-window-position', x, y) + } + + centerWindow () { + return ipcHelpers.call('center-window') + } + + focusWindow () { + return ipcHelpers.call('focus-window') + } + + showWindow () { + return ipcHelpers.call('show-window') + } + + hideWindow () { + return ipcHelpers.call('hide-window') + } + + reloadWindow () { + return ipcHelpers.call('window-method', 'reload') + } + + restartApplication () { + return ipcRenderer.send('restart-application') + } + + minimizeWindow () { + return ipcHelpers.call('window-method', 'minimize') + } + + isWindowMaximized () { + return remote.getCurrentWindow().isMaximized() + } + + maximizeWindow () { + return ipcHelpers.call('window-method', 'maximize') + } + + unmaximizeWindow () { + return ipcHelpers.call('window-method', 'unmaximize') + } + + isWindowFullScreen () { + return remote.getCurrentWindow().isFullScreen() + } + + setWindowFullScreen (fullScreen = false) { + return ipcHelpers.call('window-method', 'setFullScreen', fullScreen) + } + + onDidEnterFullScreen (callback) { + return ipcHelpers.on(ipcRenderer, 'did-enter-full-screen', callback) + } + + onDidLeaveFullScreen (callback) { + return ipcHelpers.on(ipcRenderer, 'did-leave-full-screen', callback) + } + + async openWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'openDevTools') + } + + async closeWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'closeDevTools') + } + + async toggleWindowDevTools () { + // Defer DevTools interaction to the next tick, because using them during + // event handling causes some wrong input events to be triggered on + // `TextEditorComponent` (Ref.: https://github.com/atom/atom/issues/9697). + await new Promise(process.nextTick) + return ipcHelpers.call('window-method', 'toggleDevTools') + } + + executeJavaScriptInWindowDevTools (code) { + return ipcRenderer.send('execute-javascript-in-dev-tools', code) + } + + didClosePathWithWaitSession (path) { + return ipcHelpers.call('window-method', 'didClosePathWithWaitSession', path) + } + + setWindowDocumentEdited (edited) { + return ipcHelpers.call('window-method', 'setDocumentEdited', edited) + } + + setRepresentedFilename (filename) { + return ipcHelpers.call('window-method', 'setRepresentedFilename', filename) + } + + addRecentDocument (filename) { + return ipcRenderer.send('add-recent-document', filename) + } + + setRepresentedDirectoryPaths (paths) { + return ipcHelpers.call('window-method', 'setRepresentedDirectoryPaths', paths) + } + + setAutoHideWindowMenuBar (autoHide) { + return ipcHelpers.call('window-method', 'setAutoHideMenuBar', autoHide) + } + + setWindowMenuBarVisibility (visible) { + return remote.getCurrentWindow().setMenuBarVisibility(visible) + } + + getPrimaryDisplayWorkAreaSize () { + return remote.screen.getPrimaryDisplay().workAreaSize + } + + getUserDefault (key, type) { + return remote.systemPreferences.getUserDefault(key, type) + } + + confirm (options, callback) { + if (typeof callback === 'function') { + // Async version: pass options directly to Electron but set sane defaults + options = Object.assign({type: 'info', normalizeAccessKeys: true}, options) + remote.dialog.showMessageBox(remote.getCurrentWindow(), options, callback) + } else { + // Legacy sync version: options can only have `message`, + // `detailedMessage` (optional), and buttons array or object (optional) + let {message, detailedMessage, buttons} = options + + let buttonLabels + if (!buttons) buttons = {} + if (Array.isArray(buttons)) { + buttonLabels = buttons + } else { + buttonLabels = Object.keys(buttons) + } + + const chosen = remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message, + detail: detailedMessage, + buttons: buttonLabels, + normalizeAccessKeys: true + }) + + if (Array.isArray(buttons)) { + return chosen + } else { + const callback = buttons[buttonLabels[chosen]] + if (typeof callback === 'function') callback() + } + } + } + + showMessageDialog (params) {} + + showSaveDialog (options, callback) { + if (typeof callback === 'function') { + // Async + this.getCurrentWindow().showSaveDialog(options, callback) + } else { + // Sync + if (typeof params === 'string') { + options = {defaultPath: options} + } + return this.getCurrentWindow().showSaveDialog(options) + } + } + + playBeepSound () { + return shell.beep() + } + + onDidOpenLocations (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'open-locations') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateAvailable (callback) { + const outerCallback = (event, message, detail) => { + // TODO: Yes, this is strange that `onUpdateAvailable` is listening for + // `did-begin-downloading-update`. We currently have no mechanism to know + // if there is an update, so begin of downloading is a good proxy. + if (message === 'did-begin-downloading-update') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onDidBeginDownloadingUpdate (callback) { + return this.onUpdateAvailable(callback) + } + + onDidBeginCheckingForUpdate (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'checking-for-update') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onDidCompleteDownloadingUpdate (callback) { + const outerCallback = (event, message, detail) => { + // TODO: We could rename this event to `did-complete-downloading-update` + if (message === 'update-available') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateNotAvailable (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'update-not-available') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onUpdateError (callback) { + const outerCallback = (event, message, detail) => { + if (message === 'update-error') callback(detail) + } + + ipcRenderer.on('message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('message', outerCallback)) + } + + onApplicationMenuCommand (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('command', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('command', outerCallback)) + } + + onContextMenuCommand (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('context-command', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('context-command', outerCallback)) + } + + onURIMessage (handler) { + const outerCallback = (event, ...args) => handler(...args) + + ipcRenderer.on('uri-message', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('uri-message', outerCallback)) + } + + onDidRequestUnload (callback) { + const outerCallback = async (event, message) => { + const shouldUnload = await callback(event) + ipcRenderer.send('did-prepare-to-unload', shouldUnload) + } + + ipcRenderer.on('prepare-to-unload', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('prepare-to-unload', outerCallback)) + } + + onDidChangeHistoryManager (callback) { + const outerCallback = (event, message) => callback(event) + + ipcRenderer.on('did-change-history-manager', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('did-change-history-manager', outerCallback)) + } + + didChangeHistoryManager () { + return ipcRenderer.send('did-change-history-manager') + } + + openExternal (url) { + return shell.openExternal(url) + } + + checkForUpdate () { + return ipcRenderer.send('command', 'application:check-for-update') + } + + restartAndInstallUpdate () { + return ipcRenderer.send('command', 'application:install-update') + } + + getAutoUpdateManagerState () { + return ipcRenderer.sendSync('get-auto-update-manager-state') + } + + getAutoUpdateManagerErrorMessage () { + return ipcRenderer.sendSync('get-auto-update-manager-error') + } + + emitWillSavePath (path) { + return ipcRenderer.sendSync('will-save-path', path) + } + + emitDidSavePath (path) { + return ipcRenderer.sendSync('did-save-path', path) + } + + resolveProxy (requestId, url) { + return ipcRenderer.send('resolve-proxy', requestId, url) + } + + onDidResolveProxy (callback) { + const outerCallback = (event, requestId, proxy) => callback(requestId, proxy) + + ipcRenderer.on('did-resolve-proxy', outerCallback) + return new Disposable(() => ipcRenderer.removeListener('did-resolve-proxy', outerCallback)) + } +} diff --git a/src/atom-environment.js b/src/atom-environment.js index fc3201dfcef..1594645343a 100644 --- a/src/atom-environment.js +++ b/src/atom-environment.js @@ -70,6 +70,7 @@ class AtomEnvironment { this.loadTime = null this.emitter = new Emitter() this.disposables = new CompositeDisposable() + this.pathsWithWaitSessions = new Set() // Public: A {DeserializerManager} instance this.deserializers = new DeserializerManager(this) @@ -359,6 +360,7 @@ class AtomEnvironment { this.grammars.clear() this.textEditors.clear() this.views.clear() + this.pathsWithWaitSessions.clear() } destroy () { @@ -822,7 +824,22 @@ class AtomEnvironment { this.document.body.appendChild(this.workspace.getElement()) if (this.backgroundStylesheet) this.backgroundStylesheet.remove() - this.watchProjectPaths() + let previousProjectPaths = this.project.getPaths() + this.disposables.add(this.project.onDidChangePaths(newPaths => { + for (let path of previousProjectPaths) { + if (this.pathsWithWaitSessions.has(path) && !newPaths.includes(path)) { + this.applicationDelegate.didClosePathWithWaitSession(path) + } + } + previousProjectPaths = newPaths + this.applicationDelegate.setRepresentedDirectoryPaths(newPaths) + })) + this.disposables.add(this.workspace.onDidDestroyPaneItem(({item}) => { + const path = item.getPath && item.getPath() + if (this.pathsWithWaitSessions.has(path)) { + this.applicationDelegate.didClosePathWithWaitSession(path) + } + })) this.packages.activate() this.keymaps.loadUserKeymap() @@ -948,29 +965,63 @@ class AtomEnvironment { // Essential: A flexible way to open a dialog akin to an alert dialog. // + // While both async and sync versions are provided, it is recommended to use the async version + // such that the renderer process is not blocked while the dialog box is open. + // + // The async version accepts the same options as Electron's `dialog.showMessageBox`. + // For convenience, it sets `type` to `'info'` and `normalizeAccessKeys` to `true` by default. + // // If the dialog is closed (via `Esc` key or `X` in the top corner) without selecting a button // the first button will be clicked unless a "Cancel" or "No" button is provided. // // ## Examples // - // ```coffee - // atom.confirm - // message: 'How you feeling?' - // detailedMessage: 'Be honest.' - // buttons: - // Good: -> window.alert('good to hear') - // Bad: -> window.alert('bummer') + // ```js + // // Async version (recommended) + // atom.confirm({ + // message: 'How you feeling?', + // detail: 'Be honest.', + // buttons: ['Good', 'Bad'] + // }, response => { + // if (response === 0) { + // window.alert('good to hear') + // } else { + // window.alert('bummer') + // } + // }) + // + // ```js + // // Legacy sync version + // const chosen = atom.confirm({ + // message: 'How you feeling?', + // detailedMessage: 'Be honest.', + // buttons: { + // Good: () => window.alert('good to hear'), + // Bad: () => window.alert('bummer') + // } + // }) // ``` // - // * `options` An {Object} with the following keys: + // * `options` An options {Object}. If the callback argument is also supplied, see the documentation at + // https://electronjs.org/docs/api/dialog#dialogshowmessageboxbrowserwindow-options-callback for the list of + // available options. Otherwise, only the following keys are accepted: // * `message` The {String} message to display. // * `detailedMessage` (optional) The {String} detailed message to display. - // * `buttons` (optional) Either an array of strings or an object where keys are - // button names and the values are callbacks to invoke when clicked. + // * `buttons` (optional) Either an {Array} of {String}s or an {Object} where keys are + // button names and the values are callback {Function}s to invoke when clicked. + // * `callback` (optional) A {Function} that will be called with the index of the chosen option. + // If a callback is supplied, the dialog will be non-blocking. This argument is recommended. // - // Returns the chosen button index {Number} if the buttons option is an array or the return value of the callback if the buttons option is an object. - confirm (params = {}) { - return this.applicationDelegate.confirm(params) + // Returns the chosen button index {Number} if the buttons option is an array + // or the return value of the callback if the buttons option is an object. + // If a callback function is supplied, returns `undefined`. + confirm (options = {}, callback) { + if (callback) { + // Async: no return value + this.applicationDelegate.confirm(options, callback) + } else { + return this.applicationDelegate.confirm(options) + } } /* @@ -1025,13 +1076,6 @@ class AtomEnvironment { return this.themes.load() } - // Notify the browser project of the window's current project path - watchProjectPaths () { - this.disposables.add(this.project.onDidChangePaths(() => { - this.applicationDelegate.setRepresentedDirectoryPaths(this.project.getPaths()) - })) - } - setDocumentEdited (edited) { if (typeof this.applicationDelegate.setWindowDocumentEdited === 'function') { this.applicationDelegate.setWindowDocumentEdited(edited) @@ -1061,7 +1105,7 @@ class AtomEnvironment { } } - attemptRestoreProjectStateForPaths (state, projectPaths, filesToOpen = []) { + async attemptRestoreProjectStateForPaths (state, projectPaths, filesToOpen = []) { const center = this.workspace.getCenter() const windowIsUnused = () => { for (let container of this.workspace.getPaneContainers()) { @@ -1080,30 +1124,38 @@ class AtomEnvironment { this.restoreStateIntoThisEnvironment(state) return Promise.all(filesToOpen.map(file => this.workspace.open(file))) } else { + let resolveDiscardStatePromise = null + const discardStatePromise = new Promise((resolve) => { + resolveDiscardStatePromise = resolve + }) const nouns = projectPaths.length === 1 ? 'folder' : 'folders' - const choice = this.confirm({ + this.confirm({ message: 'Previous automatically-saved project state detected', - detailedMessage: `There is previously saved state for the selected ${nouns}. ` + + detail: `There is previously saved state for the selected ${nouns}. ` + `Would you like to add the ${nouns} to this window, permanently discarding the saved state, ` + `or open the ${nouns} in a new window, restoring the saved state?`, buttons: [ '&Open in new window and recover state', '&Add to this window and discard state' - ]}) - if (choice === 0) { - this.open({ - pathsToOpen: projectPaths.concat(filesToOpen), - newWindow: true, - devMode: this.inDevMode(), - safeMode: this.inSafeMode() - }) - return Promise.resolve(null) - } else if (choice === 1) { - for (let selectedPath of projectPaths) { - this.project.addPath(selectedPath) + ] + }, response => { + if (response === 0) { + this.open({ + pathsToOpen: projectPaths.concat(filesToOpen), + newWindow: true, + devMode: this.inDevMode(), + safeMode: this.inSafeMode() + }) + resolveDiscardStatePromise(Promise.resolve(null)) + } else if (response === 1) { + for (let selectedPath of projectPaths) { + this.project.addPath(selectedPath) + } + resolveDiscardStatePromise(Promise.all(filesToOpen.map(file => this.workspace.open(file)))) } - return Promise.all(filesToOpen.map(file => this.workspace.open(file))) - } + }) + + return discardStatePromise } } @@ -1115,12 +1167,11 @@ class AtomEnvironment { return this.deserialize(state) } - showSaveDialog (callback) { - callback(this.showSaveDialogSync()) - } - showSaveDialogSync (options = {}) { - this.applicationDelegate.showSaveDialog(options) + deprecate(`atom.showSaveDialogSync is deprecated and will be removed soon. +Please, implement ::saveAs and ::getSaveDialogOptions instead for pane items +or use Pane::saveItemAs for programmatic saving.`) + return this.applicationDelegate.showSaveDialog(options) } async saveState (options, storageKey) { @@ -1300,8 +1351,9 @@ class AtomEnvironment { } } - for (var {pathToOpen, initialLine, initialColumn, forceAddToWindow} of locations) { - if (pathToOpen && (needsProjectPaths || forceAddToWindow)) { + for (const location of locations) { + const {pathToOpen} = location + if (pathToOpen && (needsProjectPaths || location.forceAddToWindow)) { if (fs.existsSync(pathToOpen)) { pushFolderToOpen(this.project.getDirectoryForProjectPath(pathToOpen).getPath()) } else if (fs.existsSync(path.dirname(pathToOpen))) { @@ -1312,8 +1364,10 @@ class AtomEnvironment { } if (!fs.isDirectorySync(pathToOpen)) { - fileLocationsToOpen.push({pathToOpen, initialLine, initialColumn}) + fileLocationsToOpen.push(location) } + + if (location.hasWaitSession) this.pathsWithWaitSessions.add(pathToOpen) } let restoredState = false @@ -1334,7 +1388,7 @@ class AtomEnvironment { if (!restoredState) { const fileOpenPromises = [] - for ({pathToOpen, initialLine, initialColumn} of fileLocationsToOpen) { + for (const {pathToOpen, initialLine, initialColumn} of fileLocationsToOpen) { fileOpenPromises.push(this.workspace && this.workspace.open(pathToOpen, {initialLine, initialColumn})) } await Promise.all(fileOpenPromises) diff --git a/src/command-installer.js b/src/command-installer.js index 2c032d6c54f..85360da17ac 100644 --- a/src/command-installer.js +++ b/src/command-installer.js @@ -23,8 +23,8 @@ class CommandInstaller { const showErrorDialog = (error) => { this.applicationDelegate.confirm({ message: 'Failed to install shell commands', - detailedMessage: error.message - }) + detail: error.message + }, () => {}) } this.installAtomCommand(true, error => { @@ -33,8 +33,8 @@ class CommandInstaller { if (error) return showErrorDialog(error) this.applicationDelegate.confirm({ message: 'Commands installed.', - detailedMessage: 'The shell commands `atom` and `apm` are installed.' - }) + detail: 'The shell commands `atom` and `apm` are installed.' + }, () => {}) }) }) } diff --git a/src/command-registry.js b/src/command-registry.js index 9e6d8c2e194..e503691db58 100644 --- a/src/command-registry.js +++ b/src/command-registry.js @@ -309,7 +309,7 @@ module.exports = class CommandRegistry { handleCommandEvent (event) { let propagationStopped = false let immediatePropagationStopped = false - let matched = false + let matched = [] let currentTarget = event.target const dispatchedEvent = new CustomEvent(event.type, { @@ -373,10 +373,6 @@ module.exports = class CommandRegistry { listeners = selectorBasedListeners.concat(listeners) } - if (listeners.length > 0) { - matched = true - } - // Call inline listeners first in reverse registration order, // and selector-based listeners by specificity and reverse // registration order. @@ -385,7 +381,7 @@ module.exports = class CommandRegistry { if (immediatePropagationStopped) { break } - listener.didDispatch.call(currentTarget, dispatchedEvent) + matched.push(listener.didDispatch.call(currentTarget, dispatchedEvent)) } if (currentTarget === window) { @@ -399,7 +395,7 @@ module.exports = class CommandRegistry { this.emitter.emit('did-dispatch', dispatchedEvent) - return matched + return (matched.length > 0 ? Promise.all(matched) : null) } commandRegistered (commandName) { diff --git a/src/config-schema.js b/src/config-schema.js index 2ff68be8665..18dc3d77405 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -342,6 +342,11 @@ const configSchema = { description: 'Emulated with Atom events' } ] + }, + useTreeSitterParsers: { + type: 'boolean', + default: false, + description: 'Use the new Tree-sitter parsing system for supported languages' } } }, diff --git a/src/config.coffee b/src/config.coffee index b8bf8a76fa0..84e7267006d 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -423,6 +423,7 @@ class Config @configFileHasErrors = false @transactDepth = 0 @pendingOperations = [] + @legacyScopeAliases = {} @requestLoad = _.debounce => @loadUserConfig() @@ -599,11 +600,22 @@ class Config # * `value` The value for the key-path getAll: (keyPath, options) -> {scope} = options if options? - result = [] if scope? scopeDescriptor = ScopeDescriptor.fromObject(scope) - result = result.concat @scopedSettingsStore.getAll(scopeDescriptor.getScopeChain(), keyPath, options) + result = @scopedSettingsStore.getAll( + scopeDescriptor.getScopeChain(), + keyPath, + options + ) + if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor) + result.push(@scopedSettingsStore.getAll( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + )...) + else + result = [] if globalValue = @getRawValue(keyPath, options) result.push(scopeSelector: '*', value: globalValue) @@ -762,6 +774,12 @@ class Config finally @endTransaction() + addLegacyScopeAlias: (languageId, legacyScopeName) -> + @legacyScopeAliases[languageId] = legacyScopeName + + removeLegacyScopeAlias: (languageId) -> + delete @legacyScopeAliases[languageId] + ### Section: Internal methods used by core ### @@ -1145,7 +1163,20 @@ class Config getRawScopedValue: (scopeDescriptor, keyPath, options) -> scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor) - @scopedSettingsStore.getPropertyValue(scopeDescriptor.getScopeChain(), keyPath, options) + result = @scopedSettingsStore.getPropertyValue( + scopeDescriptor.getScopeChain(), + keyPath, + options + ) + + if result? + result + else if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor) + @scopedSettingsStore.getPropertyValue( + legacyScopeDescriptor.getScopeChain(), + keyPath, + options + ) observeScopedKeyPath: (scope, keyPath, callback) -> callback(@get(keyPath, {scope})) @@ -1160,6 +1191,13 @@ class Config oldValue = newValue callback(event) + getLegacyScopeDescriptor: (scopeDescriptor) -> + legacyAlias = @legacyScopeAliases[scopeDescriptor.scopes[0]] + if legacyAlias + scopes = scopeDescriptor.scopes.slice() + scopes[0] = legacyAlias + new ScopeDescriptor({scopes}) + # Base schema enforcers. These will coerce raw input into the specified type, # and will throw an error when the value cannot be coerced. Throwing the error # will indicate that the value should not be set. diff --git a/src/grammar-registry.js b/src/grammar-registry.js index db86958fda9..b316bdbb0cb 100644 --- a/src/grammar-registry.js +++ b/src/grammar-registry.js @@ -1,13 +1,16 @@ const _ = require('underscore-plus') const Grim = require('grim') +const CSON = require('season') const FirstMate = require('first-mate') const {Disposable, CompositeDisposable} = require('event-kit') const TextMateLanguageMode = require('./text-mate-language-mode') +const TreeSitterLanguageMode = require('./tree-sitter-language-mode') +const TreeSitterGrammar = require('./tree-sitter-grammar') const Token = require('./token') const fs = require('fs-plus') const {Point, Range} = require('text-buffer') -const GRAMMAR_SELECTION_RANGE = Range(Point.ZERO, Point(10, 0)).freeze() +const GRAMMAR_TYPE_BONUS = 1000 const PATH_SPLIT_REGEX = new RegExp('[/.]') // Extended: This class holds the grammars used for tokenizing. @@ -24,10 +27,13 @@ class GrammarRegistry { clear () { this.textmateRegistry.clear() + this.treeSitterGrammarsById = {} if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() this.languageOverridesByBufferId = new Map() this.grammarScoresByBuffer = new Map() + this.textMateScopeNamesByTreeSitterLanguageId = new Map() + this.treeSitterLanguageIdsByTextMateScopeName = new Map() const grammarAddedOrUpdated = this.grammarAddedOrUpdated.bind(this) this.textmateRegistry.onDidAddGrammar(grammarAddedOrUpdated) @@ -102,17 +108,18 @@ class GrammarRegistry { // Extended: Force a {TextBuffer} to use a different grammar than the // one that would otherwise be selected for it. // - // * `buffer` The {TextBuffer} whose gramamr will be set. + // * `buffer` The {TextBuffer} whose grammar will be set. // * `languageId` The {String} id of the desired language. // // Returns a {Boolean} that indicates whether the language was successfully // found. assignLanguageMode (buffer, languageId) { if (buffer.getBuffer) buffer = buffer.getBuffer() + languageId = this.normalizeLanguageId(languageId) let grammar = null if (languageId != null) { - grammar = this.textmateRegistry.grammarForScopeName(languageId) + grammar = this.grammarForId(languageId) if (!grammar) return false this.languageOverridesByBufferId.set(buffer.id, languageId) } else { @@ -136,7 +143,7 @@ class GrammarRegistry { autoAssignLanguageMode (buffer) { const result = this.selectGrammarWithScore( buffer.getPath(), - buffer.getTextInRange(GRAMMAR_SELECTION_RANGE) + getGrammarSelectionContent(buffer) ) this.languageOverridesByBufferId.delete(buffer.id) this.grammarScoresByBuffer.set(buffer, result.score) @@ -146,7 +153,11 @@ class GrammarRegistry { } languageModeForGrammarAndBuffer (grammar, buffer) { - return new TextMateLanguageMode({grammar, buffer, config: this.config}) + if (grammar instanceof TreeSitterGrammar) { + return new TreeSitterLanguageMode({grammar, buffer, config: this.config}) + } else { + return new TextMateLanguageMode({grammar, buffer, config: this.config}) + } } // Extended: Select a grammar for the given file path and file contents. @@ -165,39 +176,44 @@ class GrammarRegistry { selectGrammarWithScore (filePath, fileContents) { let bestMatch = null let highestScore = -Infinity - for (let grammar of this.textmateRegistry.grammars) { + this.forEachGrammar(grammar => { const score = this.getGrammarScore(grammar, filePath, fileContents) - if ((score > highestScore) || (bestMatch == null)) { + if (score > highestScore || bestMatch == null) { bestMatch = grammar highestScore = score } - } + }) return {grammar: bestMatch, score: highestScore} } // Extended: Returns a {Number} representing how well the grammar matches the // `filePath` and `contents`. getGrammarScore (grammar, filePath, contents) { - if ((contents == null) && fs.isFileSync(filePath)) { + if (contents == null && fs.isFileSync(filePath)) { contents = fs.readFileSync(filePath, 'utf8') } let score = this.getGrammarPathScore(grammar, filePath) - if ((score > 0) && !grammar.bundledPackage) { + if (score > 0 && !grammar.bundledPackage) { score += 0.125 } if (this.grammarMatchesContents(grammar, contents)) { score += 0.25 } + + if (score > 0 && this.isGrammarPreferredType(grammar)) { + score += GRAMMAR_TYPE_BONUS + } + return score } getGrammarPathScore (grammar, filePath) { - if (!filePath) { return -1 } + if (!filePath) return -1 if (process.platform === 'win32') { filePath = filePath.replace(/\\/g, '/') } const pathComponents = filePath.toLowerCase().split(PATH_SPLIT_REGEX) - let pathScore = -1 + let pathScore = 0 let customFileTypes if (this.config.get('core.customFileTypes')) { @@ -225,25 +241,48 @@ class GrammarRegistry { } grammarMatchesContents (grammar, contents) { - if ((contents == null) || (grammar.firstLineRegex == null)) { return false } - - let escaped = false - let numberOfNewlinesInRegex = 0 - for (let character of grammar.firstLineRegex.source) { - switch (character) { - case '\\': - escaped = !escaped - break - case 'n': - if (escaped) { numberOfNewlinesInRegex++ } - escaped = false - break - default: - escaped = false + if (contents == null) return false + + if (grammar.contentRegExp) { // TreeSitter grammars + return grammar.contentRegExp.test(contents) + } else if (grammar.firstLineRegex) { // FirstMate grammars + let escaped = false + let numberOfNewlinesInRegex = 0 + for (let character of grammar.firstLineRegex.source) { + switch (character) { + case '\\': + escaped = !escaped + break + case 'n': + if (escaped) { numberOfNewlinesInRegex++ } + escaped = false + break + default: + escaped = false + } } + + const lines = contents.split('\n') + return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n')) + } else { + return false } - const lines = contents.split('\n') - return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n')) + } + + forEachGrammar (callback) { + this.textmateRegistry.grammars.forEach(callback) + for (let grammarId in this.treeSitterGrammarsById) { + callback(this.treeSitterGrammarsById[grammarId]) + } + } + + grammarForId (languageId) { + languageId = this.normalizeLanguageId(languageId) + + return ( + this.textmateRegistry.grammarForScopeName(languageId) || + this.treeSitterGrammarsById[languageId] + ) } // Deprecated: Get the grammar override for the given file path. @@ -284,6 +323,8 @@ class GrammarRegistry { } grammarAddedOrUpdated (grammar) { + if (grammar.scopeName && !grammar.id) grammar.id = grammar.scopeName + this.grammarScoresByBuffer.forEach((score, buffer) => { const languageMode = buffer.getLanguageMode() if (grammar.injectionSelector) { @@ -295,16 +336,11 @@ class GrammarRegistry { const languageOverride = this.languageOverridesByBufferId.get(buffer.id) - if ((grammar.scopeName === buffer.getLanguageMode().getLanguageId() || - grammar.scopeName === languageOverride)) { + if ((grammar.id === buffer.getLanguageMode().getLanguageId() || + grammar.id === languageOverride)) { buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) } else if (!languageOverride) { - const score = this.getGrammarScore( - grammar, - buffer.getPath(), - buffer.getTextInRange(GRAMMAR_SELECTION_RANGE) - ) - + const score = this.getGrammarScore(grammar, buffer.getPath(), getGrammarSelectionContent(buffer)) const currentScore = this.grammarScoresByBuffer.get(buffer) if (currentScore == null || score > currentScore) { buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer)) @@ -348,15 +384,35 @@ class GrammarRegistry { } grammarForScopeName (scopeName) { - return this.textmateRegistry.grammarForScopeName(scopeName) + return this.grammarForId(scopeName) } addGrammar (grammar) { - return this.textmateRegistry.addGrammar(grammar) + if (grammar instanceof TreeSitterGrammar) { + this.treeSitterGrammarsById[grammar.id] = grammar + if (grammar.legacyScopeName) { + this.config.addLegacyScopeAlias(grammar.id, grammar.legacyScopeName) + this.textMateScopeNamesByTreeSitterLanguageId.set(grammar.id, grammar.legacyScopeName) + this.treeSitterLanguageIdsByTextMateScopeName.set(grammar.legacyScopeName, grammar.id) + } + this.grammarAddedOrUpdated(grammar) + return new Disposable(() => this.removeGrammar(grammar)) + } else { + return this.textmateRegistry.addGrammar(grammar) + } } removeGrammar (grammar) { - return this.textmateRegistry.removeGrammar(grammar) + if (grammar instanceof TreeSitterGrammar) { + delete this.treeSitterGrammarsById[grammar.id] + if (grammar.legacyScopeName) { + this.config.removeLegacyScopeAlias(grammar.id) + this.textMateScopeNamesByTreeSitterLanguageId.delete(grammar.id) + this.treeSitterLanguageIdsByTextMateScopeName.delete(grammar.legacyScopeName) + } + } else { + return this.textmateRegistry.removeGrammar(grammar) + } } removeGrammarForScopeName (scopeName) { @@ -370,7 +426,11 @@ class GrammarRegistry { // * `error` An {Error}, may be null. // * `grammar` A {Grammar} or null if an error occured. loadGrammar (grammarPath, callback) { - return this.textmateRegistry.loadGrammar(grammarPath, callback) + this.readGrammar(grammarPath, (error, grammar) => { + if (error) return callback(error) + this.addGrammar(grammar) + callback(grammar) + }) } // Extended: Read a grammar synchronously and add it to this registry. @@ -379,7 +439,9 @@ class GrammarRegistry { // // Returns a {Grammar}. loadGrammarSync (grammarPath) { - return this.textmateRegistry.loadGrammarSync(grammarPath) + const grammar = this.readGrammarSync(grammarPath) + this.addGrammar(grammar) + return grammar } // Extended: Read a grammar asynchronously but don't add it to the registry. @@ -391,7 +453,15 @@ class GrammarRegistry { // // Returns undefined. readGrammar (grammarPath, callback) { - return this.textmateRegistry.readGrammar(grammarPath, callback) + if (!callback) callback = () => {} + CSON.readFile(grammarPath, (error, params = {}) => { + if (error) return callback(error) + try { + callback(null, this.createGrammar(grammarPath, params)) + } catch (error) { + callback(error) + } + }) } // Extended: Read a grammar synchronously but don't add it to the registry. @@ -400,11 +470,18 @@ class GrammarRegistry { // // Returns a {Grammar}. readGrammarSync (grammarPath) { - return this.textmateRegistry.readGrammarSync(grammarPath) + return this.createGrammar(grammarPath, CSON.readFileSync(grammarPath) || {}) } createGrammar (grammarPath, params) { - return this.textmateRegistry.createGrammar(grammarPath, params) + if (params.type === 'tree-sitter') { + return new TreeSitterGrammar(this, grammarPath, params) + } else { + if (typeof params.scopeName !== 'string' || params.scopeName.length === 0) { + throw new Error(`Grammar missing required scopeName property: ${grammarPath}`) + } + return this.textmateRegistry.createGrammar(grammarPath, params) + } } // Extended: Get all the grammars in this registry. @@ -417,4 +494,25 @@ class GrammarRegistry { scopeForId (id) { return this.textmateRegistry.scopeForId(id) } + + isGrammarPreferredType (grammar) { + return this.config.get('core.useTreeSitterParsers') + ? grammar instanceof TreeSitterGrammar + : grammar instanceof FirstMate.Grammar + } + + normalizeLanguageId (languageId) { + if (this.config.get('core.useTreeSitterParsers')) { + return this.treeSitterLanguageIdsByTextMateScopeName.get(languageId) || languageId + } else { + return this.textMateScopeNamesByTreeSitterLanguageId.get(languageId) || languageId + } + } +} + +function getGrammarSelectionContent (buffer) { + return buffer.getTextInRange(Range( + Point(0, 0), + buffer.positionForCharacterIndex(1024) + )) } diff --git a/src/main-process/application-menu.coffee b/src/main-process/application-menu.coffee deleted file mode 100644 index 35bc7d66c77..00000000000 --- a/src/main-process/application-menu.coffee +++ /dev/null @@ -1,161 +0,0 @@ -{app, Menu} = require 'electron' -_ = require 'underscore-plus' -MenuHelpers = require '../menu-helpers' - -# Used to manage the global application menu. -# -# It's created by {AtomApplication} upon instantiation and used to add, remove -# and maintain the state of all menu items. -module.exports = -class ApplicationMenu - constructor: (@version, @autoUpdateManager) -> - @windowTemplates = new WeakMap() - @setActiveTemplate(@getDefaultTemplate()) - @autoUpdateManager.on 'state-changed', (state) => @showUpdateMenuItem(state) - - # Public: Updates the entire menu with the given keybindings. - # - # window - The BrowserWindow this menu template is associated with. - # template - The Object which describes the menu to display. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - update: (window, template, keystrokesByCommand) -> - @translateTemplate(template, keystrokesByCommand) - @substituteVersion(template) - @windowTemplates.set(window, template) - @setActiveTemplate(template) if window is @lastFocusedWindow - - setActiveTemplate: (template) -> - unless _.isEqual(template, @activeTemplate) - @activeTemplate = template - @menu = Menu.buildFromTemplate(_.deepClone(template)) - Menu.setApplicationMenu(@menu) - - @showUpdateMenuItem(@autoUpdateManager.getState()) - - # Register a BrowserWindow with this application menu. - addWindow: (window) -> - @lastFocusedWindow ?= window - - focusHandler = => - @lastFocusedWindow = window - if template = @windowTemplates.get(window) - @setActiveTemplate(template) - - window.on 'focus', focusHandler - window.once 'closed', => - @lastFocusedWindow = null if window is @lastFocusedWindow - @windowTemplates.delete(window) - window.removeListener 'focus', focusHandler - - @enableWindowSpecificItems(true) - - # Flattens the given menu and submenu items into an single Array. - # - # menu - A complete menu configuration object for atom-shell's menu API. - # - # Returns an Array of native menu items. - flattenMenuItems: (menu) -> - items = [] - for index, item of menu.items or {} - items.push(item) - items = items.concat(@flattenMenuItems(item.submenu)) if item.submenu - items - - # Flattens the given menu template into an single Array. - # - # template - An object describing the menu item. - # - # Returns an Array of native menu items. - flattenMenuTemplate: (template) -> - items = [] - for item in template - items.push(item) - items = items.concat(@flattenMenuTemplate(item.submenu)) if item.submenu - items - - # Public: Used to make all window related menu items are active. - # - # enable - If true enables all window specific items, if false disables all - # window specific items. - enableWindowSpecificItems: (enable) -> - for item in @flattenMenuItems(@menu) - item.enabled = enable if item.metadata?.windowSpecific - return - - # Replaces VERSION with the current version. - substituteVersion: (template) -> - if (item = _.find(@flattenMenuTemplate(template), ({label}) -> label is 'VERSION')) - item.label = "Version #{@version}" - - # Sets the proper visible state the update menu items - showUpdateMenuItem: (state) -> - checkForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Check for Update') - checkingForUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Checking for Update') - downloadingUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Downloading Update') - installUpdateItem = _.find(@flattenMenuItems(@menu), ({label}) -> label is 'Restart and Install Update') - - return unless checkForUpdateItem? and checkingForUpdateItem? and downloadingUpdateItem? and installUpdateItem? - - checkForUpdateItem.visible = false - checkingForUpdateItem.visible = false - downloadingUpdateItem.visible = false - installUpdateItem.visible = false - - switch state - when 'idle', 'error', 'no-update-available' - checkForUpdateItem.visible = true - when 'checking' - checkingForUpdateItem.visible = true - when 'downloading' - downloadingUpdateItem.visible = true - when 'update-available' - installUpdateItem.visible = true - - # Default list of menu items. - # - # Returns an Array of menu item Objects. - getDefaultTemplate: -> - [ - label: "Atom" - submenu: [ - {label: "Check for Update", metadata: {autoUpdate: true}} - {label: 'Reload', accelerator: 'Command+R', click: => @focusedWindow()?.reload()} - {label: 'Close Window', accelerator: 'Command+Shift+W', click: => @focusedWindow()?.close()} - {label: 'Toggle Dev Tools', accelerator: 'Command+Alt+I', click: => @focusedWindow()?.toggleDevTools()} - {label: 'Quit', accelerator: 'Command+Q', click: -> app.quit()} - ] - ] - - focusedWindow: -> - _.find global.atomApplication.getAllWindows(), (atomWindow) -> atomWindow.isFocused() - - # Combines a menu template with the appropriate keystroke. - # - # template - An Object conforming to atom-shell's menu api but lacking - # accelerator and click properties. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - # - # Returns a complete menu configuration object for atom-shell's menu API. - translateTemplate: (template, keystrokesByCommand) -> - template.forEach (item) => - item.metadata ?= {} - if item.command - item.accelerator = @acceleratorForCommand(item.command, keystrokesByCommand) - item.click = -> global.atomApplication.sendCommand(item.command, item.commandDetail) - item.metadata.windowSpecific = true unless /^application:/.test(item.command, item.commandDetail) - @translateTemplate(item.submenu, keystrokesByCommand) if item.submenu - template - - # Determine the accelerator for a given command. - # - # command - The name of the command. - # keystrokesByCommand - An Object where the keys are commands and the values - # are Arrays containing the keystroke. - # - # Returns a String containing the keystroke in a format that can be interpreted - # by Electron to provide nice icons where available. - acceleratorForCommand: (command, keystrokesByCommand) -> - firstKeystroke = keystrokesByCommand[command]?[0] - MenuHelpers.acceleratorForKeystroke(firstKeystroke) diff --git a/src/main-process/application-menu.js b/src/main-process/application-menu.js new file mode 100644 index 00000000000..26dcd19419c --- /dev/null +++ b/src/main-process/application-menu.js @@ -0,0 +1,225 @@ +const {app, Menu} = require('electron') +const _ = require('underscore-plus') +const MenuHelpers = require('../menu-helpers') + +// Used to manage the global application menu. +// +// It's created by {AtomApplication} upon instantiation and used to add, remove +// and maintain the state of all menu items. +module.exports = +class ApplicationMenu { + constructor (version, autoUpdateManager) { + this.version = version + this.autoUpdateManager = autoUpdateManager + this.windowTemplates = new WeakMap() + this.setActiveTemplate(this.getDefaultTemplate()) + this.autoUpdateManager.on('state-changed', state => this.showUpdateMenuItem(state)) + } + + // Public: Updates the entire menu with the given keybindings. + // + // window - The BrowserWindow this menu template is associated with. + // template - The Object which describes the menu to display. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + update (window, template, keystrokesByCommand) { + this.translateTemplate(template, keystrokesByCommand) + this.substituteVersion(template) + this.windowTemplates.set(window, template) + if (window === this.lastFocusedWindow) return this.setActiveTemplate(template) + } + + setActiveTemplate (template) { + if (!_.isEqual(template, this.activeTemplate)) { + this.activeTemplate = template + this.menu = Menu.buildFromTemplate(_.deepClone(template)) + Menu.setApplicationMenu(this.menu) + } + + return this.showUpdateMenuItem(this.autoUpdateManager.getState()) + } + + // Register a BrowserWindow with this application menu. + addWindow (window) { + if (this.lastFocusedWindow == null) this.lastFocusedWindow = window + + const focusHandler = () => { + this.lastFocusedWindow = window + const template = this.windowTemplates.get(window) + if (template) this.setActiveTemplate(template) + } + + window.on('focus', focusHandler) + window.once('closed', () => { + if (window === this.lastFocusedWindow) this.lastFocusedWindow = null + this.windowTemplates.delete(window) + window.removeListener('focus', focusHandler) + }) + + this.enableWindowSpecificItems(true) + } + + // Flattens the given menu and submenu items into an single Array. + // + // menu - A complete menu configuration object for atom-shell's menu API. + // + // Returns an Array of native menu items. + flattenMenuItems (menu) { + const object = menu.items || {} + let items = [] + for (let index in object) { + const item = object[index] + items.push(item) + if (item.submenu) items = items.concat(this.flattenMenuItems(item.submenu)) + } + return items + } + + // Flattens the given menu template into an single Array. + // + // template - An object describing the menu item. + // + // Returns an Array of native menu items. + flattenMenuTemplate (template) { + let items = [] + for (let item of template) { + items.push(item) + if (item.submenu) items = items.concat(this.flattenMenuTemplate(item.submenu)) + } + return items + } + + // Public: Used to make all window related menu items are active. + // + // enable - If true enables all window specific items, if false disables all + // window specific items. + enableWindowSpecificItems (enable) { + for (let item of this.flattenMenuItems(this.menu)) { + if (item.metadata && item.metadata.windowSpecific) item.enabled = enable + } + } + + // Replaces VERSION with the current version. + substituteVersion (template) { + let item = this.flattenMenuTemplate(template).find(({label}) => label === 'VERSION') + if (item) item.label = `Version ${this.version}` + } + + // Sets the proper visible state the update menu items + showUpdateMenuItem (state) { + const items = this.flattenMenuItems(this.menu) + const checkForUpdateItem = items.find(({label}) => label === 'Check for Update') + const checkingForUpdateItem = items.find(({label}) => label === 'Checking for Update') + const downloadingUpdateItem = items.find(({label}) => label === 'Downloading Update') + const installUpdateItem = items.find(({label}) => label === 'Restart and Install Update') + + if (!checkForUpdateItem || !checkingForUpdateItem || + !downloadingUpdateItem || !installUpdateItem) return + + checkForUpdateItem.visible = false + checkingForUpdateItem.visible = false + downloadingUpdateItem.visible = false + installUpdateItem.visible = false + + switch (state) { + case 'idle': + case 'error': + case 'no-update-available': + checkForUpdateItem.visible = true + break + case 'checking': + checkingForUpdateItem.visible = true + break + case 'downloading': + downloadingUpdateItem.visible = true + break + case 'update-available': + installUpdateItem.visible = true + break + } + } + + // Default list of menu items. + // + // Returns an Array of menu item Objects. + getDefaultTemplate () { + return [{ + label: 'Atom', + submenu: [ + { + label: 'Check for Update', + metadata: {autoUpdate: true} + }, + { + label: 'Reload', + accelerator: 'Command+R', + click: () => { + const window = this.focusedWindow() + if (window) window.reload() + } + }, + { + label: 'Close Window', + accelerator: 'Command+Shift+W', + click: () => { + const window = this.focusedWindow() + if (window) window.close() + } + }, + { + label: 'Toggle Dev Tools', + accelerator: 'Command+Alt+I', + click: () => { + const window = this.focusedWindow() + if (window) window.toggleDevTools() + } + }, + { + label: 'Quit', + accelerator: 'Command+Q', + click: () => app.quit() + } + ] + }] + } + + focusedWindow () { + return global.atomApplication.getAllWindows().find(window => window.isFocused()) + } + + // Combines a menu template with the appropriate keystroke. + // + // template - An Object conforming to atom-shell's menu api but lacking + // accelerator and click properties. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + // + // Returns a complete menu configuration object for atom-shell's menu API. + translateTemplate (template, keystrokesByCommand) { + template.forEach(item => { + if (item.metadata == null) item.metadata = {} + if (item.command) { + item.accelerator = this.acceleratorForCommand(item.command, keystrokesByCommand) + item.click = () => global.atomApplication.sendCommand(item.command, item.commandDetail) + if (!/^application:/.test(item.command, item.commandDetail)) { + item.metadata.windowSpecific = true + } + } + if (item.submenu) this.translateTemplate(item.submenu, keystrokesByCommand) + }) + return template + } + + // Determine the accelerator for a given command. + // + // command - The name of the command. + // keystrokesByCommand - An Object where the keys are commands and the values + // are Arrays containing the keystroke. + // + // Returns a String containing the keystroke in a format that can be interpreted + // by Electron to provide nice icons where available. + acceleratorForCommand (command, keystrokesByCommand) { + const firstKeystroke = keystrokesByCommand[command] && keystrokesByCommand[command][0] + return MenuHelpers.acceleratorForKeystroke(firstKeystroke) + } +} diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee deleted file mode 100644 index 328da7fc55f..00000000000 --- a/src/main-process/atom-application.coffee +++ /dev/null @@ -1,925 +0,0 @@ -AtomWindow = require './atom-window' -ApplicationMenu = require './application-menu' -AtomProtocolHandler = require './atom-protocol-handler' -AutoUpdateManager = require './auto-update-manager' -StorageFolder = require '../storage-folder' -Config = require '../config' -FileRecoveryService = require './file-recovery-service' -ipcHelpers = require '../ipc-helpers' -{BrowserWindow, Menu, app, clipboard, dialog, ipcMain, shell, screen} = require 'electron' -{CompositeDisposable, Disposable} = require 'event-kit' -crypto = require 'crypto' -fs = require 'fs-plus' -path = require 'path' -os = require 'os' -net = require 'net' -url = require 'url' -{EventEmitter} = require 'events' -_ = require 'underscore-plus' -FindParentDir = null -Resolve = null -ConfigSchema = require '../config-schema' - -LocationSuffixRegExp = /(:\d+)(:\d+)?$/ - -# The application's singleton class. -# -# It's the entry point into the Atom application and maintains the global state -# of the application. -# -module.exports = -class AtomApplication - Object.assign @prototype, EventEmitter.prototype - - # Public: The entry point into the Atom application. - @open: (options) -> - unless options.socketPath? - username = if process.platform is 'win32' then process.env.USERNAME else process.env.USER - # Lowercasing the ATOM_HOME to make sure that we don't get multiple sockets - # on case-insensitive filesystems due to arbitrary case differences in paths. - atomHomeUnique = path.resolve(process.env.ATOM_HOME).toLowerCase() - hash = crypto.createHash('sha1').update(options.version).update('|').update(process.arch).update('|').update(username).update('|').update(atomHomeUnique) - # We only keep the first 12 characters of the hash as not to have excessively long - # socket file. Note that macOS/BSD limit the length of socket file paths (see #15081). - # The replace calls convert the digest into "URL and Filename Safe" encoding (see RFC 4648). - atomInstanceDigest = hash.digest('base64').substring(0, 12).replace(/\+/g, '-').replace(/\//g, '_') - if process.platform is 'win32' - options.socketPath = "\\\\.\\pipe\\atom-#{atomInstanceDigest}-sock" - else - options.socketPath = path.join(os.tmpdir(), "atom-#{atomInstanceDigest}.sock") - - # FIXME: Sometimes when socketPath doesn't exist, net.connect would strangely - # take a few seconds to trigger 'error' event, it could be a bug of node - # or atom-shell, before it's fixed we check the existence of socketPath to - # speedup startup. - if (process.platform isnt 'win32' and not fs.existsSync options.socketPath) or options.test or options.benchmark or options.benchmarkTest - new AtomApplication(options).initialize(options) - return - - client = net.connect {path: options.socketPath}, -> - client.write JSON.stringify(options), -> - client.end() - app.quit() - - client.on 'error', -> new AtomApplication(options).initialize(options) - - windows: null - applicationMenu: null - atomProtocolHandler: null - resourcePath: null - version: null - quitting: false - - exit: (status) -> app.exit(status) - - constructor: (options) -> - {@resourcePath, @devResourcePath, @version, @devMode, @safeMode, @socketPath, @logFile, @userDataDir} = options - @socketPath = null if options.test or options.benchmark or options.benchmarkTest - @pidsToOpenWindows = {} - @windowStack = new WindowStack() - - @config = new Config({enablePersistence: true}) - @config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)} - ConfigSchema.projectHome = { - type: 'string', - default: path.join(fs.getHomeDirectory(), 'github'), - description: 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' - } - @config.initialize({configDirPath: process.env.ATOM_HOME, @resourcePath, projectHomeSchema: ConfigSchema.projectHome}) - @config.load() - @fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, "recovery")) - @storageFolder = new StorageFolder(process.env.ATOM_HOME) - @autoUpdateManager = new AutoUpdateManager( - @version, - options.test or options.benchmark or options.benchmarkTest, - @config - ) - - @disposable = new CompositeDisposable - @handleEvents() - - # This stuff was previously done in the constructor, but we want to be able to construct this object - # for testing purposes without booting up the world. As you add tests, feel free to move instantiation - # of these various sub-objects into the constructor, but you'll need to remove the side-effects they - # perform during their construction, adding an initialize method that you call here. - initialize: (options) -> - global.atomApplication = this - - # DEPRECATED: This can be removed at some point (added in 1.13) - # It converts `useCustomTitleBar: true` to `titleBar: "custom"` - if process.platform is 'darwin' and @config.get('core.useCustomTitleBar') - @config.unset('core.useCustomTitleBar') - @config.set('core.titleBar', 'custom') - - @config.onDidChange 'core.titleBar', @promptForRestart.bind(this) - - process.nextTick => @autoUpdateManager.initialize() - @applicationMenu = new ApplicationMenu(@version, @autoUpdateManager) - @atomProtocolHandler = new AtomProtocolHandler(@resourcePath, @safeMode) - - @listenForArgumentsFromNewProcess() - @setupDockMenu() - - @launch(options) - - destroy: -> - windowsClosePromises = @getAllWindows().map (window) -> - window.close() - window.closedPromise - Promise.all(windowsClosePromises).then(=> @disposable.dispose()) - - launch: (options) -> - 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) - else - @loadState(options) or @openPath(options) - - openWithOptions: (options) -> - { - initialPaths, pathsToOpen, executedFrom, urlsToOpen, benchmark, - benchmarkTest, test, pidToKillWhenClosed, devMode, safeMode, newWindow, - logFile, profileStartup, timeout, clearWindowState, addToLastWindow, env - } = options - - app.focus() - - if test - @runTests({ - headless: true, devMode, @resourcePath, executedFrom, pathsToOpen, - logFile, timeout, env - }) - else if benchmark or benchmarkTest - @runBenchmarks({headless: true, test: benchmarkTest, @resourcePath, executedFrom, pathsToOpen, timeout, env}) - else if pathsToOpen.length > 0 - @openPaths({ - initialPaths, pathsToOpen, executedFrom, pidToKillWhenClosed, newWindow, - devMode, safeMode, profileStartup, clearWindowState, addToLastWindow, env - }) - else if urlsToOpen.length > 0 - for urlToOpen in urlsToOpen - @openUrl({urlToOpen, devMode, safeMode, env}) - else - # Always open a editor window if this is the first instance of Atom. - @openPath({ - initialPaths, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, - clearWindowState, addToLastWindow, env - }) - - # Public: Removes the {AtomWindow} from the global window list. - removeWindow: (window) -> - @windowStack.removeWindow(window) - if @getAllWindows().length is 0 - @applicationMenu?.enableWindowSpecificItems(false) - if process.platform in ['win32', 'linux'] - app.quit() - return - @saveState(true) unless window.isSpec - - # Public: Adds the {AtomWindow} to the global window list. - addWindow: (window) -> - @windowStack.addWindow(window) - @applicationMenu?.addWindow(window.browserWindow) - window.once 'window:loaded', => - @autoUpdateManager?.emitUpdateAvailableEvent(window) - - unless window.isSpec - focusHandler = => @windowStack.touch(window) - blurHandler = => @saveState(false) - window.browserWindow.on 'focus', focusHandler - window.browserWindow.on 'blur', blurHandler - window.browserWindow.once 'closed', => - @windowStack.removeWindow(window) - window.browserWindow.removeListener 'focus', focusHandler - window.browserWindow.removeListener 'blur', blurHandler - window.browserWindow.webContents.once 'did-finish-load', => @saveState(false) - - getAllWindows: => - @windowStack.all().slice() - - getLastFocusedWindow: (predicate) => - @windowStack.getLastFocusedWindow(predicate) - - # Creates server to listen for additional atom application launches. - # - # You can run the atom command multiple times, but after the first launch - # the other launches will just pass their information to this server and then - # close immediately. - listenForArgumentsFromNewProcess: -> - return unless @socketPath? - @deleteSocketFile() - server = net.createServer (connection) => - data = '' - connection.on 'data', (chunk) -> - data = data + chunk - - connection.on 'end', => - options = JSON.parse(data) - @openWithOptions(options) - - server.listen @socketPath - server.on 'error', (error) -> console.error 'Application server failed', error - - deleteSocketFile: -> - return if process.platform is 'win32' or not @socketPath? - - if fs.existsSync(@socketPath) - try - fs.unlinkSync(@socketPath) - catch error - # Ignore ENOENT errors in case the file was deleted between the exists - # check and the call to unlink sync. This occurred occasionally on CI - # which is why this check is here. - throw error unless error.code is 'ENOENT' - - # Registers basic application commands, non-idempotent. - handleEvents: -> - getLoadSettings = => - devMode: @focusedWindow()?.devMode - safeMode: @focusedWindow()?.safeMode - - @on 'application:quit', -> app.quit() - @on 'application:new-window', -> @openPath(getLoadSettings()) - @on 'application:new-file', -> (@focusedWindow() ? this).openPath() - @on 'application:open-dev', -> @promptForPathToOpen('all', devMode: true) - @on 'application:open-safe', -> @promptForPathToOpen('all', safeMode: true) - @on 'application:inspect', ({x, y, atomWindow}) -> - atomWindow ?= @focusedWindow() - atomWindow?.browserWindow.inspectElement(x, y) - - @on 'application:open-documentation', -> shell.openExternal('http://flight-manual.atom.io/') - @on 'application:open-discussions', -> shell.openExternal('https://discuss.atom.io') - @on 'application:open-faq', -> shell.openExternal('https://atom.io/faq') - @on 'application:open-terms-of-use', -> shell.openExternal('https://atom.io/terms') - @on 'application:report-issue', -> shell.openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#reporting-bugs') - @on 'application:search-issues', -> shell.openExternal('https://github.com/search?q=+is%3Aissue+user%3Aatom') - - @on 'application:install-update', => - @quitting = true - @autoUpdateManager.install() - - @on 'application:check-for-update', => @autoUpdateManager.check() - - if process.platform is 'darwin' - @on 'application:bring-all-windows-to-front', -> Menu.sendActionToFirstResponder('arrangeInFront:') - @on 'application:hide', -> Menu.sendActionToFirstResponder('hide:') - @on 'application:hide-other-applications', -> Menu.sendActionToFirstResponder('hideOtherApplications:') - @on 'application:minimize', -> Menu.sendActionToFirstResponder('performMiniaturize:') - @on 'application:unhide-all-applications', -> Menu.sendActionToFirstResponder('unhideAllApplications:') - @on 'application:zoom', -> Menu.sendActionToFirstResponder('zoom:') - else - @on 'application:minimize', -> @focusedWindow()?.minimize() - @on 'application:zoom', -> @focusedWindow()?.maximize() - - @openPathOnEvent('application:about', 'atom://about') - @openPathOnEvent('application:show-settings', 'atom://config') - @openPathOnEvent('application:open-your-config', 'atom://.atom/config') - @openPathOnEvent('application:open-your-init-script', 'atom://.atom/init-script') - @openPathOnEvent('application:open-your-keymap', 'atom://.atom/keymap') - @openPathOnEvent('application:open-your-snippets', 'atom://.atom/snippets') - @openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet') - @openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) - - @disposable.add ipcHelpers.on app, 'before-quit', (event) => - resolveBeforeQuitPromise = null - @lastBeforeQuitPromise = new Promise((resolve) -> resolveBeforeQuitPromise = resolve) - if @quitting - resolveBeforeQuitPromise() - else - event.preventDefault() - @quitting = true - windowUnloadPromises = @getAllWindows().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() - @deleteSocketFile() - - @disposable.add ipcHelpers.on app, 'open-file', (event, pathToOpen) => - event.preventDefault() - @openPath({pathToOpen}) - - @disposable.add ipcHelpers.on app, 'open-url', (event, urlToOpen) => - event.preventDefault() - @openUrl({urlToOpen, @devMode, @safeMode}) - - @disposable.add ipcHelpers.on app, 'activate', (event, hasVisibleWindows) => - unless hasVisibleWindows - event?.preventDefault() - @emit('application:new-window') - - @disposable.add ipcHelpers.on ipcMain, 'restart-application', => - @restart() - - @disposable.add ipcHelpers.on ipcMain, 'resolve-proxy', (event, requestId, url) -> - event.sender.session.resolveProxy url, (proxy) -> - unless event.sender.isDestroyed() - event.sender.send('did-resolve-proxy', requestId, proxy) - - @disposable.add ipcHelpers.on ipcMain, 'did-change-history-manager', (event) => - for atomWindow in @getAllWindows() - webContents = atomWindow.browserWindow.webContents - if webContents isnt event.sender - webContents.send('did-change-history-manager') - - # A request from the associated render process to open a new render process. - @disposable.add ipcHelpers.on ipcMain, 'open', (event, options) => - window = @atomWindowForEvent(event) - if options? - if typeof options.pathsToOpen is 'string' - options.pathsToOpen = [options.pathsToOpen] - if options.pathsToOpen?.length > 0 - options.window = window - @openPaths(options) - else - new AtomWindow(this, @fileRecoveryService, options) - else - @promptForPathToOpen('all', {window}) - - @disposable.add ipcHelpers.on ipcMain, 'update-application-menu', (event, template, keystrokesByCommand) => - win = BrowserWindow.fromWebContents(event.sender) - @applicationMenu?.update(win, template, keystrokesByCommand) - - @disposable.add ipcHelpers.on ipcMain, 'run-package-specs', (event, packageSpecPath) => - @runTests({resourcePath: @devResourcePath, pathsToOpen: [packageSpecPath], headless: false}) - - @disposable.add ipcHelpers.on ipcMain, 'run-benchmarks', (event, benchmarksPath) => - @runBenchmarks({resourcePath: @devResourcePath, pathsToOpen: [benchmarksPath], headless: false, test: false}) - - @disposable.add ipcHelpers.on ipcMain, 'command', (event, command) => - @emit(command) - - @disposable.add ipcHelpers.on ipcMain, 'open-command', (event, command, args...) => - defaultPath = args[0] if args.length > 0 - switch command - when 'application:open' then @promptForPathToOpen('all', getLoadSettings(), defaultPath) - when 'application:open-file' then @promptForPathToOpen('file', getLoadSettings(), defaultPath) - when 'application:open-folder' then @promptForPathToOpen('folder', getLoadSettings(), defaultPath) - else console.log "Invalid open-command received: " + command - - @disposable.add ipcHelpers.on ipcMain, 'window-command', (event, command, args...) -> - win = BrowserWindow.fromWebContents(event.sender) - win.emit(command, args...) - - @disposable.add ipcHelpers.respondTo 'window-method', (browserWindow, method, args...) => - @atomWindowForBrowserWindow(browserWindow)?[method](args...) - - @disposable.add ipcHelpers.on ipcMain, 'pick-folder', (event, responseChannel) => - @promptForPath "folder", (selectedPaths) -> - event.sender.send(responseChannel, selectedPaths) - - @disposable.add ipcHelpers.respondTo 'set-window-size', (win, width, height) -> - win.setSize(width, height) - - @disposable.add ipcHelpers.respondTo 'set-window-position', (win, x, y) -> - win.setPosition(x, y) - - @disposable.add ipcHelpers.respondTo 'center-window', (win) -> - win.center() - - @disposable.add ipcHelpers.respondTo 'focus-window', (win) -> - win.focus() - - @disposable.add ipcHelpers.respondTo 'show-window', (win) -> - win.show() - - @disposable.add ipcHelpers.respondTo 'hide-window', (win) -> - win.hide() - - @disposable.add ipcHelpers.respondTo 'get-temporary-window-state', (win) -> - win.temporaryState - - @disposable.add ipcHelpers.respondTo 'set-temporary-window-state', (win, state) -> - win.temporaryState = state - - @disposable.add ipcHelpers.on ipcMain, 'write-text-to-selection-clipboard', (event, selectedText) -> - clipboard.writeText(selectedText, 'selection') - - @disposable.add ipcHelpers.on ipcMain, 'write-to-stdout', (event, output) -> - process.stdout.write(output) - - @disposable.add ipcHelpers.on ipcMain, 'write-to-stderr', (event, output) -> - process.stderr.write(output) - - @disposable.add ipcHelpers.on ipcMain, 'add-recent-document', (event, filename) -> - app.addRecentDocument(filename) - - @disposable.add ipcHelpers.on ipcMain, 'execute-javascript-in-dev-tools', (event, code) -> - event.sender.devToolsWebContents?.executeJavaScript(code) - - @disposable.add ipcHelpers.on ipcMain, 'get-auto-update-manager-state', (event) => - event.returnValue = @autoUpdateManager.getState() - - @disposable.add ipcHelpers.on ipcMain, 'get-auto-update-manager-error', (event) => - event.returnValue = @autoUpdateManager.getErrorMessage() - - @disposable.add ipcHelpers.on ipcMain, 'will-save-path', (event, path) => - @fileRecoveryService.willSavePath(@atomWindowForEvent(event), path) - event.returnValue = true - - @disposable.add ipcHelpers.on ipcMain, 'did-save-path', (event, path) => - @fileRecoveryService.didSavePath(@atomWindowForEvent(event), path) - event.returnValue = true - - @disposable.add ipcHelpers.on ipcMain, 'did-change-paths', => - @saveState(false) - - @disposable.add(@disableZoomOnDisplayChange()) - - setupDockMenu: -> - if process.platform is 'darwin' - dockMenu = Menu.buildFromTemplate [ - {label: 'New Window', click: => @emit('application:new-window')} - ] - app.dock.setMenu dockMenu - - # Public: Executes the given command. - # - # If it isn't handled globally, delegate to the currently focused window. - # - # command - The string representing the command. - # args - The optional arguments to pass along. - sendCommand: (command, args...) -> - unless @emit(command, args...) - focusedWindow = @focusedWindow() - if focusedWindow? - focusedWindow.sendCommand(command, args...) - else - @sendCommandToFirstResponder(command) - - # Public: Executes the given command on the given window. - # - # command - The string representing the command. - # atomWindow - The {AtomWindow} to send the command to. - # args - The optional arguments to pass along. - sendCommandToWindow: (command, atomWindow, args...) -> - unless @emit(command, args...) - if atomWindow? - atomWindow.sendCommand(command, args...) - else - @sendCommandToFirstResponder(command) - - # Translates the command into macOS action and sends it to application's first - # responder. - sendCommandToFirstResponder: (command) -> - return false unless process.platform is 'darwin' - - switch command - when 'core:undo' then Menu.sendActionToFirstResponder('undo:') - when 'core:redo' then Menu.sendActionToFirstResponder('redo:') - when 'core:copy' then Menu.sendActionToFirstResponder('copy:') - when 'core:cut' then Menu.sendActionToFirstResponder('cut:') - when 'core:paste' then Menu.sendActionToFirstResponder('paste:') - when 'core:select-all' then Menu.sendActionToFirstResponder('selectAll:') - else return false - true - - # Public: Open the given path in the focused window when the event is - # triggered. - # - # A new window will be created if there is no currently focused window. - # - # eventName - The event to listen for. - # pathToOpen - The path to open when the event is triggered. - openPathOnEvent: (eventName, pathToOpen) -> - @on eventName, -> - if window = @focusedWindow() - window.openPath(pathToOpen) - else - @openPath({pathToOpen}) - - # Returns the {AtomWindow} for the given paths. - windowForPaths: (pathsToOpen, devMode) -> - _.find @getAllWindows(), (atomWindow) -> - atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen) - - # Returns the {AtomWindow} for the given ipcMain event. - atomWindowForEvent: ({sender}) -> - @atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender)) - - atomWindowForBrowserWindow: (browserWindow) -> - @getAllWindows().find((atomWindow) -> atomWindow.browserWindow is browserWindow) - - # Public: Returns the currently focused {AtomWindow} or undefined if none. - focusedWindow: -> - _.find @getAllWindows(), (atomWindow) -> atomWindow.isFocused() - - # Get the platform-specific window offset for new windows. - getWindowOffsetForCurrentPlatform: -> - offsetByPlatform = - darwin: 22 - win32: 26 - offsetByPlatform[process.platform] ? 0 - - # Get the dimensions for opening a new window by cascading as appropriate to - # the platform. - getDimensionsForNewWindow: -> - return if (@focusedWindow() ? @getLastFocusedWindow())?.isMaximized() - dimensions = (@focusedWindow() ? @getLastFocusedWindow())?.getDimensions() - offset = @getWindowOffsetForCurrentPlatform() - if dimensions? and offset? - dimensions.x += offset - dimensions.y += offset - dimensions - - # Public: Opens a single path, in an existing window if possible. - # - # options - - # :pathToOpen - The file path to open - # :pidToKillWhenClosed - The integer of the pid to kill - # :newWindow - Boolean of whether this should be opened in a new window. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - # :profileStartup - Boolean to control creating a profile of the startup time. - # :window - {AtomWindow} to open file paths in. - # :addToLastWindow - Boolean of whether this should be opened in last focused window. - openPath: ({initialPaths, pathToOpen, pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window, clearWindowState, addToLastWindow, env} = {}) -> - @openPaths({initialPaths, pathsToOpen: [pathToOpen], pidToKillWhenClosed, newWindow, devMode, safeMode, profileStartup, window, clearWindowState, addToLastWindow, env}) - - # Public: Opens multiple paths, in existing windows if possible. - # - # options - - # :pathsToOpen - The array of file paths to open - # :pidToKillWhenClosed - The integer of the pid to kill - # :newWindow - Boolean of whether this should be opened in a new window. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - # :windowDimensions - Object with height and width keys. - # :window - {AtomWindow} to open file paths in. - # :addToLastWindow - Boolean of whether this should be opened in last focused window. - openPaths: ({initialPaths, pathsToOpen, executedFrom, pidToKillWhenClosed, newWindow, devMode, safeMode, windowDimensions, profileStartup, window, clearWindowState, addToLastWindow, env}={}) -> - if not pathsToOpen? or pathsToOpen.length is 0 - return - env = process.env unless env? - devMode = Boolean(devMode) - safeMode = Boolean(safeMode) - clearWindowState = Boolean(clearWindowState) - locationsToOpen = (@locationForPathToOpen(pathToOpen, executedFrom, addToLastWindow) for pathToOpen in pathsToOpen) - pathsToOpen = (locationToOpen.pathToOpen for locationToOpen in locationsToOpen) - - unless pidToKillWhenClosed or newWindow - existingWindow = @windowForPaths(pathsToOpen, devMode) - stats = (fs.statSyncNoException(pathToOpen) for pathToOpen in pathsToOpen) - unless existingWindow? - if currentWindow = window ? @getLastFocusedWindow() - existingWindow = currentWindow if ( - addToLastWindow or - currentWindow.devMode is devMode and - ( - stats.every((stat) -> stat.isFile?()) or - stats.some((stat) -> stat.isDirectory?() and not currentWindow.hasProjectPath()) - ) - ) - - if existingWindow? - openedWindow = existingWindow - openedWindow.openLocations(locationsToOpen) - if openedWindow.isMinimized() - openedWindow.restore() - else - openedWindow.focus() - openedWindow.replaceEnvironment(env) - else - if devMode - try - windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) - resourcePath = @devResourcePath - - windowInitializationScript ?= require.resolve('../initialize-application-window') - resourcePath ?= @resourcePath - windowDimensions ?= @getDimensionsForNewWindow() - openedWindow = new AtomWindow(this, @fileRecoveryService, {initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env}) - openedWindow.focus() - @windowStack.addWindow(openedWindow) - - if pidToKillWhenClosed? - @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow - - openedWindow.browserWindow.once 'closed', => - @killProcessForWindow(openedWindow) - - openedWindow - - # Kill all processes associated with opened windows. - killAllProcesses: -> - @killProcess(pid) for pid of @pidsToOpenWindows - return - - # Kill process associated with the given opened window. - killProcessForWindow: (openedWindow) -> - for pid, trackedWindow of @pidsToOpenWindows - @killProcess(pid) if trackedWindow is openedWindow - return - - # Kill the process with the given pid. - killProcess: (pid) -> - try - parsedPid = parseInt(pid) - process.kill(parsedPid) if isFinite(parsedPid) - catch error - if error.code isnt 'ESRCH' - console.log("Killing process #{pid} failed: #{error.code ? error.message}") - delete @pidsToOpenWindows[pid] - - saveState: (allowEmpty=false) -> - return if @quitting - states = [] - for window in @getAllWindows() - unless window.isSpec - states.push({initialPaths: window.representedDirectoryPaths}) - states.reverse() - if states.length > 0 or allowEmpty - @storageFolder.storeSync('application.json', states) - @emit('application:did-save-state') - - loadState: (options) -> - if (@config.get('core.restorePreviousWindowsOnStart') in ['yes', 'always']) and (states = @storageFolder.load('application.json'))?.length > 0 - for state in states - @openWithOptions(Object.assign(options, { - initialPaths: state.initialPaths - pathsToOpen: state.initialPaths.filter (directoryPath) -> fs.isDirectorySync(directoryPath) - urlsToOpen: [] - devMode: @devMode - safeMode: @safeMode - })) - else - null - - # Open an atom:// url. - # - # The host of the URL being opened is assumed to be the package name - # responsible for opening the URL. A new window will be created with - # that package's `urlMain` as the bootstrap script. - # - # options - - # :urlToOpen - The atom:// url to open. - # :devMode - Boolean to control the opened window's dev mode. - # :safeMode - Boolean to control the opened window's safe mode. - openUrl: ({urlToOpen, devMode, safeMode, env}) -> - parsedUrl = url.parse(urlToOpen, true) - return unless parsedUrl.protocol is "atom:" - - pack = @findPackageWithName(parsedUrl.host, devMode) - if pack?.urlMain - @openPackageUrlMain(parsedUrl.host, pack.urlMain, urlToOpen, devMode, safeMode, env) - else - @openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env) - - openPackageUriHandler: (url, parsedUrl, devMode, safeMode, env) -> - bestWindow = null - if parsedUrl.host is 'core' - predicate = require('../core-uri-handlers').windowPredicate(parsedUrl) - bestWindow = @getLastFocusedWindow (win) -> - not win.isSpecWindow() and predicate(win) - - bestWindow ?= @getLastFocusedWindow (win) -> not win.isSpecWindow() - if bestWindow? - bestWindow.sendURIMessage url - bestWindow.focus() - else - resourcePath = @resourcePath - if devMode - try - windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) - resourcePath = @devResourcePath - - windowInitializationScript ?= require.resolve('../initialize-application-window') - windowDimensions = @getDimensionsForNewWindow() - win = new AtomWindow(this, @fileRecoveryService, {resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env}) - @windowStack.addWindow(win) - win.on 'window:loaded', -> - win.sendURIMessage url - - findPackageWithName: (packageName, devMode) -> - _.find @getPackageManager(devMode).getAvailablePackageMetadata(), ({name}) -> name is packageName - - openPackageUrlMain: (packageName, packageUrlMain, urlToOpen, devMode, safeMode, env) -> - packagePath = @getPackageManager(devMode).resolvePackagePath(packageName) - windowInitializationScript = path.resolve(packagePath, packageUrlMain) - windowDimensions = @getDimensionsForNewWindow() - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, @resourcePath, devMode, safeMode, urlToOpen, windowDimensions, env}) - - getPackageManager: (devMode) -> - unless @packages? - PackageManager = require '../package-manager' - @packages = new PackageManager({}) - @packages.initialize - configDirPath: process.env.ATOM_HOME - devMode: devMode - resourcePath: @resourcePath - - @packages - - - # Opens up a new {AtomWindow} to run specs within. - # - # options - - # :headless - A Boolean that, if true, will close the window upon - # completion. - # :resourcePath - The path to include specs from. - # :specPath - The directory to load specs from. - # :safeMode - A Boolean that, if true, won't run specs from ~/.atom/packages - # and ~/.atom/dev/packages, defaults to false. - runTests: ({headless, resourcePath, executedFrom, pathsToOpen, logFile, safeMode, timeout, env}) -> - if resourcePath isnt @resourcePath and not fs.existsSync(resourcePath) - resourcePath = @resourcePath - - timeoutInSeconds = Number.parseFloat(timeout) - unless Number.isNaN(timeoutInSeconds) - timeoutHandler = -> - console.log "The test suite has timed out because it has been running for more than #{timeoutInSeconds} seconds." - process.exit(124) # Use the same exit code as the UNIX timeout util. - setTimeout(timeoutHandler, timeoutInSeconds * 1000) - - try - windowInitializationScript = require.resolve(path.resolve(@devResourcePath, 'src', 'initialize-test-window')) - catch error - windowInitializationScript = require.resolve(path.resolve(__dirname, '..', '..', 'src', 'initialize-test-window')) - - testPaths = [] - if pathsToOpen? - for pathToOpen in pathsToOpen - testPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) - - if testPaths.length is 0 - process.stderr.write 'Error: Specify at least one test path\n\n' - process.exit(1) - - legacyTestRunnerPath = @resolveLegacyTestRunnerPath() - testRunnerPath = @resolveTestRunnerPath(testPaths[0]) - devMode = true - isSpec = true - safeMode ?= false - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, resourcePath, headless, isSpec, devMode, testRunnerPath, legacyTestRunnerPath, testPaths, logFile, safeMode, env}) - - runBenchmarks: ({headless, test, resourcePath, executedFrom, pathsToOpen, env}) -> - if resourcePath isnt @resourcePath and not fs.existsSync(resourcePath) - resourcePath = @resourcePath - - try - windowInitializationScript = require.resolve(path.resolve(@devResourcePath, 'src', 'initialize-benchmark-window')) - catch error - windowInitializationScript = require.resolve(path.resolve(__dirname, '..', '..', 'src', 'initialize-benchmark-window')) - - benchmarkPaths = [] - if pathsToOpen? - for pathToOpen in pathsToOpen - benchmarkPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) - - if benchmarkPaths.length is 0 - process.stderr.write 'Error: Specify at least one benchmark path.\n\n' - process.exit(1) - - devMode = true - isSpec = true - safeMode = false - new AtomWindow(this, @fileRecoveryService, {windowInitializationScript, resourcePath, headless, test, isSpec, devMode, benchmarkPaths, safeMode, env}) - - resolveTestRunnerPath: (testPath) -> - FindParentDir ?= require 'find-parent-dir' - - if packageRoot = FindParentDir.sync(testPath, 'package.json') - packageMetadata = require(path.join(packageRoot, 'package.json')) - if packageMetadata.atomTestRunner - Resolve ?= require('resolve') - if testRunnerPath = Resolve.sync(packageMetadata.atomTestRunner, basedir: packageRoot, extensions: Object.keys(require.extensions)) - return testRunnerPath - else - process.stderr.write "Error: Could not resolve test runner path '#{packageMetadata.atomTestRunner}'" - process.exit(1) - - @resolveLegacyTestRunnerPath() - - resolveLegacyTestRunnerPath: -> - try - require.resolve(path.resolve(@devResourcePath, 'spec', 'jasmine-test-runner')) - catch error - require.resolve(path.resolve(__dirname, '..', '..', 'spec', 'jasmine-test-runner')) - - locationForPathToOpen: (pathToOpen, executedFrom='', forceAddToWindow) -> - return {pathToOpen} unless pathToOpen - - pathToOpen = pathToOpen.replace(/[:\s]+$/, '') - match = pathToOpen.match(LocationSuffixRegExp) - - if match? - pathToOpen = pathToOpen.slice(0, -match[0].length) - initialLine = Math.max(0, parseInt(match[1].slice(1)) - 1) if match[1] - initialColumn = Math.max(0, parseInt(match[2].slice(1)) - 1) if match[2] - else - initialLine = initialColumn = null - - unless url.parse(pathToOpen).protocol? - pathToOpen = path.resolve(executedFrom, fs.normalize(pathToOpen)) - - {pathToOpen, initialLine, initialColumn, forceAddToWindow} - - # Opens a native dialog to prompt the user for a path. - # - # Once paths are selected, they're opened in a new or existing {AtomWindow}s. - # - # options - - # :type - A String which specifies the type of the dialog, could be 'file', - # 'folder' or 'all'. The 'all' is only available on macOS. - # :devMode - A Boolean which controls whether any newly opened windows - # should be in dev mode or not. - # :safeMode - A Boolean which controls whether any newly opened windows - # should be in safe mode or not. - # :window - An {AtomWindow} to use for opening a selected file path. - # :path - An optional String which controls the default path to which the - # file dialog opens. - promptForPathToOpen: (type, {devMode, safeMode, window}, path=null) -> - @promptForPath type, ((pathsToOpen) => - @openPaths({pathsToOpen, devMode, safeMode, window})), path - - promptForPath: (type, callback, path) -> - properties = - switch type - when 'file' then ['openFile'] - when 'folder' then ['openDirectory'] - when 'all' then ['openFile', 'openDirectory'] - else throw new Error("#{type} is an invalid type for promptForPath") - - # Show the open dialog as child window on Windows and Linux, and as - # independent dialog on macOS. This matches most native apps. - parentWindow = - if process.platform is 'darwin' - null - else - BrowserWindow.getFocusedWindow() - - openOptions = - properties: properties.concat(['multiSelections', 'createDirectory']) - title: switch type - when 'file' then 'Open File' - when 'folder' then 'Open Folder' - else 'Open' - - # File dialog defaults to project directory of currently active editor - if path? - openOptions.defaultPath = path - - dialog.showOpenDialog(parentWindow, openOptions, callback) - - promptForRestart: -> - chosen = dialog.showMessageBox BrowserWindow.getFocusedWindow(), - type: 'warning' - title: 'Restart required' - message: "You will need to restart Atom for this change to take effect." - buttons: ['Restart Atom', 'Cancel'] - if chosen is 0 - @restart() - - restart: -> - args = [] - args.push("--safe") if @safeMode - args.push("--log-file=#{@logFile}") if @logFile? - args.push("--socket-path=#{@socketPath}") if @socketPath? - args.push("--user-data-dir=#{@userDataDir}") if @userDataDir? - if @devMode - args.push('--dev') - args.push("--resource-path=#{@resourcePath}") - app.relaunch({args}) - app.quit() - - disableZoomOnDisplayChange: -> - outerCallback = => - for window in @getAllWindows() - window.disableZoom() - - # Set the limits every time a display is added or removed, otherwise the - # configuration gets reset to the default, which allows zooming the - # webframe. - screen.on('display-added', outerCallback) - screen.on('display-removed', outerCallback) - new Disposable -> - screen.removeListener('display-added', outerCallback) - screen.removeListener('display-removed', outerCallback) - -class WindowStack - constructor: (@windows = []) -> - - addWindow: (window) => - @removeWindow(window) - @windows.unshift(window) - - touch: (window) => - @addWindow(window) - - removeWindow: (window) => - currentIndex = @windows.indexOf(window) - @windows.splice(currentIndex, 1) if currentIndex > -1 - - getLastFocusedWindow: (predicate) => - predicate ?= (win) -> true - @windows.find(predicate) - - all: => - @windows diff --git a/src/main-process/atom-application.js b/src/main-process/atom-application.js new file mode 100644 index 00000000000..3696a3b4083 --- /dev/null +++ b/src/main-process/atom-application.js @@ -0,0 +1,1375 @@ +const AtomWindow = require('./atom-window') +const ApplicationMenu = require('./application-menu') +const AtomProtocolHandler = require('./atom-protocol-handler') +const AutoUpdateManager = require('./auto-update-manager') +const StorageFolder = require('../storage-folder') +const Config = require('../config') +const FileRecoveryService = require('./file-recovery-service') +const ipcHelpers = require('../ipc-helpers') +const {BrowserWindow, Menu, app, clipboard, dialog, ipcMain, shell, screen} = require('electron') +const {CompositeDisposable, Disposable} = require('event-kit') +const crypto = require('crypto') +const fs = require('fs-plus') +const path = require('path') +const os = require('os') +const net = require('net') +const url = require('url') +const {EventEmitter} = require('events') +const _ = require('underscore-plus') +let FindParentDir = null +let Resolve = null +const ConfigSchema = require('../config-schema') + +const LocationSuffixRegExp = /(:\d+)(:\d+)?$/ + +// The application's singleton class. +// +// It's the entry point into the Atom application and maintains the global state +// of the application. +// +module.exports = +class AtomApplication extends EventEmitter { + // Public: The entry point into the Atom application. + static open (options) { + if (!options.socketPath) { + const username = process.platform === 'win32' ? process.env.USERNAME : process.env.USER + + // Lowercasing the ATOM_HOME to make sure that we don't get multiple sockets + // on case-insensitive filesystems due to arbitrary case differences in paths. + const atomHomeUnique = path.resolve(process.env.ATOM_HOME).toLowerCase() + const hash = crypto + .createHash('sha1') + .update(options.version) + .update('|') + .update(process.arch) + .update('|') + .update(username) + .update('|') + .update(atomHomeUnique) + + // We only keep the first 12 characters of the hash as not to have excessively long + // socket file. Note that macOS/BSD limit the length of socket file paths (see #15081). + // The replace calls convert the digest into "URL and Filename Safe" encoding (see RFC 4648). + const atomInstanceDigest = hash + .digest('base64') + .substring(0, 12) + .replace(/\+/g, '-') + .replace(/\//g, '_') + + if (process.platform === 'win32') { + options.socketPath = `\\\\.\\pipe\\atom-${atomInstanceDigest}-sock` + } else { + options.socketPath = path.join(os.tmpdir(), `atom-${atomInstanceDigest}.sock`) + } + } + + // FIXME: Sometimes when socketPath doesn't exist, net.connect would strangely + // take a few seconds to trigger 'error' event, it could be a bug of node + // or electron, before it's fixed we check the existence of socketPath to + // speedup startup. + if ((process.platform !== 'win32' && !fs.existsSync(options.socketPath)) || + options.test || options.benchmark || options.benchmarkTest) { + new AtomApplication(options).initialize(options) + return + } + + const client = net.connect({path: options.socketPath}, () => { + client.write(JSON.stringify(options), () => { + client.end() + app.quit() + }) + }) + + client.on('error', () => new AtomApplication(options).initialize(options)) + } + + exit (status) { + app.exit(status) + } + + constructor (options) { + super() + this.quitting = false + this.getAllWindows = this.getAllWindows.bind(this) + this.getLastFocusedWindow = this.getLastFocusedWindow.bind(this) + + this.resourcePath = options.resourcePath + this.devResourcePath = options.devResourcePath + this.version = options.version + this.devMode = options.devMode + this.safeMode = options.safeMode + this.socketPath = options.socketPath + this.logFile = options.logFile + this.userDataDir = options.userDataDir + this._killProcess = options.killProcess || process.kill.bind(process) + if (options.test || options.benchmark || options.benchmarkTest) this.socketPath = null + + this.waitSessionsByWindow = new Map() + this.windowStack = new WindowStack() + + this.config = new Config({enablePersistence: true}) + this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)}) + ConfigSchema.projectHome = { + type: 'string', + default: path.join(fs.getHomeDirectory(), 'github'), + description: + 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.' + } + this.config.initialize({ + configDirPath: process.env.ATOM_HOME, + resourcePath: this.resourcePath, + projectHomeSchema: ConfigSchema.projectHome + }) + this.config.load() + + this.fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, 'recovery')) + this.storageFolder = new StorageFolder(process.env.ATOM_HOME) + this.autoUpdateManager = new AutoUpdateManager( + this.version, + options.test || options.benchmark || options.benchmarkTest, + this.config + ) + + this.disposable = new CompositeDisposable() + this.handleEvents() + } + + // This stuff was previously done in the constructor, but we want to be able to construct this object + // for testing purposes without booting up the world. As you add tests, feel free to move instantiation + // of these various sub-objects into the constructor, but you'll need to remove the side-effects they + // perform during their construction, adding an initialize method that you call here. + initialize (options) { + global.atomApplication = this + + // DEPRECATED: This can be removed at some point (added in 1.13) + // It converts `useCustomTitleBar: true` to `titleBar: "custom"` + if (process.platform === 'darwin' && this.config.get('core.useCustomTitleBar')) { + this.config.unset('core.useCustomTitleBar') + this.config.set('core.titleBar', 'custom') + } + + this.config.onDidChange('core.titleBar', this.promptForRestart.bind(this)) + + process.nextTick(() => this.autoUpdateManager.initialize()) + this.applicationMenu = new ApplicationMenu(this.version, this.autoUpdateManager) + this.atomProtocolHandler = new AtomProtocolHandler(this.resourcePath, this.safeMode) + + this.listenForArgumentsFromNewProcess() + this.setupDockMenu() + + return this.launch(options) + } + + async destroy () { + const windowsClosePromises = this.getAllWindows().map(window => { + window.close() + return window.closedPromise + }) + await Promise.all(windowsClosePromises) + this.disposable.dispose() + } + + launch (options) { + if (options.test || options.benchmark || options.benchmarkTest) { + return this.openWithOptions(options) + } else if ((options.pathsToOpen && options.pathsToOpen.length > 0) || + (options.urlsToOpen && options.urlsToOpen.length > 0)) { + if (this.config.get('core.restorePreviousWindowsOnStart') === 'always') { + this.loadState(_.deepClone(options)) + } + return this.openWithOptions(options) + } else { + return this.loadState(options) || this.openPath(options) + } + } + + openWithOptions (options) { + const { + initialPaths, + pathsToOpen, + executedFrom, + urlsToOpen, + benchmark, + benchmarkTest, + test, + pidToKillWhenClosed, + devMode, + safeMode, + newWindow, + logFile, + profileStartup, + timeout, + clearWindowState, + addToLastWindow, + env + } = options + + app.focus() + + if (test) { + return this.runTests({ + headless: true, + devMode, + resourcePath: this.resourcePath, + executedFrom, + pathsToOpen, + logFile, + timeout, + env + }) + } else if (benchmark || benchmarkTest) { + return this.runBenchmarks({ + headless: true, + test: benchmarkTest, + resourcePath: this.resourcePath, + executedFrom, + pathsToOpen, + timeout, + env + }) + } else if (pathsToOpen.length > 0) { + return this.openPaths({ + initialPaths, + pathsToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }) + } else if (urlsToOpen.length > 0) { + return urlsToOpen.map(urlToOpen => this.openUrl({urlToOpen, devMode, safeMode, env})) + } else { + // Always open a editor window if this is the first instance of Atom. + return this.openPath({ + initialPaths, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + clearWindowState, + addToLastWindow, + env + }) + } + } + + // Public: Removes the {AtomWindow} from the global window list. + removeWindow (window) { + this.windowStack.removeWindow(window) + if (this.getAllWindows().length === 0) { + if (this.applicationMenu != null) { + this.applicationMenu.enableWindowSpecificItems(false) + } + if (['win32', 'linux'].includes(process.platform)) { + app.quit() + return + } + } + if (!window.isSpec) this.saveState(true) + } + + // Public: Adds the {AtomWindow} to the global window list. + addWindow (window) { + this.windowStack.addWindow(window) + if (this.applicationMenu) this.applicationMenu.addWindow(window.browserWindow) + + window.once('window:loaded', () => { + this.autoUpdateManager && this.autoUpdateManager.emitUpdateAvailableEvent(window) + }) + + if (!window.isSpec) { + const focusHandler = () => this.windowStack.touch(window) + const blurHandler = () => this.saveState(false) + window.browserWindow.on('focus', focusHandler) + window.browserWindow.on('blur', blurHandler) + window.browserWindow.once('closed', () => { + this.windowStack.removeWindow(window) + window.browserWindow.removeListener('focus', focusHandler) + window.browserWindow.removeListener('blur', blurHandler) + }) + window.browserWindow.webContents.once('did-finish-load', blurHandler) + } + } + + getAllWindows () { + return this.windowStack.all().slice() + } + + getLastFocusedWindow (predicate) { + return this.windowStack.getLastFocusedWindow(predicate) + } + + // Creates server to listen for additional atom application launches. + // + // You can run the atom command multiple times, but after the first launch + // the other launches will just pass their information to this server and then + // close immediately. + listenForArgumentsFromNewProcess () { + if (!this.socketPath) return + + this.deleteSocketFile() + const server = net.createServer(connection => { + let data = '' + connection.on('data', chunk => { data += chunk }) + connection.on('end', () => this.openWithOptions(JSON.parse(data))) + }) + + server.listen(this.socketPath) + server.on('error', error => console.error('Application server failed', error)) + } + + deleteSocketFile () { + if (process.platform === 'win32' || !this.socketPath) return + + if (fs.existsSync(this.socketPath)) { + try { + fs.unlinkSync(this.socketPath) + } catch (error) { + // Ignore ENOENT errors in case the file was deleted between the exists + // check and the call to unlink sync. This occurred occasionally on CI + // which is why this check is here. + if (error.code !== 'ENOENT') throw error + } + } + } + + // Registers basic application commands, non-idempotent. + handleEvents () { + const getLoadSettings = () => { + const window = this.focusedWindow() + return {devMode: window && window.devMode, safeMode: window && window.safeMode} + } + + this.on('application:quit', () => app.quit()) + this.on('application:new-window', () => this.openPath(getLoadSettings())) + this.on('application:new-file', () => (this.focusedWindow() || this).openPath()) + this.on('application:open-dev', () => this.promptForPathToOpen('all', {devMode: true})) + this.on('application:open-safe', () => this.promptForPathToOpen('all', {safeMode: true})) + this.on('application:inspect', ({x, y, atomWindow}) => { + if (!atomWindow) atomWindow = this.focusedWindow() + if (atomWindow) atomWindow.browserWindow.inspectElement(x, y) + }) + + this.on('application:open-documentation', () => shell.openExternal('http://flight-manual.atom.io')) + this.on('application:open-discussions', () => shell.openExternal('https://discuss.atom.io')) + this.on('application:open-faq', () => shell.openExternal('https://atom.io/faq')) + this.on('application:open-terms-of-use', () => shell.openExternal('https://atom.io/terms')) + this.on('application:report-issue', () => shell.openExternal('https://github.com/atom/atom/blob/master/CONTRIBUTING.md#reporting-bugs')) + this.on('application:search-issues', () => shell.openExternal('https://github.com/search?q=+is%3Aissue+user%3Aatom')) + + this.on('application:install-update', () => { + this.quitting = true + this.autoUpdateManager.install() + }) + + this.on('application:check-for-update', () => this.autoUpdateManager.check()) + + if (process.platform === 'darwin') { + this.on('application:bring-all-windows-to-front', () => Menu.sendActionToFirstResponder('arrangeInFront:')) + this.on('application:hide', () => Menu.sendActionToFirstResponder('hide:')) + this.on('application:hide-other-applications', () => Menu.sendActionToFirstResponder('hideOtherApplications:')) + this.on('application:minimize', () => Menu.sendActionToFirstResponder('performMiniaturize:')) + this.on('application:unhide-all-applications', () => Menu.sendActionToFirstResponder('unhideAllApplications:')) + this.on('application:zoom', () => Menu.sendActionToFirstResponder('zoom:')) + } else { + this.on('application:minimize', () => { + const window = this.focusedWindow() + if (window) window.minimize() + }) + this.on('application:zoom', function () { + const window = this.focusedWindow() + if (window) window.maximize() + }) + } + + this.openPathOnEvent('application:about', 'atom://about') + this.openPathOnEvent('application:show-settings', 'atom://config') + this.openPathOnEvent('application:open-your-config', 'atom://.atom/config') + this.openPathOnEvent('application:open-your-init-script', 'atom://.atom/init-script') + this.openPathOnEvent('application:open-your-keymap', 'atom://.atom/keymap') + this.openPathOnEvent('application:open-your-snippets', 'atom://.atom/snippets') + this.openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet') + this.openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md')) + + this.disposable.add(ipcHelpers.on(app, 'before-quit', async event => { + let resolveBeforeQuitPromise + this.lastBeforeQuitPromise = new Promise(resolve => { resolveBeforeQuitPromise = resolve }) + + if (!this.quitting) { + this.quitting = true + event.preventDefault() + const windowUnloadPromises = this.getAllWindows().map(window => window.prepareToUnload()) + const windowUnloadedResults = await Promise.all(windowUnloadPromises) + if (windowUnloadedResults.every(Boolean)) app.quit() + } + + resolveBeforeQuitPromise() + })) + + this.disposable.add(ipcHelpers.on(app, 'will-quit', () => { + this.killAllProcesses() + this.deleteSocketFile() + })) + + this.disposable.add(ipcHelpers.on(app, 'open-file', (event, pathToOpen) => { + event.preventDefault() + this.openPath({pathToOpen}) + })) + + this.disposable.add(ipcHelpers.on(app, 'open-url', (event, urlToOpen) => { + event.preventDefault() + this.openUrl({urlToOpen, devMode: this.devMode, safeMode: this.safeMode}) + })) + + this.disposable.add(ipcHelpers.on(app, 'activate', (event, hasVisibleWindows) => { + if (hasVisibleWindows) return + if (event) event.preventDefault() + this.emit('application:new-window') + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'restart-application', () => { + this.restart() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'resolve-proxy', (event, requestId, url) => { + event.sender.session.resolveProxy(url, proxy => { + if (!event.sender.isDestroyed()) event.sender.send('did-resolve-proxy', requestId, proxy) + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-change-history-manager', event => { + for (let atomWindow of this.getAllWindows()) { + const {webContents} = atomWindow.browserWindow + if (webContents !== event.sender) webContents.send('did-change-history-manager') + } + })) + + // A request from the associated render process to open a new render process. + this.disposable.add(ipcHelpers.on(ipcMain, 'open', (event, options) => { + const window = this.atomWindowForEvent(event) + if (options) { + if (typeof options.pathsToOpen === 'string') { + options.pathsToOpen = [options.pathsToOpen] + } + + if (options.pathsToOpen && options.pathsToOpen.length > 0) { + options.window = window + this.openPaths(options) + } else { + this.addWindow(new AtomWindow(this, this.fileRecoveryService, options)) + } + } else { + this.promptForPathToOpen('all', {window}) + } + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'update-application-menu', (event, template, menu) => { + const window = BrowserWindow.fromWebContents(event.sender) + if (this.applicationMenu) this.applicationMenu.update(window, template, menu) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'run-package-specs', (event, packageSpecPath) => { + this.runTests({ + resourcePath: this.devResourcePath, + pathsToOpen: [packageSpecPath], + headless: false + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'run-benchmarks', (event, benchmarksPath) => { + this.runBenchmarks({ + resourcePath: this.devResourcePath, + pathsToOpen: [benchmarksPath], + headless: false, + test: false + }) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'command', (event, command) => { + this.emit(command) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'open-command', (event, command, defaultPath) => { + switch (command) { + case 'application:open': + return this.promptForPathToOpen('all', getLoadSettings(), defaultPath) + case 'application:open-file': + return this.promptForPathToOpen('file', getLoadSettings(), defaultPath) + case 'application:open-folder': + return this.promptForPathToOpen('folder', getLoadSettings(), defaultPath) + default: + return console.log(`Invalid open-command received: ${command}`) + } + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'window-command', (event, command, ...args) => { + const window = BrowserWindow.fromWebContents(event.sender) + return window.emit(command, ...args) + })) + + this.disposable.add(ipcHelpers.respondTo('window-method', (browserWindow, method, ...args) => { + const window = this.atomWindowForBrowserWindow(browserWindow) + if (window) window[method](...args) + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'pick-folder', (event, responseChannel) => { + this.promptForPath('folder', paths => event.sender.send(responseChannel, paths)) + })) + + this.disposable.add(ipcHelpers.respondTo('set-window-size', (window, width, height) => { + window.setSize(width, height) + })) + + this.disposable.add(ipcHelpers.respondTo('set-window-position', (window, x, y) => { + window.setPosition(x, y) + })) + + this.disposable.add(ipcHelpers.respondTo('center-window', window => window.center())) + this.disposable.add(ipcHelpers.respondTo('focus-window', window => window.focus())) + this.disposable.add(ipcHelpers.respondTo('show-window', window => window.show())) + this.disposable.add(ipcHelpers.respondTo('hide-window', window => window.hide())) + this.disposable.add(ipcHelpers.respondTo('get-temporary-window-state', window => window.temporaryState)) + + this.disposable.add(ipcHelpers.respondTo('set-temporary-window-state', (win, state) => { + win.temporaryState = state + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'write-text-to-selection-clipboard', (event, text) => + clipboard.writeText(text, 'selection') + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'write-to-stdout', (event, output) => + process.stdout.write(output) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'write-to-stderr', (event, output) => + process.stderr.write(output) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'add-recent-document', (event, filename) => + app.addRecentDocument(filename) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'execute-javascript-in-dev-tools', (event, code) => + event.sender.devToolsWebContents && event.sender.devToolsWebContents.executeJavaScript(code) + )) + + this.disposable.add(ipcHelpers.on(ipcMain, 'get-auto-update-manager-state', event => { + event.returnValue = this.autoUpdateManager.getState() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'get-auto-update-manager-error', event => { + event.returnValue = this.autoUpdateManager.getErrorMessage() + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'will-save-path', (event, path) => { + this.fileRecoveryService.willSavePath(this.atomWindowForEvent(event), path) + event.returnValue = true + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-save-path', (event, path) => { + this.fileRecoveryService.didSavePath(this.atomWindowForEvent(event), path) + event.returnValue = true + })) + + this.disposable.add(ipcHelpers.on(ipcMain, 'did-change-paths', () => + this.saveState(false) + )) + + this.disposable.add(this.disableZoomOnDisplayChange()) + } + + setupDockMenu () { + if (process.platform === 'darwin') { + return app.dock.setMenu(Menu.buildFromTemplate([ + {label: 'New Window', click: () => this.emit('application:new-window')} + ])) + } + } + + // Public: Executes the given command. + // + // If it isn't handled globally, delegate to the currently focused window. + // + // command - The string representing the command. + // args - The optional arguments to pass along. + sendCommand (command, ...args) { + if (!this.emit(command, ...args)) { + const focusedWindow = this.focusedWindow() + if (focusedWindow) { + return focusedWindow.sendCommand(command, ...args) + } else { + return this.sendCommandToFirstResponder(command) + } + } + } + + // Public: Executes the given command on the given window. + // + // command - The string representing the command. + // atomWindow - The {AtomWindow} to send the command to. + // args - The optional arguments to pass along. + sendCommandToWindow (command, atomWindow, ...args) { + if (!this.emit(command, ...args)) { + if (atomWindow) { + return atomWindow.sendCommand(command, ...args) + } else { + return this.sendCommandToFirstResponder(command) + } + } + } + + // Translates the command into macOS action and sends it to application's first + // responder. + sendCommandToFirstResponder (command) { + if (process.platform !== 'darwin') return false + + switch (command) { + case 'core:undo': + Menu.sendActionToFirstResponder('undo:') + break + case 'core:redo': + Menu.sendActionToFirstResponder('redo:') + break + case 'core:copy': + Menu.sendActionToFirstResponder('copy:') + break + case 'core:cut': + Menu.sendActionToFirstResponder('cut:') + break + case 'core:paste': + Menu.sendActionToFirstResponder('paste:') + break + case 'core:select-all': + Menu.sendActionToFirstResponder('selectAll:') + break + default: + return false + } + return true + } + + // Public: Open the given path in the focused window when the event is + // triggered. + // + // A new window will be created if there is no currently focused window. + // + // eventName - The event to listen for. + // pathToOpen - The path to open when the event is triggered. + openPathOnEvent (eventName, pathToOpen) { + this.on(eventName, () => { + const window = this.focusedWindow() + if (window) { + return window.openPath(pathToOpen) + } else { + return this.openPath({pathToOpen}) + } + }) + } + + // Returns the {AtomWindow} for the given paths. + windowForPaths (pathsToOpen, devMode) { + return this.getAllWindows().find(window => + window.devMode === devMode && window.containsPaths(pathsToOpen) + ) + } + + // Returns the {AtomWindow} for the given ipcMain event. + atomWindowForEvent ({sender}) { + return this.atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender)) + } + + atomWindowForBrowserWindow (browserWindow) { + return this.getAllWindows().find(atomWindow => atomWindow.browserWindow === browserWindow) + } + + // Public: Returns the currently focused {AtomWindow} or undefined if none. + focusedWindow () { + return this.getAllWindows().find(window => window.isFocused()) + } + + // Get the platform-specific window offset for new windows. + getWindowOffsetForCurrentPlatform () { + const offsetByPlatform = { + darwin: 22, + win32: 26 + } + return offsetByPlatform[process.platform] || 0 + } + + // Get the dimensions for opening a new window by cascading as appropriate to + // the platform. + getDimensionsForNewWindow () { + const window = this.focusedWindow() || this.getLastFocusedWindow() + if (!window || window.isMaximized()) return + const dimensions = window.getDimensions() + if (dimensions) { + const offset = this.getWindowOffsetForCurrentPlatform() + dimensions.x += offset + dimensions.y += offset + return dimensions + } + } + + // Public: Opens a single path, in an existing window if possible. + // + // options - + // :pathToOpen - The file path to open + // :pidToKillWhenClosed - The integer of the pid to kill + // :newWindow - Boolean of whether this should be opened in a new window. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + // :profileStartup - Boolean to control creating a profile of the startup time. + // :window - {AtomWindow} to open file paths in. + // :addToLastWindow - Boolean of whether this should be opened in last focused window. + openPath ({ + initialPaths, + pathToOpen, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + return this.openPaths({ + initialPaths, + pathsToOpen: [pathToOpen], + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + }) + } + + // Public: Opens multiple paths, in existing windows if possible. + // + // options - + // :pathsToOpen - The array of file paths to open + // :pidToKillWhenClosed - The integer of the pid to kill + // :newWindow - Boolean of whether this should be opened in a new window. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + // :windowDimensions - Object with height and width keys. + // :window - {AtomWindow} to open file paths in. + // :addToLastWindow - Boolean of whether this should be opened in last focused window. + openPaths ({ + initialPaths, + pathsToOpen, + executedFrom, + pidToKillWhenClosed, + newWindow, + devMode, + safeMode, + windowDimensions, + profileStartup, + window, + clearWindowState, + addToLastWindow, + env + } = {}) { + if (!pathsToOpen || pathsToOpen.length === 0) return + if (!env) env = process.env + devMode = Boolean(devMode) + safeMode = Boolean(safeMode) + clearWindowState = Boolean(clearWindowState) + + const locationsToOpen = [] + for (let i = 0; i < pathsToOpen.length; i++) { + const location = this.parsePathToOpen(pathsToOpen[i], executedFrom, addToLastWindow) + location.forceAddToWindow = addToLastWindow + location.hasWaitSession = pidToKillWhenClosed != null + locationsToOpen.push(location) + pathsToOpen[i] = location.pathToOpen + } + + let existingWindow + if (!newWindow) { + existingWindow = this.windowForPaths(pathsToOpen, devMode) + const stats = pathsToOpen.map(pathToOpen => fs.statSyncNoException(pathToOpen)) + if (!existingWindow) { + let lastWindow = window || this.getLastFocusedWindow() + if (lastWindow && lastWindow.devMode === devMode) { + if (addToLastWindow || ( + stats.every(s => s.isFile && s.isFile()) || + (stats.some(s => s.isDirectory && s.isDirectory()) && !lastWindow.hasProjectPath()))) { + existingWindow = lastWindow + } + } + } + } + + let openedWindow + if (existingWindow) { + openedWindow = existingWindow + openedWindow.openLocations(locationsToOpen) + if (openedWindow.isMinimized()) { + openedWindow.restore() + } else { + openedWindow.focus() + } + openedWindow.replaceEnvironment(env) + } else { + let resourcePath, windowInitializationScript + if (devMode) { + try { + windowInitializationScript = require.resolve( + path.join(this.devResourcePath, 'src', 'initialize-application-window') + ) + resourcePath = this.devResourcePath + } catch (error) {} + } + + if (!windowInitializationScript) { + windowInitializationScript = require.resolve('../initialize-application-window') + } + if (!resourcePath) resourcePath = this.resourcePath + if (!windowDimensions) windowDimensions = this.getDimensionsForNewWindow() + openedWindow = new AtomWindow(this, this.fileRecoveryService, { + initialPaths, + locationsToOpen, + windowInitializationScript, + resourcePath, + devMode, + safeMode, + windowDimensions, + profileStartup, + clearWindowState, + env + }) + this.addWindow(openedWindow) + openedWindow.focus() + } + + if (pidToKillWhenClosed != null) { + if (!this.waitSessionsByWindow.has(openedWindow)) { + this.waitSessionsByWindow.set(openedWindow, []) + } + this.waitSessionsByWindow.get(openedWindow).push({ + pid: pidToKillWhenClosed, + remainingPaths: new Set(pathsToOpen) + }) + } + + openedWindow.browserWindow.once('closed', () => this.killProcessesForWindow(openedWindow)) + return openedWindow + } + + // Kill all processes associated with opened windows. + killAllProcesses () { + for (let window of this.waitSessionsByWindow.keys()) { + this.killProcessesForWindow(window) + } + } + + killProcessesForWindow (window) { + const sessions = this.waitSessionsByWindow.get(window) + if (!sessions) return + for (const session of sessions) { + this.killProcess(session.pid) + } + this.waitSessionsByWindow.delete(window) + } + + windowDidClosePathWithWaitSession (window, initialPath) { + const waitSessions = this.waitSessionsByWindow.get(window) + if (!waitSessions) return + for (let i = waitSessions.length - 1; i >= 0; i--) { + const session = waitSessions[i] + session.remainingPaths.delete(initialPath) + if (session.remainingPaths.size === 0) { + this.killProcess(session.pid) + waitSessions.splice(i, 1) + } + } + } + + // Kill the process with the given pid. + killProcess (pid) { + try { + const parsedPid = parseInt(pid) + if (isFinite(parsedPid)) this._killProcess(parsedPid) + } catch (error) { + if (error.code !== 'ESRCH') { + console.log(`Killing process ${pid} failed: ${error.code != null ? error.code : error.message}`) + } + } + } + + saveState (allowEmpty = false) { + if (this.quitting) return + + const states = [] + for (let window of this.getAllWindows()) { + if (!window.isSpec) states.push({initialPaths: window.representedDirectoryPaths}) + } + states.reverse() + + if (states.length > 0 || allowEmpty) { + this.storageFolder.storeSync('application.json', states) + this.emit('application:did-save-state') + } + } + + loadState (options) { + const states = this.storageFolder.load('application.json') + if ( + ['yes', 'always'].includes(this.config.get('core.restorePreviousWindowsOnStart')) && + states && states.length > 0 + ) { + return states.map(state => + this.openWithOptions(Object.assign(options, { + initialPaths: state.initialPaths, + pathsToOpen: state.initialPaths.filter(p => fs.isDirectorySync(p)), + urlsToOpen: [], + devMode: this.devMode, + safeMode: this.safeMode + })) + ) + } else { + return null + } + } + + // Open an atom:// url. + // + // The host of the URL being opened is assumed to be the package name + // responsible for opening the URL. A new window will be created with + // that package's `urlMain` as the bootstrap script. + // + // options - + // :urlToOpen - The atom:// url to open. + // :devMode - Boolean to control the opened window's dev mode. + // :safeMode - Boolean to control the opened window's safe mode. + openUrl ({urlToOpen, devMode, safeMode, env}) { + const parsedUrl = url.parse(urlToOpen, true) + if (parsedUrl.protocol !== 'atom:') return + + const pack = this.findPackageWithName(parsedUrl.host, devMode) + if (pack && pack.urlMain) { + return this.openPackageUrlMain( + parsedUrl.host, + pack.urlMain, + urlToOpen, + devMode, + safeMode, + env + ) + } else { + return this.openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env) + } + } + + openPackageUriHandler (url, parsedUrl, devMode, safeMode, env) { + let bestWindow + + if (parsedUrl.host === 'core') { + const predicate = require('../core-uri-handlers').windowPredicate(parsedUrl) + bestWindow = this.getLastFocusedWindow(win => !win.isSpecWindow() && predicate(win)) + } + + if (!bestWindow) bestWindow = this.getLastFocusedWindow(win => !win.isSpecWindow()) + + if (bestWindow) { + bestWindow.sendURIMessage(url) + bestWindow.focus() + } else { + let windowInitializationScript + let {resourcePath} = this + if (devMode) { + try { + windowInitializationScript = require.resolve( + path.join(this.devResourcePath, 'src', 'initialize-application-window') + ) + resourcePath = this.devResourcePath + } catch (error) {} + } + + if (!windowInitializationScript) { + windowInitializationScript = require.resolve('../initialize-application-window') + } + + const windowDimensions = this.getDimensionsForNewWindow() + const window = new AtomWindow(this, this.fileRecoveryService, { + resourcePath, + windowInitializationScript, + devMode, + safeMode, + windowDimensions, + env + }) + this.addWindow(window) + window.on('window:loaded', () => window.sendURIMessage(url)) + return window + } + } + + findPackageWithName (packageName, devMode) { + return this.getPackageManager(devMode).getAvailablePackageMetadata().find(({name}) => + name === packageName + ) + } + + openPackageUrlMain (packageName, packageUrlMain, urlToOpen, devMode, safeMode, env) { + const packagePath = this.getPackageManager(devMode).resolvePackagePath(packageName) + const windowInitializationScript = path.resolve(packagePath, packageUrlMain) + const windowDimensions = this.getDimensionsForNewWindow() + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath: this.resourcePath, + devMode, + safeMode, + urlToOpen, + windowDimensions, + env + }) + this.addWindow(window) + return window + } + + getPackageManager (devMode) { + if (this.packages == null) { + const PackageManager = require('../package-manager') + this.packages = new PackageManager({}) + this.packages.initialize({ + configDirPath: process.env.ATOM_HOME, + devMode, + resourcePath: this.resourcePath + }) + } + + return this.packages + } + + // Opens up a new {AtomWindow} to run specs within. + // + // options - + // :headless - A Boolean that, if true, will close the window upon + // completion. + // :resourcePath - The path to include specs from. + // :specPath - The directory to load specs from. + // :safeMode - A Boolean that, if true, won't run specs from ~/.atom/packages + // and ~/.atom/dev/packages, defaults to false. + runTests ({headless, resourcePath, executedFrom, pathsToOpen, logFile, safeMode, timeout, env}) { + let windowInitializationScript + if (resourcePath !== this.resourcePath && !fs.existsSync(resourcePath)) { + ;({resourcePath} = this) + } + + const timeoutInSeconds = Number.parseFloat(timeout) + if (!Number.isNaN(timeoutInSeconds)) { + const timeoutHandler = function () { + console.log( + `The test suite has timed out because it has been running for more than ${timeoutInSeconds} seconds.` + ) + return process.exit(124) // Use the same exit code as the UNIX timeout util. + } + setTimeout(timeoutHandler, timeoutInSeconds * 1000) + } + + try { + windowInitializationScript = require.resolve( + path.resolve(this.devResourcePath, 'src', 'initialize-test-window') + ) + } catch (error) { + windowInitializationScript = require.resolve( + path.resolve(__dirname, '..', '..', 'src', 'initialize-test-window') + ) + } + + const testPaths = [] + if (pathsToOpen != null) { + for (let pathToOpen of pathsToOpen) { + testPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) + } + } + + if (testPaths.length === 0) { + process.stderr.write('Error: Specify at least one test path\n\n') + process.exit(1) + } + + const legacyTestRunnerPath = this.resolveLegacyTestRunnerPath() + const testRunnerPath = this.resolveTestRunnerPath(testPaths[0]) + const devMode = true + const isSpec = true + if (safeMode == null) { + safeMode = false + } + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath, + headless, + isSpec, + devMode, + testRunnerPath, + legacyTestRunnerPath, + testPaths, + logFile, + safeMode, + env + }) + this.addWindow(window) + return window + } + + runBenchmarks ({headless, test, resourcePath, executedFrom, pathsToOpen, env}) { + let windowInitializationScript + if (resourcePath !== this.resourcePath && !fs.existsSync(resourcePath)) { + ;({resourcePath} = this) + } + + try { + windowInitializationScript = require.resolve( + path.resolve(this.devResourcePath, 'src', 'initialize-benchmark-window') + ) + } catch (error) { + windowInitializationScript = require.resolve( + path.resolve(__dirname, '..', '..', 'src', 'initialize-benchmark-window') + ) + } + + const benchmarkPaths = [] + if (pathsToOpen != null) { + for (let pathToOpen of pathsToOpen) { + benchmarkPaths.push(path.resolve(executedFrom, fs.normalize(pathToOpen))) + } + } + + if (benchmarkPaths.length === 0) { + process.stderr.write('Error: Specify at least one benchmark path.\n\n') + process.exit(1) + } + + const devMode = true + const isSpec = true + const safeMode = false + const window = new AtomWindow(this, this.fileRecoveryService, { + windowInitializationScript, + resourcePath, + headless, + test, + isSpec, + devMode, + benchmarkPaths, + safeMode, + env + }) + this.addWindow(window) + return window + } + + resolveTestRunnerPath (testPath) { + let packageRoot + if (FindParentDir == null) { + FindParentDir = require('find-parent-dir') + } + + if ((packageRoot = FindParentDir.sync(testPath, 'package.json'))) { + const packageMetadata = require(path.join(packageRoot, 'package.json')) + if (packageMetadata.atomTestRunner) { + let testRunnerPath + if (Resolve == null) { + Resolve = require('resolve') + } + if ( + (testRunnerPath = Resolve.sync(packageMetadata.atomTestRunner, { + basedir: packageRoot, + extensions: Object.keys(require.extensions) + })) + ) { + return testRunnerPath + } else { + process.stderr.write( + `Error: Could not resolve test runner path '${packageMetadata.atomTestRunner}'` + ) + process.exit(1) + } + } + } + + return this.resolveLegacyTestRunnerPath() + } + + resolveLegacyTestRunnerPath () { + try { + return require.resolve(path.resolve(this.devResourcePath, 'spec', 'jasmine-test-runner')) + } catch (error) { + return require.resolve(path.resolve(__dirname, '..', '..', 'spec', 'jasmine-test-runner')) + } + } + + parsePathToOpen (pathToOpen, executedFrom = '') { + let initialColumn, initialLine + if (!pathToOpen) { + return {pathToOpen} + } + + pathToOpen = pathToOpen.replace(/[:\s]+$/, '') + const match = pathToOpen.match(LocationSuffixRegExp) + + if (match != null) { + pathToOpen = pathToOpen.slice(0, -match[0].length) + if (match[1]) { + initialLine = Math.max(0, parseInt(match[1].slice(1)) - 1) + } + if (match[2]) { + initialColumn = Math.max(0, parseInt(match[2].slice(1)) - 1) + } + } else { + initialLine = initialColumn = null + } + + if (url.parse(pathToOpen).protocol == null) { + pathToOpen = path.resolve(executedFrom, fs.normalize(pathToOpen)) + } + + return {pathToOpen, initialLine, initialColumn} + } + + // Opens a native dialog to prompt the user for a path. + // + // Once paths are selected, they're opened in a new or existing {AtomWindow}s. + // + // options - + // :type - A String which specifies the type of the dialog, could be 'file', + // 'folder' or 'all'. The 'all' is only available on macOS. + // :devMode - A Boolean which controls whether any newly opened windows + // should be in dev mode or not. + // :safeMode - A Boolean which controls whether any newly opened windows + // should be in safe mode or not. + // :window - An {AtomWindow} to use for opening a selected file path. + // :path - An optional String which controls the default path to which the + // file dialog opens. + promptForPathToOpen (type, {devMode, safeMode, window}, path = null) { + return this.promptForPath( + type, + pathsToOpen => { + return this.openPaths({pathsToOpen, devMode, safeMode, window}) + }, + path + ) + } + + promptForPath (type, callback, path) { + const properties = (() => { + switch (type) { + case 'file': return ['openFile'] + case 'folder': return ['openDirectory'] + case 'all': return ['openFile', 'openDirectory'] + default: throw new Error(`${type} is an invalid type for promptForPath`) + } + })() + + // Show the open dialog as child window on Windows and Linux, and as + // independent dialog on macOS. This matches most native apps. + const parentWindow = process.platform === 'darwin' ? null : BrowserWindow.getFocusedWindow() + + const openOptions = { + properties: properties.concat(['multiSelections', 'createDirectory']), + title: (() => { + switch (type) { + case 'file': return 'Open File' + case 'folder': return 'Open Folder' + default: return 'Open' + } + })() + } + + // File dialog defaults to project directory of currently active editor + if (path) openOptions.defaultPath = path + return dialog.showOpenDialog(parentWindow, openOptions, callback) + } + + promptForRestart () { + const chosen = dialog.showMessageBox(BrowserWindow.getFocusedWindow(), { + type: 'warning', + title: 'Restart required', + message: 'You will need to restart Atom for this change to take effect.', + buttons: ['Restart Atom', 'Cancel'] + }) + if (chosen === 0) return this.restart() + } + + restart () { + const args = [] + if (this.safeMode) args.push('--safe') + if (this.logFile != null) args.push(`--log-file=${this.logFile}`) + if (this.socketPath != null) args.push(`--socket-path=${this.socketPath}`) + if (this.userDataDir != null) args.push(`--user-data-dir=${this.userDataDir}`) + if (this.devMode) { + args.push('--dev') + args.push(`--resource-path=${this.resourcePath}`) + } + app.relaunch({args}) + app.quit() + } + + disableZoomOnDisplayChange () { + const callback = () => { + this.getAllWindows().map(window => window.disableZoom()) + } + + // Set the limits every time a display is added or removed, otherwise the + // configuration gets reset to the default, which allows zooming the + // webframe. + screen.on('display-added', callback) + screen.on('display-removed', callback) + return new Disposable(() => { + screen.removeListener('display-added', callback) + screen.removeListener('display-removed', callback) + }) + } +} + +class WindowStack { + constructor (windows = []) { + this.addWindow = this.addWindow.bind(this) + this.touch = this.touch.bind(this) + this.removeWindow = this.removeWindow.bind(this) + this.getLastFocusedWindow = this.getLastFocusedWindow.bind(this) + this.all = this.all.bind(this) + this.windows = windows + } + + addWindow (window) { + this.removeWindow(window) + return this.windows.unshift(window) + } + + touch (window) { + return this.addWindow(window) + } + + removeWindow (window) { + const currentIndex = this.windows.indexOf(window) + if (currentIndex > -1) { + return this.windows.splice(currentIndex, 1) + } + } + + getLastFocusedWindow (predicate) { + if (predicate == null) { + predicate = win => true + } + return this.windows.find(predicate) + } + + all () { + return this.windows + } +} diff --git a/src/main-process/atom-protocol-handler.coffee b/src/main-process/atom-protocol-handler.coffee deleted file mode 100644 index db385b4b783..00000000000 --- a/src/main-process/atom-protocol-handler.coffee +++ /dev/null @@ -1,43 +0,0 @@ -{protocol} = require 'electron' -fs = require 'fs' -path = require 'path' - -# Handles requests with 'atom' protocol. -# -# It's created by {AtomApplication} upon instantiation and is used to create a -# custom resource loader for 'atom://' URLs. -# -# The following directories are searched in order: -# * ~/.atom/assets -# * ~/.atom/dev/packages (unless in safe mode) -# * ~/.atom/packages -# * RESOURCE_PATH/node_modules -# -module.exports = -class AtomProtocolHandler - constructor: (resourcePath, safeMode) -> - @loadPaths = [] - - unless safeMode - @loadPaths.push(path.join(process.env.ATOM_HOME, 'dev', 'packages')) - - @loadPaths.push(path.join(process.env.ATOM_HOME, 'packages')) - @loadPaths.push(path.join(resourcePath, 'node_modules')) - - @registerAtomProtocol() - - # Creates the 'atom' custom protocol handler. - registerAtomProtocol: -> - protocol.registerFileProtocol 'atom', (request, callback) => - relativePath = path.normalize(request.url.substr(7)) - - if relativePath.indexOf('assets/') is 0 - assetsPath = path.join(process.env.ATOM_HOME, relativePath) - filePath = assetsPath if fs.statSyncNoException(assetsPath).isFile?() - - unless filePath - for loadPath in @loadPaths - filePath = path.join(loadPath, relativePath) - break if fs.statSyncNoException(filePath).isFile?() - - callback(filePath) diff --git a/src/main-process/atom-protocol-handler.js b/src/main-process/atom-protocol-handler.js new file mode 100644 index 00000000000..1affba02a65 --- /dev/null +++ b/src/main-process/atom-protocol-handler.js @@ -0,0 +1,54 @@ +const {protocol} = require('electron') +const fs = require('fs') +const path = require('path') + +// Handles requests with 'atom' protocol. +// +// It's created by {AtomApplication} upon instantiation and is used to create a +// custom resource loader for 'atom://' URLs. +// +// The following directories are searched in order: +// * ~/.atom/assets +// * ~/.atom/dev/packages (unless in safe mode) +// * ~/.atom/packages +// * RESOURCE_PATH/node_modules +// +module.exports = +class AtomProtocolHandler { + constructor (resourcePath, safeMode) { + this.loadPaths = [] + + if (!safeMode) { + this.loadPaths.push(path.join(process.env.ATOM_HOME, 'dev', 'packages')) + } + + this.loadPaths.push(path.join(process.env.ATOM_HOME, 'packages')) + this.loadPaths.push(path.join(resourcePath, 'node_modules')) + + this.registerAtomProtocol() + } + + // Creates the 'atom' custom protocol handler. + registerAtomProtocol () { + protocol.registerFileProtocol('atom', (request, callback) => { + const relativePath = path.normalize(request.url.substr(7)) + + let filePath + if (relativePath.indexOf('assets/') === 0) { + const assetsPath = path.join(process.env.ATOM_HOME, relativePath) + const stat = fs.statSyncNoException(assetsPath) + if (stat && stat.isFile()) filePath = assetsPath + } + + if (!filePath) { + for (let loadPath of this.loadPaths) { + filePath = path.join(loadPath, relativePath) + const stat = fs.statSyncNoException(filePath) + if (stat && stat.isFile()) break + } + } + + callback(filePath) + }) + } +} diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee deleted file mode 100644 index ca3995c055a..00000000000 --- a/src/main-process/atom-window.coffee +++ /dev/null @@ -1,323 +0,0 @@ -{BrowserWindow, app, dialog, ipcMain} = require 'electron' -path = require 'path' -fs = require 'fs' -url = require 'url' -{EventEmitter} = require 'events' - -module.exports = -class AtomWindow - Object.assign @prototype, EventEmitter.prototype - - @iconPath: path.resolve(__dirname, '..', '..', 'resources', 'atom.png') - @includeShellLoadTime: true - - browserWindow: null - loaded: null - isSpec: null - - constructor: (@atomApplication, @fileRecoveryService, settings={}) -> - {@resourcePath, pathToOpen, locationsToOpen, @isSpec, @headless, @safeMode, @devMode} = settings - locationsToOpen ?= [{pathToOpen}] if pathToOpen - locationsToOpen ?= [] - - @loadedPromise = new Promise((@resolveLoadedPromise) =>) - @closedPromise = new Promise((@resolveClosedPromise) =>) - - options = - show: false - title: 'Atom' - tabbingIdentifier: 'atom' - webPreferences: - # Prevent specs from throttling when the window is in the background: - # this should result in faster CI builds, and an improvement in the - # local development experience when running specs through the UI (which - # now won't pause when e.g. minimizing the window). - backgroundThrottling: not @isSpec - # Disable the `auxclick` feature so that `click` events are triggered in - # response to a middle-click. - # (Ref: https://github.com/atom/atom/pull/12696#issuecomment-290496960) - disableBlinkFeatures: 'Auxclick' - - # Don't set icon on Windows so the exe's ico will be used as window and - # taskbar's icon. See https://github.com/atom/atom/issues/4811 for more. - if process.platform is 'linux' - options.icon = @constructor.iconPath - - if @shouldAddCustomTitleBar() - options.titleBarStyle = 'hidden' - - if @shouldAddCustomInsetTitleBar() - options.titleBarStyle = 'hidden-inset' - - if @shouldHideTitleBar() - options.frame = false - - @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 ?= - for {pathToOpen} in locationsToOpen when pathToOpen - stat = fs.statSyncNoException(pathToOpen) or null - if stat?.isDirectory() - pathToOpen - else - parentDirectory = path.dirname(pathToOpen) - if stat?.isFile() or fs.existsSync(parentDirectory) - parentDirectory - else - pathToOpen - @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 - - @representedDirectoryPaths = @loadSettings.initialPaths - @env = @loadSettings.env if @loadSettings.env? - - @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) - - @browserWindow.on 'window:loaded', => - @disableZoom() - @emit 'window:loaded' - @resolveLoadedPromise() - - @browserWindow.on 'window:locations-opened', => - @emit 'window:locations-opened' - - @browserWindow.on 'enter-full-screen', => - @browserWindow.webContents.send('did-enter-full-screen') - - @browserWindow.on 'leave-full-screen', => - @browserWindow.webContents.send('did-leave-full-screen') - - @browserWindow.loadURL url.format - protocol: 'file' - pathname: "#{@resourcePath}/static/index.html" - slashes: true - - @browserWindow.showSaveDialog = @showSaveDialog.bind(this) - - @browserWindow.focusOnWebView() if @isSpec - @browserWindow.temporaryState = {windowDimensions} if windowDimensions? - - hasPathToOpen = not (locationsToOpen.length is 1 and not locationsToOpen[0].pathToOpen?) - @openLocations(locationsToOpen) if hasPathToOpen and not @isSpecWindow() - - @atomApplication.addWindow(this) - - hasProjectPath: -> @representedDirectoryPaths.length > 0 - - setupContextMenu: -> - ContextMenu = require './context-menu' - - @browserWindow.on 'context-menu', (menuTemplate) => - new ContextMenu(menuTemplate, this) - - containsPaths: (paths) -> - for pathToCheck in paths - return false unless @containsPath(pathToCheck) - true - - containsPath: (pathToCheck) -> - @representedDirectoryPaths.some (projectPath) -> - if not projectPath - false - else if not pathToCheck - false - else if pathToCheck is projectPath - true - else if fs.statSyncNoException(pathToCheck).isDirectory?() - false - else if pathToCheck.indexOf(path.join(projectPath, path.sep)) is 0 - true - else - false - - handleEvents: -> - @browserWindow.on 'close', (event) => - unless @atomApplication.quitting or @unloading - event.preventDefault() - @unloading = true - @atomApplication.saveState(false) - @prepareToUnload().then (result) => - @close() if result - - @browserWindow.on 'closed', => - @fileRecoveryService.didCloseWindow(this) - @atomApplication.removeWindow(this) - @resolveClosedPromise() - - @browserWindow.on 'unresponsive', => - return if @isSpec - - chosen = dialog.showMessageBox @browserWindow, - type: 'warning' - buttons: ['Force Close', 'Keep Waiting'] - message: 'Editor is not responding' - detail: 'The editor is not responding. Would you like to force close it or just keep waiting?' - @browserWindow.destroy() if chosen is 0 - - @browserWindow.webContents.on 'crashed', => - if @headless - console.log "Renderer process crashed, exiting" - @atomApplication.exit(100) - return - - @fileRecoveryService.didCrashWindow(this) - chosen = dialog.showMessageBox @browserWindow, - type: 'warning' - buttons: ['Close Window', 'Reload', 'Keep It Open'] - message: 'The editor has crashed' - detail: 'Please report this issue to https://github.com/atom/atom' - switch chosen - when 0 then @browserWindow.destroy() - when 1 then @browserWindow.reload() - - @browserWindow.webContents.on 'will-navigate', (event, url) => - unless url is @browserWindow.webContents.getURL() - event.preventDefault() - - @setupContextMenu() - - if @isSpec - # Spec window's web view should always have focus - @browserWindow.on 'blur', => - @browserWindow.focusOnWebView() - - prepareToUnload: -> - if @isSpecWindow() - return Promise.resolve(true) - @lastPrepareToUnloadPromise = new Promise (resolve) => - callback = (event, result) => - if BrowserWindow.fromWebContents(event.sender) is @browserWindow - ipcMain.removeListener('did-prepare-to-unload', callback) - unless result - @unloading = false - @atomApplication.quitting = false - resolve(result) - ipcMain.on('did-prepare-to-unload', callback) - @browserWindow.webContents.send('prepare-to-unload') - - openPath: (pathToOpen, initialLine, initialColumn) -> - @openLocations([{pathToOpen, initialLine, initialColumn}]) - - openLocations: (locationsToOpen) -> - @loadedPromise.then => @sendMessage 'open-locations', locationsToOpen - - replaceEnvironment: (env) -> - @browserWindow.webContents.send 'environment', env - - sendMessage: (message, detail) -> - @browserWindow.webContents.send 'message', message, detail - - sendCommand: (command, args...) -> - if @isSpecWindow() - unless @atomApplication.sendCommandToFirstResponder(command) - switch command - when 'window:reload' then @reload() - when 'window:toggle-dev-tools' then @toggleDevTools() - when 'window:close' then @close() - else if @isWebViewFocused() - @sendCommandToBrowserWindow(command, args...) - else - unless @atomApplication.sendCommandToFirstResponder(command) - @sendCommandToBrowserWindow(command, args...) - - sendURIMessage: (uri) -> - @browserWindow.webContents.send 'uri-message', uri - - sendCommandToBrowserWindow: (command, args...) -> - action = if args[0]?.contextCommand then 'context-command' else 'command' - @browserWindow.webContents.send action, command, args... - - getDimensions: -> - [x, y] = @browserWindow.getPosition() - [width, height] = @browserWindow.getSize() - {x, y, width, height} - - shouldAddCustomTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'custom' - - shouldAddCustomInsetTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'custom-inset' - - shouldHideTitleBar: -> - not @isSpec and - process.platform is 'darwin' and - @atomApplication.config.get('core.titleBar') is 'hidden' - - close: -> @browserWindow.close() - - focus: -> @browserWindow.focus() - - minimize: -> @browserWindow.minimize() - - maximize: -> @browserWindow.maximize() - - unmaximize: -> @browserWindow.unmaximize() - - restore: -> @browserWindow.restore() - - setFullScreen: (fullScreen) -> @browserWindow.setFullScreen(fullScreen) - - setAutoHideMenuBar: (autoHideMenuBar) -> @browserWindow.setAutoHideMenuBar(autoHideMenuBar) - - handlesAtomCommands: -> - not @isSpecWindow() and @isWebViewFocused() - - isFocused: -> @browserWindow.isFocused() - - isMaximized: -> @browserWindow.isMaximized() - - isMinimized: -> @browserWindow.isMinimized() - - isWebViewFocused: -> @browserWindow.isWebViewFocused() - - isSpecWindow: -> @isSpec - - reload: -> - @loadedPromise = new Promise((@resolveLoadedPromise) =>) - @prepareToUnload().then (result) => - @browserWindow.reload() if result - @loadedPromise - - showSaveDialog: (params) -> - params = Object.assign({ - title: 'Save File', - defaultPath: @representedDirectoryPaths[0] - }, params) - dialog.showSaveDialog(@browserWindow, params) - - toggleDevTools: -> @browserWindow.toggleDevTools() - - openDevTools: -> @browserWindow.openDevTools() - - closeDevTools: -> @browserWindow.closeDevTools() - - setDocumentEdited: (documentEdited) -> @browserWindow.setDocumentEdited(documentEdited) - - setRepresentedFilename: (representedFilename) -> @browserWindow.setRepresentedFilename(representedFilename) - - setRepresentedDirectoryPaths: (@representedDirectoryPaths) -> - @representedDirectoryPaths.sort() - @loadSettings.initialPaths = @representedDirectoryPaths - @browserWindow.loadSettingsJSON = JSON.stringify(@loadSettings) - @atomApplication.saveState() - - copy: -> @browserWindow.copy() - - disableZoom: -> - @browserWindow.webContents.setVisualZoomLevelLimits(1, 1) diff --git a/src/main-process/atom-window.js b/src/main-process/atom-window.js new file mode 100644 index 00000000000..0268cc1cf5f --- /dev/null +++ b/src/main-process/atom-window.js @@ -0,0 +1,432 @@ +const {BrowserWindow, app, dialog, ipcMain} = require('electron') +const path = require('path') +const fs = require('fs') +const url = require('url') +const {EventEmitter} = require('events') + +const ICON_PATH = path.resolve(__dirname, '..', '..', 'resources', 'atom.png') + +let includeShellLoadTime = true +let nextId = 0 + +module.exports = +class AtomWindow extends EventEmitter { + constructor (atomApplication, fileRecoveryService, settings = {}) { + super() + + this.id = nextId++ + this.atomApplication = atomApplication + this.fileRecoveryService = fileRecoveryService + this.isSpec = settings.isSpec + this.headless = settings.headless + this.safeMode = settings.safeMode + this.devMode = settings.devMode + this.resourcePath = settings.resourcePath + + let {pathToOpen, locationsToOpen} = settings + if (!locationsToOpen && pathToOpen) locationsToOpen = [{pathToOpen}] + if (!locationsToOpen) locationsToOpen = [] + + this.loadedPromise = new Promise(resolve => { this.resolveLoadedPromise = resolve }) + this.closedPromise = new Promise(resolve => { this.resolveClosedPromise = resolve }) + + const options = { + show: false, + title: 'Atom', + tabbingIdentifier: 'atom', + webPreferences: { + // Prevent specs from throttling when the window is in the background: + // this should result in faster CI builds, and an improvement in the + // local development experience when running specs through the UI (which + // now won't pause when e.g. minimizing the window). + backgroundThrottling: !this.isSpec, + // Disable the `auxclick` feature so that `click` events are triggered in + // response to a middle-click. + // (Ref: https://github.com/atom/atom/pull/12696#issuecomment-290496960) + disableBlinkFeatures: 'Auxclick' + } + } + + // Don't set icon on Windows so the exe's ico will be used as window and + // taskbar's icon. See https://github.com/atom/atom/issues/4811 for more. + if (process.platform === 'linux') options.icon = ICON_PATH + if (this.shouldAddCustomTitleBar()) options.titleBarStyle = 'hidden' + if (this.shouldAddCustomInsetTitleBar()) options.titleBarStyle = 'hidden-inset' + if (this.shouldHideTitleBar()) options.frame = false + this.browserWindow = new BrowserWindow(options) + + this.handleEvents() + + this.loadSettings = Object.assign({}, settings) + this.loadSettings.appVersion = app.getVersion() + this.loadSettings.resourcePath = this.resourcePath + this.loadSettings.atomHome = process.env.ATOM_HOME + if (this.loadSettings.devMode == null) this.loadSettings.devMode = false + if (this.loadSettings.safeMode == null) this.loadSettings.safeMode = false + if (this.loadSettings.clearWindowState == null) this.loadSettings.clearWindowState = false + + if (!this.loadSettings.initialPaths) { + this.loadSettings.initialPaths = [] + for (const {pathToOpen} of locationsToOpen) { + if (!pathToOpen) continue + const stat = fs.statSyncNoException(pathToOpen) || null + if (stat && stat.isDirectory()) { + this.loadSettings.initialPaths.push(pathToOpen) + } else { + const parentDirectory = path.dirname(pathToOpen) + if ((stat && stat.isFile()) || fs.existsSync(parentDirectory)) { + this.loadSettings.initialPaths.push(parentDirectory) + } else { + this.loadSettings.initialPaths.push(pathToOpen) + } + } + } + } + + this.loadSettings.initialPaths.sort() + + // Only send to the first non-spec window created + if (includeShellLoadTime && !this.isSpec) { + includeShellLoadTime = false + if (!this.loadSettings.shellLoadTime) { + this.loadSettings.shellLoadTime = Date.now() - global.shellStartTime + } + } + + this.representedDirectoryPaths = this.loadSettings.initialPaths + if (!this.loadSettings.env) this.env = this.loadSettings.env + + this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings) + + this.browserWindow.on('window:loaded', () => { + this.disableZoom() + this.emit('window:loaded') + this.resolveLoadedPromise() + }) + + this.browserWindow.on('window:locations-opened', () => { + this.emit('window:locations-opened') + }) + + this.browserWindow.on('enter-full-screen', () => { + this.browserWindow.webContents.send('did-enter-full-screen') + }) + + this.browserWindow.on('leave-full-screen', () => { + this.browserWindow.webContents.send('did-leave-full-screen') + }) + + this.browserWindow.loadURL( + url.format({ + protocol: 'file', + pathname: `${this.resourcePath}/static/index.html`, + slashes: true + }) + ) + + this.browserWindow.showSaveDialog = this.showSaveDialog.bind(this) + + if (this.isSpec) this.browserWindow.focusOnWebView() + + const hasPathToOpen = !(locationsToOpen.length === 1 && locationsToOpen[0].pathToOpen == null) + if (hasPathToOpen && !this.isSpecWindow()) this.openLocations(locationsToOpen) + } + + hasProjectPath () { + return this.representedDirectoryPaths.length > 0 + } + + setupContextMenu () { + const ContextMenu = require('./context-menu') + + this.browserWindow.on('context-menu', menuTemplate => { + return new ContextMenu(menuTemplate, this) + }) + } + + containsPaths (paths) { + return paths.every(p => this.containsPath(p)) + } + + containsPath (pathToCheck) { + if (!pathToCheck) return false + const stat = fs.statSyncNoException(pathToCheck) + if (stat && stat.isDirectory()) return false + + return this.representedDirectoryPaths.some(projectPath => + pathToCheck === projectPath || pathToCheck.startsWith(path.join(projectPath, path.sep)) + ) + } + + handleEvents () { + this.browserWindow.on('close', async event => { + if (!this.atomApplication.quitting && !this.unloading) { + event.preventDefault() + this.unloading = true + this.atomApplication.saveState(false) + if (await this.prepareToUnload()) this.close() + } + }) + + this.browserWindow.on('closed', () => { + this.fileRecoveryService.didCloseWindow(this) + this.atomApplication.removeWindow(this) + this.resolveClosedPromise() + }) + + this.browserWindow.on('unresponsive', () => { + if (this.isSpec) return + const chosen = dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Force Close', 'Keep Waiting'], + message: 'Editor is not responding', + detail: + 'The editor is not responding. Would you like to force close it or just keep waiting?' + }) + if (chosen === 0) this.browserWindow.destroy() + }) + + this.browserWindow.webContents.on('crashed', () => { + if (this.headless) { + console.log('Renderer process crashed, exiting') + this.atomApplication.exit(100) + return + } + + this.fileRecoveryService.didCrashWindow(this) + const chosen = dialog.showMessageBox(this.browserWindow, { + type: 'warning', + buttons: ['Close Window', 'Reload', 'Keep It Open'], + message: 'The editor has crashed', + detail: 'Please report this issue to https://github.com/atom/atom' + }) + switch (chosen) { + case 0: return this.browserWindow.destroy() + case 1: return this.browserWindow.reload() + } + }) + + this.browserWindow.webContents.on('will-navigate', (event, url) => { + if (url !== this.browserWindow.webContents.getURL()) event.preventDefault() + }) + + this.setupContextMenu() + + // Spec window's web view should always have focus + if (this.isSpec) this.browserWindow.on('blur', () => this.browserWindow.focusOnWebView()) + } + + async prepareToUnload () { + if (this.isSpecWindow()) return true + + this.lastPrepareToUnloadPromise = new Promise(resolve => { + const callback = (event, result) => { + if (BrowserWindow.fromWebContents(event.sender) === this.browserWindow) { + ipcMain.removeListener('did-prepare-to-unload', callback) + if (!result) { + this.unloading = false + this.atomApplication.quitting = false + } + resolve(result) + } + } + ipcMain.on('did-prepare-to-unload', callback) + this.browserWindow.webContents.send('prepare-to-unload') + }) + + return this.lastPrepareToUnloadPromise + } + + openPath (pathToOpen, initialLine, initialColumn) { + return this.openLocations([{pathToOpen, initialLine, initialColumn}]) + } + + async openLocations (locationsToOpen) { + await this.loadedPromise + this.sendMessage('open-locations', locationsToOpen) + } + + replaceEnvironment (env) { + this.browserWindow.webContents.send('environment', env) + } + + sendMessage (message, detail) { + this.browserWindow.webContents.send('message', message, detail) + } + + sendCommand (command, ...args) { + if (this.isSpecWindow()) { + if (!this.atomApplication.sendCommandToFirstResponder(command)) { + switch (command) { + case 'window:reload': return this.reload() + case 'window:toggle-dev-tools': return this.toggleDevTools() + case 'window:close': return this.close() + } + } + } else if (this.isWebViewFocused()) { + this.sendCommandToBrowserWindow(command, ...args) + } else if (!this.atomApplication.sendCommandToFirstResponder(command)) { + this.sendCommandToBrowserWindow(command, ...args) + } + } + + sendURIMessage (uri) { + this.browserWindow.webContents.send('uri-message', uri) + } + + sendCommandToBrowserWindow (command, ...args) { + const action = args[0] && args[0].contextCommand + ? 'context-command' + : 'command' + this.browserWindow.webContents.send(action, command, ...args) + } + + getDimensions () { + const [x, y] = Array.from(this.browserWindow.getPosition()) + const [width, height] = Array.from(this.browserWindow.getSize()) + return {x, y, width, height} + } + + shouldAddCustomTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'custom' + ) + } + + shouldAddCustomInsetTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'custom-inset' + ) + } + + shouldHideTitleBar () { + return ( + !this.isSpec && + process.platform === 'darwin' && + this.atomApplication.config.get('core.titleBar') === 'hidden' + ) + } + + close () { + return this.browserWindow.close() + } + + focus () { + return this.browserWindow.focus() + } + + minimize () { + return this.browserWindow.minimize() + } + + maximize () { + return this.browserWindow.maximize() + } + + unmaximize () { + return this.browserWindow.unmaximize() + } + + restore () { + return this.browserWindow.restore() + } + + setFullScreen (fullScreen) { + return this.browserWindow.setFullScreen(fullScreen) + } + + setAutoHideMenuBar (autoHideMenuBar) { + return this.browserWindow.setAutoHideMenuBar(autoHideMenuBar) + } + + handlesAtomCommands () { + return !this.isSpecWindow() && this.isWebViewFocused() + } + + isFocused () { + return this.browserWindow.isFocused() + } + + isMaximized () { + return this.browserWindow.isMaximized() + } + + isMinimized () { + return this.browserWindow.isMinimized() + } + + isWebViewFocused () { + return this.browserWindow.isWebViewFocused() + } + + isSpecWindow () { + return this.isSpec + } + + reload () { + this.loadedPromise = new Promise(resolve => { this.resolveLoadedPromise = resolve }) + this.prepareToUnload().then(canUnload => { + if (canUnload) this.browserWindow.reload() + }) + return this.loadedPromise + } + + showSaveDialog (options, callback) { + options = Object.assign({ + title: 'Save File', + defaultPath: this.representedDirectoryPaths[0] + }, options) + + if (typeof callback === 'function') { + // Async + dialog.showSaveDialog(this.browserWindow, options, callback) + } else { + // Sync + return dialog.showSaveDialog(this.browserWindow, options) + } + } + + toggleDevTools () { + return this.browserWindow.toggleDevTools() + } + + openDevTools () { + return this.browserWindow.openDevTools() + } + + closeDevTools () { + return this.browserWindow.closeDevTools() + } + + setDocumentEdited (documentEdited) { + return this.browserWindow.setDocumentEdited(documentEdited) + } + + setRepresentedFilename (representedFilename) { + return this.browserWindow.setRepresentedFilename(representedFilename) + } + + setRepresentedDirectoryPaths (representedDirectoryPaths) { + this.representedDirectoryPaths = representedDirectoryPaths + this.representedDirectoryPaths.sort() + this.loadSettings.initialPaths = this.representedDirectoryPaths + this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings) + return this.atomApplication.saveState() + } + + didClosePathWithWaitSession (path) { + this.atomApplication.windowDidClosePathWithWaitSession(this, path) + } + + copy () { + return this.browserWindow.copy() + } + + disableZoom () { + return this.browserWindow.webContents.setVisualZoomLevelLimits(1, 1) + } +} diff --git a/src/pane.js b/src/pane.js index 0305b39dd89..af93f8e1e1b 100644 --- a/src/pane.js +++ b/src/pane.js @@ -790,57 +790,53 @@ class Pane { } promptToSaveItem (item, options = {}) { - if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) { - return Promise.resolve(true) - } - - let uri - if (typeof item.getURI === 'function') { - uri = item.getURI() - } else if (typeof item.getUri === 'function') { - uri = item.getUri() - } else { - return Promise.resolve(true) - } - - const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri + return new Promise((resolve, reject) => { + if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) { + return resolve(true) + } - const saveDialog = (saveButtonText, saveFn, message) => { - const chosen = this.applicationDelegate.confirm({ - message, - detailedMessage: 'Your changes will be lost if you close this item without saving.', - buttons: [saveButtonText, 'Cancel', "&Don't Save"]} - ) + let uri + if (typeof item.getURI === 'function') { + uri = item.getURI() + } else if (typeof item.getUri === 'function') { + uri = item.getUri() + } else { + return resolve(true) + } - switch (chosen) { - case 0: - return new Promise(resolve => { - return saveFn(item, error => { - if (error instanceof SaveCancelledError) { - resolve(false) - } else if (error) { - saveDialog( - 'Save as', - this.saveItemAs, - `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}` - ).then(resolve) - } else { - resolve(true) - } - }) - }) - case 1: - return Promise.resolve(false) - case 2: - return Promise.resolve(true) + const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri + + const saveDialog = (saveButtonText, saveFn, message) => { + this.applicationDelegate.confirm({ + message, + detail: 'Your changes will be lost if you close this item without saving.', + buttons: [saveButtonText, 'Cancel', "&Don't Save"] + }, response => { + switch (response) { + case 0: + return saveFn(item, error => { + if (error instanceof SaveCancelledError) { + resolve(false) + } else if (error) { + saveDialog( + 'Save as', + this.saveItemAs, + `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}` + ) + } else { + resolve(true) + } + }) + case 1: + return resolve(false) + case 2: + return resolve(true) + } + }) } - } - return saveDialog( - 'Save', - this.saveItem, - `'${title}' has changes, do you want to save them?` - ) + saveDialog('Save', this.saveItem, `'${title}' has changes, do you want to save them?`) + }) } // Public: Save the active item. @@ -908,7 +904,7 @@ class Pane { // after the item is successfully saved, or with the error if it failed. // The return value will be that of `nextAction` or `undefined` if it was not // provided - saveItemAs (item, nextAction) { + async saveItemAs (item, nextAction) { if (!item) return if (typeof item.saveAs !== 'function') return @@ -919,22 +915,34 @@ class Pane { const itemPath = item.getPath() if (itemPath && !saveOptions.defaultPath) saveOptions.defaultPath = itemPath - const newItemPath = this.applicationDelegate.showSaveDialog(saveOptions) - if (newItemPath) { - return promisify(() => item.saveAs(newItemPath)) - .then(() => { - if (nextAction) nextAction() - }) - .catch(error => { - if (nextAction) { - nextAction(error) - } else { - this.handleSaveError(error, item) - } - }) - } else if (nextAction) { - return nextAction(new SaveCancelledError('Save Cancelled')) - } + let resolveSaveDialogPromise = null + const saveDialogPromise = new Promise(resolve => { resolveSaveDialogPromise = resolve }) + this.applicationDelegate.showSaveDialog(saveOptions, newItemPath => { + if (newItemPath) { + promisify(() => item.saveAs(newItemPath)) + .then(() => { + if (nextAction) { + resolveSaveDialogPromise(nextAction()) + } else { + resolveSaveDialogPromise() + } + }) + .catch(error => { + if (nextAction) { + resolveSaveDialogPromise(nextAction(error)) + } else { + this.handleSaveError(error, item) + resolveSaveDialogPromise() + } + }) + } else if (nextAction) { + resolveSaveDialogPromise(nextAction(new SaveCancelledError('Save Cancelled'))) + } else { + resolveSaveDialogPromise() + } + }) + + return await saveDialogPromise } // Public: Save all items. diff --git a/src/protocol-handler-installer.js b/src/protocol-handler-installer.js index 37df6838972..27a272ea0d9 100644 --- a/src/protocol-handler-installer.js +++ b/src/protocol-handler-installer.js @@ -12,13 +12,13 @@ class ProtocolHandlerInstaller { } isDefaultProtocolClient () { - return remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler']) + return remote.app.isDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--']) } setAsDefaultProtocolClient () { // This Electron API is only available on Windows and macOS. There might be some // hacks to make it work on Linux; see https://github.com/electron/electron/issues/6440 - return this.isSupported() && remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler']) + return this.isSupported() && remote.app.setAsDefaultProtocolClient('atom', process.execPath, ['--uri-handler', '--']) } initialize (config, notifications) { @@ -26,19 +26,28 @@ class ProtocolHandlerInstaller { return } - if (!this.isDefaultProtocolClient()) { - const behaviorWhenNotProtocolClient = config.get(SETTING) - switch (behaviorWhenNotProtocolClient) { - case PROMPT: + const behaviorWhenNotProtocolClient = config.get(SETTING) + switch (behaviorWhenNotProtocolClient) { + case PROMPT: + if (!this.isDefaultProtocolClient()) { this.promptToBecomeProtocolClient(config, notifications) - break - case ALWAYS: + } + break + case ALWAYS: + if (!this.isDefaultProtocolClient()) { this.setAsDefaultProtocolClient() - break - case NEVER: - default: - // Do nothing - } + } + break + case NEVER: + if (process.platform === 'win32') { + // Only win32 supports deregistration + const Registry = require('winreg') + const commandKey = new Registry({hive: 'HKCR', key: `\\atom`}) + commandKey.destroy((_err, _val) => { /* no op */ }) + } + break + default: + // Do nothing } } diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index 0bacfbb8e6e..a367e6188c2 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -160,6 +160,8 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage 'editor:select-to-previous-subword-boundary': -> @selectToPreviousSubwordBoundary() 'editor:select-to-first-character-of-line': -> @selectToFirstCharacterOfLine() 'editor:select-line': -> @selectLinesContainingCursors() + 'editor:select-larger-syntax-node': -> @selectLargerSyntaxNode() + 'editor:select-smaller-syntax-node': -> @selectSmallerSyntaxNode() }), false ) diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee index 95539cc692d..2085bd6b25b 100644 --- a/src/scope-descriptor.coffee +++ b/src/scope-descriptor.coffee @@ -39,11 +39,17 @@ class ScopeDescriptor getScopesArray: -> @scopes getScopeChain: -> - @scopes - .map (scope) -> - scope = ".#{scope}" unless scope[0] is '.' - scope - .join(' ') + # For backward compatibility, prefix TextMate-style scope names with + # leading dots (e.g. 'source.js' -> '.source.js'). + if @scopes[0].includes('.') + result = '' + for scope, i in @scopes + result += ' ' if i > 0 + result += '.' if scope[0] isnt '.' + result += scope + result + else + @scopes.join(' ') toString: -> @getScopeChain() diff --git a/src/selection.js b/src/selection.js index 99c1ea95ebc..2c64fa1265c 100644 --- a/src/selection.js +++ b/src/selection.js @@ -585,7 +585,8 @@ class Selection { // is empty unless the selection spans multiple lines in which case all lines // are removed. deleteLine () { - if (this.isEmpty()) { + const range = this.getBufferRange() + if (range.isEmpty()) { const start = this.cursor.getScreenRow() const range = this.editor.bufferRowsForScreenRows(start, start + 1) if (range[1] > range[0]) { @@ -594,12 +595,12 @@ class Selection { this.editor.buffer.deleteRow(range[0]) } } else { - const range = this.getBufferRange() const start = range.start.row let end = range.end.row if (end !== this.editor.buffer.getLastRow() && range.end.column === 0) end-- this.editor.buffer.deleteRows(start, end) } + this.cursor.setBufferPosition({row: this.cursor.getBufferRow(), column: range.start.column}) } // Public: Joins the current line with the one below it. Lines will @@ -831,8 +832,12 @@ class Selection { if (clippedRange.isEmpty()) continue } - const selection = this.editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers({containsScreenRange: clippedRange}) + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + } + break } } @@ -853,8 +858,12 @@ class Selection { if (clippedRange.isEmpty()) continue } - const selection = this.editor.addSelectionForScreenRange(clippedRange) - selection.setGoalScreenRange(range) + const containingSelections = this.editor.selectionsMarkerLayer.findMarkers({containsScreenRange: clippedRange}) + if (containingSelections.length === 0) { + const selection = this.editor.addSelectionForScreenRange(clippedRange) + selection.setGoalScreenRange(range) + } + break } } diff --git a/src/syntax-scope-map.js b/src/syntax-scope-map.js new file mode 100644 index 00000000000..e000fb64717 --- /dev/null +++ b/src/syntax-scope-map.js @@ -0,0 +1,178 @@ +const parser = require('postcss-selector-parser') + +module.exports = +class SyntaxScopeMap { + constructor (scopeNamesBySelector) { + this.namedScopeTable = {} + this.anonymousScopeTable = {} + for (let selector in scopeNamesBySelector) { + this.addSelector(selector, scopeNamesBySelector[selector]) + } + setTableDefaults(this.namedScopeTable) + setTableDefaults(this.anonymousScopeTable) + } + + addSelector (selector, scopeName) { + parser((parseResult) => { + for (let selectorNode of parseResult.nodes) { + let currentTable = null + let currentIndexValue = null + + for (let i = selectorNode.nodes.length - 1; i >= 0; i--) { + const termNode = selectorNode.nodes[i] + + switch (termNode.type) { + case 'tag': + if (!currentTable) currentTable = this.namedScopeTable + if (!currentTable[termNode.value]) currentTable[termNode.value] = {} + currentTable = currentTable[termNode.value] + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'string': + if (!currentTable) currentTable = this.anonymousScopeTable + const value = termNode.value.slice(1, -1) + if (!currentTable[value]) currentTable[value] = {} + currentTable = currentTable[value] + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'universal': + if (currentTable) { + if (!currentTable['*']) currentTable['*'] = {} + currentTable = currentTable['*'] + } else { + if (!this.namedScopeTable['*']) { + this.namedScopeTable['*'] = this.anonymousScopeTable['*'] = {} + } + currentTable = this.namedScopeTable['*'] + } + if (currentIndexValue != null) { + if (!currentTable.indices) currentTable.indices = {} + if (!currentTable.indices[currentIndexValue]) currentTable.indices[currentIndexValue] = {} + currentTable = currentTable.indices[currentIndexValue] + currentIndexValue = null + } + break + + case 'combinator': + if (currentIndexValue != null) { + rejectSelector(selector) + } + + if (termNode.value === '>') { + if (!currentTable.parents) currentTable.parents = {} + currentTable = currentTable.parents + } else { + rejectSelector(selector) + } + break + + case 'pseudo': + if (termNode.value === ':nth-child') { + currentIndexValue = termNode.nodes[0].nodes[0].value + } else { + rejectSelector(selector) + } + break + + default: + rejectSelector(selector) + } + } + + currentTable.scopeName = scopeName + } + }).process(selector) + } + + get (nodeTypes, childIndices, leafIsNamed = true) { + let result + let i = nodeTypes.length - 1 + let currentTable = leafIsNamed + ? this.namedScopeTable[nodeTypes[i]] + : this.anonymousScopeTable[nodeTypes[i]] + + if (!currentTable) currentTable = this.namedScopeTable['*'] + + while (currentTable) { + if (currentTable.indices && currentTable.indices[childIndices[i]]) { + currentTable = currentTable.indices[childIndices[i]] + } + + if (currentTable.scopeName) { + result = currentTable.scopeName + } + + if (i === 0) break + i-- + currentTable = currentTable.parents && ( + currentTable.parents[nodeTypes[i]] || + currentTable.parents['*'] + ) + } + + return result + } +} + +function setTableDefaults (table) { + const defaultTypeTable = table['*'] + + for (let type in table) { + let typeTable = table[type] + if (typeTable === defaultTypeTable) continue + + if (defaultTypeTable) { + mergeTable(typeTable, defaultTypeTable) + } + + if (typeTable.parents) { + setTableDefaults(typeTable.parents) + } + + for (let key in typeTable.indices) { + const indexTable = typeTable.indices[key] + mergeTable(indexTable, typeTable, false) + if (indexTable.parents) { + setTableDefaults(indexTable.parents) + } + } + } +} + +function mergeTable (table, defaultTable, mergeIndices = true) { + if (mergeIndices && defaultTable.indices) { + if (!table.indices) table.indices = {} + for (let key in defaultTable.indices) { + if (!table.indices[key]) table.indices[key] = {} + mergeTable(table.indices[key], defaultTable.indices[key]) + } + } + + if (defaultTable.parents) { + if (!table.parents) table.parents = {} + for (let key in defaultTable.parents) { + if (!table.parents[key]) table.parents[key] = {} + mergeTable(table.parents[key], defaultTable.parents[key]) + } + } + + if (defaultTable.scopeName && !table.scopeName) { + table.scopeName = defaultTable.scopeName + } +} + +function rejectSelector (selector) { + throw new TypeError(`Unsupported selector '${selector}'`) +} diff --git a/src/text-editor-component.js b/src/text-editor-component.js index aa52468643d..48cb919d0d0 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -170,6 +170,7 @@ class TextEditorComponent { this.textDecorationBoundaries = [] this.pendingScrollTopRow = this.props.initialScrollTopRow this.pendingScrollLeftColumn = this.props.initialScrollLeftColumn + this.tabIndex = this.props.element && this.props.element.tabIndex ? this.props.element.tabIndex : -1 this.measuredContent = false this.queryGuttersToRender() @@ -576,7 +577,6 @@ class TextEditorComponent { on: {mousedown: this.didMouseDownOnContent}, style }, - this.renderHighlightDecorations(), this.renderLineTiles(), this.renderBlockDecorationMeasurementArea(), this.renderCharacterMeasurementLine() @@ -594,13 +594,15 @@ class TextEditorComponent { } renderLineTiles () { - const children = [] const style = { position: 'absolute', contain: 'strict', overflow: 'hidden' } + const children = [] + children.push(this.renderHighlightDecorations()) + if (this.hasInitialMeasurements) { const {lineComponentsByScreenLineId} = this @@ -681,7 +683,8 @@ class TextEditorComponent { scrollWidth: this.getScrollWidth(), decorationsToRender: this.decorationsToRender, cursorsBlinkedOff: this.cursorsBlinkedOff, - hiddenInputPosition: this.hiddenInputPosition + hiddenInputPosition: this.hiddenInputPosition, + tabIndex: this.tabIndex }) } @@ -3533,7 +3536,8 @@ class CursorsAndInputComponent { zIndex: 1, width: scrollWidth + 'px', height: scrollHeight + 'px', - pointerEvents: 'none' + pointerEvents: 'none', + userSelect: 'none' } }, children) } @@ -3546,7 +3550,7 @@ class CursorsAndInputComponent { const { lineHeight, hiddenInputPosition, didBlurHiddenInput, didFocusHiddenInput, didPaste, didTextInput, didKeydown, didKeyup, didKeypress, - didCompositionStart, didCompositionUpdate, didCompositionEnd + didCompositionStart, didCompositionUpdate, didCompositionEnd, tabIndex } = this.props let top, left @@ -3574,7 +3578,7 @@ class CursorsAndInputComponent { compositionupdate: didCompositionUpdate, compositionend: didCompositionEnd }, - tabIndex: -1, + tabIndex: tabIndex, style: { position: 'absolute', width: '1px', @@ -4009,6 +4013,7 @@ class HighlightsComponent { this.element.style.contain = 'strict' this.element.style.position = 'absolute' this.element.style.overflow = 'hidden' + this.element.style.userSelect = 'none' this.highlightComponentsByKey = new Map() this.update(props) } diff --git a/src/text-editor-element.js b/src/text-editor-element.js index 7218b7f0597..926f7af4480 100644 --- a/src/text-editor-element.js +++ b/src/text-editor-element.js @@ -32,7 +32,7 @@ class TextEditorElement extends HTMLElement { createdCallback () { this.emitter = new Emitter() this.initialText = this.textContent - this.tabIndex = -1 + if (this.tabIndex == null) this.tabIndex = -1 this.addEventListener('focus', (event) => this.getComponent().didFocus(event)) this.addEventListener('blur', (event) => this.getComponent().didBlur(event)) } diff --git a/src/text-editor.js b/src/text-editor.js index 3964323e179..47fb9f48551 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -3083,6 +3083,36 @@ class TextEditor { return this.expandSelectionsBackward(selection => selection.selectToBeginningOfPreviousParagraph()) } + // Extended: For each selection, select the syntax node that contains + // that selection. + selectLargerSyntaxNode () { + const languageMode = this.buffer.getLanguageMode() + if (!languageMode.getRangeForSyntaxNodeContainingRange) return + + this.expandSelectionsForward(selection => { + const currentRange = selection.getBufferRange() + const newRange = languageMode.getRangeForSyntaxNodeContainingRange(currentRange) + if (newRange) { + if (!selection._rangeStack) selection._rangeStack = [] + selection._rangeStack.push(currentRange) + selection.setBufferRange(newRange) + } + }) + } + + // Extended: Undo the effect a preceding call to {::selectLargerSyntaxNode}. + selectSmallerSyntaxNode () { + this.expandSelectionsForward(selection => { + if (selection._rangeStack) { + const lastRange = selection._rangeStack[selection._rangeStack.length - 1] + if (lastRange && selection.getBufferRange().containsRange(lastRange)) { + selection._rangeStack.length-- + selection.setBufferRange(lastRange) + } + } + }) + } + // Extended: Select the range of the given marker if it is valid. // // * `marker` A {DisplayMarker} @@ -3869,7 +3899,7 @@ class TextEditor { // Extended: Fold all foldable lines at the given indent level. // - // * `level` A {Number}. + // * `level` A {Number} starting at 0. foldAllAtIndentLevel (level) { const languageMode = this.buffer.getLanguageMode() const foldableRanges = ( diff --git a/src/text-mate-language-mode.js b/src/text-mate-language-mode.js index 123e39f58fc..1a7cb6d2e46 100644 --- a/src/text-mate-language-mode.js +++ b/src/text-mate-language-mode.js @@ -74,10 +74,15 @@ class TextMateLanguageMode { // // Returns a {Number}. suggestedIndentForBufferRow (bufferRow, tabLength, options) { - return this._suggestedIndentForTokenizedLineAtBufferRow( + const line = this.buffer.lineForRow(bufferRow) + const tokenizedLine = this.tokenizedLineForRow(bufferRow) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( bufferRow, - this.buffer.lineForRow(bufferRow), - this.tokenizedLineForRow(bufferRow), + line, + scopeDescriptor, tabLength, options ) @@ -90,10 +95,14 @@ class TextMateLanguageMode { // // Returns a {Number}. suggestedIndentForLineAtBufferRow (bufferRow, line, tabLength) { - return this._suggestedIndentForTokenizedLineAtBufferRow( + const tokenizedLine = this.buildTokenizedLineForRowWithText(bufferRow, line) + const iterator = tokenizedLine.getTokenIterator() + iterator.next() + const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) + return this._suggestedIndentForLineWithScopeAtBufferRow( bufferRow, line, - this.buildTokenizedLineForRowWithText(bufferRow, line), + scopeDescriptor, tabLength ) } @@ -111,7 +120,7 @@ class TextMateLanguageMode { const currentIndentLevel = this.indentLevelForLine(line, tabLength) if (currentIndentLevel === 0) return - const scopeDescriptor = this.scopeDescriptorForPosition([bufferRow, 0]) + const scopeDescriptor = this.scopeDescriptorForPosition(new Point(bufferRow, 0)) const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) if (!decreaseIndentRegex) return @@ -138,11 +147,7 @@ class TextMateLanguageMode { return desiredIndentLevel } - _suggestedIndentForTokenizedLineAtBufferRow (bufferRow, line, tokenizedLine, tabLength, options) { - const iterator = tokenizedLine.getTokenIterator() - iterator.next() - const scopeDescriptor = new ScopeDescriptor({scopes: iterator.getScopes()}) - + _suggestedIndentForLineWithScopeAtBufferRow (bufferRow, line, scopeDescriptor, tabLength, options) { const increaseIndentRegex = this.increaseIndentRegexForScopeDescriptor(scopeDescriptor) const decreaseIndentRegex = this.decreaseIndentRegexForScopeDescriptor(scopeDescriptor) const decreaseNextIndentRegex = this.decreaseNextIndentRegexForScopeDescriptor(scopeDescriptor) diff --git a/src/tree-sitter-grammar.js b/src/tree-sitter-grammar.js new file mode 100644 index 00000000000..d00344fb110 --- /dev/null +++ b/src/tree-sitter-grammar.js @@ -0,0 +1,72 @@ +const path = require('path') +const SyntaxScopeMap = require('./syntax-scope-map') +const Module = require('module') + +module.exports = +class TreeSitterGrammar { + constructor (registry, filePath, params) { + this.registry = registry + this.id = params.id + this.name = params.name + this.legacyScopeName = params.legacyScopeName + if (params.contentRegExp) this.contentRegExp = new RegExp(params.contentRegExp) + + this.folds = params.folds || [] + + this.commentStrings = { + commentStartString: params.comments && params.comments.start, + commentEndString: params.comments && params.comments.end + } + + const scopeSelectors = {} + for (const key in params.scopes || {}) { + scopeSelectors[key] = params.scopes[key] + .split('.') + .map(s => `syntax--${s}`) + .join(' ') + } + + this.scopeMap = new SyntaxScopeMap(scopeSelectors) + this.fileTypes = params.fileTypes + + // TODO - When we upgrade to a new enough version of node, use `require.resolve` + // with the new `paths` option instead of this private API. + const languageModulePath = Module._resolveFilename(params.parser, { + id: filePath, + filename: filePath, + paths: Module._nodeModulePaths(path.dirname(filePath)) + }) + + this.languageModule = require(languageModulePath) + this.scopesById = new Map() + this.idsByScope = {} + this.nextScopeId = 256 + 1 + this.registration = null + } + + idForScope (scope) { + let id = this.idsByScope[scope] + if (!id) { + id = this.nextScopeId += 2 + this.idsByScope[scope] = id + this.scopesById.set(id, scope) + } + return id + } + + classNameForScopeId (id) { + return this.scopesById.get(id) + } + + get scopeName () { + return this.id + } + + activate () { + this.registration = this.registry.addGrammar(this) + } + + deactivate () { + if (this.registration) this.registration.dispose() + } +} diff --git a/src/tree-sitter-language-mode.js b/src/tree-sitter-language-mode.js new file mode 100644 index 00000000000..41c87ba004a --- /dev/null +++ b/src/tree-sitter-language-mode.js @@ -0,0 +1,532 @@ +const {Document} = require('tree-sitter') +const {Point, Range, Emitter} = require('atom') +const ScopeDescriptor = require('./scope-descriptor') +const TokenizedLine = require('./tokenized-line') +const TextMateLanguageMode = require('./text-mate-language-mode') + +let nextId = 0 + +module.exports = +class TreeSitterLanguageMode { + constructor ({buffer, grammar, config}) { + this.id = nextId++ + this.buffer = buffer + this.grammar = grammar + this.config = config + this.document = new Document() + this.document.setInput(new TreeSitterTextBufferInput(buffer)) + this.document.setLanguage(grammar.languageModule) + this.document.parse() + this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.id]}) + this.emitter = new Emitter() + this.isFoldableCache = [] + + // TODO: Remove this once TreeSitterLanguageMode implements its own auto-indentation system. This + // is temporarily needed in order to delegate to the TextMateLanguageMode's auto-indent system. + this.regexesByPattern = {} + } + + getLanguageId () { + return this.grammar.id + } + + bufferDidChange ({oldRange, newRange, oldText, newText}) { + const startRow = oldRange.start.row + const oldEndRow = oldRange.end.row + const newEndRow = newRange.end.row + this.isFoldableCache.splice(startRow, oldEndRow - startRow, ...new Array(newEndRow - startRow)) + this.document.edit({ + startIndex: this.buffer.characterIndexForPosition(oldRange.start), + lengthRemoved: oldText.length, + lengthAdded: newText.length, + startPosition: oldRange.start, + extentRemoved: oldRange.getExtent(), + extentAdded: newRange.getExtent() + }) + } + + /* + Section - Highlighting + */ + + buildHighlightIterator () { + const invalidatedRanges = this.document.parse() + for (let i = 0, n = invalidatedRanges.length; i < n; i++) { + const range = invalidatedRanges[i] + const startRow = range.start.row + const endRow = range.end.row + for (let row = startRow; row < endRow; row++) { + this.isFoldableCache[row] = undefined + } + this.emitter.emit('did-change-highlighting', range) + } + return new TreeSitterHighlightIterator(this) + } + + onDidChangeHighlighting (callback) { + return this.emitter.on('did-change-hightlighting', callback) + } + + classNameForScopeId (scopeId) { + return this.grammar.classNameForScopeId(scopeId) + } + + /* + Section - Commenting + */ + + commentStringsForPosition () { + return this.grammar.commentStrings + } + + isRowCommented () { + return false + } + + /* + Section - Indentation + */ + + suggestedIndentForLineAtBufferRow (row, line, tabLength) { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + line, + this.rootScopeDescriptor, + tabLength + ) + } + + suggestedIndentForBufferRow (row, tabLength, options) { + return this._suggestedIndentForLineWithScopeAtBufferRow( + row, + this.buffer.lineForRow(row), + this.rootScopeDescriptor, + tabLength, + options + ) + } + + indentLevelForLine (line, tabLength = tabLength) { + let indentLength = 0 + for (let i = 0, {length} = line; i < length; i++) { + const char = line[i] + if (char === '\t') { + indentLength += tabLength - (indentLength % tabLength) + } else if (char === ' ') { + indentLength++ + } else { + break + } + } + return indentLength / tabLength + } + + /* + Section - Folding + */ + + isFoldableAtRow (row) { + if (this.isFoldableCache[row] != null) return this.isFoldableCache[row] + const result = this.getFoldableRangeContainingPoint(Point(row, Infinity), 0, true) != null + this.isFoldableCache[row] = result + return result + } + + getFoldableRanges () { + return this.getFoldableRangesAtIndentLevel(null) + } + + getFoldableRangesAtIndentLevel (goalLevel) { + let result = [] + let stack = [{node: this.document.rootNode, level: 0}] + while (stack.length > 0) { + const {node, level} = stack.pop() + + const range = this.getFoldableRangeForNode(node) + if (range) { + if (goalLevel == null || level === goalLevel) { + let updatedExistingRange = false + for (let i = 0, {length} = result; i < length; i++) { + if (result[i].start.row === range.start.row && + result[i].end.row === range.end.row) { + result[i] = range + updatedExistingRange = true + break + } + } + if (!updatedExistingRange) result.push(range) + } + } + + const parentStartRow = node.startPosition.row + const parentEndRow = node.endPosition.row + for (let children = node.namedChildren, i = 0, {length} = children; i < length; i++) { + const child = children[i] + const {startPosition: childStart, endPosition: childEnd} = child + if (childEnd.row > childStart.row) { + if (childStart.row === parentStartRow && childEnd.row === parentEndRow) { + stack.push({node: child, level: level}) + } else { + const childLevel = range && range.containsPoint(childStart) && range.containsPoint(childEnd) + ? level + 1 + : level + if (childLevel <= goalLevel || goalLevel == null) { + stack.push({node: child, level: childLevel}) + } + } + } + } + } + + return result.sort((a, b) => a.start.row - b.start.row) + } + + getFoldableRangeContainingPoint (point, tabLength, existenceOnly = false) { + let node = this.document.rootNode.descendantForPosition(this.buffer.clipPosition(point)) + while (node) { + if (existenceOnly && node.startPosition.row < point.row) break + if (node.endPosition.row > point.row) { + const range = this.getFoldableRangeForNode(node, existenceOnly) + if (range) return range + } + node = node.parent + } + } + + getFoldableRangeForNode (node, existenceOnly) { + const {children, type: nodeType} = node + const childCount = children.length + let childTypes + + for (var i = 0, {length} = this.grammar.folds; i < length; i++) { + const foldEntry = this.grammar.folds[i] + + if (foldEntry.type) { + if (typeof foldEntry.type === 'string') { + if (foldEntry.type !== nodeType) continue + } else { + if (!foldEntry.type.includes(nodeType)) continue + } + } + + let foldStart + const startEntry = foldEntry.start + if (startEntry) { + if (startEntry.index != null) { + const child = children[startEntry.index] + if (!child || (startEntry.type && startEntry.type !== child.type)) continue + foldStart = child.endPosition + } else { + if (!childTypes) childTypes = children.map(child => child.type) + const index = typeof startEntry.type === 'string' + ? childTypes.indexOf(startEntry.type) + : childTypes.findIndex(type => startEntry.type.includes(type)) + if (index === -1) continue + foldStart = children[index].endPosition + } + } else { + foldStart = new Point(node.startPosition.row, Infinity) + } + + let foldEnd + const endEntry = foldEntry.end + if (endEntry) { + let foldEndNode + if (endEntry.index != null) { + const index = endEntry.index < 0 ? childCount + endEntry.index : endEntry.index + foldEndNode = children[index] + if (!foldEndNode || (endEntry.type && endEntry.type !== foldEndNode.type)) continue + } else { + if (!childTypes) childTypes = children.map(foldEndNode => foldEndNode.type) + const index = typeof endEntry.type === 'string' + ? childTypes.indexOf(endEntry.type) + : childTypes.findIndex(type => endEntry.type.includes(type)) + if (index === -1) continue + foldEndNode = children[index] + } + + if (foldEndNode.endIndex - foldEndNode.startIndex > 1 && foldEndNode.startPosition.row > foldStart.row) { + foldEnd = new Point(foldEndNode.startPosition.row - 1, Infinity) + } else { + foldEnd = foldEndNode.startPosition + } + } else { + const {endPosition} = node + if (endPosition.column === 0) { + foldEnd = Point(endPosition.row - 1, Infinity) + } else if (childCount > 0) { + foldEnd = endPosition + } else { + foldEnd = Point(endPosition.row, 0) + } + } + + return existenceOnly ? true : new Range(foldStart, foldEnd) + } + } + + /* + Syntax Tree APIs + */ + + getRangeForSyntaxNodeContainingRange (range) { + const startIndex = this.buffer.characterIndexForPosition(range.start) + const endIndex = this.buffer.characterIndexForPosition(range.end) + let node = this.document.rootNode.descendantForIndex(startIndex, endIndex - 1) + while (node && node.startIndex === startIndex && node.endIndex === endIndex) { + node = node.parent + } + if (node) return new Range(node.startPosition, node.endPosition) + } + + /* + Section - Backward compatibility shims + */ + + tokenizedLineForRow (row) { + return new TokenizedLine({ + openScopes: [], + text: this.buffer.lineForRow(row), + tags: [], + ruleStack: [], + lineEnding: this.buffer.lineEndingForRow(row), + tokenIterator: null, + grammar: this.grammar + }) + } + + scopeDescriptorForPosition (point) { + const result = [] + let node = this.document.rootNode.descendantForPosition(point) + + // Don't include anonymous token types like '(' because they prevent scope chains + // from being parsed as CSS selectors by the `slick` parser. Other css selector + // parsers like `postcss-selector-parser` do allow arbitrary quoted strings in + // selectors. + if (!node.isNamed) node = node.parent + + while (node) { + result.push(node.type) + node = node.parent + } + result.push(this.grammar.id) + return new ScopeDescriptor({scopes: result.reverse()}) + } + + hasTokenForSelector (scopeSelector) { + return false + } + + getGrammar () { + return this.grammar + } +} + +class TreeSitterHighlightIterator { + constructor (layer, document) { + this.layer = layer + + // Conceptually, the iterator represents a single position in the text. It stores this + // position both as a character index and as a `Point`. This position corresponds to a + // leaf node of the syntax tree, which either contains or follows the iterator's + // textual position. The `currentNode` property represents that leaf node, and + // `currentChildIndex` represents the child index of that leaf node within its parent. + this.currentIndex = null + this.currentPosition = null + this.currentNode = null + this.currentChildIndex = null + + // In order to determine which selectors match its current node, the iterator maintains + // a list of the current node's ancestors. Because the selectors can use the `:nth-child` + // pseudo-class, each node's child index is also stored. + this.containingNodeTypes = [] + this.containingNodeChildIndices = [] + + // At any given position, the iterator exposes the list of class names that should be + // *ended* at its current position and the list of class names that should be *started* + // at its current position. + this.closeTags = [] + this.openTags = [] + } + + seek (targetPosition) { + const containingTags = [] + + this.closeTags.length = 0 + this.openTags.length = 0 + this.containingNodeTypes.length = 0 + this.containingNodeChildIndices.length = 0 + this.currentPosition = targetPosition + this.currentIndex = this.layer.buffer.characterIndexForPosition(targetPosition) + + var node = this.layer.document.rootNode + var childIndex = -1 + var done = false + var nodeContainsTarget = true + do { + this.currentNode = node + this.currentChildIndex = childIndex + if (!nodeContainsTarget) break + this.containingNodeTypes.push(node.type) + this.containingNodeChildIndices.push(childIndex) + + const scopeName = this.currentScopeName() + if (scopeName) { + const id = this.layer.grammar.idForScope(scopeName) + if (this.currentIndex === node.startIndex) { + this.openTags.push(id) + } else { + containingTags.push(id) + } + } + + done = true + for (var i = 0, {children} = node, childCount = children.length; i < childCount; i++) { + const child = children[i] + if (child.endIndex > this.currentIndex) { + node = child + childIndex = i + done = false + if (child.startIndex > this.currentIndex) nodeContainsTarget = false + break + } + } + } while (!done) + + return containingTags + } + + moveToSuccessor () { + this.closeTags.length = 0 + this.openTags.length = 0 + + if (!this.currentNode) { + this.currentPosition = {row: Infinity, column: Infinity} + return false + } + + do { + if (this.currentIndex < this.currentNode.startIndex) { + this.currentIndex = this.currentNode.startIndex + this.currentPosition = this.currentNode.startPosition + this.pushOpenTag() + this.descendLeft() + } else if (this.currentIndex < this.currentNode.endIndex) { + while (true) { + this.currentIndex = this.currentNode.endIndex + this.currentPosition = this.currentNode.endPosition + this.pushCloseTag() + + const {nextSibling} = this.currentNode + if (nextSibling) { + this.currentNode = nextSibling + this.currentChildIndex++ + if (this.currentIndex === nextSibling.startIndex) { + this.pushOpenTag() + this.descendLeft() + } + break + } else { + this.currentNode = this.currentNode.parent + this.currentChildIndex = last(this.containingNodeChildIndices) + if (!this.currentNode) break + } + } + } else if (this.currentNode.startIndex < this.currentNode.endIndex) { + this.currentNode = this.currentNode.nextSibling + if (this.currentNode) { + this.currentChildIndex++ + this.currentPosition = this.currentNode.startPosition + this.currentIndex = this.currentNode.startIndex + this.pushOpenTag() + this.descendLeft() + } + } else { + this.pushCloseTag() + this.currentNode = this.currentNode.parent + this.currentChildIndex = last(this.containingNodeChildIndices) + } + } while (this.closeTags.length === 0 && this.openTags.length === 0 && this.currentNode) + + return true + } + + getPosition () { + return this.currentPosition + } + + getCloseScopeIds () { + return this.closeTags.slice() + } + + getOpenScopeIds () { + return this.openTags.slice() + } + + // Private methods + + descendLeft () { + let child + while ((child = this.currentNode.firstChild) && this.currentIndex === child.startIndex) { + this.currentNode = child + this.currentChildIndex = 0 + this.pushOpenTag() + } + } + + currentScopeName () { + return this.layer.grammar.scopeMap.get( + this.containingNodeTypes, + this.containingNodeChildIndices, + this.currentNode.isNamed + ) + } + + pushCloseTag () { + const scopeName = this.currentScopeName() + if (scopeName) this.closeTags.push(this.layer.grammar.idForScope(scopeName)) + this.containingNodeTypes.pop() + this.containingNodeChildIndices.pop() + } + + pushOpenTag () { + this.containingNodeTypes.push(this.currentNode.type) + this.containingNodeChildIndices.push(this.currentChildIndex) + const scopeName = this.currentScopeName() + if (scopeName) this.openTags.push(this.layer.grammar.idForScope(scopeName)) + } +} + +class TreeSitterTextBufferInput { + constructor (buffer) { + this.buffer = buffer + this.seek(0) + } + + seek (characterIndex) { + this.position = this.buffer.positionForCharacterIndex(characterIndex) + } + + read () { + const endPosition = this.buffer.clipPosition(this.position.traverse({row: 1000, column: 0})) + const text = this.buffer.getTextInRange([this.position, endPosition]) + this.position = endPosition + return text + } +} + +function last (array) { + return array[array.length - 1] +} + +// TODO: Remove this once TreeSitterLanguageMode implements its own auto-indent system. +[ + '_suggestedIndentForLineWithScopeAtBufferRow', + 'suggestedIndentForEditedBufferRow', + 'increaseIndentRegexForScopeDescriptor', + 'decreaseIndentRegexForScopeDescriptor', + 'decreaseNextIndentRegexForScopeDescriptor', + 'regexForPattern' +].forEach(methodName => { + module.exports.prototype[methodName] = TextMateLanguageMode.prototype[methodName] +}) diff --git a/src/window-event-handler.js b/src/window-event-handler.js index 6d380819b56..da735294e59 100644 --- a/src/window-event-handler.js +++ b/src/window-event-handler.js @@ -9,6 +9,7 @@ class WindowEventHandler { this.handleFocusNext = this.handleFocusNext.bind(this) this.handleFocusPrevious = this.handleFocusPrevious.bind(this) this.handleWindowBlur = this.handleWindowBlur.bind(this) + this.handleWindowResize = this.handleWindowResize.bind(this) this.handleEnterFullScreen = this.handleEnterFullScreen.bind(this) this.handleLeaveFullScreen = this.handleLeaveFullScreen.bind(this) this.handleWindowBeforeunload = this.handleWindowBeforeunload.bind(this) @@ -51,6 +52,7 @@ class WindowEventHandler { this.addEventListener(this.window, 'beforeunload', this.handleWindowBeforeunload) this.addEventListener(this.window, 'focus', this.handleWindowFocus) this.addEventListener(this.window, 'blur', this.handleWindowBlur) + this.addEventListener(this.window, 'resize', this.handleWindowResize) this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent) this.addEventListener(this.document, 'keydown', this.handleDocumentKeyEvent) @@ -189,6 +191,10 @@ class WindowEventHandler { this.atomEnvironment.storeWindowDimensions() } + handleWindowResize () { + this.atomEnvironment.storeWindowDimensions() + } + handleEnterFullScreen () { this.document.body.classList.add('fullscreen') } diff --git a/src/workspace.js b/src/workspace.js index 5e85401eff1..12716874800 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -1160,16 +1160,17 @@ module.exports = class Workspace extends Model { // * `uri` A {String} containing a URI. // // Returns a {Promise} that resolves to the {TextEditor} (or other item) for the given URI. - createItemForURI (uri, options) { + async createItemForURI (uri, options) { if (uri != null) { - for (let opener of this.getOpeners()) { + for (const opener of this.getOpeners()) { const item = opener(uri, options) - if (item != null) return Promise.resolve(item) + if (item != null) return item } } try { - return this.openTextFile(uri, options) + const item = await this.openTextFile(uri, options) + return item } catch (error) { switch (error.code) { case 'CANCELLED': @@ -1199,7 +1200,7 @@ module.exports = class Workspace extends Model { } } - openTextFile (uri, options) { + async openTextFile (uri, options) { const filePath = this.project.resolvePath(uri) if (filePath != null) { @@ -1214,23 +1215,38 @@ module.exports = class Workspace extends Model { } const fileSize = fs.getSizeSync(filePath) - if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { - const choice = this.applicationDelegate.confirm({ + + let [resolveConfirmFileOpenPromise, rejectConfirmFileOpenPromise] = [] + const confirmFileOpenPromise = new Promise((resolve, reject) => { + resolveConfirmFileOpenPromise = resolve + rejectConfirmFileOpenPromise = reject + }) + + if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { // 40MB by default + this.applicationDelegate.confirm({ message: 'Atom will be unresponsive during the loading of very large files.', - detailedMessage: 'Do you still want to load this file?', + detail: 'Do you still want to load this file?', buttons: ['Proceed', 'Cancel'] + }, response => { + if (response === 1) { + rejectConfirmFileOpenPromise() + } else { + resolveConfirmFileOpenPromise() + } }) - if (choice === 1) { - const error = new Error() - error.code = 'CANCELLED' - throw error - } + } else { + resolveConfirmFileOpenPromise() } - return this.project.bufferForPath(filePath, options) - .then(buffer => { - return this.textEditorRegistry.build(Object.assign({buffer, autoHeight: false}, options)) - }) + try { + await confirmFileOpenPromise + const buffer = await this.project.bufferForPath(filePath, options) + return this.textEditorRegistry.build(Object.assign({buffer, autoHeight: false}, options)) + } catch (e) { + const error = new Error() + error.code = 'CANCELLED' + throw error + } } handleGrammarUsed (grammar) { @@ -1987,25 +2003,22 @@ module.exports = class Workspace extends Model { checkoutHeadRevision (editor) { if (editor.getPath()) { - const checkoutHead = () => { - return this.project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) - .then(repository => repository && repository.checkoutHeadForEditor(editor)) + const checkoutHead = async () => { + const repository = await this.project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) + if (repository) repository.checkoutHeadForEditor(editor) } if (this.config.get('editor.confirmCheckoutHeadRevision')) { this.applicationDelegate.confirm({ message: 'Confirm Checkout HEAD Revision', - detailedMessage: `Are you sure you want to discard all changes to "${editor.getFileName()}" since the last Git commit?`, - buttons: { - OK: checkoutHead, - Cancel: null - } + detail: `Are you sure you want to discard all changes to "${editor.getFileName()}" since the last Git commit?`, + buttons: ['OK', 'Cancel'] + }, response => { + if (response === 0) checkoutHead() }) } else { - return checkoutHead() + checkoutHead() } - } else { - return Promise.resolve(false) } } }