mirror of
https://github.com/atom/atom.git
synced 2026-01-23 22:08:08 -05:00
Merge remote-tracking branch 'origin/master' into mkt-core-uri-handlers
This commit is contained in:
@@ -6,6 +6,6 @@
|
||||
"url": "https://github.com/atom/atom.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"atom-package-manager": "1.18.8"
|
||||
"atom-package-manager": "1.18.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
'ctrl-shift-w': 'editor:select-word'
|
||||
'cmd-ctrl-left': 'editor:move-selection-left'
|
||||
'cmd-ctrl-right': 'editor:move-selection-right'
|
||||
'cmd-shift-V': 'editor:paste-without-reformatting'
|
||||
|
||||
# Emacs
|
||||
'alt-f': 'editor:move-to-end-of-word'
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
'alt-shift-right': 'editor:select-to-next-subword-boundary'
|
||||
'alt-backspace': 'editor:delete-to-beginning-of-subword'
|
||||
'alt-delete': 'editor:delete-to-end-of-subword'
|
||||
'ctrl-shift-V': 'editor:paste-without-reformatting'
|
||||
|
||||
# Sublime Parity
|
||||
'ctrl-a': 'core:select-all'
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
'alt-shift-right': 'editor:select-to-next-subword-boundary'
|
||||
'alt-backspace': 'editor:delete-to-beginning-of-subword'
|
||||
'alt-delete': 'editor:delete-to-end-of-subword'
|
||||
'ctrl-shift-V': 'editor:paste-without-reformatting'
|
||||
|
||||
# Sublime Parity
|
||||
'ctrl-a': 'core:select-all'
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
{ label: 'Copy', command: 'core:copy' }
|
||||
{ label: 'Copy Path', command: 'editor:copy-path' }
|
||||
{ label: 'Paste', command: 'core:paste' }
|
||||
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
|
||||
{ label: 'Select All', command: 'core:select-all' }
|
||||
{ type: 'separator' }
|
||||
{ label: 'Toggle Comments', command: 'editor:toggle-line-comments' }
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
{ label: 'C&opy', command: 'core:copy' }
|
||||
{ label: 'Copy Pat&h', command: 'editor:copy-path' }
|
||||
{ label: '&Paste', command: 'core:paste' }
|
||||
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
|
||||
{ label: 'Select &All', command: 'core:select-all' }
|
||||
{ type: 'separator' }
|
||||
{ label: '&Toggle Comments', command: 'editor:toggle-line-comments' }
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
{ label: '&Copy', command: 'core:copy' }
|
||||
{ label: 'Copy Pat&h', command: 'editor:copy-path' }
|
||||
{ label: '&Paste', command: 'core:paste' }
|
||||
{ label: 'Paste Without Reformatting', command: 'editor:paste-without-reformatting' }
|
||||
{ label: 'Select &All', command: 'core:select-all' }
|
||||
{ type: 'separator' }
|
||||
{ label: '&Toggle Comments', command: 'editor:toggle-line-comments' }
|
||||
|
||||
90
package.json
90
package.json
@@ -14,9 +14,10 @@
|
||||
"license": "MIT",
|
||||
"electronVersion": "1.6.15",
|
||||
"dependencies": {
|
||||
"@atom/nsfw": "^1.0.18",
|
||||
"@atom/source-map-support": "^0.3.4",
|
||||
"async": "0.2.6",
|
||||
"atom-keymap": "8.2.7",
|
||||
"atom-keymap": "8.2.8",
|
||||
"atom-select-list": "^0.1.0",
|
||||
"atom-ui": "0.4.1",
|
||||
"babel-core": "5.8.38",
|
||||
@@ -24,14 +25,14 @@
|
||||
"chai": "3.5.0",
|
||||
"chart.js": "^2.3.0",
|
||||
"clear-cut": "^2.0.2",
|
||||
"coffee-script": "1.11.1",
|
||||
"coffee-script": "1.12.7",
|
||||
"color": "^0.7.3",
|
||||
"dedent": "^0.6.0",
|
||||
"dedent": "^0.7.0",
|
||||
"devtron": "1.3.0",
|
||||
"etch": "^0.12.6",
|
||||
"event-kit": "^2.4.0",
|
||||
"find-parent-dir": "^0.3.0",
|
||||
"first-mate": "7.0.9",
|
||||
"first-mate": "7.1.0",
|
||||
"focus-trap": "^2.3.0",
|
||||
"fs-admin": "^0.1.6",
|
||||
"fs-plus": "^3.0.1",
|
||||
@@ -53,7 +54,6 @@
|
||||
"mocha-multi-reporters": "^1.1.4",
|
||||
"mock-spawn": "^0.2.6",
|
||||
"normalize-package-data": "^2.0.0",
|
||||
"nsfw": "^1.0.15",
|
||||
"nslog": "^3",
|
||||
"oniguruma": "6.2.1",
|
||||
"pathwatcher": "8.0.1",
|
||||
@@ -65,12 +65,12 @@
|
||||
"scandal": "^3.1.0",
|
||||
"scoped-property-store": "^0.17.0",
|
||||
"scrollbar-style": "^3.2",
|
||||
"season": "^6.0.1",
|
||||
"season": "^6.0.2",
|
||||
"semver": "^4.3.3",
|
||||
"service-hub": "^0.7.4",
|
||||
"sinon": "1.17.4",
|
||||
"temp": "^0.8.3",
|
||||
"text-buffer": "13.5.7",
|
||||
"text-buffer": "13.8.1",
|
||||
"typescript-simple": "1.0.0",
|
||||
"underscore-plus": "^1.6.6",
|
||||
"winreg": "^1.2.1",
|
||||
@@ -90,85 +90,85 @@
|
||||
"solarized-dark-syntax": "1.1.2",
|
||||
"solarized-light-syntax": "1.1.2",
|
||||
"about": "1.7.8",
|
||||
"archive-view": "0.63.4",
|
||||
"autocomplete-atom-api": "0.10.3",
|
||||
"autocomplete-css": "0.17.3",
|
||||
"autocomplete-html": "0.8.2",
|
||||
"autocomplete-plus": "2.36.7",
|
||||
"autocomplete-snippets": "1.11.1",
|
||||
"archive-view": "0.64.1",
|
||||
"autocomplete-atom-api": "0.10.5",
|
||||
"autocomplete-css": "0.17.4",
|
||||
"autocomplete-html": "0.8.3",
|
||||
"autocomplete-plus": "2.37.2",
|
||||
"autocomplete-snippets": "1.11.2",
|
||||
"autoflow": "0.29.0",
|
||||
"autosave": "0.24.6",
|
||||
"background-tips": "0.27.1",
|
||||
"bookmarks": "0.44.4",
|
||||
"bracket-matcher": "0.88.0",
|
||||
"command-palette": "0.41.1",
|
||||
"command-palette": "0.42.0",
|
||||
"dalek": "0.2.1",
|
||||
"deprecation-cop": "0.56.9",
|
||||
"dev-live-reload": "0.47.1",
|
||||
"encoding-selector": "0.23.7",
|
||||
"exception-reporting": "0.41.4",
|
||||
"find-and-replace": "0.212.3",
|
||||
"fuzzy-finder": "1.6.1",
|
||||
"github": "0.7.0",
|
||||
"exception-reporting": "0.41.5",
|
||||
"find-and-replace": "0.213.0",
|
||||
"fuzzy-finder": "1.7.2",
|
||||
"github": "0.8.0",
|
||||
"git-diff": "1.3.6",
|
||||
"go-to-line": "0.32.1",
|
||||
"grammar-selector": "0.49.6",
|
||||
"grammar-selector": "0.49.8",
|
||||
"image-view": "0.62.4",
|
||||
"incompatible-packages": "0.27.3",
|
||||
"keybinding-resolver": "0.38.0",
|
||||
"keybinding-resolver": "0.38.1",
|
||||
"line-ending-selector": "0.7.4",
|
||||
"link": "0.31.3",
|
||||
"markdown-preview": "0.159.15",
|
||||
"markdown-preview": "0.159.18",
|
||||
"metrics": "1.2.6",
|
||||
"notifications": "0.69.2",
|
||||
"open-on-github": "1.2.1",
|
||||
"open-on-github": "1.3.0",
|
||||
"package-generator": "1.1.1",
|
||||
"settings-view": "0.252.0",
|
||||
"snippets": "1.1.5",
|
||||
"settings-view": "0.253.0",
|
||||
"snippets": "1.1.9",
|
||||
"spell-check": "0.72.3",
|
||||
"status-bar": "1.8.13",
|
||||
"styleguide": "0.49.7",
|
||||
"status-bar": "1.8.14",
|
||||
"styleguide": "0.49.8",
|
||||
"symbols-view": "0.118.1",
|
||||
"tabs": "0.107.4",
|
||||
"tabs": "0.109.1",
|
||||
"timecop": "0.36.0",
|
||||
"tree-view": "0.219.0",
|
||||
"tree-view": "0.221.1",
|
||||
"update-package-dependencies": "0.12.0",
|
||||
"welcome": "0.36.5",
|
||||
"whitespace": "0.37.4",
|
||||
"whitespace": "0.37.5",
|
||||
"wrap-guide": "0.40.2",
|
||||
"language-c": "0.58.1",
|
||||
"language-clojure": "0.22.4",
|
||||
"language-coffee-script": "0.49.1",
|
||||
"language-coffee-script": "0.49.3",
|
||||
"language-csharp": "0.14.3",
|
||||
"language-css": "0.42.6",
|
||||
"language-gfm": "0.90.1",
|
||||
"language-css": "0.42.7",
|
||||
"language-gfm": "0.90.2",
|
||||
"language-git": "0.19.1",
|
||||
"language-go": "0.44.2",
|
||||
"language-html": "0.48.1",
|
||||
"language-hyperlink": "0.16.2",
|
||||
"language-java": "0.27.4",
|
||||
"language-javascript": "0.127.5",
|
||||
"language-go": "0.44.3",
|
||||
"language-html": "0.48.2",
|
||||
"language-hyperlink": "0.16.3",
|
||||
"language-java": "0.27.6",
|
||||
"language-javascript": "0.127.6",
|
||||
"language-json": "0.19.1",
|
||||
"language-less": "0.33.0",
|
||||
"language-make": "0.22.3",
|
||||
"language-mustache": "0.14.3",
|
||||
"language-mustache": "0.14.4",
|
||||
"language-objective-c": "0.15.1",
|
||||
"language-perl": "0.37.0",
|
||||
"language-php": "0.42.1",
|
||||
"language-perl": "0.38.1",
|
||||
"language-php": "0.42.2",
|
||||
"language-property-list": "0.9.1",
|
||||
"language-python": "0.45.4",
|
||||
"language-ruby": "0.71.3",
|
||||
"language-python": "0.45.5",
|
||||
"language-ruby": "0.71.4",
|
||||
"language-ruby-on-rails": "0.25.2",
|
||||
"language-sass": "0.61.1",
|
||||
"language-shellscript": "0.25.3",
|
||||
"language-shellscript": "0.25.4",
|
||||
"language-source": "0.9.0",
|
||||
"language-sql": "0.25.8",
|
||||
"language-text": "0.7.3",
|
||||
"language-todo": "0.29.2",
|
||||
"language-todo": "0.29.3",
|
||||
"language-toml": "0.18.1",
|
||||
"language-typescript": "0.2.2",
|
||||
"language-xml": "0.35.2",
|
||||
"language-yaml": "0.31.0"
|
||||
"language-yaml": "0.31.1"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -27,47 +27,37 @@ module.exports = function (packagedAppPath) {
|
||||
coreModules.has(modulePath) ||
|
||||
(relativePath.startsWith(path.join('..', 'src')) && relativePath.endsWith('-element.js')) ||
|
||||
relativePath.startsWith(path.join('..', 'node_modules', 'dugite')) ||
|
||||
relativePath.endsWith(path.join('node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js')) ||
|
||||
relativePath.endsWith(path.join('node_modules', 'fs-extra', 'lib', 'index.js')) ||
|
||||
relativePath.endsWith(path.join('node_modules', 'graceful-fs', 'graceful-fs.js')) ||
|
||||
relativePath.endsWith(path.join('node_modules', 'htmlparser2', 'lib', 'index.js')) ||
|
||||
relativePath.endsWith(path.join('node_modules', 'minimatch', 'minimatch.js')) ||
|
||||
relativePath === path.join('..', 'exports', 'atom.js') ||
|
||||
relativePath === path.join('..', 'src', 'electron-shims.js') ||
|
||||
relativePath === path.join('..', 'src', 'safe-clipboard.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'atom-keymap', 'lib', 'command-event.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'babel-core', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'cached-run-in-this-context', 'lib', 'main.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'cson-parser', 'node_modules', 'coffee-script', 'lib', 'coffee-script', 'register.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'decompress-zip', 'lib', 'decompress-zip.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'debug', 'node.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'git-utils', 'src', 'git.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'markdown-preview', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'roaster', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'task-lists', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'iconv-lite', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'less', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less', 'fs.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'less', 'lib', 'less-node', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'less', 'node_modules', 'graceful-fs', 'graceful-fs.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'minimatch', 'minimatch.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'node-fetch', 'lib', 'fetch-error.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'nsfw', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'superstring', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'oniguruma', 'src', 'oniguruma.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'request', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'resolve', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'resolve', 'lib', 'core.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'scandal', 'node_modules', 'minimatch', 'minimatch.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'glob', 'glob.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'settings-view', 'node_modules', 'minimatch', 'minimatch.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'spellchecker', 'lib', 'spellchecker.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'spelling-manager', 'node_modules', 'natural', 'lib', 'natural', 'index.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'tar', 'tar.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'temp', 'lib', 'temp.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js') ||
|
||||
relativePath === path.join('..', 'node_modules', 'tree-view', 'node_modules', 'minimatch', 'minimatch.js')
|
||||
relativePath === path.join('..', 'node_modules', 'tmp', 'lib', 'tmp.js')
|
||||
)
|
||||
}
|
||||
}).then((snapshotScript) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'name': 'Test Ruby'
|
||||
'scopeName': 'test.rb'
|
||||
'firstLineMatch': '^\\#!.*(?:\\s|\\/)(?:testruby)(?:$|\\s)'
|
||||
'fileTypes': [
|
||||
'rb'
|
||||
]
|
||||
|
||||
@@ -120,6 +120,8 @@ describe "the `grammars` global", ->
|
||||
atom.grammars.grammarForScopeName('source.ruby').bundledPackage = true
|
||||
atom.grammars.grammarForScopeName('test.rb').bundledPackage = false
|
||||
|
||||
expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env ruby').scopeName).toBe 'source.ruby'
|
||||
expect(atom.grammars.selectGrammar('test.rb', '#!/usr/bin/env testruby').scopeName).toBe 'test.rb'
|
||||
expect(atom.grammars.selectGrammar('test.rb').scopeName).toBe 'test.rb'
|
||||
|
||||
describe "when there is no file path", ->
|
||||
|
||||
@@ -6,6 +6,7 @@ describe "MenuManager", ->
|
||||
|
||||
beforeEach ->
|
||||
menu = new MenuManager({keymapManager: atom.keymaps, packageManager: atom.packages})
|
||||
spyOn(menu, 'sendToBrowserProcess') # Do not modify Atom's actual menus
|
||||
menu.initialize({resourcePath: atom.getLoadSettings().resourcePath})
|
||||
|
||||
describe "::add(items)", ->
|
||||
@@ -54,7 +55,6 @@ describe "MenuManager", ->
|
||||
afterEach -> Object.defineProperty process, 'platform', value: originalPlatform
|
||||
|
||||
it "sends the current menu template and associated key bindings to the browser process", ->
|
||||
spyOn(menu, 'sendToBrowserProcess')
|
||||
menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}]
|
||||
atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b'
|
||||
menu.update()
|
||||
@@ -66,7 +66,6 @@ describe "MenuManager", ->
|
||||
it "omits key bindings that are mapped to unset! in any context", ->
|
||||
# it would be nice to be smarter about omitting, but that would require a much
|
||||
# more dynamic interaction between the currently focused element and the menu
|
||||
spyOn(menu, 'sendToBrowserProcess')
|
||||
menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}]
|
||||
atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b'
|
||||
atom.keymaps.add 'test', 'atom-text-editor': 'ctrl-b': 'unset!'
|
||||
@@ -77,7 +76,6 @@ describe "MenuManager", ->
|
||||
|
||||
it "omits key bindings that could conflict with AltGraph characters on macOS", ->
|
||||
Object.defineProperty process, 'platform', value: 'darwin'
|
||||
spyOn(menu, 'sendToBrowserProcess')
|
||||
menu.add [{label: "A", submenu: [
|
||||
{label: "B", command: "b"},
|
||||
{label: "C", command: "c"}
|
||||
@@ -98,7 +96,6 @@ describe "MenuManager", ->
|
||||
|
||||
it "omits key bindings that could conflict with AltGraph characters on Windows", ->
|
||||
Object.defineProperty process, 'platform', value: 'win32'
|
||||
spyOn(menu, 'sendToBrowserProcess')
|
||||
menu.add [{label: "A", submenu: [
|
||||
{label: "B", command: "b"},
|
||||
{label: "C", command: "c"}
|
||||
|
||||
@@ -1,802 +0,0 @@
|
||||
temp = require('temp').track()
|
||||
TextBuffer = require('text-buffer')
|
||||
Project = require '../src/project'
|
||||
fs = require 'fs-plus'
|
||||
path = require 'path'
|
||||
{Directory} = require 'pathwatcher'
|
||||
{stopAllWatchers} = require '../src/path-watcher'
|
||||
GitRepository = require '../src/git-repository'
|
||||
|
||||
describe "Project", ->
|
||||
beforeEach ->
|
||||
atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')])
|
||||
|
||||
# Wait for project's service consumers to be asynchronously added
|
||||
waits(1)
|
||||
|
||||
describe "serialization", ->
|
||||
deserializedProject = null
|
||||
notQuittingProject = null
|
||||
quittingProject = null
|
||||
|
||||
afterEach ->
|
||||
deserializedProject?.destroy()
|
||||
notQuittingProject?.destroy()
|
||||
quittingProject?.destroy()
|
||||
|
||||
it "does not deserialize paths to directories that don't exist", ->
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
state = atom.project.serialize()
|
||||
state.paths.push('/directory/that/does/not/exist')
|
||||
|
||||
err = null
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(state, atom.deserializers)
|
||||
.catch (e) -> err = e
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths())
|
||||
expect(err.missingProjectPaths).toEqual ['/directory/that/does/not/exist']
|
||||
|
||||
it "does not deserialize paths that are now files", ->
|
||||
childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child')
|
||||
fs.mkdirSync(childPath)
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
atom.project.setPaths([childPath])
|
||||
state = atom.project.serialize()
|
||||
|
||||
fs.rmdirSync(childPath)
|
||||
fs.writeFileSync(childPath, 'surprise!\n')
|
||||
|
||||
err = null
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(state, atom.deserializers)
|
||||
.catch (e) -> err = e
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getPaths()).toEqual([])
|
||||
expect(err.missingProjectPaths).toEqual [childPath]
|
||||
|
||||
it "does not include unretained buffers in the serialized state", ->
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath('a')
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 0
|
||||
|
||||
it "listens for destroyed events on deserialized buffers and removes them when they are destroyed", ->
|
||||
waitsForPromise ->
|
||||
atom.workspace.open('a')
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 1
|
||||
deserializedProject.getBuffers()[0].destroy()
|
||||
expect(deserializedProject.getBuffers().length).toBe 0
|
||||
|
||||
it "does not deserialize buffers when their path is now a directory", ->
|
||||
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(pathToOpen)
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
fs.mkdirSync(pathToOpen)
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 0
|
||||
|
||||
it "does not deserialize buffers when their path is inaccessible", ->
|
||||
return if process.platform is 'win32' # chmod not supported on win32
|
||||
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
fs.writeFileSync(pathToOpen, '')
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(pathToOpen)
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
fs.chmodSync(pathToOpen, '000')
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 0
|
||||
|
||||
it "does not deserialize buffers with their path is no longer present", ->
|
||||
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
fs.writeFileSync(pathToOpen, '')
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(pathToOpen)
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
fs.unlinkSync(pathToOpen)
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 0
|
||||
|
||||
it "deserializes buffers that have never been saved before", ->
|
||||
pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(pathToOpen)
|
||||
|
||||
runs ->
|
||||
atom.workspace.getActiveTextEditor().setText('unsaved\n')
|
||||
expect(atom.project.getBuffers().length).toBe 1
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise ->
|
||||
deserializedProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(deserializedProject.getBuffers().length).toBe 1
|
||||
expect(deserializedProject.getBuffers()[0].getPath()).toBe pathToOpen
|
||||
expect(deserializedProject.getBuffers()[0].getText()).toBe 'unsaved\n'
|
||||
|
||||
it "serializes marker layers and history only if Atom is quitting", ->
|
||||
waitsForPromise -> atom.workspace.open('a')
|
||||
|
||||
bufferA = null
|
||||
layerA = null
|
||||
markerA = null
|
||||
|
||||
runs ->
|
||||
bufferA = atom.project.getBuffers()[0]
|
||||
layerA = bufferA.addMarkerLayer(persistent: true)
|
||||
markerA = layerA.markPosition([0, 3])
|
||||
bufferA.append('!')
|
||||
notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise -> notQuittingProject.deserialize(atom.project.serialize({isUnloading: false}))
|
||||
|
||||
runs ->
|
||||
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined()
|
||||
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
|
||||
quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
|
||||
waitsForPromise -> quittingProject.deserialize(atom.project.serialize({isUnloading: true}))
|
||||
|
||||
runs ->
|
||||
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined()
|
||||
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
|
||||
|
||||
describe "when an editor is saved and the project has no path", ->
|
||||
it "sets the project's path to the saved file's parent directory", ->
|
||||
tempFile = temp.openSync().path
|
||||
atom.project.setPaths([])
|
||||
expect(atom.project.getPaths()[0]).toBeUndefined()
|
||||
editor = null
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open().then (o) -> editor = o
|
||||
|
||||
waitsForPromise ->
|
||||
editor.saveAs(tempFile)
|
||||
|
||||
runs ->
|
||||
expect(atom.project.getPaths()[0]).toBe path.dirname(tempFile)
|
||||
|
||||
describe "before and after saving a buffer", ->
|
||||
[buffer] = []
|
||||
beforeEach ->
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then (o) ->
|
||||
buffer = o
|
||||
buffer.retain()
|
||||
|
||||
afterEach ->
|
||||
buffer.release()
|
||||
|
||||
it "emits save events on the main process", ->
|
||||
spyOn(atom.project.applicationDelegate, 'emitDidSavePath')
|
||||
spyOn(atom.project.applicationDelegate, 'emitWillSavePath')
|
||||
|
||||
waitsForPromise -> buffer.save()
|
||||
|
||||
runs ->
|
||||
expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1)
|
||||
expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath())
|
||||
expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1)
|
||||
expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath())
|
||||
|
||||
describe "when a watch error is thrown from the TextBuffer", ->
|
||||
editor = null
|
||||
beforeEach ->
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(require.resolve('./fixtures/dir/a')).then (o) -> editor = o
|
||||
|
||||
it "creates a warning notification", ->
|
||||
atom.notifications.onDidAddNotification noteSpy = jasmine.createSpy()
|
||||
|
||||
error = new Error('SomeError')
|
||||
error.eventType = 'resurrect'
|
||||
editor.buffer.emitter.emit 'will-throw-watch-error',
|
||||
handle: jasmine.createSpy()
|
||||
error: error
|
||||
|
||||
expect(noteSpy).toHaveBeenCalled()
|
||||
|
||||
notification = noteSpy.mostRecentCall.args[0]
|
||||
expect(notification.getType()).toBe 'warning'
|
||||
expect(notification.getDetail()).toBe 'SomeError'
|
||||
expect(notification.getMessage()).toContain '`resurrect`'
|
||||
expect(notification.getMessage()).toContain path.join('fixtures', 'dir', 'a')
|
||||
|
||||
describe "when a custom repository-provider service is provided", ->
|
||||
[fakeRepositoryProvider, fakeRepository] = []
|
||||
|
||||
beforeEach ->
|
||||
fakeRepository = {destroy: -> null}
|
||||
fakeRepositoryProvider = {
|
||||
repositoryForDirectory: (directory) -> Promise.resolve(fakeRepository)
|
||||
repositoryForDirectorySync: (directory) -> fakeRepository
|
||||
}
|
||||
|
||||
it "uses it to create repositories for any directories that need one", ->
|
||||
projectPath = temp.mkdirSync('atom-project')
|
||||
atom.project.setPaths([projectPath])
|
||||
expect(atom.project.getRepositories()).toEqual [null]
|
||||
|
||||
atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
|
||||
waitsFor -> atom.project.repositoryProviders.length > 1
|
||||
runs -> atom.project.getRepositories()[0] is fakeRepository
|
||||
|
||||
it "does not create any new repositories if every directory has a repository", ->
|
||||
repositories = atom.project.getRepositories()
|
||||
expect(repositories.length).toEqual 1
|
||||
expect(repositories[0]).toBeTruthy()
|
||||
|
||||
atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
|
||||
waitsFor -> atom.project.repositoryProviders.length > 1
|
||||
runs -> expect(atom.project.getRepositories()).toBe repositories
|
||||
|
||||
it "stops using it to create repositories when the service is removed", ->
|
||||
atom.project.setPaths([])
|
||||
|
||||
disposable = atom.packages.serviceHub.provide("atom.repository-provider", "0.1.0", fakeRepositoryProvider)
|
||||
waitsFor -> atom.project.repositoryProviders.length > 1
|
||||
runs ->
|
||||
disposable.dispose()
|
||||
atom.project.addPath(temp.mkdirSync('atom-project'))
|
||||
expect(atom.project.getRepositories()).toEqual [null]
|
||||
|
||||
describe "when a custom directory-provider service is provided", ->
|
||||
class DummyDirectory
|
||||
constructor: (@path) ->
|
||||
getPath: -> @path
|
||||
getFile: -> {existsSync: -> false}
|
||||
getSubdirectory: -> {existsSync: -> false}
|
||||
isRoot: -> true
|
||||
existsSync: -> @path.endsWith('does-exist')
|
||||
contains: (filePath) -> filePath.startsWith(@path)
|
||||
|
||||
serviceDisposable = null
|
||||
|
||||
beforeEach ->
|
||||
serviceDisposable = atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", {
|
||||
directoryForURISync: (uri) ->
|
||||
if uri.startsWith("ssh://")
|
||||
new DummyDirectory(uri)
|
||||
else
|
||||
null
|
||||
})
|
||||
|
||||
waitsFor ->
|
||||
atom.project.directoryProviders.length > 0
|
||||
|
||||
it "uses the provider's custom directories for any paths that it handles", ->
|
||||
localPath = temp.mkdirSync('local-path')
|
||||
remotePath = "ssh://foreign-directory:8080/does-exist"
|
||||
|
||||
atom.project.setPaths([localPath, remotePath])
|
||||
|
||||
directories = atom.project.getDirectories()
|
||||
expect(directories[0].getPath()).toBe localPath
|
||||
expect(directories[0] instanceof Directory).toBe true
|
||||
expect(directories[1].getPath()).toBe remotePath
|
||||
expect(directories[1] instanceof DummyDirectory).toBe true
|
||||
|
||||
# It does not add new remote paths that do not exist
|
||||
nonExistentRemotePath = "ssh://another-directory:8080/does-not-exist"
|
||||
atom.project.addPath(nonExistentRemotePath)
|
||||
expect(atom.project.getDirectories().length).toBe 2
|
||||
|
||||
# It adds new remote paths if their directories exist.
|
||||
newRemotePath = "ssh://another-directory:8080/does-exist"
|
||||
atom.project.addPath(newRemotePath)
|
||||
directories = atom.project.getDirectories()
|
||||
expect(directories[2].getPath()).toBe newRemotePath
|
||||
expect(directories[2] instanceof DummyDirectory).toBe true
|
||||
|
||||
it "stops using the provider when the service is removed", ->
|
||||
serviceDisposable.dispose()
|
||||
atom.project.setPaths(["ssh://foreign-directory:8080/does-exist"])
|
||||
expect(atom.project.getDirectories().length).toBe(0)
|
||||
|
||||
describe ".open(path)", ->
|
||||
[absolutePath, newBufferHandler] = []
|
||||
|
||||
beforeEach ->
|
||||
absolutePath = require.resolve('./fixtures/dir/a')
|
||||
newBufferHandler = jasmine.createSpy('newBufferHandler')
|
||||
atom.project.onDidAddBuffer(newBufferHandler)
|
||||
|
||||
describe "when given an absolute path that isn't currently open", ->
|
||||
it "returns a new edit session for the given path and emits 'buffer-created'", ->
|
||||
editor = null
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(absolutePath).then (o) -> editor = o
|
||||
|
||||
runs ->
|
||||
expect(editor.buffer.getPath()).toBe absolutePath
|
||||
expect(newBufferHandler).toHaveBeenCalledWith editor.buffer
|
||||
|
||||
describe "when given a relative path that isn't currently opened", ->
|
||||
it "returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", ->
|
||||
editor = null
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(absolutePath).then (o) -> editor = o
|
||||
|
||||
runs ->
|
||||
expect(editor.buffer.getPath()).toBe absolutePath
|
||||
expect(newBufferHandler).toHaveBeenCalledWith editor.buffer
|
||||
|
||||
describe "when passed the path to a buffer that is currently opened", ->
|
||||
it "returns a new edit session containing currently opened buffer", ->
|
||||
editor = null
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(absolutePath).then (o) -> editor = o
|
||||
|
||||
runs ->
|
||||
newBufferHandler.reset()
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open(absolutePath).then ({buffer}) ->
|
||||
expect(buffer).toBe editor.buffer
|
||||
|
||||
waitsForPromise ->
|
||||
atom.workspace.open('a').then ({buffer}) ->
|
||||
expect(buffer).toBe editor.buffer
|
||||
expect(newBufferHandler).not.toHaveBeenCalled()
|
||||
|
||||
describe "when not passed a path", ->
|
||||
it "returns a new edit session and emits 'buffer-created'", ->
|
||||
editor = null
|
||||
waitsForPromise ->
|
||||
atom.workspace.open().then (o) -> editor = o
|
||||
|
||||
runs ->
|
||||
expect(editor.buffer.getPath()).toBeUndefined()
|
||||
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
|
||||
|
||||
describe ".bufferForPath(path)", ->
|
||||
buffer = null
|
||||
|
||||
beforeEach ->
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath("a").then (o) ->
|
||||
buffer = o
|
||||
buffer.retain()
|
||||
|
||||
afterEach ->
|
||||
buffer.release()
|
||||
|
||||
describe "when opening a previously opened path", ->
|
||||
it "does not create a new buffer", ->
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath("a").then (anotherBuffer) ->
|
||||
expect(anotherBuffer).toBe buffer
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath("b").then (anotherBuffer) ->
|
||||
expect(anotherBuffer).not.toBe buffer
|
||||
|
||||
waitsForPromise ->
|
||||
Promise.all([
|
||||
atom.project.bufferForPath('c'),
|
||||
atom.project.bufferForPath('c')
|
||||
]).then ([buffer1, buffer2]) ->
|
||||
expect(buffer1).toBe(buffer2)
|
||||
|
||||
it "retries loading the buffer if it previously failed", ->
|
||||
waitsForPromise shouldReject: true, ->
|
||||
spyOn(TextBuffer, 'load').andCallFake ->
|
||||
Promise.reject(new Error('Could not open file'))
|
||||
atom.project.bufferForPath('b')
|
||||
|
||||
waitsForPromise shouldReject: false, ->
|
||||
TextBuffer.load.andCallThrough()
|
||||
atom.project.bufferForPath('b')
|
||||
|
||||
it "creates a new buffer if the previous buffer was destroyed", ->
|
||||
buffer.release()
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.bufferForPath("b").then (anotherBuffer) ->
|
||||
expect(anotherBuffer).not.toBe buffer
|
||||
|
||||
describe ".repositoryForDirectory(directory)", ->
|
||||
it "resolves to null when the directory does not have a repository", ->
|
||||
waitsForPromise ->
|
||||
directory = new Directory("/tmp")
|
||||
atom.project.repositoryForDirectory(directory).then (result) ->
|
||||
expect(result).toBeNull()
|
||||
expect(atom.project.repositoryProviders.length).toBeGreaterThan 0
|
||||
expect(atom.project.repositoryPromisesByPath.size).toBe 0
|
||||
|
||||
it "resolves to a GitRepository and is cached when the given directory is a Git repo", ->
|
||||
waitsForPromise ->
|
||||
directory = new Directory(path.join(__dirname, '..'))
|
||||
promise = atom.project.repositoryForDirectory(directory)
|
||||
promise.then (result) ->
|
||||
expect(result).toBeInstanceOf GitRepository
|
||||
dirPath = directory.getRealPathSync()
|
||||
expect(result.getPath()).toBe path.join(dirPath, '.git')
|
||||
|
||||
# Verify that the result is cached.
|
||||
expect(atom.project.repositoryForDirectory(directory)).toBe(promise)
|
||||
|
||||
it "creates a new repository if a previous one with the same directory had been destroyed", ->
|
||||
repository = null
|
||||
directory = new Directory(path.join(__dirname, '..'))
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo
|
||||
|
||||
runs ->
|
||||
expect(repository.isDestroyed()).toBe(false)
|
||||
repository.destroy()
|
||||
expect(repository.isDestroyed()).toBe(true)
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.repositoryForDirectory(directory).then (repo) -> repository = repo
|
||||
|
||||
runs ->
|
||||
expect(repository.isDestroyed()).toBe(false)
|
||||
|
||||
describe ".setPaths(paths, options)", ->
|
||||
describe "when path is a file", ->
|
||||
it "sets its path to the file's parent directory and updates the root directory", ->
|
||||
filePath = require.resolve('./fixtures/dir/a')
|
||||
atom.project.setPaths([filePath])
|
||||
expect(atom.project.getPaths()[0]).toEqual path.dirname(filePath)
|
||||
expect(atom.project.getDirectories()[0].path).toEqual path.dirname(filePath)
|
||||
|
||||
describe "when path is a directory", ->
|
||||
it "assigns the directories and repositories", ->
|
||||
directory1 = temp.mkdirSync("non-git-repo")
|
||||
directory2 = temp.mkdirSync("git-repo1")
|
||||
directory3 = temp.mkdirSync("git-repo2")
|
||||
|
||||
gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
|
||||
fs.copySync(gitDirPath, path.join(directory2, ".git"))
|
||||
fs.copySync(gitDirPath, path.join(directory3, ".git"))
|
||||
|
||||
atom.project.setPaths([directory1, directory2, directory3])
|
||||
|
||||
[repo1, repo2, repo3] = atom.project.getRepositories()
|
||||
expect(repo1).toBeNull()
|
||||
expect(repo2.getShortHead()).toBe "master"
|
||||
expect(repo2.getPath()).toBe fs.realpathSync(path.join(directory2, ".git"))
|
||||
expect(repo3.getShortHead()).toBe "master"
|
||||
expect(repo3.getPath()).toBe fs.realpathSync(path.join(directory3, ".git"))
|
||||
|
||||
it "calls callbacks registered with ::onDidChangePaths", ->
|
||||
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
|
||||
paths = [ temp.mkdirSync("dir1"), temp.mkdirSync("dir2") ]
|
||||
atom.project.setPaths(paths)
|
||||
|
||||
expect(onDidChangePathsSpy.callCount).toBe 1
|
||||
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths)
|
||||
|
||||
it "optionally throws an error with any paths that did not exist", ->
|
||||
paths = [temp.mkdirSync("exists0"), "/doesnt-exists/0", temp.mkdirSync("exists1"), "/doesnt-exists/1"]
|
||||
|
||||
try
|
||||
atom.project.setPaths paths, mustExist: true
|
||||
expect('no exception thrown').toBeUndefined()
|
||||
catch e
|
||||
expect(e.missingProjectPaths).toEqual [paths[1], paths[3]]
|
||||
|
||||
expect(atom.project.getPaths()).toEqual [paths[0], paths[2]]
|
||||
|
||||
describe "when no paths are given", ->
|
||||
it "clears its path", ->
|
||||
atom.project.setPaths([])
|
||||
expect(atom.project.getPaths()).toEqual []
|
||||
expect(atom.project.getDirectories()).toEqual []
|
||||
|
||||
it "normalizes the path to remove consecutive slashes, ., and .. segments", ->
|
||||
atom.project.setPaths(["#{require.resolve('./fixtures/dir/a')}#{path.sep}b#{path.sep}#{path.sep}.."])
|
||||
expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
|
||||
expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a'))
|
||||
|
||||
describe ".addPath(path, options)", ->
|
||||
it "calls callbacks registered with ::onDidChangePaths", ->
|
||||
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
|
||||
[oldPath] = atom.project.getPaths()
|
||||
|
||||
newPath = temp.mkdirSync("dir")
|
||||
atom.project.addPath(newPath)
|
||||
|
||||
expect(onDidChangePathsSpy.callCount).toBe 1
|
||||
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath])
|
||||
|
||||
it "doesn't add redundant paths", ->
|
||||
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
[oldPath] = atom.project.getPaths()
|
||||
|
||||
# Doesn't re-add an existing root directory
|
||||
atom.project.addPath(oldPath)
|
||||
expect(atom.project.getPaths()).toEqual([oldPath])
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
|
||||
# Doesn't add an entry for a file-path within an existing root directory
|
||||
atom.project.addPath(path.join(oldPath, 'some-file.txt'))
|
||||
expect(atom.project.getPaths()).toEqual([oldPath])
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
|
||||
# Does add an entry for a directory within an existing directory
|
||||
newPath = path.join(oldPath, "a-dir")
|
||||
atom.project.addPath(newPath)
|
||||
expect(atom.project.getPaths()).toEqual([oldPath, newPath])
|
||||
expect(onDidChangePathsSpy).toHaveBeenCalled()
|
||||
|
||||
it "doesn't add non-existent directories", ->
|
||||
previousPaths = atom.project.getPaths()
|
||||
atom.project.addPath('/this-definitely/does-not-exist')
|
||||
expect(atom.project.getPaths()).toEqual(previousPaths)
|
||||
|
||||
it "optionally throws on non-existent directories", ->
|
||||
expect ->
|
||||
atom.project.addPath '/this-definitely/does-not-exist', mustExist: true
|
||||
.toThrow()
|
||||
|
||||
describe ".removePath(path)", ->
|
||||
onDidChangePathsSpy = null
|
||||
|
||||
beforeEach ->
|
||||
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
|
||||
it "removes the directory and repository for the path", ->
|
||||
result = atom.project.removePath(atom.project.getPaths()[0])
|
||||
expect(atom.project.getDirectories()).toEqual([])
|
||||
expect(atom.project.getRepositories()).toEqual([])
|
||||
expect(atom.project.getPaths()).toEqual([])
|
||||
expect(result).toBe true
|
||||
expect(onDidChangePathsSpy).toHaveBeenCalled()
|
||||
|
||||
it "does nothing if the path is not one of the project's root paths", ->
|
||||
originalPaths = atom.project.getPaths()
|
||||
result = atom.project.removePath(originalPaths[0] + "xyz")
|
||||
expect(result).toBe false
|
||||
expect(atom.project.getPaths()).toEqual(originalPaths)
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
|
||||
it "doesn't destroy the repository if it is shared by another root directory", ->
|
||||
atom.project.setPaths([__dirname, path.join(__dirname, "..", "src")])
|
||||
atom.project.removePath(__dirname)
|
||||
expect(atom.project.getPaths()).toEqual([path.join(__dirname, "..", "src")])
|
||||
expect(atom.project.getRepositories()[0].isSubmodule("src")).toBe false
|
||||
|
||||
it "removes a path that is represented as a URI", ->
|
||||
atom.packages.serviceHub.provide("atom.directory-provider", "0.1.0", {
|
||||
directoryForURISync: (uri) ->
|
||||
{
|
||||
getPath: -> uri
|
||||
getSubdirectory: -> {}
|
||||
isRoot: -> true
|
||||
existsSync: -> true
|
||||
off: ->
|
||||
}
|
||||
})
|
||||
|
||||
ftpURI = "ftp://example.com/some/folder"
|
||||
|
||||
atom.project.setPaths([ftpURI])
|
||||
expect(atom.project.getPaths()).toEqual [ftpURI]
|
||||
|
||||
atom.project.removePath(ftpURI)
|
||||
expect(atom.project.getPaths()).toEqual []
|
||||
|
||||
describe ".onDidChangeFiles()", ->
|
||||
sub = []
|
||||
events = []
|
||||
checkCallback = ->
|
||||
|
||||
beforeEach ->
|
||||
sub = atom.project.onDidChangeFiles (incoming) ->
|
||||
events.push incoming...
|
||||
checkCallback()
|
||||
|
||||
afterEach ->
|
||||
sub.dispose()
|
||||
|
||||
waitForEvents = (paths) ->
|
||||
remaining = new Set(fs.realpathSync(p) for p in paths)
|
||||
new Promise (resolve, reject) ->
|
||||
checkCallback = ->
|
||||
remaining.delete(event.path) for event in events
|
||||
resolve() if remaining.size is 0
|
||||
|
||||
expire = ->
|
||||
checkCallback = ->
|
||||
console.error "Paths not seen:", Array.from(remaining)
|
||||
reject(new Error('Expired before all expected events were delivered.'))
|
||||
|
||||
checkCallback()
|
||||
setTimeout expire, 2000
|
||||
|
||||
it "reports filesystem changes within project paths", ->
|
||||
dirOne = temp.mkdirSync('atom-spec-project-one')
|
||||
fileOne = path.join(dirOne, 'file-one.txt')
|
||||
fileTwo = path.join(dirOne, 'file-two.txt')
|
||||
dirTwo = temp.mkdirSync('atom-spec-project-two')
|
||||
fileThree = path.join(dirTwo, 'file-three.txt')
|
||||
|
||||
# Ensure that all preexisting watchers are stopped
|
||||
waitsForPromise -> stopAllWatchers()
|
||||
|
||||
runs -> atom.project.setPaths([dirOne])
|
||||
waitsForPromise -> atom.project.getWatcherPromise dirOne
|
||||
|
||||
runs ->
|
||||
expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual undefined
|
||||
|
||||
fs.writeFileSync fileThree, "three\n"
|
||||
fs.writeFileSync fileTwo, "two\n"
|
||||
fs.writeFileSync fileOne, "one\n"
|
||||
|
||||
waitsForPromise -> waitForEvents [fileOne, fileTwo]
|
||||
|
||||
runs ->
|
||||
expect(events.some (event) -> event.path is fileThree).toBeFalsy()
|
||||
|
||||
describe ".onDidAddBuffer()", ->
|
||||
it "invokes the callback with added text buffers", ->
|
||||
buffers = []
|
||||
added = []
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
|
||||
.then (o) -> buffers.push(o)
|
||||
|
||||
runs ->
|
||||
expect(buffers.length).toBe 1
|
||||
atom.project.onDidAddBuffer (buffer) -> added.push(buffer)
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then (o) -> buffers.push(o)
|
||||
|
||||
runs ->
|
||||
expect(buffers.length).toBe 2
|
||||
expect(added).toEqual [buffers[1]]
|
||||
|
||||
describe ".observeBuffers()", ->
|
||||
it "invokes the observer with current and future text buffers", ->
|
||||
buffers = []
|
||||
observed = []
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
|
||||
.then (o) -> buffers.push(o)
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then (o) -> buffers.push(o)
|
||||
|
||||
runs ->
|
||||
expect(buffers.length).toBe 2
|
||||
atom.project.observeBuffers (buffer) -> observed.push(buffer)
|
||||
expect(observed).toEqual buffers
|
||||
|
||||
waitsForPromise ->
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then (o) -> buffers.push(o)
|
||||
|
||||
runs ->
|
||||
expect(observed.length).toBe 3
|
||||
expect(buffers.length).toBe 3
|
||||
expect(observed).toEqual buffers
|
||||
|
||||
describe ".relativize(path)", ->
|
||||
it "returns the path, relative to whichever root directory it is inside of", ->
|
||||
atom.project.addPath(temp.mkdirSync("another-path"))
|
||||
|
||||
rootPath = atom.project.getPaths()[0]
|
||||
childPath = path.join(rootPath, "some", "child", "directory")
|
||||
expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory")
|
||||
|
||||
rootPath = atom.project.getPaths()[1]
|
||||
childPath = path.join(rootPath, "some", "child", "directory")
|
||||
expect(atom.project.relativize(childPath)).toBe path.join("some", "child", "directory")
|
||||
|
||||
it "returns the given path if it is not in any of the root directories", ->
|
||||
randomPath = path.join("some", "random", "path")
|
||||
expect(atom.project.relativize(randomPath)).toBe randomPath
|
||||
|
||||
describe ".relativizePath(path)", ->
|
||||
it "returns the root path that contains the given path, and the path relativized to that root path", ->
|
||||
atom.project.addPath(temp.mkdirSync("another-path"))
|
||||
|
||||
rootPath = atom.project.getPaths()[0]
|
||||
childPath = path.join(rootPath, "some", "child", "directory")
|
||||
expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")]
|
||||
|
||||
rootPath = atom.project.getPaths()[1]
|
||||
childPath = path.join(rootPath, "some", "child", "directory")
|
||||
expect(atom.project.relativizePath(childPath)).toEqual [rootPath, path.join("some", "child", "directory")]
|
||||
|
||||
describe "when the given path isn't inside of any of the project's path", ->
|
||||
it "returns null for the root path, and the given path unchanged", ->
|
||||
randomPath = path.join("some", "random", "path")
|
||||
expect(atom.project.relativizePath(randomPath)).toEqual [null, randomPath]
|
||||
|
||||
describe "when the given path is a URL", ->
|
||||
it "returns null for the root path, and the given path unchanged", ->
|
||||
url = "http://the-path"
|
||||
expect(atom.project.relativizePath(url)).toEqual [null, url]
|
||||
|
||||
describe "when the given path is inside more than one root folder", ->
|
||||
it "uses the root folder that is closest to the given path", ->
|
||||
atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir'))
|
||||
|
||||
inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt')
|
||||
|
||||
expect(atom.project.getDirectories()[0].contains(inputPath)).toBe true
|
||||
expect(atom.project.getDirectories()[1].contains(inputPath)).toBe true
|
||||
expect(atom.project.relativizePath(inputPath)).toEqual [
|
||||
atom.project.getPaths()[1],
|
||||
path.join('somewhere', 'something.txt')
|
||||
]
|
||||
|
||||
describe ".contains(path)", ->
|
||||
it "returns whether or not the given path is in one of the root directories", ->
|
||||
rootPath = atom.project.getPaths()[0]
|
||||
childPath = path.join(rootPath, "some", "child", "directory")
|
||||
expect(atom.project.contains(childPath)).toBe true
|
||||
|
||||
randomPath = path.join("some", "random", "path")
|
||||
expect(atom.project.contains(randomPath)).toBe false
|
||||
|
||||
describe ".resolvePath(uri)", ->
|
||||
it "normalizes disk drive letter in passed path on #win32", ->
|
||||
expect(atom.project.resolvePath("d:\\file.txt")).toEqual "D:\\file.txt"
|
||||
927
spec/project-spec.js
Normal file
927
spec/project-spec.js
Normal file
@@ -0,0 +1,927 @@
|
||||
const temp = require('temp').track()
|
||||
const TextBuffer = require('text-buffer')
|
||||
const Project = require('../src/project')
|
||||
const fs = require('fs-plus')
|
||||
const path = require('path')
|
||||
const {Directory} = require('pathwatcher')
|
||||
const {stopAllWatchers} = require('../src/path-watcher')
|
||||
const GitRepository = require('../src/git-repository')
|
||||
|
||||
describe('Project', () => {
|
||||
beforeEach(() => {
|
||||
const directory = atom.project.getDirectories()[0]
|
||||
const paths = directory ? [directory.resolve('dir')] : [null]
|
||||
atom.project.setPaths(paths)
|
||||
|
||||
// Wait for project's service consumers to be asynchronously added
|
||||
waits(1)
|
||||
})
|
||||
|
||||
describe('serialization', () => {
|
||||
let deserializedProject = null
|
||||
let notQuittingProject = null
|
||||
let quittingProject = null
|
||||
|
||||
afterEach(() => {
|
||||
if (deserializedProject != null) {
|
||||
deserializedProject.destroy()
|
||||
}
|
||||
if (notQuittingProject != null) {
|
||||
notQuittingProject.destroy()
|
||||
}
|
||||
if (quittingProject != null) {
|
||||
quittingProject.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it("does not deserialize paths to directories that don't exist", () => {
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
const state = atom.project.serialize()
|
||||
state.paths.push('/directory/that/does/not/exist')
|
||||
|
||||
let err = null
|
||||
waitsForPromise(() =>
|
||||
deserializedProject.deserialize(state, atom.deserializers)
|
||||
.catch(e => { err = e })
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths())
|
||||
expect(err.missingProjectPaths).toEqual(['/directory/that/does/not/exist'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not deserialize paths that are now files', () => {
|
||||
const childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child')
|
||||
fs.mkdirSync(childPath)
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
atom.project.setPaths([childPath])
|
||||
const state = atom.project.serialize()
|
||||
|
||||
fs.rmdirSync(childPath)
|
||||
fs.writeFileSync(childPath, 'surprise!\n')
|
||||
|
||||
let err = null
|
||||
waitsForPromise(() =>
|
||||
deserializedProject.deserialize(state, atom.deserializers)
|
||||
.catch(e => { err = e })
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(deserializedProject.getPaths()).toEqual([])
|
||||
expect(err.missingProjectPaths).toEqual([childPath])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include unretained buffers in the serialized state', () => {
|
||||
waitsForPromise(() => atom.project.bufferForPath('a'))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
|
||||
})
|
||||
|
||||
it('listens for destroyed events on deserialized buffers and removes them when they are destroyed', () => {
|
||||
waitsForPromise(() => atom.workspace.open('a'))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => {
|
||||
expect(deserializedProject.getBuffers().length).toBe(1)
|
||||
deserializedProject.getBuffers()[0].destroy()
|
||||
expect(deserializedProject.getBuffers().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not deserialize buffers when their path is now a directory', () => {
|
||||
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
|
||||
waitsForPromise(() => atom.workspace.open(pathToOpen))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
fs.mkdirSync(pathToOpen)
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
|
||||
})
|
||||
|
||||
it('does not deserialize buffers when their path is inaccessible', () => {
|
||||
if (process.platform === 'win32') { return } // chmod not supported on win32
|
||||
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
fs.writeFileSync(pathToOpen, '')
|
||||
|
||||
waitsForPromise(() => atom.workspace.open(pathToOpen))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
fs.chmodSync(pathToOpen, '000')
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
|
||||
})
|
||||
|
||||
it('does not deserialize buffers with their path is no longer present', () => {
|
||||
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
fs.writeFileSync(pathToOpen, '')
|
||||
|
||||
waitsForPromise(() => atom.workspace.open(pathToOpen))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
fs.unlinkSync(pathToOpen)
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => expect(deserializedProject.getBuffers().length).toBe(0))
|
||||
})
|
||||
|
||||
it('deserializes buffers that have never been saved before', () => {
|
||||
const pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt')
|
||||
|
||||
waitsForPromise(() => atom.workspace.open(pathToOpen))
|
||||
|
||||
runs(() => {
|
||||
atom.workspace.getActiveTextEditor().setText('unsaved\n')
|
||||
expect(atom.project.getBuffers().length).toBe(1)
|
||||
|
||||
deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => deserializedProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => {
|
||||
expect(deserializedProject.getBuffers().length).toBe(1)
|
||||
expect(deserializedProject.getBuffers()[0].getPath()).toBe(pathToOpen)
|
||||
expect(deserializedProject.getBuffers()[0].getText()).toBe('unsaved\n')
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes marker layers and history only if Atom is quitting', () => {
|
||||
waitsForPromise(() => atom.workspace.open('a'))
|
||||
|
||||
let bufferA = null
|
||||
let layerA = null
|
||||
let markerA = null
|
||||
|
||||
runs(() => {
|
||||
bufferA = atom.project.getBuffers()[0]
|
||||
layerA = bufferA.addMarkerLayer({persistent: true})
|
||||
markerA = layerA.markPosition([0, 3])
|
||||
bufferA.append('!')
|
||||
notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})))
|
||||
|
||||
runs(() => {
|
||||
expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).toBeUndefined()
|
||||
expect(notQuittingProject.getBuffers()[0].undo()).toBe(false)
|
||||
quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm})
|
||||
})
|
||||
|
||||
waitsForPromise(() => quittingProject.deserialize(atom.project.serialize({isUnloading: true})))
|
||||
|
||||
runs(() => {
|
||||
expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id), x => x.getMarker(markerA.id)).not.toBeUndefined()
|
||||
expect(quittingProject.getBuffers()[0].undo()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when an editor is saved and the project has no path', () =>
|
||||
it("sets the project's path to the saved file's parent directory", () => {
|
||||
const tempFile = temp.openSync().path
|
||||
atom.project.setPaths([])
|
||||
expect(atom.project.getPaths()[0]).toBeUndefined()
|
||||
let editor = null
|
||||
|
||||
waitsForPromise(() => atom.workspace.open().then(o => { editor = o }))
|
||||
|
||||
waitsForPromise(() => editor.saveAs(tempFile))
|
||||
|
||||
runs(() => expect(atom.project.getPaths()[0]).toBe(path.dirname(tempFile)))
|
||||
})
|
||||
)
|
||||
|
||||
describe('before and after saving a buffer', () => {
|
||||
let buffer
|
||||
beforeEach(() =>
|
||||
waitsForPromise(() =>
|
||||
atom.project.bufferForPath(path.join(__dirname, 'fixtures', 'sample.js')).then((o) => {
|
||||
buffer = o
|
||||
buffer.retain()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
afterEach(() => buffer.release())
|
||||
|
||||
it('emits save events on the main process', () => {
|
||||
spyOn(atom.project.applicationDelegate, 'emitDidSavePath')
|
||||
spyOn(atom.project.applicationDelegate, 'emitWillSavePath')
|
||||
|
||||
waitsForPromise(() => buffer.save())
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.applicationDelegate.emitDidSavePath.calls.length).toBe(1)
|
||||
expect(atom.project.applicationDelegate.emitDidSavePath).toHaveBeenCalledWith(buffer.getPath())
|
||||
expect(atom.project.applicationDelegate.emitWillSavePath.calls.length).toBe(1)
|
||||
expect(atom.project.applicationDelegate.emitWillSavePath).toHaveBeenCalledWith(buffer.getPath())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a watch error is thrown from the TextBuffer', () => {
|
||||
let editor = null
|
||||
beforeEach(() =>
|
||||
waitsForPromise(() => atom.workspace.open(require.resolve('./fixtures/dir/a')).then(o => { editor = o }))
|
||||
)
|
||||
|
||||
it('creates a warning notification', () => {
|
||||
let noteSpy
|
||||
atom.notifications.onDidAddNotification(noteSpy = jasmine.createSpy())
|
||||
|
||||
const error = new Error('SomeError')
|
||||
error.eventType = 'resurrect'
|
||||
editor.buffer.emitter.emit('will-throw-watch-error', {
|
||||
handle: jasmine.createSpy(),
|
||||
error
|
||||
}
|
||||
)
|
||||
|
||||
expect(noteSpy).toHaveBeenCalled()
|
||||
|
||||
const notification = noteSpy.mostRecentCall.args[0]
|
||||
expect(notification.getType()).toBe('warning')
|
||||
expect(notification.getDetail()).toBe('SomeError')
|
||||
expect(notification.getMessage()).toContain('`resurrect`')
|
||||
expect(notification.getMessage()).toContain(path.join('fixtures', 'dir', 'a'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a custom repository-provider service is provided', () => {
|
||||
let fakeRepositoryProvider, fakeRepository
|
||||
|
||||
beforeEach(() => {
|
||||
fakeRepository = {destroy () { return null }}
|
||||
fakeRepositoryProvider = {
|
||||
repositoryForDirectory (directory) { return Promise.resolve(fakeRepository) },
|
||||
repositoryForDirectorySync (directory) { return fakeRepository }
|
||||
}
|
||||
})
|
||||
|
||||
it('uses it to create repositories for any directories that need one', () => {
|
||||
const projectPath = temp.mkdirSync('atom-project')
|
||||
atom.project.setPaths([projectPath])
|
||||
expect(atom.project.getRepositories()).toEqual([null])
|
||||
|
||||
atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
|
||||
waitsFor(() => atom.project.repositoryProviders.length > 1)
|
||||
runs(() => atom.project.getRepositories()[0] === fakeRepository)
|
||||
})
|
||||
|
||||
it('does not create any new repositories if every directory has a repository', () => {
|
||||
const repositories = atom.project.getRepositories()
|
||||
expect(repositories.length).toEqual(1)
|
||||
expect(repositories[0]).toBeTruthy()
|
||||
|
||||
atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
|
||||
waitsFor(() => atom.project.repositoryProviders.length > 1)
|
||||
runs(() => expect(atom.project.getRepositories()).toBe(repositories))
|
||||
})
|
||||
|
||||
it('stops using it to create repositories when the service is removed', () => {
|
||||
atom.project.setPaths([])
|
||||
|
||||
const disposable = atom.packages.serviceHub.provide('atom.repository-provider', '0.1.0', fakeRepositoryProvider)
|
||||
waitsFor(() => atom.project.repositoryProviders.length > 1)
|
||||
runs(() => {
|
||||
disposable.dispose()
|
||||
atom.project.addPath(temp.mkdirSync('atom-project'))
|
||||
expect(atom.project.getRepositories()).toEqual([null])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a custom directory-provider service is provided', () => {
|
||||
class DummyDirectory {
|
||||
constructor (aPath) {
|
||||
this.path = aPath
|
||||
}
|
||||
getPath () { return this.path }
|
||||
getFile () { return {existsSync () { return false }} }
|
||||
getSubdirectory () { return {existsSync () { return false }} }
|
||||
isRoot () { return true }
|
||||
existsSync () { return this.path.endsWith('does-exist') }
|
||||
contains (filePath) { return filePath.startsWith(this.path) }
|
||||
}
|
||||
|
||||
let serviceDisposable = null
|
||||
|
||||
beforeEach(() => {
|
||||
serviceDisposable = atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', {
|
||||
directoryForURISync (uri) {
|
||||
if (uri.startsWith('ssh://')) {
|
||||
return new DummyDirectory(uri)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
waitsFor(() => atom.project.directoryProviders.length > 0)
|
||||
})
|
||||
|
||||
it("uses the provider's custom directories for any paths that it handles", () => {
|
||||
const localPath = temp.mkdirSync('local-path')
|
||||
const remotePath = 'ssh://foreign-directory:8080/does-exist'
|
||||
|
||||
atom.project.setPaths([localPath, remotePath])
|
||||
|
||||
let directories = atom.project.getDirectories()
|
||||
expect(directories[0].getPath()).toBe(localPath)
|
||||
expect(directories[0] instanceof Directory).toBe(true)
|
||||
expect(directories[1].getPath()).toBe(remotePath)
|
||||
expect(directories[1] instanceof DummyDirectory).toBe(true)
|
||||
|
||||
// It does not add new remote paths that do not exist
|
||||
const nonExistentRemotePath = 'ssh://another-directory:8080/does-not-exist'
|
||||
atom.project.addPath(nonExistentRemotePath)
|
||||
expect(atom.project.getDirectories().length).toBe(2)
|
||||
|
||||
// It adds new remote paths if their directories exist.
|
||||
const newRemotePath = 'ssh://another-directory:8080/does-exist'
|
||||
atom.project.addPath(newRemotePath)
|
||||
directories = atom.project.getDirectories()
|
||||
expect(directories[2].getPath()).toBe(newRemotePath)
|
||||
expect(directories[2] instanceof DummyDirectory).toBe(true)
|
||||
})
|
||||
|
||||
it('stops using the provider when the service is removed', () => {
|
||||
serviceDisposable.dispose()
|
||||
atom.project.setPaths(['ssh://foreign-directory:8080/does-exist'])
|
||||
expect(atom.project.getDirectories().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.open(path)', () => {
|
||||
let absolutePath, newBufferHandler
|
||||
|
||||
beforeEach(() => {
|
||||
absolutePath = require.resolve('./fixtures/dir/a')
|
||||
newBufferHandler = jasmine.createSpy('newBufferHandler')
|
||||
atom.project.onDidAddBuffer(newBufferHandler)
|
||||
})
|
||||
|
||||
describe("when given an absolute path that isn't currently open", () =>
|
||||
it("returns a new edit session for the given path and emits 'buffer-created'", () => {
|
||||
let editor = null
|
||||
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
|
||||
|
||||
runs(() => {
|
||||
expect(editor.buffer.getPath()).toBe(absolutePath)
|
||||
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe("when given a relative path that isn't currently opened", () =>
|
||||
it("returns a new edit session for the given path (relative to the project root) and emits 'buffer-created'", () => {
|
||||
let editor = null
|
||||
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
|
||||
|
||||
runs(() => {
|
||||
expect(editor.buffer.getPath()).toBe(absolutePath)
|
||||
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('when passed the path to a buffer that is currently opened', () =>
|
||||
it('returns a new edit session containing currently opened buffer', () => {
|
||||
let editor = null
|
||||
|
||||
waitsForPromise(() => atom.workspace.open(absolutePath).then(o => { editor = o }))
|
||||
|
||||
runs(() => newBufferHandler.reset())
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.workspace.open(absolutePath).then(({buffer}) => expect(buffer).toBe(editor.buffer))
|
||||
)
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.workspace.open('a').then(({buffer}) => {
|
||||
expect(buffer).toBe(editor.buffer)
|
||||
expect(newBufferHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when not passed a path', () =>
|
||||
it("returns a new edit session and emits 'buffer-created'", () => {
|
||||
let editor = null
|
||||
waitsForPromise(() => atom.workspace.open().then(o => { editor = o }))
|
||||
|
||||
runs(() => {
|
||||
expect(editor.buffer.getPath()).toBeUndefined()
|
||||
expect(newBufferHandler).toHaveBeenCalledWith(editor.buffer)
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('.bufferForPath(path)', () => {
|
||||
let buffer = null
|
||||
|
||||
beforeEach(() =>
|
||||
waitsForPromise(() =>
|
||||
atom.project.bufferForPath('a').then((o) => {
|
||||
buffer = o
|
||||
buffer.retain()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
afterEach(() => buffer.release())
|
||||
|
||||
describe('when opening a previously opened path', () => {
|
||||
it('does not create a new buffer', () => {
|
||||
waitsForPromise(() =>
|
||||
atom.project.bufferForPath('a').then(anotherBuffer => expect(anotherBuffer).toBe(buffer))
|
||||
)
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer))
|
||||
)
|
||||
|
||||
waitsForPromise(() =>
|
||||
Promise.all([
|
||||
atom.project.bufferForPath('c'),
|
||||
atom.project.bufferForPath('c')
|
||||
]).then(([buffer1, buffer2]) => {
|
||||
expect(buffer1).toBe(buffer2)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('retries loading the buffer if it previously failed', () => {
|
||||
waitsForPromise({shouldReject: true}, () => {
|
||||
spyOn(TextBuffer, 'load').andCallFake(() => Promise.reject(new Error('Could not open file')))
|
||||
return atom.project.bufferForPath('b')
|
||||
})
|
||||
|
||||
waitsForPromise({shouldReject: false}, () => {
|
||||
TextBuffer.load.andCallThrough()
|
||||
return atom.project.bufferForPath('b')
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new buffer if the previous buffer was destroyed', () => {
|
||||
buffer.release()
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.bufferForPath('b').then(anotherBuffer => expect(anotherBuffer).not.toBe(buffer))
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.repositoryForDirectory(directory)', () => {
|
||||
it('resolves to null when the directory does not have a repository', () =>
|
||||
waitsForPromise(() => {
|
||||
const directory = new Directory('/tmp')
|
||||
return atom.project.repositoryForDirectory(directory).then((result) => {
|
||||
expect(result).toBeNull()
|
||||
expect(atom.project.repositoryProviders.length).toBeGreaterThan(0)
|
||||
expect(atom.project.repositoryPromisesByPath.size).toBe(0)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
it('resolves to a GitRepository and is cached when the given directory is a Git repo', () =>
|
||||
waitsForPromise(() => {
|
||||
const directory = new Directory(path.join(__dirname, '..'))
|
||||
const promise = atom.project.repositoryForDirectory(directory)
|
||||
return promise.then((result) => {
|
||||
expect(result).toBeInstanceOf(GitRepository)
|
||||
const dirPath = directory.getRealPathSync()
|
||||
expect(result.getPath()).toBe(path.join(dirPath, '.git'))
|
||||
|
||||
// Verify that the result is cached.
|
||||
expect(atom.project.repositoryForDirectory(directory)).toBe(promise)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
it('creates a new repository if a previous one with the same directory had been destroyed', () => {
|
||||
let repository = null
|
||||
const directory = new Directory(path.join(__dirname, '..'))
|
||||
|
||||
waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo }))
|
||||
|
||||
runs(() => {
|
||||
expect(repository.isDestroyed()).toBe(false)
|
||||
repository.destroy()
|
||||
expect(repository.isDestroyed()).toBe(true)
|
||||
})
|
||||
|
||||
waitsForPromise(() => atom.project.repositoryForDirectory(directory).then(repo => { repository = repo }))
|
||||
|
||||
runs(() => expect(repository.isDestroyed()).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('.setPaths(paths, options)', () => {
|
||||
describe('when path is a file', () =>
|
||||
it("sets its path to the file's parent directory and updates the root directory", () => {
|
||||
const filePath = require.resolve('./fixtures/dir/a')
|
||||
atom.project.setPaths([filePath])
|
||||
expect(atom.project.getPaths()[0]).toEqual(path.dirname(filePath))
|
||||
expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(filePath))
|
||||
})
|
||||
)
|
||||
|
||||
describe('when path is a directory', () => {
|
||||
it('assigns the directories and repositories', () => {
|
||||
const directory1 = temp.mkdirSync('non-git-repo')
|
||||
const directory2 = temp.mkdirSync('git-repo1')
|
||||
const directory3 = temp.mkdirSync('git-repo2')
|
||||
|
||||
const gitDirPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
|
||||
fs.copySync(gitDirPath, path.join(directory2, '.git'))
|
||||
fs.copySync(gitDirPath, path.join(directory3, '.git'))
|
||||
|
||||
atom.project.setPaths([directory1, directory2, directory3])
|
||||
|
||||
const [repo1, repo2, repo3] = atom.project.getRepositories()
|
||||
expect(repo1).toBeNull()
|
||||
expect(repo2.getShortHead()).toBe('master')
|
||||
expect(repo2.getPath()).toBe(fs.realpathSync(path.join(directory2, '.git')))
|
||||
expect(repo3.getShortHead()).toBe('master')
|
||||
expect(repo3.getPath()).toBe(fs.realpathSync(path.join(directory3, '.git')))
|
||||
})
|
||||
|
||||
it('calls callbacks registered with ::onDidChangePaths', () => {
|
||||
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
|
||||
const paths = [ temp.mkdirSync('dir1'), temp.mkdirSync('dir2') ]
|
||||
atom.project.setPaths(paths)
|
||||
|
||||
expect(onDidChangePathsSpy.callCount).toBe(1)
|
||||
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths)
|
||||
})
|
||||
|
||||
it('optionally throws an error with any paths that did not exist', () => {
|
||||
const paths = [temp.mkdirSync('exists0'), '/doesnt-exists/0', temp.mkdirSync('exists1'), '/doesnt-exists/1']
|
||||
|
||||
try {
|
||||
atom.project.setPaths(paths, {mustExist: true})
|
||||
expect('no exception thrown').toBeUndefined()
|
||||
} catch (e) {
|
||||
expect(e.missingProjectPaths).toEqual([paths[1], paths[3]])
|
||||
}
|
||||
|
||||
expect(atom.project.getPaths()).toEqual([paths[0], paths[2]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no paths are given', () =>
|
||||
it('clears its path', () => {
|
||||
atom.project.setPaths([])
|
||||
expect(atom.project.getPaths()).toEqual([])
|
||||
expect(atom.project.getDirectories()).toEqual([])
|
||||
})
|
||||
)
|
||||
|
||||
it('normalizes the path to remove consecutive slashes, ., and .. segments', () => {
|
||||
atom.project.setPaths([`${require.resolve('./fixtures/dir/a')}${path.sep}b${path.sep}${path.sep}..`])
|
||||
expect(atom.project.getPaths()[0]).toEqual(path.dirname(require.resolve('./fixtures/dir/a')))
|
||||
expect(atom.project.getDirectories()[0].path).toEqual(path.dirname(require.resolve('./fixtures/dir/a')))
|
||||
})
|
||||
})
|
||||
|
||||
describe('.addPath(path, options)', () => {
|
||||
it('calls callbacks registered with ::onDidChangePaths', () => {
|
||||
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
|
||||
const [oldPath] = atom.project.getPaths()
|
||||
|
||||
const newPath = temp.mkdirSync('dir')
|
||||
atom.project.addPath(newPath)
|
||||
|
||||
expect(onDidChangePathsSpy.callCount).toBe(1)
|
||||
expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual([oldPath, newPath])
|
||||
})
|
||||
|
||||
it("doesn't add redundant paths", () => {
|
||||
const onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
const [oldPath] = atom.project.getPaths()
|
||||
|
||||
// Doesn't re-add an existing root directory
|
||||
atom.project.addPath(oldPath)
|
||||
expect(atom.project.getPaths()).toEqual([oldPath])
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Doesn't add an entry for a file-path within an existing root directory
|
||||
atom.project.addPath(path.join(oldPath, 'some-file.txt'))
|
||||
expect(atom.project.getPaths()).toEqual([oldPath])
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Does add an entry for a directory within an existing directory
|
||||
const newPath = path.join(oldPath, 'a-dir')
|
||||
atom.project.addPath(newPath)
|
||||
expect(atom.project.getPaths()).toEqual([oldPath, newPath])
|
||||
expect(onDidChangePathsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("doesn't add non-existent directories", () => {
|
||||
const previousPaths = atom.project.getPaths()
|
||||
atom.project.addPath('/this-definitely/does-not-exist')
|
||||
expect(atom.project.getPaths()).toEqual(previousPaths)
|
||||
})
|
||||
|
||||
it('optionally throws on non-existent directories', () =>
|
||||
expect(() => atom.project.addPath('/this-definitely/does-not-exist', {mustExist: true})).toThrow()
|
||||
)
|
||||
})
|
||||
|
||||
describe('.removePath(path)', () => {
|
||||
let onDidChangePathsSpy = null
|
||||
|
||||
beforeEach(() => {
|
||||
onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths listener')
|
||||
atom.project.onDidChangePaths(onDidChangePathsSpy)
|
||||
})
|
||||
|
||||
it('removes the directory and repository for the path', () => {
|
||||
const result = atom.project.removePath(atom.project.getPaths()[0])
|
||||
expect(atom.project.getDirectories()).toEqual([])
|
||||
expect(atom.project.getRepositories()).toEqual([])
|
||||
expect(atom.project.getPaths()).toEqual([])
|
||||
expect(result).toBe(true)
|
||||
expect(onDidChangePathsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does nothing if the path is not one of the project's root paths", () => {
|
||||
const originalPaths = atom.project.getPaths()
|
||||
const result = atom.project.removePath(originalPaths[0] + 'xyz')
|
||||
expect(result).toBe(false)
|
||||
expect(atom.project.getPaths()).toEqual(originalPaths)
|
||||
expect(onDidChangePathsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("doesn't destroy the repository if it is shared by another root directory", () => {
|
||||
atom.project.setPaths([__dirname, path.join(__dirname, '..', 'src')])
|
||||
atom.project.removePath(__dirname)
|
||||
expect(atom.project.getPaths()).toEqual([path.join(__dirname, '..', 'src')])
|
||||
expect(atom.project.getRepositories()[0].isSubmodule('src')).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a path that is represented as a URI', () => {
|
||||
atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', {
|
||||
directoryForURISync (uri) {
|
||||
return {
|
||||
getPath () { return uri },
|
||||
getSubdirectory () { return {} },
|
||||
isRoot () { return true },
|
||||
existsSync () { return true },
|
||||
off () {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ftpURI = 'ftp://example.com/some/folder'
|
||||
|
||||
atom.project.setPaths([ftpURI])
|
||||
expect(atom.project.getPaths()).toEqual([ftpURI])
|
||||
|
||||
atom.project.removePath(ftpURI)
|
||||
expect(atom.project.getPaths()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('.onDidChangeFiles()', () => {
|
||||
let sub = []
|
||||
const events = []
|
||||
let checkCallback = () => {}
|
||||
|
||||
beforeEach(() => {
|
||||
sub = atom.project.onDidChangeFiles((incoming) => {
|
||||
events.push(...incoming)
|
||||
checkCallback()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sub.dispose())
|
||||
|
||||
const waitForEvents = (paths) => {
|
||||
const remaining = new Set(paths.map((p) => fs.realpathSync(p)))
|
||||
return new Promise((resolve, reject) => {
|
||||
checkCallback = () => {
|
||||
for (let event of events) { remaining.delete(event.path) }
|
||||
if (remaining.size === 0) { resolve() }
|
||||
}
|
||||
|
||||
const expire = () => {
|
||||
checkCallback = () => {}
|
||||
console.error('Paths not seen:', remaining)
|
||||
reject(new Error('Expired before all expected events were delivered.'))
|
||||
}
|
||||
|
||||
checkCallback()
|
||||
setTimeout(expire, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
it('reports filesystem changes within project paths', () => {
|
||||
const dirOne = temp.mkdirSync('atom-spec-project-one')
|
||||
const fileOne = path.join(dirOne, 'file-one.txt')
|
||||
const fileTwo = path.join(dirOne, 'file-two.txt')
|
||||
const dirTwo = temp.mkdirSync('atom-spec-project-two')
|
||||
const fileThree = path.join(dirTwo, 'file-three.txt')
|
||||
|
||||
// Ensure that all preexisting watchers are stopped
|
||||
waitsForPromise(() => stopAllWatchers())
|
||||
|
||||
runs(() => atom.project.setPaths([dirOne]))
|
||||
waitsForPromise(() => atom.project.getWatcherPromise(dirOne))
|
||||
|
||||
runs(() => {
|
||||
expect(atom.project.watcherPromisesByPath[dirTwo]).toEqual(undefined)
|
||||
|
||||
fs.writeFileSync(fileThree, 'three\n')
|
||||
fs.writeFileSync(fileTwo, 'two\n')
|
||||
fs.writeFileSync(fileOne, 'one\n')
|
||||
})
|
||||
|
||||
waitsForPromise(() => waitForEvents([fileOne, fileTwo]))
|
||||
|
||||
runs(() => expect(events.some(event => event.path === fileThree)).toBeFalsy())
|
||||
})
|
||||
})
|
||||
|
||||
describe('.onDidAddBuffer()', () =>
|
||||
it('invokes the callback with added text buffers', () => {
|
||||
const buffers = []
|
||||
const added = []
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
|
||||
.then(o => buffers.push(o))
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(buffers.length).toBe(1)
|
||||
atom.project.onDidAddBuffer(buffer => added.push(buffer))
|
||||
})
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then(o => buffers.push(o))
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(buffers.length).toBe(2)
|
||||
expect(added).toEqual([buffers[1]])
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('.observeBuffers()', () =>
|
||||
it('invokes the observer with current and future text buffers', () => {
|
||||
const buffers = []
|
||||
const observed = []
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/a'))
|
||||
.then(o => buffers.push(o))
|
||||
)
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then(o => buffers.push(o))
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(buffers.length).toBe(2)
|
||||
atom.project.observeBuffers(buffer => observed.push(buffer))
|
||||
expect(observed).toEqual(buffers)
|
||||
})
|
||||
|
||||
waitsForPromise(() =>
|
||||
atom.project.buildBuffer(require.resolve('./fixtures/dir/b'))
|
||||
.then(o => buffers.push(o))
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(observed.length).toBe(3)
|
||||
expect(buffers.length).toBe(3)
|
||||
expect(observed).toEqual(buffers)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('.relativize(path)', () => {
|
||||
it('returns the path, relative to whichever root directory it is inside of', () => {
|
||||
atom.project.addPath(temp.mkdirSync('another-path'))
|
||||
|
||||
let rootPath = atom.project.getPaths()[0]
|
||||
let childPath = path.join(rootPath, 'some', 'child', 'directory')
|
||||
expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory'))
|
||||
|
||||
rootPath = atom.project.getPaths()[1]
|
||||
childPath = path.join(rootPath, 'some', 'child', 'directory')
|
||||
expect(atom.project.relativize(childPath)).toBe(path.join('some', 'child', 'directory'))
|
||||
})
|
||||
|
||||
it('returns the given path if it is not in any of the root directories', () => {
|
||||
const randomPath = path.join('some', 'random', 'path')
|
||||
expect(atom.project.relativize(randomPath)).toBe(randomPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.relativizePath(path)', () => {
|
||||
it('returns the root path that contains the given path, and the path relativized to that root path', () => {
|
||||
atom.project.addPath(temp.mkdirSync('another-path'))
|
||||
|
||||
let rootPath = atom.project.getPaths()[0]
|
||||
let childPath = path.join(rootPath, 'some', 'child', 'directory')
|
||||
expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')])
|
||||
|
||||
rootPath = atom.project.getPaths()[1]
|
||||
childPath = path.join(rootPath, 'some', 'child', 'directory')
|
||||
expect(atom.project.relativizePath(childPath)).toEqual([rootPath, path.join('some', 'child', 'directory')])
|
||||
})
|
||||
|
||||
describe("when the given path isn't inside of any of the project's path", () =>
|
||||
it('returns null for the root path, and the given path unchanged', () => {
|
||||
const randomPath = path.join('some', 'random', 'path')
|
||||
expect(atom.project.relativizePath(randomPath)).toEqual([null, randomPath])
|
||||
})
|
||||
)
|
||||
|
||||
describe('when the given path is a URL', () =>
|
||||
it('returns null for the root path, and the given path unchanged', () => {
|
||||
const url = 'http://the-path'
|
||||
expect(atom.project.relativizePath(url)).toEqual([null, url])
|
||||
})
|
||||
)
|
||||
|
||||
describe('when the given path is inside more than one root folder', () =>
|
||||
it('uses the root folder that is closest to the given path', () => {
|
||||
atom.project.addPath(path.join(atom.project.getPaths()[0], 'a-dir'))
|
||||
|
||||
const inputPath = path.join(atom.project.getPaths()[1], 'somewhere/something.txt')
|
||||
|
||||
expect(atom.project.getDirectories()[0].contains(inputPath)).toBe(true)
|
||||
expect(atom.project.getDirectories()[1].contains(inputPath)).toBe(true)
|
||||
expect(atom.project.relativizePath(inputPath)).toEqual([
|
||||
atom.project.getPaths()[1],
|
||||
path.join('somewhere', 'something.txt')
|
||||
])
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('.contains(path)', () =>
|
||||
it('returns whether or not the given path is in one of the root directories', () => {
|
||||
const rootPath = atom.project.getPaths()[0]
|
||||
const childPath = path.join(rootPath, 'some', 'child', 'directory')
|
||||
expect(atom.project.contains(childPath)).toBe(true)
|
||||
|
||||
const randomPath = path.join('some', 'random', 'path')
|
||||
expect(atom.project.contains(randomPath)).toBe(false)
|
||||
})
|
||||
)
|
||||
|
||||
describe('.resolvePath(uri)', () =>
|
||||
it('normalizes disk drive letter in passed path on #win32', () => {
|
||||
expect(atom.project.resolvePath('d:\\file.txt')).toEqual('D:\\file.txt')
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1,123 +0,0 @@
|
||||
TextEditor = require '../src/text-editor'
|
||||
|
||||
describe "Selection", ->
|
||||
[buffer, editor, selection] = []
|
||||
|
||||
beforeEach ->
|
||||
buffer = atom.project.bufferForPathSync('sample.js')
|
||||
editor = new TextEditor({buffer: buffer, tabLength: 2})
|
||||
selection = editor.getLastSelection()
|
||||
|
||||
afterEach ->
|
||||
buffer.destroy()
|
||||
|
||||
describe ".deleteSelectedText()", ->
|
||||
describe "when nothing is selected", ->
|
||||
it "deletes nothing", ->
|
||||
selection.setBufferRange [[0, 3], [0, 3]]
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe "var quicksort = function () {"
|
||||
|
||||
describe "when one line is selected", ->
|
||||
it "deletes selected text and clears the selection", ->
|
||||
selection.setBufferRange [[0, 4], [0, 14]]
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe "var = function () {"
|
||||
|
||||
endOfLine = buffer.lineForRow(0).length
|
||||
selection.setBufferRange [[0, 0], [0, endOfLine]]
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe ""
|
||||
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
|
||||
describe "when multiple lines are selected", ->
|
||||
it "deletes selected text and clears the selection", ->
|
||||
selection.setBufferRange [[0, 1], [2, 39]]
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe "v;"
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
|
||||
describe "when the cursor precedes the tail", ->
|
||||
it "deletes selected text and clears the selection", ->
|
||||
selection.cursor.setScreenPosition [0, 13]
|
||||
selection.selectToScreenPosition [0, 4]
|
||||
|
||||
selection.delete()
|
||||
expect(buffer.lineForRow(0)).toBe "var = function () {"
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
|
||||
describe ".isReversed()", ->
|
||||
it "returns true if the cursor precedes the tail", ->
|
||||
selection.cursor.setScreenPosition([0, 20])
|
||||
selection.selectToScreenPosition([0, 10])
|
||||
expect(selection.isReversed()).toBeTruthy()
|
||||
|
||||
selection.selectToScreenPosition([0, 25])
|
||||
expect(selection.isReversed()).toBeFalsy()
|
||||
|
||||
describe ".selectLine(row)", ->
|
||||
describe "when passed a row", ->
|
||||
it "selects the specified row", ->
|
||||
selection.setBufferRange([[2, 4], [3, 4]])
|
||||
selection.selectLine(5)
|
||||
expect(selection.getBufferRange()).toEqual [[5, 0], [6, 0]]
|
||||
|
||||
describe "when not passed a row", ->
|
||||
it "selects all rows spanned by the selection", ->
|
||||
selection.setBufferRange([[2, 4], [3, 4]])
|
||||
selection.selectLine()
|
||||
expect(selection.getBufferRange()).toEqual [[2, 0], [4, 0]]
|
||||
|
||||
describe "when only the selection's tail is moved (regression)", ->
|
||||
it "notifies ::onDidChangeRange observers", ->
|
||||
selection.setBufferRange([[2, 0], [2, 10]], reversed: true)
|
||||
changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler')
|
||||
selection.onDidChangeRange changeScreenRangeHandler
|
||||
|
||||
buffer.insert([2, 5], 'abc')
|
||||
expect(changeScreenRangeHandler).toHaveBeenCalled()
|
||||
|
||||
describe "when the selection is destroyed", ->
|
||||
it "destroys its marker", ->
|
||||
selection.setBufferRange([[2, 0], [2, 10]])
|
||||
marker = selection.marker
|
||||
selection.destroy()
|
||||
expect(marker.isDestroyed()).toBeTruthy()
|
||||
|
||||
describe ".insertText(text, options)", ->
|
||||
it "allows pasting white space only lines when autoIndent is enabled", ->
|
||||
selection.setBufferRange [[0, 0], [0, 0]]
|
||||
selection.insertText(" \n \n\n", autoIndent: true)
|
||||
expect(buffer.lineForRow(0)).toBe " "
|
||||
expect(buffer.lineForRow(1)).toBe " "
|
||||
expect(buffer.lineForRow(2)).toBe ""
|
||||
|
||||
it "auto-indents if only a newline is inserted", ->
|
||||
selection.setBufferRange [[2, 0], [3, 0]]
|
||||
selection.insertText("\n", autoIndent: true)
|
||||
expect(buffer.lineForRow(2)).toBe " "
|
||||
|
||||
it "auto-indents if only a carriage return + newline is inserted", ->
|
||||
selection.setBufferRange [[2, 0], [3, 0]]
|
||||
selection.insertText("\r\n", autoIndent: true)
|
||||
expect(buffer.lineForRow(2)).toBe " "
|
||||
|
||||
describe ".fold()", ->
|
||||
it "folds the buffer range spanned by the selection", ->
|
||||
selection.setBufferRange([[0, 3], [1, 6]])
|
||||
selection.fold()
|
||||
|
||||
expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]])
|
||||
expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]])
|
||||
expect(editor.lineTextForScreenRow(0)).toBe "var#{editor.displayLayer.foldCharacter}sort = function(items) {"
|
||||
expect(editor.isFoldedAtBufferRow(0)).toBe(true)
|
||||
|
||||
it "doesn't create a fold when the selection is empty", ->
|
||||
selection.setBufferRange([[0, 3], [0, 3]])
|
||||
selection.fold()
|
||||
|
||||
expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]])
|
||||
expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]])
|
||||
expect(editor.lineTextForScreenRow(0)).toBe "var quicksort = function () {"
|
||||
expect(editor.isFoldedAtBufferRow(0)).toBe(false)
|
||||
157
spec/selection-spec.js
Normal file
157
spec/selection-spec.js
Normal file
@@ -0,0 +1,157 @@
|
||||
const TextEditor = require('../src/text-editor')
|
||||
|
||||
describe('Selection', () => {
|
||||
let buffer, editor, selection
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = atom.project.bufferForPathSync('sample.js')
|
||||
editor = new TextEditor({buffer, tabLength: 2})
|
||||
selection = editor.getLastSelection()
|
||||
})
|
||||
|
||||
afterEach(() => buffer.destroy())
|
||||
|
||||
describe('.deleteSelectedText()', () => {
|
||||
describe('when nothing is selected', () => {
|
||||
it('deletes nothing', () => {
|
||||
selection.setBufferRange([[0, 3], [0, 3]])
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe('var quicksort = function () {')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when one line is selected', () => {
|
||||
it('deletes selected text and clears the selection', () => {
|
||||
selection.setBufferRange([[0, 4], [0, 14]])
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe('var = function () {')
|
||||
|
||||
const endOfLine = buffer.lineForRow(0).length
|
||||
selection.setBufferRange([[0, 0], [0, endOfLine]])
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe('')
|
||||
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when multiple lines are selected', () => {
|
||||
it('deletes selected text and clears the selection', () => {
|
||||
selection.setBufferRange([[0, 1], [2, 39]])
|
||||
selection.deleteSelectedText()
|
||||
expect(buffer.lineForRow(0)).toBe('v;')
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the cursor precedes the tail', () => {
|
||||
it('deletes selected text and clears the selection', () => {
|
||||
selection.cursor.setScreenPosition([0, 13])
|
||||
selection.selectToScreenPosition([0, 4])
|
||||
|
||||
selection.delete()
|
||||
expect(buffer.lineForRow(0)).toBe('var = function () {')
|
||||
expect(selection.isEmpty()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.isReversed()', () => {
|
||||
it('returns true if the cursor precedes the tail', () => {
|
||||
selection.cursor.setScreenPosition([0, 20])
|
||||
selection.selectToScreenPosition([0, 10])
|
||||
expect(selection.isReversed()).toBeTruthy()
|
||||
|
||||
selection.selectToScreenPosition([0, 25])
|
||||
expect(selection.isReversed()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('.selectLine(row)', () => {
|
||||
describe('when passed a row', () => {
|
||||
it('selects the specified row', () => {
|
||||
selection.setBufferRange([[2, 4], [3, 4]])
|
||||
selection.selectLine(5)
|
||||
expect(selection.getBufferRange()).toEqual([[5, 0], [6, 0]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not passed a row', () => {
|
||||
it('selects all rows spanned by the selection', () => {
|
||||
selection.setBufferRange([[2, 4], [3, 4]])
|
||||
selection.selectLine()
|
||||
expect(selection.getBufferRange()).toEqual([[2, 0], [4, 0]])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("when only the selection's tail is moved (regression)", () => {
|
||||
it('notifies ::onDidChangeRange observers', () => {
|
||||
selection.setBufferRange([[2, 0], [2, 10]], {reversed: true})
|
||||
const changeScreenRangeHandler = jasmine.createSpy('changeScreenRangeHandler')
|
||||
selection.onDidChangeRange(changeScreenRangeHandler)
|
||||
|
||||
buffer.insert([2, 5], 'abc')
|
||||
expect(changeScreenRangeHandler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the selection is destroyed', () => {
|
||||
it('destroys its marker', () => {
|
||||
selection.setBufferRange([[2, 0], [2, 10]])
|
||||
const { marker } = selection
|
||||
selection.destroy()
|
||||
expect(marker.isDestroyed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('.insertText(text, options)', () => {
|
||||
it('allows pasting white space only lines when autoIndent is enabled', () => {
|
||||
selection.setBufferRange([[0, 0], [0, 0]])
|
||||
selection.insertText(' \n \n\n', {autoIndent: true})
|
||||
expect(buffer.lineForRow(0)).toBe(' ')
|
||||
expect(buffer.lineForRow(1)).toBe(' ')
|
||||
expect(buffer.lineForRow(2)).toBe('')
|
||||
})
|
||||
|
||||
it('auto-indents if only a newline is inserted', () => {
|
||||
selection.setBufferRange([[2, 0], [3, 0]])
|
||||
selection.insertText('\n', {autoIndent: true})
|
||||
expect(buffer.lineForRow(2)).toBe(' ')
|
||||
})
|
||||
|
||||
it('auto-indents if only a carriage return + newline is inserted', () => {
|
||||
selection.setBufferRange([[2, 0], [3, 0]])
|
||||
selection.insertText('\r\n', {autoIndent: true})
|
||||
expect(buffer.lineForRow(2)).toBe(' ')
|
||||
})
|
||||
|
||||
it('does not adjust the indent of trailing lines if preserveTrailingLineIndentation is true', () => {
|
||||
selection.setBufferRange([[5, 0], [5, 0]])
|
||||
selection.insertText(' foo\n bar\n', {preserveTrailingLineIndentation: true, indentBasis: 1})
|
||||
expect(buffer.lineForRow(6)).toBe(' bar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('.fold()', () => {
|
||||
it('folds the buffer range spanned by the selection', () => {
|
||||
selection.setBufferRange([[0, 3], [1, 6]])
|
||||
selection.fold()
|
||||
|
||||
expect(selection.getScreenRange()).toEqual([[0, 4], [0, 4]])
|
||||
expect(selection.getBufferRange()).toEqual([[1, 6], [1, 6]])
|
||||
expect(editor.lineTextForScreenRow(0)).toBe(`var${editor.displayLayer.foldCharacter}sort = function(items) {`)
|
||||
expect(editor.isFoldedAtBufferRow(0)).toBe(true)
|
||||
})
|
||||
|
||||
it("doesn't create a fold when the selection is empty", () => {
|
||||
selection.setBufferRange([[0, 3], [0, 3]])
|
||||
selection.fold()
|
||||
|
||||
expect(selection.getScreenRange()).toEqual([[0, 3], [0, 3]])
|
||||
expect(selection.getBufferRange()).toEqual([[0, 3], [0, 3]])
|
||||
expect(editor.lineTextForScreenRow(0)).toBe('var quicksort = function () {')
|
||||
expect(editor.isFoldedAtBufferRow(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -58,7 +58,7 @@ if specPackagePath = FindParentDir.sync(testPaths[0], 'package.json')
|
||||
if specDirectory = FindParentDir.sync(testPaths[0], 'fixtures')
|
||||
specProjectPath = path.join(specDirectory, 'fixtures')
|
||||
else
|
||||
specProjectPath = path.join(__dirname, 'fixtures')
|
||||
specProjectPath = require('os').tmpdir()
|
||||
|
||||
beforeEach ->
|
||||
atom.project.setPaths([specProjectPath])
|
||||
|
||||
@@ -1896,6 +1896,8 @@ describe('TextEditorComponent', () => {
|
||||
const decoration = editor.decorateMarker(marker, {type: 'overlay', item: overlayElement, class: 'a'})
|
||||
await component.getNextUpdatePromise()
|
||||
|
||||
const overlayComponent = component.overlayComponents.values().next().value
|
||||
|
||||
const overlayWrapper = overlayElement.parentElement
|
||||
expect(overlayWrapper.classList.contains('a')).toBe(true)
|
||||
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
|
||||
@@ -1926,12 +1928,12 @@ describe('TextEditorComponent', () => {
|
||||
await setScrollTop(component, 20)
|
||||
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
|
||||
overlayElement.style.height = 60 + 'px'
|
||||
await component.getNextUpdatePromise()
|
||||
await overlayComponent.getNextUpdatePromise()
|
||||
expect(overlayWrapper.getBoundingClientRect().bottom).toBe(clientTopForLine(component, 4))
|
||||
|
||||
// Does not flip the overlay vertically if it would overflow the top of the window
|
||||
overlayElement.style.height = 80 + 'px'
|
||||
await component.getNextUpdatePromise()
|
||||
await overlayComponent.getNextUpdatePromise()
|
||||
expect(overlayWrapper.getBoundingClientRect().top).toBe(clientTopForLine(component, 5))
|
||||
|
||||
// Can update overlay wrapper class
|
||||
@@ -4426,11 +4428,14 @@ describe('TextEditorComponent', () => {
|
||||
const {component, editor} = buildComponent()
|
||||
|
||||
let dragging = false
|
||||
component.handleMouseDragUntilMouseUp({
|
||||
didDrag: (event) => { dragging = true },
|
||||
didStopDragging: () => { dragging = false }
|
||||
})
|
||||
function startDragging () {
|
||||
component.handleMouseDragUntilMouseUp({
|
||||
didDrag: (event) => { dragging = true },
|
||||
didStopDragging: () => { dragging = false }
|
||||
})
|
||||
}
|
||||
|
||||
startDragging()
|
||||
window.dispatchEvent(new MouseEvent('mousemove'))
|
||||
await getNextAnimationFramePromise()
|
||||
expect(dragging).toBe(true)
|
||||
@@ -4446,6 +4451,17 @@ describe('TextEditorComponent', () => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove'))
|
||||
await getNextAnimationFramePromise()
|
||||
expect(dragging).toBe(false)
|
||||
|
||||
// Pressing a modifier key does not terminate dragging, (to ensure we can add new selections with the mouse)
|
||||
startDragging()
|
||||
window.dispatchEvent(new MouseEvent('mousemove'))
|
||||
await getNextAnimationFramePromise()
|
||||
expect(dragging).toBe(true)
|
||||
component.didKeydown({key: 'Control'})
|
||||
component.didKeydown({key: 'Alt'})
|
||||
component.didKeydown({key: 'Shift'})
|
||||
component.didKeydown({key: 'Meta'})
|
||||
expect(dragging).toBe(true)
|
||||
})
|
||||
|
||||
function getNextAnimationFramePromise () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -643,186 +643,6 @@ describe('TokenizedBuffer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('.toggleLineCommentsForBufferRows', () => {
|
||||
describe('xml', () => {
|
||||
beforeEach(async () => {
|
||||
await atom.packages.activatePackage('language-xml')
|
||||
buffer = new TextBuffer('<!-- test -->')
|
||||
tokenizedBuffer = new TokenizedBuffer({
|
||||
buffer,
|
||||
grammar: atom.grammars.grammarForScopeName('text.xml'),
|
||||
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the leading whitespace from the comment end pattern match when uncommenting lines', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('less', () => {
|
||||
beforeEach(async () => {
|
||||
await atom.packages.activatePackage('language-less')
|
||||
await atom.packages.activatePackage('language-css')
|
||||
buffer = await TextBuffer.load(require.resolve('./fixtures/sample.less'))
|
||||
tokenizedBuffer = new TokenizedBuffer({
|
||||
buffer,
|
||||
grammar: atom.grammars.grammarForScopeName('source.css.less'),
|
||||
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
|
||||
})
|
||||
})
|
||||
|
||||
it('only uses the `commentEnd` pattern if it comes from the same grammar as the `commentStart` when commenting lines', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe('// @color: #4D926F;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('css', () => {
|
||||
beforeEach(async () => {
|
||||
await atom.packages.activatePackage('language-css')
|
||||
buffer = await TextBuffer.load(require.resolve('./fixtures/css.css'))
|
||||
tokenizedBuffer = new TokenizedBuffer({
|
||||
buffer,
|
||||
grammar: atom.grammars.grammarForScopeName('source.css'),
|
||||
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
|
||||
})
|
||||
})
|
||||
|
||||
it('comments/uncomments lines in the given range', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
|
||||
expect(buffer.lineForRow(0)).toBe('/*body {')
|
||||
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/')
|
||||
expect(buffer.lineForRow(2)).toBe(' width: 110%;')
|
||||
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
|
||||
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
|
||||
expect(buffer.lineForRow(0)).toBe('/*body {')
|
||||
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;*/')
|
||||
expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/')
|
||||
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
|
||||
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
|
||||
expect(buffer.lineForRow(0)).toBe('body {')
|
||||
expect(buffer.lineForRow(1)).toBe(' font-size: 1234px;')
|
||||
expect(buffer.lineForRow(2)).toBe(' /*width: 110%;*/')
|
||||
expect(buffer.lineForRow(3)).toBe(' font-weight: bold !important;')
|
||||
})
|
||||
|
||||
it('uncomments lines with leading whitespace', () => {
|
||||
buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
|
||||
expect(buffer.lineForRow(2)).toBe(' width: 110%;')
|
||||
})
|
||||
|
||||
it('uncomments lines with trailing whitespace', () => {
|
||||
buffer.setTextInRange([[2, 0], [2, Infinity]], '/*width: 110%;*/ ')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
|
||||
expect(buffer.lineForRow(2)).toBe('width: 110%; ')
|
||||
})
|
||||
|
||||
it('uncomments lines with leading and trailing whitespace', () => {
|
||||
buffer.setTextInRange([[2, 0], [2, Infinity]], ' /*width: 110%;*/ ')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(2, 2)
|
||||
expect(buffer.lineForRow(2)).toBe(' width: 110%; ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('coffeescript', () => {
|
||||
beforeEach(async () => {
|
||||
await atom.packages.activatePackage('language-coffee-script')
|
||||
buffer = await TextBuffer.load(require.resolve('./fixtures/coffee.coffee'))
|
||||
tokenizedBuffer = new TokenizedBuffer({
|
||||
buffer,
|
||||
tabLength: 2,
|
||||
grammar: atom.grammars.grammarForScopeName('source.coffee'),
|
||||
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
|
||||
})
|
||||
})
|
||||
|
||||
it('comments/uncomments lines in the given range', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 6)
|
||||
expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()')
|
||||
expect(buffer.lineForRow(5)).toBe(' # left = []')
|
||||
expect(buffer.lineForRow(6)).toBe(' # right = []')
|
||||
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
|
||||
expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()')
|
||||
expect(buffer.lineForRow(5)).toBe(' left = []')
|
||||
expect(buffer.lineForRow(6)).toBe(' # right = []')
|
||||
})
|
||||
|
||||
it('comments/uncomments empty lines', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7)
|
||||
expect(buffer.lineForRow(4)).toBe(' # pivot = items.shift()')
|
||||
expect(buffer.lineForRow(5)).toBe(' # left = []')
|
||||
expect(buffer.lineForRow(6)).toBe(' # right = []')
|
||||
expect(buffer.lineForRow(7)).toBe(' # ')
|
||||
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
|
||||
expect(buffer.lineForRow(4)).toBe(' pivot = items.shift()')
|
||||
expect(buffer.lineForRow(5)).toBe(' left = []')
|
||||
expect(buffer.lineForRow(6)).toBe(' # right = []')
|
||||
expect(buffer.lineForRow(7)).toBe(' # ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('javascript', () => {
|
||||
beforeEach(async () => {
|
||||
await atom.packages.activatePackage('language-javascript')
|
||||
buffer = await TextBuffer.load(require.resolve('./fixtures/sample.js'))
|
||||
tokenizedBuffer = new TokenizedBuffer({
|
||||
buffer,
|
||||
tabLength: 2,
|
||||
grammar: atom.grammars.grammarForScopeName('source.js'),
|
||||
scopedSettingsDelegate: new ScopedSettingsDelegate(atom.config)
|
||||
})
|
||||
})
|
||||
|
||||
it('comments/uncomments lines in the given range', () => {
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 7)
|
||||
expect(buffer.lineForRow(4)).toBe(' // while(items.length > 0) {')
|
||||
expect(buffer.lineForRow(5)).toBe(' // current = items.shift();')
|
||||
expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);')
|
||||
expect(buffer.lineForRow(7)).toBe(' // }')
|
||||
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(4, 5)
|
||||
expect(buffer.lineForRow(4)).toBe(' while(items.length > 0) {')
|
||||
expect(buffer.lineForRow(5)).toBe(' current = items.shift();')
|
||||
expect(buffer.lineForRow(6)).toBe(' // current < pivot ? left.push(current) : right.push(current);')
|
||||
expect(buffer.lineForRow(7)).toBe(' // }')
|
||||
|
||||
buffer.setText('\tvar i;')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe('\t// var i;')
|
||||
|
||||
buffer.setText('var i;')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe('// var i;')
|
||||
|
||||
buffer.setText(' var i;')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe(' // var i;')
|
||||
|
||||
buffer.setText(' ')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 0)
|
||||
expect(buffer.lineForRow(0)).toBe(' // ')
|
||||
|
||||
buffer.setText(' a\n \n b')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 2)
|
||||
expect(buffer.lineForRow(0)).toBe(' // a')
|
||||
expect(buffer.lineForRow(1)).toBe(' // ')
|
||||
expect(buffer.lineForRow(2)).toBe(' // b')
|
||||
|
||||
buffer.setText(' \n // var i;')
|
||||
tokenizedBuffer.toggleLineCommentsForBufferRows(0, 1)
|
||||
expect(buffer.lineForRow(0)).toBe(' ')
|
||||
expect(buffer.lineForRow(1)).toBe(' var i;')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.isFoldableAtRow(row)', () => {
|
||||
beforeEach(() => {
|
||||
buffer = atom.project.bufferForPathSync('sample.js')
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
{CompositeDisposable} = require 'atom'
|
||||
TooltipManager = require '../src/tooltip-manager'
|
||||
Tooltip = require '../src/tooltip'
|
||||
_ = require 'underscore-plus'
|
||||
|
||||
describe "TooltipManager", ->
|
||||
[manager, element] = []
|
||||
|
||||
ctrlX = _.humanizeKeystroke("ctrl-x")
|
||||
ctrlY = _.humanizeKeystroke("ctrl-y")
|
||||
|
||||
beforeEach ->
|
||||
manager = new TooltipManager(keymapManager: atom.keymaps, viewRegistry: atom.views)
|
||||
element = createElement 'foo'
|
||||
|
||||
createElement = (className) ->
|
||||
el = document.createElement('div')
|
||||
el.classList.add(className)
|
||||
jasmine.attachToDOM(el)
|
||||
el
|
||||
|
||||
mouseEnter = (element) ->
|
||||
element.dispatchEvent(new CustomEvent('mouseenter', bubbles: false))
|
||||
element.dispatchEvent(new CustomEvent('mouseover', bubbles: true))
|
||||
|
||||
mouseLeave = (element) ->
|
||||
element.dispatchEvent(new CustomEvent('mouseleave', bubbles: false))
|
||||
element.dispatchEvent(new CustomEvent('mouseout', bubbles: true))
|
||||
|
||||
hover = (element, fn) ->
|
||||
mouseEnter(element)
|
||||
advanceClock(manager.hoverDefaults.delay.show)
|
||||
fn()
|
||||
mouseLeave(element)
|
||||
advanceClock(manager.hoverDefaults.delay.hide)
|
||||
|
||||
describe "::add(target, options)", ->
|
||||
describe "when the trigger is 'hover' (the default)", ->
|
||||
it "creates a tooltip when hovering over the target element", ->
|
||||
manager.add element, title: "Title"
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
|
||||
|
||||
it "displays tooltips immediately when hovering over new elements once a tooltip has been displayed once", ->
|
||||
disposables = new CompositeDisposable
|
||||
element1 = createElement('foo')
|
||||
disposables.add(manager.add element1, title: 'Title')
|
||||
element2 = createElement('bar')
|
||||
disposables.add(manager.add element2, title: 'Title')
|
||||
element3 = createElement('baz')
|
||||
disposables.add(manager.add element3, title: 'Title')
|
||||
|
||||
hover element1, ->
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
mouseEnter(element2)
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
mouseLeave(element2)
|
||||
advanceClock(manager.hoverDefaults.delay.hide)
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
advanceClock(Tooltip.FOLLOW_THROUGH_DURATION)
|
||||
mouseEnter(element3)
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
advanceClock(manager.hoverDefaults.delay.show)
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
|
||||
disposables.dispose()
|
||||
|
||||
describe "when the trigger is 'manual'", ->
|
||||
it "creates a tooltip immediately and only hides it on dispose", ->
|
||||
disposable = manager.add element, title: "Title", trigger: "manual"
|
||||
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
|
||||
disposable.dispose()
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
describe "when the trigger is 'click'", ->
|
||||
it "shows and hides the tooltip when the target element is clicked", ->
|
||||
disposable = manager.add element, title: "Title", trigger: "click"
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
# Hide the tooltip when clicking anywhere but inside the tooltip element
|
||||
element.click()
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
document.body.querySelector(".tooltip").click()
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
document.body.querySelector(".tooltip").firstChild.click()
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
document.body.click()
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
# Tooltip can show again after hiding due to clicking outside of the tooltip
|
||||
element.click()
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
it "allows a custom item to be specified for the content of the tooltip", ->
|
||||
tooltipElement = document.createElement('div')
|
||||
manager.add element, item: {element: tooltipElement}
|
||||
hover element, ->
|
||||
expect(tooltipElement.closest(".tooltip")).not.toBeNull()
|
||||
|
||||
it "allows a custom class to be specified for the tooltip", ->
|
||||
tooltipElement = document.createElement('div')
|
||||
manager.add element, title: 'Title', class: 'custom-tooltip-class'
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip").classList.contains('custom-tooltip-class')).toBe(true)
|
||||
|
||||
it "allows jQuery elements to be passed as the target", ->
|
||||
element2 = document.createElement('div')
|
||||
jasmine.attachToDOM(element2)
|
||||
|
||||
fakeJqueryWrapper = [element, element2]
|
||||
fakeJqueryWrapper.jquery = 'any-version'
|
||||
disposable = manager.add fakeJqueryWrapper, title: "Title"
|
||||
|
||||
hover element, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title")
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
hover element2, -> expect(document.body.querySelector(".tooltip")).toHaveText("Title")
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
hover element, -> expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
hover element2, -> expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
describe "when a keyBindingCommand is specified", ->
|
||||
describe "when a title is specified", ->
|
||||
it "appends the key binding corresponding to the command to the title", ->
|
||||
atom.keymaps.add 'test',
|
||||
'.foo': 'ctrl-x ctrl-y': 'test-command'
|
||||
'.bar': 'ctrl-x ctrl-z': 'test-command'
|
||||
|
||||
manager.add element, title: "Title", keyBindingCommand: 'test-command'
|
||||
|
||||
hover element, ->
|
||||
tooltipElement = document.body.querySelector(".tooltip")
|
||||
expect(tooltipElement).toHaveText "Title #{ctrlX} #{ctrlY}"
|
||||
|
||||
describe "when no title is specified", ->
|
||||
it "shows the key binding corresponding to the command alone", ->
|
||||
atom.keymaps.add 'test', '.foo': 'ctrl-x ctrl-y': 'test-command'
|
||||
|
||||
manager.add element, keyBindingCommand: 'test-command'
|
||||
|
||||
hover element, ->
|
||||
tooltipElement = document.body.querySelector(".tooltip")
|
||||
expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}"
|
||||
|
||||
describe "when a keyBindingTarget is specified", ->
|
||||
it "looks up the key binding relative to the target", ->
|
||||
atom.keymaps.add 'test',
|
||||
'.bar': 'ctrl-x ctrl-z': 'test-command'
|
||||
'.foo': 'ctrl-x ctrl-y': 'test-command'
|
||||
|
||||
manager.add element, keyBindingCommand: 'test-command', keyBindingTarget: element
|
||||
|
||||
hover element, ->
|
||||
tooltipElement = document.body.querySelector(".tooltip")
|
||||
expect(tooltipElement).toHaveText "#{ctrlX} #{ctrlY}"
|
||||
|
||||
it "does not display the keybinding if there is nothing mapped to the specified keyBindingCommand", ->
|
||||
manager.add element, title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element
|
||||
|
||||
hover element, ->
|
||||
tooltipElement = document.body.querySelector(".tooltip")
|
||||
expect(tooltipElement.textContent).toBe "A Title"
|
||||
|
||||
describe "when .dispose() is called on the returned disposable", ->
|
||||
it "no longer displays the tooltip on hover", ->
|
||||
disposable = manager.add element, title: "Title"
|
||||
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
|
||||
describe "when the window is resized", ->
|
||||
it "hides the tooltips", ->
|
||||
disposable = manager.add element, title: "Title"
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
window.dispatchEvent(new CustomEvent('resize'))
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
disposable.dispose()
|
||||
|
||||
describe "findTooltips", ->
|
||||
it "adds and remove tooltips correctly", ->
|
||||
expect(manager.findTooltips(element).length).toBe(0)
|
||||
disposable1 = manager.add element, title: "elem1"
|
||||
expect(manager.findTooltips(element).length).toBe(1)
|
||||
disposable2 = manager.add element, title: "elem2"
|
||||
expect(manager.findTooltips(element).length).toBe(2)
|
||||
disposable1.dispose()
|
||||
expect(manager.findTooltips(element).length).toBe(1)
|
||||
disposable2.dispose()
|
||||
expect(manager.findTooltips(element).length).toBe(0)
|
||||
|
||||
it "lets us hide tooltips programmatically", ->
|
||||
disposable = manager.add element, title: "Title"
|
||||
hover element, ->
|
||||
expect(document.body.querySelector(".tooltip")).not.toBeNull()
|
||||
manager.findTooltips(element)[0].hide()
|
||||
expect(document.body.querySelector(".tooltip")).toBeNull()
|
||||
disposable.dispose()
|
||||
253
spec/tooltip-manager-spec.js
Normal file
253
spec/tooltip-manager-spec.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const {CompositeDisposable} = require('atom')
|
||||
const TooltipManager = require('../src/tooltip-manager')
|
||||
const Tooltip = require('../src/tooltip')
|
||||
const _ = require('underscore-plus')
|
||||
|
||||
describe('TooltipManager', () => {
|
||||
let manager, element
|
||||
|
||||
const ctrlX = _.humanizeKeystroke('ctrl-x')
|
||||
const ctrlY = _.humanizeKeystroke('ctrl-y')
|
||||
|
||||
const hover = function (element, fn) {
|
||||
mouseEnter(element)
|
||||
advanceClock(manager.hoverDefaults.delay.show)
|
||||
fn()
|
||||
mouseLeave(element)
|
||||
advanceClock(manager.hoverDefaults.delay.hide)
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
manager = new TooltipManager({keymapManager: atom.keymaps, viewRegistry: atom.views})
|
||||
element = createElement('foo')
|
||||
})
|
||||
|
||||
describe('::add(target, options)', () => {
|
||||
describe("when the trigger is 'hover' (the default)", () => {
|
||||
it('creates a tooltip when hovering over the target element', () => {
|
||||
manager.add(element, {title: 'Title'})
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
|
||||
})
|
||||
|
||||
it('displays tooltips immediately when hovering over new elements once a tooltip has been displayed once', () => {
|
||||
const disposables = new CompositeDisposable()
|
||||
const element1 = createElement('foo')
|
||||
disposables.add(manager.add(element1, {title: 'Title'}))
|
||||
const element2 = createElement('bar')
|
||||
disposables.add(manager.add(element2, {title: 'Title'}))
|
||||
const element3 = createElement('baz')
|
||||
disposables.add(manager.add(element3, {title: 'Title'}))
|
||||
|
||||
hover(element1, () => {})
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
|
||||
mouseEnter(element2)
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
mouseLeave(element2)
|
||||
advanceClock(manager.hoverDefaults.delay.hide)
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
|
||||
advanceClock(Tooltip.FOLLOW_THROUGH_DURATION)
|
||||
mouseEnter(element3)
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
advanceClock(manager.hoverDefaults.delay.show)
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
|
||||
disposables.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the trigger is 'manual'", () =>
|
||||
it('creates a tooltip immediately and only hides it on dispose', () => {
|
||||
const disposable = manager.add(element, {title: 'Title', trigger: 'manual'})
|
||||
expect(document.body.querySelector('.tooltip')).toHaveText('Title')
|
||||
disposable.dispose()
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
})
|
||||
)
|
||||
|
||||
describe("when the trigger is 'click'", () =>
|
||||
it('shows and hides the tooltip when the target element is clicked', () => {
|
||||
manager.add(element, {title: 'Title', trigger: 'click'})
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
|
||||
// Hide the tooltip when clicking anywhere but inside the tooltip element
|
||||
element.click()
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
document.body.querySelector('.tooltip').click()
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
document.body.querySelector('.tooltip').firstChild.click()
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
document.body.click()
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
|
||||
// Tooltip can show again after hiding due to clicking outside of the tooltip
|
||||
element.click()
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
element.click()
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
})
|
||||
)
|
||||
|
||||
it('allows a custom item to be specified for the content of the tooltip', () => {
|
||||
const tooltipElement = document.createElement('div')
|
||||
manager.add(element, {item: {element: tooltipElement}})
|
||||
hover(element, () => expect(tooltipElement.closest('.tooltip')).not.toBeNull())
|
||||
})
|
||||
|
||||
it('allows a custom class to be specified for the tooltip', () => {
|
||||
manager.add(element, {title: 'Title', class: 'custom-tooltip-class'})
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip').classList.contains('custom-tooltip-class')).toBe(true))
|
||||
})
|
||||
|
||||
it('allows jQuery elements to be passed as the target', () => {
|
||||
const element2 = document.createElement('div')
|
||||
jasmine.attachToDOM(element2)
|
||||
|
||||
const fakeJqueryWrapper = [element, element2]
|
||||
fakeJqueryWrapper.jquery = 'any-version'
|
||||
const disposable = manager.add(fakeJqueryWrapper, {title: 'Title'})
|
||||
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
hover(element2, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull())
|
||||
hover(element2, () => expect(document.body.querySelector('.tooltip')).toBeNull())
|
||||
})
|
||||
|
||||
describe('when a keyBindingCommand is specified', () => {
|
||||
describe('when a title is specified', () =>
|
||||
it('appends the key binding corresponding to the command to the title', () => {
|
||||
atom.keymaps.add('test', {
|
||||
'.foo': { 'ctrl-x ctrl-y': 'test-command'
|
||||
},
|
||||
'.bar': { 'ctrl-x ctrl-z': 'test-command'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
manager.add(element, {title: 'Title', keyBindingCommand: 'test-command'})
|
||||
|
||||
hover(element, function () {
|
||||
const tooltipElement = document.body.querySelector('.tooltip')
|
||||
expect(tooltipElement).toHaveText(`Title ${ctrlX} ${ctrlY}`)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('when no title is specified', () =>
|
||||
it('shows the key binding corresponding to the command alone', () => {
|
||||
atom.keymaps.add('test', {'.foo': {'ctrl-x ctrl-y': 'test-command'}})
|
||||
|
||||
manager.add(element, {keyBindingCommand: 'test-command'})
|
||||
|
||||
hover(element, function () {
|
||||
const tooltipElement = document.body.querySelector('.tooltip')
|
||||
expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('when a keyBindingTarget is specified', () => {
|
||||
it('looks up the key binding relative to the target', () => {
|
||||
atom.keymaps.add('test', {
|
||||
'.bar': { 'ctrl-x ctrl-z': 'test-command'
|
||||
},
|
||||
'.foo': { 'ctrl-x ctrl-y': 'test-command'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
manager.add(element, {keyBindingCommand: 'test-command', keyBindingTarget: element})
|
||||
|
||||
hover(element, function () {
|
||||
const tooltipElement = document.body.querySelector('.tooltip')
|
||||
expect(tooltipElement).toHaveText(`${ctrlX} ${ctrlY}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not display the keybinding if there is nothing mapped to the specified keyBindingCommand', () => {
|
||||
manager.add(element, {title: 'A Title', keyBindingCommand: 'test-command', keyBindingTarget: element})
|
||||
|
||||
hover(element, function () {
|
||||
const tooltipElement = document.body.querySelector('.tooltip')
|
||||
expect(tooltipElement.textContent).toBe('A Title')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when .dispose() is called on the returned disposable', () =>
|
||||
it('no longer displays the tooltip on hover', () => {
|
||||
const disposable = manager.add(element, {title: 'Title'})
|
||||
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip')).toHaveText('Title'))
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
hover(element, () => expect(document.body.querySelector('.tooltip')).toBeNull())
|
||||
})
|
||||
)
|
||||
|
||||
describe('when the window is resized', () =>
|
||||
it('hides the tooltips', () => {
|
||||
const disposable = manager.add(element, {title: 'Title'})
|
||||
hover(element, function () {
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
window.dispatchEvent(new CustomEvent('resize'))
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
disposable.dispose()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('findTooltips', () => {
|
||||
it('adds and remove tooltips correctly', () => {
|
||||
expect(manager.findTooltips(element).length).toBe(0)
|
||||
const disposable1 = manager.add(element, {title: 'elem1'})
|
||||
expect(manager.findTooltips(element).length).toBe(1)
|
||||
const disposable2 = manager.add(element, {title: 'elem2'})
|
||||
expect(manager.findTooltips(element).length).toBe(2)
|
||||
disposable1.dispose()
|
||||
expect(manager.findTooltips(element).length).toBe(1)
|
||||
disposable2.dispose()
|
||||
expect(manager.findTooltips(element).length).toBe(0)
|
||||
})
|
||||
|
||||
it('lets us hide tooltips programmatically', () => {
|
||||
const disposable = manager.add(element, {title: 'Title'})
|
||||
hover(element, function () {
|
||||
expect(document.body.querySelector('.tooltip')).not.toBeNull()
|
||||
manager.findTooltips(element)[0].hide()
|
||||
expect(document.body.querySelector('.tooltip')).toBeNull()
|
||||
disposable.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createElement (className) {
|
||||
const el = document.createElement('div')
|
||||
el.classList.add(className)
|
||||
jasmine.attachToDOM(el)
|
||||
return el
|
||||
}
|
||||
|
||||
function mouseEnter (element) {
|
||||
element.dispatchEvent(new CustomEvent('mouseenter', {bubbles: false}))
|
||||
element.dispatchEvent(new CustomEvent('mouseover', {bubbles: true}))
|
||||
}
|
||||
|
||||
function mouseLeave (element) {
|
||||
element.dispatchEvent(new CustomEvent('mouseleave', {bubbles: false}))
|
||||
element.dispatchEvent(new CustomEvent('mouseout', {bubbles: true}))
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
ViewRegistry = require '../src/view-registry'
|
||||
|
||||
describe "ViewRegistry", ->
|
||||
registry = null
|
||||
|
||||
beforeEach ->
|
||||
registry = new ViewRegistry
|
||||
|
||||
afterEach ->
|
||||
registry.clearDocumentRequests()
|
||||
|
||||
describe "::getView(object)", ->
|
||||
describe "when passed a DOM node", ->
|
||||
it "returns the given DOM node", ->
|
||||
node = document.createElement('div')
|
||||
expect(registry.getView(node)).toBe node
|
||||
|
||||
describe "when passed an object with an element property", ->
|
||||
it "returns the element property if it's an instance of HTMLElement", ->
|
||||
class TestComponent
|
||||
constructor: -> @element = document.createElement('div')
|
||||
|
||||
component = new TestComponent
|
||||
expect(registry.getView(component)).toBe component.element
|
||||
|
||||
describe "when passed an object with a getElement function", ->
|
||||
it "returns the return value of getElement if it's an instance of HTMLElement", ->
|
||||
class TestComponent
|
||||
getElement: ->
|
||||
@myElement ?= document.createElement('div')
|
||||
|
||||
component = new TestComponent
|
||||
expect(registry.getView(component)).toBe component.myElement
|
||||
|
||||
describe "when passed a model object", ->
|
||||
describe "when a view provider is registered matching the object's constructor", ->
|
||||
it "constructs a view element and assigns the model on it", ->
|
||||
class TestModel
|
||||
|
||||
class TestModelSubclass extends TestModel
|
||||
|
||||
class TestView
|
||||
initialize: (@model) -> this
|
||||
|
||||
model = new TestModel
|
||||
|
||||
registry.addViewProvider TestModel, (model) ->
|
||||
new TestView().initialize(model)
|
||||
|
||||
view = registry.getView(model)
|
||||
expect(view instanceof TestView).toBe true
|
||||
expect(view.model).toBe model
|
||||
|
||||
subclassModel = new TestModelSubclass
|
||||
view2 = registry.getView(subclassModel)
|
||||
expect(view2 instanceof TestView).toBe true
|
||||
expect(view2.model).toBe subclassModel
|
||||
|
||||
describe "when a view provider is registered generically, and works with the object", ->
|
||||
it "constructs a view element and assigns the model on it", ->
|
||||
model = {a: 'b'}
|
||||
|
||||
registry.addViewProvider (model) ->
|
||||
if model.a is 'b'
|
||||
element = document.createElement('div')
|
||||
element.className = 'test-element'
|
||||
element
|
||||
|
||||
view = registry.getView({a: 'b'})
|
||||
expect(view.className).toBe 'test-element'
|
||||
|
||||
expect(-> registry.getView({a: 'c'})).toThrow()
|
||||
|
||||
describe "when no view provider is registered for the object's constructor", ->
|
||||
it "throws an exception", ->
|
||||
expect(-> registry.getView(new Object)).toThrow()
|
||||
|
||||
describe "::addViewProvider(providerSpec)", ->
|
||||
it "returns a disposable that can be used to remove the provider", ->
|
||||
class TestModel
|
||||
class TestView
|
||||
initialize: (@model) -> this
|
||||
|
||||
disposable = registry.addViewProvider TestModel, (model) ->
|
||||
new TestView().initialize(model)
|
||||
|
||||
expect(registry.getView(new TestModel) instanceof TestView).toBe true
|
||||
disposable.dispose()
|
||||
expect(-> registry.getView(new TestModel)).toThrow()
|
||||
|
||||
describe "::updateDocument(fn) and ::readDocument(fn)", ->
|
||||
frameRequests = null
|
||||
|
||||
beforeEach ->
|
||||
frameRequests = []
|
||||
spyOn(window, 'requestAnimationFrame').andCallFake (fn) -> frameRequests.push(fn)
|
||||
|
||||
it "performs all pending writes before all pending reads on the next animation frame", ->
|
||||
events = []
|
||||
|
||||
registry.updateDocument -> events.push('write 1')
|
||||
registry.readDocument -> events.push('read 1')
|
||||
registry.readDocument -> events.push('read 2')
|
||||
registry.updateDocument -> events.push('write 2')
|
||||
|
||||
expect(events).toEqual []
|
||||
|
||||
expect(frameRequests.length).toBe 1
|
||||
frameRequests[0]()
|
||||
expect(events).toEqual ['write 1', 'write 2', 'read 1', 'read 2']
|
||||
|
||||
frameRequests = []
|
||||
events = []
|
||||
disposable = registry.updateDocument -> events.push('write 3')
|
||||
registry.updateDocument -> events.push('write 4')
|
||||
registry.readDocument -> events.push('read 3')
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
expect(frameRequests.length).toBe 1
|
||||
frameRequests[0]()
|
||||
expect(events).toEqual ['write 4', 'read 3']
|
||||
|
||||
it "performs writes requested from read callbacks in the same animation frame", ->
|
||||
spyOn(window, 'setInterval').andCallFake(fakeSetInterval)
|
||||
spyOn(window, 'clearInterval').andCallFake(fakeClearInterval)
|
||||
events = []
|
||||
|
||||
registry.updateDocument -> events.push('write 1')
|
||||
registry.readDocument ->
|
||||
registry.updateDocument -> events.push('write from read 1')
|
||||
events.push('read 1')
|
||||
registry.readDocument ->
|
||||
registry.updateDocument -> events.push('write from read 2')
|
||||
events.push('read 2')
|
||||
registry.updateDocument -> events.push('write 2')
|
||||
|
||||
expect(frameRequests.length).toBe 1
|
||||
frameRequests[0]()
|
||||
expect(frameRequests.length).toBe 1
|
||||
|
||||
expect(events).toEqual [
|
||||
'write 1'
|
||||
'write 2'
|
||||
'read 1'
|
||||
'read 2'
|
||||
'write from read 1'
|
||||
'write from read 2'
|
||||
]
|
||||
|
||||
describe "::getNextUpdatePromise()", ->
|
||||
it "returns a promise that resolves at the end of the next update cycle", ->
|
||||
updateCalled = false
|
||||
readCalled = false
|
||||
|
||||
waitsFor 'getNextUpdatePromise to resolve', (done) ->
|
||||
registry.getNextUpdatePromise().then ->
|
||||
expect(updateCalled).toBe true
|
||||
expect(readCalled).toBe true
|
||||
done()
|
||||
|
||||
registry.updateDocument -> updateCalled = true
|
||||
registry.readDocument -> readCalled = true
|
||||
216
spec/view-registry-spec.js
Normal file
216
spec/view-registry-spec.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const ViewRegistry = require('../src/view-registry')
|
||||
|
||||
describe('ViewRegistry', () => {
|
||||
let registry = null
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ViewRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
registry.clearDocumentRequests()
|
||||
})
|
||||
|
||||
describe('::getView(object)', () => {
|
||||
describe('when passed a DOM node', () =>
|
||||
it('returns the given DOM node', () => {
|
||||
const node = document.createElement('div')
|
||||
expect(registry.getView(node)).toBe(node)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when passed an object with an element property', () =>
|
||||
it("returns the element property if it's an instance of HTMLElement", () => {
|
||||
class TestComponent {
|
||||
constructor () {
|
||||
this.element = document.createElement('div')
|
||||
}
|
||||
}
|
||||
|
||||
const component = new TestComponent()
|
||||
expect(registry.getView(component)).toBe(component.element)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when passed an object with a getElement function', () =>
|
||||
it("returns the return value of getElement if it's an instance of HTMLElement", () => {
|
||||
class TestComponent {
|
||||
getElement () {
|
||||
if (this.myElement == null) {
|
||||
this.myElement = document.createElement('div')
|
||||
}
|
||||
return this.myElement
|
||||
}
|
||||
}
|
||||
|
||||
const component = new TestComponent()
|
||||
expect(registry.getView(component)).toBe(component.myElement)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when passed a model object', () => {
|
||||
describe("when a view provider is registered matching the object's constructor", () =>
|
||||
it('constructs a view element and assigns the model on it', () => {
|
||||
class TestModel {}
|
||||
|
||||
class TestModelSubclass extends TestModel {}
|
||||
|
||||
class TestView {
|
||||
initialize (model) {
|
||||
this.model = model
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
const model = new TestModel()
|
||||
|
||||
registry.addViewProvider(TestModel, (model) =>
|
||||
new TestView().initialize(model)
|
||||
)
|
||||
|
||||
const view = registry.getView(model)
|
||||
expect(view instanceof TestView).toBe(true)
|
||||
expect(view.model).toBe(model)
|
||||
|
||||
const subclassModel = new TestModelSubclass()
|
||||
const view2 = registry.getView(subclassModel)
|
||||
expect(view2 instanceof TestView).toBe(true)
|
||||
expect(view2.model).toBe(subclassModel)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when a view provider is registered generically, and works with the object', () =>
|
||||
it('constructs a view element and assigns the model on it', () => {
|
||||
registry.addViewProvider((model) => {
|
||||
if (model.a === 'b') {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'test-element'
|
||||
return element
|
||||
}
|
||||
})
|
||||
|
||||
const view = registry.getView({a: 'b'})
|
||||
expect(view.className).toBe('test-element')
|
||||
|
||||
expect(() => registry.getView({a: 'c'})).toThrow()
|
||||
})
|
||||
)
|
||||
|
||||
describe("when no view provider is registered for the object's constructor", () =>
|
||||
it('throws an exception', () => {
|
||||
expect(() => registry.getView({})).toThrow()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('::addViewProvider(providerSpec)', () =>
|
||||
it('returns a disposable that can be used to remove the provider', () => {
|
||||
class TestModel {}
|
||||
class TestView {
|
||||
initialize (model) {
|
||||
this.model = model
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
const disposable = registry.addViewProvider(TestModel, (model) =>
|
||||
new TestView().initialize(model)
|
||||
)
|
||||
|
||||
expect(registry.getView(new TestModel()) instanceof TestView).toBe(true)
|
||||
disposable.dispose()
|
||||
expect(() => registry.getView(new TestModel())).toThrow()
|
||||
})
|
||||
)
|
||||
|
||||
describe('::updateDocument(fn) and ::readDocument(fn)', () => {
|
||||
let frameRequests = null
|
||||
|
||||
beforeEach(() => {
|
||||
frameRequests = []
|
||||
spyOn(window, 'requestAnimationFrame').andCallFake(fn => frameRequests.push(fn))
|
||||
})
|
||||
|
||||
it('performs all pending writes before all pending reads on the next animation frame', () => {
|
||||
let events = []
|
||||
|
||||
registry.updateDocument(() => events.push('write 1'))
|
||||
registry.readDocument(() => events.push('read 1'))
|
||||
registry.readDocument(() => events.push('read 2'))
|
||||
registry.updateDocument(() => events.push('write 2'))
|
||||
|
||||
expect(events).toEqual([])
|
||||
|
||||
expect(frameRequests.length).toBe(1)
|
||||
frameRequests[0]()
|
||||
expect(events).toEqual(['write 1', 'write 2', 'read 1', 'read 2'])
|
||||
|
||||
frameRequests = []
|
||||
events = []
|
||||
const disposable = registry.updateDocument(() => events.push('write 3'))
|
||||
registry.updateDocument(() => events.push('write 4'))
|
||||
registry.readDocument(() => events.push('read 3'))
|
||||
|
||||
disposable.dispose()
|
||||
|
||||
expect(frameRequests.length).toBe(1)
|
||||
frameRequests[0]()
|
||||
expect(events).toEqual(['write 4', 'read 3'])
|
||||
})
|
||||
|
||||
it('performs writes requested from read callbacks in the same animation frame', () => {
|
||||
spyOn(window, 'setInterval').andCallFake(fakeSetInterval)
|
||||
spyOn(window, 'clearInterval').andCallFake(fakeClearInterval)
|
||||
const events = []
|
||||
|
||||
registry.updateDocument(() => events.push('write 1'))
|
||||
registry.readDocument(() => {
|
||||
registry.updateDocument(() => events.push('write from read 1'))
|
||||
events.push('read 1')
|
||||
})
|
||||
registry.readDocument(() => {
|
||||
registry.updateDocument(() => events.push('write from read 2'))
|
||||
events.push('read 2')
|
||||
})
|
||||
registry.updateDocument(() => events.push('write 2'))
|
||||
|
||||
expect(frameRequests.length).toBe(1)
|
||||
frameRequests[0]()
|
||||
expect(frameRequests.length).toBe(1)
|
||||
|
||||
expect(events).toEqual([
|
||||
'write 1',
|
||||
'write 2',
|
||||
'read 1',
|
||||
'read 2',
|
||||
'write from read 1',
|
||||
'write from read 2'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('::getNextUpdatePromise()', () =>
|
||||
it('returns a promise that resolves at the end of the next update cycle', () => {
|
||||
let updateCalled = false
|
||||
let readCalled = false
|
||||
|
||||
waitsFor('getNextUpdatePromise to resolve', (done) => {
|
||||
registry.getNextUpdatePromise().then(() => {
|
||||
expect(updateCalled).toBe(true)
|
||||
expect(readCalled).toBe(true)
|
||||
done()
|
||||
})
|
||||
|
||||
registry.updateDocument(() => { updateCalled = true })
|
||||
registry.readDocument(() => { readCalled = true })
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
KeymapManager = require 'atom-keymap'
|
||||
TextEditor = require '../src/text-editor'
|
||||
WindowEventHandler = require '../src/window-event-handler'
|
||||
{ipcRenderer} = require 'electron'
|
||||
|
||||
describe "WindowEventHandler", ->
|
||||
[windowEventHandler] = []
|
||||
|
||||
beforeEach ->
|
||||
atom.uninstallWindowEventHandler()
|
||||
spyOn(atom, 'hide')
|
||||
initialPath = atom.project.getPaths()[0]
|
||||
spyOn(atom, 'getLoadSettings').andCallFake ->
|
||||
loadSettings = atom.getLoadSettings.originalValue.call(atom)
|
||||
loadSettings.initialPath = initialPath
|
||||
loadSettings
|
||||
atom.project.destroy()
|
||||
windowEventHandler = new WindowEventHandler({atomEnvironment: atom, applicationDelegate: atom.applicationDelegate})
|
||||
windowEventHandler.initialize(window, document)
|
||||
|
||||
afterEach ->
|
||||
windowEventHandler.unsubscribe()
|
||||
atom.installWindowEventHandler()
|
||||
|
||||
describe "when the window is loaded", ->
|
||||
it "doesn't have .is-blurred on the body tag", ->
|
||||
return if process.platform is 'win32' #Win32TestFailures - can not steal focus
|
||||
expect(document.body.className).not.toMatch("is-blurred")
|
||||
|
||||
describe "when the window is blurred", ->
|
||||
beforeEach ->
|
||||
window.dispatchEvent(new CustomEvent('blur'))
|
||||
|
||||
afterEach ->
|
||||
document.body.classList.remove('is-blurred')
|
||||
|
||||
it "adds the .is-blurred class on the body", ->
|
||||
expect(document.body.className).toMatch("is-blurred")
|
||||
|
||||
describe "when the window is focused again", ->
|
||||
it "removes the .is-blurred class from the body", ->
|
||||
window.dispatchEvent(new CustomEvent('focus'))
|
||||
expect(document.body.className).not.toMatch("is-blurred")
|
||||
|
||||
describe "window:close event", ->
|
||||
it "closes the window", ->
|
||||
spyOn(atom, 'close')
|
||||
window.dispatchEvent(new CustomEvent('window:close'))
|
||||
expect(atom.close).toHaveBeenCalled()
|
||||
|
||||
describe "when a link is clicked", ->
|
||||
it "opens the http/https links in an external application", ->
|
||||
{shell} = require 'electron'
|
||||
spyOn(shell, 'openExternal')
|
||||
|
||||
link = document.createElement('a')
|
||||
linkChild = document.createElement('span')
|
||||
link.appendChild(linkChild)
|
||||
link.href = 'http://github.com'
|
||||
jasmine.attachToDOM(link)
|
||||
fakeEvent = {target: linkChild, currentTarget: link, preventDefault: (->)}
|
||||
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).toHaveBeenCalled()
|
||||
expect(shell.openExternal.argsForCall[0][0]).toBe "http://github.com"
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = 'https://github.com'
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).toHaveBeenCalled()
|
||||
expect(shell.openExternal.argsForCall[0][0]).toBe "https://github.com"
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = ''
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = '#scroll-me'
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
|
||||
describe "when a form is submitted", ->
|
||||
it "prevents the default so that the window's URL isn't changed", ->
|
||||
form = document.createElement('form')
|
||||
jasmine.attachToDOM(form)
|
||||
|
||||
defaultPrevented = false
|
||||
event = new CustomEvent('submit', bubbles: true)
|
||||
event.preventDefault = -> defaultPrevented = true
|
||||
form.dispatchEvent(event)
|
||||
expect(defaultPrevented).toBe(true)
|
||||
|
||||
describe "core:focus-next and core:focus-previous", ->
|
||||
describe "when there is no currently focused element", ->
|
||||
it "focuses the element with the lowest/highest tabindex", ->
|
||||
wrapperDiv = document.createElement('div')
|
||||
wrapperDiv.innerHTML = """
|
||||
<div>
|
||||
<button tabindex="2"></button>
|
||||
<input tabindex="1">
|
||||
</div>
|
||||
"""
|
||||
elements = wrapperDiv.firstChild
|
||||
jasmine.attachToDOM(elements)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 1
|
||||
|
||||
document.body.focus()
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 2
|
||||
|
||||
describe "when a tabindex is set on the currently focused element", ->
|
||||
it "focuses the element with the next highest/lowest tabindex, skipping disabled elements", ->
|
||||
wrapperDiv = document.createElement('div')
|
||||
wrapperDiv.innerHTML = """
|
||||
<div>
|
||||
<input tabindex="1">
|
||||
<button tabindex="2"></button>
|
||||
<button tabindex="5"></button>
|
||||
<input tabindex="-1">
|
||||
<input tabindex="3">
|
||||
<button tabindex="7"></button>
|
||||
<input tabindex="9" disabled>
|
||||
</div>
|
||||
"""
|
||||
elements = wrapperDiv.firstChild
|
||||
jasmine.attachToDOM(elements)
|
||||
|
||||
elements.querySelector('[tabindex="1"]').focus()
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 2
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 3
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 5
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 7
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-next", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 1
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 7
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 5
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 3
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 2
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 1
|
||||
|
||||
elements.dispatchEvent(new CustomEvent("core:focus-previous", bubbles: true))
|
||||
expect(document.activeElement.tabIndex).toBe 7
|
||||
|
||||
describe "when keydown events occur on the document", ->
|
||||
it "dispatches the event via the KeymapManager and CommandRegistry", ->
|
||||
dispatchedCommands = []
|
||||
atom.commands.onWillDispatch (command) -> dispatchedCommands.push(command)
|
||||
atom.commands.add '*', 'foo-command': ->
|
||||
atom.keymaps.add 'source-name', '*': {'x': 'foo-command'}
|
||||
|
||||
event = KeymapManager.buildKeydownEvent('x', target: document.createElement('div'))
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(dispatchedCommands.length).toBe 1
|
||||
expect(dispatchedCommands[0].type).toBe 'foo-command'
|
||||
|
||||
describe "native key bindings", ->
|
||||
it "correctly dispatches them to active elements with the '.native-key-bindings' class", ->
|
||||
webContentsSpy = jasmine.createSpyObj("webContents", ["copy", "paste"])
|
||||
spyOn(atom.applicationDelegate, "getCurrentWindow").andReturn({
|
||||
webContents: webContentsSpy
|
||||
on: ->
|
||||
})
|
||||
|
||||
nativeKeyBindingsInput = document.createElement("input")
|
||||
nativeKeyBindingsInput.classList.add("native-key-bindings")
|
||||
jasmine.attachToDOM(nativeKeyBindingsInput)
|
||||
nativeKeyBindingsInput.focus()
|
||||
|
||||
atom.dispatchApplicationMenuCommand("core:copy")
|
||||
atom.dispatchApplicationMenuCommand("core:paste")
|
||||
|
||||
expect(webContentsSpy.copy).toHaveBeenCalled()
|
||||
expect(webContentsSpy.paste).toHaveBeenCalled()
|
||||
|
||||
webContentsSpy.copy.reset()
|
||||
webContentsSpy.paste.reset()
|
||||
|
||||
normalInput = document.createElement("input")
|
||||
jasmine.attachToDOM(normalInput)
|
||||
normalInput.focus()
|
||||
|
||||
atom.dispatchApplicationMenuCommand("core:copy")
|
||||
atom.dispatchApplicationMenuCommand("core:paste")
|
||||
|
||||
expect(webContentsSpy.copy).not.toHaveBeenCalled()
|
||||
expect(webContentsSpy.paste).not.toHaveBeenCalled()
|
||||
228
spec/window-event-handler-spec.js
Normal file
228
spec/window-event-handler-spec.js
Normal file
@@ -0,0 +1,228 @@
|
||||
const KeymapManager = require('atom-keymap')
|
||||
const WindowEventHandler = require('../src/window-event-handler')
|
||||
|
||||
describe('WindowEventHandler', () => {
|
||||
let windowEventHandler
|
||||
|
||||
beforeEach(() => {
|
||||
atom.uninstallWindowEventHandler()
|
||||
spyOn(atom, 'hide')
|
||||
const initialPath = atom.project.getPaths()[0]
|
||||
spyOn(atom, 'getLoadSettings').andCallFake(() => {
|
||||
const loadSettings = atom.getLoadSettings.originalValue.call(atom)
|
||||
loadSettings.initialPath = initialPath
|
||||
return loadSettings
|
||||
})
|
||||
atom.project.destroy()
|
||||
windowEventHandler = new WindowEventHandler({atomEnvironment: atom, applicationDelegate: atom.applicationDelegate})
|
||||
windowEventHandler.initialize(window, document)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
windowEventHandler.unsubscribe()
|
||||
atom.installWindowEventHandler()
|
||||
})
|
||||
|
||||
describe('when the window is loaded', () =>
|
||||
it("doesn't have .is-blurred on the body tag", () => {
|
||||
if (process.platform === 'win32') { return } // Win32TestFailures - can not steal focus
|
||||
expect(document.body.className).not.toMatch('is-blurred')
|
||||
})
|
||||
)
|
||||
|
||||
describe('when the window is blurred', () => {
|
||||
beforeEach(() => window.dispatchEvent(new CustomEvent('blur')))
|
||||
|
||||
afterEach(() => document.body.classList.remove('is-blurred'))
|
||||
|
||||
it('adds the .is-blurred class on the body', () => expect(document.body.className).toMatch('is-blurred'))
|
||||
|
||||
describe('when the window is focused again', () =>
|
||||
it('removes the .is-blurred class from the body', () => {
|
||||
window.dispatchEvent(new CustomEvent('focus'))
|
||||
expect(document.body.className).not.toMatch('is-blurred')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('window:close event', () =>
|
||||
it('closes the window', () => {
|
||||
spyOn(atom, 'close')
|
||||
window.dispatchEvent(new CustomEvent('window:close'))
|
||||
expect(atom.close).toHaveBeenCalled()
|
||||
})
|
||||
)
|
||||
|
||||
describe('when a link is clicked', () =>
|
||||
it('opens the http/https links in an external application', () => {
|
||||
const {shell} = require('electron')
|
||||
spyOn(shell, 'openExternal')
|
||||
|
||||
const link = document.createElement('a')
|
||||
const linkChild = document.createElement('span')
|
||||
link.appendChild(linkChild)
|
||||
link.href = 'http://github.com'
|
||||
jasmine.attachToDOM(link)
|
||||
const fakeEvent = {target: linkChild, currentTarget: link, preventDefault: () => {}}
|
||||
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).toHaveBeenCalled()
|
||||
expect(shell.openExternal.argsForCall[0][0]).toBe('http://github.com')
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = 'https://github.com'
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).toHaveBeenCalled()
|
||||
expect(shell.openExternal.argsForCall[0][0]).toBe('https://github.com')
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = ''
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
shell.openExternal.reset()
|
||||
|
||||
link.href = '#scroll-me'
|
||||
windowEventHandler.handleLinkClick(fakeEvent)
|
||||
expect(shell.openExternal).not.toHaveBeenCalled()
|
||||
})
|
||||
)
|
||||
|
||||
describe('when a form is submitted', () =>
|
||||
it("prevents the default so that the window's URL isn't changed", () => {
|
||||
const form = document.createElement('form')
|
||||
jasmine.attachToDOM(form)
|
||||
|
||||
let defaultPrevented = false
|
||||
const event = new CustomEvent('submit', {bubbles: true})
|
||||
event.preventDefault = () => { defaultPrevented = true }
|
||||
form.dispatchEvent(event)
|
||||
expect(defaultPrevented).toBe(true)
|
||||
})
|
||||
)
|
||||
|
||||
describe('core:focus-next and core:focus-previous', () => {
|
||||
describe('when there is no currently focused element', () =>
|
||||
it('focuses the element with the lowest/highest tabindex', () => {
|
||||
const wrapperDiv = document.createElement('div')
|
||||
wrapperDiv.innerHTML = `
|
||||
<div>
|
||||
<button tabindex="2"></button>
|
||||
<input tabindex="1">
|
||||
</div>
|
||||
`.trim()
|
||||
const elements = wrapperDiv.firstChild
|
||||
jasmine.attachToDOM(elements)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(1)
|
||||
|
||||
document.body.focus()
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(2)
|
||||
})
|
||||
)
|
||||
|
||||
describe('when a tabindex is set on the currently focused element', () =>
|
||||
it('focuses the element with the next highest/lowest tabindex, skipping disabled elements', () => {
|
||||
const wrapperDiv = document.createElement('div')
|
||||
wrapperDiv.innerHTML = `
|
||||
<div>
|
||||
<input tabindex="1">
|
||||
<button tabindex="2"></button>
|
||||
<button tabindex="5"></button>
|
||||
<input tabindex="-1">
|
||||
<input tabindex="3">
|
||||
<button tabindex="7"></button>
|
||||
<input tabindex="9" disabled>
|
||||
</div>
|
||||
`.trim()
|
||||
const elements = wrapperDiv.firstChild
|
||||
jasmine.attachToDOM(elements)
|
||||
|
||||
elements.querySelector('[tabindex="1"]').focus()
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(2)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(3)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(5)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(7)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-next', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(1)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(7)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(5)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(3)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(2)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(1)
|
||||
|
||||
elements.dispatchEvent(new CustomEvent('core:focus-previous', {bubbles: true}))
|
||||
expect(document.activeElement.tabIndex).toBe(7)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('when keydown events occur on the document', () =>
|
||||
it('dispatches the event via the KeymapManager and CommandRegistry', () => {
|
||||
const dispatchedCommands = []
|
||||
atom.commands.onWillDispatch(command => dispatchedCommands.push(command))
|
||||
atom.commands.add('*', {'foo-command': () => {}})
|
||||
atom.keymaps.add('source-name', {'*': {'x': 'foo-command'}})
|
||||
|
||||
const event = KeymapManager.buildKeydownEvent('x', {target: document.createElement('div')})
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(dispatchedCommands.length).toBe(1)
|
||||
expect(dispatchedCommands[0].type).toBe('foo-command')
|
||||
})
|
||||
)
|
||||
|
||||
describe('native key bindings', () =>
|
||||
it("correctly dispatches them to active elements with the '.native-key-bindings' class", () => {
|
||||
const webContentsSpy = jasmine.createSpyObj('webContents', ['copy', 'paste'])
|
||||
spyOn(atom.applicationDelegate, 'getCurrentWindow').andReturn({
|
||||
webContents: webContentsSpy,
|
||||
on: () => {}
|
||||
})
|
||||
|
||||
const nativeKeyBindingsInput = document.createElement('input')
|
||||
nativeKeyBindingsInput.classList.add('native-key-bindings')
|
||||
jasmine.attachToDOM(nativeKeyBindingsInput)
|
||||
nativeKeyBindingsInput.focus()
|
||||
|
||||
atom.dispatchApplicationMenuCommand('core:copy')
|
||||
atom.dispatchApplicationMenuCommand('core:paste')
|
||||
|
||||
expect(webContentsSpy.copy).toHaveBeenCalled()
|
||||
expect(webContentsSpy.paste).toHaveBeenCalled()
|
||||
|
||||
webContentsSpy.copy.reset()
|
||||
webContentsSpy.paste.reset()
|
||||
|
||||
const normalInput = document.createElement('input')
|
||||
jasmine.attachToDOM(normalInput)
|
||||
normalInput.focus()
|
||||
|
||||
atom.dispatchApplicationMenuCommand('core:copy')
|
||||
atom.dispatchApplicationMenuCommand('core:paste')
|
||||
|
||||
expect(webContentsSpy.copy).not.toHaveBeenCalled()
|
||||
expect(webContentsSpy.paste).not.toHaveBeenCalled()
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1585,15 +1585,15 @@ i = /test/; #FIXME\
|
||||
atom2.project.deserialize(atom.project.serialize())
|
||||
atom2.workspace.deserialize(atom.workspace.serialize(), atom2.deserializers)
|
||||
|
||||
expect(atom2.grammars.getGrammars().map(grammar => grammar.name).sort()).toEqual([
|
||||
'CoffeeScript',
|
||||
'CoffeeScript (Literate)',
|
||||
'JSDoc',
|
||||
'JavaScript',
|
||||
'Null Grammar',
|
||||
'Regular Expression Replacement (JavaScript)',
|
||||
'Regular Expressions (JavaScript)',
|
||||
'TODO'
|
||||
expect(atom2.grammars.getGrammars().map(grammar => grammar.scopeName).sort()).toEqual([
|
||||
'source.coffee',
|
||||
'source.js',
|
||||
'source.js.regexp',
|
||||
'source.js.regexp.replacement',
|
||||
'source.jsdoc',
|
||||
'source.litcoffee',
|
||||
'text.plain.null-grammar',
|
||||
'text.todo'
|
||||
])
|
||||
|
||||
atom2.destroy()
|
||||
|
||||
@@ -43,7 +43,6 @@ PaneContainer = require './pane-container'
|
||||
PaneAxis = require './pane-axis'
|
||||
Pane = require './pane'
|
||||
Dock = require './dock'
|
||||
Project = require './project'
|
||||
TextEditor = require './text-editor'
|
||||
TextBuffer = require 'text-buffer'
|
||||
Gutter = require './gutter'
|
||||
|
||||
@@ -89,7 +89,7 @@ module.exports = class CommandRegistry {
|
||||
// DOM element, the command will be associated with just that element.
|
||||
// * `commandName` A {String} containing the name of a command you want to
|
||||
// handle such as `user:insert-date`.
|
||||
// * `listener` A listener which handles the event. Either A {Function} to
|
||||
// * `listener` A listener which handles the event. Either a {Function} to
|
||||
// call when the given command is invoked on an element matching the
|
||||
// selector, or an {Object} with a `didDispatch` property which is such a
|
||||
// function.
|
||||
@@ -97,7 +97,7 @@ module.exports = class CommandRegistry {
|
||||
// The function (`listener` itself if it is a function, or the `didDispatch`
|
||||
// method if `listener` is an object) will be called with `this` referencing
|
||||
// the matching DOM node and the following argument:
|
||||
// * `event` A standard DOM event instance. Call `stopPropagation` or
|
||||
// * `event`: A standard DOM event instance. Call `stopPropagation` or
|
||||
// `stopImmediatePropagation` to terminate bubbling early.
|
||||
//
|
||||
// Additionally, `listener` may have additional properties which are returned
|
||||
@@ -107,6 +107,13 @@ module.exports = class CommandRegistry {
|
||||
// otherwise be generated from the event name.
|
||||
// * `description`: Used by consumers to display detailed information about
|
||||
// the command.
|
||||
// * `hiddenInCommandPalette`: If `true`, this command will not appear in
|
||||
// the bundled command palette by default, but can still be shown with.
|
||||
// the `Command Palette: Show Hidden Commands` command. This is a good
|
||||
// option when you need to register large numbers of commands that don't
|
||||
// make sense to be executed from the command palette. Please use this
|
||||
// option conservatively, as it could reduce the discoverability of your
|
||||
// package's commands.
|
||||
//
|
||||
// ## Arguments: Registering Multiple Commands
|
||||
//
|
||||
|
||||
@@ -594,7 +594,7 @@ class Cursor extends Model {
|
||||
getCurrentWordBufferRange (options = {}) {
|
||||
const position = this.getBufferPosition()
|
||||
const ranges = this.editor.buffer.findAllInRangeSync(
|
||||
options.wordRegex || this.wordRegExp(),
|
||||
options.wordRegex || this.wordRegExp(options),
|
||||
new Range(new Point(position.row, 0), new Point(position.row, Infinity))
|
||||
)
|
||||
const range = ranges.find(range =>
|
||||
|
||||
@@ -58,10 +58,10 @@ class GrammarRegistry extends FirstMate.GrammarRegistry {
|
||||
|
||||
let score = this.getGrammarPathScore(grammar, filePath)
|
||||
if ((score > 0) && !grammar.bundledPackage) {
|
||||
score += 0.25
|
||||
score += 0.125
|
||||
}
|
||||
if (this.grammarMatchesContents(grammar, contents)) {
|
||||
score += 0.125
|
||||
score += 0.25
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
|
||||
const nsfw = require('nsfw')
|
||||
const nsfw = require('@atom/nsfw')
|
||||
const {NativeWatcherRegistry} = require('./native-watcher-registry')
|
||||
|
||||
// Private: Associate native watcher action flags with descriptive String equivalents.
|
||||
|
||||
@@ -174,6 +174,11 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
|
||||
'core:cut': -> @cutSelectedText()
|
||||
'core:copy': -> @copySelectedText()
|
||||
'core:paste': -> @pasteText()
|
||||
'editor:paste-without-reformatting': -> @pasteText({
|
||||
normalizeLineEndings: false,
|
||||
autoIndent: false,
|
||||
preserveTrailingLineIndentation: true
|
||||
})
|
||||
'editor:delete-to-previous-word-boundary': -> @deleteToPreviousWordBoundary()
|
||||
'editor:delete-to-next-word-boundary': -> @deleteToNextWordBoundary()
|
||||
'editor:delete-to-beginning-of-word': -> @deleteToBeginningOfWord()
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
{Point, Range} = require 'text-buffer'
|
||||
{pick} = require 'underscore-plus'
|
||||
{Emitter} = require 'event-kit'
|
||||
Model = require './model'
|
||||
|
||||
NonWhitespaceRegExp = /\S/
|
||||
|
||||
# Extended: Represents a selection in the {TextEditor}.
|
||||
module.exports =
|
||||
class Selection extends Model
|
||||
cursor: null
|
||||
marker: null
|
||||
editor: null
|
||||
initialScreenRange: null
|
||||
wordwise: false
|
||||
|
||||
constructor: ({@cursor, @marker, @editor, id}) ->
|
||||
@emitter = new Emitter
|
||||
|
||||
@assignId(id)
|
||||
@cursor.selection = this
|
||||
@decoration = @editor.decorateMarker(@marker, type: 'highlight', class: 'selection')
|
||||
|
||||
@marker.onDidChange (e) => @markerDidChange(e)
|
||||
@marker.onDidDestroy => @markerDidDestroy()
|
||||
|
||||
destroy: ->
|
||||
@marker.destroy()
|
||||
|
||||
isLastSelection: ->
|
||||
this is @editor.getLastSelection()
|
||||
|
||||
###
|
||||
Section: Event Subscription
|
||||
###
|
||||
|
||||
# Extended: Calls your `callback` when the selection was moved.
|
||||
#
|
||||
# * `callback` {Function}
|
||||
# * `event` {Object}
|
||||
# * `oldBufferRange` {Range}
|
||||
# * `oldScreenRange` {Range}
|
||||
# * `newBufferRange` {Range}
|
||||
# * `newScreenRange` {Range}
|
||||
# * `selection` {Selection} that triggered the event
|
||||
#
|
||||
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
||||
onDidChangeRange: (callback) ->
|
||||
@emitter.on 'did-change-range', callback
|
||||
|
||||
# Extended: Calls your `callback` when the selection was destroyed
|
||||
#
|
||||
# * `callback` {Function}
|
||||
#
|
||||
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
||||
onDidDestroy: (callback) ->
|
||||
@emitter.once 'did-destroy', callback
|
||||
|
||||
###
|
||||
Section: Managing the selection range
|
||||
###
|
||||
|
||||
# Public: Returns the screen {Range} for the selection.
|
||||
getScreenRange: ->
|
||||
@marker.getScreenRange()
|
||||
|
||||
# Public: Modifies the screen range for the selection.
|
||||
#
|
||||
# * `screenRange` The new {Range} to use.
|
||||
# * `options` (optional) {Object} options matching those found in {::setBufferRange}.
|
||||
setScreenRange: (screenRange, options) ->
|
||||
@setBufferRange(@editor.bufferRangeForScreenRange(screenRange), options)
|
||||
|
||||
# Public: Returns the buffer {Range} for the selection.
|
||||
getBufferRange: ->
|
||||
@marker.getBufferRange()
|
||||
|
||||
# Public: Modifies the buffer {Range} for the selection.
|
||||
#
|
||||
# * `bufferRange` The new {Range} to select.
|
||||
# * `options` (optional) {Object} with the keys:
|
||||
# * `preserveFolds` if `true`, the fold settings are preserved after the
|
||||
# selection moves.
|
||||
# * `autoscroll` {Boolean} indicating whether to autoscroll to the new
|
||||
# range. Defaults to `true` if this is the most recently added selection,
|
||||
# `false` otherwise.
|
||||
setBufferRange: (bufferRange, options={}) ->
|
||||
bufferRange = Range.fromObject(bufferRange)
|
||||
options.reversed ?= @isReversed()
|
||||
@editor.destroyFoldsIntersectingBufferRange(bufferRange) unless options.preserveFolds
|
||||
@modifySelection =>
|
||||
needsFlash = options.flash
|
||||
delete options.flash if options.flash?
|
||||
@marker.setBufferRange(bufferRange, options)
|
||||
@autoscroll() if options?.autoscroll ? @isLastSelection()
|
||||
@decoration.flash('flash', @editor.selectionFlashDuration) if needsFlash
|
||||
|
||||
# Public: Returns the starting and ending buffer rows the selection is
|
||||
# highlighting.
|
||||
#
|
||||
# Returns an {Array} of two {Number}s: the starting row, and the ending row.
|
||||
getBufferRowRange: ->
|
||||
range = @getBufferRange()
|
||||
start = range.start.row
|
||||
end = range.end.row
|
||||
end = Math.max(start, end - 1) if range.end.column is 0
|
||||
[start, end]
|
||||
|
||||
getTailScreenPosition: ->
|
||||
@marker.getTailScreenPosition()
|
||||
|
||||
getTailBufferPosition: ->
|
||||
@marker.getTailBufferPosition()
|
||||
|
||||
getHeadScreenPosition: ->
|
||||
@marker.getHeadScreenPosition()
|
||||
|
||||
getHeadBufferPosition: ->
|
||||
@marker.getHeadBufferPosition()
|
||||
|
||||
###
|
||||
Section: Info about the selection
|
||||
###
|
||||
|
||||
# Public: Determines if the selection contains anything.
|
||||
isEmpty: ->
|
||||
@getBufferRange().isEmpty()
|
||||
|
||||
# Public: Determines if the ending position of a marker is greater than the
|
||||
# starting position.
|
||||
#
|
||||
# This can happen when, for example, you highlight text "up" in a {TextBuffer}.
|
||||
isReversed: ->
|
||||
@marker.isReversed()
|
||||
|
||||
# Public: Returns whether the selection is a single line or not.
|
||||
isSingleScreenLine: ->
|
||||
@getScreenRange().isSingleLine()
|
||||
|
||||
# Public: Returns the text in the selection.
|
||||
getText: ->
|
||||
@editor.buffer.getTextInRange(@getBufferRange())
|
||||
|
||||
# Public: Identifies if a selection intersects with a given buffer range.
|
||||
#
|
||||
# * `bufferRange` A {Range} to check against.
|
||||
#
|
||||
# Returns a {Boolean}
|
||||
intersectsBufferRange: (bufferRange) ->
|
||||
@getBufferRange().intersectsWith(bufferRange)
|
||||
|
||||
intersectsScreenRowRange: (startRow, endRow) ->
|
||||
@getScreenRange().intersectsRowRange(startRow, endRow)
|
||||
|
||||
intersectsScreenRow: (screenRow) ->
|
||||
@getScreenRange().intersectsRow(screenRow)
|
||||
|
||||
# Public: Identifies if a selection intersects with another selection.
|
||||
#
|
||||
# * `otherSelection` A {Selection} to check against.
|
||||
#
|
||||
# Returns a {Boolean}
|
||||
intersectsWith: (otherSelection, exclusive) ->
|
||||
@getBufferRange().intersectsWith(otherSelection.getBufferRange(), exclusive)
|
||||
|
||||
###
|
||||
Section: Modifying the selected range
|
||||
###
|
||||
|
||||
# Public: Clears the selection, moving the marker to the head.
|
||||
#
|
||||
# * `options` (optional) {Object} with the following keys:
|
||||
# * `autoscroll` {Boolean} indicating whether to autoscroll to the new
|
||||
# range. Defaults to `true` if this is the most recently added selection,
|
||||
# `false` otherwise.
|
||||
clear: (options) ->
|
||||
@goalScreenRange = null
|
||||
@marker.clearTail() unless @retainSelection
|
||||
@autoscroll() if options?.autoscroll ? @isLastSelection()
|
||||
@finalize()
|
||||
|
||||
# Public: Selects the text from the current cursor position to a given screen
|
||||
# position.
|
||||
#
|
||||
# * `position` An instance of {Point}, with a given `row` and `column`.
|
||||
selectToScreenPosition: (position, options) ->
|
||||
position = Point.fromObject(position)
|
||||
|
||||
@modifySelection =>
|
||||
if @initialScreenRange
|
||||
if position.isLessThan(@initialScreenRange.start)
|
||||
@marker.setScreenRange([position, @initialScreenRange.end], reversed: true)
|
||||
else
|
||||
@marker.setScreenRange([@initialScreenRange.start, position], reversed: false)
|
||||
else
|
||||
@cursor.setScreenPosition(position, options)
|
||||
|
||||
if @linewise
|
||||
@expandOverLine(options)
|
||||
else if @wordwise
|
||||
@expandOverWord(options)
|
||||
|
||||
# Public: Selects the text from the current cursor position to a given buffer
|
||||
# position.
|
||||
#
|
||||
# * `position` An instance of {Point}, with a given `row` and `column`.
|
||||
selectToBufferPosition: (position) ->
|
||||
@modifySelection => @cursor.setBufferPosition(position)
|
||||
|
||||
# Public: Selects the text one position right of the cursor.
|
||||
#
|
||||
# * `columnCount` (optional) {Number} number of columns to select (default: 1)
|
||||
selectRight: (columnCount) ->
|
||||
@modifySelection => @cursor.moveRight(columnCount)
|
||||
|
||||
# Public: Selects the text one position left of the cursor.
|
||||
#
|
||||
# * `columnCount` (optional) {Number} number of columns to select (default: 1)
|
||||
selectLeft: (columnCount) ->
|
||||
@modifySelection => @cursor.moveLeft(columnCount)
|
||||
|
||||
# Public: Selects all the text one position above the cursor.
|
||||
#
|
||||
# * `rowCount` (optional) {Number} number of rows to select (default: 1)
|
||||
selectUp: (rowCount) ->
|
||||
@modifySelection => @cursor.moveUp(rowCount)
|
||||
|
||||
# Public: Selects all the text one position below the cursor.
|
||||
#
|
||||
# * `rowCount` (optional) {Number} number of rows to select (default: 1)
|
||||
selectDown: (rowCount) ->
|
||||
@modifySelection => @cursor.moveDown(rowCount)
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the top of
|
||||
# the buffer.
|
||||
selectToTop: ->
|
||||
@modifySelection => @cursor.moveToTop()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the bottom
|
||||
# of the buffer.
|
||||
selectToBottom: ->
|
||||
@modifySelection => @cursor.moveToBottom()
|
||||
|
||||
# Public: Selects all the text in the buffer.
|
||||
selectAll: ->
|
||||
@setBufferRange(@editor.buffer.getRange(), autoscroll: false)
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the
|
||||
# beginning of the line.
|
||||
selectToBeginningOfLine: ->
|
||||
@modifySelection => @cursor.moveToBeginningOfLine()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the first
|
||||
# character of the line.
|
||||
selectToFirstCharacterOfLine: ->
|
||||
@modifySelection => @cursor.moveToFirstCharacterOfLine()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the end of
|
||||
# the screen line.
|
||||
selectToEndOfLine: ->
|
||||
@modifySelection => @cursor.moveToEndOfScreenLine()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the end of
|
||||
# the buffer line.
|
||||
selectToEndOfBufferLine: ->
|
||||
@modifySelection => @cursor.moveToEndOfLine()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the
|
||||
# beginning of the word.
|
||||
selectToBeginningOfWord: ->
|
||||
@modifySelection => @cursor.moveToBeginningOfWord()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the end of
|
||||
# the word.
|
||||
selectToEndOfWord: ->
|
||||
@modifySelection => @cursor.moveToEndOfWord()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the
|
||||
# beginning of the next word.
|
||||
selectToBeginningOfNextWord: ->
|
||||
@modifySelection => @cursor.moveToBeginningOfNextWord()
|
||||
|
||||
# Public: Selects text to the previous word boundary.
|
||||
selectToPreviousWordBoundary: ->
|
||||
@modifySelection => @cursor.moveToPreviousWordBoundary()
|
||||
|
||||
# Public: Selects text to the next word boundary.
|
||||
selectToNextWordBoundary: ->
|
||||
@modifySelection => @cursor.moveToNextWordBoundary()
|
||||
|
||||
# Public: Selects text to the previous subword boundary.
|
||||
selectToPreviousSubwordBoundary: ->
|
||||
@modifySelection => @cursor.moveToPreviousSubwordBoundary()
|
||||
|
||||
# Public: Selects text to the next subword boundary.
|
||||
selectToNextSubwordBoundary: ->
|
||||
@modifySelection => @cursor.moveToNextSubwordBoundary()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the
|
||||
# beginning of the next paragraph.
|
||||
selectToBeginningOfNextParagraph: ->
|
||||
@modifySelection => @cursor.moveToBeginningOfNextParagraph()
|
||||
|
||||
# Public: Selects all the text from the current cursor position to the
|
||||
# beginning of the previous paragraph.
|
||||
selectToBeginningOfPreviousParagraph: ->
|
||||
@modifySelection => @cursor.moveToBeginningOfPreviousParagraph()
|
||||
|
||||
# Public: Modifies the selection to encompass the current word.
|
||||
#
|
||||
# Returns a {Range}.
|
||||
selectWord: (options={}) ->
|
||||
options.wordRegex = /[\t ]*/ if @cursor.isSurroundedByWhitespace()
|
||||
if @cursor.isBetweenWordAndNonWord()
|
||||
options.includeNonWordCharacters = false
|
||||
|
||||
@setBufferRange(@cursor.getCurrentWordBufferRange(options), options)
|
||||
@wordwise = true
|
||||
@initialScreenRange = @getScreenRange()
|
||||
|
||||
# Public: Expands the newest selection to include the entire word on which
|
||||
# the cursors rests.
|
||||
expandOverWord: (options) ->
|
||||
@setBufferRange(@getBufferRange().union(@cursor.getCurrentWordBufferRange()), autoscroll: false)
|
||||
@cursor.autoscroll() if options?.autoscroll ? true
|
||||
|
||||
# Public: Selects an entire line in the buffer.
|
||||
#
|
||||
# * `row` The line {Number} to select (default: the row of the cursor).
|
||||
selectLine: (row, options) ->
|
||||
if row?
|
||||
@setBufferRange(@editor.bufferRangeForBufferRow(row, includeNewline: true), options)
|
||||
else
|
||||
startRange = @editor.bufferRangeForBufferRow(@marker.getStartBufferPosition().row)
|
||||
endRange = @editor.bufferRangeForBufferRow(@marker.getEndBufferPosition().row, includeNewline: true)
|
||||
@setBufferRange(startRange.union(endRange), options)
|
||||
|
||||
@linewise = true
|
||||
@wordwise = false
|
||||
@initialScreenRange = @getScreenRange()
|
||||
|
||||
# Public: Expands the newest selection to include the entire line on which
|
||||
# the cursor currently rests.
|
||||
#
|
||||
# It also includes the newline character.
|
||||
expandOverLine: (options) ->
|
||||
range = @getBufferRange().union(@cursor.getCurrentLineBufferRange(includeNewline: true))
|
||||
@setBufferRange(range, autoscroll: false)
|
||||
@cursor.autoscroll() if options?.autoscroll ? true
|
||||
|
||||
###
|
||||
Section: Modifying the selected text
|
||||
###
|
||||
|
||||
# Public: Replaces text at the current selection.
|
||||
#
|
||||
# * `text` A {String} representing the text to add
|
||||
# * `options` (optional) {Object} with keys:
|
||||
# * `select` if `true`, selects the newly added text.
|
||||
# * `autoIndent` if `true`, indents all inserted text appropriately.
|
||||
# * `autoIndentNewline` if `true`, indent newline appropriately.
|
||||
# * `autoDecreaseIndent` if `true`, decreases indent level appropriately
|
||||
# (for example, when a closing bracket is inserted).
|
||||
# * `normalizeLineEndings` (optional) {Boolean} (default: true)
|
||||
# * `undo` if `skip`, skips the undo stack for this operation.
|
||||
insertText: (text, options={}) ->
|
||||
oldBufferRange = @getBufferRange()
|
||||
wasReversed = @isReversed()
|
||||
@clear(options)
|
||||
|
||||
autoIndentFirstLine = false
|
||||
precedingText = @editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start])
|
||||
remainingLines = text.split('\n')
|
||||
firstInsertedLine = remainingLines.shift()
|
||||
|
||||
if options.indentBasis?
|
||||
indentAdjustment = @editor.indentLevelForLine(precedingText) - options.indentBasis
|
||||
@adjustIndent(remainingLines, indentAdjustment)
|
||||
|
||||
textIsAutoIndentable = text is '\n' or text is '\r\n' or NonWhitespaceRegExp.test(text)
|
||||
if options.autoIndent and textIsAutoIndentable and not NonWhitespaceRegExp.test(precedingText) and remainingLines.length > 0
|
||||
autoIndentFirstLine = true
|
||||
firstLine = precedingText + firstInsertedLine
|
||||
desiredIndentLevel = @editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine)
|
||||
indentAdjustment = desiredIndentLevel - @editor.indentLevelForLine(firstLine)
|
||||
@adjustIndent(remainingLines, indentAdjustment)
|
||||
|
||||
text = firstInsertedLine
|
||||
text += '\n' + remainingLines.join('\n') if remainingLines.length > 0
|
||||
|
||||
newBufferRange = @editor.buffer.setTextInRange(oldBufferRange, text, pick(options, 'undo', 'normalizeLineEndings'))
|
||||
|
||||
if options.select
|
||||
@setBufferRange(newBufferRange, reversed: wasReversed)
|
||||
else
|
||||
@cursor.setBufferPosition(newBufferRange.end) if wasReversed
|
||||
|
||||
if autoIndentFirstLine
|
||||
@editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel)
|
||||
|
||||
if options.autoIndentNewline and text is '\n'
|
||||
@editor.autoIndentBufferRow(newBufferRange.end.row, preserveLeadingWhitespace: true, skipBlankLines: false)
|
||||
else if options.autoDecreaseIndent and NonWhitespaceRegExp.test(text)
|
||||
@editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row)
|
||||
|
||||
@autoscroll() if options.autoscroll ? @isLastSelection()
|
||||
|
||||
newBufferRange
|
||||
|
||||
# Public: Removes the first character before the selection if the selection
|
||||
# is empty otherwise it deletes the selection.
|
||||
backspace: ->
|
||||
@selectLeft() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or, if nothing is selected, then all
|
||||
# characters from the start of the selection back to the previous word
|
||||
# boundary.
|
||||
deleteToPreviousWordBoundary: ->
|
||||
@selectToPreviousWordBoundary() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or, if nothing is selected, then all
|
||||
# characters from the start of the selection up to the next word
|
||||
# boundary.
|
||||
deleteToNextWordBoundary: ->
|
||||
@selectToNextWordBoundary() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes from the start of the selection to the beginning of the
|
||||
# current word if the selection is empty otherwise it deletes the selection.
|
||||
deleteToBeginningOfWord: ->
|
||||
@selectToBeginningOfWord() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes from the beginning of the line which the selection begins on
|
||||
# all the way through to the end of the selection.
|
||||
deleteToBeginningOfLine: ->
|
||||
if @isEmpty() and @cursor.isAtBeginningOfLine()
|
||||
@selectLeft()
|
||||
else
|
||||
@selectToBeginningOfLine()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or the next character after the start of the
|
||||
# selection if the selection is empty.
|
||||
delete: ->
|
||||
@selectRight() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: If the selection is empty, removes all text from the cursor to the
|
||||
# end of the line. If the cursor is already at the end of the line, it
|
||||
# removes the following newline. If the selection isn't empty, only deletes
|
||||
# the contents of the selection.
|
||||
deleteToEndOfLine: ->
|
||||
return @delete() if @isEmpty() and @cursor.isAtEndOfLine()
|
||||
@selectToEndOfLine() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or all characters from the start of the
|
||||
# selection to the end of the current word if nothing is selected.
|
||||
deleteToEndOfWord: ->
|
||||
@selectToEndOfWord() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or all characters from the start of the
|
||||
# selection to the end of the current word if nothing is selected.
|
||||
deleteToBeginningOfSubword: ->
|
||||
@selectToPreviousSubwordBoundary() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes the selection or all characters from the start of the
|
||||
# selection to the end of the current word if nothing is selected.
|
||||
deleteToEndOfSubword: ->
|
||||
@selectToNextSubwordBoundary() if @isEmpty()
|
||||
@deleteSelectedText()
|
||||
|
||||
# Public: Removes only the selected text.
|
||||
deleteSelectedText: ->
|
||||
bufferRange = @getBufferRange()
|
||||
@editor.buffer.delete(bufferRange) unless bufferRange.isEmpty()
|
||||
@cursor?.setBufferPosition(bufferRange.start)
|
||||
|
||||
# Public: Removes the line at the beginning of the selection if the selection
|
||||
# is empty unless the selection spans multiple lines in which case all lines
|
||||
# are removed.
|
||||
deleteLine: ->
|
||||
if @isEmpty()
|
||||
start = @cursor.getScreenRow()
|
||||
range = @editor.bufferRowsForScreenRows(start, start + 1)
|
||||
if range[1] > range[0]
|
||||
@editor.buffer.deleteRows(range[0], range[1] - 1)
|
||||
else
|
||||
@editor.buffer.deleteRow(range[0])
|
||||
else
|
||||
range = @getBufferRange()
|
||||
start = range.start.row
|
||||
end = range.end.row
|
||||
if end isnt @editor.buffer.getLastRow() and range.end.column is 0
|
||||
end--
|
||||
@editor.buffer.deleteRows(start, end)
|
||||
|
||||
# Public: Joins the current line with the one below it. Lines will
|
||||
# be separated by a single space.
|
||||
#
|
||||
# If there selection spans more than one line, all the lines are joined together.
|
||||
joinLines: ->
|
||||
selectedRange = @getBufferRange()
|
||||
if selectedRange.isEmpty()
|
||||
return if selectedRange.start.row is @editor.buffer.getLastRow()
|
||||
else
|
||||
joinMarker = @editor.markBufferRange(selectedRange, invalidate: 'never')
|
||||
|
||||
rowCount = Math.max(1, selectedRange.getRowCount() - 1)
|
||||
for [0...rowCount]
|
||||
@cursor.setBufferPosition([selectedRange.start.row])
|
||||
@cursor.moveToEndOfLine()
|
||||
|
||||
# Remove trailing whitespace from the current line
|
||||
scanRange = @cursor.getCurrentLineBufferRange()
|
||||
trailingWhitespaceRange = null
|
||||
@editor.scanInBufferRange /[ \t]+$/, scanRange, ({range}) ->
|
||||
trailingWhitespaceRange = range
|
||||
if trailingWhitespaceRange?
|
||||
@setBufferRange(trailingWhitespaceRange)
|
||||
@deleteSelectedText()
|
||||
|
||||
currentRow = selectedRange.start.row
|
||||
nextRow = currentRow + 1
|
||||
insertSpace = nextRow <= @editor.buffer.getLastRow() and
|
||||
@editor.buffer.lineLengthForRow(nextRow) > 0 and
|
||||
@editor.buffer.lineLengthForRow(currentRow) > 0
|
||||
@insertText(' ') if insertSpace
|
||||
|
||||
@cursor.moveToEndOfLine()
|
||||
|
||||
# Remove leading whitespace from the line below
|
||||
@modifySelection =>
|
||||
@cursor.moveRight()
|
||||
@cursor.moveToFirstCharacterOfLine()
|
||||
@deleteSelectedText()
|
||||
|
||||
@cursor.moveLeft() if insertSpace
|
||||
|
||||
if joinMarker?
|
||||
newSelectedRange = joinMarker.getBufferRange()
|
||||
@setBufferRange(newSelectedRange)
|
||||
joinMarker.destroy()
|
||||
|
||||
# Public: Removes one level of indent from the currently selected rows.
|
||||
outdentSelectedRows: ->
|
||||
[start, end] = @getBufferRowRange()
|
||||
buffer = @editor.buffer
|
||||
leadingTabRegex = new RegExp("^( {1,#{@editor.getTabLength()}}|\t)")
|
||||
for row in [start..end]
|
||||
if matchLength = buffer.lineForRow(row).match(leadingTabRegex)?[0].length
|
||||
buffer.delete [[row, 0], [row, matchLength]]
|
||||
return
|
||||
|
||||
# Public: Sets the indentation level of all selected rows to values suggested
|
||||
# by the relevant grammars.
|
||||
autoIndentSelectedRows: ->
|
||||
[start, end] = @getBufferRowRange()
|
||||
@editor.autoIndentBufferRows(start, end)
|
||||
|
||||
# Public: Wraps the selected lines in comments if they aren't currently part
|
||||
# of a comment.
|
||||
#
|
||||
# Removes the comment if they are currently wrapped in a comment.
|
||||
toggleLineComments: ->
|
||||
@editor.toggleLineCommentsForBufferRows(@getBufferRowRange()...)
|
||||
|
||||
# Public: Cuts the selection until the end of the screen line.
|
||||
cutToEndOfLine: (maintainClipboard) ->
|
||||
@selectToEndOfLine() if @isEmpty()
|
||||
@cut(maintainClipboard)
|
||||
|
||||
# Public: Cuts the selection until the end of the buffer line.
|
||||
cutToEndOfBufferLine: (maintainClipboard) ->
|
||||
@selectToEndOfBufferLine() if @isEmpty()
|
||||
@cut(maintainClipboard)
|
||||
|
||||
# Public: Copies the selection to the clipboard and then deletes it.
|
||||
#
|
||||
# * `maintainClipboard` {Boolean} (default: false) See {::copy}
|
||||
# * `fullLine` {Boolean} (default: false) See {::copy}
|
||||
cut: (maintainClipboard=false, fullLine=false) ->
|
||||
@copy(maintainClipboard, fullLine)
|
||||
@delete()
|
||||
|
||||
# Public: Copies the current selection to the clipboard.
|
||||
#
|
||||
# * `maintainClipboard` {Boolean} if `true`, a specific metadata property
|
||||
# is created to store each content copied to the clipboard. The clipboard
|
||||
# `text` still contains the concatenation of the clipboard with the
|
||||
# current selection. (default: false)
|
||||
# * `fullLine` {Boolean} if `true`, the copied text will always be pasted
|
||||
# at the beginning of the line containing the cursor, regardless of the
|
||||
# cursor's horizontal position. (default: false)
|
||||
copy: (maintainClipboard=false, fullLine=false) ->
|
||||
return if @isEmpty()
|
||||
{start, end} = @getBufferRange()
|
||||
selectionText = @editor.getTextInRange([start, end])
|
||||
precedingText = @editor.getTextInRange([[start.row, 0], start])
|
||||
startLevel = @editor.indentLevelForLine(precedingText)
|
||||
|
||||
if maintainClipboard
|
||||
{text: clipboardText, metadata} = @editor.constructor.clipboard.readWithMetadata()
|
||||
metadata ?= {}
|
||||
unless metadata.selections?
|
||||
metadata.selections = [{
|
||||
text: clipboardText,
|
||||
indentBasis: metadata.indentBasis,
|
||||
fullLine: metadata.fullLine,
|
||||
}]
|
||||
metadata.selections.push({
|
||||
text: selectionText,
|
||||
indentBasis: startLevel,
|
||||
fullLine: fullLine
|
||||
})
|
||||
@editor.constructor.clipboard.write([clipboardText, selectionText].join("\n"), metadata)
|
||||
else
|
||||
@editor.constructor.clipboard.write(selectionText, {
|
||||
indentBasis: startLevel,
|
||||
fullLine: fullLine
|
||||
})
|
||||
|
||||
# Public: Creates a fold containing the current selection.
|
||||
fold: ->
|
||||
range = @getBufferRange()
|
||||
unless range.isEmpty()
|
||||
@editor.foldBufferRange(range)
|
||||
@cursor.setBufferPosition(range.end)
|
||||
|
||||
# Private: Increase the indentation level of the given text by given number
|
||||
# of levels. Leaves the first line unchanged.
|
||||
adjustIndent: (lines, indentAdjustment) ->
|
||||
for line, i in lines
|
||||
if indentAdjustment is 0 or line is ''
|
||||
continue
|
||||
else if indentAdjustment > 0
|
||||
lines[i] = @editor.buildIndentString(indentAdjustment) + line
|
||||
else
|
||||
currentIndentLevel = @editor.indentLevelForLine(lines[i])
|
||||
indentLevel = Math.max(0, currentIndentLevel + indentAdjustment)
|
||||
lines[i] = line.replace(/^[\t ]+/, @editor.buildIndentString(indentLevel))
|
||||
return
|
||||
|
||||
# Indent the current line(s).
|
||||
#
|
||||
# If the selection is empty, indents the current line if the cursor precedes
|
||||
# non-whitespace characters, and otherwise inserts a tab. If the selection is
|
||||
# non empty, calls {::indentSelectedRows}.
|
||||
#
|
||||
# * `options` (optional) {Object} with the keys:
|
||||
# * `autoIndent` If `true`, the line is indented to an automatically-inferred
|
||||
# level. Otherwise, {TextEditor::getTabText} is inserted.
|
||||
indent: ({autoIndent}={}) ->
|
||||
{row} = @cursor.getBufferPosition()
|
||||
|
||||
if @isEmpty()
|
||||
@cursor.skipLeadingWhitespace()
|
||||
desiredIndent = @editor.suggestedIndentForBufferRow(row)
|
||||
delta = desiredIndent - @cursor.getIndentLevel()
|
||||
|
||||
if autoIndent and delta > 0
|
||||
delta = Math.max(delta, 1) unless @editor.getSoftTabs()
|
||||
@insertText(@editor.buildIndentString(delta))
|
||||
else
|
||||
@insertText(@editor.buildIndentString(1, @cursor.getBufferColumn()))
|
||||
else
|
||||
@indentSelectedRows()
|
||||
|
||||
# Public: If the selection spans multiple rows, indent all of them.
|
||||
indentSelectedRows: ->
|
||||
[start, end] = @getBufferRowRange()
|
||||
for row in [start..end]
|
||||
@editor.buffer.insert([row, 0], @editor.getTabText()) unless @editor.buffer.lineLengthForRow(row) is 0
|
||||
return
|
||||
|
||||
###
|
||||
Section: Managing multiple selections
|
||||
###
|
||||
|
||||
# Public: Moves the selection down one row.
|
||||
addSelectionBelow: ->
|
||||
range = @getGoalScreenRange().copy()
|
||||
nextRow = range.end.row + 1
|
||||
|
||||
for row in [nextRow..@editor.getLastScreenRow()]
|
||||
range.start.row = row
|
||||
range.end.row = row
|
||||
clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true)
|
||||
|
||||
if range.isEmpty()
|
||||
continue if range.end.column > 0 and clippedRange.end.column is 0
|
||||
else
|
||||
continue if clippedRange.isEmpty()
|
||||
|
||||
selection = @editor.addSelectionForScreenRange(clippedRange)
|
||||
selection.setGoalScreenRange(range)
|
||||
break
|
||||
|
||||
return
|
||||
|
||||
# Public: Moves the selection up one row.
|
||||
addSelectionAbove: ->
|
||||
range = @getGoalScreenRange().copy()
|
||||
previousRow = range.end.row - 1
|
||||
|
||||
for row in [previousRow..0]
|
||||
range.start.row = row
|
||||
range.end.row = row
|
||||
clippedRange = @editor.clipScreenRange(range, skipSoftWrapIndentation: true)
|
||||
|
||||
if range.isEmpty()
|
||||
continue if range.end.column > 0 and clippedRange.end.column is 0
|
||||
else
|
||||
continue if clippedRange.isEmpty()
|
||||
|
||||
selection = @editor.addSelectionForScreenRange(clippedRange)
|
||||
selection.setGoalScreenRange(range)
|
||||
break
|
||||
|
||||
return
|
||||
|
||||
# Public: Combines the given selection into this selection and then destroys
|
||||
# the given selection.
|
||||
#
|
||||
# * `otherSelection` A {Selection} to merge with.
|
||||
# * `options` (optional) {Object} options matching those found in {::setBufferRange}.
|
||||
merge: (otherSelection, options) ->
|
||||
myGoalScreenRange = @getGoalScreenRange()
|
||||
otherGoalScreenRange = otherSelection.getGoalScreenRange()
|
||||
|
||||
if myGoalScreenRange? and otherGoalScreenRange?
|
||||
options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange)
|
||||
else
|
||||
options.goalScreenRange = myGoalScreenRange ? otherGoalScreenRange
|
||||
|
||||
@setBufferRange(@getBufferRange().union(otherSelection.getBufferRange()), Object.assign(autoscroll: false, options))
|
||||
otherSelection.destroy()
|
||||
|
||||
###
|
||||
Section: Comparing to other selections
|
||||
###
|
||||
|
||||
# Public: Compare this selection's buffer range to another selection's buffer
|
||||
# range.
|
||||
#
|
||||
# See {Range::compare} for more details.
|
||||
#
|
||||
# * `otherSelection` A {Selection} to compare against
|
||||
compare: (otherSelection) ->
|
||||
@marker.compare(otherSelection.marker)
|
||||
|
||||
###
|
||||
Section: Private Utilities
|
||||
###
|
||||
|
||||
setGoalScreenRange: (range) ->
|
||||
@goalScreenRange = Range.fromObject(range)
|
||||
|
||||
getGoalScreenRange: ->
|
||||
@goalScreenRange ? @getScreenRange()
|
||||
|
||||
markerDidChange: (e) ->
|
||||
{oldHeadBufferPosition, oldTailBufferPosition, newHeadBufferPosition} = e
|
||||
{oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e
|
||||
{textChanged} = e
|
||||
|
||||
unless oldHeadScreenPosition.isEqual(newHeadScreenPosition)
|
||||
@cursor.goalColumn = null
|
||||
cursorMovedEvent = {
|
||||
oldBufferPosition: oldHeadBufferPosition
|
||||
oldScreenPosition: oldHeadScreenPosition
|
||||
newBufferPosition: newHeadBufferPosition
|
||||
newScreenPosition: newHeadScreenPosition
|
||||
textChanged: textChanged
|
||||
cursor: @cursor
|
||||
}
|
||||
@cursor.emitter.emit('did-change-position', cursorMovedEvent)
|
||||
@editor.cursorMoved(cursorMovedEvent)
|
||||
|
||||
@emitter.emit 'did-change-range'
|
||||
@editor.selectionRangeChanged(
|
||||
oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition)
|
||||
oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition)
|
||||
newBufferRange: @getBufferRange()
|
||||
newScreenRange: @getScreenRange()
|
||||
selection: this
|
||||
)
|
||||
|
||||
markerDidDestroy: ->
|
||||
return if @editor.isDestroyed()
|
||||
|
||||
@destroyed = true
|
||||
@cursor.destroyed = true
|
||||
|
||||
@editor.removeSelection(this)
|
||||
|
||||
@cursor.emitter.emit 'did-destroy'
|
||||
@emitter.emit 'did-destroy'
|
||||
|
||||
@cursor.emitter.dispose()
|
||||
@emitter.dispose()
|
||||
|
||||
finalize: ->
|
||||
@initialScreenRange = null unless @initialScreenRange?.isEqual(@getScreenRange())
|
||||
if @isEmpty()
|
||||
@wordwise = false
|
||||
@linewise = false
|
||||
|
||||
autoscroll: (options) ->
|
||||
if @marker.hasTail()
|
||||
@editor.scrollToScreenRange(@getScreenRange(), Object.assign({reversed: @isReversed()}, options))
|
||||
else
|
||||
@cursor.autoscroll(options)
|
||||
|
||||
clearAutoscroll: ->
|
||||
|
||||
modifySelection: (fn) ->
|
||||
@retainSelection = true
|
||||
@plantTail()
|
||||
fn()
|
||||
@retainSelection = false
|
||||
|
||||
# Sets the marker's tail to the same position as the marker's head.
|
||||
#
|
||||
# This only works if there isn't already a tail position.
|
||||
#
|
||||
# Returns a {Point} representing the new tail position.
|
||||
plantTail: ->
|
||||
@marker.plantTail()
|
||||
977
src/selection.js
Normal file
977
src/selection.js
Normal file
@@ -0,0 +1,977 @@
|
||||
const {Point, Range} = require('text-buffer')
|
||||
const {pick} = require('underscore-plus')
|
||||
const {Emitter} = require('event-kit')
|
||||
|
||||
const NonWhitespaceRegExp = /\S/
|
||||
let nextId = 0
|
||||
|
||||
// Extended: Represents a selection in the {TextEditor}.
|
||||
module.exports =
|
||||
class Selection {
|
||||
constructor ({cursor, marker, editor, id}) {
|
||||
this.id = (id != null) ? id : nextId++
|
||||
this.cursor = cursor
|
||||
this.marker = marker
|
||||
this.editor = editor
|
||||
this.emitter = new Emitter()
|
||||
this.initialScreenRange = null
|
||||
this.wordwise = false
|
||||
this.cursor.selection = this
|
||||
this.decoration = this.editor.decorateMarker(this.marker, {type: 'highlight', class: 'selection'})
|
||||
this.marker.onDidChange(e => this.markerDidChange(e))
|
||||
this.marker.onDidDestroy(() => this.markerDidDestroy())
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.marker.destroy()
|
||||
}
|
||||
|
||||
isLastSelection () {
|
||||
return this === this.editor.getLastSelection()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Event Subscription
|
||||
*/
|
||||
|
||||
// Extended: Calls your `callback` when the selection was moved.
|
||||
//
|
||||
// * `callback` {Function}
|
||||
// * `event` {Object}
|
||||
// * `oldBufferRange` {Range}
|
||||
// * `oldScreenRange` {Range}
|
||||
// * `newBufferRange` {Range}
|
||||
// * `newScreenRange` {Range}
|
||||
// * `selection` {Selection} that triggered the event
|
||||
//
|
||||
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
||||
onDidChangeRange (callback) {
|
||||
return this.emitter.on('did-change-range', callback)
|
||||
}
|
||||
|
||||
// Extended: Calls your `callback` when the selection was destroyed
|
||||
//
|
||||
// * `callback` {Function}
|
||||
//
|
||||
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
||||
onDidDestroy (callback) {
|
||||
return this.emitter.once('did-destroy', callback)
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Managing the selection range
|
||||
*/
|
||||
|
||||
// Public: Returns the screen {Range} for the selection.
|
||||
getScreenRange () {
|
||||
return this.marker.getScreenRange()
|
||||
}
|
||||
|
||||
// Public: Modifies the screen range for the selection.
|
||||
//
|
||||
// * `screenRange` The new {Range} to use.
|
||||
// * `options` (optional) {Object} options matching those found in {::setBufferRange}.
|
||||
setScreenRange (screenRange, options) {
|
||||
return this.setBufferRange(this.editor.bufferRangeForScreenRange(screenRange), options)
|
||||
}
|
||||
|
||||
// Public: Returns the buffer {Range} for the selection.
|
||||
getBufferRange () {
|
||||
return this.marker.getBufferRange()
|
||||
}
|
||||
|
||||
// Public: Modifies the buffer {Range} for the selection.
|
||||
//
|
||||
// * `bufferRange` The new {Range} to select.
|
||||
// * `options` (optional) {Object} with the keys:
|
||||
// * `preserveFolds` if `true`, the fold settings are preserved after the
|
||||
// selection moves.
|
||||
// * `autoscroll` {Boolean} indicating whether to autoscroll to the new
|
||||
// range. Defaults to `true` if this is the most recently added selection,
|
||||
// `false` otherwise.
|
||||
setBufferRange (bufferRange, options = {}) {
|
||||
bufferRange = Range.fromObject(bufferRange)
|
||||
if (options.reversed == null) options.reversed = this.isReversed()
|
||||
if (!options.preserveFolds) this.editor.destroyFoldsContainingBufferPositions([bufferRange.start, bufferRange.end], true)
|
||||
this.modifySelection(() => {
|
||||
const needsFlash = options.flash
|
||||
options.flash = null
|
||||
this.marker.setBufferRange(bufferRange, options)
|
||||
const autoscroll = options.autoscroll != null ? options.autoscroll : this.isLastSelection()
|
||||
if (autoscroll) this.autoscroll()
|
||||
if (needsFlash) this.decoration.flash('flash', this.editor.selectionFlashDuration)
|
||||
})
|
||||
}
|
||||
|
||||
// Public: Returns the starting and ending buffer rows the selection is
|
||||
// highlighting.
|
||||
//
|
||||
// Returns an {Array} of two {Number}s: the starting row, and the ending row.
|
||||
getBufferRowRange () {
|
||||
const range = this.getBufferRange()
|
||||
const start = range.start.row
|
||||
let end = range.end.row
|
||||
if (range.end.column === 0) end = Math.max(start, end - 1)
|
||||
return [start, end]
|
||||
}
|
||||
|
||||
getTailScreenPosition () {
|
||||
return this.marker.getTailScreenPosition()
|
||||
}
|
||||
|
||||
getTailBufferPosition () {
|
||||
return this.marker.getTailBufferPosition()
|
||||
}
|
||||
|
||||
getHeadScreenPosition () {
|
||||
return this.marker.getHeadScreenPosition()
|
||||
}
|
||||
|
||||
getHeadBufferPosition () {
|
||||
return this.marker.getHeadBufferPosition()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Info about the selection
|
||||
*/
|
||||
|
||||
// Public: Determines if the selection contains anything.
|
||||
isEmpty () {
|
||||
return this.getBufferRange().isEmpty()
|
||||
}
|
||||
|
||||
// Public: Determines if the ending position of a marker is greater than the
|
||||
// starting position.
|
||||
//
|
||||
// This can happen when, for example, you highlight text "up" in a {TextBuffer}.
|
||||
isReversed () {
|
||||
return this.marker.isReversed()
|
||||
}
|
||||
|
||||
// Public: Returns whether the selection is a single line or not.
|
||||
isSingleScreenLine () {
|
||||
return this.getScreenRange().isSingleLine()
|
||||
}
|
||||
|
||||
// Public: Returns the text in the selection.
|
||||
getText () {
|
||||
return this.editor.buffer.getTextInRange(this.getBufferRange())
|
||||
}
|
||||
|
||||
// Public: Identifies if a selection intersects with a given buffer range.
|
||||
//
|
||||
// * `bufferRange` A {Range} to check against.
|
||||
//
|
||||
// Returns a {Boolean}
|
||||
intersectsBufferRange (bufferRange) {
|
||||
return this.getBufferRange().intersectsWith(bufferRange)
|
||||
}
|
||||
|
||||
intersectsScreenRowRange (startRow, endRow) {
|
||||
return this.getScreenRange().intersectsRowRange(startRow, endRow)
|
||||
}
|
||||
|
||||
intersectsScreenRow (screenRow) {
|
||||
return this.getScreenRange().intersectsRow(screenRow)
|
||||
}
|
||||
|
||||
// Public: Identifies if a selection intersects with another selection.
|
||||
//
|
||||
// * `otherSelection` A {Selection} to check against.
|
||||
//
|
||||
// Returns a {Boolean}
|
||||
intersectsWith (otherSelection, exclusive) {
|
||||
return this.getBufferRange().intersectsWith(otherSelection.getBufferRange(), exclusive)
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Modifying the selected range
|
||||
*/
|
||||
|
||||
// Public: Clears the selection, moving the marker to the head.
|
||||
//
|
||||
// * `options` (optional) {Object} with the following keys:
|
||||
// * `autoscroll` {Boolean} indicating whether to autoscroll to the new
|
||||
// range. Defaults to `true` if this is the most recently added selection,
|
||||
// `false` otherwise.
|
||||
clear (options) {
|
||||
this.goalScreenRange = null
|
||||
if (!this.retainSelection) this.marker.clearTail()
|
||||
const autoscroll = options && options.autoscroll != null
|
||||
? options.autoscroll
|
||||
: this.isLastSelection()
|
||||
if (autoscroll) this.autoscroll()
|
||||
this.finalize()
|
||||
}
|
||||
|
||||
// Public: Selects the text from the current cursor position to a given screen
|
||||
// position.
|
||||
//
|
||||
// * `position` An instance of {Point}, with a given `row` and `column`.
|
||||
selectToScreenPosition (position, options) {
|
||||
position = Point.fromObject(position)
|
||||
|
||||
this.modifySelection(() => {
|
||||
if (this.initialScreenRange) {
|
||||
if (position.isLessThan(this.initialScreenRange.start)) {
|
||||
this.marker.setScreenRange([position, this.initialScreenRange.end], {reversed: true})
|
||||
} else {
|
||||
this.marker.setScreenRange([this.initialScreenRange.start, position], {reversed: false})
|
||||
}
|
||||
} else {
|
||||
this.cursor.setScreenPosition(position, options)
|
||||
}
|
||||
|
||||
if (this.linewise) {
|
||||
this.expandOverLine(options)
|
||||
} else if (this.wordwise) {
|
||||
this.expandOverWord(options)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Public: Selects the text from the current cursor position to a given buffer
|
||||
// position.
|
||||
//
|
||||
// * `position` An instance of {Point}, with a given `row` and `column`.
|
||||
selectToBufferPosition (position) {
|
||||
this.modifySelection(() => this.cursor.setBufferPosition(position))
|
||||
}
|
||||
|
||||
// Public: Selects the text one position right of the cursor.
|
||||
//
|
||||
// * `columnCount` (optional) {Number} number of columns to select (default: 1)
|
||||
selectRight (columnCount) {
|
||||
this.modifySelection(() => this.cursor.moveRight(columnCount))
|
||||
}
|
||||
|
||||
// Public: Selects the text one position left of the cursor.
|
||||
//
|
||||
// * `columnCount` (optional) {Number} number of columns to select (default: 1)
|
||||
selectLeft (columnCount) {
|
||||
this.modifySelection(() => this.cursor.moveLeft(columnCount))
|
||||
}
|
||||
|
||||
// Public: Selects all the text one position above the cursor.
|
||||
//
|
||||
// * `rowCount` (optional) {Number} number of rows to select (default: 1)
|
||||
selectUp (rowCount) {
|
||||
this.modifySelection(() => this.cursor.moveUp(rowCount))
|
||||
}
|
||||
|
||||
// Public: Selects all the text one position below the cursor.
|
||||
//
|
||||
// * `rowCount` (optional) {Number} number of rows to select (default: 1)
|
||||
selectDown (rowCount) {
|
||||
this.modifySelection(() => this.cursor.moveDown(rowCount))
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the top of
|
||||
// the buffer.
|
||||
selectToTop () {
|
||||
this.modifySelection(() => this.cursor.moveToTop())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the bottom
|
||||
// of the buffer.
|
||||
selectToBottom () {
|
||||
this.modifySelection(() => this.cursor.moveToBottom())
|
||||
}
|
||||
|
||||
// Public: Selects all the text in the buffer.
|
||||
selectAll () {
|
||||
this.setBufferRange(this.editor.buffer.getRange(), {autoscroll: false})
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the
|
||||
// beginning of the line.
|
||||
selectToBeginningOfLine () {
|
||||
this.modifySelection(() => this.cursor.moveToBeginningOfLine())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the first
|
||||
// character of the line.
|
||||
selectToFirstCharacterOfLine () {
|
||||
this.modifySelection(() => this.cursor.moveToFirstCharacterOfLine())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the end of
|
||||
// the screen line.
|
||||
selectToEndOfLine () {
|
||||
this.modifySelection(() => this.cursor.moveToEndOfScreenLine())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the end of
|
||||
// the buffer line.
|
||||
selectToEndOfBufferLine () {
|
||||
this.modifySelection(() => this.cursor.moveToEndOfLine())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the
|
||||
// beginning of the word.
|
||||
selectToBeginningOfWord () {
|
||||
this.modifySelection(() => this.cursor.moveToBeginningOfWord())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the end of
|
||||
// the word.
|
||||
selectToEndOfWord () {
|
||||
this.modifySelection(() => this.cursor.moveToEndOfWord())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the
|
||||
// beginning of the next word.
|
||||
selectToBeginningOfNextWord () {
|
||||
this.modifySelection(() => this.cursor.moveToBeginningOfNextWord())
|
||||
}
|
||||
|
||||
// Public: Selects text to the previous word boundary.
|
||||
selectToPreviousWordBoundary () {
|
||||
this.modifySelection(() => this.cursor.moveToPreviousWordBoundary())
|
||||
}
|
||||
|
||||
// Public: Selects text to the next word boundary.
|
||||
selectToNextWordBoundary () {
|
||||
this.modifySelection(() => this.cursor.moveToNextWordBoundary())
|
||||
}
|
||||
|
||||
// Public: Selects text to the previous subword boundary.
|
||||
selectToPreviousSubwordBoundary () {
|
||||
this.modifySelection(() => this.cursor.moveToPreviousSubwordBoundary())
|
||||
}
|
||||
|
||||
// Public: Selects text to the next subword boundary.
|
||||
selectToNextSubwordBoundary () {
|
||||
this.modifySelection(() => this.cursor.moveToNextSubwordBoundary())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the
|
||||
// beginning of the next paragraph.
|
||||
selectToBeginningOfNextParagraph () {
|
||||
this.modifySelection(() => this.cursor.moveToBeginningOfNextParagraph())
|
||||
}
|
||||
|
||||
// Public: Selects all the text from the current cursor position to the
|
||||
// beginning of the previous paragraph.
|
||||
selectToBeginningOfPreviousParagraph () {
|
||||
this.modifySelection(() => this.cursor.moveToBeginningOfPreviousParagraph())
|
||||
}
|
||||
|
||||
// Public: Modifies the selection to encompass the current word.
|
||||
//
|
||||
// Returns a {Range}.
|
||||
selectWord (options = {}) {
|
||||
if (this.cursor.isSurroundedByWhitespace()) options.wordRegex = /[\t ]*/
|
||||
if (this.cursor.isBetweenWordAndNonWord()) {
|
||||
options.includeNonWordCharacters = false
|
||||
}
|
||||
|
||||
this.setBufferRange(this.cursor.getCurrentWordBufferRange(options), options)
|
||||
this.wordwise = true
|
||||
this.initialScreenRange = this.getScreenRange()
|
||||
}
|
||||
|
||||
// Public: Expands the newest selection to include the entire word on which
|
||||
// the cursors rests.
|
||||
expandOverWord (options) {
|
||||
this.setBufferRange(this.getBufferRange().union(this.cursor.getCurrentWordBufferRange()), {autoscroll: false})
|
||||
const autoscroll = options && options.autoscroll != null ? options.autoscroll : this.isLastSelection()
|
||||
if (autoscroll) this.cursor.autoscroll()
|
||||
}
|
||||
|
||||
// Public: Selects an entire line in the buffer.
|
||||
//
|
||||
// * `row` The line {Number} to select (default: the row of the cursor).
|
||||
selectLine (row, options) {
|
||||
if (row != null) {
|
||||
this.setBufferRange(this.editor.bufferRangeForBufferRow(row, {includeNewline: true}), options)
|
||||
} else {
|
||||
const startRange = this.editor.bufferRangeForBufferRow(this.marker.getStartBufferPosition().row)
|
||||
const endRange = this.editor.bufferRangeForBufferRow(this.marker.getEndBufferPosition().row, {includeNewline: true})
|
||||
this.setBufferRange(startRange.union(endRange), options)
|
||||
}
|
||||
|
||||
this.linewise = true
|
||||
this.wordwise = false
|
||||
this.initialScreenRange = this.getScreenRange()
|
||||
}
|
||||
|
||||
// Public: Expands the newest selection to include the entire line on which
|
||||
// the cursor currently rests.
|
||||
//
|
||||
// It also includes the newline character.
|
||||
expandOverLine (options) {
|
||||
const range = this.getBufferRange().union(this.cursor.getCurrentLineBufferRange({includeNewline: true}))
|
||||
this.setBufferRange(range, {autoscroll: false})
|
||||
const autoscroll = options && options.autoscroll != null ? options.autoscroll : this.isLastSelection()
|
||||
if (autoscroll) this.cursor.autoscroll()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Modifying the selected text
|
||||
*/
|
||||
|
||||
// Public: Replaces text at the current selection.
|
||||
//
|
||||
// * `text` A {String} representing the text to add
|
||||
// * `options` (optional) {Object} with keys:
|
||||
// * `select` If `true`, selects the newly added text.
|
||||
// * `autoIndent` If `true`, indents all inserted text appropriately.
|
||||
// * `autoIndentNewline` If `true`, indent newline appropriately.
|
||||
// * `autoDecreaseIndent` If `true`, decreases indent level appropriately
|
||||
// (for example, when a closing bracket is inserted).
|
||||
// * `preserveTrailingLineIndentation` By default, when pasting multiple
|
||||
// lines, Atom attempts to preserve the relative indent level between the
|
||||
// first line and trailing lines, even if the indent level of the first
|
||||
// line has changed from the copied text. If this option is `true`, this
|
||||
// behavior is suppressed.
|
||||
// level between the first lines and the trailing lines.
|
||||
// * `normalizeLineEndings` (optional) {Boolean} (default: true)
|
||||
// * `undo` If `skip`, skips the undo stack for this operation.
|
||||
insertText (text, options = {}) {
|
||||
let desiredIndentLevel, indentAdjustment
|
||||
const oldBufferRange = this.getBufferRange()
|
||||
const wasReversed = this.isReversed()
|
||||
this.clear(options)
|
||||
|
||||
let autoIndentFirstLine = false
|
||||
const precedingText = this.editor.getTextInRange([[oldBufferRange.start.row, 0], oldBufferRange.start])
|
||||
const remainingLines = text.split('\n')
|
||||
const firstInsertedLine = remainingLines.shift()
|
||||
|
||||
if (options.indentBasis != null && !options.preserveTrailingLineIndentation) {
|
||||
indentAdjustment = this.editor.indentLevelForLine(precedingText) - options.indentBasis
|
||||
this.adjustIndent(remainingLines, indentAdjustment)
|
||||
}
|
||||
|
||||
const textIsAutoIndentable = (text === '\n') || (text === '\r\n') || NonWhitespaceRegExp.test(text)
|
||||
if (options.autoIndent && textIsAutoIndentable && !NonWhitespaceRegExp.test(precedingText) && (remainingLines.length > 0)) {
|
||||
autoIndentFirstLine = true
|
||||
const firstLine = precedingText + firstInsertedLine
|
||||
desiredIndentLevel = this.editor.tokenizedBuffer.suggestedIndentForLineAtBufferRow(oldBufferRange.start.row, firstLine)
|
||||
indentAdjustment = desiredIndentLevel - this.editor.indentLevelForLine(firstLine)
|
||||
this.adjustIndent(remainingLines, indentAdjustment)
|
||||
}
|
||||
|
||||
text = firstInsertedLine
|
||||
if (remainingLines.length > 0) text += `\n${remainingLines.join('\n')}`
|
||||
|
||||
const newBufferRange = this.editor.buffer.setTextInRange(oldBufferRange, text, pick(options, 'undo', 'normalizeLineEndings'))
|
||||
|
||||
if (options.select) {
|
||||
this.setBufferRange(newBufferRange, {reversed: wasReversed})
|
||||
} else {
|
||||
if (wasReversed) this.cursor.setBufferPosition(newBufferRange.end)
|
||||
}
|
||||
|
||||
if (autoIndentFirstLine) {
|
||||
this.editor.setIndentationForBufferRow(oldBufferRange.start.row, desiredIndentLevel)
|
||||
}
|
||||
|
||||
if (options.autoIndentNewline && (text === '\n')) {
|
||||
this.editor.autoIndentBufferRow(newBufferRange.end.row, {preserveLeadingWhitespace: true, skipBlankLines: false})
|
||||
} else if (options.autoDecreaseIndent && NonWhitespaceRegExp.test(text)) {
|
||||
this.editor.autoDecreaseIndentForBufferRow(newBufferRange.start.row)
|
||||
}
|
||||
|
||||
const autoscroll = options.autoscroll != null ? options.autoscroll : this.isLastSelection()
|
||||
if (autoscroll) this.autoscroll()
|
||||
|
||||
return newBufferRange
|
||||
}
|
||||
|
||||
// Public: Removes the first character before the selection if the selection
|
||||
// is empty otherwise it deletes the selection.
|
||||
backspace () {
|
||||
if (this.isEmpty()) this.selectLeft()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or, if nothing is selected, then all
|
||||
// characters from the start of the selection back to the previous word
|
||||
// boundary.
|
||||
deleteToPreviousWordBoundary () {
|
||||
if (this.isEmpty()) this.selectToPreviousWordBoundary()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or, if nothing is selected, then all
|
||||
// characters from the start of the selection up to the next word
|
||||
// boundary.
|
||||
deleteToNextWordBoundary () {
|
||||
if (this.isEmpty()) this.selectToNextWordBoundary()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes from the start of the selection to the beginning of the
|
||||
// current word if the selection is empty otherwise it deletes the selection.
|
||||
deleteToBeginningOfWord () {
|
||||
if (this.isEmpty()) this.selectToBeginningOfWord()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes from the beginning of the line which the selection begins on
|
||||
// all the way through to the end of the selection.
|
||||
deleteToBeginningOfLine () {
|
||||
if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) {
|
||||
this.selectLeft()
|
||||
} else {
|
||||
this.selectToBeginningOfLine()
|
||||
}
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or the next character after the start of the
|
||||
// selection if the selection is empty.
|
||||
delete () {
|
||||
if (this.isEmpty()) this.selectRight()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: If the selection is empty, removes all text from the cursor to the
|
||||
// end of the line. If the cursor is already at the end of the line, it
|
||||
// removes the following newline. If the selection isn't empty, only deletes
|
||||
// the contents of the selection.
|
||||
deleteToEndOfLine () {
|
||||
if (this.isEmpty()) {
|
||||
if (this.cursor.isAtEndOfLine()) {
|
||||
this.delete()
|
||||
return
|
||||
}
|
||||
this.selectToEndOfLine()
|
||||
}
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or all characters from the start of the
|
||||
// selection to the end of the current word if nothing is selected.
|
||||
deleteToEndOfWord () {
|
||||
if (this.isEmpty()) this.selectToEndOfWord()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or all characters from the start of the
|
||||
// selection to the end of the current word if nothing is selected.
|
||||
deleteToBeginningOfSubword () {
|
||||
if (this.isEmpty()) this.selectToPreviousSubwordBoundary()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes the selection or all characters from the start of the
|
||||
// selection to the end of the current word if nothing is selected.
|
||||
deleteToEndOfSubword () {
|
||||
if (this.isEmpty()) this.selectToNextSubwordBoundary()
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
// Public: Removes only the selected text.
|
||||
deleteSelectedText () {
|
||||
const bufferRange = this.getBufferRange()
|
||||
if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange)
|
||||
if (this.cursor) this.cursor.setBufferPosition(bufferRange.start)
|
||||
}
|
||||
|
||||
// Public: Removes the line at the beginning of the selection if the selection
|
||||
// is empty unless the selection spans multiple lines in which case all lines
|
||||
// are removed.
|
||||
deleteLine () {
|
||||
if (this.isEmpty()) {
|
||||
const start = this.cursor.getScreenRow()
|
||||
const range = this.editor.bufferRowsForScreenRows(start, start + 1)
|
||||
if (range[1] > range[0]) {
|
||||
this.editor.buffer.deleteRows(range[0], range[1] - 1)
|
||||
} else {
|
||||
this.editor.buffer.deleteRow(range[0])
|
||||
}
|
||||
} else {
|
||||
const range = this.getBufferRange()
|
||||
const start = range.start.row
|
||||
let end = range.end.row
|
||||
if (end !== this.editor.buffer.getLastRow() && range.end.column === 0) end--
|
||||
this.editor.buffer.deleteRows(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Joins the current line with the one below it. Lines will
|
||||
// be separated by a single space.
|
||||
//
|
||||
// If there selection spans more than one line, all the lines are joined together.
|
||||
joinLines () {
|
||||
let joinMarker
|
||||
const selectedRange = this.getBufferRange()
|
||||
if (selectedRange.isEmpty()) {
|
||||
if (selectedRange.start.row === this.editor.buffer.getLastRow()) return
|
||||
} else {
|
||||
joinMarker = this.editor.markBufferRange(selectedRange, {invalidate: 'never'})
|
||||
}
|
||||
|
||||
const rowCount = Math.max(1, selectedRange.getRowCount() - 1)
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
this.cursor.setBufferPosition([selectedRange.start.row])
|
||||
this.cursor.moveToEndOfLine()
|
||||
|
||||
// Remove trailing whitespace from the current line
|
||||
const scanRange = this.cursor.getCurrentLineBufferRange()
|
||||
let trailingWhitespaceRange = null
|
||||
this.editor.scanInBufferRange(/[ \t]+$/, scanRange, ({range}) => {
|
||||
trailingWhitespaceRange = range
|
||||
})
|
||||
if (trailingWhitespaceRange) {
|
||||
this.setBufferRange(trailingWhitespaceRange)
|
||||
this.deleteSelectedText()
|
||||
}
|
||||
|
||||
const currentRow = selectedRange.start.row
|
||||
const nextRow = currentRow + 1
|
||||
const insertSpace =
|
||||
(nextRow <= this.editor.buffer.getLastRow()) &&
|
||||
(this.editor.buffer.lineLengthForRow(nextRow) > 0) &&
|
||||
(this.editor.buffer.lineLengthForRow(currentRow) > 0)
|
||||
if (insertSpace) this.insertText(' ')
|
||||
|
||||
this.cursor.moveToEndOfLine()
|
||||
|
||||
// Remove leading whitespace from the line below
|
||||
this.modifySelection(() => {
|
||||
this.cursor.moveRight()
|
||||
this.cursor.moveToFirstCharacterOfLine()
|
||||
})
|
||||
this.deleteSelectedText()
|
||||
|
||||
if (insertSpace) this.cursor.moveLeft()
|
||||
}
|
||||
|
||||
if (joinMarker) {
|
||||
const newSelectedRange = joinMarker.getBufferRange()
|
||||
this.setBufferRange(newSelectedRange)
|
||||
joinMarker.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Removes one level of indent from the currently selected rows.
|
||||
outdentSelectedRows () {
|
||||
const [start, end] = this.getBufferRowRange()
|
||||
const {buffer} = this.editor
|
||||
const leadingTabRegex = new RegExp(`^( {1,${this.editor.getTabLength()}}|\t)`)
|
||||
for (let row = start; row <= end; row++) {
|
||||
const match = buffer.lineForRow(row).match(leadingTabRegex)
|
||||
if (match && match[0].length > 0) {
|
||||
buffer.delete([[row, 0], [row, match[0].length]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Sets the indentation level of all selected rows to values suggested
|
||||
// by the relevant grammars.
|
||||
autoIndentSelectedRows () {
|
||||
const [start, end] = this.getBufferRowRange()
|
||||
return this.editor.autoIndentBufferRows(start, end)
|
||||
}
|
||||
|
||||
// Public: Wraps the selected lines in comments if they aren't currently part
|
||||
// of a comment.
|
||||
//
|
||||
// Removes the comment if they are currently wrapped in a comment.
|
||||
toggleLineComments () {
|
||||
this.editor.toggleLineCommentsForBufferRows(...(this.getBufferRowRange() || []))
|
||||
}
|
||||
|
||||
// Public: Cuts the selection until the end of the screen line.
|
||||
cutToEndOfLine (maintainClipboard) {
|
||||
if (this.isEmpty()) this.selectToEndOfLine()
|
||||
return this.cut(maintainClipboard)
|
||||
}
|
||||
|
||||
// Public: Cuts the selection until the end of the buffer line.
|
||||
cutToEndOfBufferLine (maintainClipboard) {
|
||||
if (this.isEmpty()) this.selectToEndOfBufferLine()
|
||||
this.cut(maintainClipboard)
|
||||
}
|
||||
|
||||
// Public: Copies the selection to the clipboard and then deletes it.
|
||||
//
|
||||
// * `maintainClipboard` {Boolean} (default: false) See {::copy}
|
||||
// * `fullLine` {Boolean} (default: false) See {::copy}
|
||||
cut (maintainClipboard = false, fullLine = false) {
|
||||
this.copy(maintainClipboard, fullLine)
|
||||
this.delete()
|
||||
}
|
||||
|
||||
// Public: Copies the current selection to the clipboard.
|
||||
//
|
||||
// * `maintainClipboard` {Boolean} if `true`, a specific metadata property
|
||||
// is created to store each content copied to the clipboard. The clipboard
|
||||
// `text` still contains the concatenation of the clipboard with the
|
||||
// current selection. (default: false)
|
||||
// * `fullLine` {Boolean} if `true`, the copied text will always be pasted
|
||||
// at the beginning of the line containing the cursor, regardless of the
|
||||
// cursor's horizontal position. (default: false)
|
||||
copy (maintainClipboard = false, fullLine = false) {
|
||||
if (this.isEmpty()) return
|
||||
const {start, end} = this.getBufferRange()
|
||||
const selectionText = this.editor.getTextInRange([start, end])
|
||||
const precedingText = this.editor.getTextInRange([[start.row, 0], start])
|
||||
const startLevel = this.editor.indentLevelForLine(precedingText)
|
||||
|
||||
if (maintainClipboard) {
|
||||
let {text: clipboardText, metadata} = this.editor.constructor.clipboard.readWithMetadata()
|
||||
if (!metadata) metadata = {}
|
||||
if (!metadata.selections) {
|
||||
metadata.selections = [{
|
||||
text: clipboardText,
|
||||
indentBasis: metadata.indentBasis,
|
||||
fullLine: metadata.fullLine
|
||||
}]
|
||||
}
|
||||
metadata.selections.push({
|
||||
text: selectionText,
|
||||
indentBasis: startLevel,
|
||||
fullLine
|
||||
})
|
||||
this.editor.constructor.clipboard.write([clipboardText, selectionText].join('\n'), metadata)
|
||||
} else {
|
||||
this.editor.constructor.clipboard.write(selectionText, {
|
||||
indentBasis: startLevel,
|
||||
fullLine
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Creates a fold containing the current selection.
|
||||
fold () {
|
||||
const range = this.getBufferRange()
|
||||
if (!range.isEmpty()) {
|
||||
this.editor.foldBufferRange(range)
|
||||
this.cursor.setBufferPosition(range.end)
|
||||
}
|
||||
}
|
||||
|
||||
// Private: Increase the indentation level of the given text by given number
|
||||
// of levels. Leaves the first line unchanged.
|
||||
adjustIndent (lines, indentAdjustment) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (indentAdjustment === 0 || line === '') {
|
||||
continue
|
||||
} else if (indentAdjustment > 0) {
|
||||
lines[i] = this.editor.buildIndentString(indentAdjustment) + line
|
||||
} else {
|
||||
const currentIndentLevel = this.editor.indentLevelForLine(lines[i])
|
||||
const indentLevel = Math.max(0, currentIndentLevel + indentAdjustment)
|
||||
lines[i] = line.replace(/^[\t ]+/, this.editor.buildIndentString(indentLevel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Indent the current line(s).
|
||||
//
|
||||
// If the selection is empty, indents the current line if the cursor precedes
|
||||
// non-whitespace characters, and otherwise inserts a tab. If the selection is
|
||||
// non empty, calls {::indentSelectedRows}.
|
||||
//
|
||||
// * `options` (optional) {Object} with the keys:
|
||||
// * `autoIndent` If `true`, the line is indented to an automatically-inferred
|
||||
// level. Otherwise, {TextEditor::getTabText} is inserted.
|
||||
indent ({autoIndent} = {}) {
|
||||
const {row} = this.cursor.getBufferPosition()
|
||||
|
||||
if (this.isEmpty()) {
|
||||
this.cursor.skipLeadingWhitespace()
|
||||
const desiredIndent = this.editor.suggestedIndentForBufferRow(row)
|
||||
let delta = desiredIndent - this.cursor.getIndentLevel()
|
||||
|
||||
if (autoIndent && delta > 0) {
|
||||
if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1)
|
||||
this.insertText(this.editor.buildIndentString(delta))
|
||||
} else {
|
||||
this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn()))
|
||||
}
|
||||
} else {
|
||||
this.indentSelectedRows()
|
||||
}
|
||||
}
|
||||
|
||||
// Public: If the selection spans multiple rows, indent all of them.
|
||||
indentSelectedRows () {
|
||||
const [start, end] = this.getBufferRowRange()
|
||||
for (let row = start; row <= end; row++) {
|
||||
if (this.editor.buffer.lineLengthForRow(row) !== 0) {
|
||||
this.editor.buffer.insert([row, 0], this.editor.getTabText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Managing multiple selections
|
||||
*/
|
||||
|
||||
// Public: Moves the selection down one row.
|
||||
addSelectionBelow () {
|
||||
const range = this.getGoalScreenRange().copy()
|
||||
const nextRow = range.end.row + 1
|
||||
|
||||
for (let row = nextRow, end = this.editor.getLastScreenRow(); row <= end; row++) {
|
||||
range.start.row = row
|
||||
range.end.row = row
|
||||
const clippedRange = this.editor.clipScreenRange(range, {skipSoftWrapIndentation: true})
|
||||
|
||||
if (range.isEmpty()) {
|
||||
if (range.end.column > 0 && clippedRange.end.column === 0) continue
|
||||
} else {
|
||||
if (clippedRange.isEmpty()) continue
|
||||
}
|
||||
|
||||
const selection = this.editor.addSelectionForScreenRange(clippedRange)
|
||||
selection.setGoalScreenRange(range)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Moves the selection up one row.
|
||||
addSelectionAbove () {
|
||||
const range = this.getGoalScreenRange().copy()
|
||||
const previousRow = range.end.row - 1
|
||||
|
||||
for (let row = previousRow; row >= 0; row--) {
|
||||
range.start.row = row
|
||||
range.end.row = row
|
||||
const clippedRange = this.editor.clipScreenRange(range, {skipSoftWrapIndentation: true})
|
||||
|
||||
if (range.isEmpty()) {
|
||||
if (range.end.column > 0 && clippedRange.end.column === 0) continue
|
||||
} else {
|
||||
if (clippedRange.isEmpty()) continue
|
||||
}
|
||||
|
||||
const selection = this.editor.addSelectionForScreenRange(clippedRange)
|
||||
selection.setGoalScreenRange(range)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Combines the given selection into this selection and then destroys
|
||||
// the given selection.
|
||||
//
|
||||
// * `otherSelection` A {Selection} to merge with.
|
||||
// * `options` (optional) {Object} options matching those found in {::setBufferRange}.
|
||||
merge (otherSelection, options = {}) {
|
||||
const myGoalScreenRange = this.getGoalScreenRange()
|
||||
const otherGoalScreenRange = otherSelection.getGoalScreenRange()
|
||||
|
||||
if (myGoalScreenRange && otherGoalScreenRange) {
|
||||
options.goalScreenRange = myGoalScreenRange.union(otherGoalScreenRange)
|
||||
} else {
|
||||
options.goalScreenRange = myGoalScreenRange || otherGoalScreenRange
|
||||
}
|
||||
|
||||
const bufferRange = this.getBufferRange().union(otherSelection.getBufferRange())
|
||||
this.setBufferRange(bufferRange, Object.assign({autoscroll: false}, options))
|
||||
otherSelection.destroy()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Comparing to other selections
|
||||
*/
|
||||
|
||||
// Public: Compare this selection's buffer range to another selection's buffer
|
||||
// range.
|
||||
//
|
||||
// See {Range::compare} for more details.
|
||||
//
|
||||
// * `otherSelection` A {Selection} to compare against
|
||||
compare (otherSelection) {
|
||||
return this.marker.compare(otherSelection.marker)
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Private Utilities
|
||||
*/
|
||||
|
||||
setGoalScreenRange (range) {
|
||||
this.goalScreenRange = Range.fromObject(range)
|
||||
}
|
||||
|
||||
getGoalScreenRange () {
|
||||
return this.goalScreenRange || this.getScreenRange()
|
||||
}
|
||||
|
||||
markerDidChange (e) {
|
||||
const {oldHeadBufferPosition, oldTailBufferPosition, newHeadBufferPosition} = e
|
||||
const {oldHeadScreenPosition, oldTailScreenPosition, newHeadScreenPosition} = e
|
||||
const {textChanged} = e
|
||||
|
||||
if (!oldHeadScreenPosition.isEqual(newHeadScreenPosition)) {
|
||||
this.cursor.goalColumn = null
|
||||
const cursorMovedEvent = {
|
||||
oldBufferPosition: oldHeadBufferPosition,
|
||||
oldScreenPosition: oldHeadScreenPosition,
|
||||
newBufferPosition: newHeadBufferPosition,
|
||||
newScreenPosition: newHeadScreenPosition,
|
||||
textChanged,
|
||||
cursor: this.cursor
|
||||
}
|
||||
this.cursor.emitter.emit('did-change-position', cursorMovedEvent)
|
||||
this.editor.cursorMoved(cursorMovedEvent)
|
||||
}
|
||||
|
||||
this.emitter.emit('did-change-range')
|
||||
this.editor.selectionRangeChanged({
|
||||
oldBufferRange: new Range(oldHeadBufferPosition, oldTailBufferPosition),
|
||||
oldScreenRange: new Range(oldHeadScreenPosition, oldTailScreenPosition),
|
||||
newBufferRange: this.getBufferRange(),
|
||||
newScreenRange: this.getScreenRange(),
|
||||
selection: this
|
||||
})
|
||||
}
|
||||
|
||||
markerDidDestroy () {
|
||||
if (this.editor.isDestroyed()) return
|
||||
|
||||
this.destroyed = true
|
||||
this.cursor.destroyed = true
|
||||
|
||||
this.editor.removeSelection(this)
|
||||
|
||||
this.cursor.emitter.emit('did-destroy')
|
||||
this.emitter.emit('did-destroy')
|
||||
|
||||
this.cursor.emitter.dispose()
|
||||
this.emitter.dispose()
|
||||
}
|
||||
|
||||
finalize () {
|
||||
if (!this.initialScreenRange || !this.initialScreenRange.isEqual(this.getScreenRange())) {
|
||||
this.initialScreenRange = null
|
||||
}
|
||||
if (this.isEmpty()) {
|
||||
this.wordwise = false
|
||||
this.linewise = false
|
||||
}
|
||||
}
|
||||
|
||||
autoscroll (options) {
|
||||
if (this.marker.hasTail()) {
|
||||
this.editor.scrollToScreenRange(this.getScreenRange(), Object.assign({reversed: this.isReversed()}, options))
|
||||
} else {
|
||||
this.cursor.autoscroll(options)
|
||||
}
|
||||
}
|
||||
|
||||
clearAutoscroll () {}
|
||||
|
||||
modifySelection (fn) {
|
||||
this.retainSelection = true
|
||||
this.plantTail()
|
||||
fn()
|
||||
this.retainSelection = false
|
||||
}
|
||||
|
||||
// Sets the marker's tail to the same position as the marker's head.
|
||||
//
|
||||
// This only works if there isn't already a tail position.
|
||||
//
|
||||
// Returns a {Point} representing the new tail position.
|
||||
plantTail () {
|
||||
this.marker.plantTail()
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,6 @@ class TextEditorComponent {
|
||||
this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this))
|
||||
this.lineComponentsByScreenLineId = new Map()
|
||||
this.overlayComponents = new Set()
|
||||
this.overlayDimensionsByElement = new WeakMap()
|
||||
this.shouldRenderDummyScrollbars = true
|
||||
this.remeasureScrollbars = false
|
||||
this.pendingAutoscroll = null
|
||||
@@ -803,8 +802,10 @@ class TextEditorComponent {
|
||||
{
|
||||
key: overlayProps.element,
|
||||
overlayComponents: this.overlayComponents,
|
||||
measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element),
|
||||
didResize: () => { this.updateSync() }
|
||||
didResize: (overlayComponent) => {
|
||||
this.updateOverlayToRender(overlayProps)
|
||||
overlayComponent.update(overlayProps)
|
||||
}
|
||||
},
|
||||
overlayProps
|
||||
))
|
||||
@@ -1339,42 +1340,46 @@ class TextEditorComponent {
|
||||
})
|
||||
}
|
||||
|
||||
updateOverlayToRender (decoration) {
|
||||
const windowInnerHeight = this.getWindowInnerHeight()
|
||||
const windowInnerWidth = this.getWindowInnerWidth()
|
||||
const contentClientRect = this.refs.content.getBoundingClientRect()
|
||||
|
||||
const {element, screenPosition, avoidOverflow} = decoration
|
||||
const {row, column} = screenPosition
|
||||
let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight()
|
||||
let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column)
|
||||
const clientRect = element.getBoundingClientRect()
|
||||
|
||||
if (avoidOverflow !== false) {
|
||||
const computedStyle = window.getComputedStyle(element)
|
||||
const elementTop = wrapperTop + parseInt(computedStyle.marginTop)
|
||||
const elementBottom = elementTop + clientRect.height
|
||||
const flippedElementTop = wrapperTop - this.getLineHeight() - clientRect.height - parseInt(computedStyle.marginBottom)
|
||||
const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft)
|
||||
const elementRight = elementLeft + clientRect.width
|
||||
|
||||
if (elementBottom > windowInnerHeight && flippedElementTop >= 0) {
|
||||
wrapperTop -= (elementTop - flippedElementTop)
|
||||
}
|
||||
if (elementLeft < 0) {
|
||||
wrapperLeft -= elementLeft
|
||||
} else if (elementRight > windowInnerWidth) {
|
||||
wrapperLeft -= (elementRight - windowInnerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
decoration.pixelTop = Math.round(wrapperTop)
|
||||
decoration.pixelLeft = Math.round(wrapperLeft)
|
||||
}
|
||||
|
||||
updateOverlaysToRender () {
|
||||
const overlayCount = this.decorationsToRender.overlays.length
|
||||
if (overlayCount === 0) return null
|
||||
|
||||
const windowInnerHeight = this.getWindowInnerHeight()
|
||||
const windowInnerWidth = this.getWindowInnerWidth()
|
||||
const contentClientRect = this.refs.content.getBoundingClientRect()
|
||||
for (let i = 0; i < overlayCount; i++) {
|
||||
const decoration = this.decorationsToRender.overlays[i]
|
||||
const {element, screenPosition, avoidOverflow} = decoration
|
||||
const {row, column} = screenPosition
|
||||
let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight()
|
||||
let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column)
|
||||
const clientRect = element.getBoundingClientRect()
|
||||
this.overlayDimensionsByElement.set(element, clientRect)
|
||||
|
||||
if (avoidOverflow !== false) {
|
||||
const computedStyle = window.getComputedStyle(element)
|
||||
const elementTop = wrapperTop + parseInt(computedStyle.marginTop)
|
||||
const elementBottom = elementTop + clientRect.height
|
||||
const flippedElementTop = wrapperTop - this.getLineHeight() - clientRect.height - parseInt(computedStyle.marginBottom)
|
||||
const elementLeft = wrapperLeft + parseInt(computedStyle.marginLeft)
|
||||
const elementRight = elementLeft + clientRect.width
|
||||
|
||||
if (elementBottom > windowInnerHeight && flippedElementTop >= 0) {
|
||||
wrapperTop -= (elementTop - flippedElementTop)
|
||||
}
|
||||
if (elementLeft < 0) {
|
||||
wrapperLeft -= elementLeft
|
||||
} else if (elementRight > windowInnerWidth) {
|
||||
wrapperLeft -= (elementRight - windowInnerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
decoration.pixelTop = Math.round(wrapperTop)
|
||||
decoration.pixelLeft = Math.round(wrapperLeft)
|
||||
this.updateOverlayToRender(decoration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1603,11 +1608,23 @@ class TextEditorComponent {
|
||||
if (this.isInputEnabled()) {
|
||||
event.stopPropagation()
|
||||
|
||||
// WARNING: If we call preventDefault on the input of a space character,
|
||||
// then the browser interprets the spacebar keypress as a page-down command,
|
||||
// causing spaces to scroll elements containing editors. This is impossible
|
||||
// to test.
|
||||
if (event.data !== ' ') event.preventDefault()
|
||||
// WARNING: If we call preventDefault on the input of a space
|
||||
// character, then the browser interprets the spacebar keypress as a
|
||||
// page-down command, causing spaces to scroll elements containing
|
||||
// editors. This means typing space will actually change the contents
|
||||
// of the hidden input, which will cause the browser to autoscroll the
|
||||
// scroll container to reveal the input if it is off screen (See
|
||||
// https://github.com/atom/atom/issues/16046). To correct for this
|
||||
// situation, we automatically reset the scroll position to 0,0 after
|
||||
// typing a space. None of this can really be tested.
|
||||
if (event.data === ' ') {
|
||||
window.setImmediate(() => {
|
||||
this.refs.scrollContainer.scrollTop = 0
|
||||
this.refs.scrollContainer.scrollLeft = 0
|
||||
})
|
||||
} else {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// If the input event is fired while the accented character menu is open it
|
||||
// means that the user has chosen one of the accented alternatives. Thus, we
|
||||
@@ -1640,8 +1657,11 @@ class TextEditorComponent {
|
||||
didKeydown (event) {
|
||||
// Stop dragging when user interacts with the keyboard. This prevents
|
||||
// unwanted selections in the case edits are performed while selecting text
|
||||
// at the same time.
|
||||
if (this.stopDragging) this.stopDragging()
|
||||
// at the same time. Modifier keys are exempt to preserve the ability to
|
||||
// add selections, shift-scroll horizontally while selecting.
|
||||
if (this.stopDragging && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta' && event.key !== 'Shift') {
|
||||
this.stopDragging()
|
||||
}
|
||||
|
||||
if (this.lastKeydownBeforeKeypress != null) {
|
||||
if (this.lastKeydownBeforeKeypress.code === event.code) {
|
||||
@@ -1758,7 +1778,7 @@ class TextEditorComponent {
|
||||
|
||||
if (target && target.matches('.fold-marker')) {
|
||||
const bufferPosition = model.bufferPositionForScreenPosition(screenPosition)
|
||||
model.destroyFoldsIntersectingBufferRange(Range(bufferPosition, bufferPosition))
|
||||
model.destroyFoldsContainingBufferPositions([bufferPosition], false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2443,8 +2463,12 @@ class TextEditorComponent {
|
||||
|
||||
didChangeDisplayLayer (changes) {
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
const {start, oldExtent, newExtent} = changes[i]
|
||||
this.spliceLineTopIndex(start.row, oldExtent.row, newExtent.row)
|
||||
const {oldRange, newRange} = changes[i]
|
||||
this.spliceLineTopIndex(
|
||||
newRange.start.row,
|
||||
oldRange.end.row - oldRange.start.row,
|
||||
newRange.end.row - newRange.start.row
|
||||
)
|
||||
}
|
||||
|
||||
this.scheduleUpdate()
|
||||
@@ -4194,17 +4218,26 @@ class OverlayComponent {
|
||||
this.element.style.zIndex = 4
|
||||
this.element.style.top = (this.props.pixelTop || 0) + 'px'
|
||||
this.element.style.left = (this.props.pixelLeft || 0) + 'px'
|
||||
this.currentContentRect = null
|
||||
|
||||
// Synchronous DOM updates in response to resize events might trigger a
|
||||
// "loop limit exceeded" error. We disconnect the observer before
|
||||
// potentially mutating the DOM, and then reconnect it on the next tick.
|
||||
// Note: ResizeObserver calls its callback when .observe is called
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
const {contentRect} = entries[0]
|
||||
if (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) {
|
||||
|
||||
if (
|
||||
this.currentContentRect &&
|
||||
(this.currentContentRect.width !== contentRect.width ||
|
||||
this.currentContentRect.height !== contentRect.height)
|
||||
) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.props.didResize()
|
||||
this.props.didResize(this)
|
||||
process.nextTick(() => { this.resizeObserver.observe(this.props.element) })
|
||||
}
|
||||
|
||||
this.currentContentRect = contentRect
|
||||
})
|
||||
this.didAttach()
|
||||
this.props.overlayComponents.add(this)
|
||||
@@ -4215,15 +4248,30 @@ class OverlayComponent {
|
||||
this.didDetach()
|
||||
}
|
||||
|
||||
getNextUpdatePromise () {
|
||||
if (!this.nextUpdatePromise) {
|
||||
this.nextUpdatePromise = new Promise((resolve) => {
|
||||
this.resolveNextUpdatePromise = () => {
|
||||
this.nextUpdatePromise = null
|
||||
this.resolveNextUpdatePromise = null
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.nextUpdatePromise
|
||||
}
|
||||
|
||||
update (newProps) {
|
||||
const oldProps = this.props
|
||||
this.props = newProps
|
||||
this.props = Object.assign({}, oldProps, newProps)
|
||||
if (this.props.pixelTop != null) this.element.style.top = this.props.pixelTop + 'px'
|
||||
if (this.props.pixelLeft != null) this.element.style.left = this.props.pixelLeft + 'px'
|
||||
if (newProps.className !== oldProps.className) {
|
||||
if (oldProps.className != null) this.element.classList.remove(oldProps.className)
|
||||
if (newProps.className != null) this.element.classList.add(newProps.className)
|
||||
}
|
||||
|
||||
if (this.resolveNextUpdatePromise) this.resolveNextUpdatePromise()
|
||||
}
|
||||
|
||||
didAttach () {
|
||||
|
||||
@@ -288,7 +288,7 @@ export default class TextEditorRegistry {
|
||||
|
||||
let currentScore = this.editorGrammarScores.get(editor)
|
||||
if (currentScore == null || score > currentScore) {
|
||||
editor.setGrammar(grammar, score)
|
||||
editor.setGrammar(grammar)
|
||||
this.editorGrammarScores.set(editor, score)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
4587
src/text-editor.js
Normal file
4587
src/text-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,322 +0,0 @@
|
||||
path = require 'path'
|
||||
_ = require 'underscore-plus'
|
||||
{Emitter, CompositeDisposable} = require 'event-kit'
|
||||
{File} = require 'pathwatcher'
|
||||
fs = require 'fs-plus'
|
||||
LessCompileCache = require './less-compile-cache'
|
||||
|
||||
# Extended: Handles loading and activating available themes.
|
||||
#
|
||||
# An instance of this class is always available as the `atom.themes` global.
|
||||
module.exports =
|
||||
class ThemeManager
|
||||
constructor: ({@packageManager, @config, @styleManager, @notificationManager, @viewRegistry}) ->
|
||||
@emitter = new Emitter
|
||||
@styleSheetDisposablesBySourcePath = {}
|
||||
@lessCache = null
|
||||
@initialLoadComplete = false
|
||||
@packageManager.registerPackageActivator(this, ['theme'])
|
||||
@packageManager.onDidActivateInitialPackages =>
|
||||
@onDidChangeActiveThemes => @packageManager.reloadActivePackageStyleSheets()
|
||||
|
||||
initialize: ({@resourcePath, @configDirPath, @safeMode, devMode}) ->
|
||||
@lessSourcesByRelativeFilePath = null
|
||||
if devMode or typeof snapshotAuxiliaryData is 'undefined'
|
||||
@lessSourcesByRelativeFilePath = {}
|
||||
@importedFilePathsByRelativeImportPath = {}
|
||||
else
|
||||
@lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath
|
||||
@importedFilePathsByRelativeImportPath = snapshotAuxiliaryData.importedFilePathsByRelativeImportPath
|
||||
|
||||
###
|
||||
Section: Event Subscription
|
||||
###
|
||||
|
||||
# Essential: Invoke `callback` when style sheet changes associated with
|
||||
# updating the list of active themes have completed.
|
||||
#
|
||||
# * `callback` {Function}
|
||||
onDidChangeActiveThemes: (callback) ->
|
||||
@emitter.on 'did-change-active-themes', callback
|
||||
|
||||
###
|
||||
Section: Accessing Available Themes
|
||||
###
|
||||
|
||||
getAvailableNames: ->
|
||||
# TODO: Maybe should change to list all the available themes out there?
|
||||
@getLoadedNames()
|
||||
|
||||
###
|
||||
Section: Accessing Loaded Themes
|
||||
###
|
||||
|
||||
# Public: Returns an {Array} of {String}s of all the loaded theme names.
|
||||
getLoadedThemeNames: ->
|
||||
theme.name for theme in @getLoadedThemes()
|
||||
|
||||
# Public: Returns an {Array} of all the loaded themes.
|
||||
getLoadedThemes: ->
|
||||
pack for pack in @packageManager.getLoadedPackages() when pack.isTheme()
|
||||
|
||||
###
|
||||
Section: Accessing Active Themes
|
||||
###
|
||||
|
||||
# Public: Returns an {Array} of {String}s all the active theme names.
|
||||
getActiveThemeNames: ->
|
||||
theme.name for theme in @getActiveThemes()
|
||||
|
||||
# Public: Returns an {Array} of all the active themes.
|
||||
getActiveThemes: ->
|
||||
pack for pack in @packageManager.getActivePackages() when pack.isTheme()
|
||||
|
||||
activatePackages: -> @activateThemes()
|
||||
|
||||
###
|
||||
Section: Managing Enabled Themes
|
||||
###
|
||||
|
||||
warnForNonExistentThemes: ->
|
||||
themeNames = @config.get('core.themes') ? []
|
||||
themeNames = [themeNames] unless _.isArray(themeNames)
|
||||
for themeName in themeNames
|
||||
unless themeName and typeof themeName is 'string' and @packageManager.resolvePackagePath(themeName)
|
||||
console.warn("Enabled theme '#{themeName}' is not installed.")
|
||||
|
||||
# Public: Get the enabled theme names from the config.
|
||||
#
|
||||
# Returns an array of theme names in the order that they should be activated.
|
||||
getEnabledThemeNames: ->
|
||||
themeNames = @config.get('core.themes') ? []
|
||||
themeNames = [themeNames] unless _.isArray(themeNames)
|
||||
themeNames = themeNames.filter (themeName) =>
|
||||
if themeName and typeof themeName is 'string'
|
||||
return true if @packageManager.resolvePackagePath(themeName)
|
||||
false
|
||||
|
||||
# Use a built-in syntax and UI theme any time the configured themes are not
|
||||
# available.
|
||||
if themeNames.length < 2
|
||||
builtInThemeNames = [
|
||||
'atom-dark-syntax'
|
||||
'atom-dark-ui'
|
||||
'atom-light-syntax'
|
||||
'atom-light-ui'
|
||||
'base16-tomorrow-dark-theme'
|
||||
'base16-tomorrow-light-theme'
|
||||
'solarized-dark-syntax'
|
||||
'solarized-light-syntax'
|
||||
]
|
||||
themeNames = _.intersection(themeNames, builtInThemeNames)
|
||||
if themeNames.length is 0
|
||||
themeNames = ['atom-dark-syntax', 'atom-dark-ui']
|
||||
else if themeNames.length is 1
|
||||
if _.endsWith(themeNames[0], '-ui')
|
||||
themeNames.unshift('atom-dark-syntax')
|
||||
else
|
||||
themeNames.push('atom-dark-ui')
|
||||
|
||||
# Reverse so the first (top) theme is loaded after the others. We want
|
||||
# the first/top theme to override later themes in the stack.
|
||||
themeNames.reverse()
|
||||
|
||||
###
|
||||
Section: Private
|
||||
###
|
||||
|
||||
# Resolve and apply the stylesheet specified by the path.
|
||||
#
|
||||
# This supports both CSS and Less stylesheets.
|
||||
#
|
||||
# * `stylesheetPath` A {String} path to the stylesheet that can be an absolute
|
||||
# path or a relative path that will be resolved against the load path.
|
||||
#
|
||||
# Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
# required stylesheet.
|
||||
requireStylesheet: (stylesheetPath, priority, skipDeprecatedSelectorsTransformation) ->
|
||||
if fullPath = @resolveStylesheet(stylesheetPath)
|
||||
content = @loadStylesheet(fullPath)
|
||||
@applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation)
|
||||
else
|
||||
throw new Error("Could not find a file at path '#{stylesheetPath}'")
|
||||
|
||||
unwatchUserStylesheet: ->
|
||||
@userStylesheetSubscriptions?.dispose()
|
||||
@userStylesheetSubscriptions = null
|
||||
@userStylesheetFile = null
|
||||
@userStyleSheetDisposable?.dispose()
|
||||
@userStyleSheetDisposable = null
|
||||
|
||||
loadUserStylesheet: ->
|
||||
@unwatchUserStylesheet()
|
||||
|
||||
userStylesheetPath = @styleManager.getUserStyleSheetPath()
|
||||
return unless fs.isFileSync(userStylesheetPath)
|
||||
|
||||
try
|
||||
@userStylesheetFile = new File(userStylesheetPath)
|
||||
@userStylesheetSubscriptions = new CompositeDisposable()
|
||||
reloadStylesheet = => @loadUserStylesheet()
|
||||
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidChange(reloadStylesheet))
|
||||
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidRename(reloadStylesheet))
|
||||
@userStylesheetSubscriptions.add(@userStylesheetFile.onDidDelete(reloadStylesheet))
|
||||
catch error
|
||||
message = """
|
||||
Unable to watch path: `#{path.basename(userStylesheetPath)}`. Make sure
|
||||
you have permissions to `#{userStylesheetPath}`.
|
||||
|
||||
On linux there are currently problems with watch sizes. See
|
||||
[this document][watches] for more info.
|
||||
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path
|
||||
"""
|
||||
@notificationManager.addError(message, dismissable: true)
|
||||
|
||||
try
|
||||
userStylesheetContents = @loadStylesheet(userStylesheetPath, true)
|
||||
catch
|
||||
return
|
||||
|
||||
@userStyleSheetDisposable = @styleManager.addStyleSheet(userStylesheetContents, sourcePath: userStylesheetPath, priority: 2)
|
||||
|
||||
loadBaseStylesheets: ->
|
||||
@reloadBaseStylesheets()
|
||||
|
||||
reloadBaseStylesheets: ->
|
||||
@requireStylesheet('../static/atom', -2, true)
|
||||
|
||||
stylesheetElementForId: (id) ->
|
||||
escapedId = id.replace(/\\/g, '\\\\')
|
||||
document.head.querySelector("atom-styles style[source-path=\"#{escapedId}\"]")
|
||||
|
||||
resolveStylesheet: (stylesheetPath) ->
|
||||
if path.extname(stylesheetPath).length > 0
|
||||
fs.resolveOnLoadPath(stylesheetPath)
|
||||
else
|
||||
fs.resolveOnLoadPath(stylesheetPath, ['css', 'less'])
|
||||
|
||||
loadStylesheet: (stylesheetPath, importFallbackVariables) ->
|
||||
if path.extname(stylesheetPath) is '.less'
|
||||
@loadLessStylesheet(stylesheetPath, importFallbackVariables)
|
||||
else
|
||||
fs.readFileSync(stylesheetPath, 'utf8')
|
||||
|
||||
loadLessStylesheet: (lessStylesheetPath, importFallbackVariables=false) ->
|
||||
@lessCache ?= new LessCompileCache({
|
||||
@resourcePath,
|
||||
@lessSourcesByRelativeFilePath,
|
||||
@importedFilePathsByRelativeImportPath,
|
||||
importPaths: @getImportPaths()
|
||||
})
|
||||
|
||||
try
|
||||
if importFallbackVariables
|
||||
baseVarImports = """
|
||||
@import "variables/ui-variables";
|
||||
@import "variables/syntax-variables";
|
||||
"""
|
||||
relativeFilePath = path.relative(@resourcePath, lessStylesheetPath)
|
||||
lessSource = @lessSourcesByRelativeFilePath[relativeFilePath]
|
||||
if lessSource?
|
||||
content = lessSource.content
|
||||
digest = lessSource.digest
|
||||
else
|
||||
content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8')
|
||||
digest = null
|
||||
|
||||
@lessCache.cssForFile(lessStylesheetPath, content, digest)
|
||||
else
|
||||
@lessCache.read(lessStylesheetPath)
|
||||
catch error
|
||||
error.less = true
|
||||
if error.line?
|
||||
# Adjust line numbers for import fallbacks
|
||||
error.line -= 2 if importFallbackVariables
|
||||
|
||||
message = "Error compiling Less stylesheet: `#{lessStylesheetPath}`"
|
||||
detail = """
|
||||
Line number: #{error.line}
|
||||
#{error.message}
|
||||
"""
|
||||
else
|
||||
message = "Error loading Less stylesheet: `#{lessStylesheetPath}`"
|
||||
detail = error.message
|
||||
|
||||
@notificationManager.addError(message, {detail, dismissable: true})
|
||||
throw error
|
||||
|
||||
removeStylesheet: (stylesheetPath) ->
|
||||
@styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose()
|
||||
|
||||
applyStylesheet: (path, text, priority, skipDeprecatedSelectorsTransformation) ->
|
||||
@styleSheetDisposablesBySourcePath[path] = @styleManager.addStyleSheet(
|
||||
text,
|
||||
{
|
||||
priority,
|
||||
skipDeprecatedSelectorsTransformation,
|
||||
sourcePath: path
|
||||
}
|
||||
)
|
||||
|
||||
activateThemes: ->
|
||||
new Promise (resolve) =>
|
||||
# @config.observe runs the callback once, then on subsequent changes.
|
||||
@config.observe 'core.themes', =>
|
||||
@deactivateThemes().then =>
|
||||
@warnForNonExistentThemes()
|
||||
@refreshLessCache() # Update cache for packages in core.themes config
|
||||
|
||||
promises = []
|
||||
for themeName in @getEnabledThemeNames()
|
||||
if @packageManager.resolvePackagePath(themeName)
|
||||
promises.push(@packageManager.activatePackage(themeName))
|
||||
else
|
||||
console.warn("Failed to activate theme '#{themeName}' because it isn't installed.")
|
||||
|
||||
Promise.all(promises).then =>
|
||||
@addActiveThemeClasses()
|
||||
@refreshLessCache() # Update cache again now that @getActiveThemes() is populated
|
||||
@loadUserStylesheet()
|
||||
@reloadBaseStylesheets()
|
||||
@initialLoadComplete = true
|
||||
@emitter.emit 'did-change-active-themes'
|
||||
resolve()
|
||||
|
||||
deactivateThemes: ->
|
||||
@removeActiveThemeClasses()
|
||||
@unwatchUserStylesheet()
|
||||
results = @getActiveThemes().map((pack) => @packageManager.deactivatePackage(pack.name))
|
||||
Promise.all(results.filter((r) -> typeof r?.then is 'function'))
|
||||
|
||||
isInitialLoadComplete: -> @initialLoadComplete
|
||||
|
||||
addActiveThemeClasses: ->
|
||||
if workspaceElement = @viewRegistry.getView(@workspace)
|
||||
for pack in @getActiveThemes()
|
||||
workspaceElement.classList.add("theme-#{pack.name}")
|
||||
return
|
||||
|
||||
removeActiveThemeClasses: ->
|
||||
workspaceElement = @viewRegistry.getView(@workspace)
|
||||
for pack in @getActiveThemes()
|
||||
workspaceElement.classList.remove("theme-#{pack.name}")
|
||||
return
|
||||
|
||||
refreshLessCache: ->
|
||||
@lessCache?.setImportPaths(@getImportPaths())
|
||||
|
||||
getImportPaths: ->
|
||||
activeThemes = @getActiveThemes()
|
||||
if activeThemes.length > 0
|
||||
themePaths = (theme.getStylesheetsPath() for theme in activeThemes when theme)
|
||||
else
|
||||
themePaths = []
|
||||
for themeName in @getEnabledThemeNames()
|
||||
if themePath = @packageManager.resolvePackagePath(themeName)
|
||||
deprecatedPath = path.join(themePath, 'stylesheets')
|
||||
if fs.isDirectorySync(deprecatedPath)
|
||||
themePaths.push(deprecatedPath)
|
||||
else
|
||||
themePaths.push(path.join(themePath, 'styles'))
|
||||
|
||||
themePaths.filter (themePath) -> fs.isDirectorySync(themePath)
|
||||
401
src/theme-manager.js
Normal file
401
src/theme-manager.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/* global snapshotAuxiliaryData */
|
||||
|
||||
const path = require('path')
|
||||
const _ = require('underscore-plus')
|
||||
const {Emitter, CompositeDisposable} = require('event-kit')
|
||||
const {File} = require('pathwatcher')
|
||||
const fs = require('fs-plus')
|
||||
const LessCompileCache = require('./less-compile-cache')
|
||||
|
||||
// Extended: Handles loading and activating available themes.
|
||||
//
|
||||
// An instance of this class is always available as the `atom.themes` global.
|
||||
module.exports =
|
||||
class ThemeManager {
|
||||
constructor ({packageManager, config, styleManager, notificationManager, viewRegistry}) {
|
||||
this.packageManager = packageManager
|
||||
this.config = config
|
||||
this.styleManager = styleManager
|
||||
this.notificationManager = notificationManager
|
||||
this.viewRegistry = viewRegistry
|
||||
this.emitter = new Emitter()
|
||||
this.styleSheetDisposablesBySourcePath = {}
|
||||
this.lessCache = null
|
||||
this.initialLoadComplete = false
|
||||
this.packageManager.registerPackageActivator(this, ['theme'])
|
||||
this.packageManager.onDidActivateInitialPackages(() => {
|
||||
this.onDidChangeActiveThemes(() => this.packageManager.reloadActivePackageStyleSheets())
|
||||
})
|
||||
}
|
||||
|
||||
initialize ({resourcePath, configDirPath, safeMode, devMode}) {
|
||||
this.resourcePath = resourcePath
|
||||
this.configDirPath = configDirPath
|
||||
this.safeMode = safeMode
|
||||
this.lessSourcesByRelativeFilePath = null
|
||||
if (devMode || (typeof snapshotAuxiliaryData === 'undefined')) {
|
||||
this.lessSourcesByRelativeFilePath = {}
|
||||
this.importedFilePathsByRelativeImportPath = {}
|
||||
} else {
|
||||
this.lessSourcesByRelativeFilePath = snapshotAuxiliaryData.lessSourcesByRelativeFilePath
|
||||
this.importedFilePathsByRelativeImportPath = snapshotAuxiliaryData.importedFilePathsByRelativeImportPath
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Event Subscription
|
||||
*/
|
||||
|
||||
// Essential: Invoke `callback` when style sheet changes associated with
|
||||
// updating the list of active themes have completed.
|
||||
//
|
||||
// * `callback` {Function}
|
||||
onDidChangeActiveThemes (callback) {
|
||||
return this.emitter.on('did-change-active-themes', callback)
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Accessing Available Themes
|
||||
*/
|
||||
|
||||
getAvailableNames () {
|
||||
// TODO: Maybe should change to list all the available themes out there?
|
||||
return this.getLoadedNames()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Accessing Loaded Themes
|
||||
*/
|
||||
|
||||
// Public: Returns an {Array} of {String}s of all the loaded theme names.
|
||||
getLoadedThemeNames () {
|
||||
return this.getLoadedThemes().map((theme) => theme.name)
|
||||
}
|
||||
|
||||
// Public: Returns an {Array} of all the loaded themes.
|
||||
getLoadedThemes () {
|
||||
return this.packageManager.getLoadedPackages().filter((pack) => pack.isTheme())
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Accessing Active Themes
|
||||
*/
|
||||
|
||||
// Public: Returns an {Array} of {String}s of all the active theme names.
|
||||
getActiveThemeNames () {
|
||||
return this.getActiveThemes().map((theme) => theme.name)
|
||||
}
|
||||
|
||||
// Public: Returns an {Array} of all the active themes.
|
||||
getActiveThemes () {
|
||||
return this.packageManager.getActivePackages().filter((pack) => pack.isTheme())
|
||||
}
|
||||
|
||||
activatePackages () {
|
||||
return this.activateThemes()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Managing Enabled Themes
|
||||
*/
|
||||
|
||||
warnForNonExistentThemes () {
|
||||
let themeNames = this.config.get('core.themes') || []
|
||||
if (!_.isArray(themeNames)) { themeNames = [themeNames] }
|
||||
for (let themeName of themeNames) {
|
||||
if (!themeName || (typeof themeName !== 'string') || !this.packageManager.resolvePackagePath(themeName)) {
|
||||
console.warn(`Enabled theme '${themeName}' is not installed.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public: Get the enabled theme names from the config.
|
||||
//
|
||||
// Returns an array of theme names in the order that they should be activated.
|
||||
getEnabledThemeNames () {
|
||||
let themeNames = this.config.get('core.themes') || []
|
||||
if (!_.isArray(themeNames)) { themeNames = [themeNames] }
|
||||
themeNames = themeNames.filter((themeName) =>
|
||||
(typeof themeName === 'string') && this.packageManager.resolvePackagePath(themeName)
|
||||
)
|
||||
|
||||
// Use a built-in syntax and UI theme any time the configured themes are not
|
||||
// available.
|
||||
if (themeNames.length < 2) {
|
||||
const builtInThemeNames = [
|
||||
'atom-dark-syntax',
|
||||
'atom-dark-ui',
|
||||
'atom-light-syntax',
|
||||
'atom-light-ui',
|
||||
'base16-tomorrow-dark-theme',
|
||||
'base16-tomorrow-light-theme',
|
||||
'solarized-dark-syntax',
|
||||
'solarized-light-syntax'
|
||||
]
|
||||
themeNames = _.intersection(themeNames, builtInThemeNames)
|
||||
if (themeNames.length === 0) {
|
||||
themeNames = ['atom-dark-syntax', 'atom-dark-ui']
|
||||
} else if (themeNames.length === 1) {
|
||||
if (_.endsWith(themeNames[0], '-ui')) {
|
||||
themeNames.unshift('atom-dark-syntax')
|
||||
} else {
|
||||
themeNames.push('atom-dark-ui')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so the first (top) theme is loaded after the others. We want
|
||||
// the first/top theme to override later themes in the stack.
|
||||
return themeNames.reverse()
|
||||
}
|
||||
|
||||
/*
|
||||
Section: Private
|
||||
*/
|
||||
|
||||
// Resolve and apply the stylesheet specified by the path.
|
||||
//
|
||||
// This supports both CSS and Less stylesheets.
|
||||
//
|
||||
// * `stylesheetPath` A {String} path to the stylesheet that can be an absolute
|
||||
// path or a relative path that will be resolved against the load path.
|
||||
//
|
||||
// Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
// required stylesheet.
|
||||
requireStylesheet (stylesheetPath, priority, skipDeprecatedSelectorsTransformation) {
|
||||
let fullPath = this.resolveStylesheet(stylesheetPath)
|
||||
if (fullPath) {
|
||||
const content = this.loadStylesheet(fullPath)
|
||||
return this.applyStylesheet(fullPath, content, priority, skipDeprecatedSelectorsTransformation)
|
||||
} else {
|
||||
throw new Error(`Could not find a file at path '${stylesheetPath}'`)
|
||||
}
|
||||
}
|
||||
|
||||
unwatchUserStylesheet () {
|
||||
if (this.userStylesheetSubscriptions != null) this.userStylesheetSubscriptions.dispose()
|
||||
this.userStylesheetSubscriptions = null
|
||||
this.userStylesheetFile = null
|
||||
if (this.userStyleSheetDisposable != null) this.userStyleSheetDisposable.dispose()
|
||||
this.userStyleSheetDisposable = null
|
||||
}
|
||||
|
||||
loadUserStylesheet () {
|
||||
this.unwatchUserStylesheet()
|
||||
|
||||
const userStylesheetPath = this.styleManager.getUserStyleSheetPath()
|
||||
if (!fs.isFileSync(userStylesheetPath)) { return }
|
||||
|
||||
try {
|
||||
this.userStylesheetFile = new File(userStylesheetPath)
|
||||
this.userStylesheetSubscriptions = new CompositeDisposable()
|
||||
const reloadStylesheet = () => this.loadUserStylesheet()
|
||||
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidChange(reloadStylesheet))
|
||||
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidRename(reloadStylesheet))
|
||||
this.userStylesheetSubscriptions.add(this.userStylesheetFile.onDidDelete(reloadStylesheet))
|
||||
} catch (error) {
|
||||
const message = `\
|
||||
Unable to watch path: \`${path.basename(userStylesheetPath)}\`. Make sure
|
||||
you have permissions to \`${userStylesheetPath}\`.
|
||||
|
||||
On linux there are currently problems with watch sizes. See
|
||||
[this document][watches] for more info.
|
||||
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\
|
||||
`
|
||||
this.notificationManager.addError(message, {dismissable: true})
|
||||
}
|
||||
|
||||
let userStylesheetContents
|
||||
try {
|
||||
userStylesheetContents = this.loadStylesheet(userStylesheetPath, true)
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
this.userStyleSheetDisposable = this.styleManager.addStyleSheet(userStylesheetContents, {sourcePath: userStylesheetPath, priority: 2})
|
||||
}
|
||||
|
||||
loadBaseStylesheets () {
|
||||
this.reloadBaseStylesheets()
|
||||
}
|
||||
|
||||
reloadBaseStylesheets () {
|
||||
this.requireStylesheet('../static/atom', -2, true)
|
||||
}
|
||||
|
||||
stylesheetElementForId (id) {
|
||||
const escapedId = id.replace(/\\/g, '\\\\')
|
||||
return document.head.querySelector(`atom-styles style[source-path="${escapedId}"]`)
|
||||
}
|
||||
|
||||
resolveStylesheet (stylesheetPath) {
|
||||
if (path.extname(stylesheetPath).length > 0) {
|
||||
return fs.resolveOnLoadPath(stylesheetPath)
|
||||
} else {
|
||||
return fs.resolveOnLoadPath(stylesheetPath, ['css', 'less'])
|
||||
}
|
||||
}
|
||||
|
||||
loadStylesheet (stylesheetPath, importFallbackVariables) {
|
||||
if (path.extname(stylesheetPath) === '.less') {
|
||||
return this.loadLessStylesheet(stylesheetPath, importFallbackVariables)
|
||||
} else {
|
||||
return fs.readFileSync(stylesheetPath, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
loadLessStylesheet (lessStylesheetPath, importFallbackVariables = false) {
|
||||
if (this.lessCache == null) {
|
||||
this.lessCache = new LessCompileCache({
|
||||
resourcePath: this.resourcePath,
|
||||
lessSourcesByRelativeFilePath: this.lessSourcesByRelativeFilePath,
|
||||
importedFilePathsByRelativeImportPath: this.importedFilePathsByRelativeImportPath,
|
||||
importPaths: this.getImportPaths()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
if (importFallbackVariables) {
|
||||
const baseVarImports = `\
|
||||
@import "variables/ui-variables";
|
||||
@import "variables/syntax-variables";\
|
||||
`
|
||||
const relativeFilePath = path.relative(this.resourcePath, lessStylesheetPath)
|
||||
const lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath]
|
||||
|
||||
let content, digest
|
||||
if (lessSource != null) {
|
||||
({ content } = lessSource);
|
||||
({ digest } = lessSource)
|
||||
} else {
|
||||
content = baseVarImports + '\n' + fs.readFileSync(lessStylesheetPath, 'utf8')
|
||||
digest = null
|
||||
}
|
||||
|
||||
return this.lessCache.cssForFile(lessStylesheetPath, content, digest)
|
||||
} else {
|
||||
return this.lessCache.read(lessStylesheetPath)
|
||||
}
|
||||
} catch (error) {
|
||||
let detail, message
|
||||
error.less = true
|
||||
if (error.line != null) {
|
||||
// Adjust line numbers for import fallbacks
|
||||
if (importFallbackVariables) { error.line -= 2 }
|
||||
|
||||
message = `Error compiling Less stylesheet: \`${lessStylesheetPath}\``
|
||||
detail = `Line number: ${error.line}\n${error.message}`
|
||||
} else {
|
||||
message = `Error loading Less stylesheet: \`${lessStylesheetPath}\``
|
||||
detail = error.message
|
||||
}
|
||||
|
||||
this.notificationManager.addError(message, {detail, dismissable: true})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
removeStylesheet (stylesheetPath) {
|
||||
if (this.styleSheetDisposablesBySourcePath[stylesheetPath] != null) {
|
||||
this.styleSheetDisposablesBySourcePath[stylesheetPath].dispose()
|
||||
}
|
||||
}
|
||||
|
||||
applyStylesheet (path, text, priority, skipDeprecatedSelectorsTransformation) {
|
||||
this.styleSheetDisposablesBySourcePath[path] = this.styleManager.addStyleSheet(
|
||||
text,
|
||||
{
|
||||
priority,
|
||||
skipDeprecatedSelectorsTransformation,
|
||||
sourcePath: path
|
||||
}
|
||||
)
|
||||
|
||||
return this.styleSheetDisposablesBySourcePath[path]
|
||||
}
|
||||
|
||||
activateThemes () {
|
||||
return new Promise(resolve => {
|
||||
// @config.observe runs the callback once, then on subsequent changes.
|
||||
this.config.observe('core.themes', () => {
|
||||
this.deactivateThemes().then(() => {
|
||||
this.warnForNonExistentThemes()
|
||||
this.refreshLessCache() // Update cache for packages in core.themes config
|
||||
|
||||
const promises = []
|
||||
for (const themeName of this.getEnabledThemeNames()) {
|
||||
if (this.packageManager.resolvePackagePath(themeName)) {
|
||||
promises.push(this.packageManager.activatePackage(themeName))
|
||||
} else {
|
||||
console.warn(`Failed to activate theme '${themeName}' because it isn't installed.`)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
this.addActiveThemeClasses()
|
||||
this.refreshLessCache() // Update cache again now that @getActiveThemes() is populated
|
||||
this.loadUserStylesheet()
|
||||
this.reloadBaseStylesheets()
|
||||
this.initialLoadComplete = true
|
||||
this.emitter.emit('did-change-active-themes')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deactivateThemes () {
|
||||
this.removeActiveThemeClasses()
|
||||
this.unwatchUserStylesheet()
|
||||
const results = this.getActiveThemes().map(pack => this.packageManager.deactivatePackage(pack.name))
|
||||
return Promise.all(results.filter((r) => (r != null) && (typeof r.then === 'function')))
|
||||
}
|
||||
|
||||
isInitialLoadComplete () {
|
||||
return this.initialLoadComplete
|
||||
}
|
||||
|
||||
addActiveThemeClasses () {
|
||||
const workspaceElement = this.viewRegistry.getView(this.workspace)
|
||||
if (workspaceElement) {
|
||||
for (const pack of this.getActiveThemes()) {
|
||||
workspaceElement.classList.add(`theme-${pack.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeActiveThemeClasses () {
|
||||
const workspaceElement = this.viewRegistry.getView(this.workspace)
|
||||
for (const pack of this.getActiveThemes()) {
|
||||
workspaceElement.classList.remove(`theme-${pack.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
refreshLessCache () {
|
||||
if (this.lessCache) this.lessCache.setImportPaths(this.getImportPaths())
|
||||
}
|
||||
|
||||
getImportPaths () {
|
||||
let themePaths
|
||||
const activeThemes = this.getActiveThemes()
|
||||
if (activeThemes.length > 0) {
|
||||
themePaths = (activeThemes.filter((theme) => theme).map((theme) => theme.getStylesheetsPath()))
|
||||
} else {
|
||||
themePaths = []
|
||||
for (const themeName of this.getEnabledThemeNames()) {
|
||||
const themePath = this.packageManager.resolvePackagePath(themeName)
|
||||
if (themePath) {
|
||||
const deprecatedPath = path.join(themePath, 'stylesheets')
|
||||
if (fs.isDirectorySync(deprecatedPath)) {
|
||||
themePaths.push(deprecatedPath)
|
||||
} else {
|
||||
themePaths.push(path.join(themePath, 'styles'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return themePaths.filter(themePath => fs.isDirectorySync(themePath))
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
path = require 'path'
|
||||
Package = require './package'
|
||||
|
||||
module.exports =
|
||||
class ThemePackage extends Package
|
||||
getType: -> 'theme'
|
||||
|
||||
getStyleSheetPriority: -> 1
|
||||
|
||||
enable: ->
|
||||
@config.unshiftAtKeyPath('core.themes', @name)
|
||||
|
||||
disable: ->
|
||||
@config.removeAtKeyPath('core.themes', @name)
|
||||
|
||||
preload: ->
|
||||
@loadTime = 0
|
||||
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
|
||||
|
||||
finishLoading: ->
|
||||
@path = path.join(@packageManager.resourcePath, @path)
|
||||
|
||||
load: ->
|
||||
@loadTime = 0
|
||||
@configSchemaRegisteredOnLoad = @registerConfigSchemaFromMetadata()
|
||||
this
|
||||
|
||||
activate: ->
|
||||
@activationPromise ?= new Promise (resolve, reject) =>
|
||||
@resolveActivationPromise = resolve
|
||||
@rejectActivationPromise = reject
|
||||
@measure 'activateTime', =>
|
||||
try
|
||||
@loadStylesheets()
|
||||
@activateNow()
|
||||
catch error
|
||||
@handleError("Failed to activate the #{@name} theme", error)
|
||||
55
src/theme-package.js
Normal file
55
src/theme-package.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const path = require('path')
|
||||
const Package = require('./package')
|
||||
|
||||
module.exports =
|
||||
class ThemePackage extends Package {
|
||||
getType () {
|
||||
return 'theme'
|
||||
}
|
||||
|
||||
getStyleSheetPriority () {
|
||||
return 1
|
||||
}
|
||||
|
||||
enable () {
|
||||
this.config.unshiftAtKeyPath('core.themes', this.name)
|
||||
}
|
||||
|
||||
disable () {
|
||||
this.config.removeAtKeyPath('core.themes', this.name)
|
||||
}
|
||||
|
||||
preload () {
|
||||
this.loadTime = 0
|
||||
this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata()
|
||||
}
|
||||
|
||||
finishLoading () {
|
||||
this.path = path.join(this.packageManager.resourcePath, this.path)
|
||||
}
|
||||
|
||||
load () {
|
||||
this.loadTime = 0
|
||||
this.configSchemaRegisteredOnLoad = this.registerConfigSchemaFromMetadata()
|
||||
return this
|
||||
}
|
||||
|
||||
activate () {
|
||||
if (this.activationPromise == null) {
|
||||
this.activationPromise = new Promise((resolve, reject) => {
|
||||
this.resolveActivationPromise = resolve
|
||||
this.rejectActivationPromise = reject
|
||||
this.measure('activateTime', () => {
|
||||
try {
|
||||
this.loadStylesheets()
|
||||
this.activateNow()
|
||||
} catch (error) {
|
||||
this.handleError(`Failed to activate the ${this.name} theme`, error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return this.activationPromise
|
||||
}
|
||||
}
|
||||
@@ -163,99 +163,12 @@ class TokenizedBuffer {
|
||||
Section - Comments
|
||||
*/
|
||||
|
||||
toggleLineCommentsForBufferRows (start, end) {
|
||||
const scope = this.scopeDescriptorForPosition([start, 0])
|
||||
const commentStrings = this.commentStringsForScopeDescriptor(scope)
|
||||
if (!commentStrings) return
|
||||
const {commentStartString, commentEndString} = commentStrings
|
||||
if (!commentStartString) return
|
||||
|
||||
const commentStartRegexString = _.escapeRegExp(commentStartString).replace(/(\s+)$/, '(?:$1)?')
|
||||
const commentStartRegex = new OnigRegExp(`^(\\s*)(${commentStartRegexString})`)
|
||||
|
||||
if (commentEndString) {
|
||||
const shouldUncomment = commentStartRegex.testSync(this.buffer.lineForRow(start))
|
||||
if (shouldUncomment) {
|
||||
const commentEndRegexString = _.escapeRegExp(commentEndString).replace(/^(\s+)/, '(?:$1)?')
|
||||
const commentEndRegex = new OnigRegExp(`(${commentEndRegexString})(\\s*)$`)
|
||||
const startMatch = commentStartRegex.searchSync(this.buffer.lineForRow(start))
|
||||
const endMatch = commentEndRegex.searchSync(this.buffer.lineForRow(end))
|
||||
if (startMatch && endMatch) {
|
||||
this.buffer.transact(() => {
|
||||
const columnStart = startMatch[1].length
|
||||
const columnEnd = columnStart + startMatch[2].length
|
||||
this.buffer.setTextInRange([[start, columnStart], [start, columnEnd]], '')
|
||||
|
||||
const endLength = this.buffer.lineLengthForRow(end) - endMatch[2].length
|
||||
const endColumn = endLength - endMatch[1].length
|
||||
return this.buffer.setTextInRange([[end, endColumn], [end, endLength]], '')
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.buffer.transact(() => {
|
||||
const indentLength = this.buffer.lineForRow(start).match(/^\s*/)[0].length
|
||||
this.buffer.insert([start, indentLength], commentStartString)
|
||||
this.buffer.insert([end, this.buffer.lineLengthForRow(end)], commentEndString)
|
||||
})
|
||||
}
|
||||
commentStringsForPosition (position) {
|
||||
if (this.scopedSettingsDelegate) {
|
||||
const scope = this.scopeDescriptorForPosition(position)
|
||||
return this.scopedSettingsDelegate.getCommentStrings(scope)
|
||||
} else {
|
||||
let hasCommentedLines = false
|
||||
let hasUncommentedLines = false
|
||||
for (let row = start; row <= end; row++) {
|
||||
const line = this.buffer.lineForRow(row)
|
||||
if (NON_WHITESPACE_REGEX.test(line)) {
|
||||
if (commentStartRegex.testSync(line)) {
|
||||
hasCommentedLines = true
|
||||
} else {
|
||||
hasUncommentedLines = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldUncomment = hasCommentedLines && !hasUncommentedLines
|
||||
|
||||
if (shouldUncomment) {
|
||||
for (let row = start; row <= end; row++) {
|
||||
const match = commentStartRegex.searchSync(this.buffer.lineForRow(row))
|
||||
if (match) {
|
||||
const columnStart = match[1].length
|
||||
const columnEnd = columnStart + match[2].length
|
||||
this.buffer.setTextInRange([[row, columnStart], [row, columnEnd]], '')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let minIndentLevel = Infinity
|
||||
let minBlankIndentLevel = Infinity
|
||||
for (let row = start; row <= end; row++) {
|
||||
const line = this.buffer.lineForRow(row)
|
||||
const indentLevel = this.indentLevelForLine(line)
|
||||
if (NON_WHITESPACE_REGEX.test(line)) {
|
||||
if (indentLevel < minIndentLevel) minIndentLevel = indentLevel
|
||||
} else {
|
||||
if (indentLevel < minBlankIndentLevel) minBlankIndentLevel = indentLevel
|
||||
}
|
||||
}
|
||||
minIndentLevel = Number.isFinite(minIndentLevel)
|
||||
? minIndentLevel
|
||||
: Number.isFinite(minBlankIndentLevel)
|
||||
? minBlankIndentLevel
|
||||
: 0
|
||||
|
||||
const tabLength = this.getTabLength()
|
||||
const indentString = ' '.repeat(tabLength * minIndentLevel)
|
||||
for (let row = start; row <= end; row++) {
|
||||
const line = this.buffer.lineForRow(row)
|
||||
if (NON_WHITESPACE_REGEX.test(line)) {
|
||||
const indentColumn = this.columnForIndentLevel(line, minIndentLevel)
|
||||
this.buffer.insert(Point(row, indentColumn), commentStartString)
|
||||
} else {
|
||||
this.buffer.setTextInRange(
|
||||
new Range(new Point(row, 0), new Point(row, Infinity)),
|
||||
indentString + commentStartString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,24 +507,6 @@ class TokenizedBuffer {
|
||||
return scopes
|
||||
}
|
||||
|
||||
columnForIndentLevel (line, indentLevel, tabLength = this.tabLength) {
|
||||
let column = 0
|
||||
let indentLength = 0
|
||||
const goalIndentLength = indentLevel * tabLength
|
||||
while (indentLength < goalIndentLength) {
|
||||
const char = line[column]
|
||||
if (char === '\t') {
|
||||
indentLength += tabLength - (indentLength % tabLength)
|
||||
} else if (char === ' ') {
|
||||
indentLength++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
column++
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
indentLevelForLine (line, tabLength = this.tabLength) {
|
||||
let indentLength = 0
|
||||
for (let i = 0, {length} = line; i < length; i++) {
|
||||
@@ -841,12 +736,6 @@ class TokenizedBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
commentStringsForScopeDescriptor (scopes) {
|
||||
if (this.scopedSettingsDelegate) {
|
||||
return this.scopedSettingsDelegate.getCommentStrings(scopes)
|
||||
}
|
||||
}
|
||||
|
||||
regexForPattern (pattern) {
|
||||
if (pattern) {
|
||||
if (!this.regexesByPattern[pattern]) {
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
_ = require 'underscore-plus'
|
||||
{Disposable, CompositeDisposable} = require 'event-kit'
|
||||
Tooltip = null
|
||||
|
||||
# Essential: Associates tooltips with HTML elements.
|
||||
#
|
||||
# You can get the `TooltipManager` via `atom.tooltips`.
|
||||
#
|
||||
# ## Examples
|
||||
#
|
||||
# The essence of displaying a tooltip
|
||||
#
|
||||
# ```coffee
|
||||
# # display it
|
||||
# disposable = atom.tooltips.add(div, {title: 'This is a tooltip'})
|
||||
#
|
||||
# # remove it
|
||||
# disposable.dispose()
|
||||
# ```
|
||||
#
|
||||
# In practice there are usually multiple tooltips. So we add them to a
|
||||
# CompositeDisposable
|
||||
#
|
||||
# ```coffee
|
||||
# {CompositeDisposable} = require 'atom'
|
||||
# subscriptions = new CompositeDisposable
|
||||
#
|
||||
# div1 = document.createElement('div')
|
||||
# div2 = document.createElement('div')
|
||||
# subscriptions.add atom.tooltips.add(div1, {title: 'This is a tooltip'})
|
||||
# subscriptions.add atom.tooltips.add(div2, {title: 'Another tooltip'})
|
||||
#
|
||||
# # remove them all
|
||||
# subscriptions.dispose()
|
||||
# ```
|
||||
#
|
||||
# You can display a key binding in the tooltip as well with the
|
||||
# `keyBindingCommand` option.
|
||||
#
|
||||
# ```coffee
|
||||
# disposable = atom.tooltips.add @caseOptionButton,
|
||||
# title: "Match Case"
|
||||
# keyBindingCommand: 'find-and-replace:toggle-case-option'
|
||||
# keyBindingTarget: @findEditor.element
|
||||
# ```
|
||||
module.exports =
|
||||
class TooltipManager
|
||||
defaults:
|
||||
trigger: 'hover'
|
||||
container: 'body'
|
||||
html: true
|
||||
placement: 'auto top'
|
||||
viewportPadding: 2
|
||||
|
||||
hoverDefaults:
|
||||
{delay: {show: 1000, hide: 100}}
|
||||
|
||||
constructor: ({@keymapManager, @viewRegistry}) ->
|
||||
@tooltips = new Map()
|
||||
|
||||
# Essential: Add a tooltip to the given element.
|
||||
#
|
||||
# * `target` An `HTMLElement`
|
||||
# * `options` An object with one or more of the following options:
|
||||
# * `title` A {String} or {Function} to use for the text in the tip. If
|
||||
# a function is passed, `this` will be set to the `target` element. This
|
||||
# option is mutually exclusive with the `item` option.
|
||||
# * `html` A {Boolean} affecting the interpretation of the `title` option.
|
||||
# If `true` (the default), the `title` string will be interpreted as HTML.
|
||||
# Otherwise it will be interpreted as plain text.
|
||||
# * `item` A view (object with an `.element` property) or a DOM element
|
||||
# containing custom content for the tooltip. This option is mutually
|
||||
# exclusive with the `title` option.
|
||||
# * `class` A {String} with a class to apply to the tooltip element to
|
||||
# enable custom styling.
|
||||
# * `placement` A {String} or {Function} returning a string to indicate
|
||||
# the position of the tooltip relative to `element`. Can be `'top'`,
|
||||
# `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is
|
||||
# specified, it will dynamically reorient the tooltip. For example, if
|
||||
# placement is `'auto left'`, the tooltip will display to the left when
|
||||
# possible, otherwise it will display right.
|
||||
# When a function is used to determine the placement, it is called with
|
||||
# the tooltip DOM node as its first argument and the triggering element
|
||||
# DOM node as its second. The `this` context is set to the tooltip
|
||||
# instance.
|
||||
# * `trigger` A {String} indicating how the tooltip should be displayed.
|
||||
# Choose from one of the following options:
|
||||
# * `'hover'` Show the tooltip when the mouse hovers over the element.
|
||||
# This is the default.
|
||||
# * `'click'` Show the tooltip when the element is clicked. The tooltip
|
||||
# will be hidden after clicking the element again or anywhere else
|
||||
# outside of the tooltip itself.
|
||||
# * `'focus'` Show the tooltip when the element is focused.
|
||||
# * `'manual'` Show the tooltip immediately and only hide it when the
|
||||
# returned disposable is disposed.
|
||||
# * `delay` An object specifying the show and hide delay in milliseconds.
|
||||
# Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and
|
||||
# otherwise defaults to `0` for both values.
|
||||
# * `keyBindingCommand` A {String} containing a command name. If you specify
|
||||
# this option and a key binding exists that matches the command, it will
|
||||
# be appended to the title or rendered alone if no title is specified.
|
||||
# * `keyBindingTarget` An `HTMLElement` on which to look up the key binding.
|
||||
# If this option is not supplied, the first of all matching key bindings
|
||||
# for the given command will be rendered.
|
||||
#
|
||||
# Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
# tooltip.
|
||||
add: (target, options) ->
|
||||
if target.jquery
|
||||
disposable = new CompositeDisposable
|
||||
disposable.add @add(element, options) for element in target
|
||||
return disposable
|
||||
|
||||
Tooltip ?= require './tooltip'
|
||||
|
||||
{keyBindingCommand, keyBindingTarget} = options
|
||||
|
||||
if keyBindingCommand?
|
||||
bindings = @keymapManager.findKeyBindings(command: keyBindingCommand, target: keyBindingTarget)
|
||||
keystroke = getKeystroke(bindings)
|
||||
if options.title? and keystroke?
|
||||
options.title += " " + getKeystroke(bindings)
|
||||
else if keystroke?
|
||||
options.title = getKeystroke(bindings)
|
||||
|
||||
delete options.selector
|
||||
options = _.defaults(options, @defaults)
|
||||
if options.trigger is 'hover'
|
||||
options = _.defaults(options, @hoverDefaults)
|
||||
|
||||
tooltip = new Tooltip(target, options, @viewRegistry)
|
||||
|
||||
if not @tooltips.has(target)
|
||||
@tooltips.set(target, [])
|
||||
@tooltips.get(target).push(tooltip)
|
||||
|
||||
hideTooltip = ->
|
||||
tooltip.leave(currentTarget: target)
|
||||
tooltip.hide()
|
||||
|
||||
window.addEventListener('resize', hideTooltip)
|
||||
|
||||
disposable = new Disposable =>
|
||||
window.removeEventListener('resize', hideTooltip)
|
||||
hideTooltip()
|
||||
tooltip.destroy()
|
||||
|
||||
if @tooltips.has(target)
|
||||
tooltipsForTarget = @tooltips.get(target)
|
||||
index = tooltipsForTarget.indexOf(tooltip)
|
||||
if index isnt -1
|
||||
tooltipsForTarget.splice(index, 1)
|
||||
if tooltipsForTarget.length is 0
|
||||
@tooltips.delete(target)
|
||||
|
||||
disposable
|
||||
|
||||
# Extended: Find the tooltips that have been applied to the given element.
|
||||
#
|
||||
# * `target` The `HTMLElement` to find tooltips on.
|
||||
#
|
||||
# Returns an {Array} of `Tooltip` objects that match the `target`.
|
||||
findTooltips: (target) ->
|
||||
if @tooltips.has(target)
|
||||
@tooltips.get(target).slice()
|
||||
else
|
||||
[]
|
||||
|
||||
humanizeKeystrokes = (keystroke) ->
|
||||
keystrokes = keystroke.split(' ')
|
||||
keystrokes = (_.humanizeKeystroke(stroke) for stroke in keystrokes)
|
||||
keystrokes.join(' ')
|
||||
|
||||
getKeystroke = (bindings) ->
|
||||
if bindings?.length
|
||||
"<span class=\"keystroke\">#{humanizeKeystrokes(bindings[0].keystrokes)}</span>"
|
||||
199
src/tooltip-manager.js
Normal file
199
src/tooltip-manager.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const _ = require('underscore-plus')
|
||||
const {Disposable, CompositeDisposable} = require('event-kit')
|
||||
let Tooltip = null
|
||||
|
||||
// Essential: Associates tooltips with HTML elements.
|
||||
//
|
||||
// You can get the `TooltipManager` via `atom.tooltips`.
|
||||
//
|
||||
// ## Examples
|
||||
//
|
||||
// The essence of displaying a tooltip
|
||||
//
|
||||
// ```javascript
|
||||
// // display it
|
||||
// const disposable = atom.tooltips.add(div, {title: 'This is a tooltip'})
|
||||
//
|
||||
// // remove it
|
||||
// disposable.dispose()
|
||||
// ```
|
||||
//
|
||||
// In practice there are usually multiple tooltips. So we add them to a
|
||||
// CompositeDisposable
|
||||
//
|
||||
// ```javascript
|
||||
// const {CompositeDisposable} = require('atom')
|
||||
// const subscriptions = new CompositeDisposable()
|
||||
//
|
||||
// const div1 = document.createElement('div')
|
||||
// const div2 = document.createElement('div')
|
||||
// subscriptions.add(atom.tooltips.add(div1, {title: 'This is a tooltip'}))
|
||||
// subscriptions.add(atom.tooltips.add(div2, {title: 'Another tooltip'}))
|
||||
//
|
||||
// // remove them all
|
||||
// subscriptions.dispose()
|
||||
// ```
|
||||
//
|
||||
// You can display a key binding in the tooltip as well with the
|
||||
// `keyBindingCommand` option.
|
||||
//
|
||||
// ```javascript
|
||||
// disposable = atom.tooltips.add(this.caseOptionButton, {
|
||||
// title: 'Match Case',
|
||||
// keyBindingCommand: 'find-and-replace:toggle-case-option',
|
||||
// keyBindingTarget: this.findEditor.element
|
||||
// })
|
||||
// ```
|
||||
module.exports =
|
||||
class TooltipManager {
|
||||
constructor ({keymapManager, viewRegistry}) {
|
||||
this.defaults = {
|
||||
trigger: 'hover',
|
||||
container: 'body',
|
||||
html: true,
|
||||
placement: 'auto top',
|
||||
viewportPadding: 2
|
||||
}
|
||||
|
||||
this.hoverDefaults = {
|
||||
delay: {show: 1000, hide: 100}
|
||||
}
|
||||
|
||||
this.keymapManager = keymapManager
|
||||
this.viewRegistry = viewRegistry
|
||||
this.tooltips = new Map()
|
||||
}
|
||||
|
||||
// Essential: Add a tooltip to the given element.
|
||||
//
|
||||
// * `target` An `HTMLElement`
|
||||
// * `options` An object with one or more of the following options:
|
||||
// * `title` A {String} or {Function} to use for the text in the tip. If
|
||||
// a function is passed, `this` will be set to the `target` element. This
|
||||
// option is mutually exclusive with the `item` option.
|
||||
// * `html` A {Boolean} affecting the interpretation of the `title` option.
|
||||
// If `true` (the default), the `title` string will be interpreted as HTML.
|
||||
// Otherwise it will be interpreted as plain text.
|
||||
// * `item` A view (object with an `.element` property) or a DOM element
|
||||
// containing custom content for the tooltip. This option is mutually
|
||||
// exclusive with the `title` option.
|
||||
// * `class` A {String} with a class to apply to the tooltip element to
|
||||
// enable custom styling.
|
||||
// * `placement` A {String} or {Function} returning a string to indicate
|
||||
// the position of the tooltip relative to `element`. Can be `'top'`,
|
||||
// `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is
|
||||
// specified, it will dynamically reorient the tooltip. For example, if
|
||||
// placement is `'auto left'`, the tooltip will display to the left when
|
||||
// possible, otherwise it will display right.
|
||||
// When a function is used to determine the placement, it is called with
|
||||
// the tooltip DOM node as its first argument and the triggering element
|
||||
// DOM node as its second. The `this` context is set to the tooltip
|
||||
// instance.
|
||||
// * `trigger` A {String} indicating how the tooltip should be displayed.
|
||||
// Choose from one of the following options:
|
||||
// * `'hover'` Show the tooltip when the mouse hovers over the element.
|
||||
// This is the default.
|
||||
// * `'click'` Show the tooltip when the element is clicked. The tooltip
|
||||
// will be hidden after clicking the element again or anywhere else
|
||||
// outside of the tooltip itself.
|
||||
// * `'focus'` Show the tooltip when the element is focused.
|
||||
// * `'manual'` Show the tooltip immediately and only hide it when the
|
||||
// returned disposable is disposed.
|
||||
// * `delay` An object specifying the show and hide delay in milliseconds.
|
||||
// Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and
|
||||
// otherwise defaults to `0` for both values.
|
||||
// * `keyBindingCommand` A {String} containing a command name. If you specify
|
||||
// this option and a key binding exists that matches the command, it will
|
||||
// be appended to the title or rendered alone if no title is specified.
|
||||
// * `keyBindingTarget` An `HTMLElement` on which to look up the key binding.
|
||||
// If this option is not supplied, the first of all matching key bindings
|
||||
// for the given command will be rendered.
|
||||
//
|
||||
// Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
// tooltip.
|
||||
add (target, options) {
|
||||
if (target.jquery) {
|
||||
const disposable = new CompositeDisposable()
|
||||
for (const element of target) { disposable.add(this.add(element, options)) }
|
||||
return disposable
|
||||
}
|
||||
|
||||
if (Tooltip == null) { Tooltip = require('./tooltip') }
|
||||
|
||||
const {keyBindingCommand, keyBindingTarget} = options
|
||||
|
||||
if (keyBindingCommand != null) {
|
||||
const bindings = this.keymapManager.findKeyBindings({command: keyBindingCommand, target: keyBindingTarget})
|
||||
const keystroke = getKeystroke(bindings)
|
||||
if ((options.title != null) && (keystroke != null)) {
|
||||
options.title += ` ${getKeystroke(bindings)}`
|
||||
} else if (keystroke != null) {
|
||||
options.title = getKeystroke(bindings)
|
||||
}
|
||||
}
|
||||
|
||||
delete options.selector
|
||||
options = _.defaults(options, this.defaults)
|
||||
if (options.trigger === 'hover') {
|
||||
options = _.defaults(options, this.hoverDefaults)
|
||||
}
|
||||
|
||||
const tooltip = new Tooltip(target, options, this.viewRegistry)
|
||||
|
||||
if (!this.tooltips.has(target)) {
|
||||
this.tooltips.set(target, [])
|
||||
}
|
||||
this.tooltips.get(target).push(tooltip)
|
||||
|
||||
const hideTooltip = function () {
|
||||
tooltip.leave({currentTarget: target})
|
||||
tooltip.hide()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', hideTooltip)
|
||||
|
||||
const disposable = new Disposable(() => {
|
||||
window.removeEventListener('resize', hideTooltip)
|
||||
hideTooltip()
|
||||
tooltip.destroy()
|
||||
|
||||
if (this.tooltips.has(target)) {
|
||||
const tooltipsForTarget = this.tooltips.get(target)
|
||||
const index = tooltipsForTarget.indexOf(tooltip)
|
||||
if (index !== -1) {
|
||||
tooltipsForTarget.splice(index, 1)
|
||||
}
|
||||
if (tooltipsForTarget.length === 0) {
|
||||
this.tooltips.delete(target)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return disposable
|
||||
}
|
||||
|
||||
// Extended: Find the tooltips that have been applied to the given element.
|
||||
//
|
||||
// * `target` The `HTMLElement` to find tooltips on.
|
||||
//
|
||||
// Returns an {Array} of `Tooltip` objects that match the `target`.
|
||||
findTooltips (target) {
|
||||
if (this.tooltips.has(target)) {
|
||||
return this.tooltips.get(target).slice()
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function humanizeKeystrokes (keystroke) {
|
||||
let keystrokes = keystroke.split(' ')
|
||||
keystrokes = (keystrokes.map((stroke) => _.humanizeKeystroke(stroke)))
|
||||
return keystrokes.join(' ')
|
||||
}
|
||||
|
||||
function getKeystroke (bindings) {
|
||||
if (bindings && bindings.length) {
|
||||
return `<span class="keystroke">${humanizeKeystrokes(bindings[0].keystrokes)}</span>`
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
Grim = require 'grim'
|
||||
{Disposable} = require 'event-kit'
|
||||
_ = require 'underscore-plus'
|
||||
|
||||
AnyConstructor = Symbol('any-constructor')
|
||||
|
||||
# Essential: `ViewRegistry` handles the association between model and view
|
||||
# types in Atom. We call this association a View Provider. As in, for a given
|
||||
# model, this class can provide a view via {::getView}, as long as the
|
||||
# model/view association was registered via {::addViewProvider}
|
||||
#
|
||||
# If you're adding your own kind of pane item, a good strategy for all but the
|
||||
# simplest items is to separate the model and the view. The model handles
|
||||
# application logic and is the primary point of API interaction. The view
|
||||
# just handles presentation.
|
||||
#
|
||||
# Note: Models can be any object, but must implement a `getTitle()` function
|
||||
# if they are to be displayed in a {Pane}
|
||||
#
|
||||
# View providers inform the workspace how your model objects should be
|
||||
# presented in the DOM. A view provider must always return a DOM node, which
|
||||
# makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/)
|
||||
# an ideal tool for implementing views in Atom.
|
||||
#
|
||||
# You can access the `ViewRegistry` object via `atom.views`.
|
||||
module.exports =
|
||||
class ViewRegistry
|
||||
animationFrameRequest: null
|
||||
documentReadInProgress: false
|
||||
|
||||
constructor: (@atomEnvironment) ->
|
||||
@clear()
|
||||
|
||||
clear: ->
|
||||
@views = new WeakMap
|
||||
@providers = []
|
||||
@clearDocumentRequests()
|
||||
|
||||
# Essential: Add a provider that will be used to construct views in the
|
||||
# workspace's view layer based on model objects in its model layer.
|
||||
#
|
||||
# ## Examples
|
||||
#
|
||||
# Text editors are divided into a model and a view layer, so when you interact
|
||||
# with methods like `atom.workspace.getActiveTextEditor()` you're only going
|
||||
# to get the model object. We display text editors on screen by teaching the
|
||||
# workspace what view constructor it should use to represent them:
|
||||
#
|
||||
# ```coffee
|
||||
# atom.views.addViewProvider TextEditor, (textEditor) ->
|
||||
# textEditorElement = new TextEditorElement
|
||||
# textEditorElement.initialize(textEditor)
|
||||
# textEditorElement
|
||||
# ```
|
||||
#
|
||||
# * `modelConstructor` (optional) Constructor {Function} for your model. If
|
||||
# a constructor is given, the `createView` function will only be used
|
||||
# for model objects inheriting from that constructor. Otherwise, it will
|
||||
# will be called for any object.
|
||||
# * `createView` Factory {Function} that is passed an instance of your model
|
||||
# and must return a subclass of `HTMLElement` or `undefined`. If it returns
|
||||
# `undefined`, then the registry will continue to search for other view
|
||||
# providers.
|
||||
#
|
||||
# Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
# added provider.
|
||||
addViewProvider: (modelConstructor, createView) ->
|
||||
if arguments.length is 1
|
||||
switch typeof modelConstructor
|
||||
when 'function'
|
||||
provider = {createView: modelConstructor, modelConstructor: AnyConstructor}
|
||||
when 'object'
|
||||
Grim.deprecate("atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.")
|
||||
provider = modelConstructor
|
||||
else
|
||||
throw new TypeError("Arguments to addViewProvider must be functions")
|
||||
else
|
||||
provider = {modelConstructor, createView}
|
||||
|
||||
@providers.push(provider)
|
||||
new Disposable =>
|
||||
@providers = @providers.filter (p) -> p isnt provider
|
||||
|
||||
getViewProviderCount: ->
|
||||
@providers.length
|
||||
|
||||
# Essential: Get the view associated with an object in the workspace.
|
||||
#
|
||||
# If you're just *using* the workspace, you shouldn't need to access the view
|
||||
# layer, but view layer access may be necessary if you want to perform DOM
|
||||
# manipulation that isn't supported via the model API.
|
||||
#
|
||||
# ## View Resolution Algorithm
|
||||
#
|
||||
# The view associated with the object is resolved using the following
|
||||
# sequence
|
||||
#
|
||||
# 1. Is the object an instance of `HTMLElement`? If true, return the object.
|
||||
# 2. Does the object have a method named `getElement` that returns an
|
||||
# instance of `HTMLElement`? If true, return that value.
|
||||
# 3. Does the object have a property named `element` with a value which is
|
||||
# an instance of `HTMLElement`? If true, return the property value.
|
||||
# 4. Is the object a jQuery object, indicated by the presence of a `jquery`
|
||||
# property? If true, return the root DOM element (i.e. `object[0]`).
|
||||
# 5. Has a view provider been registered for the object? If true, use the
|
||||
# provider to create a view associated with the object, and return the
|
||||
# view.
|
||||
#
|
||||
# If no associated view is returned by the sequence an error is thrown.
|
||||
#
|
||||
# Returns a DOM element.
|
||||
getView: (object) ->
|
||||
return unless object?
|
||||
|
||||
if view = @views.get(object)
|
||||
view
|
||||
else
|
||||
view = @createView(object)
|
||||
@views.set(object, view)
|
||||
view
|
||||
|
||||
createView: (object) ->
|
||||
if object instanceof HTMLElement
|
||||
return object
|
||||
|
||||
if typeof object?.getElement is 'function'
|
||||
element = object.getElement()
|
||||
if element instanceof HTMLElement
|
||||
return element
|
||||
|
||||
if object?.element instanceof HTMLElement
|
||||
return object.element
|
||||
|
||||
if object?.jquery
|
||||
return object[0]
|
||||
|
||||
for provider in @providers
|
||||
if provider.modelConstructor is AnyConstructor
|
||||
if element = provider.createView(object, @atomEnvironment)
|
||||
return element
|
||||
continue
|
||||
|
||||
if object instanceof provider.modelConstructor
|
||||
if element = provider.createView?(object, @atomEnvironment)
|
||||
return element
|
||||
|
||||
if viewConstructor = provider.viewConstructor
|
||||
element = new viewConstructor
|
||||
element.initialize?(object) ? element.setModel?(object)
|
||||
return element
|
||||
|
||||
if viewConstructor = object?.getViewClass?()
|
||||
view = new viewConstructor(object)
|
||||
return view[0]
|
||||
|
||||
throw new Error("Can't create a view for #{object.constructor.name} instance. Please register a view provider.")
|
||||
|
||||
updateDocument: (fn) ->
|
||||
@documentWriters.push(fn)
|
||||
@requestDocumentUpdate() unless @documentReadInProgress
|
||||
new Disposable =>
|
||||
@documentWriters = @documentWriters.filter (writer) -> writer isnt fn
|
||||
|
||||
readDocument: (fn) ->
|
||||
@documentReaders.push(fn)
|
||||
@requestDocumentUpdate()
|
||||
new Disposable =>
|
||||
@documentReaders = @documentReaders.filter (reader) -> reader isnt fn
|
||||
|
||||
getNextUpdatePromise: ->
|
||||
@nextUpdatePromise ?= new Promise (resolve) =>
|
||||
@resolveNextUpdatePromise = resolve
|
||||
|
||||
clearDocumentRequests: ->
|
||||
@documentReaders = []
|
||||
@documentWriters = []
|
||||
@nextUpdatePromise = null
|
||||
@resolveNextUpdatePromise = null
|
||||
if @animationFrameRequest?
|
||||
cancelAnimationFrame(@animationFrameRequest)
|
||||
@animationFrameRequest = null
|
||||
|
||||
requestDocumentUpdate: ->
|
||||
@animationFrameRequest ?= requestAnimationFrame(@performDocumentUpdate)
|
||||
|
||||
performDocumentUpdate: =>
|
||||
resolveNextUpdatePromise = @resolveNextUpdatePromise
|
||||
@animationFrameRequest = null
|
||||
@nextUpdatePromise = null
|
||||
@resolveNextUpdatePromise = null
|
||||
|
||||
writer() while writer = @documentWriters.shift()
|
||||
|
||||
@documentReadInProgress = true
|
||||
reader() while reader = @documentReaders.shift()
|
||||
@documentReadInProgress = false
|
||||
|
||||
# process updates requested as a result of reads
|
||||
writer() while writer = @documentWriters.shift()
|
||||
|
||||
resolveNextUpdatePromise?()
|
||||
259
src/view-registry.js
Normal file
259
src/view-registry.js
Normal file
@@ -0,0 +1,259 @@
|
||||
const Grim = require('grim')
|
||||
const {Disposable} = require('event-kit')
|
||||
|
||||
const AnyConstructor = Symbol('any-constructor')
|
||||
|
||||
// Essential: `ViewRegistry` handles the association between model and view
|
||||
// types in Atom. We call this association a View Provider. As in, for a given
|
||||
// model, this class can provide a view via {::getView}, as long as the
|
||||
// model/view association was registered via {::addViewProvider}
|
||||
//
|
||||
// If you're adding your own kind of pane item, a good strategy for all but the
|
||||
// simplest items is to separate the model and the view. The model handles
|
||||
// application logic and is the primary point of API interaction. The view
|
||||
// just handles presentation.
|
||||
//
|
||||
// Note: Models can be any object, but must implement a `getTitle()` function
|
||||
// if they are to be displayed in a {Pane}
|
||||
//
|
||||
// View providers inform the workspace how your model objects should be
|
||||
// presented in the DOM. A view provider must always return a DOM node, which
|
||||
// makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/)
|
||||
// an ideal tool for implementing views in Atom.
|
||||
//
|
||||
// You can access the `ViewRegistry` object via `atom.views`.
|
||||
module.exports =
|
||||
class ViewRegistry {
|
||||
constructor (atomEnvironment) {
|
||||
this.animationFrameRequest = null
|
||||
this.documentReadInProgress = false
|
||||
this.performDocumentUpdate = this.performDocumentUpdate.bind(this)
|
||||
this.atomEnvironment = atomEnvironment
|
||||
this.clear()
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.views = new WeakMap()
|
||||
this.providers = []
|
||||
this.clearDocumentRequests()
|
||||
}
|
||||
|
||||
// Essential: Add a provider that will be used to construct views in the
|
||||
// workspace's view layer based on model objects in its model layer.
|
||||
//
|
||||
// ## Examples
|
||||
//
|
||||
// Text editors are divided into a model and a view layer, so when you interact
|
||||
// with methods like `atom.workspace.getActiveTextEditor()` you're only going
|
||||
// to get the model object. We display text editors on screen by teaching the
|
||||
// workspace what view constructor it should use to represent them:
|
||||
//
|
||||
// ```coffee
|
||||
// atom.views.addViewProvider TextEditor, (textEditor) ->
|
||||
// textEditorElement = new TextEditorElement
|
||||
// textEditorElement.initialize(textEditor)
|
||||
// textEditorElement
|
||||
// ```
|
||||
//
|
||||
// * `modelConstructor` (optional) Constructor {Function} for your model. If
|
||||
// a constructor is given, the `createView` function will only be used
|
||||
// for model objects inheriting from that constructor. Otherwise, it will
|
||||
// will be called for any object.
|
||||
// * `createView` Factory {Function} that is passed an instance of your model
|
||||
// and must return a subclass of `HTMLElement` or `undefined`. If it returns
|
||||
// `undefined`, then the registry will continue to search for other view
|
||||
// providers.
|
||||
//
|
||||
// Returns a {Disposable} on which `.dispose()` can be called to remove the
|
||||
// added provider.
|
||||
addViewProvider (modelConstructor, createView) {
|
||||
let provider
|
||||
if (arguments.length === 1) {
|
||||
switch (typeof modelConstructor) {
|
||||
case 'function':
|
||||
provider = {createView: modelConstructor, modelConstructor: AnyConstructor}
|
||||
break
|
||||
case 'object':
|
||||
Grim.deprecate('atom.views.addViewProvider now takes 2 arguments: a model constructor and a createView function. See docs for details.')
|
||||
provider = modelConstructor
|
||||
break
|
||||
default:
|
||||
throw new TypeError('Arguments to addViewProvider must be functions')
|
||||
}
|
||||
} else {
|
||||
provider = {modelConstructor, createView}
|
||||
}
|
||||
|
||||
this.providers.push(provider)
|
||||
return new Disposable(() => {
|
||||
this.providers = this.providers.filter(p => p !== provider)
|
||||
})
|
||||
}
|
||||
|
||||
getViewProviderCount () {
|
||||
return this.providers.length
|
||||
}
|
||||
|
||||
// Essential: Get the view associated with an object in the workspace.
|
||||
//
|
||||
// If you're just *using* the workspace, you shouldn't need to access the view
|
||||
// layer, but view layer access may be necessary if you want to perform DOM
|
||||
// manipulation that isn't supported via the model API.
|
||||
//
|
||||
// ## View Resolution Algorithm
|
||||
//
|
||||
// The view associated with the object is resolved using the following
|
||||
// sequence
|
||||
//
|
||||
// 1. Is the object an instance of `HTMLElement`? If true, return the object.
|
||||
// 2. Does the object have a method named `getElement` that returns an
|
||||
// instance of `HTMLElement`? If true, return that value.
|
||||
// 3. Does the object have a property named `element` with a value which is
|
||||
// an instance of `HTMLElement`? If true, return the property value.
|
||||
// 4. Is the object a jQuery object, indicated by the presence of a `jquery`
|
||||
// property? If true, return the root DOM element (i.e. `object[0]`).
|
||||
// 5. Has a view provider been registered for the object? If true, use the
|
||||
// provider to create a view associated with the object, and return the
|
||||
// view.
|
||||
//
|
||||
// If no associated view is returned by the sequence an error is thrown.
|
||||
//
|
||||
// Returns a DOM element.
|
||||
getView (object) {
|
||||
if (object == null) { return }
|
||||
|
||||
let view = this.views.get(object)
|
||||
if (!view) {
|
||||
view = this.createView(object)
|
||||
this.views.set(object, view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
createView (object) {
|
||||
if (object instanceof HTMLElement) { return object }
|
||||
|
||||
let element
|
||||
if (object && (typeof object.getElement === 'function')) {
|
||||
element = object.getElement()
|
||||
if (element instanceof HTMLElement) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
|
||||
if (object && object.element instanceof HTMLElement) {
|
||||
return object.element
|
||||
}
|
||||
|
||||
if (object && object.jquery) {
|
||||
return object[0]
|
||||
}
|
||||
|
||||
for (let provider of this.providers) {
|
||||
if (provider.modelConstructor === AnyConstructor) {
|
||||
element = provider.createView(object, this.atomEnvironment)
|
||||
if (element) { return element }
|
||||
continue
|
||||
}
|
||||
|
||||
if (object instanceof provider.modelConstructor) {
|
||||
element = provider.createView && provider.createView(object, this.atomEnvironment)
|
||||
if (element) { return element }
|
||||
|
||||
let ViewConstructor = provider.viewConstructor
|
||||
if (ViewConstructor) {
|
||||
element = new ViewConstructor()
|
||||
if (element.initialize) {
|
||||
element.initialize(object)
|
||||
} else if (element.setModel) {
|
||||
element.setModel(object)
|
||||
}
|
||||
return element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (object && object.getViewClass) {
|
||||
let ViewConstructor = object.getViewClass()
|
||||
if (ViewConstructor) {
|
||||
const view = new ViewConstructor(object)
|
||||
return view[0]
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Can't create a view for ${object.constructor.name} instance. Please register a view provider.`)
|
||||
}
|
||||
|
||||
updateDocument (fn) {
|
||||
this.documentWriters.push(fn)
|
||||
if (!this.documentReadInProgress) { this.requestDocumentUpdate() }
|
||||
return new Disposable(() => {
|
||||
this.documentWriters = this.documentWriters.filter(writer => writer !== fn)
|
||||
})
|
||||
}
|
||||
|
||||
readDocument (fn) {
|
||||
this.documentReaders.push(fn)
|
||||
this.requestDocumentUpdate()
|
||||
return new Disposable(() => {
|
||||
this.documentReaders = this.documentReaders.filter(reader => reader !== fn)
|
||||
})
|
||||
}
|
||||
|
||||
getNextUpdatePromise () {
|
||||
if (this.nextUpdatePromise == null) {
|
||||
this.nextUpdatePromise = new Promise(resolve => {
|
||||
this.resolveNextUpdatePromise = resolve
|
||||
})
|
||||
}
|
||||
|
||||
return this.nextUpdatePromise
|
||||
}
|
||||
|
||||
clearDocumentRequests () {
|
||||
this.documentReaders = []
|
||||
this.documentWriters = []
|
||||
this.nextUpdatePromise = null
|
||||
this.resolveNextUpdatePromise = null
|
||||
if (this.animationFrameRequest != null) {
|
||||
cancelAnimationFrame(this.animationFrameRequest)
|
||||
this.animationFrameRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
requestDocumentUpdate () {
|
||||
if (this.animationFrameRequest == null) {
|
||||
this.animationFrameRequest = requestAnimationFrame(this.performDocumentUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
performDocumentUpdate () {
|
||||
const { resolveNextUpdatePromise } = this
|
||||
this.animationFrameRequest = null
|
||||
this.nextUpdatePromise = null
|
||||
this.resolveNextUpdatePromise = null
|
||||
|
||||
var writer = this.documentWriters.shift()
|
||||
while (writer) {
|
||||
writer()
|
||||
writer = this.documentWriters.shift()
|
||||
}
|
||||
|
||||
var reader = this.documentReaders.shift()
|
||||
this.documentReadInProgress = true
|
||||
while (reader) {
|
||||
reader()
|
||||
reader = this.documentReaders.shift()
|
||||
}
|
||||
this.documentReadInProgress = false
|
||||
|
||||
// process updates requested as a result of reads
|
||||
writer = this.documentWriters.shift()
|
||||
while (writer) {
|
||||
writer()
|
||||
writer = this.documentWriters.shift()
|
||||
}
|
||||
|
||||
if (resolveNextUpdatePromise) { resolveNextUpdatePromise() }
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
{Disposable, CompositeDisposable} = require 'event-kit'
|
||||
listen = require './delegated-listener'
|
||||
|
||||
# Handles low-level events related to the @window.
|
||||
module.exports =
|
||||
class WindowEventHandler
|
||||
constructor: ({@atomEnvironment, @applicationDelegate}) ->
|
||||
@reloadRequested = false
|
||||
@subscriptions = new CompositeDisposable
|
||||
|
||||
@handleNativeKeybindings()
|
||||
|
||||
initialize: (@window, @document) ->
|
||||
@subscriptions.add @atomEnvironment.commands.add @window,
|
||||
'window:toggle-full-screen': @handleWindowToggleFullScreen
|
||||
'window:close': @handleWindowClose
|
||||
'window:reload': @handleWindowReload
|
||||
'window:toggle-dev-tools': @handleWindowToggleDevTools
|
||||
|
||||
if process.platform in ['win32', 'linux']
|
||||
@subscriptions.add @atomEnvironment.commands.add @window,
|
||||
'window:toggle-menu-bar': @handleWindowToggleMenuBar
|
||||
|
||||
@subscriptions.add @atomEnvironment.commands.add @document,
|
||||
'core:focus-next': @handleFocusNext
|
||||
'core:focus-previous': @handleFocusPrevious
|
||||
|
||||
@addEventListener(@window, 'beforeunload', @handleWindowBeforeunload)
|
||||
@addEventListener(@window, 'focus', @handleWindowFocus)
|
||||
@addEventListener(@window, 'blur', @handleWindowBlur)
|
||||
|
||||
@addEventListener(@document, 'keyup', @handleDocumentKeyEvent)
|
||||
@addEventListener(@document, 'keydown', @handleDocumentKeyEvent)
|
||||
@addEventListener(@document, 'drop', @handleDocumentDrop)
|
||||
@addEventListener(@document, 'dragover', @handleDocumentDragover)
|
||||
@addEventListener(@document, 'contextmenu', @handleDocumentContextmenu)
|
||||
@subscriptions.add listen(@document, 'click', 'a', @handleLinkClick)
|
||||
@subscriptions.add listen(@document, 'submit', 'form', @handleFormSubmit)
|
||||
|
||||
@subscriptions.add(@applicationDelegate.onDidEnterFullScreen(@handleEnterFullScreen))
|
||||
@subscriptions.add(@applicationDelegate.onDidLeaveFullScreen(@handleLeaveFullScreen))
|
||||
|
||||
# Wire commands that should be handled by Chromium for elements with the
|
||||
# `.native-key-bindings` class.
|
||||
handleNativeKeybindings: ->
|
||||
bindCommandToAction = (command, action) =>
|
||||
@subscriptions.add @atomEnvironment.commands.add(
|
||||
'.native-key-bindings',
|
||||
command,
|
||||
((event) => @applicationDelegate.getCurrentWindow().webContents[action]()),
|
||||
false
|
||||
)
|
||||
|
||||
bindCommandToAction('core:copy', 'copy')
|
||||
bindCommandToAction('core:paste', 'paste')
|
||||
bindCommandToAction('core:undo', 'undo')
|
||||
bindCommandToAction('core:redo', 'redo')
|
||||
bindCommandToAction('core:select-all', 'selectAll')
|
||||
bindCommandToAction('core:cut', 'cut')
|
||||
|
||||
unsubscribe: ->
|
||||
@subscriptions.dispose()
|
||||
|
||||
on: (target, eventName, handler) ->
|
||||
target.on(eventName, handler)
|
||||
@subscriptions.add(new Disposable ->
|
||||
target.removeListener(eventName, handler)
|
||||
)
|
||||
|
||||
addEventListener: (target, eventName, handler) ->
|
||||
target.addEventListener(eventName, handler)
|
||||
@subscriptions.add(new Disposable(-> target.removeEventListener(eventName, handler)))
|
||||
|
||||
handleDocumentKeyEvent: (event) =>
|
||||
@atomEnvironment.keymaps.handleKeyboardEvent(event)
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
handleDrop: (event) ->
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
handleDragover: (event) ->
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
|
||||
eachTabIndexedElement: (callback) ->
|
||||
for element in @document.querySelectorAll('[tabindex]')
|
||||
continue if element.disabled
|
||||
continue unless element.tabIndex >= 0
|
||||
callback(element, element.tabIndex)
|
||||
return
|
||||
|
||||
handleFocusNext: =>
|
||||
focusedTabIndex = @document.activeElement.tabIndex ? -Infinity
|
||||
|
||||
nextElement = null
|
||||
nextTabIndex = Infinity
|
||||
lowestElement = null
|
||||
lowestTabIndex = Infinity
|
||||
@eachTabIndexedElement (element, tabIndex) ->
|
||||
if tabIndex < lowestTabIndex
|
||||
lowestTabIndex = tabIndex
|
||||
lowestElement = element
|
||||
|
||||
if focusedTabIndex < tabIndex < nextTabIndex
|
||||
nextTabIndex = tabIndex
|
||||
nextElement = element
|
||||
|
||||
if nextElement?
|
||||
nextElement.focus()
|
||||
else if lowestElement?
|
||||
lowestElement.focus()
|
||||
|
||||
handleFocusPrevious: =>
|
||||
focusedTabIndex = @document.activeElement.tabIndex ? Infinity
|
||||
|
||||
previousElement = null
|
||||
previousTabIndex = -Infinity
|
||||
highestElement = null
|
||||
highestTabIndex = -Infinity
|
||||
@eachTabIndexedElement (element, tabIndex) ->
|
||||
if tabIndex > highestTabIndex
|
||||
highestTabIndex = tabIndex
|
||||
highestElement = element
|
||||
|
||||
if focusedTabIndex > tabIndex > previousTabIndex
|
||||
previousTabIndex = tabIndex
|
||||
previousElement = element
|
||||
|
||||
if previousElement?
|
||||
previousElement.focus()
|
||||
else if highestElement?
|
||||
highestElement.focus()
|
||||
|
||||
handleWindowFocus: ->
|
||||
@document.body.classList.remove('is-blurred')
|
||||
|
||||
handleWindowBlur: =>
|
||||
@document.body.classList.add('is-blurred')
|
||||
@atomEnvironment.storeWindowDimensions()
|
||||
|
||||
handleEnterFullScreen: =>
|
||||
@document.body.classList.add("fullscreen")
|
||||
|
||||
handleLeaveFullScreen: =>
|
||||
@document.body.classList.remove("fullscreen")
|
||||
|
||||
handleWindowBeforeunload: (event) =>
|
||||
if not @reloadRequested and not @atomEnvironment.inSpecMode() and @atomEnvironment.getCurrentWindow().isWebViewFocused()
|
||||
@atomEnvironment.hide()
|
||||
@reloadRequested = false
|
||||
@atomEnvironment.storeWindowDimensions()
|
||||
@atomEnvironment.unloadEditorWindow()
|
||||
@atomEnvironment.destroy()
|
||||
|
||||
handleWindowToggleFullScreen: =>
|
||||
@atomEnvironment.toggleFullScreen()
|
||||
|
||||
handleWindowClose: =>
|
||||
@atomEnvironment.close()
|
||||
|
||||
handleWindowReload: =>
|
||||
@reloadRequested = true
|
||||
@atomEnvironment.reload()
|
||||
|
||||
handleWindowToggleDevTools: =>
|
||||
@atomEnvironment.toggleDevTools()
|
||||
|
||||
handleWindowToggleMenuBar: =>
|
||||
@atomEnvironment.config.set('core.autoHideMenuBar', not @atomEnvironment.config.get('core.autoHideMenuBar'))
|
||||
|
||||
if @atomEnvironment.config.get('core.autoHideMenuBar')
|
||||
detail = "To toggle, press the Alt key or execute the window:toggle-menu-bar command"
|
||||
@atomEnvironment.notifications.addInfo('Menu bar hidden', {detail})
|
||||
|
||||
handleLinkClick: (event) =>
|
||||
event.preventDefault()
|
||||
uri = event.currentTarget?.getAttribute('href')
|
||||
if uri and uri[0] isnt '#' and /^https?:\/\//.test(uri)
|
||||
@applicationDelegate.openExternal(uri)
|
||||
|
||||
handleFormSubmit: (event) ->
|
||||
# Prevent form submits from changing the current window's URL
|
||||
event.preventDefault()
|
||||
|
||||
handleDocumentContextmenu: (event) =>
|
||||
event.preventDefault()
|
||||
@atomEnvironment.contextMenu.showForEvent(event)
|
||||
253
src/window-event-handler.js
Normal file
253
src/window-event-handler.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const {Disposable, CompositeDisposable} = require('event-kit')
|
||||
const listen = require('./delegated-listener')
|
||||
|
||||
// Handles low-level events related to the `window`.
|
||||
module.exports =
|
||||
class WindowEventHandler {
|
||||
constructor ({atomEnvironment, applicationDelegate}) {
|
||||
this.handleDocumentKeyEvent = this.handleDocumentKeyEvent.bind(this)
|
||||
this.handleFocusNext = this.handleFocusNext.bind(this)
|
||||
this.handleFocusPrevious = this.handleFocusPrevious.bind(this)
|
||||
this.handleWindowBlur = this.handleWindowBlur.bind(this)
|
||||
this.handleEnterFullScreen = this.handleEnterFullScreen.bind(this)
|
||||
this.handleLeaveFullScreen = this.handleLeaveFullScreen.bind(this)
|
||||
this.handleWindowBeforeunload = this.handleWindowBeforeunload.bind(this)
|
||||
this.handleWindowToggleFullScreen = this.handleWindowToggleFullScreen.bind(this)
|
||||
this.handleWindowClose = this.handleWindowClose.bind(this)
|
||||
this.handleWindowReload = this.handleWindowReload.bind(this)
|
||||
this.handleWindowToggleDevTools = this.handleWindowToggleDevTools.bind(this)
|
||||
this.handleWindowToggleMenuBar = this.handleWindowToggleMenuBar.bind(this)
|
||||
this.handleLinkClick = this.handleLinkClick.bind(this)
|
||||
this.handleDocumentContextmenu = this.handleDocumentContextmenu.bind(this)
|
||||
this.atomEnvironment = atomEnvironment
|
||||
this.applicationDelegate = applicationDelegate
|
||||
this.reloadRequested = false
|
||||
this.subscriptions = new CompositeDisposable()
|
||||
|
||||
this.handleNativeKeybindings()
|
||||
}
|
||||
|
||||
initialize (window, document) {
|
||||
this.window = window
|
||||
this.document = document
|
||||
this.subscriptions.add(this.atomEnvironment.commands.add(this.window, {
|
||||
'window:toggle-full-screen': this.handleWindowToggleFullScreen,
|
||||
'window:close': this.handleWindowClose,
|
||||
'window:reload': this.handleWindowReload,
|
||||
'window:toggle-dev-tools': this.handleWindowToggleDevTools
|
||||
}))
|
||||
|
||||
if (['win32', 'linux'].includes(process.platform)) {
|
||||
this.subscriptions.add(this.atomEnvironment.commands.add(this.window,
|
||||
{'window:toggle-menu-bar': this.handleWindowToggleMenuBar})
|
||||
)
|
||||
}
|
||||
|
||||
this.subscriptions.add(this.atomEnvironment.commands.add(this.document, {
|
||||
'core:focus-next': this.handleFocusNext,
|
||||
'core:focus-previous': this.handleFocusPrevious
|
||||
}))
|
||||
|
||||
this.addEventListener(this.window, 'beforeunload', this.handleWindowBeforeunload)
|
||||
this.addEventListener(this.window, 'focus', this.handleWindowFocus)
|
||||
this.addEventListener(this.window, 'blur', this.handleWindowBlur)
|
||||
|
||||
this.addEventListener(this.document, 'keyup', this.handleDocumentKeyEvent)
|
||||
this.addEventListener(this.document, 'keydown', this.handleDocumentKeyEvent)
|
||||
this.addEventListener(this.document, 'drop', this.handleDocumentDrop)
|
||||
this.addEventListener(this.document, 'dragover', this.handleDocumentDragover)
|
||||
this.addEventListener(this.document, 'contextmenu', this.handleDocumentContextmenu)
|
||||
this.subscriptions.add(listen(this.document, 'click', 'a', this.handleLinkClick))
|
||||
this.subscriptions.add(listen(this.document, 'submit', 'form', this.handleFormSubmit))
|
||||
|
||||
this.subscriptions.add(this.applicationDelegate.onDidEnterFullScreen(this.handleEnterFullScreen))
|
||||
this.subscriptions.add(this.applicationDelegate.onDidLeaveFullScreen(this.handleLeaveFullScreen))
|
||||
}
|
||||
|
||||
// Wire commands that should be handled by Chromium for elements with the
|
||||
// `.native-key-bindings` class.
|
||||
handleNativeKeybindings () {
|
||||
const bindCommandToAction = (command, action) => {
|
||||
this.subscriptions.add(
|
||||
this.atomEnvironment.commands.add(
|
||||
'.native-key-bindings',
|
||||
command,
|
||||
event => this.applicationDelegate.getCurrentWindow().webContents[action](),
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
bindCommandToAction('core:copy', 'copy')
|
||||
bindCommandToAction('core:paste', 'paste')
|
||||
bindCommandToAction('core:undo', 'undo')
|
||||
bindCommandToAction('core:redo', 'redo')
|
||||
bindCommandToAction('core:select-all', 'selectAll')
|
||||
bindCommandToAction('core:cut', 'cut')
|
||||
}
|
||||
|
||||
unsubscribe () {
|
||||
this.subscriptions.dispose()
|
||||
}
|
||||
|
||||
on (target, eventName, handler) {
|
||||
target.on(eventName, handler)
|
||||
this.subscriptions.add(new Disposable(function () {
|
||||
target.removeListener(eventName, handler)
|
||||
}))
|
||||
}
|
||||
|
||||
addEventListener (target, eventName, handler) {
|
||||
target.addEventListener(eventName, handler)
|
||||
this.subscriptions.add(new Disposable(function () {
|
||||
target.removeEventListener(eventName, handler)
|
||||
}))
|
||||
}
|
||||
|
||||
handleDocumentKeyEvent (event) {
|
||||
this.atomEnvironment.keymaps.handleKeyboardEvent(event)
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
handleDrop (event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
handleDragover (event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
|
||||
eachTabIndexedElement (callback) {
|
||||
for (let element of this.document.querySelectorAll('[tabindex]')) {
|
||||
if (element.disabled) { continue }
|
||||
if (!(element.tabIndex >= 0)) { continue }
|
||||
callback(element, element.tabIndex)
|
||||
}
|
||||
}
|
||||
|
||||
handleFocusNext () {
|
||||
const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : -Infinity
|
||||
|
||||
let nextElement = null
|
||||
let nextTabIndex = Infinity
|
||||
let lowestElement = null
|
||||
let lowestTabIndex = Infinity
|
||||
this.eachTabIndexedElement(function (element, tabIndex) {
|
||||
if (tabIndex < lowestTabIndex) {
|
||||
lowestTabIndex = tabIndex
|
||||
lowestElement = element
|
||||
}
|
||||
|
||||
if (focusedTabIndex < tabIndex && tabIndex < nextTabIndex) {
|
||||
nextTabIndex = tabIndex
|
||||
nextElement = element
|
||||
}
|
||||
})
|
||||
|
||||
if (nextElement != null) {
|
||||
nextElement.focus()
|
||||
} else if (lowestElement != null) {
|
||||
lowestElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
handleFocusPrevious () {
|
||||
const focusedTabIndex = this.document.activeElement.tabIndex != null ? this.document.activeElement.tabIndex : Infinity
|
||||
|
||||
let previousElement = null
|
||||
let previousTabIndex = -Infinity
|
||||
let highestElement = null
|
||||
let highestTabIndex = -Infinity
|
||||
this.eachTabIndexedElement(function (element, tabIndex) {
|
||||
if (tabIndex > highestTabIndex) {
|
||||
highestTabIndex = tabIndex
|
||||
highestElement = element
|
||||
}
|
||||
|
||||
if (focusedTabIndex > tabIndex && tabIndex > previousTabIndex) {
|
||||
previousTabIndex = tabIndex
|
||||
previousElement = element
|
||||
}
|
||||
})
|
||||
|
||||
if (previousElement != null) {
|
||||
previousElement.focus()
|
||||
} else if (highestElement != null) {
|
||||
highestElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
handleWindowFocus () {
|
||||
this.document.body.classList.remove('is-blurred')
|
||||
}
|
||||
|
||||
handleWindowBlur () {
|
||||
this.document.body.classList.add('is-blurred')
|
||||
this.atomEnvironment.storeWindowDimensions()
|
||||
}
|
||||
|
||||
handleEnterFullScreen () {
|
||||
this.document.body.classList.add('fullscreen')
|
||||
}
|
||||
|
||||
handleLeaveFullScreen () {
|
||||
this.document.body.classList.remove('fullscreen')
|
||||
}
|
||||
|
||||
handleWindowBeforeunload (event) {
|
||||
if (!this.reloadRequested && !this.atomEnvironment.inSpecMode() && this.atomEnvironment.getCurrentWindow().isWebViewFocused()) {
|
||||
this.atomEnvironment.hide()
|
||||
}
|
||||
this.reloadRequested = false
|
||||
this.atomEnvironment.storeWindowDimensions()
|
||||
this.atomEnvironment.unloadEditorWindow()
|
||||
this.atomEnvironment.destroy()
|
||||
}
|
||||
|
||||
handleWindowToggleFullScreen () {
|
||||
this.atomEnvironment.toggleFullScreen()
|
||||
}
|
||||
|
||||
handleWindowClose () {
|
||||
this.atomEnvironment.close()
|
||||
}
|
||||
|
||||
handleWindowReload () {
|
||||
this.reloadRequested = true
|
||||
this.atomEnvironment.reload()
|
||||
}
|
||||
|
||||
handleWindowToggleDevTools () {
|
||||
this.atomEnvironment.toggleDevTools()
|
||||
}
|
||||
|
||||
handleWindowToggleMenuBar () {
|
||||
this.atomEnvironment.config.set('core.autoHideMenuBar', !this.atomEnvironment.config.get('core.autoHideMenuBar'))
|
||||
|
||||
if (this.atomEnvironment.config.get('core.autoHideMenuBar')) {
|
||||
const detail = 'To toggle, press the Alt key or execute the window:toggle-menu-bar command'
|
||||
this.atomEnvironment.notifications.addInfo('Menu bar hidden', {detail})
|
||||
}
|
||||
}
|
||||
|
||||
handleLinkClick (event) {
|
||||
event.preventDefault()
|
||||
const uri = event.currentTarget && event.currentTarget.getAttribute('href')
|
||||
if (uri && (uri[0] !== '#') && /^https?:\/\//.test(uri)) {
|
||||
this.applicationDelegate.openExternal(uri)
|
||||
}
|
||||
}
|
||||
|
||||
handleFormSubmit (event) {
|
||||
// Prevent form submits from changing the current window's URL
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
handleDocumentContextmenu (event) {
|
||||
event.preventDefault()
|
||||
this.atomEnvironment.contextMenu.showForEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -659,7 +659,7 @@ module.exports = class Workspace extends Model {
|
||||
// changing or closing tabs and ensures critical UI feedback, like changing the
|
||||
// highlighted tab, gets priority over work that can be done asynchronously.
|
||||
//
|
||||
// * `callback` {Function} to be called when the active pane item stopts
|
||||
// * `callback` {Function} to be called when the active pane item stops
|
||||
// changing.
|
||||
// * `item` The active pane item.
|
||||
//
|
||||
@@ -1050,10 +1050,10 @@ module.exports = class Workspace extends Model {
|
||||
|
||||
// Essential: Search the workspace for items matching the given URI and hide them.
|
||||
//
|
||||
// * `itemOrURI` (optional) The item to hide or a {String} containing the URI
|
||||
// * `itemOrURI` The item to hide or a {String} containing the URI
|
||||
// of the item to hide.
|
||||
//
|
||||
// Returns a {boolean} indicating whether any items were found (and hidden).
|
||||
// Returns a {Boolean} indicating whether any items were found (and hidden).
|
||||
hide (itemOrURI) {
|
||||
let foundItems = false
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ body {
|
||||
font-weight: bold;
|
||||
color: #d9534f;
|
||||
padding: 5px 0 5px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.result-message.deprecation-message {
|
||||
|
||||
Reference in New Issue
Block a user