+
+ class Noun {
+ public:
+ void verb();
+ };
+ `)
+ expect(grammar.name).toBe('C++')
+
+ // The word `class` only indicates C++ in `.h` files, not in all files.
+ grammar = grammarRegistry.selectGrammar('test.coffee', dedent `
+ module.exports =
+ class Noun
+ verb: -> true
+ `)
+ expect(grammar.name).toBe('CoffeeScript')
+ })
+
+ it('recognizes C++ files that do not match the content regex (regression)', () => {
+ atom.config.set('core.useTreeSitterParsers', true)
+ grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/tree-sitter-c.cson'))
+ grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/c++.cson'))
+ grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/tree-sitter-cpp.cson'))
+
+ let grammar = grammarRegistry.selectGrammar('test.cc', dedent `
+ int a();
+ `)
+ expect(grammar.name).toBe('C++')
+ })
+
+ it('does not apply content regexes from grammars without filetype or first line matches', () => {
+ atom.config.set('core.useTreeSitterParsers', true)
+ grammarRegistry.loadGrammarSync(require.resolve('language-c/grammars/tree-sitter-cpp.cson'))
+
+ let grammar = grammarRegistry.selectGrammar('', dedent `
+ class Foo
+ # this is ruby, not C++
+ end
+ `)
+
+ expect(grammar.name).toBe('Null Grammar')
+ })
+
+ it('recognizes shell scripts with shebang lines', () => {
+ atom.config.set('core.useTreeSitterParsers', true)
+ grammarRegistry.loadGrammarSync(require.resolve('language-shellscript/grammars/shell-unix-bash.cson'))
+ grammarRegistry.loadGrammarSync(require.resolve('language-shellscript/grammars/tree-sitter-bash.cson'))
+
+ let grammar = grammarRegistry.selectGrammar('test.h', dedent `
+ #!/bin/bash
+
+ echo "hi"
+ `)
+ expect(grammar.name).toBe('Shell Script')
+ expect(grammar instanceof TreeSitterGrammar).toBeTruthy()
+
+ grammar = grammarRegistry.selectGrammar('test.h', dedent `
+ # vim: set ft=bash
+
+ echo "hi"
+ `)
+ expect(grammar.name).toBe('Shell Script')
+ expect(grammar instanceof TreeSitterGrammar).toBeTruthy()
+
+ atom.config.set('core.useTreeSitterParsers', false)
+ grammar = grammarRegistry.selectGrammar('test.h', dedent `
+ #!/bin/bash
+
+ echo "hi"
+ `)
+ expect(grammar.name).toBe('Shell Script')
+ expect(grammar instanceof TreeSitterGrammar).toBeFalsy()
+ })
+
+ it('recognizes JavaScript files that use Flow', () => {
+ atom.config.set('core.useTreeSitterParsers', true)
+ grammarRegistry.loadGrammarSync(require.resolve('language-javascript/grammars/tree-sitter-javascript.cson'))
+ grammarRegistry.loadGrammarSync(require.resolve('language-typescript/grammars/tree-sitter-flow.cson'))
+
+ let grammar = grammarRegistry.selectGrammar('test.js', dedent`
+ // Copyright something
+ // @flow
+
+ module.exports = function () { return 1 + 1 }
+ `)
+ expect(grammar.name).toBe('Flow JavaScript')
+
+ grammar = grammarRegistry.selectGrammar('test.js', dedent`
+ module.exports = function () { return 1 + 1 }
+ `)
+ expect(grammar.name).toBe('JavaScript')
+ })
})
})
@@ -450,6 +545,34 @@ describe('GrammarRegistry', () => {
})
})
+ describe('.addInjectionPoint(languageId, {type, language, content})', () => {
+ const injectionPoint = {
+ type: 'some_node_type',
+ language() { return 'some_language_name' },
+ content(node) { return node }
+ }
+
+ beforeEach(() => {
+ atom.config.set('core.useTreeSitterParsers', true)
+ })
+
+ it('adds an injection point to the grammar with the given id', async () => {
+ await atom.packages.activatePackage('language-javascript')
+ atom.grammars.addInjectionPoint('javascript', injectionPoint)
+ const grammar = atom.grammars.grammarForId('javascript')
+ expect(grammar.injectionPoints).toContain(injectionPoint)
+ })
+
+ describe('when called before a grammar with the given id is loaded', () => {
+ it('adds the injection point once the grammar is loaded', async () => {
+ atom.grammars.addInjectionPoint('javascript', injectionPoint)
+ await atom.packages.activatePackage('language-javascript')
+ const grammar = atom.grammars.grammarForId('javascript')
+ expect(grammar.injectionPoints).toContain(injectionPoint)
+ })
+ })
+ })
+
describe('serialization', () => {
it('persists editors\' grammar overrides', async () => {
const buffer1 = new TextBuffer()
diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js
index 90a512692..16aef8e27 100644
--- a/spec/main-process/atom-application.test.js
+++ b/spec/main-process/atom-application.test.js
@@ -49,7 +49,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(filePath, '1\n2\n3\n4\n')
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([filePath + ':3']))
+ const [window] = await atomApplication.launch(parseCommandLine([filePath + ':3']))
await focusWindow(window)
const cursorRow = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => {
@@ -66,7 +66,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(filePath, '1\n2\n3\n4\n')
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([filePath + ':2:2']))
+ const [window] = await atomApplication.launch(parseCommandLine([filePath + ':2:2']))
await focusWindow(window)
const cursorPosition = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => {
@@ -83,7 +83,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(filePath, '1\n2\n3\n4\n')
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([filePath + ':: ']))
+ const [window] = await atomApplication.launch(parseCommandLine([filePath + ':: ']))
await focusWindow(window)
const openedPath = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => {
@@ -99,11 +99,11 @@ describe('AtomApplication', function () {
it('positions new windows at an offset distance from the previous window', async () => {
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([makeTempDir()]))
+ const [window1] = await atomApplication.launch(parseCommandLine([makeTempDir()]))
await focusWindow(window1)
window1.browserWindow.setBounds({width: 400, height: 400, x: 0, y: 0})
- const window2 = atomApplication.launch(parseCommandLine([makeTempDir()]))
+ const [window2] = await atomApplication.launch(parseCommandLine([makeTempDir()]))
await focusWindow(window2)
assert.notEqual(window1, window2)
@@ -122,7 +122,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(existingDirCFilePath, 'this is an existing file')
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')]))
+ const [window1] = await atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')]))
await emitterEventPromise(window1, 'window:locations-opened')
await focusWindow(window1)
@@ -135,7 +135,7 @@ describe('AtomApplication', function () {
// Reuses the window when opening *files*, even if they're in a different directory
// Does not change the project paths when doing so.
- const reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath]))
+ const [reusedWindow] = await atomApplication.launch(parseCommandLine([existingDirCFilePath]))
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.getAllWindows(), [window1])
activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
@@ -148,7 +148,7 @@ describe('AtomApplication', function () {
assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath])
// Opens new windows when opening directories
- const window2 = atomApplication.launch(parseCommandLine([dirCPath]))
+ const [window2] = await atomApplication.launch(parseCommandLine([dirCPath]))
await emitterEventPromise(window2, 'window:locations-opened')
assert.notEqual(window2, window1)
await focusWindow(window2)
@@ -163,7 +163,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(existingDirCFilePath, 'this is an existing file')
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')]))
+ const [window1] = await atomApplication.launch(parseCommandLine([path.join(dirAPath, 'new-file')]))
await focusWindow(window1)
let activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
@@ -175,7 +175,7 @@ describe('AtomApplication', function () {
// When opening *files* with --add, reuses an existing window and adds
// parent directory to the project
- let reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add']))
+ let [reusedWindow] = await atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add']))
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.getAllWindows(), [window1])
activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
@@ -189,7 +189,7 @@ describe('AtomApplication', function () {
// When opening *directories* with add reuses an existing window and adds
// the directory to the project
- reusedWindow = atomApplication.launch(parseCommandLine([dirBPath, '-a']))
+ reusedWindow = (await atomApplication.launch(parseCommandLine([dirBPath, '-a'])))[0]
assert.equal(reusedWindow, window1)
assert.deepEqual(atomApplication.getAllWindows(), [window1])
@@ -202,7 +202,7 @@ describe('AtomApplication', function () {
const atomApplication = buildAtomApplication()
const nonExistentFilePath = path.join(tempDirPath, 'new-file')
- const window1 = atomApplication.launch(parseCommandLine([nonExistentFilePath]))
+ const [window1] = await atomApplication.launch(parseCommandLine([nonExistentFilePath]))
await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
atom.workspace.observeTextEditors(textEditor => {
textEditor.insertText('Hello World!')
@@ -214,7 +214,7 @@ describe('AtomApplication', function () {
await window1.closedPromise
// Restore unsaved state when opening the directory itself
- const window2 = atomApplication.launch(parseCommandLine([tempDirPath]))
+ const [window2] = await atomApplication.launch(parseCommandLine([tempDirPath]))
await window2.loadedPromise
const window2Text = await evalInWebContents(window2.browserWindow.webContents, sendBackToMainProcess => {
const textEditor = atom.workspace.getActiveTextEditor()
@@ -228,7 +228,7 @@ describe('AtomApplication', function () {
await window2.closedPromise
// Restore unsaved state when opening a path to a non-existent file in the directory
- const window3 = atomApplication.launch(parseCommandLine([path.join(tempDirPath, 'another-non-existent-file')]))
+ const [window3] = await atomApplication.launch(parseCommandLine([path.join(tempDirPath, 'another-non-existent-file')]))
await window3.loadedPromise
const window3Texts = await evalInWebContents(window3.browserWindow.webContents, (sendBackToMainProcess, nonExistentFilePath) => {
sendBackToMainProcess(atom.workspace.getTextEditors().map(editor => editor.getText()))
@@ -243,7 +243,7 @@ describe('AtomApplication', function () {
fs.mkdirSync(dirBSubdirPath)
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([dirAPath, dirBPath]))
+ const [window1] = await atomApplication.launch(parseCommandLine([dirAPath, dirBPath]))
await focusWindow(window1)
assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirBPath])
@@ -252,17 +252,17 @@ describe('AtomApplication', function () {
it('reuses windows with no project paths to open directories', async () => {
const tempDirPath = makeTempDir()
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([]))
+ const [window1] = await atomApplication.launch(parseCommandLine([]))
await focusWindow(window1)
- const reusedWindow = atomApplication.launch(parseCommandLine([tempDirPath]))
+ const [reusedWindow] = await atomApplication.launch(parseCommandLine([tempDirPath]))
assert.equal(reusedWindow, window1)
await conditionPromise(async () => (await getTreeViewRootDirectories(reusedWindow)).length > 0)
})
it('opens a new window with a single untitled buffer when launched with no path, even if windows already exist', async () => {
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([]))
+ const [window1] = await atomApplication.launch(parseCommandLine([]))
await focusWindow(window1)
const window1EditorTitle = await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
sendBackToMainProcess(atom.workspace.getActiveTextEditor().getTitle())
@@ -287,7 +287,7 @@ describe('AtomApplication', function () {
season.writeFileSync(configPath, config)
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([]))
+ const [window1] = await atomApplication.launch(parseCommandLine([]))
await focusWindow(window1)
// wait a bit just to make sure we don't pass due to querying the render process before it loads
@@ -302,7 +302,7 @@ describe('AtomApplication', function () {
it('opens an empty text editor and loads its parent directory in the tree-view when launched with a new file path', async () => {
const atomApplication = buildAtomApplication()
const newFilePath = path.join(makeTempDir(), 'new-file')
- const window = atomApplication.launch(parseCommandLine([newFilePath]))
+ const [window] = await atomApplication.launch(parseCommandLine([newFilePath]))
await focusWindow(window)
const {editorTitle, editorText} = await evalInWebContents(window.browserWindow.webContents, sendBackToMainProcess => {
atom.workspace.observeTextEditors(editor => {
@@ -324,7 +324,7 @@ describe('AtomApplication', function () {
atomApplication.config.set('core.disabledPackages', ['fuzzy-finder'])
const remotePath = 'remote://server:3437/some/directory/path'
- let window = atomApplication.launch(parseCommandLine([remotePath]))
+ let [window] = await atomApplication.launch(parseCommandLine([remotePath]))
await focusWindow(window)
await conditionPromise(async () => (await getProjectDirectories()).length > 0)
@@ -350,9 +350,9 @@ describe('AtomApplication', function () {
const tempDirPath2 = makeTempDir()
const atomApplication1 = buildAtomApplication()
- const app1Window1 = atomApplication1.launch(parseCommandLine([tempDirPath1]))
+ const [app1Window1] = await atomApplication1.launch(parseCommandLine([tempDirPath1]))
await emitterEventPromise(app1Window1, 'window:locations-opened')
- const app1Window2 = atomApplication1.launch(parseCommandLine([tempDirPath2]))
+ const [app1Window2] = await atomApplication1.launch(parseCommandLine([tempDirPath2]))
await emitterEventPromise(app1Window2, 'window:locations-opened')
await Promise.all([
@@ -361,7 +361,7 @@ describe('AtomApplication', function () {
])
const atomApplication2 = buildAtomApplication()
- const [app2Window1, app2Window2] = atomApplication2.launch(parseCommandLine([]))
+ const [app2Window1, app2Window2] = await atomApplication2.launch(parseCommandLine([]))
await Promise.all([
emitterEventPromise(app2Window1, 'window:locations-opened'),
emitterEventPromise(app2Window2, 'window:locations-opened')
@@ -373,9 +373,9 @@ describe('AtomApplication', function () {
it('does not reopen any previously opened windows when launched with no path and `core.restorePreviousWindowsOnStart` is no', async () => {
const atomApplication1 = buildAtomApplication()
- const app1Window1 = atomApplication1.launch(parseCommandLine([makeTempDir()]))
+ const [app1Window1] = await atomApplication1.launch(parseCommandLine([makeTempDir()]))
await focusWindow(app1Window1)
- const app1Window2 = atomApplication1.launch(parseCommandLine([makeTempDir()]))
+ const [app1Window2] = await atomApplication1.launch(parseCommandLine([makeTempDir()]))
await focusWindow(app1Window2)
const configPath = path.join(process.env.ATOM_HOME, 'config.cson')
@@ -385,7 +385,7 @@ describe('AtomApplication', function () {
season.writeFileSync(configPath, config)
const atomApplication2 = buildAtomApplication()
- const app2Window = atomApplication2.launch(parseCommandLine([]))
+ const [app2Window] = await atomApplication2.launch(parseCommandLine([]))
await focusWindow(app2Window)
assert.deepEqual(app2Window.representedDirectoryPaths, [])
})
@@ -405,10 +405,10 @@ describe('AtomApplication', function () {
})
it('kills the specified pid after a newly-opened window is closed', async () => {
- const window1 = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101']))
+ const [window1] = await atomApplication.launch(parseCommandLine(['--wait', '--pid', '101']))
await focusWindow(window1)
- const [window2] = atomApplication.launch(parseCommandLine(['--new-window', '--wait', '--pid', '102']))
+ const [window2] = await atomApplication.launch(parseCommandLine(['--new-window', '--wait', '--pid', '102']))
await focusWindow(window2)
assert.deepEqual(killedPids, [])
@@ -424,7 +424,7 @@ describe('AtomApplication', function () {
})
it('kills the specified pid after a newly-opened file in an existing window is closed', async () => {
- const window = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101']))
+ const [window] = await atomApplication.launch(parseCommandLine(['--wait', '--pid', '101']))
await focusWindow(window)
const filePath1 = temp.openSync('test').path
@@ -432,7 +432,7 @@ describe('AtomApplication', function () {
fs.writeFileSync(filePath1, 'File 1')
fs.writeFileSync(filePath2, 'File 2')
- const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '102', filePath1, filePath2]))
+ const [reusedWindow] = await atomApplication.launch(parseCommandLine(['--wait', '--pid', '102', filePath1, filePath2]))
assert.equal(reusedWindow, window)
const activeEditorPath = await evalInWebContents(window.browserWindow.webContents, send => {
@@ -467,11 +467,11 @@ describe('AtomApplication', function () {
})
it('kills the specified pid after a newly-opened directory in an existing window is closed', async () => {
- const window = atomApplication.launch(parseCommandLine([]))
+ const [window] = await atomApplication.launch(parseCommandLine([]))
await focusWindow(window)
const dirPath1 = makeTempDir()
- const reusedWindow = atomApplication.launch(parseCommandLine(['--wait', '--pid', '101', dirPath1]))
+ const [reusedWindow] = await atomApplication.launch(parseCommandLine(['--wait', '--pid', '101', dirPath1]))
assert.equal(reusedWindow, window)
assert.deepEqual(await getTreeViewRootDirectories(window), [dirPath1])
assert.deepEqual(killedPids, [])
@@ -498,7 +498,7 @@ describe('AtomApplication', function () {
if (process.platform === 'linux' || process.platform === 'win32') {
it('quits the application', async () => {
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')]))
+ const [window] = await atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')]))
await focusWindow(window)
window.close()
await window.closedPromise
@@ -508,7 +508,7 @@ describe('AtomApplication', function () {
} else if (process.platform === 'darwin') {
it('leaves the application open', async () => {
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')]))
+ const [window] = await atomApplication.launch(parseCommandLine([path.join(makeTempDir("a"), 'file-a')]))
await focusWindow(window)
window.close()
await window.closedPromise
@@ -524,7 +524,7 @@ describe('AtomApplication', function () {
const dirB = makeTempDir()
const atomApplication = buildAtomApplication()
- const window = atomApplication.launch(parseCommandLine([dirA, dirB]))
+ const [window] = await atomApplication.launch(parseCommandLine([dirA, dirB]))
await emitterEventPromise(window, 'window:locations-opened')
await focusWindow(window)
assert.deepEqual(await getTreeViewRootDirectories(window), [dirA, dirB])
@@ -539,7 +539,7 @@ describe('AtomApplication', function () {
// Window state should be saved when the project folder is removed
const atomApplication2 = buildAtomApplication()
- const [window2] = atomApplication2.launch(parseCommandLine([]))
+ const [window2] = await atomApplication2.launch(parseCommandLine([]))
await emitterEventPromise(window2, 'window:locations-opened')
await focusWindow(window2)
assert.deepEqual(await getTreeViewRootDirectories(window2), [dirB])
@@ -556,7 +556,7 @@ describe('AtomApplication', function () {
const atomApplication = buildAtomApplication()
const launchOptions = parseCommandLine([])
launchOptions.urlsToOpen = ['atom://package-with-url-main/test']
- let windows = atomApplication.launch(launchOptions)
+ let [windows] = await atomApplication.launch(launchOptions)
await windows[0].loadedPromise
let reached = await evalInWebContents(windows[0].browserWindow.webContents, sendBackToMainProcess => {
@@ -571,9 +571,9 @@ describe('AtomApplication', function () {
const dirBPath = makeTempDir('b')
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath)]))
+ const [window1] = await atomApplication.launch(parseCommandLine([path.join(dirAPath)]))
await focusWindow(window1)
- const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath)]))
+ const [window2] = await atomApplication.launch(parseCommandLine([path.join(dirBPath)]))
await focusWindow(window2)
const fileA = path.join(dirAPath, 'file-a')
@@ -597,9 +597,9 @@ describe('AtomApplication', function () {
const dirAPath = makeTempDir("a")
const dirBPath = makeTempDir("b")
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath, 'file-a')]))
+ const [window1] = await atomApplication.launch(parseCommandLine([path.join(dirAPath, 'file-a')]))
await focusWindow(window1)
- const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')]))
+ const [window2] = await atomApplication.launch(parseCommandLine([path.join(dirBPath, 'file-b')]))
await focusWindow(window2)
electron.app.quit()
await new Promise(process.nextTick)
@@ -612,8 +612,8 @@ describe('AtomApplication', function () {
it('prevents quitting if user cancels when prompted to save an item', async () => {
const atomApplication = buildAtomApplication()
- const window1 = atomApplication.launch(parseCommandLine([]))
- const window2 = atomApplication.launch(parseCommandLine([]))
+ const [window1] = await atomApplication.launch(parseCommandLine([]))
+ const [window2] = await atomApplication.launch(parseCommandLine([]))
await Promise.all([window1.loadedPromise, window2.loadedPromise])
await evalInWebContents(window1.browserWindow.webContents, sendBackToMainProcess => {
atom.workspace.getActiveTextEditor().insertText('unsaved text')
diff --git a/spec/main-process/file-recovery-service.test.js b/spec/main-process/file-recovery-service.test.js
index 618c30ab0..45c10c25b 100644
--- a/spec/main-process/file-recovery-service.test.js
+++ b/spec/main-process/file-recovery-service.test.js
@@ -1,21 +1,23 @@
-/** @babel */
-
-import {dialog} from 'electron'
-import FileRecoveryService from '../../src/main-process/file-recovery-service'
-import fs from 'fs-plus'
-import sinon from 'sinon'
-import {escapeRegExp} from 'underscore-plus'
+const {dialog} = require('electron')
+const FileRecoveryService = require('../../src/main-process/file-recovery-service')
+const fs = require('fs-plus')
+const fsreal = require('fs')
+const EventEmitter = require('events').EventEmitter
+const sinon = require('sinon')
+const {escapeRegExp} = require('underscore-plus')
const temp = require('temp').track()
describe("FileRecoveryService", () => {
- let recoveryService, recoveryDirectory
+ let recoveryService, recoveryDirectory, spies
beforeEach(() => {
recoveryDirectory = temp.mkdirSync('atom-spec-file-recovery')
recoveryService = new FileRecoveryService(recoveryDirectory)
+ spies = sinon.sandbox.create()
})
afterEach(() => {
+ spies.restore()
try {
temp.cleanupSync()
} catch (e) {
@@ -24,38 +26,38 @@ describe("FileRecoveryService", () => {
})
describe("when no crash happens during a save", () => {
- it("creates a recovery file and deletes it after saving", () => {
+ it("creates a recovery file and deletes it after saving", async () => {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
- recoveryService.willSavePath(mockWindow, filePath)
+ await recoveryService.willSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
- recoveryService.didSavePath(mockWindow, filePath)
+ await recoveryService.didSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
fs.removeSync(filePath)
})
- it("creates only one recovery file when many windows attempt to save the same file, deleting it when the last one finishes saving it", () => {
+ it("creates only one recovery file when many windows attempt to save the same file, deleting it when the last one finishes saving it", async () => {
const mockWindow = {}
const anotherMockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
- recoveryService.willSavePath(mockWindow, filePath)
- recoveryService.willSavePath(anotherMockWindow, filePath)
+ await recoveryService.willSavePath(mockWindow, filePath)
+ await recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
- recoveryService.didSavePath(mockWindow, filePath)
+ await recoveryService.didSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
- recoveryService.didSavePath(anotherMockWindow, filePath)
+ await recoveryService.didSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "changed")
@@ -64,66 +66,75 @@ describe("FileRecoveryService", () => {
})
describe("when a crash happens during a save", () => {
- it("restores the created recovery file and deletes it", () => {
+ it("restores the created recovery file and deletes it", async () => {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "some content")
- recoveryService.willSavePath(mockWindow, filePath)
+ await recoveryService.willSavePath(mockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "changed")
- recoveryService.didCrashWindow(mockWindow)
+ await recoveryService.didCrashWindow(mockWindow)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
assert.equal(fs.readFileSync(filePath, 'utf8'), "some content")
fs.removeSync(filePath)
})
- it("restores the created recovery file when many windows attempt to save the same file and one of them crashes", () => {
+ it("restores the created recovery file when many windows attempt to save the same file and one of them crashes", async () => {
const mockWindow = {}
const anotherMockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "A")
- recoveryService.willSavePath(mockWindow, filePath)
+ await recoveryService.willSavePath(mockWindow, filePath)
fs.writeFileSync(filePath, "B")
- recoveryService.willSavePath(anotherMockWindow, filePath)
+ await recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "C")
- recoveryService.didCrashWindow(mockWindow)
+ await recoveryService.didCrashWindow(mockWindow)
assert.equal(fs.readFileSync(filePath, 'utf8'), "A")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
fs.writeFileSync(filePath, "D")
- recoveryService.willSavePath(mockWindow, filePath)
+ await recoveryService.willSavePath(mockWindow, filePath)
fs.writeFileSync(filePath, "E")
- recoveryService.willSavePath(anotherMockWindow, filePath)
+ await recoveryService.willSavePath(anotherMockWindow, filePath)
assert.equal(fs.listTreeSync(recoveryDirectory).length, 1)
fs.writeFileSync(filePath, "F")
- recoveryService.didCrashWindow(anotherMockWindow)
+ await recoveryService.didCrashWindow(anotherMockWindow)
assert.equal(fs.readFileSync(filePath, 'utf8'), "D")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
fs.removeSync(filePath)
})
- it("emits a warning when a file can't be recovered", sinon.test(function () {
+ it("emits a warning when a file can't be recovered", async () => {
const mockWindow = {}
const filePath = temp.path()
fs.writeFileSync(filePath, "content")
- fs.chmodSync(filePath, 0444)
let logs = []
- this.stub(console, 'log', (message) => logs.push(message))
- this.stub(dialog, 'showMessageBox')
+ spies.stub(console, 'log', (message) => logs.push(message))
+ spies.stub(dialog, 'showMessageBox')
- recoveryService.willSavePath(mockWindow, filePath)
- recoveryService.didCrashWindow(mockWindow)
+ // Copy files to be recovered before mocking fs.createWriteStream
+ await recoveryService.willSavePath(mockWindow, filePath)
+
+ // Stub out fs.createWriteStream so that we can return a fake error when
+ // attempting to copy the recovered file to its original location
+ var fakeEmitter = new EventEmitter()
+ var onStub = spies.stub(fakeEmitter, 'on')
+ onStub.withArgs('error').yields(new Error('Nope')).returns(fakeEmitter)
+ onStub.withArgs('open').returns(fakeEmitter)
+ spies.stub(fsreal, 'createWriteStream').withArgs(filePath).returns(fakeEmitter)
+
+ await recoveryService.didCrashWindow(mockWindow)
let recoveryFiles = fs.listTreeSync(recoveryDirectory)
assert.equal(recoveryFiles.length, 1)
assert.equal(logs.length, 1)
@@ -131,16 +142,16 @@ describe("FileRecoveryService", () => {
assert.match(logs[0], new RegExp(escapeRegExp(recoveryFiles[0])))
fs.removeSync(filePath)
- }))
+ })
})
- it("doesn't create a recovery file when the file that's being saved doesn't exist yet", () => {
+ it("doesn't create a recovery file when the file that's being saved doesn't exist yet", async () => {
const mockWindow = {}
- recoveryService.willSavePath(mockWindow, "a-file-that-doesnt-exist")
+ await recoveryService.willSavePath(mockWindow, "a-file-that-doesnt-exist")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
- recoveryService.didSavePath(mockWindow, "a-file-that-doesnt-exist")
+ await recoveryService.didSavePath(mockWindow, "a-file-that-doesnt-exist")
assert.equal(fs.listTreeSync(recoveryDirectory).length, 0)
})
})
diff --git a/spec/menu-manager-spec.coffee b/spec/menu-manager-spec.coffee
index 3bbd8b9da..c7b15aae6 100644
--- a/spec/menu-manager-spec.coffee
+++ b/spec/menu-manager-spec.coffee
@@ -58,10 +58,8 @@ describe "MenuManager", ->
menu.add [{label: "A", submenu: [{label: "B", command: "b"}]}]
atom.keymaps.add 'test', 'atom-workspace': 'ctrl-b': 'b'
menu.update()
-
- waits 50
-
- runs -> expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toEqual ['ctrl-b']
+ advanceClock(1)
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toEqual ['ctrl-b']
it "omits key bindings that are mapped to unset! in any context", ->
# it would be nice to be smarter about omitting, but that would require a much
@@ -69,10 +67,8 @@ describe "MenuManager", ->
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!'
-
- waits 50
-
- runs -> expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
+ advanceClock(1)
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
it "omits key bindings that could conflict with AltGraph characters on macOS", ->
Object.defineProperty process, 'platform', value: 'darwin'
@@ -87,12 +83,10 @@ describe "MenuManager", ->
'alt-shift-C': 'c'
'alt-cmd-d': 'd'
- waits 50
-
- runs ->
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['c']).toBeUndefined()
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['d']).toEqual(['alt-cmd-d'])
+ advanceClock(1)
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['c']).toBeUndefined()
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['d']).toEqual(['alt-cmd-d'])
it "omits key bindings that could conflict with AltGraph characters on Windows", ->
Object.defineProperty process, 'platform', value: 'win32'
@@ -107,12 +101,10 @@ describe "MenuManager", ->
'ctrl-alt-shift-C': 'c'
'ctrl-alt-cmd-d': 'd'
- waits 50
-
- runs ->
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['c']).toBeUndefined()
- expect(menu.sendToBrowserProcess.argsForCall[0][1]['d']).toEqual(['ctrl-alt-cmd-d'])
+ advanceClock(1)
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['b']).toBeUndefined()
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['c']).toBeUndefined()
+ expect(menu.sendToBrowserProcess.argsForCall[0][1]['d']).toEqual(['ctrl-alt-cmd-d'])
it "updates the application menu when a keymap is reloaded", ->
spyOn(menu, 'update')
diff --git a/spec/menu-sort-helpers-spec.js b/spec/menu-sort-helpers-spec.js
new file mode 100644
index 000000000..86f00b37e
--- /dev/null
+++ b/spec/menu-sort-helpers-spec.js
@@ -0,0 +1,243 @@
+const {sortMenuItems} = require('../src/menu-sort-helpers')
+
+describe('contextMenu', () => {
+ describe('dedupes separators', () => {
+ it('preserves existing submenus', () => {
+ const items = [{ submenu: [] }]
+ expect(sortMenuItems(items)).toEqual(items)
+ })
+ })
+
+ describe('dedupes separators', () => {
+ it('trims leading separators', () => {
+ const items = [{ type: 'separator' }, { command: 'core:one' }]
+ const expected = [{ command: 'core:one' }]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('preserves separators at the begining of set two', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' }, { command: 'core:two' }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('trims trailing separators', () => {
+ const items = [{ command: 'core:one' }, { type: 'separator' }]
+ const expected = [{ command: 'core:one' }]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('removes duplicate separators across sets', () => {
+ const items = [
+ { command: 'core:one' }, { type: 'separator' },
+ { type: 'separator' }, { command: 'core:two' }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+ })
+
+ describe('can move an item to a different group by merging groups', () => {
+ it('can move a group of one item', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' },
+ { type: 'separator' },
+ { command: 'core:three', after: ['core:one'] },
+ { type: 'separator' }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { command: 'core:three', after: ['core:one'] },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it("moves all items in the moving item's group", () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' },
+ { type: 'separator' },
+ { command: 'core:three', after: ['core:one'] },
+ { command: 'core:four' },
+ { type: 'separator' }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { command: 'core:three', after: ['core:one'] },
+ { command: 'core:four' },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it("ignores positions relative to commands that don't exist", () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' },
+ { type: 'separator' },
+ { command: 'core:three', after: ['core:does-not-exist'] },
+ { command: 'core:four', after: ['core:one'] },
+ { type: 'separator' }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { command: 'core:three', after: ['core:does-not-exist'] },
+ { command: 'core:four', after: ['core:one'] },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('can handle recursive group merging', () => {
+ const items = [
+ { command: 'core:one', after: ['core:three'] },
+ { command: 'core:two', before: ['core:one'] },
+ { command: 'core:three' }
+ ]
+ const expected = [
+ { command: 'core:three' },
+ { command: 'core:two', before: ['core:one'] },
+ { command: 'core:one', after: ['core:three'] }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('can merge multiple groups when given a list of before/after commands', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' },
+ { type: 'separator' },
+ { command: 'core:three', after: ['core:one', 'core:two'] }
+ ]
+ const expected = [
+ { command: 'core:two' },
+ { command: 'core:one' },
+ { command: 'core:three', after: ['core:one', 'core:two'] }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+
+ it('can merge multiple groups based on both before/after commands', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' },
+ { type: 'separator' },
+ { command: 'core:three', after: ['core:one'], before: ['core:two'] }
+ ]
+ const expected = [
+ { command: 'core:one' },
+ { command: 'core:three', after: ['core:one'], before: ['core:two'] },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual(expected)
+ })
+ })
+
+ describe('sorts items within their ultimate group', () => {
+ it('does a simple sort', () => {
+ const items = [
+ { command: 'core:two', after: ['core:one'] },
+ { command: 'core:one' }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:one' },
+ { command: 'core:two', after: ['core:one'] }
+ ])
+ })
+
+ it('resolves cycles by ignoring things that conflict', () => {
+ const items = [
+ { command: 'core:two', after: ['core:one'] },
+ { command: 'core:one', after: ['core:two'] }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:one', after: ['core:two'] },
+ { command: 'core:two', after: ['core:one'] }
+ ])
+ })
+ })
+
+ describe('sorts groups', () => {
+ it('does a simple sort', () => {
+ const items = [
+ { command: 'core:two', afterGroupContaining: ['core:one'] },
+ { type: 'separator' },
+ { command: 'core:one' }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two', afterGroupContaining: ['core:one'] }
+ ])
+ })
+
+ it('resolves cycles by ignoring things that conflict', () => {
+ const items = [
+ { command: 'core:two', afterGroupContaining: ['core:one'] },
+ { type: 'separator' },
+ { command: 'core:one', afterGroupContaining: ['core:two'] }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:one', afterGroupContaining: ['core:two'] },
+ { type: 'separator' },
+ { command: 'core:two', afterGroupContaining: ['core:one'] }
+ ])
+ })
+
+ it('ignores references to commands that do not exist', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ {
+ command: 'core:two',
+ afterGroupContaining: ['core:does-not-exist']
+ }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two', afterGroupContaining: ['core:does-not-exist'] }
+ ])
+ })
+
+ it('only respects the first matching [before|after]GroupContaining rule in a given group', () => {
+ const items = [
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:three', beforeGroupContaining: ['core:one'] },
+ { command: 'core:four', afterGroupContaining: ['core:two'] },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ]
+ expect(sortMenuItems(items)).toEqual([
+ { command: 'core:three', beforeGroupContaining: ['core:one'] },
+ { command: 'core:four', afterGroupContaining: ['core:two'] },
+ { type: 'separator' },
+ { command: 'core:one' },
+ { type: 'separator' },
+ { command: 'core:two' }
+ ])
+ })
+ })
+})
diff --git a/spec/package-manager-spec.js b/spec/package-manager-spec.js
index b1ecf834d..dd87f85fa 100644
--- a/spec/package-manager-spec.js
+++ b/spec/package-manager-spec.js
@@ -30,13 +30,15 @@ describe('PackageManager', () => {
expect(packageManger.packageDirPaths[0]).toBe(path.join(configDirPath, 'packages'))
})
- it('adds regular package path and dev package path in dev mode', () => {
+ it('adds regular package path, dev package path, and Atom repo package path in dev mode and dev resource path is set', () => {
const packageManger = new PackageManager({})
const configDirPath = path.join('~', 'someConfig')
- packageManger.initialize({configDirPath, devMode: true})
- expect(packageManger.packageDirPaths.length).toBe(2)
+ const resourcePath = path.join('~', '/atom')
+ packageManger.initialize({configDirPath, resourcePath, devMode: true})
+ expect(packageManger.packageDirPaths.length).toBe(3)
expect(packageManger.packageDirPaths).toContain(path.join(configDirPath, 'packages'))
expect(packageManger.packageDirPaths).toContain(path.join(configDirPath, 'dev', 'packages'))
+ expect(packageManger.packageDirPaths).toContain(path.join(resourcePath, 'packages'))
})
})
@@ -1032,6 +1034,7 @@ describe('PackageManager', () => {
})
it('loads any tree-sitter grammars defined in the package', async () => {
+ atom.config.set('core.useTreeSitterParsers', true)
await atom.packages.activatePackage('package-with-tree-sitter-grammar')
const grammar = atom.grammars.selectGrammar('test.somelang')
expect(grammar.name).toBe('Some Language')
diff --git a/spec/pane-container-element-spec.coffee b/spec/pane-container-element-spec.coffee
index 21c1d000a..22651b489 100644
--- a/spec/pane-container-element-spec.coffee
+++ b/spec/pane-container-element-spec.coffee
@@ -237,3 +237,30 @@ describe "PaneContainerElement", ->
atom.commands.dispatch(rightPane.getElement(), 'pane:decrease-size')
expect(leftPane.getFlexScale()).toBe 1/1.1
expect(rightPane.getFlexScale()).toBe 1/1.1
+
+ describe "when only a single pane is present", ->
+ [singlePane] = []
+
+ beforeEach ->
+ container = new PaneContainer(params)
+ singlePane = container.getActivePane()
+
+ describe "when pane:increase-size is triggered", ->
+ it "does not increases the size of the pane", ->
+ expect(singlePane.getFlexScale()).toBe 1
+
+ atom.commands.dispatch(singlePane.getElement(), 'pane:increase-size')
+ expect(singlePane.getFlexScale()).toBe 1
+
+ atom.commands.dispatch(singlePane.getElement(), 'pane:increase-size')
+ expect(singlePane.getFlexScale()).toBe 1
+
+ describe "when pane:decrease-size is triggered", ->
+ it "does not decreases the size of the pane", ->
+ expect(singlePane.getFlexScale()).toBe 1
+
+ atom.commands.dispatch(singlePane.getElement(), 'pane:decrease-size')
+ expect(singlePane.getFlexScale()).toBe 1
+
+ atom.commands.dispatch(singlePane.getElement(), 'pane:decrease-size')
+ expect(singlePane.getFlexScale()).toBe 1
diff --git a/spec/pane-spec.js b/spec/pane-spec.js
index 8ef274c2d..ddb92b96e 100644
--- a/spec/pane-spec.js
+++ b/spec/pane-spec.js
@@ -219,6 +219,34 @@ describe('Pane', () => {
runs(() => expect(eventOrder).toEqual(['add', 'remove']))
})
+ it('subscribes to be notified when item terminates its pending state', () => {
+ const fakeDisposable = { dispose: () => {} }
+ const spy = jasmine.createSpy('onDidTerminatePendingState').andReturn((fakeDisposable))
+
+ const pane = new Pane(paneParams({items: []}))
+ const item = {
+ getTitle: () => '',
+ onDidTerminatePendingState: spy
+ }
+ pane.addItem(item)
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('subscribes to be notified when item is destroyed', () => {
+ const fakeDisposable = { dispose: () => {} }
+ const spy = jasmine.createSpy('onDidDestroy').andReturn((fakeDisposable))
+
+ const pane = new Pane(paneParams({items: []}))
+ const item = {
+ getTitle: () => '',
+ onDidDestroy: spy
+ }
+ pane.addItem(item)
+
+ expect(spy).toHaveBeenCalled()
+ })
+
describe('when using the old API of ::addItem(item, index)', () => {
beforeEach(() => spyOn(Grim, 'deprecate'))
diff --git a/spec/project-spec.js b/spec/project-spec.js
index bd6bb1fa6..861a0f53a 100644
--- a/spec/project-spec.js
+++ b/spec/project-spec.js
@@ -274,6 +274,51 @@ describe('Project', () => {
})
})
+ describe('.replace', () => {
+ let projectSpecification, projectPath1, projectPath2
+ beforeEach(() => {
+ atom.project.replace(null)
+ projectPath1 = temp.mkdirSync('project-path1')
+ projectPath2 = temp.mkdirSync('project-path2')
+ projectSpecification = {
+ paths: [projectPath1, projectPath2],
+ originPath: 'originPath',
+ config: {
+ 'baz': 'buzz'
+ }
+ }
+ })
+ it('sets a project specification', () => {
+ expect(atom.config.get('baz')).toBeUndefined()
+ atom.project.replace(projectSpecification)
+ expect(atom.project.getPaths()).toEqual([projectPath1, projectPath2])
+ expect(atom.config.get('baz')).toBe('buzz')
+ })
+
+ it('clears a project through replace with no params', () => {
+ expect(atom.config.get('baz')).toBeUndefined()
+ atom.project.replace(projectSpecification)
+ expect(atom.config.get('baz')).toBe('buzz')
+ expect(atom.project.getPaths()).toEqual([projectPath1, projectPath2])
+ atom.project.replace()
+ expect(atom.config.get('baz')).toBeUndefined()
+ expect(atom.project.getPaths()).toEqual([])
+ })
+
+ it('responds to change of project specification', () => {
+ let wasCalled = false
+ const callback = () => {
+ wasCalled = true
+ }
+ atom.project.onDidReplace(callback)
+ atom.project.replace(projectSpecification)
+ expect(wasCalled).toBe(true)
+ wasCalled = false
+ atom.project.replace()
+ expect(wasCalled).toBe(true)
+ })
+ })
+
describe('before and after saving a buffer', () => {
let buffer
beforeEach(() =>
@@ -924,6 +969,77 @@ describe('Project', () => {
})
})
+ describe('.observeRepositories()', () => {
+ it('invokes the observer with current and future repositories', () => {
+ const observed = []
+
+ const directory1 = temp.mkdirSync('git-repo1')
+ const gitDirPath1 = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
+ fs.copySync(gitDirPath1, path.join(directory1, '.git'))
+
+ const directory2 = temp.mkdirSync('git-repo2')
+ const gitDirPath2 = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'repo-with-submodules', 'git.git'))
+ fs.copySync(gitDirPath2, path.join(directory2, '.git'))
+
+ atom.project.setPaths([directory1])
+
+ const disposable = atom.project.observeRepositories((repo) => observed.push(repo))
+ expect(observed.length).toBe(1)
+ expect(observed[0].getReferenceTarget('refs/heads/master')).toBe('ef046e9eecaa5255ea5e9817132d4001724d6ae1')
+
+ atom.project.addPath(directory2)
+ expect(observed.length).toBe(2)
+ expect(observed[1].getReferenceTarget('refs/heads/master')).toBe('d2b0ad9cbc6f6c4372e8956e5cc5af771b2342e5')
+
+ disposable.dispose()
+ })
+ })
+
+ describe('.onDidAddRepository()', () => {
+ it('invokes callback when a path is added and the path is the root of a repository', () => {
+ const observed = []
+ const disposable = atom.project.onDidAddRepository((repo) => observed.push(repo))
+
+ const projectRootPath = temp.mkdirSync()
+ const fixtureRepoPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
+ fs.copySync(fixtureRepoPath, path.join(projectRootPath, '.git'))
+
+ atom.project.addPath(projectRootPath)
+ expect(observed.length).toBe(1)
+ expect(observed[0].getOriginURL()).toEqual('https://github.com/example-user/example-repo.git')
+
+ disposable.dispose()
+ })
+
+ it('invokes callback when a path is added and the path is subdirectory of a repository', () => {
+ const observed = []
+ const disposable = atom.project.onDidAddRepository((repo) => observed.push(repo))
+
+ const projectRootPath = temp.mkdirSync()
+ const fixtureRepoPath = fs.absolute(path.join(__dirname, 'fixtures', 'git', 'master.git'))
+ fs.copySync(fixtureRepoPath, path.join(projectRootPath, '.git'))
+
+ const projectSubDirPath = path.join(projectRootPath, 'sub-dir')
+ fs.mkdirSync(projectSubDirPath)
+
+ atom.project.addPath(projectSubDirPath)
+ expect(observed.length).toBe(1)
+ expect(observed[0].getOriginURL()).toEqual('https://github.com/example-user/example-repo.git')
+
+ disposable.dispose()
+ })
+
+ it('does not invoke callback when a path is added and the path is not part of a repository', () => {
+ const observed = []
+ const disposable = atom.project.onDidAddRepository((repo) => observed.push(repo))
+
+ atom.project.addPath(temp.mkdirSync('not-a-repository'))
+ expect(observed.length).toBe(0)
+
+ disposable.dispose()
+ })
+ })
+
describe('.relativize(path)', () => {
it('returns the path, relative to whichever root directory it is inside of', () => {
atom.project.addPath(temp.mkdirSync('another-path'))
diff --git a/spec/sample-with-comments.js b/spec/sample-with-comments.js
deleted file mode 100644
index 66dc9051d..000000000
--- a/spec/sample-with-comments.js
+++ /dev/null
@@ -1 +0,0 @@
-undefined
\ No newline at end of file
diff --git a/spec/selection-spec.js b/spec/selection-spec.js
index cb586da26..8afc67575 100644
--- a/spec/selection-spec.js
+++ b/spec/selection-spec.js
@@ -154,4 +154,118 @@ describe('Selection', () => {
expect(editor.isFoldedAtBufferRow(0)).toBe(false)
})
})
+
+ describe('within a read-only editor', () => {
+ beforeEach(() => {
+ editor.setReadOnly(true)
+ selection.setBufferRange([[0, 0], [0, 13]])
+ })
+
+ const modifications = [
+ {
+ name: 'insertText',
+ op: opts => selection.insertText('yes', opts)
+ },
+ {
+ name: 'backspace',
+ op: opts => selection.backspace(opts)
+ },
+ {
+ name: 'deleteToPreviousWordBoundary',
+ op: opts => selection.deleteToPreviousWordBoundary(opts)
+ },
+ {
+ name: 'deleteToNextWordBoundary',
+ op: opts => selection.deleteToNextWordBoundary(opts)
+ },
+ {
+ name: 'deleteToBeginningOfWord',
+ op: opts => selection.deleteToBeginningOfWord(opts)
+ },
+ {
+ name: 'deleteToBeginningOfLine',
+ op: opts => selection.deleteToBeginningOfLine(opts)
+ },
+ {
+ name: 'delete',
+ op: opts => selection.delete(opts)
+ },
+ {
+ name: 'deleteToEndOfLine',
+ op: opts => selection.deleteToEndOfLine(opts)
+ },
+ {
+ name: 'deleteToEndOfWord',
+ op: opts => selection.deleteToEndOfWord(opts)
+ },
+ {
+ name: 'deleteToBeginningOfSubword',
+ op: opts => selection.deleteToBeginningOfSubword(opts)
+ },
+ {
+ name: 'deleteToEndOfSubword',
+ op: opts => selection.deleteToEndOfSubword(opts)
+ },
+ {
+ name: 'deleteSelectedText',
+ op: opts => selection.deleteSelectedText(opts)
+ },
+ {
+ name: 'deleteLine',
+ op: opts => selection.deleteLine(opts)
+ },
+ {
+ name: 'joinLines',
+ op: opts => selection.joinLines(opts)
+ },
+ {
+ name: 'outdentSelectedRows',
+ op: opts => selection.outdentSelectedRows(opts)
+ },
+ {
+ name: 'autoIndentSelectedRows',
+ op: opts => selection.autoIndentSelectedRows(opts)
+ },
+ {
+ name: 'toggleLineComments',
+ op: opts => selection.toggleLineComments(opts)
+ },
+ {
+ name: 'cutToEndOfLine',
+ op: opts => selection.cutToEndOfLine(false, opts)
+ },
+ {
+ name: 'cutToEndOfBufferLine',
+ op: opts => selection.cutToEndOfBufferLine(false, opts)
+ },
+ {
+ name: 'cut',
+ op: opts => selection.cut(false, false, opts.bypassReadOnly)
+ },
+ {
+ name: 'indent',
+ op: opts => selection.indent(opts)
+ },
+ {
+ name: 'indentSelectedRows',
+ op: opts => selection.indentSelectedRows(opts)
+ },
+ ]
+
+ describe('without bypassReadOnly', () => {
+ for (const {name, op} of modifications) {
+ it(`throws an error on ${name}`, () => {
+ expect(op).toThrow()
+ })
+ }
+ })
+
+ describe('with bypassReadOnly', () => {
+ for (const {name, op} of modifications) {
+ it(`permits ${name}`, () => {
+ op({bypassReadOnly: true})
+ })
+ }
+ })
+ })
})
diff --git a/spec/text-editor-component-spec.js b/spec/text-editor-component-spec.js
index 97210dd41..66274a99a 100644
--- a/spec/text-editor-component-spec.js
+++ b/spec/text-editor-component-spec.js
@@ -26,6 +26,7 @@ document.registerElement('text-editor-component-test-element', {
})
const editors = []
+let verticalScrollbarWidth, horizontalScrollbarHeight
describe('TextEditorComponent', () => {
beforeEach(() => {
@@ -33,8 +34,15 @@ describe('TextEditorComponent', () => {
// Force scrollbars to be visible regardless of local system configuration
const scrollbarStyle = document.createElement('style')
- scrollbarStyle.textContent = '::-webkit-scrollbar { -webkit-appearance: none }'
+ scrollbarStyle.textContent = 'atom-text-editor ::-webkit-scrollbar { -webkit-appearance: none }'
jasmine.attachToDOM(scrollbarStyle)
+
+ if (verticalScrollbarWidth == null) {
+ const {component, element} = buildComponent({text: 'abcdefgh\n'.repeat(10), width: 30, height: 30})
+ verticalScrollbarWidth = getVerticalScrollbarWidth(component)
+ horizontalScrollbarHeight = getHorizontalScrollbarHeight(component)
+ element.remove()
+ }
})
afterEach(() => {
@@ -104,7 +112,7 @@ describe('TextEditorComponent', () => {
{
expect(editor.getApproximateLongestScreenRow()).toBe(3)
- const expectedWidth = Math.round(
+ const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(3, Infinity)).left +
component.getBaseCharacterWidth()
)
@@ -121,7 +129,7 @@ describe('TextEditorComponent', () => {
// Capture the width of the lines before requesting the width of
// longest line, because making that request forces a DOM update
const actualWidth = element.querySelector('.lines').style.width
- const expectedWidth = Math.round(
+ const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(6, Infinity)).left +
component.getBaseCharacterWidth()
)
@@ -184,8 +192,8 @@ describe('TextEditorComponent', () => {
})
it('makes the content at least as tall as the scroll container client height', async () => {
- const {component, element, editor} = buildComponent({text: 'a', height: 100})
- expect(component.refs.content.offsetHeight).toBe(100)
+ const {component, element, editor} = buildComponent({text: 'a'.repeat(100), width: 50, height: 100})
+ expect(component.refs.content.offsetHeight).toBe(100 - getHorizontalScrollbarHeight(component))
editor.setText('a\n'.repeat(30))
await component.getNextUpdatePromise()
@@ -201,7 +209,7 @@ describe('TextEditorComponent', () => {
await setEditorHeightInLines(component, 6)
// scroll to end
- await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight)
+ await setScrollTop(component, Infinity)
expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() - 3)
editor.update({scrollPastEnd: false})
@@ -211,7 +219,7 @@ describe('TextEditorComponent', () => {
// Always allows at least 3 lines worth of overscroll if the editor is short
await setEditorHeightInLines(component, 2)
await editor.update({scrollPastEnd: true})
- await setScrollTop(component, scrollContainer.scrollHeight - scrollContainer.clientHeight)
+ await setScrollTop(component, Infinity)
expect(component.getFirstVisibleRow()).toBe(editor.getScreenLineCount() + 1)
})
@@ -296,31 +304,6 @@ describe('TextEditorComponent', () => {
expect(lineNumberNodeForScreenRow(component, 0).querySelector('.foldable')).toBeNull()
})
- it('gracefully handles folds that change the soft-wrap boundary by causing the vertical scrollbar to disappear (regression)', async () => {
- const text = ('x'.repeat(100) + '\n') + 'y\n'.repeat(28) + ' z\n'.repeat(50)
- const {component, element, editor} = buildComponent({text, height: 1000, width: 500})
-
- element.addEventListener('scroll', (event) => {
- event.stopPropagation()
- }, true)
-
- editor.setSoftWrapped(true)
- jasmine.attachToDOM(element)
- await component.getNextUpdatePromise()
-
- const firstScreenLineLengthWithVerticalScrollbar = element.querySelector('.line').textContent.length
-
- setScrollTop(component, 620)
- await component.getNextUpdatePromise()
-
- editor.foldBufferRow(28)
- await component.getNextUpdatePromise()
-
- const firstLineElement = element.querySelector('.line')
- expect(firstLineElement.dataset.screenRow).toBe('0')
- expect(firstLineElement.textContent.length).toBeGreaterThan(firstScreenLineLengthWithVerticalScrollbar)
- })
-
it('shows the foldable icon on the last screen row of a buffer row that can be folded', async () => {
const {component, element, editor} = buildComponent({text: 'abc\n de\nfghijklm\n no', softWrapped: true})
await setEditorWidthInCharacters(component, 5)
@@ -361,18 +344,14 @@ describe('TextEditorComponent', () => {
expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0)
expect(getHorizontalScrollbarHeight(component)).toBe(0)
expect(verticalScrollbar.style.visibility).toBe('')
- expect(verticalScrollbar.style.bottom).toBe('0px')
expect(horizontalScrollbar.style.visibility).toBe('hidden')
- expect(component.refs.scrollbarCorner).toBeUndefined()
editor.setText('a'.repeat(100))
await component.getNextUpdatePromise()
expect(getVerticalScrollbarWidth(component)).toBe(0)
expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0)
expect(verticalScrollbar.style.visibility).toBe('hidden')
- expect(horizontalScrollbar.style.right).toBe('0px')
expect(horizontalScrollbar.style.visibility).toBe('')
- expect(component.refs.scrollbarCorner).toBeUndefined()
editor.setText('')
await component.getNextUpdatePromise()
@@ -380,37 +359,6 @@ describe('TextEditorComponent', () => {
expect(getHorizontalScrollbarHeight(component)).toBe(0)
expect(verticalScrollbar.style.visibility).toBe('hidden')
expect(horizontalScrollbar.style.visibility).toBe('hidden')
- expect(component.refs.scrollbarCorner).toBeUndefined()
-
- editor.setText(SAMPLE_TEXT)
- await component.getNextUpdatePromise()
-
- // Does not show scrollbars if the content perfectly fits
- element.style.width = component.getGutterContainerWidth() + component.getContentWidth() + 'px'
- element.style.height = component.getContentHeight() + 'px'
- await component.getNextUpdatePromise()
- expect(getVerticalScrollbarWidth(component)).toBe(0)
- expect(getHorizontalScrollbarHeight(component)).toBe(0)
- expect(verticalScrollbar.style.visibility).toBe('hidden')
- expect(horizontalScrollbar.style.visibility).toBe('hidden')
-
- // Shows scrollbars if the only reason we overflow is the presence of the
- // scrollbar for the opposite axis.
- element.style.width = component.getGutterContainerWidth() + component.getContentWidth() - 1 + 'px'
- element.style.height = component.getContentHeight() + component.getHorizontalScrollbarHeight() - 1 + 'px'
- await component.getNextUpdatePromise()
- expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0)
- expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0)
- expect(verticalScrollbar.style.visibility).toBe('')
- expect(horizontalScrollbar.style.visibility).toBe('')
-
- element.style.width = component.getGutterContainerWidth() + component.getContentWidth() + component.getVerticalScrollbarWidth() - 1 + 'px'
- element.style.height = component.getContentHeight() - 1 + 'px'
- await component.getNextUpdatePromise()
- expect(getVerticalScrollbarWidth(component)).toBeGreaterThan(0)
- expect(getHorizontalScrollbarHeight(component)).toBeGreaterThan(0)
- expect(verticalScrollbar.style.visibility).toBe('')
- expect(horizontalScrollbar.style.visibility).toBe('')
})
describe('when scrollbar styles change or the editor element is detached and then reattached', () => {
@@ -564,9 +512,20 @@ describe('TextEditorComponent', () => {
it('gives cursors at the end of lines the width of an "x" character', async () => {
const {component, element, editor} = buildComponent()
+ editor.setText('abcde')
+ await setEditorWidthInCharacters(component, 5.5)
+
editor.setCursorScreenPosition([0, Infinity])
await component.getNextUpdatePromise()
expect(element.querySelector('.cursor').offsetWidth).toBe(Math.round(component.getBaseCharacterWidth()))
+
+ // Clip cursor width when soft-wrap is on and the cursor is at the end of
+ // the line. This prevents the parent tile from disabling sub-pixel
+ // anti-aliasing. For some reason, adding overflow: hidden to the cursor
+ // container doesn't solve this issue so we're adding this workaround instead.
+ editor.setSoftWrapped(true)
+ await component.getNextUpdatePromise()
+ expect(element.querySelector('.cursor').offsetWidth).toBeLessThan(Math.round(component.getBaseCharacterWidth()))
})
it('positions and sizes cursors correctly when they are located next to a fold marker', async () => {
@@ -672,17 +631,6 @@ describe('TextEditorComponent', () => {
expect(scrollContainer.clientWidth).toBe(scrollContainer.scrollWidth)
})
- it('accounts for the width of the vertical scrollbar when soft-wrapping lines', async () => {
- const {component, element, editor} = buildComponent({
- height: 200,
- text: 'a'.repeat(300),
- softWrapped: true
- })
- await setEditorWidthInCharacters(component, 23)
- expect(Math.floor(component.getScrollContainerClientWidth() / component.getBaseCharacterWidth())).toBe(20)
- expect(editor.lineLengthForScreenRow(0)).toBe(20)
- })
-
it('correctly forces the display layer to index visible rows when resizing (regression)', async () => {
const text = 'a'.repeat(30) + '\n' + 'b'.repeat(1000)
const {component, element, editor} = buildComponent({height: 300, width: 800, attach: false, text})
@@ -707,7 +655,7 @@ describe('TextEditorComponent', () => {
editor.setText('a')
await component.getNextUpdatePromise()
- expect(element.querySelector('.line').offsetWidth).toBe(scrollContainer.offsetWidth)
+ expect(element.querySelector('.line').offsetWidth).toBe(scrollContainer.offsetWidth - verticalScrollbarWidth)
})
it('resizes based on the content when the autoHeight and/or autoWidth options are true', async () => {
@@ -717,44 +665,39 @@ describe('TextEditorComponent', () => {
const {gutterContainer, scrollContainer} = component.refs
const initialWidth = element.offsetWidth
const initialHeight = element.offsetHeight
- expect(initialWidth).toBe(component.getGutterContainerWidth() + component.getContentWidth() + 2 * editorPadding)
- expect(initialHeight).toBe(component.getContentHeight() + 2 * editorPadding)
+ expect(initialWidth).toBe(
+ component.getGutterContainerWidth() +
+ component.getContentWidth() +
+ verticalScrollbarWidth +
+ 2 * editorPadding
+ )
+ expect(initialHeight).toBe(
+ component.getContentHeight() +
+ horizontalScrollbarHeight +
+ 2 * editorPadding
+ )
// When autoWidth is enabled, width adjusts to content
editor.setCursorScreenPosition([6, Infinity])
editor.insertText('x'.repeat(50))
await component.getNextUpdatePromise()
- expect(element.offsetWidth).toBe(component.getGutterContainerWidth() + component.getContentWidth() + 2 * editorPadding)
+ expect(element.offsetWidth).toBe(
+ component.getGutterContainerWidth() +
+ component.getContentWidth() +
+ verticalScrollbarWidth +
+ 2 * editorPadding
+ )
expect(element.offsetWidth).toBeGreaterThan(initialWidth)
// When autoHeight is enabled, height adjusts to content
editor.insertText('\n'.repeat(5))
await component.getNextUpdatePromise()
- expect(element.offsetHeight).toBe(component.getContentHeight() + 2 * editorPadding)
- expect(element.offsetHeight).toBeGreaterThan(initialHeight)
-
- // When a horizontal scrollbar is visible, autoHeight accounts for it
- editor.update({autoWidth: false})
- await component.getNextUpdatePromise()
- element.style.width = component.getGutterContainerWidth() + component.getContentHeight() - 20 + 'px'
- await component.getNextUpdatePromise()
- expect(component.canScrollHorizontally()).toBe(true)
- expect(component.canScrollVertically()).toBe(false)
- expect(element.offsetHeight).toBe(component.getContentHeight() + component.getHorizontalScrollbarHeight() + 2 * editorPadding)
-
- // When a vertical scrollbar is visible, autoWidth accounts for it
- editor.update({autoWidth: true, autoHeight: false})
- await component.getNextUpdatePromise()
- element.style.height = component.getContentHeight() - 20
- await component.getNextUpdatePromise()
- expect(component.canScrollHorizontally()).toBe(false)
- expect(component.canScrollVertically()).toBe(true)
- expect(element.offsetWidth).toBe(
- component.getGutterContainerWidth() +
- component.getContentWidth() +
- component.getVerticalScrollbarWidth() +
+ expect(element.offsetHeight).toBe(
+ component.getContentHeight() +
+ horizontalScrollbarHeight +
2 * editorPadding
)
+ expect(element.offsetHeight).toBeGreaterThan(initialHeight)
})
it('does not render the line number gutter at all if the isLineNumberGutterVisible parameter is false', () => {
@@ -875,6 +818,18 @@ describe('TextEditorComponent', () => {
expect(element.className).toBe('editor a b')
})
+ it('does not blow away class names managed by the component when packages change the element class name', async () => {
+ assertDocumentFocused()
+ const {component, element, editor} = buildComponent({mini: true})
+ element.classList.add('a', 'b')
+ element.focus()
+ await component.getNextUpdatePromise()
+ expect(element.className).toBe('editor mini a b is-focused')
+ element.className = 'a c d';
+ await component.getNextUpdatePromise()
+ expect(element.className).toBe('a c d editor is-focused mini')
+ })
+
it('ignores resize events when the editor is hidden', async () => {
const {component, element, editor} = buildComponent({autoHeight: false})
element.style.height = 5 * component.getLineHeight() + 'px'
@@ -921,7 +876,7 @@ describe('TextEditorComponent', () => {
const initialSeed = Date.now()
for (var i = 0; i < 20; i++) {
let seed = initialSeed + i
- // seed = 1507224195357
+ // seed = 1520247533732
const failureMessage = 'Randomized test failed with seed: ' + seed
const random = Random(seed)
@@ -930,6 +885,12 @@ describe('TextEditorComponent', () => {
editor.setSoftWrapped(Boolean(random(2)))
await setEditorWidthInCharacters(component, random(20))
await setEditorHeightInLines(component, random(10))
+
+ element.style.fontSize = random(20) + 'px'
+ element.style.lineHeight = random.floatBetween(0.1, 2.0)
+ TextEditor.didUpdateStyles()
+ await component.getNextUpdatePromise()
+
element.focus()
for (var j = 0; j < 5; j++) {
@@ -1034,7 +995,6 @@ describe('TextEditorComponent', () => {
it('does not render scrollbars', async () => {
const {component, element, editor} = buildComponent({mini: true, autoHeight: false})
await setEditorWidthInCharacters(component, 10)
- await setEditorHeightInLines(component, 1)
editor.setText('x'.repeat(20) + 'y'.repeat(20))
await component.getNextUpdatePromise()
@@ -1119,7 +1079,7 @@ describe('TextEditorComponent', () => {
describe('autoscroll', () => {
it('automatically scrolls vertically when the requested range is within the vertical scroll margin of the top or bottom', async () => {
- const {component, editor} = buildComponent({height: 120})
+ const {component, editor} = buildComponent({height: 120 + horizontalScrollbarHeight})
expect(component.getLastVisibleRow()).toBe(7)
editor.scrollToScreenRange([[4, 0], [6, 0]])
@@ -1141,7 +1101,7 @@ describe('TextEditorComponent', () => {
it('does not vertically autoscroll by more than half of the visible lines if the editor is shorter than twice the scroll margin', async () => {
const {component, element, editor} = buildComponent({autoHeight: false})
- element.style.height = 5.5 * component.measurements.lineHeight + 'px'
+ element.style.height = 5.5 * component.measurements.lineHeight + horizontalScrollbarHeight + 'px'
await component.getNextUpdatePromise()
expect(component.getLastVisibleRow()).toBe(5)
const scrollMarginInLines = 2
@@ -1171,8 +1131,8 @@ describe('TextEditorComponent', () => {
await component.getNextUpdatePromise()
const actualScrollCenter = (component.getScrollTop() + component.getScrollBottom()) / 2
- const expectedScrollCenter = Math.round((4 + 7) / 2 * component.getLineHeight())
- expect(actualScrollCenter).toBe(expectedScrollCenter)
+ const expectedScrollCenter = (4 + 7) / 2 * component.getLineHeight()
+ expect(actualScrollCenter).toBeCloseTo(expectedScrollCenter, 0)
})
it('automatically scrolls horizontally when the requested range is within the horizontal scroll margin of the right edge of the gutter or right edge of the scroll container', async () => {
@@ -1185,29 +1145,27 @@ describe('TextEditorComponent', () => {
editor.scrollToScreenRange([[1, 12], [2, 28]])
await component.getNextUpdatePromise()
- let expectedScrollLeft = Math.round(
+ let expectedScrollLeft =
clientLeftForCharacter(component, 1, 12) -
lineNodeForScreenRow(component, 1).getBoundingClientRect().left -
(editor.horizontalScrollMargin * component.measurements.baseCharacterWidth)
- )
- expect(component.getScrollLeft()).toBe(expectedScrollLeft)
+ expect(component.getScrollLeft()).toBeCloseTo(expectedScrollLeft, 0)
editor.scrollToScreenRange([[1, 12], [2, 28]], {reversed: false})
await component.getNextUpdatePromise()
- expectedScrollLeft = Math.round(
+ expectedScrollLeft =
component.getGutterContainerWidth() +
clientLeftForCharacter(component, 2, 28) -
lineNodeForScreenRow(component, 2).getBoundingClientRect().left +
(editor.horizontalScrollMargin * component.measurements.baseCharacterWidth) -
component.getScrollContainerClientWidth()
- )
- expect(component.getScrollLeft()).toBe(expectedScrollLeft)
+ expect(component.getScrollLeft()).toBeCloseTo(expectedScrollLeft, 0)
})
it('does not horizontally autoscroll by more than half of the visible "base-width" characters if the editor is narrower than twice the scroll margin', async () => {
const {component, editor} = buildComponent({autoHeight: false})
await setEditorWidthInCharacters(component, 1.5 * editor.horizontalScrollMargin)
- const editorWidthInChars = component.getScrollContainerWidth() / component.getBaseCharacterWidth()
+ const editorWidthInChars = component.getScrollContainerClientWidth() / component.getBaseCharacterWidth()
expect(Math.round(editorWidthInChars)).toBe(9)
editor.scrollToScreenRange([[6, 10], [6, 15]])
@@ -1307,22 +1265,22 @@ describe('TextEditorComponent', () => {
// Assigns the scrollTop based on the logical position when attached
jasmine.attachToDOM(element)
- expect(component.getScrollLeft()).toBe(Math.round(2 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(2 * component.getBaseCharacterWidth(), 0)
// Allows the scrollTopRow to be updated while attached
component.setScrollLeftColumn(4)
- expect(component.getScrollLeft()).toBe(Math.round(4 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(4 * component.getBaseCharacterWidth(), 0)
// Preserves the scrollTopRow when detached
element.remove()
- expect(component.getScrollLeft()).toBe(Math.round(4 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(4 * component.getBaseCharacterWidth(), 0)
component.setScrollLeftColumn(6)
- expect(component.getScrollLeft()).toBe(Math.round(6 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(6 * component.getBaseCharacterWidth(), 0)
jasmine.attachToDOM(element)
element.style.width = '60px'
- expect(component.getScrollLeft()).toBe(Math.round(6 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(6 * component.getBaseCharacterWidth(), 0)
})
})
@@ -1368,40 +1326,6 @@ describe('TextEditorComponent', () => {
}
})
- it('always scrolls by a minimum of 1, even when the delta is small or the scroll sensitivity is low', () => {
- const scrollSensitivity = 10
- const {component, editor} = buildComponent({height: 50, width: 50, scrollSensitivity})
-
- {
- component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: -3})
- expect(component.getScrollTop()).toBe(1)
- expect(component.getScrollLeft()).toBe(0)
- expect(component.refs.content.style.transform).toBe(`translate(0px, -1px)`)
- }
-
- {
- component.didMouseWheel({wheelDeltaX: -4, wheelDeltaY: 0})
- expect(component.getScrollTop()).toBe(1)
- expect(component.getScrollLeft()).toBe(1)
- expect(component.refs.content.style.transform).toBe(`translate(-1px, -1px)`)
- }
-
- editor.update({scrollSensitivity: 100})
- {
- component.didMouseWheel({wheelDeltaX: 0, wheelDeltaY: 0.3})
- expect(component.getScrollTop()).toBe(0)
- expect(component.getScrollLeft()).toBe(1)
- expect(component.refs.content.style.transform).toBe(`translate(-1px, 0px)`)
- }
-
- {
- component.didMouseWheel({wheelDeltaX: 0.1, wheelDeltaY: 0})
- expect(component.getScrollTop()).toBe(0)
- expect(component.getScrollLeft()).toBe(0)
- expect(component.refs.content.style.transform).toBe(`translate(0px, 0px)`)
- }
- })
-
it('inverts deltaX and deltaY when holding shift on Windows and Linux', async () => {
const scrollSensitivity = 50
const {component, editor} = buildComponent({height: 50, width: 50, scrollSensitivity})
@@ -2130,6 +2054,37 @@ describe('TextEditorComponent', () => {
expect(decorationNode2.firstChild).toBeNull()
expect(gutterB.getElement().firstChild.children.length).toBe(0)
})
+
+ it('renders custom line number gutters', async () => {
+ const {component, editor} = buildComponent()
+ const gutterA = editor.addGutter({
+ name: 'a',
+ priority: 1,
+ type: 'line-number',
+ class: 'a-number',
+ labelFn: ({bufferRow}) => `a - ${bufferRow}`
+ })
+ const gutterB = editor.addGutter({
+ name: 'b',
+ priority: 1,
+ type: 'line-number',
+ class: 'b-number',
+ labelFn: ({bufferRow}) => `b - ${bufferRow}`
+ })
+ editor.setText('0000\n0001\n0002\n0003\n0004\n')
+
+ await component.getNextUpdatePromise()
+
+ const gutterAElement = gutterA.getElement()
+ const aNumbers = gutterAElement.querySelectorAll('div.line-number[data-buffer-row]')
+ const aLabels = Array.from(aNumbers, e => e.textContent)
+ expect(aLabels).toEqual(['a - 0', 'a - 1', 'a - 2', 'a - 3', 'a - 4', 'a - 5'])
+
+ const gutterBElement = gutterB.getElement()
+ const bNumbers = gutterBElement.querySelectorAll('div.line-number[data-buffer-row]')
+ const bLabels = Array.from(bNumbers, e => e.textContent)
+ expect(bLabels).toEqual(['b - 0', 'b - 1', 'b - 2', 'b - 3', 'b - 4', 'b - 5'])
+ })
})
describe('block decorations', () => {
@@ -2140,7 +2095,8 @@ describe('TextEditorComponent', () => {
// render an editor that already contains some block decorations
const {component, element} = buildComponent({editor, rowsPerTile: 3})
- await setEditorHeightInLines(component, 4)
+ element.style.height = 4 * component.getLineHeight() + horizontalScrollbarHeight + 'px'
+ await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
expect(component.getRenderedEndRow()).toBe(9)
expect(component.getScrollHeight()).toBe(
@@ -2355,7 +2311,7 @@ describe('TextEditorComponent', () => {
component.element.style.width = (
component.getGutterContainerWidth() +
component.getScrollContainerClientWidth() * 2 +
- component.getVerticalScrollbarWidth()
+ verticalScrollbarWidth
) + 'px'
await component.getNextUpdatePromise()
expect(component.getRenderedStartRow()).toBe(0)
@@ -2840,9 +2796,9 @@ describe('TextEditorComponent', () => {
describe('mouse input', () => {
describe('on the lines', () => {
- describe('when there is only one cursor and no selection', () => {
- it('positions the cursor on single-click or when middle/right-clicking', async () => {
- for (const button of [0, 1, 2]) {
+ describe('when there is only one cursor', () => {
+ it('positions the cursor on single-click or when middle-clicking', async () => {
+ for (const button of [0, 1]) {
const {component, element, editor} = buildComponent()
const {lineHeight} = component.measurements
@@ -2921,70 +2877,6 @@ describe('TextEditorComponent', () => {
})
})
- describe('when there is more than one cursor', () => {
- it('does not move the cursor when right-clicking', async () => {
- const {component, element, editor} = buildComponent()
- const {lineHeight} = component.measurements
-
- editor.setCursorScreenPosition([5, 17], {autoscroll: false})
- editor.addCursorAtScreenPosition([2, 4])
- component.didMouseDownOnContent({
- detail: 1,
- button: 2,
- clientX: clientLeftForCharacter(component, 0, 0) - 1,
- clientY: clientTopForLine(component, 0) - 1
- })
- expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([5, 17]), Point.fromObject([2, 4])])
- })
-
- it('does move the cursor when middle-clicking', async () => {
- const {component, element, editor} = buildComponent()
- const {lineHeight} = component.measurements
-
- editor.setCursorScreenPosition([5, 17], {autoscroll: false})
- editor.addCursorAtScreenPosition([2, 4])
- component.didMouseDownOnContent({
- detail: 1,
- button: 1,
- clientX: clientLeftForCharacter(component, 0, 0) - 1,
- clientY: clientTopForLine(component, 0) - 1
- })
- expect(editor.getCursorScreenPositions()).toEqual([Point.fromObject([0, 0])])
- })
- })
-
- describe('when there are non-empty selections', () => {
- it('does not move the cursor when right-clicking', async () => {
- const {component, element, editor} = buildComponent()
- const {lineHeight} = component.measurements
-
- editor.setCursorScreenPosition([5, 17], {autoscroll: false})
- editor.selectRight(3)
- component.didMouseDownOnContent({
- detail: 1,
- button: 2,
- clientX: clientLeftForCharacter(component, 0, 0) - 1,
- clientY: clientTopForLine(component, 0) - 1
- })
- expect(editor.getSelectedScreenRange()).toEqual([[5, 17], [5, 20]])
- })
-
- it('does move the cursor when middle-clicking', async () => {
- const {component, element, editor} = buildComponent()
- const {lineHeight} = component.measurements
-
- editor.setCursorScreenPosition([5, 17], {autoscroll: false})
- editor.selectRight(3)
- component.didMouseDownOnContent({
- detail: 1,
- button: 1,
- clientX: clientLeftForCharacter(component, 0, 0) - 1,
- clientY: clientTopForLine(component, 0) - 1
- })
- expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]])
- })
- })
-
describe('when the input is for the primary mouse button', () => {
it('selects words on double-click', () => {
const {component, editor} = buildComponent()
@@ -3056,7 +2948,7 @@ describe('TextEditorComponent', () => {
[[1, 16], [1, 16]]
])
- // ctrl-click does not add cursors on macOS, but it *does* move the cursor
+ // ctrl-click does not add cursors on macOS, nor does it move the cursor
component.didMouseDownOnContent(
Object.assign(clientPositionForCharacter(component, 1, 4), {
detail: 1,
@@ -3065,7 +2957,7 @@ describe('TextEditorComponent', () => {
})
)
expect(editor.getSelectedScreenRanges()).toEqual([
- [[1, 4], [1, 4]]
+ [[1, 16], [1, 16]]
])
// ctrl-click adds cursors on platforms *other* than macOS
@@ -3374,6 +3266,31 @@ describe('TextEditorComponent', () => {
})
expect(editor.lineTextForBufferRow(10)).toBe('var')
})
+
+ it('does not paste into a read only editor when clicking the middle mouse button on Linux', async () => {
+ spyOn(electron.ipcRenderer, 'send').andCallFake(function (eventName, selectedText) {
+ if (eventName === 'write-text-to-selection-clipboard') {
+ clipboard.writeText(selectedText, 'selection')
+ }
+ })
+
+ const {component, editor} = buildComponent({platform: 'linux', readOnly: true})
+
+ // Select the word 'sort' on line 2 and copy to clipboard
+ editor.setSelectedBufferRange([[1, 6], [1, 10]])
+ await conditionPromise(() => TextEditor.clipboard.read() === 'sort')
+
+ // Middle-click in the buffer at line 11, column 1
+ component.didMouseDownOnContent({
+ button: 1,
+ clientX: clientLeftForCharacter(component, 10, 0),
+ clientY: clientTopForLine(component, 10)
+ })
+
+ // Ensure that the correct text was copied but not pasted
+ expect(TextEditor.clipboard.read()).toBe('sort')
+ expect(editor.lineTextForBufferRow(10)).toBe('')
+ })
})
describe('on the line number gutter', () => {
@@ -3624,12 +3541,12 @@ describe('TextEditorComponent', () => {
describe('on the scrollbars', () => {
it('delegates the mousedown events to the parent component unless the mousedown was on the actual scrollbar', async () => {
const {component, element, editor} = buildComponent({height: 100})
- await setEditorWidthInCharacters(component, 8.5)
+ await setEditorWidthInCharacters(component, 6)
const verticalScrollbar = component.refs.verticalScrollbar
const horizontalScrollbar = component.refs.horizontalScrollbar
- const leftEdgeOfVerticalScrollbar = verticalScrollbar.element.getBoundingClientRect().right - getVerticalScrollbarWidth(component)
- const topEdgeOfHorizontalScrollbar = horizontalScrollbar.element.getBoundingClientRect().bottom - getHorizontalScrollbarHeight(component)
+ const leftEdgeOfVerticalScrollbar = verticalScrollbar.element.getBoundingClientRect().right - verticalScrollbarWidth
+ const topEdgeOfHorizontalScrollbar = horizontalScrollbar.element.getBoundingClientRect().bottom - horizontalScrollbarHeight
verticalScrollbar.didMouseDown({
button: 0,
@@ -3980,7 +3897,7 @@ describe('TextEditorComponent', () => {
// Capture the width of the lines before requesting the width of
// longest line, because making that request forces a DOM update
const actualWidth = element.querySelector('.lines').style.width
- const expectedWidth = Math.round(
+ const expectedWidth = Math.ceil(
component.pixelPositionForScreenPosition(Point(3, Infinity)).left +
component.getBaseCharacterWidth()
)
@@ -4191,7 +4108,7 @@ describe('TextEditorComponent', () => {
it('assigns scrollTop on the component when calling setFirstVisibleScreenRow', async () => {
const {component, element, editor} = buildComponent({rowsPerTile: 3, autoHeight: false})
- element.style.height = 4 * component.measurements.lineHeight + 'px'
+ element.style.height = 4 * component.measurements.lineHeight + horizontalScrollbarHeight + 'px'
await component.getNextUpdatePromise()
expect(component.getMaxScrollTop() / component.getLineHeight()).toBe(9)
@@ -4218,17 +4135,17 @@ describe('TextEditorComponent', () => {
element.style.width = 30 * component.getBaseCharacterWidth() + 'px'
await component.getNextUpdatePromise()
expect(editor.getFirstVisibleScreenColumn()).toBe(0)
- expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(0 * component.getBaseCharacterWidth())
+ expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(0)
setScrollLeft(component, 5.5 * component.getBaseCharacterWidth())
expect(editor.getFirstVisibleScreenColumn()).toBe(5)
await component.getNextUpdatePromise()
- expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(Math.round(5.5 * component.getBaseCharacterWidth()))
+ expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeCloseTo(5.5 * component.getBaseCharacterWidth(), -1)
editor.setFirstVisibleScreenColumn(12)
- expect(component.getScrollLeft()).toBe(Math.round(12 * component.getBaseCharacterWidth()))
+ expect(component.getScrollLeft()).toBeCloseTo(12 * component.getBaseCharacterWidth(), -1)
await component.getNextUpdatePromise()
- expect(component.refs.horizontalScrollbar.element.scrollLeft).toBe(Math.round(12 * component.getBaseCharacterWidth()))
+ expect(component.refs.horizontalScrollbar.element.scrollLeft).toBeCloseTo(12 * component.getBaseCharacterWidth(), -1)
})
})
@@ -4329,7 +4246,7 @@ describe('TextEditorComponent', () => {
function buildEditor (params = {}) {
const text = params.text != null ? params.text : SAMPLE_TEXT
const buffer = new TextBuffer({text})
- const editorParams = {buffer}
+ const editorParams = {buffer, readOnly: params.readOnly}
if (params.height != null) params.autoHeight = false
for (const paramName of ['mini', 'autoHeight', 'autoWidth', 'lineNumberGutterVisible', 'showLineNumbers', 'placeholderText', 'softWrapped', 'scrollSensitivity']) {
if (params[paramName] != null) editorParams[paramName] = params[paramName]
@@ -4375,6 +4292,7 @@ async function setEditorWidthInCharacters (component, widthInCharacters) {
component.element.style.width =
component.getGutterContainerWidth() +
widthInCharacters * component.measurements.baseCharacterWidth +
+ verticalScrollbarWidth +
'px'
await component.getNextUpdatePromise()
}
diff --git a/spec/text-editor-element-spec.js b/spec/text-editor-element-spec.js
index 7ffdf374d..d6c33e7ad 100644
--- a/spec/text-editor-element-spec.js
+++ b/spec/text-editor-element-spec.js
@@ -9,7 +9,7 @@ describe('TextEditorElement', () => {
jasmineContent = document.body.querySelector('#jasmine-content')
// Force scrollbars to be visible regardless of local system configuration
const scrollbarStyle = document.createElement('style')
- scrollbarStyle.textContent = '::-webkit-scrollbar { -webkit-appearance: none }'
+ scrollbarStyle.textContent = 'atom-text-editor ::-webkit-scrollbar { -webkit-appearance: none }'
jasmine.attachToDOM(scrollbarStyle)
})
@@ -338,18 +338,20 @@ describe('TextEditorElement', () => {
element.style.width = '200px'
jasmine.attachToDOM(element)
+ const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight()
+
expect(element.getMaxScrollTop()).toBe(0)
await editor.update({autoHeight: false})
- element.style.height = '100px'
+ element.style.height = 100 + horizontalScrollbarHeight + 'px'
await element.getNextUpdatePromise()
expect(element.getMaxScrollTop()).toBe(60)
- element.style.height = '120px'
+ element.style.height = 120 + horizontalScrollbarHeight + 'px'
await element.getNextUpdatePromise()
expect(element.getMaxScrollTop()).toBe(40)
- element.style.height = '200px'
+ element.style.height = 200 + horizontalScrollbarHeight + 'px'
await element.getNextUpdatePromise()
expect(element.getMaxScrollTop()).toBe(0)
})
@@ -392,10 +394,13 @@ describe('TextEditorElement', () => {
it('returns true if the given row range intersects the visible row range', async () => {
const element = buildTextEditorElement()
const editor = element.getModel()
+ const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight()
+
editor.update({autoHeight: false})
element.getModel().setText('x\n'.repeat(20))
- element.style.height = '120px'
+ element.style.height = 120 + horizontalScrollbarHeight + 'px'
await element.getNextUpdatePromise()
+
element.setScrollTop(80)
await element.getNextUpdatePromise()
expect(element.getVisibleRowRange()).toEqual([4, 11])
@@ -412,9 +417,11 @@ describe('TextEditorElement', () => {
it('returns a {top/left/width/height} object describing the rectangle between two screen positions, even if they are not on screen', async () => {
const element = buildTextEditorElement()
const editor = element.getModel()
+ const horizontalScrollbarHeight = element.component.getHorizontalScrollbarHeight()
+
editor.update({autoHeight: false})
element.getModel().setText('xxxxxxxxxxxxxxxxxxxxxx\n'.repeat(20))
- element.style.height = '120px'
+ element.style.height = 120 + horizontalScrollbarHeight + 'px'
await element.getNextUpdatePromise()
element.setScrollTop(80)
await element.getNextUpdatePromise()
diff --git a/spec/text-editor-registry-spec.js b/spec/text-editor-registry-spec.js
index e3086a302..4c6d680eb 100644
--- a/spec/text-editor-registry-spec.js
+++ b/spec/text-editor-registry-spec.js
@@ -1,6 +1,7 @@
const TextEditorRegistry = require('../src/text-editor-registry')
const TextEditor = require('../src/text-editor')
const TextBuffer = require('text-buffer')
+const {Point, Range} = TextBuffer
const {it, fit, ffit, fffit} = require('./async-spec-helpers')
const dedent = require('dedent')
@@ -154,6 +155,45 @@ describe('TextEditorRegistry', function () {
expect(editor.getEncoding()).toBe('utf8')
})
+ it('preserves editor settings that haven\'t changed between previous and current language modes', async function () {
+ await atom.packages.activatePackage('language-javascript')
+
+ registry.maintainConfig(editor)
+ await initialPackageActivation
+
+ expect(editor.getEncoding()).toBe('utf8')
+ editor.setEncoding('utf16le')
+ expect(editor.getEncoding()).toBe('utf16le')
+
+ expect(editor.isSoftWrapped()).toBe(false)
+ editor.setSoftWrapped(true)
+ expect(editor.isSoftWrapped()).toBe(true)
+
+ atom.grammars.assignLanguageMode(editor, 'source.js')
+ await initialPackageActivation
+ expect(editor.getEncoding()).toBe('utf16le')
+ expect(editor.isSoftWrapped()).toBe(true)
+ })
+
+ it('updates editor settings that have changed between previous and current language modes', async function () {
+ await atom.packages.activatePackage('language-javascript')
+
+ registry.maintainConfig(editor)
+ await initialPackageActivation
+
+ expect(editor.getEncoding()).toBe('utf8')
+ atom.config.set('core.fileEncoding', 'utf16be', {scopeSelector: '.text.plain.null-grammar'})
+ atom.config.set('core.fileEncoding', 'utf16le', {scopeSelector: '.source.js'})
+ expect(editor.getEncoding()).toBe('utf16be')
+
+ editor.setEncoding('utf8')
+ expect(editor.getEncoding()).toBe('utf8')
+
+ atom.grammars.assignLanguageMode(editor, 'source.js')
+ await initialPackageActivation
+ expect(editor.getEncoding()).toBe('utf16le')
+ })
+
it('returns a disposable that can be used to stop the registry from updating the editor\'s config', async function () {
await atom.packages.activatePackage('language-javascript')
@@ -218,19 +258,19 @@ describe('TextEditorRegistry', function () {
describe('when the "tabType" config setting is "auto"', function () {
it('enables or disables soft tabs based on the editor\'s content', async function () {
+ await initialPackageActivation
await atom.packages.activatePackage('language-javascript')
atom.grammars.assignLanguageMode(editor, 'source.js')
atom.config.set('editor.tabType', 'auto')
-
- registry.maintainConfig(editor)
await initialPackageActivation
+ const languageMode = editor.getBuffer().getLanguageMode()
editor.setText(dedent`
{
hello;
}
`)
- editor.getBuffer().getLanguageMode().retokenizeLines()
+ let disposable = registry.maintainConfig(editor)
expect(editor.getSoftTabs()).toBe(true)
editor.setText(dedent`
@@ -238,18 +278,17 @@ describe('TextEditorRegistry', function () {
hello;
}
`)
- editor.getBuffer().getLanguageMode().retokenizeLines()
+ disposable.dispose()
+ disposable = registry.maintainConfig(editor)
expect(editor.getSoftTabs()).toBe(false)
- editor.setText(dedent`
+ editor.setTextInBufferRange(new Range(Point.ZERO, Point.ZERO), dedent`
/*
* Comment with a leading space.
*/
- {
- ${'\t'}hello;
- }
- ` + editor.getText())
- editor.getBuffer().getLanguageMode().retokenizeLines()
+ ` + '\n')
+ disposable.dispose()
+ disposable = registry.maintainConfig(editor)
expect(editor.getSoftTabs()).toBe(false)
editor.setText(dedent`
@@ -261,8 +300,8 @@ describe('TextEditorRegistry', function () {
hello;
}
`)
-
- editor.getBuffer().getLanguageMode().retokenizeLines()
+ disposable.dispose()
+ disposable = registry.maintainConfig(editor)
expect(editor.getSoftTabs()).toBe(false)
editor.setText(dedent`
@@ -274,7 +313,8 @@ describe('TextEditorRegistry', function () {
hello;
}
`)
- editor.getBuffer().getLanguageMode().retokenizeLines()
+ disposable.dispose()
+ disposable = registry.maintainConfig(editor)
expect(editor.getSoftTabs()).toBe(true)
})
})
diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js
index 32883a01d..9041d3528 100644
--- a/spec/text-editor-spec.js
+++ b/spec/text-editor-spec.js
@@ -861,6 +861,15 @@ describe('TextEditor', () => {
})
})
})
+
+ it("clears the goal column", () => {
+ editor.setText('first\n\nthird')
+ editor.setCursorScreenPosition([0, 3])
+ editor.moveDown()
+ editor.moveToFirstCharacterOfLine()
+ editor.moveDown()
+ expect(editor.getCursorBufferPosition()).toEqual([2, 0])
+ })
})
describe('.moveToBeginningOfWord()', () => {
@@ -5193,6 +5202,111 @@ describe('TextEditor', () => {
})
})
+ describe('undo/redo restore selections of editor which initiated original change', () => {
+ let editor1, editor2
+
+ beforeEach(async () => {
+ editor1 = editor
+ editor2 = new TextEditor({buffer: editor1.buffer})
+
+ editor1.setText(dedent `
+ aaaaaa
+ bbbbbb
+ cccccc
+ dddddd
+ eeeeee
+ `)
+ })
+
+ it('[editor.transact] restore selection of change-initiated-editor', () => {
+ editor1.setCursorBufferPosition([0, 0]); editor1.transact(() => editor1.insertText('1'))
+ editor2.setCursorBufferPosition([1, 0]); editor2.transact(() => editor2.insertText('2'))
+ editor1.setCursorBufferPosition([2, 0]); editor1.transact(() => editor1.insertText('3'))
+ editor2.setCursorBufferPosition([3, 0]); editor2.transact(() => editor2.insertText('4'))
+
+ expect(editor1.getText()).toBe(dedent `
+ 1aaaaaa
+ 2bbbbbb
+ 3cccccc
+ 4dddddd
+ eeeeee
+ `)
+
+ editor2.setCursorBufferPosition([4, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([3, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([2, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([1, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([0, 0])
+ expect(editor2.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([0, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([1, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([2, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([3, 1])
+ expect(editor2.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor1.setCursorBufferPosition([4, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([3, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([2, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([1, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([0, 0])
+ expect(editor1.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([0, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([1, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([2, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([3, 1])
+ expect(editor1.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+ })
+
+ it('[manually group checkpoint] restore selection of change-initiated-editor', () => {
+ const transact = (editor, fn) => {
+ const checkpoint = editor.createCheckpoint()
+ fn()
+ editor.groupChangesSinceCheckpoint(checkpoint)
+ }
+
+ editor1.setCursorBufferPosition([0, 0]); transact(editor1, () => editor1.insertText('1'))
+ editor2.setCursorBufferPosition([1, 0]); transact(editor2, () => editor2.insertText('2'))
+ editor1.setCursorBufferPosition([2, 0]); transact(editor1, () => editor1.insertText('3'))
+ editor2.setCursorBufferPosition([3, 0]); transact(editor2, () => editor2.insertText('4'))
+
+ expect(editor1.getText()).toBe(dedent `
+ 1aaaaaa
+ 2bbbbbb
+ 3cccccc
+ 4dddddd
+ eeeeee
+ `)
+
+ editor2.setCursorBufferPosition([4, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([3, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([2, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([1, 0])
+ editor1.undo(); expect(editor1.getCursorBufferPosition()).toEqual([0, 0])
+ expect(editor2.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([0, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([1, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([2, 1])
+ editor1.redo(); expect(editor1.getCursorBufferPosition()).toEqual([3, 1])
+ expect(editor2.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor1.setCursorBufferPosition([4, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([3, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([2, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([1, 0])
+ editor2.undo(); expect(editor2.getCursorBufferPosition()).toEqual([0, 0])
+ expect(editor1.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([0, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([1, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([2, 1])
+ editor2.redo(); expect(editor2.getCursorBufferPosition()).toEqual([3, 1])
+ expect(editor1.getCursorBufferPosition()).toEqual([4, 0]) // remain unchanged
+ })
+ })
+
describe('when the buffer is changed (via its direct api, rather than via than edit session)', () => {
it('moves the cursor so it is in the same relative position of the buffer', () => {
expect(editor.getCursorScreenPosition()).toEqual([0, 0])
@@ -5383,6 +5497,195 @@ describe('TextEditor', () => {
})
})
})
+
+ describe('when readonly', () => {
+ beforeEach(() => {
+ editor.setReadOnly(true)
+ })
+
+ const modifications = [
+ {
+ name: 'moveLineUp',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([1, 0])
+ editor.moveLineUp(opts)
+ }
+ },
+ {
+ name: 'moveLineDown',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([0, 0])
+ editor.moveLineDown(opts)
+ }
+ },
+ {
+ name: 'insertText',
+ op: (opts = {}) => {
+ editor.setSelectedBufferRange([[1, 0], [1, 2]])
+ editor.insertText('xxx', opts)
+ }
+ },
+ {
+ name: 'insertNewline',
+ op: (opts = {}) => {
+ editor.setCursorScreenPosition({row: 1, column: 0})
+ editor.insertNewline(opts)
+ }
+ },
+ {
+ name: 'insertNewlineBelow',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([0, 2])
+ editor.insertNewlineBelow(opts)
+ }
+ },
+ {
+ name: 'insertNewlineAbove',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([0])
+ editor.insertNewlineAbove(opts)
+ }
+ },
+ {
+ name: 'backspace',
+ op: (opts = {}) => {
+ editor.setCursorScreenPosition({row: 1, column: 7})
+ editor.backspace(opts)
+ }
+ },
+ {
+ name: 'deleteToPreviousWordBoundary',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([0, 16])
+ editor.deleteToPreviousWordBoundary(opts)
+ }
+ },
+ {
+ name: 'deleteToNextWordBoundary',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([0, 15])
+ editor.deleteToNextWordBoundary(opts)
+ }
+ },
+ {
+ name: 'deleteToBeginningOfWord',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([1, 24])
+ editor.deleteToBeginningOfWord(opts)
+ }
+ },
+ {
+ name: 'deleteToEndOfLine',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([1, 24])
+ editor.deleteToEndOfLine(opts)
+ }
+ },
+ {
+ name: 'deleteToBeginningOfLine',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([1, 24])
+ editor.deleteToBeginningOfLine(opts)
+ }
+ },
+ {
+ name: 'delete',
+ op: (opts = {}) => {
+ editor.setCursorScreenPosition([1, 6])
+ editor.delete(opts)
+ }
+ },
+ {
+ name: 'deleteToEndOfWord',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([1, 24])
+ editor.deleteToEndOfWord(opts)
+ }
+ },
+ {
+ name: 'indent',
+ op: (opts = {}) => {
+ editor.indent(opts)
+ }
+ },
+ {
+ name: 'cutSelectedText',
+ op: (opts = {}) => {
+ editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]])
+ editor.cutSelectedText(opts)
+ }
+ },
+ {
+ name: 'cutToEndOfLine',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([2, 20])
+ editor.cutToEndOfLine(opts)
+ }
+ },
+ {
+ name: 'cutToEndOfBufferLine',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([2, 20])
+ editor.cutToEndOfBufferLine(opts)
+ }
+ },
+ {
+ name: 'pasteText',
+ op: (opts = {}) => {
+ editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]])
+ atom.clipboard.write('first')
+ editor.pasteText(opts)
+ }
+ },
+ {
+ name: 'indentSelectedRows',
+ op: (opts = {}) => {
+ editor.setSelectedBufferRange([[0, 3], [0, 3]])
+ editor.indentSelectedRows(opts)
+ }
+ },
+ {
+ name: 'outdentSelectedRows',
+ op: (opts = {}) => {
+ editor.setSelectedBufferRange([[1, 3], [1, 3]])
+ editor.outdentSelectedRows(opts)
+ }
+ },
+ {
+ name: 'autoIndentSelectedRows',
+ op: (opts = {}) => {
+ editor.setCursorBufferPosition([2, 0])
+ editor.insertText('function() {\ninside=true\n}\n i=1\n', opts)
+ editor.getLastSelection().setBufferRange([[2, 0], [6, 0]])
+ editor.autoIndentSelectedRows(opts)
+ }
+ },
+ {
+ name: 'undo/redo',
+ op: (opts = {}) => {
+ editor.insertText('foo', opts)
+ editor.undo(opts)
+ editor.redo(opts)
+ }
+ }
+ ]
+
+ describe('without bypassReadOnly', () => {
+ for (const {name, op} of modifications) {
+ it(`throws an error on ${name}`, () => {
+ expect(op).toThrow()
+ })
+ }
+ })
+
+ describe('with bypassReadOnly', () => {
+ for (const {name, op} of modifications) {
+ it(`permits ${name}`, () => {
+ op({bypassReadOnly: true})
+ })
+ }
+ })
+ })
})
describe('reading text', () => {
@@ -6413,6 +6716,20 @@ describe('TextEditor', () => {
const gutter = editor.addGutter(options)
expect(editor.getGutters().length).toBe(2)
expect(editor.getGutters()[1]).toBe(gutter)
+ expect(gutter.type).toBe('decorated')
+ })
+
+ it('can add a custom line-number gutter', () => {
+ expect(editor.getGutters().length).toBe(1)
+ const options = {
+ name: 'another-gutter',
+ priority: 2,
+ type: 'line-number'
+ }
+ const gutter = editor.addGutter(options)
+ expect(editor.getGutters().length).toBe(2)
+ expect(editor.getGutters()[1]).toBe(gutter)
+ expect(gutter.type).toBe('line-number')
})
it("does not allow a custom gutter with the 'line-number' name.", () => expect(editor.addGutter.bind(editor, {name: 'line-number'})).toThrow())
@@ -6747,6 +7064,14 @@ describe('TextEditor', () => {
editor.destroy()
})
+ describe('.scopeDescriptorForBufferPosition(position)', () => {
+ it('returns a default scope descriptor when no language mode is assigned', () => {
+ editor = new TextEditor({buffer: new TextBuffer()})
+ const scopeDescriptor = editor.scopeDescriptorForBufferPosition([0, 0])
+ expect(scopeDescriptor.getScopesArray()).toEqual(['text'])
+ })
+ })
+
describe('.shouldPromptToSave()', () => {
beforeEach(async () => {
editor = await atom.workspace.open('sample.js')
diff --git a/spec/text-mate-language-mode-spec.js b/spec/text-mate-language-mode-spec.js
index 2d02348cb..c6292a63b 100644
--- a/spec/text-mate-language-mode-spec.js
+++ b/spec/text-mate-language-mode-spec.js
@@ -912,6 +912,20 @@ describe('TextMateLanguageMode', () => {
}
`)
+ range = languageMode.getFoldableRangeContainingPoint(Point(7, 0), 2)
+ expect(simulateFold([range])).toBe(dedent `
+ if (a) {
+ b();
+ if (c) {⋯
+ }
+ h()
+ }
+ i()
+ if (j) {
+ k()
+ }
+ `)
+
range = languageMode.getFoldableRangeContainingPoint(Point(1, Infinity), 2)
expect(simulateFold([range])).toBe(dedent `
if (a) {⋯
diff --git a/spec/tooltip-manager-spec.js b/spec/tooltip-manager-spec.js
index 3a6b56a1b..0cf3c3e64 100644
--- a/spec/tooltip-manager-spec.js
+++ b/spec/tooltip-manager-spec.js
@@ -213,6 +213,18 @@ describe('TooltipManager', () => {
})
)
+ describe('when a user types', () =>
+ 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('keydown'))
+ expect(document.body.querySelector('.tooltip')).toBeNull()
+ disposable.dispose()
+ })
+ })
+ )
+
describe('findTooltips', () => {
it('adds and remove tooltips correctly', () => {
expect(manager.findTooltips(element).length).toBe(0)
diff --git a/spec/tree-sitter-language-mode-spec.js b/spec/tree-sitter-language-mode-spec.js
index ec38c1a06..58dae0241 100644
--- a/spec/tree-sitter-language-mode-spec.js
+++ b/spec/tree-sitter-language-mode-spec.js
@@ -10,6 +10,9 @@ const TreeSitterLanguageMode = require('../src/tree-sitter-language-mode')
const cGrammarPath = require.resolve('language-c/grammars/tree-sitter-c.cson')
const pythonGrammarPath = require.resolve('language-python/grammars/tree-sitter-python.cson')
const jsGrammarPath = require.resolve('language-javascript/grammars/tree-sitter-javascript.cson')
+const htmlGrammarPath = require.resolve('language-html/grammars/tree-sitter-html.cson')
+const ejsGrammarPath = require.resolve('language-html/grammars/tree-sitter-ejs.cson')
+const rubyGrammarPath = require.resolve('language-ruby/grammars/tree-sitter-ruby.cson')
describe('TreeSitterLanguageMode', () => {
let editor, buffer
@@ -17,10 +20,11 @@ describe('TreeSitterLanguageMode', () => {
beforeEach(async () => {
editor = await atom.workspace.open('')
buffer = editor.getBuffer()
+ editor.displayLayer.reset({foldCharacter: '…'})
})
describe('highlighting', () => {
- it('applies the most specific scope mapping to each node in the syntax tree', () => {
+ it('applies the most specific scope mapping to each node in the syntax tree', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
scopes: {
@@ -31,8 +35,11 @@ describe('TreeSitterLanguageMode', () => {
}
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText('aa.bbb = cc(d.eee());')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
expectTokensToEqual(editor, [[
{text: 'aa.', scopes: ['source']},
{text: 'bbb', scopes: ['source', 'property']},
@@ -44,7 +51,7 @@ describe('TreeSitterLanguageMode', () => {
]])
})
- it('can start or end multiple scopes at the same position', () => {
+ it('can start or end multiple scopes at the same position', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
scopes: {
@@ -57,8 +64,11 @@ describe('TreeSitterLanguageMode', () => {
}
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText('a = bb.ccc();')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
expectTokensToEqual(editor, [[
{text: 'a', scopes: ['source', 'variable']},
{text: ' = ', scopes: ['source']},
@@ -70,7 +80,7 @@ describe('TreeSitterLanguageMode', () => {
]])
})
- it('can resume highlighting on a line that starts with whitespace', () => {
+ it('can resume highlighting on a line that starts with whitespace', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
scopes: {
@@ -80,14 +90,17 @@ describe('TreeSitterLanguageMode', () => {
}
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText('a\n .b();')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
expectTokensToEqual(editor, [
[
{text: 'a', scopes: ['variable']},
],
[
- {text: ' ', scopes: ['whitespace']},
+ {text: ' ', scopes: ['leading-whitespace']},
{text: '.', scopes: []},
{text: 'b', scopes: ['function']},
{text: '();', scopes: []}
@@ -95,8 +108,8 @@ describe('TreeSitterLanguageMode', () => {
])
})
- it('correctly skips over tokens with zero size', () => {
- const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ it('correctly skips over tokens with zero size', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, {
parser: 'tree-sitter-c',
scopes: {
'primitive_type': 'type',
@@ -104,13 +117,13 @@ describe('TreeSitterLanguageMode', () => {
}
})
- const languageMode = new TreeSitterLanguageMode({buffer, grammar})
- buffer.setLanguageMode(languageMode)
buffer.setText('int main() {\n int a\n int b;\n}');
- editor.screenLineForScreenRow(0)
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
expect(
- languageMode.document.rootNode.descendantForPosition(Point(1, 2), Point(1, 6)).toString()
+ languageMode.tree.rootNode.descendantForPosition(Point(1, 2), Point(1, 6)).toString()
).toBe('(declaration (primitive_type) (identifier) (MISSING))')
expectTokensToEqual(editor, [
@@ -121,13 +134,13 @@ describe('TreeSitterLanguageMode', () => {
{text: '() {', scopes: []}
],
[
- {text: ' ', scopes: ['whitespace']},
+ {text: ' ', scopes: ['leading-whitespace']},
{text: 'int', scopes: ['type']},
{text: ' ', scopes: []},
{text: 'a', scopes: ['variable']}
],
[
- {text: ' ', scopes: ['whitespace']},
+ {text: ' ', scopes: ['leading-whitespace']},
{text: 'int', scopes: ['type']},
{text: ' ', scopes: []},
{text: 'b', scopes: ['variable']},
@@ -138,14 +151,604 @@ describe('TreeSitterLanguageMode', () => {
]
])
})
+
+ it('updates lines\' highlighting when they are affected by distant changes', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'call_expression > identifier': 'function',
+ 'property_identifier': 'member'
+ }
+ })
+
+ buffer.setText('a(\nb,\nc\n')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ // missing closing paren
+ expectTokensToEqual(editor, [
+ [{text: 'a(', scopes: []}],
+ [{text: 'b,', scopes: []}],
+ [{text: 'c', scopes: []}],
+ [{text: '', scopes: []}]
+ ])
+
+ buffer.append(')')
+ await nextHighlightingUpdate(languageMode)
+ expectTokensToEqual(editor, [
+ [
+ {text: 'a', scopes: ['function']},
+ {text: '(', scopes: []}
+ ],
+ [{text: 'b,', scopes: []}],
+ [{text: 'c', scopes: []}],
+ [{text: ')', scopes: []}]
+ ])
+ })
+
+ it('allows comma-separated selectors as scope mapping keys', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'identifier, call_expression > identifier': [
+ {match: '^[A-Z]', scopes: 'constructor'}
+ ],
+
+ 'call_expression > identifier': 'function'
+ }
+ })
+
+ buffer.setText(`a(B(new C))`)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'a', scopes: ['function']},
+ {text: '(', scopes: []},
+ {text: 'B', scopes: ['constructor']},
+ {text: '(new ', scopes: []},
+ {text: 'C', scopes: ['constructor']},
+ {text: '))', scopes: []},
+ ]
+ ])
+ })
+
+ it('handles edits after tokens that end between CR and LF characters (regression)', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'comment': 'comment',
+ 'string': 'string',
+ 'property_identifier': 'property',
+ }
+ })
+
+ buffer.setText([
+ '// abc',
+ '',
+ 'a("b").c'
+ ].join('\r\n'))
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [{text: '// abc', scopes: ['comment']}],
+ [{text: '', scopes: []}],
+ [
+ {text: 'a(', scopes: []},
+ {text: '"b"', scopes: ['string']},
+ {text: ').', scopes: []},
+ {text: 'c', scopes: ['property']}
+ ]
+ ])
+
+ buffer.insert([2, 0], ' ')
+ expectTokensToEqual(editor, [
+ [{text: '// abc', scopes: ['comment']}],
+ [{text: '', scopes: []}],
+ [
+ {text: ' ', scopes: ['leading-whitespace']},
+ {text: 'a(', scopes: []},
+ {text: '"b"', scopes: ['string']},
+ {text: ').', scopes: []},
+ {text: 'c', scopes: ['property']}
+ ]
+ ])
+ })
+
+ it('handles multi-line nodes with children on different lines (regression)', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'template_string': 'string',
+ '"${"': 'interpolation',
+ '"}"': 'interpolation'
+ }
+ });
+
+ buffer.setText('`\na${1}\nb${2}\n`;')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: '`', scopes: ['string']}
+ ], [
+ {text: 'a', scopes: ['string']},
+ {text: '${', scopes: ['string', 'interpolation']},
+ {text: '1', scopes: ['string']},
+ {text: '}', scopes: ['string', 'interpolation']}
+ ], [
+ {text: 'b', scopes: ['string']},
+ {text: '${', scopes: ['string', 'interpolation']},
+ {text: '2', scopes: ['string']},
+ {text: '}', scopes: ['string', 'interpolation']}
+ ],
+ [
+ {text: '`', scopes: ['string']},
+ {text: ';', scopes: []}
+ ]
+ ])
+ })
+
+ it('handles folds inside of highlighted tokens', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'comment': 'comment',
+ 'call_expression > identifier': 'function',
+ }
+ })
+
+ buffer.setText(dedent `
+ /*
+ * Hello
+ */
+
+ hello();
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ editor.foldBufferRange([[0, 2], [2, 0]])
+
+ expectTokensToEqual(editor, [
+ [
+ {text: '/*', scopes: ['comment']},
+ {text: '…', scopes: ['fold-marker']},
+ {text: ' */', scopes: ['comment']}
+ ],
+ [
+ {text: '', scopes: []}
+ ],
+ [
+ {text: 'hello', scopes: ['function']},
+ {text: '();', scopes: []},
+ ]
+ ])
+ })
+
+ it('applies regex match rules when specified', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'identifier': [
+ {match: '^(exports|document|window|global)$', scopes: 'global'},
+ {match: '^[A-Z_]+$', scopes: 'constant'},
+ {match: '^[A-Z]', scopes: 'constructor'},
+ 'variable'
+ ],
+ }
+ })
+
+ buffer.setText(`exports.object = Class(SOME_CONSTANT, x)`)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'exports', scopes: ['global']},
+ {text: '.object = ', scopes: []},
+ {text: 'Class', scopes: ['constructor']},
+ {text: '(', scopes: []},
+ {text: 'SOME_CONSTANT', scopes: ['constant']},
+ {text: ', ', scopes: []},
+ {text: 'x', scopes: ['variable']},
+ {text: ')', scopes: []},
+ ]
+ ])
+ })
+
+ it('handles nodes that start before their first child and end after their last child', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, rubyGrammarPath, {
+ parser: 'tree-sitter-ruby',
+ scopes: {
+ 'bare_string': 'string',
+ 'interpolation': 'embedded',
+ '"#{"': 'punctuation',
+ '"}"': 'punctuation',
+ }
+ })
+
+ // The bare string node `bc#{d}ef` has one child: the interpolation, and that child
+ // starts later and ends earlier than the bare string.
+ buffer.setText('a = %W( bc#{d}ef )')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'a = %W( ', scopes: []},
+ {text: 'bc', scopes: ['string']},
+ {text: '#{', scopes: ['string', 'embedded', 'punctuation']},
+ {text: 'd', scopes: ['string', 'embedded']},
+ {text: '}', scopes: ['string', 'embedded', 'punctuation']},
+ {text: 'ef', scopes: ['string']},
+ {text: ' )', scopes: []},
+ ]
+ ])
+ })
+
+ describe('when the buffer changes during a parse', () => {
+ it('immediately parses again when the current parse completes', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'identifier': 'variable',
+ 'call_expression > identifier': 'function',
+ 'new_expression > call_expression > identifier': 'constructor'
+ }
+ })
+
+ buffer.setText('abc;');
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar, syncOperationLimit: 0})
+ buffer.setLanguageMode(languageMode)
+ await nextHighlightingUpdate(languageMode)
+ await new Promise(process.nextTick)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'abc', scopes: ['variable']},
+ {text: ';', scopes: []}
+ ],
+ ])
+
+ buffer.setTextInRange([[0, 3], [0, 3]], '()');
+ expectTokensToEqual(editor, [
+ [
+ {text: 'abc()', scopes: ['variable']},
+ {text: ';', scopes: []}
+ ],
+ ])
+
+ buffer.setTextInRange([[0, 0], [0, 0]], 'new ');
+ expectTokensToEqual(editor, [
+ [
+ {text: 'new ', scopes: []},
+ {text: 'abc()', scopes: ['variable']},
+ {text: ';', scopes: []}
+ ],
+ ])
+
+ await nextHighlightingUpdate(languageMode)
+ expectTokensToEqual(editor, [
+ [
+ {text: 'new ', scopes: []},
+ {text: 'abc', scopes: ['function']},
+ {text: '();', scopes: []}
+ ],
+ ])
+
+ await nextHighlightingUpdate(languageMode)
+ expectTokensToEqual(editor, [
+ [
+ {text: 'new ', scopes: []},
+ {text: 'abc', scopes: ['constructor']},
+ {text: '();', scopes: []}
+ ],
+ ])
+ })
+ })
+
+ describe('injectionPoints and injectionPatterns', () => {
+ let jsGrammar, htmlGrammar
+
+ beforeEach(() => {
+ jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'property_identifier': 'property',
+ 'call_expression > identifier': 'function',
+ 'template_string': 'string',
+ 'template_substitution > "${"': 'interpolation',
+ 'template_substitution > "}"': 'interpolation'
+ },
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {
+ fragment: 'html',
+ tag_name: 'tag',
+ attribute_name: 'attr'
+ },
+ injectionRegExp: 'html',
+ injectionPoints: [SCRIPT_TAG_INJECTION_POINT]
+ })
+ })
+
+ it('highlights code inside of injection points', async () => {
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+ buffer.setText('node.innerHTML = html `\na ${b}
\n`;')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'node.', scopes: []},
+ {text: 'innerHTML', scopes: ['property']},
+ {text: ' = ', scopes: []},
+ {text: 'html', scopes: ['function']},
+ {text: ' ', scopes: []},
+ {text: '`', scopes: ['string']},
+ {text: '', scopes: ['string', 'html']}
+ ], [
+ {text: 'a ', scopes: ['string', 'html']},
+ {text: '${', scopes: ['string', 'html', 'interpolation']},
+ {text: 'b', scopes: ['string', 'html']},
+ {text: '}', scopes: ['string', 'html', 'interpolation']},
+ {text: '<', scopes: ['string', 'html']},
+ {text: 'img', scopes: ['string', 'html', 'tag']},
+ {text: ' ', scopes: ['string', 'html']},
+ {text: 'src', scopes: ['string', 'html', 'attr']},
+ {text: '="d">', scopes: ['string', 'html']}
+ ], [
+ {text: '`', scopes: ['string']},
+ {text: ';', scopes: []},
+ ],
+ ])
+
+ const range = buffer.findSync('html')
+ buffer.setTextInRange(range, 'xml')
+ await nextHighlightingUpdate(languageMode)
+ await nextHighlightingUpdate(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'node.', scopes: []},
+ {text: 'innerHTML', scopes: ['property']},
+ {text: ' = ', scopes: []},
+ {text: 'xml', scopes: ['function']},
+ {text: ' ', scopes: []},
+ {text: '`', scopes: ['string']}
+ ], [
+ {text: 'a ', scopes: ['string']},
+ {text: '${', scopes: ['string', 'interpolation']},
+ {text: 'b', scopes: ['string']},
+ {text: '}', scopes: ['string', 'interpolation']},
+ {text: '
', scopes: ['string']},
+ ], [
+ {text: '`', scopes: ['string']},
+ {text: ';', scopes: []},
+ ],
+ ])
+ })
+
+ it('highlights the content after injections', async () => {
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+ buffer.setText('\n\n
')
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: '<', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ],
+ [
+ {text: 'hello', scopes: ['html', 'function']},
+ {text: '();', scopes: ['html']},
+ ],
+ [
+ {text: '', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ],
+ [
+ {text: '<', scopes: ['html']},
+ {text: 'div', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ],
+ [
+ {text: '', scopes: ['html']},
+ {text: 'div', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ]
+ ])
+ })
+
+ it('updates buffers highlighting when a grammar with injectionRegExp is added', async () => {
+ atom.grammars.addGrammar(jsGrammar)
+
+ buffer.setText('node.innerHTML = html `\na ${b}
\n`;')
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: 'node.', scopes: []},
+ {text: 'innerHTML', scopes: ['property']},
+ {text: ' = ', scopes: []},
+ {text: 'html', scopes: ['function']},
+ {text: ' ', scopes: []},
+ {text: '`', scopes: ['string']}
+ ], [
+ {text: 'a ', scopes: ['string']},
+ {text: '${', scopes: ['string', 'interpolation']},
+ {text: 'b', scopes: ['string']},
+ {text: '}', scopes: ['string', 'interpolation']},
+ {text: '
', scopes: ['string']},
+ ], [
+ {text: '`', scopes: ['string']},
+ {text: ';', scopes: []},
+ ],
+ ])
+
+ atom.grammars.addGrammar(htmlGrammar)
+ await nextHighlightingUpdate(languageMode)
+ expectTokensToEqual(editor, [
+ [
+ {text: 'node.', scopes: []},
+ {text: 'innerHTML', scopes: ['property']},
+ {text: ' = ', scopes: []},
+ {text: 'html', scopes: ['function']},
+ {text: ' ', scopes: []},
+ {text: '`', scopes: ['string']},
+ {text: '', scopes: ['string', 'html']}
+ ], [
+ {text: 'a ', scopes: ['string', 'html']},
+ {text: '${', scopes: ['string', 'html', 'interpolation']},
+ {text: 'b', scopes: ['string', 'html']},
+ {text: '}', scopes: ['string', 'html', 'interpolation']},
+ {text: '<', scopes: ['string', 'html']},
+ {text: 'img', scopes: ['string', 'html', 'tag']},
+ {text: ' ', scopes: ['string', 'html']},
+ {text: 'src', scopes: ['string', 'html', 'attr']},
+ {text: '="d">', scopes: ['string', 'html']}
+ ], [
+ {text: '`', scopes: ['string']},
+ {text: ';', scopes: []},
+ ],
+ ])
+ })
+
+ it('handles injections that intersect', async () => {
+ const ejsGrammar = new TreeSitterGrammar(atom.grammars, ejsGrammarPath, {
+ id: 'ejs',
+ parser: 'tree-sitter-embedded-template',
+ scopes: {
+ '"<%="': 'directive',
+ '"%>"': 'directive',
+ },
+ injectionPoints: [
+ {
+ type: 'template',
+ language (node) { return 'javascript' },
+ content (node) { return node.descendantsOfType('code') }
+ },
+ {
+ type: 'template',
+ language (node) { return 'html' },
+ content (node) { return node.descendantsOfType('content') }
+ }
+ ]
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText('\n\n')
+ const languageMode = new TreeSitterLanguageMode({
+ buffer,
+ grammar: ejsGrammar,
+ grammars: atom.grammars,
+ })
+ buffer.setLanguageMode(languageMode)
+
+ expectTokensToEqual(editor, [
+ [
+ {text: '<', scopes: ['html']},
+ {text: 'body', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']}
+ ],
+ [
+ {text: '<', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']}
+ ],
+ [
+ {text: 'b', scopes: ['html', 'function']},
+ {text: '(', scopes: ['html']},
+ {text: '<%=', scopes: ['html', 'directive']},
+ {text: ' c.', scopes: ['html']},
+ {text: 'd', scopes: ['html', 'property']},
+ {text: ' ', scopes: ['html']},
+ {text: '%>', scopes: ['html', 'directive']},
+ {text: ')', scopes: ['html']},
+ ],
+ [
+ {text: '', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']}
+ ],
+ [
+ {text: '', scopes: ['html']},
+ {text: 'body', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']}
+ ],
+ ])
+ })
+
+ it('notifies onDidTokenize listeners the first time all syntax highlighting is done', async () => {
+ const promise = new Promise(resolve => {
+ editor.onDidTokenize(event => {
+ expectTokensToEqual(editor, [
+ [
+ {text: '<', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ],
+ [
+ {text: 'hello', scopes: ['html', 'function']},
+ {text: '();', scopes: ['html']},
+ ],
+ [
+ {text: '', scopes: ['html']},
+ {text: 'script', scopes: ['html', 'tag']},
+ {text: '>', scopes: ['html']},
+ ]
+ ])
+ resolve()
+ })
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+ buffer.setText('')
+
+ const languageMode = new TreeSitterLanguageMode({
+ buffer,
+ grammar: htmlGrammar,
+ grammars: atom.grammars,
+ syncOperationLimit: 0
+ })
+ buffer.setLanguageMode(languageMode)
+
+ await promise
+ })
+ })
})
describe('folding', () => {
- beforeEach(() => {
- editor.displayLayer.reset({foldCharacter: '…'})
- })
-
- it('can fold nodes that start and end with specified tokens', () => {
+ it('can fold nodes that start and end with specified tokens', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
folds: [
@@ -160,7 +763,6 @@ describe('TreeSitterLanguageMode', () => {
]
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText(dedent `
module.exports =
class A {
@@ -172,7 +774,8 @@ describe('TreeSitterLanguageMode', () => {
}
`)
- editor.screenLineForScreenRow(0)
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
expect(editor.isFoldableAtBufferRow(0)).toBe(false)
expect(editor.isFoldableAtBufferRow(1)).toBe(true)
@@ -185,7 +788,7 @@ describe('TreeSitterLanguageMode', () => {
expect(getDisplayText(editor)).toBe(dedent `
module.exports =
class A {
- getB (…) {
+ getB (c,…) {
return this.f(g)
}
}
@@ -195,12 +798,60 @@ describe('TreeSitterLanguageMode', () => {
expect(getDisplayText(editor)).toBe(dedent `
module.exports =
class A {
- getB (…) {…}
+ getB (c,…) {…}
}
`)
})
- it('can fold nodes of specified types', () => {
+ it('folds entire buffer rows when necessary to keep words on separate lines', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ parser: 'tree-sitter-javascript',
+ folds: [
+ {
+ start: {type: '{', index: 0},
+ end: {type: '}', index: -1}
+ },
+ {
+ start: {type: '(', index: 0},
+ end: {type: ')', index: -1}
+ }
+ ]
+ })
+
+ buffer.setText(dedent `
+ if (a) {
+ b
+ } else if (c) {
+ d
+ } else {
+ e
+ }
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ // Avoid bringing the `else if...` up onto the same screen line as the preceding `if`.
+ editor.foldBufferRow(1)
+ editor.foldBufferRow(3)
+ expect(getDisplayText(editor)).toBe(dedent `
+ if (a) {…
+ } else if (c) {…
+ } else {
+ e
+ }
+ `)
+
+ // It's ok to bring the final `}` onto the same screen line as the preceding `else`.
+ editor.foldBufferRow(5)
+ expect(getDisplayText(editor)).toBe(dedent `
+ if (a) {…
+ } else if (c) {…
+ } else {…}
+ `)
+ })
+
+ it('can fold nodes of specified types', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
folds: [
@@ -221,7 +872,6 @@ describe('TreeSitterLanguageMode', () => {
]
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText(dedent `
const element1 = {
`)
- editor.screenLineForScreenRow(0)
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
expect(editor.isFoldableAtBufferRow(0)).toBe(true)
expect(editor.isFoldableAtBufferRow(1)).toBe(false)
@@ -261,7 +912,7 @@ describe('TreeSitterLanguageMode', () => {
`)
})
- it('can fold entire nodes when no start or end parameters are specified', () => {
+ it('can fold entire nodes when no start or end parameters are specified', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
folds: [
@@ -271,7 +922,6 @@ describe('TreeSitterLanguageMode', () => {
]
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText(dedent `
/**
* Important
@@ -281,7 +931,8 @@ describe('TreeSitterLanguageMode', () => {
*/
`)
- editor.screenLineForScreenRow(0)
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
expect(editor.isFoldableAtBufferRow(0)).toBe(true)
expect(editor.isFoldableAtBufferRow(1)).toBe(false)
@@ -304,7 +955,7 @@ describe('TreeSitterLanguageMode', () => {
`)
})
- it('tries each folding strategy for a given node in the order specified', () => {
+ it('tries each folding strategy for a given node in the order specified', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, {
parser: 'tree-sitter-c',
folds: [
@@ -330,8 +981,6 @@ describe('TreeSitterLanguageMode', () => {
]
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
-
buffer.setText(dedent `
#ifndef FOO_H_
#define FOO_H_
@@ -356,7 +1005,8 @@ describe('TreeSitterLanguageMode', () => {
#endif
`)
- editor.screenLineForScreenRow(0)
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
editor.foldBufferRow(3)
expect(getDisplayText(editor)).toBe(dedent `
@@ -417,8 +1067,127 @@ describe('TreeSitterLanguageMode', () => {
`)
})
+ it('does not fold when the start and end parameters match the same child', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ parser: 'tree-sitter-html',
+ folds: [
+ {
+ type: 'element',
+ start: {index: 0},
+ end: {index: -1}
+ }
+ ]
+ })
+
+ buffer.setText(dedent `
+
+
+
+
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ // Void elements have only one child
+ expect(editor.isFoldableAtBufferRow(1)).toBe(false)
+ expect(editor.isFoldableAtBufferRow(2)).toBe(false)
+
+ editor.foldBufferRow(0)
+ expect(getDisplayText(editor)).toBe(dedent `
+ …
+
+ `)
+ })
+
+ it('can target named vs anonymous nodes as fold boundaries', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, rubyGrammarPath, {
+ parser: 'tree-sitter-ruby',
+ folds: [
+ {
+ type: 'elsif',
+ start: {index: 1},
+
+ // There are no double quotes around the `elsif` type. This indicates
+ // that we're targeting a *named* node in the syntax tree. The fold
+ // should end at the nested `elsif` node, not at the token that represents
+ // the literal string "elsif".
+ end: {type: ['else', 'elsif']}
+ },
+ {
+ type: 'else',
+
+ // There are double quotes around the `else` type. This indicates that
+ // we're targetting an *anonymous* node in the syntax tree. The fold
+ // should start at the token representing the literal string "else",
+ // not at an `else` node.
+ start: {type: '"else"'}
+ }
+ ]
+ })
+
+ buffer.setText(dedent `
+ if a
+ b
+ elsif c
+ d
+ elsif e
+ f
+ else
+ g
+ end
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+
+ expect(languageMode.tree.rootNode.toString()).toBe(
+ "(program (if (identifier) " +
+ "(identifier) " +
+ "(elsif (identifier) " +
+ "(identifier) " +
+ "(elsif (identifier) " +
+ "(identifier) " +
+ "(else " +
+ "(identifier))))))"
+ )
+
+ editor.foldBufferRow(2)
+ expect(getDisplayText(editor)).toBe(dedent `
+ if a
+ b
+ elsif c…
+ elsif e
+ f
+ else
+ g
+ end
+ `)
+
+ editor.foldBufferRow(4)
+ expect(getDisplayText(editor)).toBe(dedent `
+ if a
+ b
+ elsif c…
+ elsif e…
+ else
+ g
+ end
+ `)
+
+ editor.foldBufferRow(6)
+ expect(getDisplayText(editor)).toBe(dedent `
+ if a
+ b
+ elsif c…
+ elsif e…
+ else…
+ end
+ `)
+ })
+
describe('when folding a node that ends with a line break', () => {
- it('ends the fold at the end of the previous line', () => {
+ it('ends the fold at the end of the previous line', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, pythonGrammarPath, {
parser: 'tree-sitter-python',
folds: [
@@ -429,8 +1198,6 @@ describe('TreeSitterLanguageMode', () => {
]
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
-
buffer.setText(dedent `
def ab():
print 'a'
@@ -441,7 +1208,7 @@ describe('TreeSitterLanguageMode', () => {
print 'd'
`)
- editor.screenLineForScreenRow(0)
+ buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
editor.foldBufferRow(0)
expect(getDisplayText(editor)).toBe(dedent `
@@ -453,41 +1220,354 @@ describe('TreeSitterLanguageMode', () => {
`)
})
})
+
+ it('folds code in injected languages', async () => {
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {},
+ folds: [{
+ type: ['element', 'raw_element'],
+ start: {index: 0},
+ end: {index: -1}
+ }],
+ injectionRegExp: 'html'
+ })
+
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {},
+ folds: [{
+ type: ['template_string'],
+ start: {index: 0},
+ end: {index: -1},
+ },
+ {
+ start: {index: 0, type: '('},
+ end: {index: -1, type: ')'}
+ }],
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText(
+ `a = html \`
+
+ c\${def(
+ 1,
+ 2,
+ 3,
+ )}e\${f}g
+
+ \`
+ `
+ )
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ editor.foldBufferRow(2)
+ expect(getDisplayText(editor)).toBe(
+ `a = html \`
+
+ c\${def(…
+ )}e\${f}g
+
+ \`
+ `
+ )
+
+ editor.foldBufferRow(1)
+ expect(getDisplayText(editor)).toBe(
+ `a = html \`
+ …
+
+ \`
+ `
+ )
+
+ editor.foldBufferRow(0)
+ expect(getDisplayText(editor)).toBe(
+ `a = html \`…\`
+ `
+ )
+ })
})
describe('.scopeDescriptorForPosition', () => {
- it('returns a scope descriptor representing the given position in the syntax tree', () => {
+ it('returns a scope descriptor representing the given position in the syntax tree', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
- id: 'javascript',
- parser: 'tree-sitter-javascript'
+ scopeName: 'source.js',
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ program: 'source.js',
+ property_identifier: 'property.name'
+ }
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
-
buffer.setText('foo({bar: baz});')
- editor.screenLineForScreenRow(0)
- expect(editor.scopeDescriptorForBufferPosition({row: 0, column: 6}).getScopesArray()).toEqual([
- 'javascript',
- 'program',
- 'expression_statement',
- 'call_expression',
- 'arguments',
- 'object',
- 'pair',
- 'property_identifier'
+ buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
+ expect(editor.scopeDescriptorForBufferPosition([0, 'foo({b'.length]).getScopesArray()).toEqual([
+ 'source.js',
+ 'property.name'
+ ])
+ expect(editor.scopeDescriptorForBufferPosition([0, 'foo({'.length]).getScopesArray()).toEqual([
+ 'source.js',
+ 'property.name'
+ ])
+ })
+
+ it('includes nodes in injected syntax trees', async () => {
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'source.js',
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ program: 'source.js',
+ template_string: 'string.quoted',
+ interpolation: 'meta.embedded',
+ property_identifier: 'property.name'
+ },
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'text.html',
+ parser: 'tree-sitter-html',
+ scopes: {
+ fragment: 'text.html',
+ raw_element: 'script.tag'
+ },
+ injectionRegExp: 'html',
+ injectionPoints: [SCRIPT_TAG_INJECTION_POINT]
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText(`
+
+
+
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ const position = buffer.findSync('name').start
+ expect(languageMode.scopeDescriptorForPosition(position).getScopesArray()).toEqual([
+ 'text.html',
+ 'script.tag',
+ 'source.js',
+ 'string.quoted',
+ 'text.html',
+ 'property.name'
])
})
})
+ describe('.bufferRangeForScopeAtPosition(selector?, position)', () => {
+ describe('when selector = null', () => {
+ it('returns the range of the smallest node at position', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript'
+ })
+
+ buffer.setText('foo({bar: baz});')
+
+ buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
+ expect(editor.bufferRangeForScopeAtPosition(null, [0, 6])).toEqual(
+ [[0, 5], [0, 8]]
+ )
+ expect(editor.bufferRangeForScopeAtPosition(null, [0, 9])).toEqual(
+ [[0, 8], [0, 9]]
+ )
+ })
+
+ it('includes nodes in injected syntax trees', async () => {
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {},
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {},
+ injectionRegExp: 'html',
+ injectionPoints: [SCRIPT_TAG_INJECTION_POINT]
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText(`
+
+
+
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ const nameProperty = buffer.findSync('name')
+ const {start} = nameProperty
+ const position = Object.assign({}, start, {column: start.column + 2})
+ expect(languageMode.bufferRangeForScopeAtPosition(null, position))
+ .toEqual(nameProperty)
+ })
+ })
+
+ describe('with a selector', () => {
+ it('returns the range of the smallest matching node at position', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript'
+ })
+
+ buffer.setText('foo({bar: baz});')
+
+ buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
+ expect(editor.bufferRangeForScopeAtPosition('.property_identifier', [0, 6])).toEqual(
+ buffer.findSync('bar')
+ )
+ expect(editor.bufferRangeForScopeAtPosition('.call_expression', [0, 6])).toEqual(
+ [[0, 0], [0, buffer.getText().length - 1]]
+ )
+ expect(editor.bufferRangeForScopeAtPosition('.object', [0, 9])).toEqual(
+ buffer.findSync('{bar: baz}')
+ )
+ })
+
+ it('includes nodes in injected syntax trees', async () => {
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {},
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {},
+ injectionRegExp: 'html',
+ injectionPoints: [SCRIPT_TAG_INJECTION_POINT]
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText(`
+
+
+
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ const nameProperty = buffer.findSync('name')
+ const {start} = nameProperty
+ const position = Object.assign({}, start, {column: start.column + 2})
+ expect(languageMode.bufferRangeForScopeAtPosition('.property_identifier', position))
+ .toEqual(nameProperty)
+ expect(languageMode.bufferRangeForScopeAtPosition('.element', position))
+ .toEqual(buffer.findSync('\\${person\\.name}'))
+ })
+
+ it('accepts node-matching functions as selectors', async () => {
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {},
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {},
+ injectionRegExp: 'html',
+ injectionPoints: [SCRIPT_TAG_INJECTION_POINT]
+ })
+
+ atom.grammars.addGrammar(jsGrammar)
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText(`
+
+
+
+ `)
+
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ const nameProperty = buffer.findSync('name')
+ const {start} = nameProperty
+ const position = Object.assign({}, start, {column: start.column + 2})
+ const templateStringInCallExpression = node =>
+ node.type === 'template_string' && node.parent.type === 'call_expression'
+ expect(languageMode.bufferRangeForScopeAtPosition(templateStringInCallExpression, position))
+ .toEqual([[3, 19], [5, 15]])
+ })
+ })
+ })
+
+ describe('.getSyntaxNodeAtPosition(position, where?)', () => {
+ it('returns the range of the smallest matching node at position', async () => {
+ const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript'
+ })
+
+ buffer.setText('foo(bar({x: 2}));')
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar})
+ buffer.setLanguageMode(languageMode)
+ expect(languageMode.getSyntaxNodeAtPosition([0, 6]).range).toEqual(
+ buffer.findSync('bar')
+ )
+ const findFoo = node =>
+ node.type === 'call_expression' && node.firstChild.text === 'foo'
+ expect(languageMode.getSyntaxNodeAtPosition([0, 6], findFoo).range).toEqual(
+ [[0, 0], [0, buffer.getText().length - 1]]
+ )
+ })
+ })
+
describe('TextEditor.selectLargerSyntaxNode and .selectSmallerSyntaxNode', () => {
- it('expands and contract the selection based on the syntax tree', () => {
+ it('expands and contracts the selection based on the syntax tree', async () => {
const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
parser: 'tree-sitter-javascript',
scopes: {'program': 'source'}
})
- buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
buffer.setText(dedent `
function a (b, c, d) {
eee.f()
@@ -495,7 +1575,7 @@ describe('TreeSitterLanguageMode', () => {
}
`)
- editor.screenLineForScreenRow(0)
+ buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar}))
editor.setCursorBufferPosition([1, 3])
editor.selectLargerSyntaxNode()
@@ -520,9 +1600,69 @@ describe('TreeSitterLanguageMode', () => {
editor.selectSmallerSyntaxNode()
expect(editor.getSelectedBufferRange()).toEqual([[1, 3], [1, 3]])
})
+
+ it('handles injected languages', async () => {
+ const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, {
+ scopeName: 'javascript',
+ parser: 'tree-sitter-javascript',
+ scopes: {
+ 'property_identifier': 'property',
+ 'call_expression > identifier': 'function',
+ 'template_string': 'string',
+ 'template_substitution > "${"': 'interpolation',
+ 'template_substitution > "}"': 'interpolation'
+ },
+ injectionRegExp: 'javascript',
+ injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT]
+ })
+
+ const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, {
+ scopeName: 'html',
+ parser: 'tree-sitter-html',
+ scopes: {
+ fragment: 'html',
+ tag_name: 'tag',
+ attribute_name: 'attr'
+ },
+ injectionRegExp: 'html'
+ })
+
+ atom.grammars.addGrammar(htmlGrammar)
+
+ buffer.setText('a = html ` c${def()}e${f}g `')
+ const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars})
+ buffer.setLanguageMode(languageMode)
+
+ editor.setCursorBufferPosition({row: 0, column: buffer.getText().indexOf('ef()')})
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('def')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('def()')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('${def()}')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('c${def()}e${f}g')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('c${def()}e${f}g')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe(' c${def()}e${f}g ')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('` c${def()}e${f}g `')
+ editor.selectLargerSyntaxNode()
+ expect(editor.getSelectedText()).toBe('html ` c${def()}e${f}g `')
+ })
})
})
+function nextHighlightingUpdate (languageMode) {
+ return new Promise(resolve => {
+ const subscription = languageMode.onDidChangeHighlighting(() => {
+ subscription.dispose()
+ resolve()
+ })
+ })
+}
+
function getDisplayText (editor) {
return editor.displayLayer.getText()
}
@@ -533,7 +1673,14 @@ function expectTokensToEqual (editor, expectedTokenLines) {
// Assert that the correct tokens are returned regardless of which row
// the highlighting iterator starts on.
for (let startRow = 0; startRow <= lastRow; startRow++) {
- editor.displayLayer.clearSpatialIndex()
+
+ // Clear the screen line cache between iterations, but not on the first
+ // iteration, so that the first iteration tests that the cache has been
+ // correctly invalidated by any changes.
+ if (startRow > 0) {
+ editor.displayLayer.clearSpatialIndex()
+ }
+
editor.displayLayer.getScreenLines(startRow, Infinity)
const tokenLines = []
@@ -542,7 +1689,7 @@ function expectTokensToEqual (editor, expectedTokenLines) {
text,
scopes: scopes.map(scope => scope
.split(' ')
- .map(className => className.slice('syntax--'.length))
+ .map(className => className.replace('syntax--', ''))
.join(' '))
}))
}
@@ -557,4 +1704,26 @@ function expectTokensToEqual (editor, expectedTokenLines) {
}
}
}
+
+ // Fully populate the screen line cache again so that cache invalidation
+ // due to subsequent edits can be tested.
+ editor.displayLayer.getScreenLines(0, Infinity)
+}
+
+const HTML_TEMPLATE_LITERAL_INJECTION_POINT = {
+ type: 'call_expression',
+ language (node) {
+ if (node.lastChild.type === 'template_string' && node.firstChild.type === 'identifier') {
+ return node.firstChild.text
+ }
+ },
+ content (node) {
+ return node.lastChild
+ }
+}
+
+const SCRIPT_TAG_INJECTION_POINT = {
+ type: 'raw_element',
+ language () { return 'javascript' },
+ content (node) { return node.child(1) }
}
diff --git a/spec/update-process-env-spec.js b/spec/update-process-env-spec.js
index e5a1cfd9c..f7948d998 100644
--- a/spec/update-process-env-spec.js
+++ b/spec/update-process-env-spec.js
@@ -36,7 +36,7 @@ describe('updateProcessEnv(launchEnv)', function () {
})
describe('when the launch environment appears to come from a shell', function () {
- it('updates process.env to match the launch environment', async function () {
+ it('updates process.env to match the launch environment because PWD is set', async function () {
process.env = {
WILL_BE_DELETED: 'hi',
NODE_ENV: 'the-node-env',
@@ -64,6 +64,65 @@ describe('updateProcessEnv(launchEnv)', function () {
expect(process.env).toBe(initialProcessEnv)
})
+ it('updates process.env to match the launch environment because PROMPT is set', async function () {
+ process.env = {
+ WILL_BE_DELETED: 'hi',
+ NODE_ENV: 'the-node-env',
+ NODE_PATH: '/the/node/path',
+ ATOM_HOME: '/the/atom/home'
+ }
+
+ const initialProcessEnv = process.env
+
+ await updateProcessEnv({ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true', PROMPT: '$P$G', KEY1: 'value1', KEY2: 'value2'})
+ expect(process.env).toEqual({
+ ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true',
+ PROMPT: '$P$G',
+ KEY1: 'value1',
+ KEY2: 'value2',
+ NODE_ENV: 'the-node-env',
+ NODE_PATH: '/the/node/path',
+ ATOM_HOME: '/the/atom/home'
+ })
+
+ // See #11302. On Windows, `process.env` is a magic object that offers
+ // case-insensitive environment variable matching, so we cannot replace it
+ // with another object.
+ expect(process.env).toBe(initialProcessEnv)
+ })
+
+ it('updates process.env to match the launch environment because PSModulePath is set', async function () {
+ process.env = {
+ WILL_BE_DELETED: 'hi',
+ NODE_ENV: 'the-node-env',
+ NODE_PATH: '/the/node/path',
+ ATOM_HOME: '/the/atom/home'
+ }
+
+ const initialProcessEnv = process.env
+
+ await updateProcessEnv({
+ ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true',
+ PSModulePath: 'C:\\Program Files\\WindowsPowerShell\\Modules;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules\\',
+ KEY1: 'value1',
+ KEY2: 'value2'
+ })
+ expect(process.env).toEqual({
+ ATOM_DISABLE_SHELLING_OUT_FOR_ENVIRONMENT: 'true',
+ PSModulePath: 'C:\\Program Files\\WindowsPowerShell\\Modules;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules\\',
+ KEY1: 'value1',
+ KEY2: 'value2',
+ NODE_ENV: 'the-node-env',
+ NODE_PATH: '/the/node/path',
+ ATOM_HOME: '/the/atom/home'
+ })
+
+ // See #11302. On Windows, `process.env` is a magic object that offers
+ // case-insensitive environment variable matching, so we cannot replace it
+ // with another object.
+ expect(process.env).toBe(initialProcessEnv)
+ })
+
it('allows ATOM_HOME to be overwritten only if the new value is a valid path', async function () {
let newAtomHomePath = temp.mkdirSync('atom-home')
diff --git a/spec/workspace-element-spec.js b/spec/workspace-element-spec.js
index 552e95b6d..7564f6931 100644
--- a/spec/workspace-element-spec.js
+++ b/spec/workspace-element-spec.js
@@ -1,11 +1,14 @@
/** @babel */
const {ipcRenderer} = require('electron')
+const etch = require('etch')
const path = require('path')
const temp = require('temp').track()
const {Disposable} = require('event-kit')
const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers')
+const getNextUpdatePromise = () => etch.getScheduler().nextUpdatePromise
+
describe('WorkspaceElement', () => {
afterEach(() => {
try {
@@ -561,41 +564,46 @@ describe('WorkspaceElement', () => {
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
- workspaceElement.paneContainer.dispatchEvent(new MouseEvent('mouseleave'))
-
// --- Right Dock ---
// Mouse over where the toggle button would be if the dock were hovered
moveMouse({clientX: 440, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
// Mouse over the dock
moveMouse({clientX: 460, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonVisible(rightDock, 'icon-chevron-right')
expectToggleButtonHidden(bottomDock)
// Mouse over the toggle button
moveMouse({clientX: 440, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonVisible(rightDock, 'icon-chevron-right')
expectToggleButtonHidden(bottomDock)
// Click the toggle button
- rightDock.toggleButton.innerElement.click()
+ rightDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(rightDock.isVisible()).toBe(false)
expectToggleButtonHidden(rightDock)
// Mouse to edge of the window
moveMouse({clientX: 575, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(rightDock)
- moveMouse({clientX: 600, clientY: 150})
+ moveMouse({clientX: 598, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonVisible(rightDock, 'icon-chevron-left')
// Click the toggle button again
- rightDock.toggleButton.innerElement.click()
+ rightDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(rightDock.isVisible()).toBe(true)
expectToggleButtonVisible(rightDock, 'icon-chevron-right')
@@ -603,35 +611,42 @@ describe('WorkspaceElement', () => {
// Mouse over where the toggle button would be if the dock were hovered
moveMouse({clientX: 160, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
// Mouse over the dock
moveMouse({clientX: 140, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonVisible(leftDock, 'icon-chevron-left')
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
// Mouse over the toggle button
moveMouse({clientX: 160, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonVisible(leftDock, 'icon-chevron-left')
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
// Click the toggle button
- leftDock.toggleButton.innerElement.click()
+ leftDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(leftDock.isVisible()).toBe(false)
expectToggleButtonHidden(leftDock)
// Mouse to edge of the window
moveMouse({clientX: 25, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
- moveMouse({clientX: 0, clientY: 150})
+ moveMouse({clientX: 2, clientY: 150})
+ await getNextUpdatePromise()
expectToggleButtonVisible(leftDock, 'icon-chevron-right')
// Click the toggle button again
- leftDock.toggleButton.innerElement.click()
+ leftDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(leftDock.isVisible()).toBe(true)
expectToggleButtonVisible(leftDock, 'icon-chevron-left')
@@ -639,51 +654,58 @@ describe('WorkspaceElement', () => {
// Mouse over where the toggle button would be if the dock were hovered
moveMouse({clientX: 300, clientY: 190})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonHidden(rightDock)
expectToggleButtonHidden(bottomDock)
// Mouse over the dock
moveMouse({clientX: 300, clientY: 210})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonHidden(rightDock)
expectToggleButtonVisible(bottomDock, 'icon-chevron-down')
// Mouse over the toggle button
moveMouse({clientX: 300, clientY: 195})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
expectToggleButtonHidden(rightDock)
expectToggleButtonVisible(bottomDock, 'icon-chevron-down')
// Click the toggle button
- bottomDock.toggleButton.innerElement.click()
+ bottomDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(bottomDock.isVisible()).toBe(false)
expectToggleButtonHidden(bottomDock)
// Mouse to edge of the window
moveMouse({clientX: 300, clientY: 290})
+ await getNextUpdatePromise()
expectToggleButtonHidden(leftDock)
- moveMouse({clientX: 300, clientY: 300})
+ moveMouse({clientX: 300, clientY: 299})
+ await getNextUpdatePromise()
expectToggleButtonVisible(bottomDock, 'icon-chevron-up')
// Click the toggle button again
- bottomDock.toggleButton.innerElement.click()
+ bottomDock.refs.toggleButton.refs.innerElement.click()
+ await getNextUpdatePromise()
expect(bottomDock.isVisible()).toBe(true)
expectToggleButtonVisible(bottomDock, 'icon-chevron-down')
})
- function moveMouse(coordinates) {
+ function moveMouse (coordinates) {
window.dispatchEvent(new MouseEvent('mousemove', coordinates))
advanceClock(100)
}
function expectToggleButtonHidden(dock) {
- expect(dock.toggleButton.element).not.toHaveClass('atom-dock-toggle-button-visible')
+ expect(dock.refs.toggleButton.element).not.toHaveClass('atom-dock-toggle-button-visible')
}
function expectToggleButtonVisible(dock, iconClass) {
- expect(dock.toggleButton.element).toHaveClass('atom-dock-toggle-button-visible')
- expect(dock.toggleButton.iconElement).toHaveClass(iconClass)
+ expect(dock.refs.toggleButton.element).toHaveClass('atom-dock-toggle-button-visible')
+ expect(dock.refs.toggleButton.refs.iconElement).toHaveClass(iconClass)
}
})
@@ -873,27 +895,39 @@ describe('WorkspaceElement', () => {
// No active item. Use first project directory.
atom.commands.dispatch(workspaceElement, 'window:run-package-specs')
- expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'))
+ expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'), {})
ipcRenderer.send.reset()
// Active item doesn't implement ::getPath(). Use first project directory.
const item = document.createElement('div')
atom.workspace.getActivePane().activateItem(item)
atom.commands.dispatch(workspaceElement, 'window:run-package-specs')
- expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'))
+ expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'), {})
ipcRenderer.send.reset()
// Active item has no path. Use first project directory.
item.getPath = () => null
atom.commands.dispatch(workspaceElement, 'window:run-package-specs')
- expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'))
+ expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[0], 'spec'), {})
ipcRenderer.send.reset()
// Active item has path. Use project path for item path.
item.getPath = () => path.join(projectPaths[1], 'a-file.txt')
atom.commands.dispatch(workspaceElement, 'window:run-package-specs')
- expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[1], 'spec'))
+ expect(ipcRenderer.send).toHaveBeenCalledWith('run-package-specs', path.join(projectPaths[1], 'spec'), {})
ipcRenderer.send.reset()
})
+
+ it("passes additional options to the spec window", () => {
+ const workspaceElement = atom.workspace.getElement()
+ spyOn(ipcRenderer, 'send')
+
+ const projectPath = temp.mkdirSync('dir1-')
+ atom.project.setPaths([projectPath])
+ workspaceElement.runPackageSpecs({env: {ATOM_GITHUB_BABEL_ENV: 'coverage'}})
+
+ expect(ipcRenderer.send).toHaveBeenCalledWith(
+ 'run-package-specs', path.join(projectPath, 'spec'), {env: {ATOM_GITHUB_BABEL_ENV: 'coverage'}})
+ })
})
})
diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js
index 4b115e594..091588a70 100644
--- a/spec/workspace-spec.js
+++ b/spec/workspace-spec.js
@@ -274,6 +274,21 @@ describe('Workspace', () => {
})
})
+ it('discovers existing editors that are still opening', () => {
+ let editor0 = null
+ let editor1 = null
+
+ waitsForPromise(() => Promise.all([
+ workspace.open('spartacus.txt').then(o0 => { editor0 = o0 }),
+ workspace.open('spartacus.txt').then(o1 => { editor1 = o1 }),
+ ]))
+
+ runs(() => {
+ expect(editor0).toEqual(editor1)
+ expect(workspace.getActivePane().items).toEqual([editor0])
+ })
+ })
+
it("uses the location specified by the model's `getDefaultLocation()` method", () => {
const item = {
getDefaultLocation: jasmine.createSpy().andReturn('right'),
@@ -361,6 +376,28 @@ describe('Workspace', () => {
})
})
+ it('discovers existing editors that are still opening in an inactive pane', () => {
+ let editor0 = null
+ let editor1 = null
+ const pane0 = workspace.getActivePane()
+ const pane1 = workspace.getActivePane().splitRight()
+
+ pane0.activate()
+ const promise0 = workspace.open('spartacus.txt', {searchAllPanes: true}).then(o0 => { editor0 = o0 })
+ pane1.activate()
+ const promise1 = workspace.open('spartacus.txt', {searchAllPanes: true}).then(o1 => { editor1 = o1 })
+
+ waitsForPromise(() => Promise.all([promise0, promise1]))
+
+ runs(() => {
+ expect(editor0).toBeDefined()
+ expect(editor1).toBeDefined()
+
+ expect(editor0).toEqual(editor1)
+ expect(workspace.getActivePane().items).toEqual([editor0])
+ })
+ })
+
it('activates the pane in the dock with the matching item', () => {
const dock = atom.workspace.getRightDock()
const ITEM_URI = 'atom://test'
@@ -1236,21 +1273,57 @@ describe('Workspace', () => {
describe('the grammar-used hook', () => {
it('fires when opening a file or changing the grammar of an open file', async () => {
- let resolveJavascriptGrammarUsed, resolveCoffeeScriptGrammarUsed
- const javascriptGrammarUsed = new Promise(resolve => { resolveJavascriptGrammarUsed = resolve })
- const coffeescriptGrammarUsed = new Promise(resolve => { resolveCoffeeScriptGrammarUsed = resolve })
+ await atom.packages.activatePackage('language-javascript')
+ await atom.packages.activatePackage('language-coffee-script')
+
+ const observeTextEditorsSpy = jasmine.createSpy('observeTextEditors')
+ const javascriptGrammarUsed = jasmine.createSpy('javascript')
+ const coffeeScriptGrammarUsed = jasmine.createSpy('coffeescript')
atom.packages.triggerDeferredActivationHooks()
- atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', resolveJavascriptGrammarUsed)
- atom.packages.onDidTriggerActivationHook('language-coffee-script:grammar-used', resolveCoffeeScriptGrammarUsed)
+ atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', () => {
+ atom.workspace.observeTextEditors(observeTextEditorsSpy)
+ javascriptGrammarUsed()
+ })
+ atom.packages.onDidTriggerActivationHook('language-coffee-script:grammar-used', coffeeScriptGrammarUsed)
+ expect(javascriptGrammarUsed).not.toHaveBeenCalled()
+ expect(observeTextEditorsSpy).not.toHaveBeenCalled()
const editor = await atom.workspace.open('sample.js', {autoIndent: false})
- await atom.packages.activatePackage('language-javascript')
- await javascriptGrammarUsed
+ expect(javascriptGrammarUsed).toHaveBeenCalled()
+ expect(observeTextEditorsSpy.callCount).toBe(1)
- await atom.packages.activatePackage('language-coffee-script')
+ expect(coffeeScriptGrammarUsed).not.toHaveBeenCalled()
atom.grammars.assignLanguageMode(editor, 'source.coffee')
- await coffeescriptGrammarUsed
+ expect(coffeeScriptGrammarUsed).toHaveBeenCalled()
+ })
+ })
+
+ describe('the root-scope-used hook', () => {
+ it('fires when opening a file or changing the grammar of an open file', async () => {
+ await atom.packages.activatePackage('language-javascript')
+ await atom.packages.activatePackage('language-coffee-script')
+
+ const observeTextEditorsSpy = jasmine.createSpy('observeTextEditors')
+ const javascriptGrammarUsed = jasmine.createSpy('javascript')
+ const coffeeScriptGrammarUsed = jasmine.createSpy('coffeescript')
+
+ atom.packages.triggerDeferredActivationHooks()
+ atom.packages.onDidTriggerActivationHook('source.js:root-scope-used', () => {
+ atom.workspace.observeTextEditors(observeTextEditorsSpy)
+ javascriptGrammarUsed()
+ })
+ atom.packages.onDidTriggerActivationHook('source.coffee:root-scope-used', coffeeScriptGrammarUsed)
+
+ expect(javascriptGrammarUsed).not.toHaveBeenCalled()
+ expect(observeTextEditorsSpy).not.toHaveBeenCalled()
+ const editor = await atom.workspace.open('sample.js', {autoIndent: false})
+ expect(javascriptGrammarUsed).toHaveBeenCalled()
+ expect(observeTextEditorsSpy.callCount).toBe(1)
+
+ expect(coffeeScriptGrammarUsed).not.toHaveBeenCalled()
+ atom.grammars.assignLanguageMode(editor, 'source.coffee')
+ expect(coffeeScriptGrammarUsed).toHaveBeenCalled()
})
})
diff --git a/src/application-delegate.js b/src/application-delegate.js
index a6d701078..8d7981edb 100644
--- a/src/application-delegate.js
+++ b/src/application-delegate.js
@@ -1,10 +1,25 @@
const {ipcRenderer, remote, shell} = require('electron')
const ipcHelpers = require('./ipc-helpers')
-const {Disposable} = require('event-kit')
+const {Emitter, Disposable} = require('event-kit')
const getWindowLoadSettings = require('./get-window-load-settings')
module.exports =
class ApplicationDelegate {
+ constructor () {
+ this.pendingSettingsUpdateCount = 0
+ this._ipcMessageEmitter = null
+ }
+
+ ipcMessageEmitter () {
+ if (!this._ipcMessageEmitter) {
+ this._ipcMessageEmitter = new Emitter()
+ ipcRenderer.on('message', (event, message, detail) => {
+ this._ipcMessageEmitter.emit(message, detail)
+ })
+ }
+ return this._ipcMessageEmitter
+ }
+
getWindowLoadSettings () { return getWindowLoadSettings() }
open (params) {
@@ -175,6 +190,25 @@ class ApplicationDelegate {
return remote.systemPreferences.getUserDefault(key, type)
}
+ async setUserSettings (config, configFilePath) {
+ this.pendingSettingsUpdateCount++
+ try {
+ await ipcHelpers.call('set-user-settings', JSON.stringify(config), configFilePath)
+ } finally {
+ this.pendingSettingsUpdateCount--
+ }
+ }
+
+ onDidChangeUserSettings (callback) {
+ return this.ipcMessageEmitter().on('did-change-user-settings', detail => {
+ if (this.pendingSettingsUpdateCount === 0) callback(detail)
+ })
+ }
+
+ onDidFailToReadUserSettings (callback) {
+ return this.ipcMessageEmitter().on('did-fail-to-read-user-setting', callback)
+ }
+
confirm (options, callback) {
if (typeof callback === 'function') {
// Async version: pass options directly to Electron but set sane defaults
@@ -205,7 +239,7 @@ class ApplicationDelegate {
return chosen
} else {
const callback = buttons[buttonLabels[chosen]]
- if (typeof callback === 'function') callback()
+ if (typeof callback === 'function') return callback()
}
}
}
@@ -218,7 +252,7 @@ class ApplicationDelegate {
this.getCurrentWindow().showSaveDialog(options, callback)
} else {
// Sync
- if (typeof params === 'string') {
+ if (typeof options === 'string') {
options = {defaultPath: options}
}
return this.getCurrentWindow().showSaveDialog(options)
@@ -230,24 +264,14 @@ class ApplicationDelegate {
}
onDidOpenLocations (callback) {
- const outerCallback = (event, message, detail) => {
- if (message === 'open-locations') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ return this.ipcMessageEmitter().on('open-locations', callback)
}
onUpdateAvailable (callback) {
- const outerCallback = (event, message, detail) => {
- // TODO: Yes, this is strange that `onUpdateAvailable` is listening for
- // `did-begin-downloading-update`. We currently have no mechanism to know
- // if there is an update, so begin of downloading is a good proxy.
- if (message === 'did-begin-downloading-update') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ // TODO: Yes, this is strange that `onUpdateAvailable` is listening for
+ // `did-begin-downloading-update`. We currently have no mechanism to know
+ // if there is an update, so begin of downloading is a good proxy.
+ return this.ipcMessageEmitter().on('did-begin-downloading-update', callback)
}
onDidBeginDownloadingUpdate (callback) {
@@ -255,40 +279,19 @@ class ApplicationDelegate {
}
onDidBeginCheckingForUpdate (callback) {
- const outerCallback = (event, message, detail) => {
- if (message === 'checking-for-update') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ return this.ipcMessageEmitter().on('checking-for-update', callback)
}
onDidCompleteDownloadingUpdate (callback) {
- const outerCallback = (event, message, detail) => {
- // TODO: We could rename this event to `did-complete-downloading-update`
- if (message === 'update-available') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ return this.ipcMessageEmitter().on('update-available', callback)
}
onUpdateNotAvailable (callback) {
- const outerCallback = (event, message, detail) => {
- if (message === 'update-not-available') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ return this.ipcMessageEmitter().on('update-not-available', callback)
}
onUpdateError (callback) {
- const outerCallback = (event, message, detail) => {
- if (message === 'update-error') callback(detail)
- }
-
- ipcRenderer.on('message', outerCallback)
- return new Disposable(() => ipcRenderer.removeListener('message', outerCallback))
+ return this.ipcMessageEmitter().on('update-error', callback)
}
onApplicationMenuCommand (handler) {
@@ -354,11 +357,11 @@ class ApplicationDelegate {
}
emitWillSavePath (path) {
- return ipcRenderer.sendSync('will-save-path', path)
+ return ipcHelpers.call('will-save-path', path)
}
emitDidSavePath (path) {
- return ipcRenderer.sendSync('did-save-path', path)
+ return ipcHelpers.call('did-save-path', path)
}
resolveProxy (requestId, url) {
diff --git a/src/atom-environment.js b/src/atom-environment.js
index 159464534..59e4da1f6 100644
--- a/src/atom-environment.js
+++ b/src/atom-environment.js
@@ -9,7 +9,6 @@ const fs = require('fs-plus')
const {mapSourcePosition} = require('@atom/source-map-support')
const WindowEventHandler = require('./window-event-handler')
const StateStore = require('./state-store')
-const StorageFolder = require('./storage-folder')
const registerDefaultCommands = require('./register-default-commands')
const {updateProcessEnv} = require('./update-process-env')
const ConfigSchema = require('./config-schema')
@@ -51,7 +50,6 @@ let nextId = 0
//
// An instance of this class is always available as the `atom` global.
class AtomEnvironment {
-
/*
Section: Properties
*/
@@ -86,8 +84,11 @@ class AtomEnvironment {
// Public: A {Config} instance
this.config = new Config({
- notificationManager: this.notifications,
- enablePersistence: this.enablePersistence
+ saveCallback: settings => {
+ if (this.enablePersistence) {
+ this.applicationDelegate.setUserSettings(settings, this.config.getUserConfigPath())
+ }
+ }
})
this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)})
@@ -208,19 +209,23 @@ class AtomEnvironment {
this.blobStore = params.blobStore
this.configDirPath = params.configDirPath
- const {devMode, safeMode, resourcePath, clearWindowState} = this.getLoadSettings()
-
- if (clearWindowState) {
- this.getStorageFolder().clear()
- this.stateStore.clear()
- }
+ const {devMode, safeMode, resourcePath, userSettings, projectSpecification} = this.getLoadSettings()
ConfigSchema.projectHome = {
type: 'string',
default: path.join(fs.getHomeDirectory(), 'github'),
description: 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.'
}
- this.config.initialize({configDirPath: this.configDirPath, resourcePath, projectHomeSchema: ConfigSchema.projectHome})
+
+ this.config.initialize({
+ mainSource: this.enablePersistence && path.join(this.configDirPath, 'config.cson'),
+ projectHomeSchema: ConfigSchema.projectHome
+ })
+ this.config.resetUserSettings(userSettings)
+
+ if (projectSpecification != null && projectSpecification.config != null) {
+ this.project.replace(projectSpecification)
+ }
this.menu.initialize({resourcePath})
this.contextMenu.initialize({resourcePath, devMode})
@@ -242,8 +247,6 @@ class AtomEnvironment {
this.uriHandlerRegistry.registerHostHandler('core', CoreURIHandlers.create(this))
this.autoUpdater.initialize()
- this.config.load()
-
this.protocolHandlerInstaller.initialize(this.config, this.notifications)
this.themes.loadBaseStylesheets()
@@ -373,8 +376,7 @@ class AtomEnvironment {
if (this.project) this.project.destroy()
this.project = null
this.commands.clear()
- this.stylesElement.remove()
- this.config.unobserveUserConfig()
+ if (this.stylesElement) this.stylesElement.remove()
this.autoUpdater.destroy()
this.uriHandlerRegistry.destroy()
@@ -485,21 +487,25 @@ class AtomEnvironment {
// Public: Gets the release channel of the Atom application.
//
- // Returns the release channel as a {String}. Will return one of `dev`, `beta`, or `stable`.
+ // Returns the release channel as a {String}. Will return a specific release channel
+ // name like 'beta' or 'nightly' if one is found in the Atom version or 'stable'
+ // otherwise.
getReleaseChannel () {
- const version = this.getVersion()
- if (version.includes('beta')) {
- return 'beta'
- } else if (version.includes('dev')) {
- return 'dev'
- } else {
- return 'stable'
+ // This matches stable, dev (with or without commit hash) and any other
+ // release channel following the pattern '1.00.0-channel0'
+ const match = this.getVersion().match(/\d+\.\d+\.\d+(-([a-z]+)(\d+|-\w{4,})?)?$/)
+ if (!match) {
+ return 'unrecognized'
+ } else if (match[2]) {
+ return match[2]
}
+
+ return 'stable'
}
// Public: Returns a {Boolean} that is `true` if the current version is an official release.
isReleasedVersion () {
- return !/\w{7}/.test(this.getVersion()) // Check if the release is a 7-character SHA prefix
+ return this.getReleaseChannel().match(/stable|beta|nightly/) != null
}
// Public: Get the time taken to completely load the current window.
@@ -764,7 +770,11 @@ class AtomEnvironment {
}
// Call this method when establishing a real application window.
- startEditorWindow () {
+ async startEditorWindow () {
+ if (this.getLoadSettings().clearWindowState) {
+ await this.stateStore.clear()
+ }
+
this.unloaded = false
const updateProcessEnvPromise = this.updateProcessEnvAndTriggerHooks()
@@ -779,6 +789,13 @@ class AtomEnvironment {
if (error) console.warn(error.message)
})
+ this.disposables.add(this.applicationDelegate.onDidChangeUserSettings(settings =>
+ this.config.resetUserSettings(settings)
+ ))
+ this.disposables.add(this.applicationDelegate.onDidFailToReadUserSettings(message =>
+ this.notifications.addError(message)
+ ))
+
this.disposables.add(this.applicationDelegate.onDidOpenLocations(this.openLocations.bind(this)))
this.disposables.add(this.applicationDelegate.onApplicationMenuCommand(this.dispatchApplicationMenuCommand.bind(this)))
this.disposables.add(this.applicationDelegate.onContextMenuCommand(this.dispatchContextMenuCommand.bind(this)))
@@ -1121,7 +1138,7 @@ class AtomEnvironment {
}
if (windowIsUnused()) {
- this.restoreStateIntoThisEnvironment(state)
+ await this.restoreStateIntoThisEnvironment(state)
return Promise.all(filesToOpen.map(file => this.workspace.open(file)))
} else {
let resolveDiscardStatePromise = null
@@ -1264,11 +1281,6 @@ or use Pane::saveItemAs for programmatic saving.`)
}
}
- getStorageFolder () {
- if (!this.storageFolder) this.storageFolder = new StorageFolder(this.getConfigDirPath())
- return this.storageFolder
- }
-
getConfigDirPath () {
if (!this.configDirPath) this.configDirPath = process.env.ATOM_HOME
return this.configDirPath
diff --git a/src/atom-paths.js b/src/atom-paths.js
index 39a768e91..d36ac25f5 100644
--- a/src/atom-paths.js
+++ b/src/atom-paths.js
@@ -1,5 +1,3 @@
-/** @babel */
-
const fs = require('fs-plus')
const path = require('path')
diff --git a/src/auto-update-manager.js b/src/auto-update-manager.js
index 111147f32..5e3a80129 100644
--- a/src/auto-update-manager.js
+++ b/src/auto-update-manager.js
@@ -1,8 +1,7 @@
-'use babel'
+const {Emitter, CompositeDisposable} = require('event-kit')
-import {Emitter, CompositeDisposable} from 'event-kit'
-
-export default class AutoUpdateManager {
+module.exports =
+class AutoUpdateManager {
constructor ({applicationDelegate}) {
this.applicationDelegate = applicationDelegate
this.subscriptions = new CompositeDisposable()
diff --git a/src/babel.js b/src/babel.js
index a944f2e8c..8476a33c0 100644
--- a/src/babel.js
+++ b/src/babel.js
@@ -11,7 +11,8 @@ var PREFIXES = [
'/** @babel */',
'"use babel"',
'\'use babel\'',
- '/* @flow */'
+ '/* @flow */',
+ '// @flow'
]
var PREFIX_LENGTH = Math.max.apply(Math, PREFIXES.map(function (prefix) {
diff --git a/src/buffered-node-process.js b/src/buffered-node-process.js
index 86b0c5747..a33176e51 100644
--- a/src/buffered-node-process.js
+++ b/src/buffered-node-process.js
@@ -1,6 +1,4 @@
-/** @babel */
-
-import BufferedProcess from './buffered-process'
+const BufferedProcess = require('./buffered-process')
// Extended: Like {BufferedProcess}, but accepts a Node script as the command
// to run.
@@ -12,7 +10,8 @@ import BufferedProcess from './buffered-process'
// ```js
// const {BufferedNodeProcess} = require('atom')
// ```
-export default class BufferedNodeProcess extends BufferedProcess {
+module.exports =
+class BufferedNodeProcess extends BufferedProcess {
// Public: Runs the given Node script by spawning a new child process.
//
diff --git a/src/buffered-process.js b/src/buffered-process.js
index 339bf05c5..c82c78fac 100644
--- a/src/buffered-process.js
+++ b/src/buffered-process.js
@@ -1,9 +1,7 @@
-/** @babel */
-
-import _ from 'underscore-plus'
-import ChildProcess from 'child_process'
-import {Emitter} from 'event-kit'
-import path from 'path'
+const _ = require('underscore-plus')
+const ChildProcess = require('child_process')
+const {Emitter} = require('event-kit')
+const path = require('path')
// Extended: A wrapper which provides standard error/output line buffering for
// Node's ChildProcess.
@@ -19,7 +17,8 @@ import path from 'path'
// const exit = (code) => console.log("ps -ef exited with #{code}")
// const process = new BufferedProcess({command, args, stdout, exit})
// ```
-export default class BufferedProcess {
+module.exports =
+class BufferedProcess {
/*
Section: Construction
*/
@@ -190,12 +189,12 @@ export default class BufferedProcess {
output += data
})
wmicProcess.stdout.on('close', () => {
- const pidsToKill = output.split(/\s+/)
- .filter((pid) => /^\d+$/.test(pid))
- .map((pid) => parseInt(pid))
- .filter((pid) => pid !== parentPid && pid > 0 && pid < Infinity)
+ for (let pid of output.split(/\s+/)) {
+ if (!/^\d{1,10}$/.test(pid)) continue
+ pid = parseInt(pid, 10)
+
+ if (!pid || pid === parentPid) continue
- for (let pid of pidsToKill) {
try {
process.kill(pid)
} catch (error) {}
diff --git a/src/color.js b/src/color.js
index 2f2947e16..c183fb3e5 100644
--- a/src/color.js
+++ b/src/color.js
@@ -1,10 +1,9 @@
-/** @babel */
-
let ParsedColor = null
// Essential: A simple color class returned from {Config::get} when the value
// at the key path is of type 'color'.
-export default class Color {
+module.exports =
+class Color {
// Essential: Parse a {String} or {Object} into a {Color}.
//
// * `value` A {String} such as `'white'`, `#ff00ff`, or
@@ -89,6 +88,10 @@ export default class Color {
return this.alpha === 1 ? this.toHexString() : this.toRGBAString()
}
+ toString () {
+ return this.toRGBAString()
+ }
+
isEqual (color) {
if (this === color) {
return true
diff --git a/src/command-installer.js b/src/command-installer.js
index 85360da17..b432023ba 100644
--- a/src/command-installer.js
+++ b/src/command-installer.js
@@ -27,22 +27,36 @@ class CommandInstaller {
}, () => {})
}
- this.installAtomCommand(true, error => {
+ this.installAtomCommand(true, (error, atomCommandName) => {
if (error) return showErrorDialog(error)
- this.installApmCommand(true, error => {
+ this.installApmCommand(true, (error, apmCommandName) => {
if (error) return showErrorDialog(error)
this.applicationDelegate.confirm({
message: 'Commands installed.',
- detail: 'The shell commands `atom` and `apm` are installed.'
+ detail: `The shell commands \`${atomCommandName}\` and \`${apmCommandName}\` are installed.`
}, () => {})
})
})
}
+ getCommandNameForChannel (commandName) {
+ let channelMatch = this.appVersion.match(/beta|nightly/)
+ let channel = channelMatch ? channelMatch[0] : ''
+
+ switch (channel) {
+ case 'beta':
+ return `${commandName}-beta`
+ case 'nightly':
+ return `${commandName}-nightly`
+ default:
+ return commandName
+ }
+ }
+
installAtomCommand (askForPrivilege, callback) {
this.installCommand(
path.join(this.getResourcesDirectory(), 'app', 'atom.sh'),
- this.appVersion.includes('beta') ? 'atom-beta' : 'atom',
+ this.getCommandNameForChannel('atom'),
askForPrivilege,
callback
)
@@ -51,7 +65,7 @@ class CommandInstaller {
installApmCommand (askForPrivilege, callback) {
this.installCommand(
path.join(this.getResourcesDirectory(), 'app', 'apm', 'node_modules', '.bin', 'apm'),
- this.appVersion.includes('beta') ? 'apm-beta' : 'apm',
+ this.getCommandNameForChannel('apm'),
askForPrivilege,
callback
)
@@ -64,11 +78,11 @@ class CommandInstaller {
fs.readlink(destinationPath, (error, realpath) => {
if (error && error.code !== 'ENOENT') return callback(error)
- if (realpath === commandPath) return callback()
+ if (realpath === commandPath) return callback(null, commandName)
this.createSymlink(fs, commandPath, destinationPath, error => {
if (error && error.code === 'EACCES' && askForPrivilege) {
const fsAdmin = require('fs-admin')
- this.createSymlink(fsAdmin, commandPath, destinationPath, callback)
+ this.createSymlink(fsAdmin, commandPath, destinationPath, (error) => { callback(error, commandName) })
} else {
callback(error)
}
diff --git a/src/compile-cache.js b/src/compile-cache.js
index a4f9ded1e..ea387a631 100644
--- a/src/compile-cache.js
+++ b/src/compile-cache.js
@@ -17,6 +17,7 @@ var packageTranspilationRegistry = new PackageTranspilationRegistry()
var COMPILERS = {
'.js': packageTranspilationRegistry.wrapTranspiler(require('./babel')),
'.ts': packageTranspilationRegistry.wrapTranspiler(require('./typescript')),
+ '.tsx': packageTranspilationRegistry.wrapTranspiler(require('./typescript')),
'.coffee': packageTranspilationRegistry.wrapTranspiler(require('./coffee-script'))
}
diff --git a/src/config-file.js b/src/config-file.js
new file mode 100644
index 000000000..f8cba37ef
--- /dev/null
+++ b/src/config-file.js
@@ -0,0 +1,145 @@
+const _ = require('underscore-plus')
+const fs = require('fs-plus')
+const dedent = require('dedent')
+const {Emitter} = require('event-kit')
+const {watchPath} = require('./path-watcher')
+const CSON = require('season')
+const Path = require('path')
+const async = require('async')
+const temp = require('temp')
+
+const EVENT_TYPES = new Set([
+ 'created',
+ 'modified',
+ 'renamed'
+])
+
+module.exports =
+class ConfigFile {
+ static at (path) {
+ if (!this._known) {
+ this._known = new Map()
+ }
+
+ const existing = this._known.get(path)
+ if (existing) {
+ return existing
+ }
+
+ const created = new ConfigFile(path)
+ this._known.set(path, created)
+ return created
+ }
+
+ constructor (path) {
+ this.path = path
+ this.emitter = new Emitter()
+ this.value = {}
+ this.reloadCallbacks = []
+
+ // Use a queue to prevent multiple concurrent write to the same file.
+ const writeQueue = async.queue((data, callback) => {
+ (async () => {
+ try {
+ await writeCSONFileAtomically(this.path, data)
+ } catch (error) {
+ this.emitter.emit('did-error', dedent `
+ Failed to write \`${Path.basename(this.path)}\`.
+
+ ${error.message}
+ `)
+ }
+ callback()
+ })()
+ })
+
+ this.requestLoad = _.debounce(() => this.reload(), 200)
+ this.requestSave = _.debounce((data) => writeQueue.push(data), 200)
+ }
+
+ get () {
+ return this.value
+ }
+
+ update (value) {
+ return new Promise(resolve => {
+ this.requestSave(value)
+ this.reloadCallbacks.push(resolve)
+ })
+ }
+
+ async watch (callback) {
+ if (!fs.existsSync(this.path)) {
+ fs.makeTreeSync(Path.dirname(this.path))
+ CSON.writeFileSync(this.path, {}, {flag: 'wx'})
+ }
+
+ await this.reload()
+
+ try {
+ const watcher = await watchPath(this.path, {}, events => {
+ if (events.some(event => EVENT_TYPES.has(event.action))) this.requestLoad()
+ })
+ return watcher
+ } catch (error) {
+ this.emitter.emit('did-error', dedent `
+ Unable to watch path: \`${Path.basename(this.path)}\`.
+
+ Make sure you have permissions to \`${this.path}\`.
+ 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\
+ `)
+ }
+ }
+
+ onDidChange (callback) {
+ return this.emitter.on('did-change', callback)
+ }
+
+ onDidError (callback) {
+ return this.emitter.on('did-error', callback)
+ }
+
+ reload () {
+ return new Promise(resolve => {
+ CSON.readFile(this.path, (error, data) => {
+ if (error) {
+ this.emitter.emit('did-error', `Failed to load \`${Path.basename(this.path)}\` - ${error.message}`)
+ } else {
+ this.value = data || {}
+ this.emitter.emit('did-change', this.value)
+
+ for (const callback of this.reloadCallbacks) callback()
+ this.reloadCallbacks.length = 0
+ }
+ resolve()
+ })
+ })
+ }
+}
+
+function writeCSONFile (path, data) {
+ return new Promise((resolve, reject) => {
+ CSON.writeFile(path, data, error => {
+ if (error) reject(error)
+ else resolve()
+ })
+ })
+}
+
+async function writeCSONFileAtomically (path, data) {
+ const tempPath = temp.path()
+ await writeCSONFile(tempPath, data)
+ await rename(tempPath, path)
+}
+
+function rename (oldPath, newPath) {
+ return new Promise((resolve, reject) => {
+ fs.rename(oldPath, newPath, error => {
+ if (error) reject(error)
+ else resolve()
+ })
+ })
+}
diff --git a/src/config-schema.js b/src/config-schema.js
index 18dc3d774..343726d2c 100644
--- a/src/config-schema.js
+++ b/src/config-schema.js
@@ -337,6 +337,14 @@ const configSchema = {
value: 'native',
description: 'Native operating system APIs'
},
+ {
+ value: 'experimental',
+ description: 'Experimental filesystem watching library'
+ },
+ {
+ value: 'poll',
+ description: 'Polling'
+ },
{
value: 'atom',
description: 'Emulated with Atom events'
@@ -346,7 +354,22 @@ const configSchema = {
useTreeSitterParsers: {
type: 'boolean',
default: false,
- description: 'Use the new Tree-sitter parsing system for supported languages'
+ description: 'Experimental: Use the new Tree-sitter parsing system for supported languages.'
+ },
+ colorProfile: {
+ description: "Specify whether Atom should use the operating system's color profile (recommended) or an alternative color profile.
Changing this setting will require a relaunch of Atom to take effect.",
+ type: 'string',
+ default: 'default',
+ enum: [
+ {
+ value: 'default',
+ description: 'Use color profile configured in the operating system'
+ },
+ {
+ value: 'srgb',
+ description: 'Use sRGB color profile'
+ }
+ ]
}
}
},
@@ -372,7 +395,7 @@ const configSchema = {
// These can be used as globals or scoped, thus defaults.
fontFamily: {
type: 'string',
- default: '',
+ default: 'Menlo, Consolas, DejaVu Sans Mono, monospace',
description: 'The name of the font family used for editor text.'
},
fontSize: {
diff --git a/src/config.coffee b/src/config.coffee
deleted file mode 100644
index 84e726700..000000000
--- a/src/config.coffee
+++ /dev/null
@@ -1,1353 +0,0 @@
-_ = require 'underscore-plus'
-fs = require 'fs-plus'
-{Emitter} = require 'event-kit'
-CSON = require 'season'
-path = require 'path'
-async = require 'async'
-{watchPath} = require './path-watcher'
-{
- getValueAtKeyPath, setValueAtKeyPath, deleteValueAtKeyPath,
- pushKeyPath, splitKeyPath,
-} = require 'key-path-helpers'
-
-Color = require './color'
-ScopedPropertyStore = require 'scoped-property-store'
-ScopeDescriptor = require './scope-descriptor'
-
-# Essential: Used to access all of Atom's configuration details.
-#
-# An instance of this class is always available as the `atom.config` global.
-#
-# ## Getting and setting config settings.
-#
-# ```coffee
-# # Note that with no value set, ::get returns the setting's default value.
-# atom.config.get('my-package.myKey') # -> 'defaultValue'
-#
-# atom.config.set('my-package.myKey', 'value')
-# atom.config.get('my-package.myKey') # -> 'value'
-# ```
-#
-# You may want to watch for changes. Use {::observe} to catch changes to the setting.
-#
-# ```coffee
-# atom.config.set('my-package.myKey', 'value')
-# atom.config.observe 'my-package.myKey', (newValue) ->
-# # `observe` calls immediately and every time the value is changed
-# console.log 'My configuration changed:', newValue
-# ```
-#
-# If you want a notification only when the value changes, use {::onDidChange}.
-#
-# ```coffee
-# atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) ->
-# console.log 'My configuration changed:', newValue, oldValue
-# ```
-#
-# ### Value Coercion
-#
-# Config settings each have a type specified by way of a
-# [schema](json-schema.org). For example we might an integer setting that only
-# allows integers greater than `0`:
-#
-# ```coffee
-# # When no value has been set, `::get` returns the setting's default value
-# atom.config.get('my-package.anInt') # -> 12
-#
-# # The string will be coerced to the integer 123
-# atom.config.set('my-package.anInt', '123')
-# atom.config.get('my-package.anInt') # -> 123
-#
-# # The string will be coerced to an integer, but it must be greater than 0, so is set to 1
-# atom.config.set('my-package.anInt', '-20')
-# atom.config.get('my-package.anInt') # -> 1
-# ```
-#
-# ## Defining settings for your package
-#
-# Define a schema under a `config` key in your package main.
-#
-# ```coffee
-# module.exports =
-# # Your config schema
-# config:
-# someInt:
-# type: 'integer'
-# default: 23
-# minimum: 1
-#
-# activate: (state) -> # ...
-# # ...
-# ```
-#
-# See [package docs](http://flight-manual.atom.io/hacking-atom/sections/package-word-count/) for
-# more info.
-#
-# ## Config Schemas
-#
-# We use [json schema](http://json-schema.org) which allows you to define your value's
-# default, the type it should be, etc. A simple example:
-#
-# ```coffee
-# # We want to provide an `enableThing`, and a `thingVolume`
-# config:
-# enableThing:
-# type: 'boolean'
-# default: false
-# thingVolume:
-# type: 'integer'
-# default: 5
-# minimum: 1
-# maximum: 11
-# ```
-#
-# The type keyword allows for type coercion and validation. If a `thingVolume` is
-# set to a string `'10'`, it will be coerced into an integer.
-#
-# ```coffee
-# atom.config.set('my-package.thingVolume', '10')
-# atom.config.get('my-package.thingVolume') # -> 10
-#
-# # It respects the min / max
-# atom.config.set('my-package.thingVolume', '400')
-# atom.config.get('my-package.thingVolume') # -> 11
-#
-# # If it cannot be coerced, the value will not be set
-# atom.config.set('my-package.thingVolume', 'cats')
-# atom.config.get('my-package.thingVolume') # -> 11
-# ```
-#
-# ### Supported Types
-#
-# The `type` keyword can be a string with any one of the following. You can also
-# chain them by specifying multiple in an an array. For example
-#
-# ```coffee
-# config:
-# someSetting:
-# type: ['boolean', 'integer']
-# default: 5
-#
-# # Then
-# atom.config.set('my-package.someSetting', 'true')
-# atom.config.get('my-package.someSetting') # -> true
-#
-# atom.config.set('my-package.someSetting', '12')
-# atom.config.get('my-package.someSetting') # -> 12
-# ```
-#
-# #### string
-#
-# Values must be a string.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'string'
-# default: 'hello'
-# ```
-#
-# #### integer
-#
-# Values will be coerced into integer. Supports the (optional) `minimum` and
-# `maximum` keys.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'integer'
-# default: 5
-# minimum: 1
-# maximum: 11
-# ```
-#
-# #### number
-#
-# Values will be coerced into a number, including real numbers. Supports the
-# (optional) `minimum` and `maximum` keys.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'number'
-# default: 5.3
-# minimum: 1.5
-# maximum: 11.5
-# ```
-#
-# #### boolean
-#
-# Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into
-# a boolean. Numbers, arrays, objects, and anything else will not be coerced.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'boolean'
-# default: false
-# ```
-#
-# #### array
-#
-# Value must be an Array. The types of the values can be specified by a
-# subschema in the `items` key.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'array'
-# default: [1, 2, 3]
-# items:
-# type: 'integer'
-# minimum: 1.5
-# maximum: 11.5
-# ```
-#
-# #### color
-#
-# Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha`
-# properties that all have numeric values. `red`, `green`, `blue` will be in
-# the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any
-# valid CSS color format such as `#abc`, `#abcdef`, `white`,
-# `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'color'
-# default: 'white'
-# ```
-#
-# #### object / Grouping other types
-#
-# A config setting with the type `object` allows grouping a set of config
-# settings. The group will be visually separated and has its own group headline.
-# The sub options must be listed under a `properties` key.
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'object'
-# properties:
-# myChildIntOption:
-# type: 'integer'
-# minimum: 1.5
-# maximum: 11.5
-# ```
-#
-# ### Other Supported Keys
-#
-# #### enum
-#
-# All types support an `enum` key, which lets you specify all the values the
-# setting can take. `enum` may be an array of allowed values (of the specified
-# type), or an array of objects with `value` and `description` properties, where
-# the `value` is an allowed value, and the `description` is a descriptive string
-# used in the settings view.
-#
-# In this example, the setting must be one of the 4 integers:
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'integer'
-# default: 4
-# enum: [2, 4, 6, 8]
-# ```
-#
-# In this example, the setting must be either 'foo' or 'bar', which are
-# presented using the provided descriptions in the settings pane:
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'string'
-# default: 'foo'
-# enum: [
-# {value: 'foo', description: 'Foo mode. You want this.'}
-# {value: 'bar', description: 'Bar mode. Nobody wants that!'}
-# ]
-# ```
-#
-# Usage:
-#
-# ```coffee
-# atom.config.set('my-package.someSetting', '2')
-# atom.config.get('my-package.someSetting') # -> 2
-#
-# # will not set values outside of the enum values
-# atom.config.set('my-package.someSetting', '3')
-# atom.config.get('my-package.someSetting') # -> 2
-#
-# # If it cannot be coerced, the value will not be set
-# atom.config.set('my-package.someSetting', '4')
-# atom.config.get('my-package.someSetting') # -> 4
-# ```
-#
-# #### title and description
-#
-# The settings view will use the `title` and `description` keys to display your
-# config setting in a readable way. By default the settings view humanizes your
-# config key, so `someSetting` becomes `Some Setting`. In some cases, this is
-# confusing for users, and a more descriptive title is useful.
-#
-# Descriptions will be displayed below the title in the settings view.
-#
-# For a group of config settings the humanized key or the title and the
-# description are used for the group headline.
-#
-# ```coffee
-# config:
-# someSetting:
-# title: 'Setting Magnitude'
-# description: 'This will affect the blah and the other blah'
-# type: 'integer'
-# default: 4
-# ```
-#
-# __Note__: You should strive to be so clear in your naming of the setting that
-# you do not need to specify a title or description!
-#
-# Descriptions allow a subset of
-# [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/).
-# Specifically, you may use the following in configuration setting descriptions:
-#
-# * **bold** - `**bold**`
-# * *italics* - `*italics*`
-# * [links](https://atom.io) - `[links](https://atom.io)`
-# * `code spans` - `\`code spans\``
-# * line breaks - `line breaks
`
-# * ~~strikethrough~~ - `~~strikethrough~~`
-#
-# #### order
-#
-# The settings view orders your settings alphabetically. You can override this
-# ordering with the order key.
-#
-# ```coffee
-# config:
-# zSetting:
-# type: 'integer'
-# default: 4
-# order: 1
-# aSetting:
-# type: 'integer'
-# default: 4
-# order: 2
-# ```
-#
-# ## Manipulating values outside your configuration schema
-#
-# It is possible to manipulate(`get`, `set`, `observe` etc) values that do not
-# appear in your configuration schema. For example, if the config schema of the
-# package 'some-package' is
-#
-# ```coffee
-# config:
-# someSetting:
-# type: 'boolean'
-# default: false
-# ```
-#
-# You can still do the following
-#
-# ```coffee
-# let otherSetting = atom.config.get('some-package.otherSetting')
-# atom.config.set('some-package.stillAnotherSetting', otherSetting * 5)
-# ```
-#
-# In other words, if a function asks for a `key-path`, that path doesn't have to
-# be described in the config schema for the package or any package. However, as
-# highlighted in the best practices section, you are advised against doing the
-# above.
-#
-# ## Best practices
-#
-# * Don't depend on (or write to) configuration keys outside of your keypath.
-#
-module.exports =
-class Config
- @schemaEnforcers = {}
-
- @addSchemaEnforcer: (typeName, enforcerFunction) ->
- @schemaEnforcers[typeName] ?= []
- @schemaEnforcers[typeName].push(enforcerFunction)
-
- @addSchemaEnforcers: (filters) ->
- for typeName, functions of filters
- for name, enforcerFunction of functions
- @addSchemaEnforcer(typeName, enforcerFunction)
- return
-
- @executeSchemaEnforcers: (keyPath, value, schema) ->
- error = null
- types = schema.type
- types = [types] unless Array.isArray(types)
- for type in types
- try
- enforcerFunctions = @schemaEnforcers[type].concat(@schemaEnforcers['*'])
- for enforcer in enforcerFunctions
- # At some point in one's life, one must call upon an enforcer.
- value = enforcer.call(this, keyPath, value, schema)
- error = null
- break
- catch e
- error = e
-
- throw error if error?
- value
-
- # Created during initialization, available as `atom.config`
- constructor: ({@notificationManager, @enablePersistence}={}) ->
- @clear()
-
- initialize: ({@configDirPath, @resourcePath, projectHomeSchema}) ->
- if @enablePersistence?
- @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson'])
- @configFilePath ?= path.join(@configDirPath, 'config.cson')
-
- @schema.properties.core.properties.projectHome = projectHomeSchema
- @defaultSettings.core.projectHome = projectHomeSchema.default
-
- clear: ->
- @emitter = new Emitter
- @schema =
- type: 'object'
- properties: {}
- @defaultSettings = {}
- @settings = {}
- @scopedSettingsStore = new ScopedPropertyStore
-
- @settingsLoaded = false
- @savePending = false
- @configFileHasErrors = false
- @transactDepth = 0
- @pendingOperations = []
- @legacyScopeAliases = {}
-
- @requestLoad = _.debounce =>
- @loadUserConfig()
- , 100
-
- debouncedSave = _.debounce =>
- @savePending = false
- @save()
- , 100
- @requestSave = =>
- @savePending = true
- debouncedSave()
-
- shouldNotAccessFileSystem: -> not @enablePersistence
-
- ###
- Section: Config Subscription
- ###
-
- # Essential: Add a listener for changes to a given key path. This is different
- # than {::onDidChange} in that it will immediately call your callback with the
- # current value of the config entry.
- #
- # ### Examples
- #
- # You might want to be notified when the themes change. We'll watch
- # `core.themes` for changes
- #
- # ```coffee
- # atom.config.observe 'core.themes', (value) ->
- # # do stuff with value
- # ```
- #
- # * `keyPath` {String} name of the key to observe
- # * `options` (optional) {Object}
- # * `scope` (optional) {ScopeDescriptor} describing a path from
- # the root of the syntax tree to a token. Get one by calling
- # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
- # See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
- # for more information.
- # * `callback` {Function} to call when the value of the key changes.
- # * `value` the new value of the key
- #
- # Returns a {Disposable} with the following keys on which you can call
- # `.dispose()` to unsubscribe.
- observe: ->
- if arguments.length is 2
- [keyPath, callback] = arguments
- else if arguments.length is 3 and (_.isString(arguments[0]) and _.isObject(arguments[1]))
- [keyPath, options, callback] = arguments
- scopeDescriptor = options.scope
- else
- console.error 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details'
- return
-
- if scopeDescriptor?
- @observeScopedKeyPath(scopeDescriptor, keyPath, callback)
- else
- @observeKeyPath(keyPath, options ? {}, callback)
-
- # Essential: Add a listener for changes to a given key path. If `keyPath` is
- # not specified, your callback will be called on changes to any key.
- #
- # * `keyPath` (optional) {String} name of the key to observe. Must be
- # specified if `scopeDescriptor` is specified.
- # * `options` (optional) {Object}
- # * `scope` (optional) {ScopeDescriptor} describing a path from
- # the root of the syntax tree to a token. Get one by calling
- # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
- # See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
- # for more information.
- # * `callback` {Function} to call when the value of the key changes.
- # * `event` {Object}
- # * `newValue` the new value of the key
- # * `oldValue` the prior value of the key.
- #
- # Returns a {Disposable} with the following keys on which you can call
- # `.dispose()` to unsubscribe.
- onDidChange: ->
- if arguments.length is 1
- [callback] = arguments
- else if arguments.length is 2
- [keyPath, callback] = arguments
- else
- [keyPath, options, callback] = arguments
- scopeDescriptor = options.scope
-
- if scopeDescriptor?
- @onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback)
- else
- @onDidChangeKeyPath(keyPath, callback)
-
- ###
- Section: Managing Settings
- ###
-
- # Essential: Retrieves the setting for the given key.
- #
- # ### Examples
- #
- # You might want to know what themes are enabled, so check `core.themes`
- #
- # ```coffee
- # atom.config.get('core.themes')
- # ```
- #
- # With scope descriptors you can get settings within a specific editor
- # scope. For example, you might want to know `editor.tabLength` for ruby
- # files.
- #
- # ```coffee
- # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
- # ```
- #
- # This setting in ruby files might be different than the global tabLength setting
- #
- # ```coffee
- # atom.config.get('editor.tabLength') # => 4
- # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
- # ```
- #
- # You can get the language scope descriptor via
- # {TextEditor::getRootScopeDescriptor}. This will get the setting specifically
- # for the editor's language.
- #
- # ```coffee
- # atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2
- # ```
- #
- # Additionally, you can get the setting at the specific cursor position.
- #
- # ```coffee
- # scopeDescriptor = @editor.getLastCursor().getScopeDescriptor()
- # atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2
- # ```
- #
- # * `keyPath` The {String} name of the key to retrieve.
- # * `options` (optional) {Object}
- # * `sources` (optional) {Array} of {String} source names. If provided, only
- # values that were associated with these sources during {::set} will be used.
- # * `excludeSources` (optional) {Array} of {String} source names. If provided,
- # values that were associated with these sources during {::set} will not
- # be used.
- # * `scope` (optional) {ScopeDescriptor} describing a path from
- # the root of the syntax tree to a token. Get one by calling
- # {editor.getLastCursor().getScopeDescriptor()}
- # See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
- # for more information.
- #
- # Returns the value from Atom's default settings, the user's configuration
- # file in the type specified by the configuration schema.
- get: ->
- if arguments.length > 1
- if typeof arguments[0] is 'string' or not arguments[0]?
- [keyPath, options] = arguments
- {scope} = options
- else
- [keyPath] = arguments
-
- if scope?
- value = @getRawScopedValue(scope, keyPath, options)
- value ? @getRawValue(keyPath, options)
- else
- @getRawValue(keyPath, options)
-
- # Extended: Get all of the values for the given key-path, along with their
- # associated scope selector.
- #
- # * `keyPath` The {String} name of the key to retrieve
- # * `options` (optional) {Object} see the `options` argument to {::get}
- #
- # Returns an {Array} of {Object}s with the following keys:
- # * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated
- # * `value` The value for the key-path
- getAll: (keyPath, options) ->
- {scope} = options if options?
-
- if scope?
- scopeDescriptor = ScopeDescriptor.fromObject(scope)
- result = @scopedSettingsStore.getAll(
- scopeDescriptor.getScopeChain(),
- keyPath,
- options
- )
- if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor)
- result.push(@scopedSettingsStore.getAll(
- legacyScopeDescriptor.getScopeChain(),
- keyPath,
- options
- )...)
- else
- result = []
-
- if globalValue = @getRawValue(keyPath, options)
- result.push(scopeSelector: '*', value: globalValue)
-
- result
-
- # Essential: Sets the value for a configuration setting.
- #
- # This value is stored in Atom's internal configuration file.
- #
- # ### Examples
- #
- # You might want to change the themes programmatically:
- #
- # ```coffee
- # atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax'])
- # ```
- #
- # You can also set scoped settings. For example, you might want change the
- # `editor.tabLength` only for ruby files.
- #
- # ```coffee
- # atom.config.get('editor.tabLength') # => 4
- # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4
- # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
- #
- # # Set ruby to 2
- # atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true
- #
- # # Notice it's only set to 2 in the case of ruby
- # atom.config.get('editor.tabLength') # => 4
- # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
- # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
- # ```
- #
- # * `keyPath` The {String} name of the key.
- # * `value` The value of the setting. Passing `undefined` will revert the
- # setting to the default value.
- # * `options` (optional) {Object}
- # * `scopeSelector` (optional) {String}. eg. '.source.ruby'
- # See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
- # for more information.
- # * `source` (optional) {String} The name of a file with which the setting
- # is associated. Defaults to the user's config file.
- #
- # Returns a {Boolean}
- # * `true` if the value was set.
- # * `false` if the value was not able to be coerced to the type specified in the setting's schema.
- set: ->
- [keyPath, value, options] = arguments
-
- unless @settingsLoaded
- @pendingOperations.push => @set.call(this, keyPath, value, options)
-
- scopeSelector = options?.scopeSelector
- source = options?.source
- shouldSave = options?.save ? true
-
- if source and not scopeSelector
- throw new Error("::set with a 'source' and no 'sourceSelector' is not yet implemented!")
-
- source ?= @getUserConfigPath()
-
- unless value is undefined
- try
- value = @makeValueConformToSchema(keyPath, value)
- catch e
- return false
-
- if scopeSelector?
- @setRawScopedValue(keyPath, value, source, scopeSelector)
- else
- @setRawValue(keyPath, value)
-
- if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors and @settingsLoaded
- @requestSave()
- true
-
- # Essential: Restore the setting at `keyPath` to its default value.
- #
- # * `keyPath` The {String} name of the key.
- # * `options` (optional) {Object}
- # * `scopeSelector` (optional) {String}. See {::set}
- # * `source` (optional) {String}. See {::set}
- unset: (keyPath, options) ->
- unless @settingsLoaded
- @pendingOperations.push => @unset.call(this, keyPath, options)
-
- {scopeSelector, source} = options ? {}
- source ?= @getUserConfigPath()
-
- if scopeSelector?
- if keyPath?
- settings = @scopedSettingsStore.propertiesForSourceAndSelector(source, scopeSelector)
- if getValueAtKeyPath(settings, keyPath)?
- @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
- setValueAtKeyPath(settings, keyPath, undefined)
- settings = withoutEmptyObjects(settings)
- @set(null, settings, {scopeSelector, source, priority: @priorityForSource(source)}) if settings?
- if source is @getUserConfigPath() and not @configFileHasErrors and @settingsLoaded
- @requestSave()
- else
- @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
- @emitChangeEvent()
- else
- for scopeSelector of @scopedSettingsStore.propertiesForSource(source)
- @unset(keyPath, {scopeSelector, source})
- if keyPath? and source is @getUserConfigPath()
- @set(keyPath, getValueAtKeyPath(@defaultSettings, keyPath))
-
- # Extended: Get an {Array} of all of the `source` {String}s with which
- # settings have been added via {::set}.
- getSources: ->
- _.uniq(_.pluck(@scopedSettingsStore.propertySets, 'source')).sort()
-
- # Extended: Retrieve the schema for a specific key path. The schema will tell
- # you what type the keyPath expects, and other metadata about the config
- # option.
- #
- # * `keyPath` The {String} name of the key.
- #
- # Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`.
- # Returns `null` when the keyPath has no schema specified, but is accessible
- # from the root schema.
- getSchema: (keyPath) ->
- keys = splitKeyPath(keyPath)
- schema = @schema
- for key in keys
- if schema.type is 'object'
- childSchema = schema.properties?[key]
- unless childSchema?
- if isPlainObject(schema.additionalProperties)
- childSchema = schema.additionalProperties
- else if schema.additionalProperties is false
- return null
- else
- return {type: 'any'}
- else
- return null
- schema = childSchema
- schema
-
- # Extended: Get the {String} path to the config file being used.
- getUserConfigPath: ->
- @configFilePath
-
- # Extended: Suppress calls to handler functions registered with {::onDidChange}
- # and {::observe} for the duration of `callback`. After `callback` executes,
- # handlers will be called once if the value for their key-path has changed.
- #
- # * `callback` {Function} to execute while suppressing calls to handlers.
- transact: (callback) ->
- @beginTransaction()
- try
- callback()
- finally
- @endTransaction()
-
- addLegacyScopeAlias: (languageId, legacyScopeName) ->
- @legacyScopeAliases[languageId] = legacyScopeName
-
- removeLegacyScopeAlias: (languageId) ->
- delete @legacyScopeAliases[languageId]
-
- ###
- Section: Internal methods used by core
- ###
-
- # Private: Suppress calls to handler functions registered with {::onDidChange}
- # and {::observe} for the duration of the {Promise} returned by `callback`.
- # After the {Promise} is either resolved or rejected, handlers will be called
- # once if the value for their key-path has changed.
- #
- # * `callback` {Function} that returns a {Promise}, which will be executed
- # while suppressing calls to handlers.
- #
- # Returns a {Promise} that is either resolved or rejected according to the
- # `{Promise}` returned by `callback`. If `callback` throws an error, a
- # rejected {Promise} will be returned instead.
- transactAsync: (callback) ->
- @beginTransaction()
- try
- endTransaction = (fn) => (args...) =>
- @endTransaction()
- fn(args...)
- result = callback()
- new Promise (resolve, reject) ->
- result.then(endTransaction(resolve)).catch(endTransaction(reject))
- catch error
- @endTransaction()
- Promise.reject(error)
-
- beginTransaction: ->
- @transactDepth++
-
- endTransaction: ->
- @transactDepth--
- @emitChangeEvent()
-
- pushAtKeyPath: (keyPath, value) ->
- arrayValue = @get(keyPath) ? []
- result = arrayValue.push(value)
- @set(keyPath, arrayValue)
- result
-
- unshiftAtKeyPath: (keyPath, value) ->
- arrayValue = @get(keyPath) ? []
- result = arrayValue.unshift(value)
- @set(keyPath, arrayValue)
- result
-
- removeAtKeyPath: (keyPath, value) ->
- arrayValue = @get(keyPath) ? []
- result = _.remove(arrayValue, value)
- @set(keyPath, arrayValue)
- result
-
- setSchema: (keyPath, schema) ->
- unless isPlainObject(schema)
- throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!")
-
- unless typeof schema.type?
- throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute")
-
- rootSchema = @schema
- if keyPath
- for key in splitKeyPath(keyPath)
- rootSchema.type = 'object'
- rootSchema.properties ?= {}
- properties = rootSchema.properties
- properties[key] ?= {}
- rootSchema = properties[key]
-
- Object.assign rootSchema, schema
- @transact =>
- @setDefaults(keyPath, @extractDefaultsFromSchema(schema))
- @setScopedDefaultsFromSchema(keyPath, schema)
- @resetSettingsForSchemaChange()
-
- load: ->
- @initializeConfigDirectory()
- @loadUserConfig()
- @observeUserConfig()
-
- ###
- Section: Private methods managing the user's config file
- ###
-
- initializeConfigDirectory: (done) ->
- return if fs.existsSync(@configDirPath) or @shouldNotAccessFileSystem()
-
- fs.makeTreeSync(@configDirPath)
-
- queue = async.queue ({sourcePath, destinationPath}, callback) ->
- fs.copy(sourcePath, destinationPath, callback)
- queue.drain = done
-
- templateConfigDirPath = fs.resolve(@resourcePath, 'dot-atom')
- onConfigDirFile = (sourcePath) =>
- relativePath = sourcePath.substring(templateConfigDirPath.length + 1)
- destinationPath = path.join(@configDirPath, relativePath)
- queue.push({sourcePath, destinationPath})
- fs.traverseTree(templateConfigDirPath, onConfigDirFile, ((path) -> true), (->))
-
- loadUserConfig: ->
- return if @shouldNotAccessFileSystem()
- return if @savePending
-
- try
- unless fs.existsSync(@configFilePath)
- fs.makeTreeSync(path.dirname(@configFilePath))
- CSON.writeFileSync(@configFilePath, {}, {flag: 'wx'}) # fails if file exists
- catch error
- if error.code isnt 'EEXIST'
- @configFileHasErrors = true
- @notifyFailure("Failed to initialize `#{path.basename(@configFilePath)}`", error.stack)
- return
-
- try
- userConfig = CSON.readFileSync(@configFilePath)
- userConfig = {} if userConfig is null
-
- unless isPlainObject(userConfig)
- throw new Error("`#{path.basename(@configFilePath)}` must contain valid JSON or CSON")
-
- @resetUserSettings(userConfig)
- @configFileHasErrors = false
- catch error
- @configFileHasErrors = true
- message = "Failed to load `#{path.basename(@configFilePath)}`"
-
- detail = if error.location?
- # stack is the output from CSON in this case
- error.stack
- else
- # message will be EACCES permission denied, et al
- error.message
-
- @notifyFailure(message, detail)
-
- observeUserConfig: ->
- return if @shouldNotAccessFileSystem()
-
- try
- @watchSubscriptionPromise ?= watchPath @configFilePath, {}, (events) =>
- for {action} in events
- if action in ['created', 'modified', 'renamed'] and @watchSubscriptionPromise?
- @requestLoad()
- catch error
- @notifyFailure """
- Unable to watch path: `#{path.basename(@configFilePath)}`. Make sure you have permissions to
- `#{@configFilePath}`. 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
- """
-
- @watchSubscriptionPromise
-
- unobserveUserConfig: ->
- @watchSubscriptionPromise?.then (watcher) -> watcher?.dispose()
- @watchSubscriptionPromise = null
-
- notifyFailure: (errorMessage, detail) ->
- @notificationManager?.addError(errorMessage, {detail, dismissable: true})
-
- save: ->
- return if @shouldNotAccessFileSystem()
-
- allSettings = {'*': @settings}
- allSettings = Object.assign allSettings, @scopedSettingsStore.propertiesForSource(@getUserConfigPath())
- allSettings = sortObject(allSettings)
- try
- CSON.writeFileSync(@configFilePath, allSettings)
- catch error
- message = "Failed to save `#{path.basename(@configFilePath)}`"
- detail = error.message
- @notifyFailure(message, detail)
-
- ###
- Section: Private methods managing global settings
- ###
-
- resetUserSettings: (newSettings) ->
- if newSettings.global?
- newSettings['*'] = newSettings.global
- delete newSettings.global
-
- if newSettings['*']?
- scopedSettings = newSettings
- newSettings = newSettings['*']
- delete scopedSettings['*']
- @resetUserScopedSettings(scopedSettings)
-
- @transact =>
- @settings = {}
- @settingsLoaded = true
- @set(key, value, save: false) for key, value of newSettings
- if @pendingOperations.length
- op() for op in @pendingOperations
- @pendingOperations = []
-
- getRawValue: (keyPath, options) ->
- unless options?.excludeSources?.indexOf(@getUserConfigPath()) >= 0
- value = getValueAtKeyPath(@settings, keyPath)
- unless options?.sources?.length > 0
- defaultValue = getValueAtKeyPath(@defaultSettings, keyPath)
-
- if value?
- value = @deepClone(value)
- @deepDefaults(value, defaultValue) if isPlainObject(value) and isPlainObject(defaultValue)
- else
- value = @deepClone(defaultValue)
-
- value
-
- setRawValue: (keyPath, value) ->
- defaultValue = getValueAtKeyPath(@defaultSettings, keyPath)
- if _.isEqual(defaultValue, value)
- if keyPath?
- deleteValueAtKeyPath(@settings, keyPath)
- else
- @settings = null
- else
- if keyPath?
- setValueAtKeyPath(@settings, keyPath, value)
- else
- @settings = value
- @emitChangeEvent()
-
- observeKeyPath: (keyPath, options, callback) ->
- callback(@get(keyPath))
- @onDidChangeKeyPath keyPath, (event) -> callback(event.newValue)
-
- onDidChangeKeyPath: (keyPath, callback) ->
- oldValue = @get(keyPath)
- @emitter.on 'did-change', =>
- newValue = @get(keyPath)
- unless _.isEqual(oldValue, newValue)
- event = {oldValue, newValue}
- oldValue = newValue
- callback(event)
-
- isSubKeyPath: (keyPath, subKeyPath) ->
- return false unless keyPath? and subKeyPath?
- pathSubTokens = splitKeyPath(subKeyPath)
- pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length)
- _.isEqual(pathTokens, pathSubTokens)
-
- setRawDefault: (keyPath, value) ->
- setValueAtKeyPath(@defaultSettings, keyPath, value)
- @emitChangeEvent()
-
- setDefaults: (keyPath, defaults) ->
- if defaults? and isPlainObject(defaults)
- keys = splitKeyPath(keyPath)
- @transact =>
- for key, childValue of defaults
- continue unless defaults.hasOwnProperty(key)
- @setDefaults(keys.concat([key]).join('.'), childValue)
- else
- try
- defaults = @makeValueConformToSchema(keyPath, defaults)
- @setRawDefault(keyPath, defaults)
- catch e
- console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}")
- return
-
- deepClone: (object) ->
- if object instanceof Color
- object.clone()
- else if _.isArray(object)
- object.map (value) => @deepClone(value)
- else if isPlainObject(object)
- _.mapObject object, (key, value) => [key, @deepClone(value)]
- else
- object
-
- deepDefaults: (target) ->
- result = target
- i = 0
- while ++i < arguments.length
- object = arguments[i]
- if isPlainObject(result) and isPlainObject(object)
- for key in Object.keys(object)
- result[key] = @deepDefaults(result[key], object[key])
- else
- if not result?
- result = @deepClone(object)
- result
-
- # `schema` will look something like this
- #
- # ```coffee
- # type: 'string'
- # default: 'ok'
- # scopes:
- # '.source.js':
- # default: 'omg'
- # ```
- setScopedDefaultsFromSchema: (keyPath, schema) ->
- if schema.scopes? and isPlainObject(schema.scopes)
- scopedDefaults = {}
- for scope, scopeSchema of schema.scopes
- continue unless scopeSchema.hasOwnProperty('default')
- scopedDefaults[scope] = {}
- setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default)
- @scopedSettingsStore.addProperties('schema-default', scopedDefaults)
-
- if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties)
- keys = splitKeyPath(keyPath)
- for key, childValue of schema.properties
- continue unless schema.properties.hasOwnProperty(key)
- @setScopedDefaultsFromSchema(keys.concat([key]).join('.'), childValue)
-
- return
-
- extractDefaultsFromSchema: (schema) ->
- if schema.default?
- schema.default
- else if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties)
- defaults = {}
- properties = schema.properties or {}
- defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties
- defaults
-
- makeValueConformToSchema: (keyPath, value, options) ->
- if options?.suppressException
- try
- @makeValueConformToSchema(keyPath, value)
- catch e
- undefined
- else
- unless (schema = @getSchema(keyPath))?
- throw new Error("Illegal key path #{keyPath}") if schema is false
- @constructor.executeSchemaEnforcers(keyPath, value, schema)
-
- # When the schema is changed / added, there may be values set in the config
- # that do not conform to the schema. This will reset make them conform.
- resetSettingsForSchemaChange: (source=@getUserConfigPath()) ->
- @transact =>
- @settings = @makeValueConformToSchema(null, @settings, suppressException: true)
- selectorsAndSettings = @scopedSettingsStore.propertiesForSource(source)
- @scopedSettingsStore.removePropertiesForSource(source)
- for scopeSelector, settings of selectorsAndSettings
- settings = @makeValueConformToSchema(null, settings, suppressException: true)
- @setRawScopedValue(null, settings, source, scopeSelector)
- return
-
- ###
- Section: Private Scoped Settings
- ###
-
- priorityForSource: (source) ->
- if source is @getUserConfigPath()
- 1000
- else
- 0
-
- emitChangeEvent: ->
- @emitter.emit 'did-change' unless @transactDepth > 0
-
- resetUserScopedSettings: (newScopedSettings) ->
- source = @getUserConfigPath()
- priority = @priorityForSource(source)
- @scopedSettingsStore.removePropertiesForSource(source)
-
- for scopeSelector, settings of newScopedSettings
- settings = @makeValueConformToSchema(null, settings, suppressException: true)
- validatedSettings = {}
- validatedSettings[scopeSelector] = withoutEmptyObjects(settings)
- @scopedSettingsStore.addProperties(source, validatedSettings, {priority}) if validatedSettings[scopeSelector]?
-
- @emitChangeEvent()
-
- setRawScopedValue: (keyPath, value, source, selector, options) ->
- if keyPath?
- newValue = {}
- setValueAtKeyPath(newValue, keyPath, value)
- value = newValue
-
- settingsBySelector = {}
- settingsBySelector[selector] = value
- @scopedSettingsStore.addProperties(source, settingsBySelector, priority: @priorityForSource(source))
- @emitChangeEvent()
-
- getRawScopedValue: (scopeDescriptor, keyPath, options) ->
- scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor)
- result = @scopedSettingsStore.getPropertyValue(
- scopeDescriptor.getScopeChain(),
- keyPath,
- options
- )
-
- if result?
- result
- else if legacyScopeDescriptor = @getLegacyScopeDescriptor(scopeDescriptor)
- @scopedSettingsStore.getPropertyValue(
- legacyScopeDescriptor.getScopeChain(),
- keyPath,
- options
- )
-
- observeScopedKeyPath: (scope, keyPath, callback) ->
- callback(@get(keyPath, {scope}))
- @onDidChangeScopedKeyPath scope, keyPath, (event) -> callback(event.newValue)
-
- onDidChangeScopedKeyPath: (scope, keyPath, callback) ->
- oldValue = @get(keyPath, {scope})
- @emitter.on 'did-change', =>
- newValue = @get(keyPath, {scope})
- unless _.isEqual(oldValue, newValue)
- event = {oldValue, newValue}
- oldValue = newValue
- callback(event)
-
- getLegacyScopeDescriptor: (scopeDescriptor) ->
- legacyAlias = @legacyScopeAliases[scopeDescriptor.scopes[0]]
- if legacyAlias
- scopes = scopeDescriptor.scopes.slice()
- scopes[0] = legacyAlias
- new ScopeDescriptor({scopes})
-
-# Base schema enforcers. These will coerce raw input into the specified type,
-# and will throw an error when the value cannot be coerced. Throwing the error
-# will indicate that the value should not be set.
-#
-# Enforcers are run from most specific to least. For a schema with type
-# `integer`, all the enforcers for the `integer` type will be run first, in
-# order of specification. Then the `*` enforcers will be run, in order of
-# specification.
-Config.addSchemaEnforcers
- 'any':
- coerce: (keyPath, value, schema) ->
- value
-
- 'integer':
- coerce: (keyPath, value, schema) ->
- value = parseInt(value)
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value)
- value
-
- 'number':
- coerce: (keyPath, value, schema) ->
- value = parseFloat(value)
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value)
- value
-
- 'boolean':
- coerce: (keyPath, value, schema) ->
- switch typeof value
- when 'string'
- if value.toLowerCase() is 'true'
- true
- else if value.toLowerCase() is 'false'
- false
- else
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'")
- when 'boolean'
- value
- else
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'")
-
- 'string':
- validate: (keyPath, value, schema) ->
- unless typeof value is 'string'
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string")
- value
-
- validateMaximumLength: (keyPath, value, schema) ->
- if typeof schema.maximumLength is 'number' and value.length > schema.maximumLength
- value.slice(0, schema.maximumLength)
- else
- value
-
- 'null':
- # null sort of isnt supported. It will just unset in this case
- coerce: (keyPath, value, schema) ->
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value in [undefined, null]
- value
-
- 'object':
- coerce: (keyPath, value, schema) ->
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value)
- return value unless schema.properties?
-
- defaultChildSchema = null
- allowsAdditionalProperties = true
- if isPlainObject(schema.additionalProperties)
- defaultChildSchema = schema.additionalProperties
- if schema.additionalProperties is false
- allowsAdditionalProperties = false
-
- newValue = {}
- for prop, propValue of value
- childSchema = schema.properties[prop] ? defaultChildSchema
- if childSchema?
- try
- newValue[prop] = @executeSchemaEnforcers(pushKeyPath(keyPath, prop), propValue, childSchema)
- catch error
- console.warn "Error setting item in object: #{error.message}"
- else if allowsAdditionalProperties
- # Just pass through un-schema'd values
- newValue[prop] = propValue
- else
- console.warn "Illegal object key: #{keyPath}.#{prop}"
-
- newValue
-
- 'array':
- coerce: (keyPath, value, schema) ->
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value)
- itemSchema = schema.items
- if itemSchema?
- newValue = []
- for item in value
- try
- newValue.push @executeSchemaEnforcers(keyPath, item, itemSchema)
- catch error
- console.warn "Error setting item in array: #{error.message}"
- newValue
- else
- value
-
- 'color':
- coerce: (keyPath, value, schema) ->
- color = Color.parse(value)
- unless color?
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a color")
- color
-
- '*':
- coerceMinimumAndMaximum: (keyPath, value, schema) ->
- return value unless typeof value is 'number'
- if schema.minimum? and typeof schema.minimum is 'number'
- value = Math.max(value, schema.minimum)
- if schema.maximum? and typeof schema.maximum is 'number'
- value = Math.min(value, schema.maximum)
- value
-
- validateEnum: (keyPath, value, schema) ->
- possibleValues = schema.enum
-
- if Array.isArray(possibleValues)
- possibleValues = possibleValues.map (value) ->
- if value.hasOwnProperty('value') then value.value else value
-
- return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length
-
- for possibleValue in possibleValues
- # Using `isEqual` for possibility of placing enums on array and object schemas
- return value if _.isEqual(possibleValue, value)
-
- throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}")
-
-isPlainObject = (value) ->
- _.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value) and not (value instanceof Color)
-
-sortObject = (value) ->
- return value unless isPlainObject(value)
- result = {}
- for key in Object.keys(value).sort()
- result[key] = sortObject(value[key])
- result
-
-withoutEmptyObjects = (object) ->
- resultObject = undefined
- if isPlainObject(object)
- for key, value of object
- newValue = withoutEmptyObjects(value)
- if newValue?
- resultObject ?= {}
- resultObject[key] = newValue
- else
- resultObject = object
- resultObject
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 000000000..ce20db30c
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,1484 @@
+const _ = require('underscore-plus')
+const {Emitter} = require('event-kit')
+const {
+ getValueAtKeyPath, setValueAtKeyPath, deleteValueAtKeyPath,
+ pushKeyPath, splitKeyPath
+} = require('key-path-helpers')
+const Color = require('./color')
+const ScopedPropertyStore = require('scoped-property-store')
+const ScopeDescriptor = require('./scope-descriptor')
+
+const schemaEnforcers = {}
+
+// Essential: Used to access all of Atom's configuration details.
+//
+// An instance of this class is always available as the `atom.config` global.
+//
+// ## Getting and setting config settings.
+//
+// ```coffee
+// # Note that with no value set, ::get returns the setting's default value.
+// atom.config.get('my-package.myKey') # -> 'defaultValue'
+//
+// atom.config.set('my-package.myKey', 'value')
+// atom.config.get('my-package.myKey') # -> 'value'
+// ```
+//
+// You may want to watch for changes. Use {::observe} to catch changes to the setting.
+//
+// ```coffee
+// atom.config.set('my-package.myKey', 'value')
+// atom.config.observe 'my-package.myKey', (newValue) ->
+// # `observe` calls immediately and every time the value is changed
+// console.log 'My configuration changed:', newValue
+// ```
+//
+// If you want a notification only when the value changes, use {::onDidChange}.
+//
+// ```coffee
+// atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) ->
+// console.log 'My configuration changed:', newValue, oldValue
+// ```
+//
+// ### Value Coercion
+//
+// Config settings each have a type specified by way of a
+// [schema](json-schema.org). For example we might want an integer setting that only
+// allows integers greater than `0`:
+//
+// ```coffee
+// # When no value has been set, `::get` returns the setting's default value
+// atom.config.get('my-package.anInt') # -> 12
+//
+// # The string will be coerced to the integer 123
+// atom.config.set('my-package.anInt', '123')
+// atom.config.get('my-package.anInt') # -> 123
+//
+// # The string will be coerced to an integer, but it must be greater than 0, so is set to 1
+// atom.config.set('my-package.anInt', '-20')
+// atom.config.get('my-package.anInt') # -> 1
+// ```
+//
+// ## Defining settings for your package
+//
+// Define a schema under a `config` key in your package main.
+//
+// ```coffee
+// module.exports =
+// # Your config schema
+// config:
+// someInt:
+// type: 'integer'
+// default: 23
+// minimum: 1
+//
+// activate: (state) -> # ...
+// # ...
+// ```
+//
+// See [package docs](http://flight-manual.atom.io/hacking-atom/sections/package-word-count/) for
+// more info.
+//
+// ## Config Schemas
+//
+// We use [json schema](http://json-schema.org) which allows you to define your value's
+// default, the type it should be, etc. A simple example:
+//
+// ```coffee
+// # We want to provide an `enableThing`, and a `thingVolume`
+// config:
+// enableThing:
+// type: 'boolean'
+// default: false
+// thingVolume:
+// type: 'integer'
+// default: 5
+// minimum: 1
+// maximum: 11
+// ```
+//
+// The type keyword allows for type coercion and validation. If a `thingVolume` is
+// set to a string `'10'`, it will be coerced into an integer.
+//
+// ```coffee
+// atom.config.set('my-package.thingVolume', '10')
+// atom.config.get('my-package.thingVolume') # -> 10
+//
+// # It respects the min / max
+// atom.config.set('my-package.thingVolume', '400')
+// atom.config.get('my-package.thingVolume') # -> 11
+//
+// # If it cannot be coerced, the value will not be set
+// atom.config.set('my-package.thingVolume', 'cats')
+// atom.config.get('my-package.thingVolume') # -> 11
+// ```
+//
+// ### Supported Types
+//
+// The `type` keyword can be a string with any one of the following. You can also
+// chain them by specifying multiple in an an array. For example
+//
+// ```coffee
+// config:
+// someSetting:
+// type: ['boolean', 'integer']
+// default: 5
+//
+// # Then
+// atom.config.set('my-package.someSetting', 'true')
+// atom.config.get('my-package.someSetting') # -> true
+//
+// atom.config.set('my-package.someSetting', '12')
+// atom.config.get('my-package.someSetting') # -> 12
+// ```
+//
+// #### string
+//
+// Values must be a string.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'string'
+// default: 'hello'
+// ```
+//
+// #### integer
+//
+// Values will be coerced into integer. Supports the (optional) `minimum` and
+// `maximum` keys.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'integer'
+// default: 5
+// minimum: 1
+// maximum: 11
+// ```
+//
+// #### number
+//
+// Values will be coerced into a number, including real numbers. Supports the
+// (optional) `minimum` and `maximum` keys.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'number'
+// default: 5.3
+// minimum: 1.5
+// maximum: 11.5
+// ```
+//
+// #### boolean
+//
+// Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into
+// a boolean. Numbers, arrays, objects, and anything else will not be coerced.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'boolean'
+// default: false
+// ```
+//
+// #### array
+//
+// Value must be an Array. The types of the values can be specified by a
+// subschema in the `items` key.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'array'
+// default: [1, 2, 3]
+// items:
+// type: 'integer'
+// minimum: 1.5
+// maximum: 11.5
+// ```
+//
+// #### color
+//
+// Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha`
+// properties that all have numeric values. `red`, `green`, `blue` will be in
+// the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any
+// valid CSS color format such as `#abc`, `#abcdef`, `white`,
+// `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'color'
+// default: 'white'
+// ```
+//
+// #### object / Grouping other types
+//
+// A config setting with the type `object` allows grouping a set of config
+// settings. The group will be visually separated and has its own group headline.
+// The sub options must be listed under a `properties` key.
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'object'
+// properties:
+// myChildIntOption:
+// type: 'integer'
+// minimum: 1.5
+// maximum: 11.5
+// ```
+//
+// ### Other Supported Keys
+//
+// #### enum
+//
+// All types support an `enum` key, which lets you specify all the values the
+// setting can take. `enum` may be an array of allowed values (of the specified
+// type), or an array of objects with `value` and `description` properties, where
+// the `value` is an allowed value, and the `description` is a descriptive string
+// used in the settings view.
+//
+// In this example, the setting must be one of the 4 integers:
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'integer'
+// default: 4
+// enum: [2, 4, 6, 8]
+// ```
+//
+// In this example, the setting must be either 'foo' or 'bar', which are
+// presented using the provided descriptions in the settings pane:
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'string'
+// default: 'foo'
+// enum: [
+// {value: 'foo', description: 'Foo mode. You want this.'}
+// {value: 'bar', description: 'Bar mode. Nobody wants that!'}
+// ]
+// ```
+//
+// Usage:
+//
+// ```coffee
+// atom.config.set('my-package.someSetting', '2')
+// atom.config.get('my-package.someSetting') # -> 2
+//
+// # will not set values outside of the enum values
+// atom.config.set('my-package.someSetting', '3')
+// atom.config.get('my-package.someSetting') # -> 2
+//
+// # If it cannot be coerced, the value will not be set
+// atom.config.set('my-package.someSetting', '4')
+// atom.config.get('my-package.someSetting') # -> 4
+// ```
+//
+// #### title and description
+//
+// The settings view will use the `title` and `description` keys to display your
+// config setting in a readable way. By default the settings view humanizes your
+// config key, so `someSetting` becomes `Some Setting`. In some cases, this is
+// confusing for users, and a more descriptive title is useful.
+//
+// Descriptions will be displayed below the title in the settings view.
+//
+// For a group of config settings the humanized key or the title and the
+// description are used for the group headline.
+//
+// ```coffee
+// config:
+// someSetting:
+// title: 'Setting Magnitude'
+// description: 'This will affect the blah and the other blah'
+// type: 'integer'
+// default: 4
+// ```
+//
+// __Note__: You should strive to be so clear in your naming of the setting that
+// you do not need to specify a title or description!
+//
+// Descriptions allow a subset of
+// [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/).
+// Specifically, you may use the following in configuration setting descriptions:
+//
+// * **bold** - `**bold**`
+// * *italics* - `*italics*`
+// * [links](https://atom.io) - `[links](https://atom.io)`
+// * `code spans` - `\`code spans\``
+// * line breaks - `line breaks
`
+// * ~~strikethrough~~ - `~~strikethrough~~`
+//
+// #### order
+//
+// The settings view orders your settings alphabetically. You can override this
+// ordering with the order key.
+//
+// ```coffee
+// config:
+// zSetting:
+// type: 'integer'
+// default: 4
+// order: 1
+// aSetting:
+// type: 'integer'
+// default: 4
+// order: 2
+// ```
+//
+// ## Manipulating values outside your configuration schema
+//
+// It is possible to manipulate(`get`, `set`, `observe` etc) values that do not
+// appear in your configuration schema. For example, if the config schema of the
+// package 'some-package' is
+//
+// ```coffee
+// config:
+// someSetting:
+// type: 'boolean'
+// default: false
+// ```
+//
+// You can still do the following
+//
+// ```coffee
+// let otherSetting = atom.config.get('some-package.otherSetting')
+// atom.config.set('some-package.stillAnotherSetting', otherSetting * 5)
+// ```
+//
+// In other words, if a function asks for a `key-path`, that path doesn't have to
+// be described in the config schema for the package or any package. However, as
+// highlighted in the best practices section, you are advised against doing the
+// above.
+//
+// ## Best practices
+//
+// * Don't depend on (or write to) configuration keys outside of your keypath.
+//
+class Config {
+ static addSchemaEnforcer (typeName, enforcerFunction) {
+ if (schemaEnforcers[typeName] == null) { schemaEnforcers[typeName] = [] }
+ return schemaEnforcers[typeName].push(enforcerFunction)
+ }
+
+ static addSchemaEnforcers (filters) {
+ for (let typeName in filters) {
+ const functions = filters[typeName]
+ for (let name in functions) {
+ const enforcerFunction = functions[name]
+ this.addSchemaEnforcer(typeName, enforcerFunction)
+ }
+ }
+ }
+
+ static executeSchemaEnforcers (keyPath, value, schema) {
+ let error = null
+ let types = schema.type
+ if (!Array.isArray(types)) { types = [types] }
+ for (let type of types) {
+ try {
+ const enforcerFunctions = schemaEnforcers[type].concat(schemaEnforcers['*'])
+ for (let enforcer of enforcerFunctions) {
+ // At some point in one's life, one must call upon an enforcer.
+ value = enforcer.call(this, keyPath, value, schema)
+ }
+ error = null
+ break
+ } catch (e) {
+ error = e
+ }
+ }
+
+ if (error != null) { throw error }
+ return value
+ }
+
+ // Created during initialization, available as `atom.config`
+ constructor (params = {}) {
+ this.clear()
+ this.initialize(params)
+ }
+
+ initialize ({saveCallback, mainSource, projectHomeSchema}) {
+ if (saveCallback) {
+ this.saveCallback = saveCallback
+ }
+ if (mainSource) this.mainSource = mainSource
+ if (projectHomeSchema) {
+ this.schema.properties.core.properties.projectHome = projectHomeSchema
+ this.defaultSettings.core.projectHome = projectHomeSchema.default
+ }
+ }
+
+ clear () {
+ this.emitter = new Emitter()
+ this.schema = {
+ type: 'object',
+ properties: {}
+ }
+
+ this.defaultSettings = {}
+ this.settings = {}
+ this.projectSettings = {}
+ this.projectFile = null
+
+ this.scopedSettingsStore = new ScopedPropertyStore()
+
+ this.settingsLoaded = false
+ this.transactDepth = 0
+ this.pendingOperations = []
+ this.legacyScopeAliases = new Map()
+ this.requestSave = _.debounce(() => this.save(), 1)
+ }
+
+ /*
+ Section: Config Subscription
+ */
+
+ // Essential: Add a listener for changes to a given key path. This is different
+ // than {::onDidChange} in that it will immediately call your callback with the
+ // current value of the config entry.
+ //
+ // ### Examples
+ //
+ // You might want to be notified when the themes change. We'll watch
+ // `core.themes` for changes
+ //
+ // ```coffee
+ // atom.config.observe 'core.themes', (value) ->
+ // # do stuff with value
+ // ```
+ //
+ // * `keyPath` {String} name of the key to observe
+ // * `options` (optional) {Object}
+ // * `scope` (optional) {ScopeDescriptor} describing a path from
+ // the root of the syntax tree to a token. Get one by calling
+ // {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
+ // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
+ // for more information.
+ // * `callback` {Function} to call when the value of the key changes.
+ // * `value` the new value of the key
+ //
+ // Returns a {Disposable} with the following keys on which you can call
+ // `.dispose()` to unsubscribe.
+ observe (...args) {
+ let callback, keyPath, options, scopeDescriptor
+ if (args.length === 2) {
+ [keyPath, callback] = args
+ } else if ((args.length === 3) && (_.isString(args[0]) && _.isObject(args[1]))) {
+ [keyPath, options, callback] = args
+ scopeDescriptor = options.scope
+ } else {
+ console.error('An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details')
+ return
+ }
+
+ if (scopeDescriptor != null) {
+ return this.observeScopedKeyPath(scopeDescriptor, keyPath, callback)
+ } else {
+ return this.observeKeyPath(keyPath, options != null ? options : {}, callback)
+ }
+ }
+
+ // Essential: Add a listener for changes to a given key path. If `keyPath` is
+ // not specified, your callback will be called on changes to any key.
+ //
+ // * `keyPath` (optional) {String} name of the key to observe. Must be
+ // specified if `scopeDescriptor` is specified.
+ // * `options` (optional) {Object}
+ // * `scope` (optional) {ScopeDescriptor} describing a path from
+ // the root of the syntax tree to a token. Get one by calling
+ // {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
+ // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
+ // for more information.
+ // * `callback` {Function} to call when the value of the key changes.
+ // * `event` {Object}
+ // * `newValue` the new value of the key
+ // * `oldValue` the prior value of the key.
+ //
+ // Returns a {Disposable} with the following keys on which you can call
+ // `.dispose()` to unsubscribe.
+ onDidChange (...args) {
+ let callback, keyPath, scopeDescriptor
+ if (args.length === 1) {
+ [callback] = args
+ } else if (args.length === 2) {
+ [keyPath, callback] = args
+ } else {
+ let options;
+ [keyPath, options, callback] = args
+ scopeDescriptor = options.scope
+ }
+
+ if (scopeDescriptor != null) {
+ return this.onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback)
+ } else {
+ return this.onDidChangeKeyPath(keyPath, callback)
+ }
+ }
+
+ /*
+ Section: Managing Settings
+ */
+
+ // Essential: Retrieves the setting for the given key.
+ //
+ // ### Examples
+ //
+ // You might want to know what themes are enabled, so check `core.themes`
+ //
+ // ```coffee
+ // atom.config.get('core.themes')
+ // ```
+ //
+ // With scope descriptors you can get settings within a specific editor
+ // scope. For example, you might want to know `editor.tabLength` for ruby
+ // files.
+ //
+ // ```coffee
+ // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
+ // ```
+ //
+ // This setting in ruby files might be different than the global tabLength setting
+ //
+ // ```coffee
+ // atom.config.get('editor.tabLength') # => 4
+ // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
+ // ```
+ //
+ // You can get the language scope descriptor via
+ // {TextEditor::getRootScopeDescriptor}. This will get the setting specifically
+ // for the editor's language.
+ //
+ // ```coffee
+ // atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2
+ // ```
+ //
+ // Additionally, you can get the setting at the specific cursor position.
+ //
+ // ```coffee
+ // scopeDescriptor = @editor.getLastCursor().getScopeDescriptor()
+ // atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2
+ // ```
+ //
+ // * `keyPath` The {String} name of the key to retrieve.
+ // * `options` (optional) {Object}
+ // * `sources` (optional) {Array} of {String} source names. If provided, only
+ // values that were associated with these sources during {::set} will be used.
+ // * `excludeSources` (optional) {Array} of {String} source names. If provided,
+ // values that were associated with these sources during {::set} will not
+ // be used.
+ // * `scope` (optional) {ScopeDescriptor} describing a path from
+ // the root of the syntax tree to a token. Get one by calling
+ // {editor.getLastCursor().getScopeDescriptor()}
+ // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
+ // for more information.
+ //
+ // Returns the value from Atom's default settings, the user's configuration
+ // file in the type specified by the configuration schema.
+ get (...args) {
+ let keyPath, options, scope
+ if (args.length > 1) {
+ if ((typeof args[0] === 'string') || (args[0] == null)) {
+ [keyPath, options] = args;
+ ({scope} = options)
+ }
+ } else {
+ [keyPath] = args
+ }
+
+ if (scope != null) {
+ const value = this.getRawScopedValue(scope, keyPath, options)
+ return value != null ? value : this.getRawValue(keyPath, options)
+ } else {
+ return this.getRawValue(keyPath, options)
+ }
+ }
+
+ // Extended: Get all of the values for the given key-path, along with their
+ // associated scope selector.
+ //
+ // * `keyPath` The {String} name of the key to retrieve
+ // * `options` (optional) {Object} see the `options` argument to {::get}
+ //
+ // Returns an {Array} of {Object}s with the following keys:
+ // * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated
+ // * `value` The value for the key-path
+ getAll (keyPath, options) {
+ let globalValue, result, scope
+ if (options != null) { ({scope} = options) }
+
+ if (scope != null) {
+ let legacyScopeDescriptor
+ const scopeDescriptor = ScopeDescriptor.fromObject(scope)
+ result = this.scopedSettingsStore.getAll(
+ scopeDescriptor.getScopeChain(),
+ keyPath,
+ options
+ )
+ legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor)
+ if (legacyScopeDescriptor) {
+ result.push(...Array.from(this.scopedSettingsStore.getAll(
+ legacyScopeDescriptor.getScopeChain(),
+ keyPath,
+ options
+ ) || []))
+ }
+ } else {
+ result = []
+ }
+
+ globalValue = this.getRawValue(keyPath, options)
+ if (globalValue) {
+ result.push({scopeSelector: '*', value: globalValue})
+ }
+
+ return result
+ }
+
+ // Essential: Sets the value for a configuration setting.
+ //
+ // This value is stored in Atom's internal configuration file.
+ //
+ // ### Examples
+ //
+ // You might want to change the themes programmatically:
+ //
+ // ```coffee
+ // atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax'])
+ // ```
+ //
+ // You can also set scoped settings. For example, you might want change the
+ // `editor.tabLength` only for ruby files.
+ //
+ // ```coffee
+ // atom.config.get('editor.tabLength') # => 4
+ // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4
+ // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
+ //
+ // # Set ruby to 2
+ // atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true
+ //
+ // # Notice it's only set to 2 in the case of ruby
+ // atom.config.get('editor.tabLength') # => 4
+ // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
+ // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
+ // ```
+ //
+ // * `keyPath` The {String} name of the key.
+ // * `value` The value of the setting. Passing `undefined` will revert the
+ // setting to the default value.
+ // * `options` (optional) {Object}
+ // * `scopeSelector` (optional) {String}. eg. '.source.ruby'
+ // See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
+ // for more information.
+ // * `source` (optional) {String} The name of a file with which the setting
+ // is associated. Defaults to the user's config file.
+ //
+ // Returns a {Boolean}
+ // * `true` if the value was set.
+ // * `false` if the value was not able to be coerced to the type specified in the setting's schema.
+ set (...args) {
+ let [keyPath, value, options = {}] = args
+
+ if (!this.settingsLoaded) {
+ this.pendingOperations.push(() => this.set(keyPath, value, options))
+ }
+
+ const scopeSelector = options.scopeSelector
+ let source = options.source
+ const shouldSave = options.save != null ? options.save : true
+
+ if (source && !scopeSelector && source !== this.projectFile) {
+ throw new Error("::set with a 'source' and no 'sourceSelector' is not yet implemented!")
+ }
+
+ if (!source) source = this.mainSource
+
+ if (value !== undefined) {
+ try {
+ value = this.makeValueConformToSchema(keyPath, value)
+ } catch (e) {
+ return false
+ }
+ }
+
+ if (scopeSelector != null) {
+ this.setRawScopedValue(keyPath, value, source, scopeSelector)
+ } else {
+ this.setRawValue(keyPath, value, {source})
+ }
+
+ if (source === this.mainSource && shouldSave && this.settingsLoaded) {
+ this.requestSave()
+ }
+ return true
+ }
+
+ // Essential: Restore the setting at `keyPath` to its default value.
+ //
+ // * `keyPath` The {String} name of the key.
+ // * `options` (optional) {Object}
+ // * `scopeSelector` (optional) {String}. See {::set}
+ // * `source` (optional) {String}. See {::set}
+ unset (keyPath, options) {
+ if (!this.settingsLoaded) {
+ this.pendingOperations.push(() => this.unset(keyPath, options))
+ }
+
+ let {scopeSelector, source} = options != null ? options : {}
+ if (source == null) { source = this.mainSource }
+
+ if (scopeSelector != null) {
+ if (keyPath != null) {
+ let settings = this.scopedSettingsStore.propertiesForSourceAndSelector(source, scopeSelector)
+ if (getValueAtKeyPath(settings, keyPath) != null) {
+ this.scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
+ setValueAtKeyPath(settings, keyPath, undefined)
+ settings = withoutEmptyObjects(settings)
+ if (settings != null) {
+ this.set(null, settings, {scopeSelector, source, priority: this.priorityForSource(source)})
+ }
+
+ const configIsReady = (source === this.mainSource) && this.settingsLoaded
+ if (configIsReady) {
+ return this.requestSave()
+ }
+ }
+ } else {
+ this.scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
+ return this.emitChangeEvent()
+ }
+ } else {
+ for (scopeSelector in this.scopedSettingsStore.propertiesForSource(source)) {
+ this.unset(keyPath, {scopeSelector, source})
+ }
+ if ((keyPath != null) && (source === this.mainSource)) {
+ return this.set(keyPath, getValueAtKeyPath(this.defaultSettings, keyPath))
+ }
+ }
+ }
+
+ // Extended: Get an {Array} of all of the `source` {String}s with which
+ // settings have been added via {::set}.
+ getSources () {
+ return _.uniq(_.pluck(this.scopedSettingsStore.propertySets, 'source')).sort()
+ }
+
+ // Extended: Retrieve the schema for a specific key path. The schema will tell
+ // you what type the keyPath expects, and other metadata about the config
+ // option.
+ //
+ // * `keyPath` The {String} name of the key.
+ //
+ // Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`.
+ // Returns `null` when the keyPath has no schema specified, but is accessible
+ // from the root schema.
+ getSchema (keyPath) {
+ const keys = splitKeyPath(keyPath)
+ let { schema } = this
+ for (let key of keys) {
+ let childSchema
+ if (schema.type === 'object') {
+ childSchema = schema.properties != null ? schema.properties[key] : undefined
+ if (childSchema == null) {
+ if (isPlainObject(schema.additionalProperties)) {
+ childSchema = schema.additionalProperties
+ } else if (schema.additionalProperties === false) {
+ return null
+ } else {
+ return {type: 'any'}
+ }
+ }
+ } else {
+ return null
+ }
+ schema = childSchema
+ }
+ return schema
+ }
+
+ getUserConfigPath () {
+ return this.mainSource
+ }
+
+ // Extended: Suppress calls to handler functions registered with {::onDidChange}
+ // and {::observe} for the duration of `callback`. After `callback` executes,
+ // handlers will be called once if the value for their key-path has changed.
+ //
+ // * `callback` {Function} to execute while suppressing calls to handlers.
+ transact (callback) {
+ this.beginTransaction()
+ try {
+ return callback()
+ } finally {
+ this.endTransaction()
+ }
+ }
+
+ getLegacyScopeDescriptorForNewScopeDescriptor (scopeDescriptor) {
+ return null
+ }
+
+ /*
+ Section: Internal methods used by core
+ */
+
+ // Private: Suppress calls to handler functions registered with {::onDidChange}
+ // and {::observe} for the duration of the {Promise} returned by `callback`.
+ // After the {Promise} is either resolved or rejected, handlers will be called
+ // once if the value for their key-path has changed.
+ //
+ // * `callback` {Function} that returns a {Promise}, which will be executed
+ // while suppressing calls to handlers.
+ //
+ // Returns a {Promise} that is either resolved or rejected according to the
+ // `{Promise}` returned by `callback`. If `callback` throws an error, a
+ // rejected {Promise} will be returned instead.
+ transactAsync (callback) {
+ let endTransaction
+ this.beginTransaction()
+ try {
+ endTransaction = fn => (...args) => {
+ this.endTransaction()
+ return fn(...args)
+ }
+ const result = callback()
+ return new Promise((resolve, reject) => {
+ return result.then(endTransaction(resolve)).catch(endTransaction(reject))
+ })
+ } catch (error) {
+ this.endTransaction()
+ return Promise.reject(error)
+ }
+ }
+
+ beginTransaction () {
+ this.transactDepth++
+ }
+
+ endTransaction () {
+ this.transactDepth--
+ this.emitChangeEvent()
+ }
+
+ pushAtKeyPath (keyPath, value) {
+ const left = this.get(keyPath)
+ const arrayValue = (left == null ? [] : left)
+ const result = arrayValue.push(value)
+ this.set(keyPath, arrayValue)
+ return result
+ }
+
+ unshiftAtKeyPath (keyPath, value) {
+ const left = this.get(keyPath)
+ const arrayValue = (left == null ? [] : left)
+ const result = arrayValue.unshift(value)
+ this.set(keyPath, arrayValue)
+ return result
+ }
+
+ removeAtKeyPath (keyPath, value) {
+ const left = this.get(keyPath)
+ const arrayValue = (left == null ? [] : left)
+ const result = _.remove(arrayValue, value)
+ this.set(keyPath, arrayValue)
+ return result
+ }
+
+ setSchema (keyPath, schema) {
+ if (!isPlainObject(schema)) {
+ throw new Error(`Error loading schema for ${keyPath}: schemas can only be objects!`)
+ }
+
+ if (schema.type == null) {
+ throw new Error(`Error loading schema for ${keyPath}: schema objects must have a type attribute`)
+ }
+
+ let rootSchema = this.schema
+ if (keyPath) {
+ for (let key of splitKeyPath(keyPath)) {
+ rootSchema.type = 'object'
+ if (rootSchema.properties == null) { rootSchema.properties = {} }
+ const { properties } = rootSchema
+ if (properties[key] == null) { properties[key] = {} }
+ rootSchema = properties[key]
+ }
+ }
+
+ Object.assign(rootSchema, schema)
+ this.transact(() => {
+ this.setDefaults(keyPath, this.extractDefaultsFromSchema(schema))
+ this.setScopedDefaultsFromSchema(keyPath, schema)
+ this.resetSettingsForSchemaChange()
+ })
+ }
+
+ save () {
+ if (this.saveCallback) {
+ let allSettings = {'*': this.settings}
+ allSettings = Object.assign(allSettings, this.scopedSettingsStore.propertiesForSource(this.mainSource))
+ allSettings = sortObject(allSettings)
+ this.saveCallback(allSettings)
+ }
+ }
+
+ /*
+ Section: Private methods managing global settings
+ */
+
+ resetUserSettings (newSettings, options = {}) {
+ this._resetSettings(newSettings, options)
+ }
+
+ _resetSettings (newSettings, options = {}) {
+ const source = options.source
+ newSettings = Object.assign({}, newSettings)
+ if (newSettings.global != null) {
+ newSettings['*'] = newSettings.global
+ delete newSettings.global
+ }
+
+ if (newSettings['*'] != null) {
+ const scopedSettings = newSettings
+ newSettings = newSettings['*']
+ delete scopedSettings['*']
+ this.resetScopedSettings(scopedSettings, {source})
+ }
+
+ return this.transact(() => {
+ this._clearUnscopedSettingsForSource(source)
+ this.settingsLoaded = true
+ for (let key in newSettings) {
+ const value = newSettings[key]
+ this.set(key, value, {save: false, source})
+ }
+ if (this.pendingOperations.length) {
+ for (let op of this.pendingOperations) { op() }
+ this.pendingOperations = []
+ }
+ })
+ }
+
+ _clearUnscopedSettingsForSource (source) {
+ if (source === this.projectFile) {
+ this.projectSettings = {}
+ } else {
+ this.settings = {}
+ }
+ }
+
+ resetProjectSettings (newSettings, projectFile) {
+ // Sets the scope and source of all project settings to `path`.
+ newSettings = Object.assign({}, newSettings)
+ const oldProjectFile = this.projectFile
+ this.projectFile = projectFile
+ if (this.projectFile != null) {
+ this._resetSettings(newSettings, {source: this.projectFile})
+ } else {
+ this.scopedSettingsStore.removePropertiesForSource(oldProjectFile)
+ this.projectSettings = {}
+ }
+ }
+
+ clearProjectSettings () {
+ this.resetProjectSettings({}, null)
+ }
+
+ getRawValue (keyPath, options = {}) {
+ let value
+ if (!options.excludeSources || !options.excludeSources.includes(this.mainSource)) {
+ value = getValueAtKeyPath(this.settings, keyPath)
+ if (this.projectFile != null) {
+ const projectValue = getValueAtKeyPath(this.projectSettings, keyPath)
+ value = (projectValue === undefined) ? value : projectValue
+ }
+ }
+
+ let defaultValue
+ if (!options.sources || options.sources.length === 0) {
+ defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath)
+ }
+
+ if (value != null) {
+ value = this.deepClone(value)
+ if (isPlainObject(value) && isPlainObject(defaultValue)) {
+ this.deepDefaults(value, defaultValue)
+ }
+ return value
+ } else {
+ return this.deepClone(defaultValue)
+ }
+ }
+
+ setRawValue (keyPath, value, options = {}) {
+ const source = options.source ? options.source : undefined
+ const settingsToChange = source === this.projectFile ? 'projectSettings' : 'settings'
+ const defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath)
+
+ if (_.isEqual(defaultValue, value)) {
+ if (keyPath != null) {
+ deleteValueAtKeyPath(this[settingsToChange], keyPath)
+ } else {
+ this[settingsToChange] = null
+ }
+ } else {
+ if (keyPath != null) {
+ setValueAtKeyPath(this[settingsToChange], keyPath, value)
+ } else {
+ this[settingsToChange] = value
+ }
+ }
+ return this.emitChangeEvent()
+ }
+
+ observeKeyPath (keyPath, options, callback) {
+ callback(this.get(keyPath))
+ return this.onDidChangeKeyPath(keyPath, event => callback(event.newValue))
+ }
+
+ onDidChangeKeyPath (keyPath, callback) {
+ let oldValue = this.get(keyPath)
+ return this.emitter.on('did-change', () => {
+ const newValue = this.get(keyPath)
+ if (!_.isEqual(oldValue, newValue)) {
+ const event = {oldValue, newValue}
+ oldValue = newValue
+ return callback(event)
+ }
+ })
+ }
+
+ isSubKeyPath (keyPath, subKeyPath) {
+ if ((keyPath == null) || (subKeyPath == null)) { return false }
+ const pathSubTokens = splitKeyPath(subKeyPath)
+ const pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length)
+ return _.isEqual(pathTokens, pathSubTokens)
+ }
+
+ setRawDefault (keyPath, value) {
+ setValueAtKeyPath(this.defaultSettings, keyPath, value)
+ return this.emitChangeEvent()
+ }
+
+ setDefaults (keyPath, defaults) {
+ if ((defaults != null) && isPlainObject(defaults)) {
+ const keys = splitKeyPath(keyPath)
+ this.transact(() => {
+ const result = []
+ for (let key in defaults) {
+ const childValue = defaults[key]
+ if (!defaults.hasOwnProperty(key)) { continue }
+ result.push(this.setDefaults(keys.concat([key]).join('.'), childValue))
+ }
+ return result
+ })
+ } else {
+ try {
+ defaults = this.makeValueConformToSchema(keyPath, defaults)
+ this.setRawDefault(keyPath, defaults)
+ } catch (e) {
+ console.warn(`'${keyPath}' could not set the default. Attempted default: ${JSON.stringify(defaults)}; Schema: ${JSON.stringify(this.getSchema(keyPath))}`)
+ }
+ }
+ }
+
+ deepClone (object) {
+ if (object instanceof Color) {
+ return object.clone()
+ } else if (Array.isArray(object)) {
+ return object.map(value => this.deepClone(value))
+ } else if (isPlainObject(object)) {
+ return _.mapObject(object, (key, value) => [key, this.deepClone(value)])
+ } else {
+ return object
+ }
+ }
+
+ deepDefaults (target) {
+ let result = target
+ let i = 0
+ while (++i < arguments.length) {
+ const object = arguments[i]
+ if (isPlainObject(result) && isPlainObject(object)) {
+ for (let key of Object.keys(object)) {
+ result[key] = this.deepDefaults(result[key], object[key])
+ }
+ } else {
+ if ((result == null)) {
+ result = this.deepClone(object)
+ }
+ }
+ }
+ return result
+ }
+
+ // `schema` will look something like this
+ //
+ // ```coffee
+ // type: 'string'
+ // default: 'ok'
+ // scopes:
+ // '.source.js':
+ // default: 'omg'
+ // ```
+ setScopedDefaultsFromSchema (keyPath, schema) {
+ if ((schema.scopes != null) && isPlainObject(schema.scopes)) {
+ const scopedDefaults = {}
+ for (let scope in schema.scopes) {
+ const scopeSchema = schema.scopes[scope]
+ if (!scopeSchema.hasOwnProperty('default')) { continue }
+ scopedDefaults[scope] = {}
+ setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default)
+ }
+ this.scopedSettingsStore.addProperties('schema-default', scopedDefaults)
+ }
+
+ if ((schema.type === 'object') && (schema.properties != null) && isPlainObject(schema.properties)) {
+ const keys = splitKeyPath(keyPath)
+ for (let key in schema.properties) {
+ const childValue = schema.properties[key]
+ if (!schema.properties.hasOwnProperty(key)) { continue }
+ this.setScopedDefaultsFromSchema(keys.concat([key]).join('.'), childValue)
+ }
+ }
+ }
+
+ extractDefaultsFromSchema (schema) {
+ if (schema.default != null) {
+ return schema.default
+ } else if ((schema.type === 'object') && (schema.properties != null) && isPlainObject(schema.properties)) {
+ const defaults = {}
+ const properties = schema.properties || {}
+ for (let key in properties) { const value = properties[key]; defaults[key] = this.extractDefaultsFromSchema(value) }
+ return defaults
+ }
+ }
+
+ makeValueConformToSchema (keyPath, value, options) {
+ if (options != null ? options.suppressException : undefined) {
+ try {
+ return this.makeValueConformToSchema(keyPath, value)
+ } catch (e) {
+ return undefined
+ }
+ } else {
+ let schema
+ if ((schema = this.getSchema(keyPath)) == null) {
+ if (schema === false) { throw new Error(`Illegal key path ${keyPath}`) }
+ }
+ return this.constructor.executeSchemaEnforcers(keyPath, value, schema)
+ }
+ }
+
+ // When the schema is changed / added, there may be values set in the config
+ // that do not conform to the schema. This will reset make them conform.
+ resetSettingsForSchemaChange (source) {
+ if (source == null) { source = this.mainSource }
+ return this.transact(() => {
+ this.settings = this.makeValueConformToSchema(null, this.settings, {suppressException: true})
+ const selectorsAndSettings = this.scopedSettingsStore.propertiesForSource(source)
+ this.scopedSettingsStore.removePropertiesForSource(source)
+ for (let scopeSelector in selectorsAndSettings) {
+ let settings = selectorsAndSettings[scopeSelector]
+ settings = this.makeValueConformToSchema(null, settings, {suppressException: true})
+ this.setRawScopedValue(null, settings, source, scopeSelector)
+ }
+ })
+ }
+
+ /*
+ Section: Private Scoped Settings
+ */
+
+ priorityForSource (source) {
+ switch (source) {
+ case this.mainSource:
+ return 1000
+ case this.projectFile:
+ return 2000
+ default:
+ return 0
+ }
+ }
+
+ emitChangeEvent () {
+ if (this.transactDepth <= 0) { return this.emitter.emit('did-change') }
+ }
+
+ resetScopedSettings (newScopedSettings, options = {}) {
+ const source = options.source == null ? this.mainSource : options.source
+ const priority = this.priorityForSource(source)
+ this.scopedSettingsStore.removePropertiesForSource(source)
+
+ for (let scopeSelector in newScopedSettings) {
+ let settings = newScopedSettings[scopeSelector]
+ settings = this.makeValueConformToSchema(null, settings, {suppressException: true})
+ const validatedSettings = {}
+ validatedSettings[scopeSelector] = withoutEmptyObjects(settings)
+ if (validatedSettings[scopeSelector] != null) { this.scopedSettingsStore.addProperties(source, validatedSettings, {priority}) }
+ }
+
+ return this.emitChangeEvent()
+ }
+
+ setRawScopedValue (keyPath, value, source, selector, options) {
+ if (keyPath != null) {
+ const newValue = {}
+ setValueAtKeyPath(newValue, keyPath, value)
+ value = newValue
+ }
+
+ const settingsBySelector = {}
+ settingsBySelector[selector] = value
+ this.scopedSettingsStore.addProperties(source, settingsBySelector, {priority: this.priorityForSource(source)})
+ return this.emitChangeEvent()
+ }
+
+ getRawScopedValue (scopeDescriptor, keyPath, options) {
+ scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor)
+ const result = this.scopedSettingsStore.getPropertyValue(
+ scopeDescriptor.getScopeChain(),
+ keyPath,
+ options
+ )
+
+ const legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor)
+ if (result != null) {
+ return result
+ } else if (legacyScopeDescriptor) {
+ return this.scopedSettingsStore.getPropertyValue(
+ legacyScopeDescriptor.getScopeChain(),
+ keyPath,
+ options
+ )
+ }
+ }
+
+ observeScopedKeyPath (scope, keyPath, callback) {
+ callback(this.get(keyPath, {scope}))
+ return this.onDidChangeScopedKeyPath(scope, keyPath, event => callback(event.newValue))
+ }
+
+ onDidChangeScopedKeyPath (scope, keyPath, callback) {
+ let oldValue = this.get(keyPath, {scope})
+ return this.emitter.on('did-change', () => {
+ const newValue = this.get(keyPath, {scope})
+ if (!_.isEqual(oldValue, newValue)) {
+ const event = {oldValue, newValue}
+ oldValue = newValue
+ callback(event)
+ }
+ })
+ }
+};
+
+// Base schema enforcers. These will coerce raw input into the specified type,
+// and will throw an error when the value cannot be coerced. Throwing the error
+// will indicate that the value should not be set.
+//
+// Enforcers are run from most specific to least. For a schema with type
+// `integer`, all the enforcers for the `integer` type will be run first, in
+// order of specification. Then the `*` enforcers will be run, in order of
+// specification.
+Config.addSchemaEnforcers({
+ 'any': {
+ coerce (keyPath, value, schema) {
+ return value
+ }
+ },
+
+ 'integer': {
+ coerce (keyPath, value, schema) {
+ value = parseInt(value)
+ if (isNaN(value) || !isFinite(value)) { throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} cannot be coerced into an int`) }
+ return value
+ }
+ },
+
+ 'number': {
+ coerce (keyPath, value, schema) {
+ value = parseFloat(value)
+ if (isNaN(value) || !isFinite(value)) { throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} cannot be coerced into a number`) }
+ return value
+ }
+ },
+
+ 'boolean': {
+ coerce (keyPath, value, schema) {
+ switch (typeof value) {
+ case 'string':
+ if (value.toLowerCase() === 'true') {
+ return true
+ } else if (value.toLowerCase() === 'false') {
+ return false
+ } else {
+ throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be a boolean or the string 'true' or 'false'`)
+ }
+ case 'boolean':
+ return value
+ default:
+ throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be a boolean or the string 'true' or 'false'`)
+ }
+ }
+ },
+
+ 'string': {
+ validate (keyPath, value, schema) {
+ if (typeof value !== 'string') {
+ throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be a string`)
+ }
+ return value
+ },
+
+ validateMaximumLength (keyPath, value, schema) {
+ if ((typeof schema.maximumLength === 'number') && (value.length > schema.maximumLength)) {
+ return value.slice(0, schema.maximumLength)
+ } else {
+ return value
+ }
+ }
+ },
+
+ 'null': {
+ // null sort of isnt supported. It will just unset in this case
+ coerce (keyPath, value, schema) {
+ if (![undefined, null].includes(value)) { throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be null`) }
+ return value
+ }
+ },
+
+ 'object': {
+ coerce (keyPath, value, schema) {
+ if (!isPlainObject(value)) { throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be an object`) }
+ if (schema.properties == null) { return value }
+
+ let defaultChildSchema = null
+ let allowsAdditionalProperties = true
+ if (isPlainObject(schema.additionalProperties)) {
+ defaultChildSchema = schema.additionalProperties
+ }
+ if (schema.additionalProperties === false) {
+ allowsAdditionalProperties = false
+ }
+
+ const newValue = {}
+ for (let prop in value) {
+ const propValue = value[prop]
+ const childSchema = schema.properties[prop] != null ? schema.properties[prop] : defaultChildSchema
+ if (childSchema != null) {
+ try {
+ newValue[prop] = this.executeSchemaEnforcers(pushKeyPath(keyPath, prop), propValue, childSchema)
+ } catch (error) {
+ console.warn(`Error setting item in object: ${error.message}`)
+ }
+ } else if (allowsAdditionalProperties) {
+ // Just pass through un-schema'd values
+ newValue[prop] = propValue
+ } else {
+ console.warn(`Illegal object key: ${keyPath}.${prop}`)
+ }
+ }
+
+ return newValue
+ }
+ },
+
+ 'array': {
+ coerce (keyPath, value, schema) {
+ if (!Array.isArray(value)) { throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} must be an array`) }
+ const itemSchema = schema.items
+ if (itemSchema != null) {
+ const newValue = []
+ for (let item of value) {
+ try {
+ newValue.push(this.executeSchemaEnforcers(keyPath, item, itemSchema))
+ } catch (error) {
+ console.warn(`Error setting item in array: ${error.message}`)
+ }
+ }
+ return newValue
+ } else {
+ return value
+ }
+ }
+ },
+
+ 'color': {
+ coerce (keyPath, value, schema) {
+ const color = Color.parse(value)
+ if (color == null) {
+ throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} cannot be coerced into a color`)
+ }
+ return color
+ }
+ },
+
+ '*': {
+ coerceMinimumAndMaximum (keyPath, value, schema) {
+ if (typeof value !== 'number') { return value }
+ if ((schema.minimum != null) && (typeof schema.minimum === 'number')) {
+ value = Math.max(value, schema.minimum)
+ }
+ if ((schema.maximum != null) && (typeof schema.maximum === 'number')) {
+ value = Math.min(value, schema.maximum)
+ }
+ return value
+ },
+
+ validateEnum (keyPath, value, schema) {
+ let possibleValues = schema.enum
+
+ if (Array.isArray(possibleValues)) {
+ possibleValues = possibleValues.map(value => {
+ if (value.hasOwnProperty('value')) { return value.value } else { return value }
+ })
+ }
+
+ if ((possibleValues == null) || !Array.isArray(possibleValues) || !possibleValues.length) { return value }
+
+ for (let possibleValue of possibleValues) {
+ // Using `isEqual` for possibility of placing enums on array and object schemas
+ if (_.isEqual(possibleValue, value)) { return value }
+ }
+
+ throw new Error(`Validation failed at ${keyPath}, ${JSON.stringify(value)} is not one of ${JSON.stringify(possibleValues)}`)
+ }
+ }
+})
+
+let isPlainObject = value => _.isObject(value) && !Array.isArray(value) && !_.isFunction(value) && !_.isString(value) && !(value instanceof Color)
+
+let sortObject = value => {
+ if (!isPlainObject(value)) { return value }
+ const result = {}
+ for (let key of Object.keys(value).sort()) {
+ result[key] = sortObject(value[key])
+ }
+ return result
+}
+
+const withoutEmptyObjects = (object) => {
+ let resultObject
+ if (isPlainObject(object)) {
+ for (let key in object) {
+ const value = object[key]
+ const newValue = withoutEmptyObjects(value)
+ if (newValue != null) {
+ if (resultObject == null) { resultObject = {} }
+ resultObject[key] = newValue
+ }
+ }
+ } else {
+ resultObject = object
+ }
+ return resultObject
+}
+
+module.exports = Config
diff --git a/src/context-menu-manager.coffee b/src/context-menu-manager.coffee
index 10a7a3bdb..9cff5497b 100644
--- a/src/context-menu-manager.coffee
+++ b/src/context-menu-manager.coffee
@@ -5,6 +5,7 @@ fs = require 'fs-plus'
{Disposable} = require 'event-kit'
{remote} = require 'electron'
MenuHelpers = require './menu-helpers'
+{sortMenuItems} = require './menu-sort-helpers'
platformContextMenu = require('../package.json')?._atomMenu?['context-menu']
@@ -149,7 +150,7 @@ class ContextMenuManager
@pruneRedundantSeparators(template)
@addAccelerators(template)
- template
+ return @sortTemplate(template)
# Adds an `accelerator` property to items that have key bindings. Electron
# uses this property to surface the relevant keymaps in the context menu.
@@ -175,6 +176,13 @@ class ContextMenuManager
keepNextItemIfSeparator = true
index++
+ sortTemplate: (template) ->
+ template = sortMenuItems(template)
+ for id, item of template
+ if Array.isArray(item.submenu)
+ item.submenu = @sortTemplate(item.submenu)
+ return template
+
# Returns an object compatible with `::add()` or `null`.
cloneItemForEvent: (item, event) ->
return null if item.devMode and not @devMode
diff --git a/src/cursor.js b/src/cursor.js
index 41e47bb75..f75f94709 100644
--- a/src/cursor.js
+++ b/src/cursor.js
@@ -326,7 +326,9 @@ class Cursor extends Model {
// Public: Moves the cursor to the bottom of the buffer.
moveToBottom () {
+ const column = this.goalColumn
this.setBufferPosition(this.editor.getEofBufferPosition())
+ this.goalColumn = column
}
// Public: Moves the cursor to the beginning of the line.
@@ -524,7 +526,7 @@ class Cursor extends Model {
: new Range(new Point(position.row, 0), position)
const ranges = this.editor.buffer.findAllInRangeSync(
- options.wordRegex || this.wordRegExp(),
+ options.wordRegex || this.wordRegExp(options),
scanRange
)
@@ -556,7 +558,7 @@ class Cursor extends Model {
: new Range(position, new Point(position.row, Infinity))
const ranges = this.editor.buffer.findAllInRangeSync(
- options.wordRegex || this.wordRegExp(),
+ options.wordRegex || this.wordRegExp(options),
scanRange
)
@@ -664,7 +666,7 @@ class Cursor extends Model {
// Returns a {RegExp}.
wordRegExp (options) {
const nonWordCharacters = _.escapeRegExp(this.getNonWordCharacters())
- let source = `^[\t\r ]*$|[^\\s${nonWordCharacters}]+`
+ let source = `^[\t ]*$|[^\\s${nonWordCharacters}]+`
if (!options || options.includeNonWordCharacters !== false) {
source += `|${`[${nonWordCharacters}]+`}`
}
@@ -711,6 +713,7 @@ class Cursor extends Model {
changePosition (options, fn) {
this.clearSelection({autoscroll: false})
fn()
+ this.goalColumn = null
const autoscroll = (options && options.autoscroll != null)
? options.autoscroll
: this.isLastCursor()
diff --git a/src/decoration.js b/src/decoration.js
index 731935506..69bbcaa19 100644
--- a/src/decoration.js
+++ b/src/decoration.js
@@ -1,4 +1,3 @@
-const _ = require('underscore-plus')
const {Emitter} = require('event-kit')
let idCounter = 0
@@ -49,7 +48,7 @@ class Decoration {
// 'line-number' is a 'gutter', but a 'gutter' is not a 'line-number'.
static isType (decorationProperties, type) {
// 'line-number' is a special case of 'gutter'.
- if (_.isArray(decorationProperties.type)) {
+ if (Array.isArray(decorationProperties.type)) {
if (decorationProperties.type.includes(type)) {
return true
}
@@ -158,7 +157,7 @@ class Decoration {
// ## Examples
//
// ```coffee
- // decoration.update({type: 'line-number', class: 'my-new-class'})
+ // decoration.setProperties({type: 'line-number', class: 'my-new-class'})
// ```
//
// * `newProperties` {Object} eg. `{type: 'line-number', class: 'my-new-class'}`
diff --git a/src/deserializer-manager.js b/src/deserializer-manager.js
index f5f2e6429..72ed9485d 100644
--- a/src/deserializer-manager.js
+++ b/src/deserializer-manager.js
@@ -1,6 +1,4 @@
-/** @babel */
-
-import {Disposable} from 'event-kit'
+const {Disposable} = require('event-kit')
// Extended: Manages the deserializers used for serialized state
//
@@ -21,7 +19,8 @@ import {Disposable} from 'event-kit'
// serialize: ->
// @state
// ```
-export default class DeserializerManager {
+module.exports =
+class DeserializerManager {
constructor (atomEnvironment) {
this.atomEnvironment = atomEnvironment
this.deserializers = {}
@@ -34,7 +33,7 @@ export default class DeserializerManager {
// common approach is to register a *constructor* as the deserializer for its
// instances by adding a `.deserialize()` class method. When your method is
// called, it will be passed serialized state as the first argument and the
- // {Atom} environment object as the second argument, which is useful if you
+ // {AtomEnvironment} object as the second argument, which is useful if you
// wish to avoid referencing the `atom` global.
add (...deserializers) {
for (let i = 0; i < deserializers.length; i++) {
diff --git a/src/dock.js b/src/dock.js
index 7f2856800..dc77365fb 100644
--- a/src/dock.js
+++ b/src/dock.js
@@ -1,11 +1,11 @@
-'use strict'
-
+const etch = require('etch')
const _ = require('underscore-plus')
const {CompositeDisposable, Emitter} = require('event-kit')
const PaneContainer = require('./pane-container')
const TextEditor = require('./text-editor')
const Grim = require('grim')
+const $ = etch.dom
const MINIMUM_SIZE = 100
const DEFAULT_INITIAL_SIZE = 300
const SHOULD_ANIMATE_CLASS = 'atom-dock-should-animate'
@@ -26,6 +26,8 @@ module.exports = class Dock {
this.handleMouseUp = this.handleMouseUp.bind(this)
this.handleDrag = _.throttle(this.handleDrag.bind(this), 30)
this.handleDragEnd = this.handleDragEnd.bind(this)
+ this.handleToggleButtonDragEnter = this.handleToggleButtonDragEnter.bind(this)
+ this.toggle = this.toggle.bind(this)
this.location = params.location
this.widthOrHeight = getWidthOrHeight(this.location)
@@ -72,11 +74,16 @@ module.exports = class Dock {
// This method is called explicitly by the object which adds the Dock to the document.
elementAttached () {
// Re-render when the dock is attached to make sure we remeasure sizes defined in CSS.
- this.render(this.state)
+ etch.updateSync(this)
}
getElement () {
- if (!this.element) this.render(this.state)
+ // Because this code is included in the snapshot, we have to make sure we don't touch the DOM
+ // during initialization. Therefore, we defer initialization of the component (which creates a
+ // DOM element) until somebody asks for the element.
+ if (this.element == null) {
+ etch.initialize(this)
+ }
return this.element
}
@@ -151,88 +158,94 @@ module.exports = class Dock {
}
this.state = nextState
- this.render(this.state)
- const {visible} = this.state
+ const {hovered, visible} = this.state
+
+ // Render immediately if the dock becomes visible or the size changes in case people are
+ // measuring after opening, for example.
+ if (this.element != null) {
+ if ((visible && !prevState.visible) || (this.state.size !== prevState.size)) etch.updateSync(this)
+ else etch.update(this)
+ }
+
+ if (hovered !== prevState.hovered) {
+ this.emitter.emit('did-change-hovered', hovered)
+ }
if (visible !== prevState.visible) {
this.emitter.emit('did-change-visible', visible)
}
}
- render (state) {
- if (this.element == null) {
- this.element = document.createElement('atom-dock')
- this.element.classList.add(this.location)
- this.innerElement = document.createElement('div')
- this.innerElement.classList.add('atom-dock-inner', this.location)
- this.maskElement = document.createElement('div')
- this.maskElement.classList.add('atom-dock-mask')
- this.wrapperElement = document.createElement('div')
- this.wrapperElement.classList.add('atom-dock-content-wrapper', this.location)
- this.resizeHandle = new DockResizeHandle({
- location: this.location,
- onResizeStart: this.handleResizeHandleDragStart,
- onResizeToFit: this.handleResizeToFit
- })
- this.toggleButton = new DockToggleButton({
- onDragEnter: this.handleToggleButtonDragEnter.bind(this),
- location: this.location,
- toggle: this.toggle.bind(this)
- })
- this.cursorOverlayElement = document.createElement('div')
- this.cursorOverlayElement.classList.add('atom-dock-cursor-overlay', this.location)
+ render () {
+ const innerElementClassList = ['atom-dock-inner', this.location]
+ if (this.state.visible) innerElementClassList.push(VISIBLE_CLASS)
- // Add the children to the DOM tree
- this.element.appendChild(this.innerElement)
- this.innerElement.appendChild(this.maskElement)
- this.maskElement.appendChild(this.wrapperElement)
- this.wrapperElement.appendChild(this.resizeHandle.getElement())
- this.wrapperElement.appendChild(this.paneContainer.getElement())
- this.wrapperElement.appendChild(this.cursorOverlayElement)
- // The toggle button must be rendered outside the mask because (1) it shouldn't be masked and
- // (2) if we made the mask larger to avoid masking it, the mask would block mouse events.
- this.innerElement.appendChild(this.toggleButton.getElement())
- }
+ const maskElementClassList = ['atom-dock-mask']
+ if (this.state.shouldAnimate) maskElementClassList.push(SHOULD_ANIMATE_CLASS)
- if (state.visible) {
- this.innerElement.classList.add(VISIBLE_CLASS)
- } else {
- this.innerElement.classList.remove(VISIBLE_CLASS)
- }
+ const cursorOverlayElementClassList = ['atom-dock-cursor-overlay', this.location]
+ if (this.state.resizing) cursorOverlayElementClassList.push(CURSOR_OVERLAY_VISIBLE_CLASS)
- if (state.shouldAnimate) {
- this.maskElement.classList.add(SHOULD_ANIMATE_CLASS)
- } else {
- this.maskElement.classList.remove(SHOULD_ANIMATE_CLASS)
- }
-
- if (state.resizing) {
- this.cursorOverlayElement.classList.add(CURSOR_OVERLAY_VISIBLE_CLASS)
- } else {
- this.cursorOverlayElement.classList.remove(CURSOR_OVERLAY_VISIBLE_CLASS)
- }
-
- const shouldBeVisible = state.visible || state.showDropTarget
+ const shouldBeVisible = this.state.visible || this.state.showDropTarget
const size = Math.max(MINIMUM_SIZE,
- state.size ||
- (state.draggingItem && getPreferredSize(state.draggingItem, this.location)) ||
+ this.state.size ||
+ (this.state.draggingItem && getPreferredSize(this.state.draggingItem, this.location)) ||
DEFAULT_INITIAL_SIZE
)
// We need to change the size of the mask...
- this.maskElement.style[this.widthOrHeight] = `${shouldBeVisible ? size : 0}px`
+ const maskStyle = {[this.widthOrHeight]: `${shouldBeVisible ? size : 0}px`}
// ...but the content needs to maintain a constant size.
- this.wrapperElement.style[this.widthOrHeight] = `${size}px`
+ const wrapperStyle = {[this.widthOrHeight]: `${size}px`}
- this.resizeHandle.update({dockIsVisible: this.state.visible})
- this.toggleButton.update({
- dockIsVisible: shouldBeVisible,
- visible:
- // Don't show the toggle button if the dock is closed and empty...
- (state.hovered && (this.state.visible || this.getPaneItems().length > 0)) ||
- // ...or if the item can't be dropped in that dock.
- (!shouldBeVisible && state.draggingItem && isItemAllowed(state.draggingItem, this.location))
- })
+ return $(
+ 'atom-dock',
+ {className: this.location},
+ $.div(
+ {ref: 'innerElement', className: innerElementClassList.join(' ')},
+ $.div(
+ {
+ className: maskElementClassList.join(' '),
+ style: maskStyle
+ },
+ $.div(
+ {
+ ref: 'wrapperElement',
+ className: `atom-dock-content-wrapper ${this.location}`,
+ style: wrapperStyle
+ },
+ $(DockResizeHandle, {
+ location: this.location,
+ onResizeStart: this.handleResizeHandleDragStart,
+ onResizeToFit: this.handleResizeToFit,
+ dockIsVisible: this.state.visible
+ }),
+ $(ElementComponent, {element: this.paneContainer.getElement()}),
+ $.div({className: cursorOverlayElementClassList.join(' ')})
+ )
+ ),
+ $(DockToggleButton, {
+ ref: 'toggleButton',
+ onDragEnter: this.state.draggingItem ? this.handleToggleButtonDragEnter : null,
+ location: this.location,
+ toggle: this.toggle,
+ dockIsVisible: shouldBeVisible,
+ visible:
+ // Don't show the toggle button if the dock is closed and empty...
+ (this.state.hovered &&
+ (this.state.visible || this.getPaneItems().length > 0)) ||
+ // ...or if the item can't be dropped in that dock.
+ (!shouldBeVisible &&
+ this.state.draggingItem &&
+ isItemAllowed(this.state.draggingItem, this.location))
+ })
+ )
+ )
+ }
+
+ update (props) {
+ // Since we're interopping with non-etch stuff, this method's actually never called.
+ return etch.update(this)
}
handleDidAddPaneItem () {
@@ -296,7 +309,7 @@ module.exports = class Dock {
}
handleDrag (event) {
- if (!this.pointWithinHoverArea({x: event.pageX, y: event.pageY}, false)) {
+ if (!this.pointWithinHoverArea({x: event.pageX, y: event.pageY}, true)) {
this.draggedOut()
}
}
@@ -313,9 +326,13 @@ module.exports = class Dock {
// Determine whether the cursor is within the dock hover area. This isn't as simple as just using
// mouseenter/leave because we want to be a little more forgiving. For example, if the cursor is
- // over the footer, we want to show the bottom dock's toggle button.
- pointWithinHoverArea (point, includeButtonWidth = this.state.hovered) {
- const dockBounds = this.innerElement.getBoundingClientRect()
+ // over the footer, we want to show the bottom dock's toggle button. Also note that our criteria
+ // for detecting entry are different than detecting exit but, in order for us to avoid jitter, the
+ // area considered when detecting exit MUST fully encompass the area considered when detecting
+ // entry.
+ pointWithinHoverArea (point, detectingExit) {
+ const dockBounds = this.refs.innerElement.getBoundingClientRect()
+
// Copy the bounds object since we can't mutate it.
const bounds = {
top: dockBounds.top,
@@ -324,7 +341,20 @@ module.exports = class Dock {
left: dockBounds.left
}
- // Include all panels that are closer to the edge than the dock in our calculations.
+ // To provide a minimum target, expand the area toward the center a bit.
+ switch (this.location) {
+ case 'right':
+ bounds.left = Math.min(bounds.left, bounds.right - 2)
+ break
+ case 'bottom':
+ bounds.top = Math.min(bounds.top, bounds.bottom - 1)
+ break
+ case 'left':
+ bounds.right = Math.max(bounds.right, bounds.left + 2)
+ break
+ }
+
+ // Further expand the area to include all panels that are closer to the edge than the dock.
switch (this.location) {
case 'right':
bounds.right = Number.POSITIVE_INFINITY
@@ -337,23 +367,41 @@ module.exports = class Dock {
break
}
- // The area used when detecting "leave" events is actually larger than when detecting entrances.
- if (includeButtonWidth) {
+ // If we're in this area, we know we're within the hover area without having to take further
+ // measurements.
+ if (rectContainsPoint(bounds, point)) return true
+
+ // If we're within the toggle button, we're definitely in the hover area. Unfortunately, we
+ // can't do this measurement conditionally (e.g. only if the toggle button is visible) because
+ // our knowledge of the toggle's button is incomplete due to CSS animations. (We may think the
+ // toggle button isn't visible when in actuality it is, but is animating to its hidden state.)
+ //
+ // Since `point` is always the current mouse position, one possible optimization would be to
+ // remove it as an argument and determine whether we're inside the toggle button using
+ // mouseenter/leave events on it. This class would still need to keep track of the mouse
+ // position (via a mousemove listener) for the other measurements, though.
+ const toggleButtonBounds = this.refs.toggleButton.getBounds()
+ if (rectContainsPoint(toggleButtonBounds, point)) return true
+
+ // The area used when detecting exit is actually larger than when detecting entrances. Expand
+ // our bounds and recheck them.
+ if (detectingExit) {
const hoverMargin = 20
- const {width, height} = this.toggleButton.getBounds()
switch (this.location) {
case 'right':
- bounds.left -= width + hoverMargin
+ bounds.left = Math.min(bounds.left, toggleButtonBounds.left) - hoverMargin
break
case 'bottom':
- bounds.top -= height + hoverMargin
+ bounds.top = Math.min(bounds.top, toggleButtonBounds.top) - hoverMargin
break
case 'left':
- bounds.right += width + hoverMargin
+ bounds.right = Math.max(bounds.right, toggleButtonBounds.right) + hoverMargin
break
}
+ if (rectContainsPoint(bounds, point)) return true
}
- return rectContainsPoint(bounds, point)
+
+ return false
}
getInitialSize () {
@@ -574,6 +622,16 @@ module.exports = class Dock {
return this.paneContainer.onDidDestroyPaneItem(callback)
}
+ // Extended: Invoke the given callback when the hovered state of the dock changes.
+ //
+ // * `callback` {Function} to be called when the hovered state changes.
+ // * `hovered` {Boolean} Is the dock now hovered?
+ //
+ // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
+ onDidChangeHovered (callback) {
+ return this.emitter.on('did-change-hovered', callback)
+ }
+
/*
Section: Pane Items
*/
@@ -659,13 +717,18 @@ module.exports = class Dock {
class DockResizeHandle {
constructor (props) {
- this.handleMouseDown = this.handleMouseDown.bind(this)
-
- this.element = document.createElement('div')
- this.element.classList.add('atom-dock-resize-handle', props.location)
- this.element.addEventListener('mousedown', this.handleMouseDown)
this.props = props
- this.update(props)
+ etch.initialize(this)
+ }
+
+ render () {
+ const classList = ['atom-dock-resize-handle', this.props.location]
+ if (this.props.dockIsVisible) classList.push(RESIZE_HANDLE_RESIZABLE_CLASS)
+
+ return $.div({
+ className: classList.join(' '),
+ on: {mousedown: this.handleMouseDown}
+ })
}
getElement () {
@@ -681,12 +744,7 @@ class DockResizeHandle {
update (newProps) {
this.props = Object.assign({}, this.props, newProps)
-
- if (this.props.dockIsVisible) {
- this.element.classList.add(RESIZE_HANDLE_RESIZABLE_CLASS)
- } else {
- this.element.classList.remove(RESIZE_HANDLE_RESIZABLE_CLASS)
- }
+ return etch.update(this)
}
handleMouseDown (event) {
@@ -700,22 +758,34 @@ class DockResizeHandle {
class DockToggleButton {
constructor (props) {
- this.handleClick = this.handleClick.bind(this)
- this.handleDragEnter = this.handleDragEnter.bind(this)
-
- this.element = document.createElement('div')
- this.element.classList.add('atom-dock-toggle-button', props.location)
- this.element.classList.add(props.location)
- this.innerElement = document.createElement('div')
- this.innerElement.classList.add('atom-dock-toggle-button-inner', props.location)
- this.innerElement.addEventListener('click', this.handleClick)
- this.innerElement.addEventListener('dragenter', this.handleDragEnter)
- this.iconElement = document.createElement('span')
- this.innerElement.appendChild(this.iconElement)
- this.element.appendChild(this.innerElement)
-
this.props = props
- this.update(props)
+ etch.initialize(this)
+ }
+
+ render () {
+ const classList = ['atom-dock-toggle-button', this.props.location]
+ if (this.props.visible) classList.push(TOGGLE_BUTTON_VISIBLE_CLASS)
+
+ return $.div(
+ {className: classList.join(' ')},
+ $.div(
+ {
+ ref: 'innerElement',
+ className: `atom-dock-toggle-button-inner ${this.props.location}`,
+ on: {
+ click: this.handleClick,
+ dragenter: this.props.onDragEnter
+ }
+ },
+ $.span({
+ ref: 'iconElement',
+ className: `icon ${getIconName(
+ this.props.location,
+ this.props.dockIsVisible
+ )}`
+ })
+ )
+ )
}
getElement () {
@@ -723,30 +793,28 @@ class DockToggleButton {
}
getBounds () {
- if (this.bounds == null) {
- this.bounds = this.element.getBoundingClientRect()
- }
- return this.bounds
+ return this.refs.innerElement.getBoundingClientRect()
}
update (newProps) {
this.props = Object.assign({}, this.props, newProps)
-
- if (this.props.visible) {
- this.element.classList.add(TOGGLE_BUTTON_VISIBLE_CLASS)
- } else {
- this.element.classList.remove(TOGGLE_BUTTON_VISIBLE_CLASS)
- }
-
- this.iconElement.className = 'icon ' + getIconName(this.props.location, this.props.dockIsVisible)
+ return etch.update(this)
}
handleClick () {
this.props.toggle()
}
+}
- handleDragEnter () {
- this.props.onDragEnter()
+// An etch component that doesn't use etch, this component provides a gateway from JSX back into
+// the mutable DOM world.
+class ElementComponent {
+ constructor (props) {
+ this.element = props.element
+ }
+
+ update (props) {
+ this.element = props.element
}
}
diff --git a/src/git-repository.js b/src/git-repository.js
index 55d70c12c..80f76e40a 100644
--- a/src/git-repository.js
+++ b/src/git-repository.js
@@ -163,7 +163,7 @@ class GitRepository {
// Public: Invoke the given callback when a multiple files' statuses have
// changed. For example, on window focus, the status of all the paths in the
// repo is checked. If any of them have changed, this will be fired. Call
- // {::getPathStatus(path)} to get the status for your path of choice.
+ // {::getPathStatus} to get the status for your path of choice.
//
// * `callback` {Function}
//
diff --git a/src/grammar-registry.js b/src/grammar-registry.js
index b316bdbb0..0c79d5b1e 100644
--- a/src/grammar-registry.js
+++ b/src/grammar-registry.js
@@ -10,7 +10,6 @@ const Token = require('./token')
const fs = require('fs-plus')
const {Point, Range} = require('text-buffer')
-const GRAMMAR_TYPE_BONUS = 1000
const PATH_SPLIT_REGEX = new RegExp('[/.]')
// Extended: This class holds the grammars used for tokenizing.
@@ -38,6 +37,14 @@ class GrammarRegistry {
const grammarAddedOrUpdated = this.grammarAddedOrUpdated.bind(this)
this.textmateRegistry.onDidAddGrammar(grammarAddedOrUpdated)
this.textmateRegistry.onDidUpdateGrammar(grammarAddedOrUpdated)
+
+ this.subscriptions.add(this.config.onDidChange('core.useTreeSitterParsers', () => {
+ this.grammarScoresByBuffer.forEach((score, buffer) => {
+ if (!this.languageOverridesByBufferId.has(buffer.id)) {
+ this.autoAssignLanguageMode(buffer)
+ }
+ })
+ }))
}
serialize () {
@@ -115,7 +122,6 @@ class GrammarRegistry {
// found.
assignLanguageMode (buffer, languageId) {
if (buffer.getBuffer) buffer = buffer.getBuffer()
- languageId = this.normalizeLanguageId(languageId)
let grammar = null
if (languageId != null) {
@@ -128,13 +134,21 @@ class GrammarRegistry {
}
this.grammarScoresByBuffer.set(buffer, null)
- if (grammar.scopeName !== buffer.getLanguageMode().getLanguageId()) {
+ if (grammar !== buffer.getLanguageMode().grammar) {
buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer))
}
return true
}
+ // Extended: Get the `languageId` that has been explicitly assigned to
+ // to the given buffer, if any.
+ //
+ // Returns a {String} id of the language
+ getAssignedLanguageId (buffer) {
+ return this.languageOverridesByBufferId.get(buffer.id)
+ }
+
// Extended: Remove any language mode override that has been set for the
// given {TextBuffer}. This will assign to the buffer the best language
// mode available.
@@ -147,14 +161,14 @@ class GrammarRegistry {
)
this.languageOverridesByBufferId.delete(buffer.id)
this.grammarScoresByBuffer.set(buffer, result.score)
- if (result.grammar.scopeName !== buffer.getLanguageMode().getLanguageId()) {
+ if (result.grammar !== buffer.getLanguageMode().grammar) {
buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(result.grammar, buffer))
}
}
languageModeForGrammarAndBuffer (grammar, buffer) {
if (grammar instanceof TreeSitterGrammar) {
- return new TreeSitterLanguageMode({grammar, buffer, config: this.config})
+ return new TreeSitterLanguageMode({grammar, buffer, config: this.config, grammars: this})
} else {
return new TextMateLanguageMode({grammar, buffer, config: this.config})
}
@@ -193,16 +207,34 @@ class GrammarRegistry {
contents = fs.readFileSync(filePath, 'utf8')
}
+ // Initially identify matching grammars based on the filename and the first
+ // line of the file.
let score = this.getGrammarPathScore(grammar, filePath)
- if (score > 0 && !grammar.bundledPackage) {
- score += 0.125
- }
- if (this.grammarMatchesContents(grammar, contents)) {
- score += 0.25
- }
+ if (this.grammarMatchesPrefix(grammar, contents)) score += 0.5
- if (score > 0 && this.isGrammarPreferredType(grammar)) {
- score += GRAMMAR_TYPE_BONUS
+ // If multiple grammars match by one of the above criteria, break ties.
+ if (score > 0) {
+ // Prefer either TextMate or Tree-sitter grammars based on the user's settings.
+ if (grammar instanceof TreeSitterGrammar) {
+ if (this.config.get('core.useTreeSitterParsers')) {
+ score += 0.1
+ } else {
+ return -Infinity
+ }
+ }
+
+ // Prefer grammars with matching content regexes. Prefer a grammar with no content regex
+ // over one with a non-matching content regex.
+ if (grammar.contentRegex) {
+ if (grammar.contentRegex.test(contents)) {
+ score += 0.05
+ } else {
+ score -= 0.05
+ }
+ }
+
+ // Prefer grammars that the user has manually installed over bundled grammars.
+ if (!grammar.bundledPackage) score += 0.01
}
return score
@@ -240,12 +272,8 @@ class GrammarRegistry {
return pathScore
}
- grammarMatchesContents (grammar, contents) {
- if (contents == null) return false
-
- if (grammar.contentRegExp) { // TreeSitter grammars
- return grammar.contentRegExp.test(contents)
- } else if (grammar.firstLineRegex) { // FirstMate grammars
+ grammarMatchesPrefix (grammar, contents) {
+ if (contents && grammar.firstLineRegex) {
let escaped = false
let numberOfNewlinesInRegex = 0
for (let character of grammar.firstLineRegex.source) {
@@ -262,8 +290,12 @@ class GrammarRegistry {
}
}
- const lines = contents.split('\n')
- return grammar.firstLineRegex.testSync(lines.slice(0, numberOfNewlinesInRegex + 1).join('\n'))
+ const prefix = contents.split('\n').slice(0, numberOfNewlinesInRegex + 1).join('\n')
+ if (grammar.firstLineRegex.testSync) {
+ return grammar.firstLineRegex.testSync(prefix)
+ } else {
+ return grammar.firstLineRegex.test(prefix)
+ }
} else {
return false
}
@@ -271,18 +303,25 @@ class GrammarRegistry {
forEachGrammar (callback) {
this.textmateRegistry.grammars.forEach(callback)
- for (let grammarId in this.treeSitterGrammarsById) {
- callback(this.treeSitterGrammarsById[grammarId])
+ for (const grammarId in this.treeSitterGrammarsById) {
+ const grammar = this.treeSitterGrammarsById[grammarId]
+ if (grammar.scopeName) callback(grammar)
}
}
grammarForId (languageId) {
- languageId = this.normalizeLanguageId(languageId)
-
- return (
- this.textmateRegistry.grammarForScopeName(languageId) ||
- this.treeSitterGrammarsById[languageId]
- )
+ if (!languageId) return null
+ if (this.config.get('core.useTreeSitterParsers')) {
+ return (
+ this.treeSitterGrammarsById[languageId] ||
+ this.textmateRegistry.grammarForScopeName(languageId)
+ )
+ } else {
+ return (
+ this.textmateRegistry.grammarForScopeName(languageId) ||
+ this.treeSitterGrammarsById[languageId]
+ )
+ }
}
// Deprecated: Get the grammar override for the given file path.
@@ -293,7 +332,7 @@ class GrammarRegistry {
grammarOverrideForPath (filePath) {
Grim.deprecate('Use buffer.getLanguageMode().getLanguageId() instead')
const buffer = atom.project.findBufferForPath(filePath)
- if (buffer) return this.languageOverridesByBufferId.get(buffer.id)
+ if (buffer) return this.getAssignedLanguageId(buffer)
}
// Deprecated: Set the grammar override for the given file path.
@@ -327,26 +366,23 @@ class GrammarRegistry {
this.grammarScoresByBuffer.forEach((score, buffer) => {
const languageMode = buffer.getLanguageMode()
- if (grammar.injectionSelector) {
- if (languageMode.hasTokenForSelector(grammar.injectionSelector)) {
- languageMode.retokenizeLines()
- }
- return
- }
-
const languageOverride = this.languageOverridesByBufferId.get(buffer.id)
- if ((grammar.id === buffer.getLanguageMode().getLanguageId() ||
- grammar.id === languageOverride)) {
+ if (grammar === buffer.getLanguageMode().grammar ||
+ grammar === this.grammarForId(languageOverride)) {
buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer))
+ return
} else if (!languageOverride) {
const score = this.getGrammarScore(grammar, buffer.getPath(), getGrammarSelectionContent(buffer))
const currentScore = this.grammarScoresByBuffer.get(buffer)
if (currentScore == null || score > currentScore) {
buffer.setLanguageMode(this.languageModeForGrammarAndBuffer(grammar, buffer))
this.grammarScoresByBuffer.set(buffer, score)
+ return
}
}
+
+ languageMode.updateForInjection(grammar)
})
}
@@ -371,6 +407,32 @@ class GrammarRegistry {
return this.textmateRegistry.onDidUpdateGrammar(callback)
}
+ // Experimental: Specify a type of syntax node that may embed other languages.
+ //
+ // * `grammarId` The {String} id of the parent language
+ // * `injectionPoint` An {Object} with the following keys:
+ // * `type` The {String} type of syntax node that may embed other languages
+ // * `language` A {Function} that is called with syntax nodes of the specified `type` and
+ // returns a {String} that will be tested against other grammars' `injectionRegex` in
+ // order to determine what language should be embedded.
+ // * `content` A {Function} that is called with syntax nodes of the specified `type` and
+ // returns another syntax node or array of syntax nodes that contain the embedded source code.
+ addInjectionPoint (grammarId, injectionPoint) {
+ const grammar = this.treeSitterGrammarsById[grammarId]
+ if (grammar) {
+ grammar.injectionPoints.push(injectionPoint)
+ } else {
+ this.treeSitterGrammarsById[grammarId] = {
+ injectionPoints: [injectionPoint]
+ }
+ }
+ return new Disposable(() => {
+ const grammar = this.treeSitterGrammarsById[grammarId]
+ const index = grammar.injectionPoints.indexOf(injectionPoint)
+ if (index !== -1) grammar.injectionPoints.splice(index, 1)
+ })
+ }
+
get nullGrammar () {
return this.textmateRegistry.nullGrammar
}
@@ -389,12 +451,9 @@ class GrammarRegistry {
addGrammar (grammar) {
if (grammar instanceof TreeSitterGrammar) {
- this.treeSitterGrammarsById[grammar.id] = grammar
- if (grammar.legacyScopeName) {
- this.config.addLegacyScopeAlias(grammar.id, grammar.legacyScopeName)
- this.textMateScopeNamesByTreeSitterLanguageId.set(grammar.id, grammar.legacyScopeName)
- this.treeSitterLanguageIdsByTextMateScopeName.set(grammar.legacyScopeName, grammar.id)
- }
+ const existingParams = this.treeSitterGrammarsById[grammar.scopeName] || {}
+ if (grammar.scopeName) this.treeSitterGrammarsById[grammar.scopeName] = grammar
+ if (existingParams.injectionPoints) grammar.injectionPoints.push(...existingParams.injectionPoints)
this.grammarAddedOrUpdated(grammar)
return new Disposable(() => this.removeGrammar(grammar))
} else {
@@ -404,12 +463,7 @@ class GrammarRegistry {
removeGrammar (grammar) {
if (grammar instanceof TreeSitterGrammar) {
- delete this.treeSitterGrammarsById[grammar.id]
- if (grammar.legacyScopeName) {
- this.config.removeLegacyScopeAlias(grammar.id)
- this.textMateScopeNamesByTreeSitterLanguageId.delete(grammar.id)
- this.treeSitterLanguageIdsByTextMateScopeName.delete(grammar.legacyScopeName)
- }
+ delete this.treeSitterGrammarsById[grammar.scopeName]
} else {
return this.textmateRegistry.removeGrammar(grammar)
}
@@ -429,7 +483,7 @@ class GrammarRegistry {
this.readGrammar(grammarPath, (error, grammar) => {
if (error) return callback(error)
this.addGrammar(grammar)
- callback(grammar)
+ callback(null, grammar)
})
}
@@ -495,10 +549,23 @@ class GrammarRegistry {
return this.textmateRegistry.scopeForId(id)
}
- isGrammarPreferredType (grammar) {
- return this.config.get('core.useTreeSitterParsers')
- ? grammar instanceof TreeSitterGrammar
- : grammar instanceof FirstMate.Grammar
+ treeSitterGrammarForLanguageString (languageString) {
+ let longestMatchLength = 0
+ let grammarWithLongestMatch = null
+ for (const id in this.treeSitterGrammarsById) {
+ const grammar = this.treeSitterGrammarsById[id]
+ if (grammar.injectionRegex) {
+ const match = languageString.match(grammar.injectionRegex)
+ if (match) {
+ const {length} = match[0]
+ if (length > longestMatchLength) {
+ grammarWithLongestMatch = grammar
+ longestMatchLength = length
+ }
+ }
+ }
+ }
+ return grammarWithLongestMatch
}
normalizeLanguageId (languageId) {
diff --git a/src/gutter-container.js b/src/gutter-container.js
index 3faece073..cd0c796b2 100644
--- a/src/gutter-container.js
+++ b/src/gutter-container.js
@@ -97,7 +97,7 @@ module.exports = class GutterContainer {
// The public interface is Gutter::decorateMarker or TextEditor::decorateMarker.
addGutterDecoration (gutter, marker, options) {
- if (gutter.name === 'line-number') {
+ if (gutter.type === 'line-number') {
options.type = 'line-number'
} else {
options.type = 'gutter'
diff --git a/src/gutter.js b/src/gutter.js
index 3bf7a72ea..bd5955b78 100644
--- a/src/gutter.js
+++ b/src/gutter.js
@@ -11,6 +11,12 @@ module.exports = class Gutter {
this.name = options && options.name
this.priority = (options && options.priority != null) ? options.priority : DefaultPriority
this.visible = (options && options.visible != null) ? options.visible : true
+ this.type = (options && options.type != null) ? options.type : 'decorated'
+ this.labelFn = options && options.labelFn
+ this.className = options && options.class
+
+ this.onMouseDown = options && options.onMouseDown
+ this.onMouseMove = options && options.onMouseMove
this.emitter = new Emitter()
}
diff --git a/src/history-manager.js b/src/history-manager.js
index 306c11812..e4651d9d9 100644
--- a/src/history-manager.js
+++ b/src/history-manager.js
@@ -1,13 +1,11 @@
-/** @babel */
-
-import {Emitter, CompositeDisposable} from 'event-kit'
+const {Emitter, CompositeDisposable} = require('event-kit')
// Extended: History manager for remembering which projects have been opened.
//
// An instance of this class is always available as the `atom.history` global.
//
// The project history is used to enable the 'Reopen Project' menu.
-export class HistoryManager {
+class HistoryManager {
constructor ({project, commands, stateStore}) {
this.stateStore = stateStore
this.emitter = new Emitter()
@@ -116,7 +114,7 @@ function arrayEquivalent (a, b) {
return true
}
-export class HistoryProject {
+class HistoryProject {
constructor (paths, lastOpened) {
this.paths = paths
this.lastOpened = lastOpened || new Date()
@@ -128,3 +126,5 @@ export class HistoryProject {
set lastOpened (lastOpened) { this._lastOpened = lastOpened }
get lastOpened () { return this._lastOpened }
}
+
+module.exports = {HistoryManager, HistoryProject}
diff --git a/src/initialize-application-window.coffee b/src/initialize-application-window.coffee
index f8f670cf5..913c83977 100644
--- a/src/initialize-application-window.coffee
+++ b/src/initialize-application-window.coffee
@@ -36,6 +36,9 @@ if global.isGeneratingSnapshot
require('image-view')
require('incompatible-packages')
require('keybinding-resolver')
+ require('language-html')
+ require('language-javascript')
+ require('language-ruby')
require('line-ending-selector')
require('link')
require('markdown-preview')
diff --git a/src/initialize-benchmark-window.js b/src/initialize-benchmark-window.js
index 7ba99c468..131785454 100644
--- a/src/initialize-benchmark-window.js
+++ b/src/initialize-benchmark-window.js
@@ -1,11 +1,9 @@
-/** @babel */
+const {remote} = require('electron')
+const path = require('path')
+const ipcHelpers = require('./ipc-helpers')
+const util = require('util')
-import {remote} from 'electron'
-import path from 'path'
-import ipcHelpers from './ipc-helpers'
-import util from 'util'
-
-export default async function () {
+module.exports = async function () {
const getWindowLoadSettings = require('./get-window-load-settings')
const {test, headless, resourcePath, benchmarkPaths} = getWindowLoadSettings()
try {
diff --git a/src/initialize-test-window.coffee b/src/initialize-test-window.coffee
index c6aaada0e..4cbd02bfd 100644
--- a/src/initialize-test-window.coffee
+++ b/src/initialize-test-window.coffee
@@ -24,9 +24,13 @@ module.exports = ({blobStore}) ->
ApplicationDelegate = require '../src/application-delegate'
Clipboard = require '../src/clipboard'
TextEditor = require '../src/text-editor'
+ {updateProcessEnv} = require('./update-process-env')
require './electron-shims'
- {testRunnerPath, legacyTestRunnerPath, headless, logFile, testPaths} = getWindowLoadSettings()
+ ipcRenderer.on 'environment', (event, env) ->
+ updateProcessEnv(env)
+
+ {testRunnerPath, legacyTestRunnerPath, headless, logFile, testPaths, env} = getWindowLoadSettings()
unless headless
# Show window synchronously so a focusout doesn't fire on input elements
@@ -59,6 +63,8 @@ module.exports = ({blobStore}) ->
require('module').globalPaths.push(exportsPath)
process.env.NODE_PATH = exportsPath # Set NODE_PATH env variable since tasks may need it.
+ updateProcessEnv(env)
+
# Set up optional transpilation for packages under test if any
FindParentDir = require 'find-parent-dir'
if packageRoot = FindParentDir.sync(testPaths[0], 'package.json')
diff --git a/src/ipc-helpers.js b/src/ipc-helpers.js
index 4be9f9613..b68877f99 100644
--- a/src/ipc-helpers.js
+++ b/src/ipc-helpers.js
@@ -1,15 +1,13 @@
-'use strict'
-
const Disposable = require('event-kit').Disposable
let ipcRenderer = null
let ipcMain = null
let BrowserWindow = null
+let nextResponseChannelId = 0
+
exports.on = function (emitter, eventName, callback) {
emitter.on(eventName, callback)
- return new Disposable(function () {
- emitter.removeListener(eventName, callback)
- })
+ return new Disposable(() => emitter.removeListener(eventName, callback))
}
exports.call = function (channel, ...args) {
@@ -18,34 +16,28 @@ exports.call = function (channel, ...args) {
ipcRenderer.setMaxListeners(20)
}
- var responseChannel = getResponseChannel(channel)
+ const responseChannel = `ipc-helpers-response-${nextResponseChannelId++}`
- return new Promise(function (resolve) {
- ipcRenderer.on(responseChannel, function (event, result) {
+ return new Promise(resolve => {
+ ipcRenderer.on(responseChannel, (event, result) => {
ipcRenderer.removeAllListeners(responseChannel)
resolve(result)
})
- ipcRenderer.send(channel, ...args)
+ ipcRenderer.send(channel, responseChannel, ...args)
})
}
exports.respondTo = function (channel, callback) {
if (!ipcMain) {
- var electron = require('electron')
+ const electron = require('electron')
ipcMain = electron.ipcMain
BrowserWindow = electron.BrowserWindow
}
- var responseChannel = getResponseChannel(channel)
-
- return exports.on(ipcMain, channel, function (event, ...args) {
- var browserWindow = BrowserWindow.fromWebContents(event.sender)
- var result = callback(browserWindow, ...args)
+ return exports.on(ipcMain, channel, async (event, responseChannel, ...args) => {
+ const browserWindow = BrowserWindow.fromWebContents(event.sender)
+ const result = await callback(browserWindow, ...args)
event.sender.send(responseChannel, result)
})
}
-
-function getResponseChannel (channel) {
- return 'ipc-helpers-' + channel + '-response'
-}
diff --git a/src/item-registry.coffee b/src/item-registry.coffee
deleted file mode 100644
index 43af4cd11..000000000
--- a/src/item-registry.coffee
+++ /dev/null
@@ -1,15 +0,0 @@
-module.exports =
-class ItemRegistry
- constructor: ->
- @items = new WeakSet
-
- addItem: (item) ->
- if @hasItem(item)
- throw new Error("The workspace can only contain one instance of item #{item}")
- @items.add(item)
-
- removeItem: (item) ->
- @items.delete(item)
-
- hasItem: (item) ->
- @items.has(item)
diff --git a/src/item-registry.js b/src/item-registry.js
new file mode 100644
index 000000000..80c83c701
--- /dev/null
+++ b/src/item-registry.js
@@ -0,0 +1,21 @@
+module.exports =
+class ItemRegistry {
+ constructor () {
+ this.items = new WeakSet()
+ }
+
+ addItem (item) {
+ if (this.hasItem(item)) {
+ throw new Error(`The workspace can only contain one instance of item ${item}`)
+ }
+ return this.items.add(item)
+ }
+
+ removeItem (item) {
+ return this.items.delete(item)
+ }
+
+ hasItem (item) {
+ return this.items.has(item)
+ }
+}
diff --git a/src/main-process/application-menu.js b/src/main-process/application-menu.js
index 26dcd1941..2a46f06f4 100644
--- a/src/main-process/application-menu.js
+++ b/src/main-process/application-menu.js
@@ -201,7 +201,7 @@ class ApplicationMenu {
if (item.command) {
item.accelerator = this.acceleratorForCommand(item.command, keystrokesByCommand)
item.click = () => global.atomApplication.sendCommand(item.command, item.commandDetail)
- if (!/^application:/.test(item.command, item.commandDetail)) {
+ if (!/^application:/.test(item.command)) {
item.metadata.windowSpecific = true
}
}
diff --git a/src/main-process/atom-application.js b/src/main-process/atom-application.js
index 3696a3b40..8a6d03733 100644
--- a/src/main-process/atom-application.js
+++ b/src/main-process/atom-application.js
@@ -4,6 +4,7 @@ const AtomProtocolHandler = require('./atom-protocol-handler')
const AutoUpdateManager = require('./auto-update-manager')
const StorageFolder = require('../storage-folder')
const Config = require('../config')
+const ConfigFile = require('../config-file')
const FileRecoveryService = require('./file-recovery-service')
const ipcHelpers = require('../ipc-helpers')
const {BrowserWindow, Menu, app, clipboard, dialog, ipcMain, shell, screen} = require('electron')
@@ -32,7 +33,7 @@ class AtomApplication extends EventEmitter {
// Public: The entry point into the Atom application.
static open (options) {
if (!options.socketPath) {
- const username = process.platform === 'win32' ? process.env.USERNAME : process.env.USER
+ const {username} = os.userInfo()
// Lowercasing the ATOM_HOME to make sure that we don't get multiple sockets
// on case-insensitive filesystems due to arbitrary case differences in paths.
@@ -43,7 +44,7 @@ class AtomApplication extends EventEmitter {
.update('|')
.update(process.arch)
.update('|')
- .update(username)
+ .update(username || '')
.update('|')
.update(atomHomeUnique)
@@ -92,7 +93,6 @@ class AtomApplication extends EventEmitter {
this.quitting = false
this.getAllWindows = this.getAllWindows.bind(this)
this.getLastFocusedWindow = this.getLastFocusedWindow.bind(this)
-
this.resourcePath = options.resourcePath
this.devResourcePath = options.devResourcePath
this.version = options.version
@@ -107,20 +107,21 @@ class AtomApplication extends EventEmitter {
this.waitSessionsByWindow = new Map()
this.windowStack = new WindowStack()
- this.config = new Config({enablePersistence: true})
- this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)})
- ConfigSchema.projectHome = {
- type: 'string',
- default: path.join(fs.getHomeDirectory(), 'github'),
- description:
- 'The directory where projects are assumed to be located. Packages created using the Package Generator will be stored here by default.'
- }
- this.config.initialize({
- configDirPath: process.env.ATOM_HOME,
- resourcePath: this.resourcePath,
- projectHomeSchema: ConfigSchema.projectHome
+ this.initializeAtomHome(process.env.ATOM_HOME)
+
+ const configFilePath = fs.existsSync(path.join(process.env.ATOM_HOME, 'config.json'))
+ ? path.join(process.env.ATOM_HOME, 'config.json')
+ : path.join(process.env.ATOM_HOME, 'config.cson')
+
+ this.configFile = ConfigFile.at(configFilePath)
+ this.config = new Config({
+ saveCallback: settings => {
+ if (!this.quitting) {
+ return this.configFile.update(settings)
+ }
+ }
})
- this.config.load()
+ this.config.setSchema(null, {type: 'object', properties: _.clone(ConfigSchema)})
this.fileRecoveryService = new FileRecoveryService(path.join(process.env.ATOM_HOME, 'recovery'))
this.storageFolder = new StorageFolder(process.env.ATOM_HOME)
@@ -138,7 +139,7 @@ class AtomApplication extends EventEmitter {
// for testing purposes without booting up the world. As you add tests, feel free to move instantiation
// of these various sub-objects into the constructor, but you'll need to remove the side-effects they
// perform during their construction, adding an initialize method that you call here.
- initialize (options) {
+ async initialize (options) {
global.atomApplication = this
// DEPRECATED: This can be removed at some point (added in 1.13)
@@ -148,16 +149,15 @@ class AtomApplication extends EventEmitter {
this.config.set('core.titleBar', 'custom')
}
- this.config.onDidChange('core.titleBar', this.promptForRestart.bind(this))
-
- process.nextTick(() => this.autoUpdateManager.initialize())
this.applicationMenu = new ApplicationMenu(this.version, this.autoUpdateManager)
this.atomProtocolHandler = new AtomProtocolHandler(this.resourcePath, this.safeMode)
this.listenForArgumentsFromNewProcess()
this.setupDockMenu()
- return this.launch(options)
+ const result = await this.launch(options)
+ this.autoUpdateManager.initialize()
+ return result
}
async destroy () {
@@ -169,18 +169,39 @@ class AtomApplication extends EventEmitter {
this.disposable.dispose()
}
- launch (options) {
+ async launch (options) {
+ if (!this.configFilePromise) {
+ this.configFilePromise = this.configFile.watch()
+ this.disposable.add(await this.configFilePromise)
+ this.config.onDidChange('core.titleBar', () => this.promptForRestart())
+ this.config.onDidChange('core.colorProfile', () => this.promptForRestart())
+ }
+
+ const optionsForWindowsToOpen = []
+
+ let shouldReopenPreviousWindows = false
+
if (options.test || options.benchmark || options.benchmarkTest) {
- return this.openWithOptions(options)
+ optionsForWindowsToOpen.push(options)
} else if ((options.pathsToOpen && options.pathsToOpen.length > 0) ||
(options.urlsToOpen && options.urlsToOpen.length > 0)) {
- if (this.config.get('core.restorePreviousWindowsOnStart') === 'always') {
- this.loadState(_.deepClone(options))
- }
- return this.openWithOptions(options)
+ optionsForWindowsToOpen.push(options)
+ shouldReopenPreviousWindows = this.config.get('core.restorePreviousWindowsOnStart') === 'always'
} else {
- return this.loadState(options) || this.openPath(options)
+ shouldReopenPreviousWindows = this.config.get('core.restorePreviousWindowsOnStart') !== 'no'
}
+
+ if (shouldReopenPreviousWindows) {
+ for (const previousOptions of await this.loadPreviousWindowOptions()) {
+ optionsForWindowsToOpen.push(Object.assign({}, options, previousOptions))
+ }
+ }
+
+ if (optionsForWindowsToOpen.length === 0) {
+ optionsForWindowsToOpen.push(options)
+ }
+
+ return optionsForWindowsToOpen.map(options => this.openWithOptions(options))
}
openWithOptions (options) {
@@ -271,7 +292,7 @@ class AtomApplication extends EventEmitter {
return
}
}
- if (!window.isSpec) this.saveState(true)
+ if (!window.isSpec) this.saveCurrentWindowOptions(true)
}
// Public: Adds the {AtomWindow} to the global window list.
@@ -285,7 +306,7 @@ class AtomApplication extends EventEmitter {
if (!window.isSpec) {
const focusHandler = () => this.windowStack.touch(window)
- const blurHandler = () => this.saveState(false)
+ const blurHandler = () => this.saveCurrentWindowOptions(false)
window.browserWindow.on('focus', focusHandler)
window.browserWindow.on('blur', blurHandler)
window.browserWindow.once('closed', () => {
@@ -397,6 +418,18 @@ class AtomApplication extends EventEmitter {
this.openPathOnEvent('application:open-your-stylesheet', 'atom://.atom/stylesheet')
this.openPathOnEvent('application:open-license', path.join(process.resourcesPath, 'LICENSE.md'))
+ this.configFile.onDidChange(settings => {
+ for (let window of this.getAllWindows()) {
+ window.didChangeUserSettings(settings)
+ }
+ this.config.resetUserSettings(settings)
+ })
+
+ this.configFile.onDidError(message => {
+ const window = this.focusedWindow() || this.getLastFocusedWindow()
+ if (window) window.didFailToReadUserSettings(message)
+ })
+
this.disposable.add(ipcHelpers.on(app, 'before-quit', async event => {
let resolveBeforeQuitPromise
this.lastBeforeQuitPromise = new Promise(resolve => { resolveBeforeQuitPromise = resolve })
@@ -406,7 +439,11 @@ class AtomApplication extends EventEmitter {
event.preventDefault()
const windowUnloadPromises = this.getAllWindows().map(window => window.prepareToUnload())
const windowUnloadedResults = await Promise.all(windowUnloadPromises)
- if (windowUnloadedResults.every(Boolean)) app.quit()
+ if (windowUnloadedResults.every(Boolean)) {
+ app.quit()
+ } else {
+ this.quitting = false
+ }
}
resolveBeforeQuitPromise()
@@ -474,12 +511,12 @@ class AtomApplication extends EventEmitter {
if (this.applicationMenu) this.applicationMenu.update(window, template, menu)
}))
- this.disposable.add(ipcHelpers.on(ipcMain, 'run-package-specs', (event, packageSpecPath) => {
- this.runTests({
+ this.disposable.add(ipcHelpers.on(ipcMain, 'run-package-specs', (event, packageSpecPath, options = {}) => {
+ this.runTests(Object.assign({
resourcePath: this.devResourcePath,
pathsToOpen: [packageSpecPath],
headless: false
- })
+ }, options))
}))
this.disposable.add(ipcHelpers.on(ipcMain, 'run-benchmarks', (event, benchmarksPath) => {
@@ -530,6 +567,12 @@ class AtomApplication extends EventEmitter {
window.setPosition(x, y)
}))
+ this.disposable.add(ipcHelpers.respondTo('set-user-settings', (window, settings, filePath) => {
+ if (!this.quitting) {
+ ConfigFile.at(filePath || this.configFilePath).update(JSON.parse(settings))
+ }
+ }))
+
this.disposable.add(ipcHelpers.respondTo('center-window', window => window.center()))
this.disposable.add(ipcHelpers.respondTo('focus-window', window => window.focus()))
this.disposable.add(ipcHelpers.respondTo('show-window', window => window.show()))
@@ -568,18 +611,16 @@ class AtomApplication extends EventEmitter {
event.returnValue = this.autoUpdateManager.getErrorMessage()
}))
- this.disposable.add(ipcHelpers.on(ipcMain, 'will-save-path', (event, path) => {
- this.fileRecoveryService.willSavePath(this.atomWindowForEvent(event), path)
- event.returnValue = true
- }))
+ this.disposable.add(ipcHelpers.respondTo('will-save-path', (window, path) =>
+ this.fileRecoveryService.willSavePath(window, path)
+ ))
- this.disposable.add(ipcHelpers.on(ipcMain, 'did-save-path', (event, path) => {
- this.fileRecoveryService.didSavePath(this.atomWindowForEvent(event), path)
- event.returnValue = true
- }))
+ this.disposable.add(ipcHelpers.respondTo('did-save-path', (window, path) =>
+ this.fileRecoveryService.didSavePath(window, path)
+ ))
this.disposable.add(ipcHelpers.on(ipcMain, 'did-change-paths', () =>
- this.saveState(false)
+ this.saveCurrentWindowOptions(false)
))
this.disposable.add(this.disableZoomOnDisplayChange())
@@ -593,6 +634,13 @@ class AtomApplication extends EventEmitter {
}
}
+ initializeAtomHome (configDirPath) {
+ if (!fs.existsSync(configDirPath)) {
+ const templateConfigDirPath = fs.resolve(this.resourcePath, 'dot-atom')
+ fs.copySync(templateConfigDirPath, configDirPath)
+ }
+ }
+
// Public: Executes the given command.
//
// If it isn't handled globally, delegate to the currently focused window.
@@ -800,13 +848,12 @@ class AtomApplication extends EventEmitter {
let existingWindow
if (!newWindow) {
existingWindow = this.windowForPaths(pathsToOpen, devMode)
- const stats = pathsToOpen.map(pathToOpen => fs.statSyncNoException(pathToOpen))
if (!existingWindow) {
let lastWindow = window || this.getLastFocusedWindow()
if (lastWindow && lastWindow.devMode === devMode) {
if (addToLastWindow || (
- stats.every(s => s.isFile && s.isFile()) ||
- (stats.some(s => s.isDirectory && s.isDirectory()) && !lastWindow.hasProjectPath()))) {
+ locationsToOpen.every(({stat}) => stat && stat.isFile()) ||
+ (locationsToOpen.some(({stat}) => stat && stat.isDirectory()) && !lastWindow.hasProjectPath()))) {
existingWindow = lastWindow
}
}
@@ -839,6 +886,7 @@ class AtomApplication extends EventEmitter {
}
if (!resourcePath) resourcePath = this.resourcePath
if (!windowDimensions) windowDimensions = this.getDimensionsForNewWindow()
+
openedWindow = new AtomWindow(this, this.fileRecoveryService, {
initialPaths,
locationsToOpen,
@@ -910,7 +958,7 @@ class AtomApplication extends EventEmitter {
}
}
- saveState (allowEmpty = false) {
+ async saveCurrentWindowOptions (allowEmpty = false) {
if (this.quitting) return
const states = []
@@ -920,28 +968,23 @@ class AtomApplication extends EventEmitter {
states.reverse()
if (states.length > 0 || allowEmpty) {
- this.storageFolder.storeSync('application.json', states)
+ await this.storageFolder.store('application.json', states)
this.emit('application:did-save-state')
}
}
- loadState (options) {
- const states = this.storageFolder.load('application.json')
- if (
- ['yes', 'always'].includes(this.config.get('core.restorePreviousWindowsOnStart')) &&
- states && states.length > 0
- ) {
- return states.map(state =>
- this.openWithOptions(Object.assign(options, {
- initialPaths: state.initialPaths,
- pathsToOpen: state.initialPaths.filter(p => fs.isDirectorySync(p)),
- urlsToOpen: [],
- devMode: this.devMode,
- safeMode: this.safeMode
- }))
- )
+ async loadPreviousWindowOptions () {
+ const states = await this.storageFolder.load('application.json')
+ if (states) {
+ return states.map(state => ({
+ initialPaths: state.initialPaths,
+ pathsToOpen: state.initialPaths.filter(p => fs.isDirectorySync(p)),
+ urlsToOpen: [],
+ devMode: this.devMode,
+ safeMode: this.safeMode
+ }))
} else {
- return null
+ return []
}
}
@@ -1124,6 +1167,7 @@ class AtomApplication extends EventEmitter {
env
})
this.addWindow(window)
+ if (env) window.replaceEnvironment(env)
return window
}
@@ -1234,11 +1278,11 @@ class AtomApplication extends EventEmitter {
initialLine = initialColumn = null
}
- if (url.parse(pathToOpen).protocol == null) {
- pathToOpen = path.resolve(executedFrom, fs.normalize(pathToOpen))
- }
+ const normalizedPath = path.normalize(path.resolve(executedFrom, fs.normalize(pathToOpen)))
+ const stat = fs.statSyncNoException(normalizedPath)
+ if (stat || !url.parse(pathToOpen).protocol) pathToOpen = normalizedPath
- return {pathToOpen, initialLine, initialColumn}
+ return {pathToOpen, stat, initialLine, initialColumn}
}
// Opens a native dialog to prompt the user for a path.
@@ -1292,17 +1336,16 @@ class AtomApplication extends EventEmitter {
// File dialog defaults to project directory of currently active editor
if (path) openOptions.defaultPath = path
- return dialog.showOpenDialog(parentWindow, openOptions, callback)
+ dialog.showOpenDialog(parentWindow, openOptions, callback)
}
promptForRestart () {
- const chosen = dialog.showMessageBox(BrowserWindow.getFocusedWindow(), {
+ dialog.showMessageBox(BrowserWindow.getFocusedWindow(), {
type: 'warning',
title: 'Restart required',
message: 'You will need to restart Atom for this change to take effect.',
buttons: ['Restart Atom', 'Cancel']
- })
- if (chosen === 0) return this.restart()
+ }, response => { if (response === 0) this.restart() })
}
restart () {
diff --git a/src/main-process/atom-protocol-handler.js b/src/main-process/atom-protocol-handler.js
index 1affba02a..47c3da14f 100644
--- a/src/main-process/atom-protocol-handler.js
+++ b/src/main-process/atom-protocol-handler.js
@@ -20,6 +20,7 @@ class AtomProtocolHandler {
if (!safeMode) {
this.loadPaths.push(path.join(process.env.ATOM_HOME, 'dev', 'packages'))
+ this.loadPaths.push(path.join(resourcePath, 'packages'))
}
this.loadPaths.push(path.join(process.env.ATOM_HOME, 'packages'))
diff --git a/src/main-process/atom-window.js b/src/main-process/atom-window.js
index 0268cc1cf..a56679143 100644
--- a/src/main-process/atom-window.js
+++ b/src/main-process/atom-window.js
@@ -51,10 +51,18 @@ class AtomWindow extends EventEmitter {
// taskbar's icon. See https://github.com/atom/atom/issues/4811 for more.
if (process.platform === 'linux') options.icon = ICON_PATH
if (this.shouldAddCustomTitleBar()) options.titleBarStyle = 'hidden'
- if (this.shouldAddCustomInsetTitleBar()) options.titleBarStyle = 'hidden-inset'
+ if (this.shouldAddCustomInsetTitleBar()) options.titleBarStyle = 'hiddenInset'
if (this.shouldHideTitleBar()) options.frame = false
this.browserWindow = new BrowserWindow(options)
+ Object.defineProperty(this.browserWindow, 'loadSettingsJSON', {
+ get: () => JSON.stringify(Object.assign({
+ userSettings: !this.isSpec
+ ? this.atomApplication.configFile.get()
+ : null
+ }, this.loadSettings))
+ })
+
this.handleEvents()
this.loadSettings = Object.assign({}, settings)
@@ -67,14 +75,13 @@ class AtomWindow extends EventEmitter {
if (!this.loadSettings.initialPaths) {
this.loadSettings.initialPaths = []
- for (const {pathToOpen} of locationsToOpen) {
+ for (const {pathToOpen, stat} of locationsToOpen) {
if (!pathToOpen) continue
- const stat = fs.statSyncNoException(pathToOpen) || null
if (stat && stat.isDirectory()) {
this.loadSettings.initialPaths.push(pathToOpen)
} else {
const parentDirectory = path.dirname(pathToOpen)
- if ((stat && stat.isFile()) || fs.existsSync(parentDirectory)) {
+ if (stat && stat.isFile() || fs.existsSync(parentDirectory)) {
this.loadSettings.initialPaths.push(parentDirectory)
} else {
this.loadSettings.initialPaths.push(pathToOpen)
@@ -96,8 +103,6 @@ class AtomWindow extends EventEmitter {
this.representedDirectoryPaths = this.loadSettings.initialPaths
if (!this.loadSettings.env) this.env = this.loadSettings.env
- this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings)
-
this.browserWindow.on('window:loaded', () => {
this.disableZoom()
this.emit('window:loaded')
@@ -150,12 +155,13 @@ class AtomWindow extends EventEmitter {
containsPath (pathToCheck) {
if (!pathToCheck) return false
- const stat = fs.statSyncNoException(pathToCheck)
- if (stat && stat.isDirectory()) return false
-
- return this.representedDirectoryPaths.some(projectPath =>
- pathToCheck === projectPath || pathToCheck.startsWith(path.join(projectPath, path.sep))
- )
+ let stat
+ return this.representedDirectoryPaths.some(projectPath => {
+ if (pathToCheck === projectPath) return true
+ if (!pathToCheck.startsWith(path.join(projectPath, path.sep))) return false
+ if (stat === undefined) stat = fs.statSyncNoException(pathToCheck)
+ return !stat || !stat.isDirectory()
+ })
}
handleEvents () {
@@ -163,7 +169,7 @@ class AtomWindow extends EventEmitter {
if (!this.atomApplication.quitting && !this.unloading) {
event.preventDefault()
this.unloading = true
- this.atomApplication.saveState(false)
+ this.atomApplication.saveCurrentWindowOptions(false)
if (await this.prepareToUnload()) this.close()
}
})
@@ -176,34 +182,36 @@ class AtomWindow extends EventEmitter {
this.browserWindow.on('unresponsive', () => {
if (this.isSpec) return
- const chosen = dialog.showMessageBox(this.browserWindow, {
+ dialog.showMessageBox(this.browserWindow, {
type: 'warning',
buttons: ['Force Close', 'Keep Waiting'],
+ cancelId: 1, // Canceling should be the least destructive action
message: 'Editor is not responding',
detail:
'The editor is not responding. Would you like to force close it or just keep waiting?'
- })
- if (chosen === 0) this.browserWindow.destroy()
+ }, response => { if (response === 0) this.browserWindow.destroy() })
})
- this.browserWindow.webContents.on('crashed', () => {
+ this.browserWindow.webContents.on('crashed', async () => {
if (this.headless) {
console.log('Renderer process crashed, exiting')
this.atomApplication.exit(100)
return
}
- this.fileRecoveryService.didCrashWindow(this)
- const chosen = dialog.showMessageBox(this.browserWindow, {
+ await this.fileRecoveryService.didCrashWindow(this)
+ dialog.showMessageBox(this.browserWindow, {
type: 'warning',
buttons: ['Close Window', 'Reload', 'Keep It Open'],
+ cancelId: 2, // Canceling should be the least destructive action
message: 'The editor has crashed',
detail: 'Please report this issue to https://github.com/atom/atom'
+ }, response => {
+ switch (response) {
+ case 0: return this.browserWindow.destroy()
+ case 1: return this.browserWindow.reload()
+ }
})
- switch (chosen) {
- case 0: return this.browserWindow.destroy()
- case 1: return this.browserWindow.reload()
- }
})
this.browserWindow.webContents.on('will-navigate', (event, url) => {
@@ -246,6 +254,14 @@ class AtomWindow extends EventEmitter {
this.sendMessage('open-locations', locationsToOpen)
}
+ didChangeUserSettings (settings) {
+ this.sendMessage('did-change-user-settings', settings)
+ }
+
+ didFailToReadUserSettings (message) {
+ this.sendMessage('did-fail-to-read-user-settings', message)
+ }
+
replaceEnvironment (env) {
this.browserWindow.webContents.send('environment', env)
}
@@ -414,8 +430,7 @@ class AtomWindow extends EventEmitter {
this.representedDirectoryPaths = representedDirectoryPaths
this.representedDirectoryPaths.sort()
this.loadSettings.initialPaths = this.representedDirectoryPaths
- this.browserWindow.loadSettingsJSON = JSON.stringify(this.loadSettings)
- return this.atomApplication.saveState()
+ return this.atomApplication.saveCurrentWindowOptions()
}
didClosePathWithWaitSession (path) {
diff --git a/src/main-process/auto-update-manager.coffee b/src/main-process/auto-update-manager.coffee
index 0e4144c1a..74bf2f886 100644
--- a/src/main-process/auto-update-manager.coffee
+++ b/src/main-process/auto-update-manager.coffee
@@ -94,7 +94,7 @@ class AutoUpdateManager
scheduleUpdateCheck: ->
# Only schedule update check periodically if running in release version and
# and there is no existing scheduled update check.
- unless /\w{7}/.test(@version) or @checkForUpdatesIntervalID
+ unless /-dev/.test(@version) or @checkForUpdatesIntervalID
checkForUpdates = => @check(hidePopups: true)
fourHours = 1000 * 60 * 60 * 4
@checkForUpdatesIntervalID = setInterval(checkForUpdates, fourHours)
@@ -118,24 +118,26 @@ class AutoUpdateManager
onUpdateNotAvailable: =>
autoUpdater.removeListener 'error', @onUpdateError
{dialog} = require 'electron'
- dialog.showMessageBox
+ dialog.showMessageBox {
type: 'info'
buttons: ['OK']
icon: @iconPath
message: 'No update available.'
title: 'No Update Available'
detail: "Version #{@version} is the latest version."
+ }, -> # noop callback to get async behavior
onUpdateError: (event, message) =>
autoUpdater.removeListener 'update-not-available', @onUpdateNotAvailable
{dialog} = require 'electron'
- dialog.showMessageBox
+ dialog.showMessageBox {
type: 'warning'
buttons: ['OK']
icon: @iconPath
message: 'There was an error checking for updates.'
title: 'Update Error'
detail: message
+ }, -> # noop callback to get async behavior
getWindows: ->
global.atomApplication.getAllWindows()
diff --git a/src/main-process/file-recovery-service.js b/src/main-process/file-recovery-service.js
index f55e3f956..abe2df84e 100644
--- a/src/main-process/file-recovery-service.js
+++ b/src/main-process/file-recovery-service.js
@@ -1,11 +1,11 @@
-'use babel'
+const {dialog} = require('electron')
+const crypto = require('crypto')
+const Path = require('path')
+const fs = require('fs-plus')
+const mkdirp = require('mkdirp')
-import {dialog} from 'electron'
-import crypto from 'crypto'
-import Path from 'path'
-import fs from 'fs-plus'
-
-export default class FileRecoveryService {
+module.exports =
+class FileRecoveryService {
constructor (recoveryDirectory) {
this.recoveryDirectory = recoveryDirectory
this.recoveryFilesByFilePath = new Map()
@@ -13,15 +13,16 @@ export default class FileRecoveryService {
this.windowsByRecoveryFile = new Map()
}
- willSavePath (window, path) {
- if (!fs.existsSync(path)) return
+ async willSavePath (window, path) {
+ const stats = await tryStatFile(path)
+ if (!stats) return
const recoveryPath = Path.join(this.recoveryDirectory, RecoveryFile.fileNameForPath(path))
const recoveryFile =
- this.recoveryFilesByFilePath.get(path) || new RecoveryFile(path, recoveryPath)
+ this.recoveryFilesByFilePath.get(path) || new RecoveryFile(path, stats.mode, recoveryPath)
try {
- recoveryFile.retain()
+ await recoveryFile.retain()
} catch (err) {
console.log(`Couldn't retain ${recoveryFile.recoveryPath}. Code: ${err.code}. Message: ${err.message}`)
return
@@ -39,11 +40,11 @@ export default class FileRecoveryService {
this.recoveryFilesByFilePath.set(path, recoveryFile)
}
- didSavePath (window, path) {
+ async didSavePath (window, path) {
const recoveryFile = this.recoveryFilesByFilePath.get(path)
if (recoveryFile != null) {
try {
- recoveryFile.release()
+ await recoveryFile.release()
} catch (err) {
console.log(`Couldn't release ${recoveryFile.recoveryPath}. Code: ${err.code}. Message: ${err.message}`)
}
@@ -53,27 +54,31 @@ export default class FileRecoveryService {
}
}
- didCrashWindow (window) {
+ async didCrashWindow (window) {
if (!this.recoveryFilesByWindow.has(window)) return
+ const promises = []
for (const recoveryFile of this.recoveryFilesByWindow.get(window)) {
- try {
- recoveryFile.recoverSync()
- } catch (error) {
- const message = 'A file that Atom was saving could be corrupted'
- const detail =
- `Error ${error.code}. There was a crash while saving "${recoveryFile.originalPath}", so this file might be blank or corrupted.\n` +
- `Atom couldn't recover it automatically, but a recovery file has been saved at: "${recoveryFile.recoveryPath}".`
- console.log(detail)
- dialog.showMessageBox(window.browserWindow, {type: 'info', buttons: ['OK'], message, detail})
- } finally {
- for (let window of this.windowsByRecoveryFile.get(recoveryFile)) {
- this.recoveryFilesByWindow.get(window).delete(recoveryFile)
- }
- this.windowsByRecoveryFile.delete(recoveryFile)
- this.recoveryFilesByFilePath.delete(recoveryFile.originalPath)
- }
+ promises.push(recoveryFile.recover()
+ .catch(error => {
+ const message = 'A file that Atom was saving could be corrupted'
+ const detail =
+ `Error ${error.code}. There was a crash while saving "${recoveryFile.originalPath}", so this file might be blank or corrupted.\n` +
+ `Atom couldn't recover it automatically, but a recovery file has been saved at: "${recoveryFile.recoveryPath}".`
+ console.log(detail)
+ dialog.showMessageBox(window, {type: 'info', buttons: ['OK'], message, detail}, () => { /* noop callback to get async behavior */ })
+ })
+ .then(() => {
+ for (let window of this.windowsByRecoveryFile.get(recoveryFile)) {
+ this.recoveryFilesByWindow.get(window).delete(recoveryFile)
+ }
+ this.windowsByRecoveryFile.delete(recoveryFile)
+ this.recoveryFilesByFilePath.delete(recoveryFile.originalPath)
+ })
+ )
}
+
+ await Promise.all(promises)
}
didCloseWindow (window) {
@@ -94,36 +99,67 @@ class RecoveryFile {
return `${basename}-${randomSuffix}${extension}`
}
- constructor (originalPath, recoveryPath) {
+ constructor (originalPath, fileMode, recoveryPath) {
this.originalPath = originalPath
+ this.fileMode = fileMode
this.recoveryPath = recoveryPath
this.refCount = 0
}
- storeSync () {
- fs.copyFileSync(this.originalPath, this.recoveryPath)
+ async store () {
+ await copyFile(this.originalPath, this.recoveryPath, this.fileMode)
}
- recoverSync () {
- fs.copyFileSync(this.recoveryPath, this.originalPath)
- this.removeSync()
+ async recover () {
+ await copyFile(this.recoveryPath, this.originalPath, this.fileMode)
+ await this.remove()
}
- removeSync () {
- fs.unlinkSync(this.recoveryPath)
+ async remove () {
+ return new Promise((resolve, reject) =>
+ fs.unlink(this.recoveryPath, error =>
+ error && error.code !== 'ENOENT' ? reject(error) : resolve()
+ )
+ )
}
- retain () {
- if (this.isReleased()) this.storeSync()
+ async retain () {
+ if (this.isReleased()) await this.store()
this.refCount++
}
- release () {
+ async release () {
this.refCount--
- if (this.isReleased()) this.removeSync()
+ if (this.isReleased()) await this.remove()
}
isReleased () {
return this.refCount === 0
}
}
+
+async function tryStatFile (path) {
+ return new Promise((resolve, reject) =>
+ fs.stat(path, (error, result) =>
+ resolve(error == null && result)
+ )
+ )
+}
+
+async function copyFile (source, destination, mode) {
+ return new Promise((resolve, reject) => {
+ mkdirp(Path.dirname(destination), (error) => {
+ if (error) return reject(error)
+ const readStream = fs.createReadStream(source)
+ readStream
+ .on('error', reject)
+ .once('open', () => {
+ const writeStream = fs.createWriteStream(destination, {mode})
+ writeStream
+ .on('error', reject)
+ .on('open', () => readStream.pipe(writeStream))
+ .once('close', () => resolve())
+ })
+ })
+ })
+}
diff --git a/src/main-process/main.js b/src/main-process/main.js
index ee7b96232..11703bf3f 100644
--- a/src/main-process/main.js
+++ b/src/main-process/main.js
@@ -4,37 +4,55 @@ if (typeof snapshotResult !== 'undefined') {
const startTime = Date.now()
-const electron = require('electron')
-const fs = require('fs')
const path = require('path')
+const fs = require('fs-plus')
+const CSON = require('season')
const yargs = require('yargs')
+const electron = require('electron')
const args =
yargs(process.argv)
.alias('d', 'dev')
.alias('t', 'test')
+ .alias('r', 'resource-path')
.argv
+function isAtomRepoPath (repoPath) {
+ let packageJsonPath = path.join(repoPath, 'package.json')
+ if (fs.statSyncNoException(packageJsonPath)) {
+ let packageJson = CSON.readFileSync(packageJsonPath)
+ return packageJson.name === 'atom'
+ }
+
+ return false
+}
+
let resourcePath
+let devResourcePath
if (args.resourcePath) {
resourcePath = args.resourcePath
+ devResourcePath = resourcePath
} else {
const stableResourcePath = path.dirname(path.dirname(__dirname))
const defaultRepositoryPath = path.join(electron.app.getPath('home'), 'github', 'atom')
+ if (process.env.ATOM_DEV_RESOURCE_PATH) {
+ devResourcePath = process.env.ATOM_DEV_RESOURCE_PATH
+ } else if (isAtomRepoPath(process.cwd())) {
+ devResourcePath = process.cwd()
+ } else if (fs.statSyncNoException(defaultRepositoryPath)) {
+ devResourcePath = defaultRepositoryPath
+ } else {
+ devResourcePath = stableResourcePath
+ }
+
if (args.dev || args.test || args.benchmark || args.benchmarkTest) {
- if (process.env.ATOM_DEV_RESOURCE_PATH) {
- resourcePath = process.env.ATOM_DEV_RESOURCE_PATH
- } else if (fs.statSyncNoException(defaultRepositoryPath)) {
- resourcePath = defaultRepositoryPath
- } else {
- resourcePath = stableResourcePath
- }
+ resourcePath = devResourcePath
} else {
resourcePath = stableResourcePath
}
}
const start = require(path.join(resourcePath, 'src', 'main-process', 'start'))
-start(resourcePath, startTime)
+start(resourcePath, devResourcePath, startTime)
diff --git a/src/main-process/parse-command-line.js b/src/main-process/parse-command-line.js
index 3b0654962..5d7849eac 100644
--- a/src/main-process/parse-command-line.js
+++ b/src/main-process/parse-command-line.js
@@ -3,8 +3,6 @@
const dedent = require('dedent')
const yargs = require('yargs')
const {app} = require('electron')
-const path = require('path')
-const fs = require('fs-plus')
module.exports = function parseCommandLine (processArgs) {
const options = yargs(processArgs).wrap(yargs.terminalWidth())
@@ -12,13 +10,18 @@ module.exports = function parseCommandLine (processArgs) {
options.usage(
dedent`Atom Editor v${version}
- Usage: atom [options] [path ...]
+ Usage:
+ atom [options] [path ...]
+ atom file[:line[:column]]
One or more paths to files or folders may be specified. If there is an
existing Atom window that contains all of the given folders, the paths
will be opened in that window. Otherwise, they will be opened in a new
window.
+ A file may be opened at the desired line (and optionally column) by
+ appending the numbers right after the file name, e.g. \`atom file:5:8\`.
+
Paths that start with \`atom://\` will be interpreted as URLs.
Environment Variables:
@@ -44,7 +47,7 @@ module.exports = function parseCommandLine (processArgs) {
'Do not load packages from ~/.atom/packages or ~/.atom/dev/packages.'
)
options.boolean('benchmark').describe('benchmark', 'Open a new window that runs the specified benchmarks.')
- options.boolean('benchmark-test').describe('benchmark--test', 'Run a faster version of the benchmarks in headless mode.')
+ options.boolean('benchmark-test').describe('benchmark-test', 'Run a faster version of the benchmarks in headless mode.')
options.alias('t', 'test').boolean('t').describe('t', 'Run the specified specs and exit with error code on failures.')
options.alias('m', 'main-process').boolean('m').describe('m', 'Run the specified specs in the main process.')
options.string('timeout').describe(
@@ -114,8 +117,6 @@ module.exports = function parseCommandLine (processArgs) {
let pathsToOpen = []
let urlsToOpen = []
let devMode = args['dev']
- let devResourcePath = process.env.ATOM_DEV_RESOURCE_PATH || path.join(app.getPath('home'), 'github', 'atom')
- let resourcePath = null
for (const path of args._) {
if (path.startsWith('atom://')) {
@@ -125,21 +126,8 @@ module.exports = function parseCommandLine (processArgs) {
}
}
- if (args['resource-path']) {
+ if (args.resourcePath || test) {
devMode = true
- devResourcePath = args['resource-path']
- }
-
- if (test) {
- devMode = true
- }
-
- if (devMode) {
- resourcePath = devResourcePath
- }
-
- if (!fs.statSyncNoException(resourcePath)) {
- resourcePath = path.dirname(path.dirname(__dirname))
}
if (args['path-environment']) {
@@ -148,12 +136,7 @@ module.exports = function parseCommandLine (processArgs) {
process.env.PATH = args['path-environment']
}
- resourcePath = normalizeDriveLetterName(resourcePath)
- devResourcePath = normalizeDriveLetterName(devResourcePath)
-
return {
- resourcePath,
- devResourcePath,
pathsToOpen,
urlsToOpen,
executedFrom,
@@ -176,11 +159,3 @@ module.exports = function parseCommandLine (processArgs) {
env: process.env
}
}
-
-function normalizeDriveLetterName (filePath) {
- if (process.platform === 'win32') {
- return filePath.replace(/^([a-z]):/, ([driveLetter]) => driveLetter.toUpperCase() + ':')
- } else {
- return filePath
- }
-}
diff --git a/src/main-process/start.js b/src/main-process/start.js
index 9670e67b6..10713fa4b 100644
--- a/src/main-process/start.js
+++ b/src/main-process/start.js
@@ -5,8 +5,11 @@ const temp = require('temp').track()
const parseCommandLine = require('./parse-command-line')
const startCrashReporter = require('../crash-reporter-start')
const atomPaths = require('../atom-paths')
+const fs = require('fs')
+const CSON = require('season')
+const Config = require('../config')
-module.exports = function start (resourcePath, startTime) {
+module.exports = function start (resourcePath, devResourcePath, startTime) {
global.shellStartTime = startTime
process.on('uncaughtException', function (error = {}) {
@@ -19,16 +22,35 @@ module.exports = function start (resourcePath, startTime) {
}
})
+ process.on('unhandledRejection', function (error = {}) {
+ if (error.message != null) {
+ console.log(error.message)
+ }
+
+ if (error.stack != null) {
+ console.log(error.stack)
+ }
+ })
+
const previousConsoleLog = console.log
console.log = nslog
app.commandLine.appendSwitch('enable-experimental-web-platform-features')
const args = parseCommandLine(process.argv.slice(1))
+ args.resourcePath = normalizeDriveLetterName(resourcePath)
+ args.devResourcePath = normalizeDriveLetterName(devResourcePath)
+
atomPaths.setAtomHome(app.getPath('home'))
atomPaths.setUserData(app)
setupCompileCache()
+ const config = getConfig()
+ const colorProfile = config.get('core.colorProfile')
+ if (colorProfile && colorProfile !== 'default') {
+ app.commandLine.appendSwitch('force-color-profile', colorProfile)
+ }
+
if (handleStartupEventWithSquirrel()) {
return
} else if (args.test && args.mainProcess) {
@@ -87,3 +109,29 @@ function setupCompileCache () {
CompileCache.setAtomHomeDirectory(process.env.ATOM_HOME)
CompileCache.install(process.resourcesPath, require)
}
+
+function getConfig () {
+ const config = new Config()
+
+ let configFilePath
+ if (fs.existsSync(path.join(process.env.ATOM_HOME, 'config.json'))) {
+ configFilePath = path.join(process.env.ATOM_HOME, 'config.json')
+ } else if (fs.existsSync(path.join(process.env.ATOM_HOME, 'config.cson'))) {
+ configFilePath = path.join(process.env.ATOM_HOME, 'config.cson')
+ }
+
+ if (configFilePath) {
+ const configFileData = CSON.readFileSync(configFilePath)
+ config.resetUserSettings(configFileData)
+ }
+
+ return config
+}
+
+function normalizeDriveLetterName (filePath) {
+ if (process.platform === 'win32' && filePath) {
+ return filePath.replace(/^([a-z]):/, ([driveLetter]) => driveLetter.toUpperCase() + ':')
+ } else {
+ return filePath
+ }
+}
diff --git a/src/main-process/win-shell.js b/src/main-process/win-shell.js
index 9670936c7..dd694b9dd 100644
--- a/src/main-process/win-shell.js
+++ b/src/main-process/win-shell.js
@@ -1,7 +1,5 @@
-'use babel'
-
-import Registry from 'winreg'
-import Path from 'path'
+const Registry = require('winreg')
+const Path = require('path')
let exeName = Path.basename(process.execPath)
let appPath = `\"${process.execPath}\"`
diff --git a/src/menu-helpers.coffee b/src/menu-helpers.coffee
deleted file mode 100644
index d648d81ed..000000000
--- a/src/menu-helpers.coffee
+++ /dev/null
@@ -1,74 +0,0 @@
-_ = require 'underscore-plus'
-
-ItemSpecificities = new WeakMap
-
-merge = (menu, item, itemSpecificity=Infinity) ->
- item = cloneMenuItem(item)
- ItemSpecificities.set(item, itemSpecificity) if itemSpecificity
- matchingItemIndex = findMatchingItemIndex(menu, item)
- matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1
-
- if matchingItem?
- if item.submenu?
- merge(matchingItem.submenu, submenuItem, itemSpecificity) for submenuItem in item.submenu
- else if itemSpecificity
- unless itemSpecificity < ItemSpecificities.get(matchingItem)
- menu[matchingItemIndex] = item
- else unless item.type is 'separator' and _.last(menu)?.type is 'separator'
- menu.push(item)
-
- return
-
-unmerge = (menu, item) ->
- matchingItemIndex = findMatchingItemIndex(menu, item)
- matchingItem = menu[matchingItemIndex] unless matchingItemIndex is - 1
-
- if matchingItem?
- if item.submenu?
- unmerge(matchingItem.submenu, submenuItem) for submenuItem in item.submenu
-
- unless matchingItem.submenu?.length > 0
- menu.splice(matchingItemIndex, 1)
-
-findMatchingItemIndex = (menu, {type, label, submenu}) ->
- return -1 if type is 'separator'
- for item, index in menu
- if normalizeLabel(item.label) is normalizeLabel(label) and item.submenu? is submenu?
- return index
- -1
-
-normalizeLabel = (label) ->
- return undefined unless label?
-
- if process.platform is 'darwin'
- label
- else
- label.replace(/\&/g, '')
-
-cloneMenuItem = (item) ->
- item = _.pick(item, 'type', 'label', 'enabled', 'visible', 'command', 'submenu', 'commandDetail', 'role', 'accelerator')
- if item.submenu?
- item.submenu = item.submenu.map (submenuItem) -> cloneMenuItem(submenuItem)
- item
-
-# Determine the Electron accelerator for a given Atom keystroke.
-#
-# keystroke - The keystroke.
-#
-# Returns a String containing the keystroke in a format that can be interpreted
-# by Electron to provide nice icons where available.
-acceleratorForKeystroke = (keystroke) ->
- return null unless keystroke
- modifiers = keystroke.split(/-(?=.)/)
- key = modifiers.pop().toUpperCase().replace('+', 'Plus')
-
- modifiers = modifiers.map (modifier) ->
- modifier.replace(/shift/ig, "Shift")
- .replace(/cmd/ig, "Command")
- .replace(/ctrl/ig, "Ctrl")
- .replace(/alt/ig, "Alt")
-
- keys = modifiers.concat([key])
- keys.join("+")
-
-module.exports = {merge, unmerge, normalizeLabel, cloneMenuItem, acceleratorForKeystroke}
diff --git a/src/menu-helpers.js b/src/menu-helpers.js
new file mode 100644
index 000000000..12598764e
--- /dev/null
+++ b/src/menu-helpers.js
@@ -0,0 +1,132 @@
+const _ = require('underscore-plus')
+
+const ItemSpecificities = new WeakMap()
+
+// Add an item to a menu, ensuring separators are not duplicated.
+function addItemToMenu (item, menu) {
+ const lastMenuItem = _.last(menu)
+ const lastMenuItemIsSpearator = lastMenuItem && lastMenuItem.type === 'separator'
+ if (!(item.type === 'separator' && lastMenuItemIsSpearator)) {
+ menu.push(item)
+ }
+}
+
+function merge (menu, item, itemSpecificity = Infinity) {
+ item = cloneMenuItem(item)
+ ItemSpecificities.set(item, itemSpecificity)
+ const matchingItemIndex = findMatchingItemIndex(menu, item)
+
+ if (matchingItemIndex === -1) {
+ addItemToMenu(item, menu)
+ return
+ }
+
+ const matchingItem = menu[matchingItemIndex]
+ if (item.submenu != null) {
+ for (let submenuItem of item.submenu) {
+ merge(matchingItem.submenu, submenuItem, itemSpecificity)
+ }
+ } else if (itemSpecificity && itemSpecificity >= ItemSpecificities.get(matchingItem)) {
+ menu[matchingItemIndex] = item
+ }
+}
+
+function unmerge (menu, item) {
+ const matchingItemIndex = findMatchingItemIndex(menu, item)
+ if (matchingItemIndex === -1) {
+ return
+ }
+
+ const matchingItem = menu[matchingItemIndex]
+ if (item.submenu != null) {
+ for (let submenuItem of item.submenu) {
+ unmerge(matchingItem.submenu, submenuItem)
+ }
+ }
+
+ if (matchingItem.submenu == null || matchingItem.submenu.length === 0) {
+ menu.splice(matchingItemIndex, 1)
+ }
+}
+
+function findMatchingItemIndex (menu, { type, label, submenu }) {
+ if (type === 'separator') {
+ return -1
+ }
+ for (let index = 0; index < menu.length; index++) {
+ const item = menu[index]
+ if (
+ normalizeLabel(item.label) === normalizeLabel(label) &&
+ (item.submenu != null) === (submenu != null)
+ ) {
+ return index
+ }
+ }
+ return -1
+}
+
+function normalizeLabel (label) {
+ if (label == null) {
+ return
+ }
+ return process.platform === 'darwin' ? label : label.replace(/&/g, '')
+}
+
+function cloneMenuItem (item) {
+ item = _.pick(
+ item,
+ 'type',
+ 'label',
+ 'enabled',
+ 'visible',
+ 'command',
+ 'submenu',
+ 'commandDetail',
+ 'role',
+ 'accelerator',
+ 'before',
+ 'after',
+ 'beforeGroupContaining',
+ 'afterGroupContaining'
+ )
+ if (item.submenu != null) {
+ item.submenu = item.submenu.map(submenuItem => cloneMenuItem(submenuItem))
+ }
+ return item
+}
+
+// Determine the Electron accelerator for a given Atom keystroke.
+//
+// keystroke - The keystroke.
+//
+// Returns a String containing the keystroke in a format that can be interpreted
+// by Electron to provide nice icons where available.
+function acceleratorForKeystroke (keystroke) {
+ if (!keystroke) {
+ return null
+ }
+ let modifiers = keystroke.split(/-(?=.)/)
+ const key = modifiers
+ .pop()
+ .toUpperCase()
+ .replace('+', 'Plus')
+
+ modifiers = modifiers.map(modifier =>
+ modifier
+ .replace(/shift/gi, 'Shift')
+ .replace(/cmd/gi, 'Command')
+ .replace(/ctrl/gi, 'Ctrl')
+ .replace(/alt/gi, 'Alt')
+ )
+
+ const keys = [...modifiers, key]
+ return keys.join('+')
+}
+
+module.exports = {
+ merge,
+ unmerge,
+ normalizeLabel,
+ cloneMenuItem,
+ acceleratorForKeystroke
+}
diff --git a/src/menu-manager.coffee b/src/menu-manager.coffee
index dbdcb2f0d..a3d35a1de 100644
--- a/src/menu-manager.coffee
+++ b/src/menu-manager.coffee
@@ -149,9 +149,9 @@ class MenuManager
update: ->
return unless @initialized
- clearImmediate(@pendingUpdateOperation) if @pendingUpdateOperation?
+ clearTimeout(@pendingUpdateOperation) if @pendingUpdateOperation?
- @pendingUpdateOperation = setImmediate =>
+ @pendingUpdateOperation = setTimeout(=>
unsetKeystrokes = new Set
for binding in @keymapManager.getKeyBindings()
if binding.command is 'unset!'
@@ -168,6 +168,7 @@ class MenuManager
keystrokesByCommand[binding.command].unshift binding.keystrokes
@sendToBrowserProcess(@template, keystrokesByCommand)
+ , 1)
loadPlatformItems: ->
if platformMenu?
diff --git a/src/menu-sort-helpers.js b/src/menu-sort-helpers.js
new file mode 100644
index 000000000..259f8321e
--- /dev/null
+++ b/src/menu-sort-helpers.js
@@ -0,0 +1,186 @@
+// UTILS
+
+function splitArray (arr, predicate) {
+ let lastArr = []
+ const multiArr = [lastArr]
+ arr.forEach(item => {
+ if (predicate(item)) {
+ if (lastArr.length > 0) {
+ lastArr = []
+ multiArr.push(lastArr)
+ }
+ } else {
+ lastArr.push(item)
+ }
+ })
+ return multiArr
+}
+
+function joinArrays (arrays, joiner) {
+ const joinedArr = []
+ arrays.forEach((arr, i) => {
+ if (i > 0 && arr.length > 0) {
+ joinedArr.push(joiner)
+ }
+ joinedArr.push(...arr)
+ })
+ return joinedArr
+}
+
+const pushOntoMultiMap = (map, key, value) => {
+ if (!map.has(key)) {
+ map.set(key, [])
+ }
+ map.get(key).push(value)
+}
+
+function indexOfGroupContainingCommand (groups, command, ignoreGroup) {
+ return groups.findIndex(
+ candiateGroup =>
+ candiateGroup !== ignoreGroup &&
+ candiateGroup.some(
+ candidateItem => candidateItem.command === command
+ )
+ )
+}
+
+// Sort nodes topologically using a depth-first approach. Encountered cycles
+// are broken.
+function sortTopologically (originalOrder, edgesById) {
+ const sorted = []
+ const marked = new Set()
+
+ function visit (id) {
+ if (marked.has(id)) {
+ // Either this node has already been placed, or we have encountered a
+ // cycle and need to exit.
+ return
+ }
+ marked.add(id)
+ const edges = edgesById.get(id)
+ if (edges != null) {
+ edges.forEach(visit)
+ }
+ sorted.push(id)
+ }
+
+ originalOrder.forEach(visit)
+ return sorted
+}
+
+function attemptToMergeAGroup (groups) {
+ for (let i = 0; i < groups.length; i++) {
+ const group = groups[i]
+ for (const item of group) {
+ const toCommands = [...(item.before || []), ...(item.after || [])]
+ for (const command of toCommands) {
+ const index = indexOfGroupContainingCommand(groups, command, group)
+ if (index === -1) {
+ // No valid edge for this command
+ continue
+ }
+ const mergeTarget = groups[index]
+ // Merge with group containing `command`
+ mergeTarget.push(...group)
+ groups.splice(i, 1)
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// Merge groups based on before/after positions
+// Mutates both the array of groups, and the individual group arrays.
+function mergeGroups (groups) {
+ let mergedAGroup = true
+ while (mergedAGroup) {
+ mergedAGroup = attemptToMergeAGroup(groups)
+ }
+ return groups
+}
+
+function sortItemsInGroup (group) {
+ const originalOrder = group.map((node, i) => i)
+ const edges = new Map()
+ const commandToIndex = new Map(group.map((item, i) => [item.command, i]))
+
+ group.forEach((item, i) => {
+ if (item.before) {
+ item.before.forEach(toCommand => {
+ const to = commandToIndex.get(toCommand)
+ if (to != null) {
+ pushOntoMultiMap(edges, to, i)
+ }
+ })
+ }
+ if (item.after) {
+ item.after.forEach(toCommand => {
+ const to = commandToIndex.get(toCommand)
+ if (to != null) {
+ pushOntoMultiMap(edges, i, to)
+ }
+ })
+ }
+ })
+
+ const sortedNodes = sortTopologically(originalOrder, edges)
+
+ return sortedNodes.map(i => group[i])
+}
+
+function findEdgesInGroup (groups, i, edges) {
+ const group = groups[i]
+ for (const item of group) {
+ if (item.beforeGroupContaining) {
+ for (const command of item.beforeGroupContaining) {
+ const to = indexOfGroupContainingCommand(groups, command, group)
+ if (to !== -1) {
+ pushOntoMultiMap(edges, to, i)
+ return
+ }
+ }
+ }
+ if (item.afterGroupContaining) {
+ for (const command of item.afterGroupContaining) {
+ const to = indexOfGroupContainingCommand(groups, command, group)
+ if (to !== -1) {
+ pushOntoMultiMap(edges, i, to)
+ return
+ }
+ }
+ }
+ }
+}
+
+function sortGroups (groups) {
+ const originalOrder = groups.map((item, i) => i)
+ const edges = new Map()
+
+ for (let i = 0; i < groups.length; i++) {
+ findEdgesInGroup(groups, i, edges)
+ }
+
+ const sortedGroupIndexes = sortTopologically(originalOrder, edges)
+ return sortedGroupIndexes.map(i => groups[i])
+}
+
+function isSeparator (item) {
+ return item.type === 'separator'
+}
+
+function sortMenuItems (menuItems) {
+ // Split the items into their implicit groups based upon separators.
+ const groups = splitArray(menuItems, isSeparator)
+ // Merge groups that contain before/after references to eachother.
+ const mergedGroups = mergeGroups(groups)
+ // Sort each individual group internally.
+ const mergedGroupsWithSortedItems = mergedGroups.map(sortItemsInGroup)
+ // Sort the groups based upon their beforeGroupContaining/afterGroupContaining
+ // references.
+ const sortedGroups = sortGroups(mergedGroupsWithSortedItems)
+ // Join the groups back
+ return joinArrays(sortedGroups, { type: 'separator' })
+}
+
+module.exports = {sortMenuItems}
diff --git a/src/module-cache.coffee b/src/module-cache.coffee
index 5bc162ab1..358ed3393 100644
--- a/src/module-cache.coffee
+++ b/src/module-cache.coffee
@@ -189,7 +189,7 @@ resolveModulePath = (relativePath, parentModule) ->
return unless candidates?
for version, resolvedPath of candidates
- if Module._cache.hasOwnProperty(resolvedPath) or isCorePath(resolvedPath)
+ if Module._cache[resolvedPath] or isCorePath(resolvedPath)
return resolvedPath if satisfies(version, range)
return
diff --git a/src/native-watcher-registry.js b/src/native-watcher-registry.js
index e63ac6cda..97f33e3fb 100644
--- a/src/native-watcher-registry.js
+++ b/src/native-watcher-registry.js
@@ -1,5 +1,3 @@
-/** @babel */
-
const path = require('path')
// Private: re-join the segments split from an absolute path to form another absolute path.
diff --git a/src/notification.js b/src/notification.js
index 320866d6b..96fad59e0 100644
--- a/src/notification.js
+++ b/src/notification.js
@@ -21,7 +21,7 @@ class Notification {
throw new Error(`Notification must be created with string message: ${this.message}`)
}
- if (!_.isObject(this.options) || _.isArray(this.options)) {
+ if (!_.isObject(this.options) || Array.isArray(this.options)) {
throw new Error(`Notification must be created with an options object: ${this.options}`)
}
}
diff --git a/src/null-grammar.js b/src/null-grammar.js
index fe9c3889e..12cfbbe53 100644
--- a/src/null-grammar.js
+++ b/src/null-grammar.js
@@ -1,8 +1,6 @@
-/** @babel */
+const {Disposable} = require('event-kit')
-import {Disposable} from 'event-kit'
-
-export default {
+module.exports = {
name: 'Null Grammar',
scopeName: 'text.plain.null-grammar',
scopeForId (id) {
diff --git a/src/package-manager.js b/src/package-manager.js
index 17a5f2214..0c7943bca 100644
--- a/src/package-manager.js
+++ b/src/package-manager.js
@@ -61,6 +61,7 @@ module.exports = class PackageManager {
if (params.configDirPath != null && !params.safeMode) {
if (this.devMode) {
this.packageDirPaths.push(path.join(params.configDirPath, 'dev', 'packages'))
+ this.packageDirPaths.push(path.join(this.resourcePath, 'packages'))
}
this.packageDirPaths.push(path.join(params.configDirPath, 'packages'))
}
diff --git a/src/package.js b/src/package.js
index 8d5cbc3ca..bbcb0061f 100644
--- a/src/package.js
+++ b/src/package.js
@@ -43,8 +43,8 @@ class Package {
? params.bundledPackage
: this.packageManager.isBundledPackagePath(this.path)
this.name =
- params.name ||
(this.metadata && this.metadata.name) ||
+ params.name ||
path.basename(this.path)
this.reset()
}
diff --git a/src/pane-container-element.coffee b/src/pane-container-element.coffee
deleted file mode 100644
index 78e2fbad3..000000000
--- a/src/pane-container-element.coffee
+++ /dev/null
@@ -1,28 +0,0 @@
-{CompositeDisposable} = require 'event-kit'
-_ = require 'underscore-plus'
-
-module.exports =
-class PaneContainerElement extends HTMLElement
- createdCallback: ->
- @subscriptions = new CompositeDisposable
- @classList.add 'panes'
-
- initialize: (@model, {@views}) ->
- throw new Error("Must pass a views parameter when initializing PaneContainerElements") unless @views?
-
- @subscriptions.add @model.observeRoot(@rootChanged.bind(this))
- this
-
- rootChanged: (root) ->
- focusedElement = document.activeElement if @hasFocus()
- @firstChild?.remove()
- if root?
- view = @views.getView(root)
- @appendChild(view)
- focusedElement?.focus()
-
- hasFocus: ->
- this is document.activeElement or @contains(document.activeElement)
-
-
-module.exports = PaneContainerElement = document.registerElement 'atom-pane-container', prototype: PaneContainerElement.prototype
diff --git a/src/pane-container-element.js b/src/pane-container-element.js
new file mode 100644
index 000000000..7a2a88463
--- /dev/null
+++ b/src/pane-container-element.js
@@ -0,0 +1,40 @@
+const {CompositeDisposable} = require('event-kit')
+
+class PaneContainerElement extends HTMLElement {
+ createdCallback () {
+ this.subscriptions = new CompositeDisposable()
+ this.classList.add('panes')
+ }
+
+ initialize (model, {views}) {
+ this.model = model
+ this.views = views
+ if (this.views == null) {
+ throw new Error('Must pass a views parameter when initializing PaneContainerElements')
+ }
+ this.subscriptions.add(this.model.observeRoot(this.rootChanged.bind(this)))
+ return this
+ }
+
+ rootChanged (root) {
+ const focusedElement = this.hasFocus() ? document.activeElement : null
+ if (this.firstChild != null) {
+ this.firstChild.remove()
+ }
+ if (root != null) {
+ const view = this.views.getView(root)
+ this.appendChild(view)
+ if (focusedElement != null) {
+ focusedElement.focus()
+ }
+ }
+ }
+
+ hasFocus () {
+ return this === document.activeElement || this.contains(document.activeElement)
+ }
+}
+
+module.exports = document.registerElement('atom-pane-container', {
+ prototype: PaneContainerElement.prototype
+})
diff --git a/src/pane-container.js b/src/pane-container.js
index 25e57acc8..b14d6ea57 100644
--- a/src/pane-container.js
+++ b/src/pane-container.js
@@ -51,6 +51,7 @@ class PaneContainer {
deserialize (state, deserializerManager) {
if (state.version !== SERIALIZATION_VERSION) return
+ this.itemRegistry = new ItemRegistry()
this.setRoot(deserializerManager.deserialize(state.root))
this.activePane = find(this.getRoot().getPanes(), pane => pane.id === state.activePaneId) || this.getPanes()[0]
if (this.config.get('core.destroyEmptyPanes')) this.destroyEmptyPanes()
diff --git a/src/pane-element.coffee b/src/pane-element.coffee
deleted file mode 100644
index d68b3b834..000000000
--- a/src/pane-element.coffee
+++ /dev/null
@@ -1,139 +0,0 @@
-path = require 'path'
-{CompositeDisposable} = require 'event-kit'
-
-class PaneElement extends HTMLElement
- attached: false
-
- createdCallback: ->
- @attached = false
- @subscriptions = new CompositeDisposable
- @inlineDisplayStyles = new WeakMap
-
- @initializeContent()
- @subscribeToDOMEvents()
-
- attachedCallback: ->
- @attached = true
- @focus() if @model.isFocused()
-
- detachedCallback: ->
- @attached = false
-
- initializeContent: ->
- @setAttribute 'class', 'pane'
- @setAttribute 'tabindex', -1
- @appendChild @itemViews = document.createElement('div')
- @itemViews.setAttribute 'class', 'item-views'
-
- subscribeToDOMEvents: ->
- handleFocus = (event) =>
- @model.focus() unless @isActivating or @model.isDestroyed() or @contains(event.relatedTarget)
- if event.target is this and view = @getActiveView()
- view.focus()
- event.stopPropagation()
-
- handleBlur = (event) =>
- @model.blur() unless @contains(event.relatedTarget)
-
- handleDragOver = (event) ->
- event.preventDefault()
- event.stopPropagation()
-
- handleDrop = (event) =>
- event.preventDefault()
- event.stopPropagation()
- @getModel().activate()
- pathsToOpen = Array::map.call event.dataTransfer.files, (file) -> file.path
- @applicationDelegate.open({pathsToOpen}) if pathsToOpen.length > 0
-
- @addEventListener 'focus', handleFocus, true
- @addEventListener 'blur', handleBlur, true
- @addEventListener 'dragover', handleDragOver
- @addEventListener 'drop', handleDrop
-
- initialize: (@model, {@views, @applicationDelegate}) ->
- throw new Error("Must pass a views parameter when initializing PaneElements") unless @views?
- throw new Error("Must pass an applicationDelegate parameter when initializing PaneElements") unless @applicationDelegate?
-
- @subscriptions.add @model.onDidActivate(@activated.bind(this))
- @subscriptions.add @model.observeActive(@activeStatusChanged.bind(this))
- @subscriptions.add @model.observeActiveItem(@activeItemChanged.bind(this))
- @subscriptions.add @model.onDidRemoveItem(@itemRemoved.bind(this))
- @subscriptions.add @model.onDidDestroy(@paneDestroyed.bind(this))
- @subscriptions.add @model.observeFlexScale(@flexScaleChanged.bind(this))
- this
-
- getModel: -> @model
-
- activated: ->
- @isActivating = true
- @focus() unless @hasFocus() # Don't steal focus from children.
- @isActivating = false
-
- activeStatusChanged: (active) ->
- if active
- @classList.add('active')
- else
- @classList.remove('active')
-
- activeItemChanged: (item) ->
- delete @dataset.activeItemName
- delete @dataset.activeItemPath
- @changePathDisposable?.dispose()
-
- return unless item?
-
- hasFocus = @hasFocus()
- itemView = @views.getView(item)
-
- if itemPath = item.getPath?()
- @dataset.activeItemName = path.basename(itemPath)
- @dataset.activeItemPath = itemPath
-
- if item.onDidChangePath?
- @changePathDisposable = item.onDidChangePath =>
- itemPath = item.getPath()
- @dataset.activeItemName = path.basename(itemPath)
- @dataset.activeItemPath = itemPath
-
- unless @itemViews.contains(itemView)
- @itemViews.appendChild(itemView)
-
- for child in @itemViews.children
- if child is itemView
- @showItemView(child) if @attached
- else
- @hideItemView(child)
-
- itemView.focus() if hasFocus
-
- showItemView: (itemView) ->
- inlineDisplayStyle = @inlineDisplayStyles.get(itemView)
- if inlineDisplayStyle?
- itemView.style.display = inlineDisplayStyle
- else
- itemView.style.display = ''
-
- hideItemView: (itemView) ->
- inlineDisplayStyle = itemView.style.display
- unless inlineDisplayStyle is 'none'
- @inlineDisplayStyles.set(itemView, inlineDisplayStyle) if inlineDisplayStyle?
- itemView.style.display = 'none'
-
- itemRemoved: ({item, index, destroyed}) ->
- if viewToRemove = @views.getView(item)
- viewToRemove.remove()
-
- paneDestroyed: ->
- @subscriptions.dispose()
- @changePathDisposable?.dispose()
-
- flexScaleChanged: (flexScale) ->
- @style.flexGrow = flexScale
-
- getActiveView: -> @views.getView(@model.getActiveItem())
-
- hasFocus: ->
- this is document.activeElement or @contains(document.activeElement)
-
-module.exports = PaneElement = document.registerElement 'atom-pane', prototype: PaneElement.prototype
diff --git a/src/pane-element.js b/src/pane-element.js
new file mode 100644
index 000000000..2c9f4eeef
--- /dev/null
+++ b/src/pane-element.js
@@ -0,0 +1,218 @@
+const path = require('path')
+const {CompositeDisposable} = require('event-kit')
+
+class PaneElement extends HTMLElement {
+ createdCallback () {
+ this.attached = false
+ this.subscriptions = new CompositeDisposable()
+ this.inlineDisplayStyles = new WeakMap()
+ this.initializeContent()
+ this.subscribeToDOMEvents()
+ }
+
+ attachedCallback () {
+ this.attached = true
+ if (this.model.isFocused()) {
+ this.focus()
+ }
+ }
+
+ detachedCallback () {
+ this.attached = false
+ }
+
+ initializeContent () {
+ this.setAttribute('class', 'pane')
+ this.setAttribute('tabindex', -1)
+ this.itemViews = document.createElement('div')
+ this.appendChild(this.itemViews)
+ this.itemViews.setAttribute('class', 'item-views')
+ }
+
+ subscribeToDOMEvents () {
+ const handleFocus = event => {
+ if (
+ !(
+ this.isActivating ||
+ this.model.isDestroyed() ||
+ this.contains(event.relatedTarget)
+ )
+ ) {
+ this.model.focus()
+ }
+ if (event.target !== this) return
+ const view = this.getActiveView()
+ if (view) {
+ view.focus()
+ event.stopPropagation()
+ }
+ }
+ const handleBlur = event => {
+ if (!this.contains(event.relatedTarget)) {
+ this.model.blur()
+ }
+ }
+ const handleDragOver = event => {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ const handleDrop = event => {
+ event.preventDefault()
+ event.stopPropagation()
+ this.getModel().activate()
+ const pathsToOpen = [...event.dataTransfer.files].map(file => file.path)
+ if (pathsToOpen.length > 0) {
+ this.applicationDelegate.open({pathsToOpen})
+ }
+ }
+ this.addEventListener('focus', handleFocus, true)
+ this.addEventListener('blur', handleBlur, true)
+ this.addEventListener('dragover', handleDragOver)
+ this.addEventListener('drop', handleDrop)
+ }
+
+ initialize (model, {views, applicationDelegate}) {
+ this.model = model
+ this.views = views
+ this.applicationDelegate = applicationDelegate
+ if (this.views == null) {
+ throw new Error(
+ 'Must pass a views parameter when initializing PaneElements'
+ )
+ }
+ if (this.applicationDelegate == null) {
+ throw new Error(
+ 'Must pass an applicationDelegate parameter when initializing PaneElements'
+ )
+ }
+ this.subscriptions.add(this.model.onDidActivate(this.activated.bind(this)))
+ this.subscriptions.add(
+ this.model.observeActive(this.activeStatusChanged.bind(this))
+ )
+ this.subscriptions.add(
+ this.model.observeActiveItem(this.activeItemChanged.bind(this))
+ )
+ this.subscriptions.add(
+ this.model.onDidRemoveItem(this.itemRemoved.bind(this))
+ )
+ this.subscriptions.add(
+ this.model.onDidDestroy(this.paneDestroyed.bind(this))
+ )
+ this.subscriptions.add(
+ this.model.observeFlexScale(this.flexScaleChanged.bind(this))
+ )
+ return this
+ }
+
+ getModel () {
+ return this.model
+ }
+
+ activated () {
+ this.isActivating = true
+ if (!this.hasFocus()) {
+ // Don't steal focus from children.
+ this.focus()
+ }
+ this.isActivating = false
+ }
+
+ activeStatusChanged (active) {
+ if (active) {
+ this.classList.add('active')
+ } else {
+ this.classList.remove('active')
+ }
+ }
+
+ activeItemChanged (item) {
+ delete this.dataset.activeItemName
+ delete this.dataset.activeItemPath
+ if (this.changePathDisposable != null) {
+ this.changePathDisposable.dispose()
+ }
+ if (item == null) {
+ return
+ }
+ const hasFocus = this.hasFocus()
+ const itemView = this.views.getView(item)
+ const itemPath = typeof item.getPath === 'function' ? item.getPath() : null
+ if (itemPath) {
+ this.dataset.activeItemName = path.basename(itemPath)
+ this.dataset.activeItemPath = itemPath
+ if (item.onDidChangePath != null) {
+ this.changePathDisposable = item.onDidChangePath(() => {
+ const itemPath = item.getPath()
+ this.dataset.activeItemName = path.basename(itemPath)
+ this.dataset.activeItemPath = itemPath
+ })
+ }
+ }
+ if (!this.itemViews.contains(itemView)) {
+ this.itemViews.appendChild(itemView)
+ }
+ for (const child of this.itemViews.children) {
+ if (child === itemView) {
+ if (this.attached) {
+ this.showItemView(child)
+ }
+ } else {
+ this.hideItemView(child)
+ }
+ }
+ if (hasFocus) {
+ itemView.focus()
+ }
+ }
+
+ showItemView (itemView) {
+ const inlineDisplayStyle = this.inlineDisplayStyles.get(itemView)
+ if (inlineDisplayStyle != null) {
+ itemView.style.display = inlineDisplayStyle
+ } else {
+ itemView.style.display = ''
+ }
+ }
+
+ hideItemView (itemView) {
+ const inlineDisplayStyle = itemView.style.display
+ if (inlineDisplayStyle !== 'none') {
+ if (inlineDisplayStyle != null) {
+ this.inlineDisplayStyles.set(itemView, inlineDisplayStyle)
+ }
+ itemView.style.display = 'none'
+ }
+ }
+
+ itemRemoved ({item, index, destroyed}) {
+ const viewToRemove = this.views.getView(item)
+ if (viewToRemove) {
+ viewToRemove.remove()
+ }
+ }
+
+ paneDestroyed () {
+ this.subscriptions.dispose()
+ if (this.changePathDisposable != null) {
+ this.changePathDisposable.dispose()
+ }
+ }
+
+ flexScaleChanged (flexScale) {
+ this.style.flexGrow = flexScale
+ }
+
+ getActiveView () {
+ return this.views.getView(this.model.getActiveItem())
+ }
+
+ hasFocus () {
+ return (
+ this === document.activeElement || this.contains(document.activeElement)
+ )
+ }
+}
+
+module.exports = document.registerElement('atom-pane', {
+ prototype: PaneElement.prototype
+})
diff --git a/src/pane.js b/src/pane.js
index af93f8e1e..fca1260c4 100644
--- a/src/pane.js
+++ b/src/pane.js
@@ -155,9 +155,17 @@ class Pane {
getFlexScale () { return this.flexScale }
- increaseSize () { this.setFlexScale(this.getFlexScale() * 1.1) }
+ increaseSize () {
+ if (this.getContainer().getPanes().length > 1) {
+ this.setFlexScale(this.getFlexScale() * 1.1)
+ }
+ }
- decreaseSize () { this.setFlexScale(this.getFlexScale() / 1.1) }
+ decreaseSize () {
+ if (this.getContainer().getPanes().length > 1) {
+ this.setFlexScale(this.getFlexScale() / 1.1)
+ }
+ }
/*
Section: Event Subscription
@@ -606,15 +614,15 @@ class Pane {
if (this.items.includes(item)) return
+ const itemSubscriptions = new CompositeDisposable()
+ this.subscriptionsPerItem.set(item, itemSubscriptions)
if (typeof item.onDidDestroy === 'function') {
- const itemSubscriptions = new CompositeDisposable()
itemSubscriptions.add(item.onDidDestroy(() => this.removeItem(item, false)))
- if (typeof item.onDidTerminatePendingState === 'function') {
- itemSubscriptions.add(item.onDidTerminatePendingState(() => {
- if (this.getPendingItem() === item) this.clearPendingItem()
- }))
- }
- this.subscriptionsPerItem.set(item, itemSubscriptions)
+ }
+ if (typeof item.onDidTerminatePendingState === 'function') {
+ itemSubscriptions.add(item.onDidTerminatePendingState(() => {
+ if (this.getPendingItem() === item) this.clearPendingItem()
+ }))
}
this.items.splice(index, 0, item)
diff --git a/src/path-watcher.js b/src/path-watcher.js
index d0ff90dd1..6693489ef 100644
--- a/src/path-watcher.js
+++ b/src/path-watcher.js
@@ -1,10 +1,9 @@
-/** @babel */
-
const fs = require('fs')
const path = require('path')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const nsfw = require('@atom/nsfw')
+const watcher = require('@atom/watcher')
const {NativeWatcherRegistry} = require('./native-watcher-registry')
// Private: Associate native watcher action flags with descriptive String equivalents.
@@ -23,145 +22,7 @@ const WATCHER_STATE = {
STOPPING: Symbol('stopping')
}
-// Private: Emulate a "filesystem watcher" by subscribing to Atom events like buffers being saved. This will miss
-// any changes made to files outside of Atom, but it also has no overhead.
-class AtomBackend {
- async start (rootPath, eventCallback, errorCallback) {
- const getRealPath = givenPath => {
- return new Promise(resolve => {
- fs.realpath(givenPath, (err, resolvedPath) => {
- err ? resolve(null) : resolve(resolvedPath)
- })
- })
- }
-
- this.subs = new CompositeDisposable()
-
- this.subs.add(atom.workspace.observeTextEditors(async editor => {
- let realPath = await getRealPath(editor.getPath())
- if (!realPath || !realPath.startsWith(rootPath)) {
- return
- }
-
- const announce = (action, oldPath) => {
- const payload = {action, path: realPath}
- if (oldPath) payload.oldPath = oldPath
- eventCallback([payload])
- }
-
- const buffer = editor.getBuffer()
-
- this.subs.add(buffer.onDidConflict(() => announce('modified')))
- this.subs.add(buffer.onDidReload(() => announce('modified')))
- this.subs.add(buffer.onDidSave(event => {
- if (event.path === realPath) {
- announce('modified')
- } else {
- const oldPath = realPath
- realPath = event.path
- announce('renamed', oldPath)
- }
- }))
-
- this.subs.add(buffer.onDidDelete(() => announce('deleted')))
-
- this.subs.add(buffer.onDidChangePath(newPath => {
- if (newPath !== realPath) {
- const oldPath = realPath
- realPath = newPath
- announce('renamed', oldPath)
- }
- }))
- }))
-
- // Giant-ass brittle hack to hook files (and eventually directories) created from the TreeView.
- const treeViewPackage = await atom.packages.getLoadedPackage('tree-view')
- if (!treeViewPackage) return
- await treeViewPackage.activationPromise
- const treeViewModule = treeViewPackage.mainModule
- if (!treeViewModule) return
- const treeView = treeViewModule.getTreeViewInstance()
-
- const isOpenInEditor = async eventPath => {
- const openPaths = await Promise.all(
- atom.workspace.getTextEditors().map(editor => getRealPath(editor.getPath()))
- )
- return openPaths.includes(eventPath)
- }
-
- this.subs.add(treeView.onFileCreated(async event => {
- const realPath = await getRealPath(event.path)
- if (!realPath) return
-
- eventCallback([{action: 'added', path: realPath}])
- }))
-
- this.subs.add(treeView.onEntryDeleted(async event => {
- const realPath = await getRealPath(event.path)
- if (!realPath || isOpenInEditor(realPath)) return
-
- eventCallback([{action: 'deleted', path: realPath}])
- }))
-
- this.subs.add(treeView.onEntryMoved(async event => {
- const [realNewPath, realOldPath] = await Promise.all([
- getRealPath(event.newPath),
- getRealPath(event.initialPath)
- ])
- if (!realNewPath || !realOldPath || isOpenInEditor(realNewPath) || isOpenInEditor(realOldPath)) return
-
- eventCallback([{action: 'renamed', path: realNewPath, oldPath: realOldPath}])
- }))
- }
-
- async stop () {
- this.subs && this.subs.dispose()
- }
-}
-
-// Private: Implement a native watcher by translating events from an NSFW watcher.
-class NSFWBackend {
- async start (rootPath, eventCallback, errorCallback) {
- const handler = events => {
- eventCallback(events.map(event => {
- const action = ACTION_MAP.get(event.action) || `unexpected (${event.action})`
- const payload = {action}
-
- if (event.file) {
- payload.path = path.join(event.directory, event.file)
- } else {
- payload.oldPath = path.join(event.directory, event.oldFile)
- payload.path = path.join(event.directory, event.newFile)
- }
-
- return payload
- }))
- }
-
- this.watcher = await nsfw(
- rootPath,
- handler,
- {debounceMS: 100, errorCallback}
- )
-
- await this.watcher.start()
- }
-
- stop () {
- return this.watcher.stop()
- }
-}
-
-// Private: Map configuration settings from the feature flag to backend implementations.
-const BACKENDS = {
- atom: AtomBackend,
- native: NSFWBackend
-}
-
-// Private: the backend implementation to fall back to if the config setting is invalid.
-const DEFAULT_BACKEND = BACKENDS.nsfw
-
-// Private: Interface with and normalize events from a native OS filesystem watcher.
+// Private: Interface with and normalize events from a filesystem watcher implementation.
class NativeWatcher {
// Private: Initialize a native watcher on a path.
@@ -172,37 +33,10 @@ class NativeWatcher {
this.emitter = new Emitter()
this.subs = new CompositeDisposable()
- this.backend = null
this.state = WATCHER_STATE.STOPPED
this.onEvents = this.onEvents.bind(this)
this.onError = this.onError.bind(this)
-
- this.subs.add(atom.config.onDidChange('core.fileSystemWatcher', async () => {
- if (this.state === WATCHER_STATE.STARTING) {
- // Wait for this watcher to finish starting.
- await new Promise(resolve => {
- const sub = this.onDidStart(() => {
- sub.dispose()
- resolve()
- })
- })
- }
-
- // Re-read the config setting in case it's changed again while we were waiting for the watcher
- // to start.
- const Backend = this.getCurrentBackend()
- if (this.state === WATCHER_STATE.RUNNING && !(this.backend instanceof Backend)) {
- await this.stop()
- await this.start()
- }
- }))
- }
-
- // Private: Read the `core.fileSystemWatcher` setting to determine the filesystem backend to use.
- getCurrentBackend () {
- const setting = atom.config.get('core.fileSystemWatcher')
- return BACKENDS[setting] || DEFAULT_BACKEND
}
// Private: Begin watching for filesystem events.
@@ -214,15 +48,16 @@ class NativeWatcher {
}
this.state = WATCHER_STATE.STARTING
- const Backend = this.getCurrentBackend()
-
- this.backend = new Backend()
- await this.backend.start(this.normalizedPath, this.onEvents, this.onError)
+ await this.doStart()
this.state = WATCHER_STATE.RUNNING
this.emitter.emit('did-start')
}
+ doStart () {
+ return Promise.reject('doStart() not overridden')
+ }
+
// Private: Return true if the underlying watcher is actively listening for filesystem events.
isRunning () {
return this.state === WATCHER_STATE.RUNNING
@@ -285,8 +120,8 @@ class NativeWatcher {
//
// * `replacement` the new {NativeWatcher} instance that a live {Watcher} instance should reattach to instead.
// * `watchedPath` absolute path watched by the new {NativeWatcher}.
- reattachTo (replacement, watchedPath) {
- this.emitter.emit('should-detach', {replacement, watchedPath})
+ reattachTo (replacement, watchedPath, options) {
+ this.emitter.emit('should-detach', {replacement, watchedPath, options})
}
// Private: Stop the native watcher and release any operating system resources associated with it.
@@ -299,12 +134,17 @@ class NativeWatcher {
this.state = WATCHER_STATE.STOPPING
this.emitter.emit('will-stop')
- await this.backend.stop()
+ await this.doStop()
+
this.state = WATCHER_STATE.STOPPED
this.emitter.emit('did-stop')
}
+ doStop () {
+ return Promise.resolve()
+ }
+
// Private: Detach any event subscribers.
dispose () {
this.emitter.dispose()
@@ -326,6 +166,133 @@ class NativeWatcher {
}
}
+// Private: Emulate a "filesystem watcher" by subscribing to Atom events like buffers being saved. This will miss
+// any changes made to files outside of Atom, but it also has no overhead.
+class AtomNativeWatcher extends NativeWatcher {
+ async doStart () {
+ const getRealPath = givenPath => {
+ if (!givenPath) {
+ return Promise.resolve(null)
+ }
+
+ return new Promise(resolve => {
+ fs.realpath(givenPath, (err, resolvedPath) => {
+ err ? resolve(null) : resolve(resolvedPath)
+ })
+ })
+ }
+
+ this.subs.add(atom.workspace.observeTextEditors(async editor => {
+ let realPath = await getRealPath(editor.getPath())
+ if (!realPath || !realPath.startsWith(this.normalizedPath)) {
+ return
+ }
+
+ const announce = (action, oldPath) => {
+ const payload = {action, path: realPath}
+ if (oldPath) payload.oldPath = oldPath
+ this.onEvents([payload])
+ }
+
+ const buffer = editor.getBuffer()
+
+ this.subs.add(buffer.onDidConflict(() => announce('modified')))
+ this.subs.add(buffer.onDidReload(() => announce('modified')))
+ this.subs.add(buffer.onDidSave(event => {
+ if (event.path === realPath) {
+ announce('modified')
+ } else {
+ const oldPath = realPath
+ realPath = event.path
+ announce('renamed', oldPath)
+ }
+ }))
+
+ this.subs.add(buffer.onDidDelete(() => announce('deleted')))
+
+ this.subs.add(buffer.onDidChangePath(newPath => {
+ if (newPath !== this.normalizedPath) {
+ const oldPath = this.normalizedPath
+ this.normalizedPath = newPath
+ announce('renamed', oldPath)
+ }
+ }))
+ }))
+
+ // Giant-ass brittle hack to hook files (and eventually directories) created from the TreeView.
+ const treeViewPackage = await atom.packages.getLoadedPackage('tree-view')
+ if (!treeViewPackage) return
+ await treeViewPackage.activationPromise
+ const treeViewModule = treeViewPackage.mainModule
+ if (!treeViewModule) return
+ const treeView = treeViewModule.getTreeViewInstance()
+
+ const isOpenInEditor = async eventPath => {
+ const openPaths = await Promise.all(
+ atom.workspace.getTextEditors().map(editor => getRealPath(editor.getPath()))
+ )
+ return openPaths.includes(eventPath)
+ }
+
+ this.subs.add(treeView.onFileCreated(async event => {
+ const realPath = await getRealPath(event.path)
+ if (!realPath) return
+
+ this.onEvents([{action: 'added', path: realPath}])
+ }))
+
+ this.subs.add(treeView.onEntryDeleted(async event => {
+ const realPath = await getRealPath(event.path)
+ if (!realPath || await isOpenInEditor(realPath)) return
+
+ this.onEvents([{action: 'deleted', path: realPath}])
+ }))
+
+ this.subs.add(treeView.onEntryMoved(async event => {
+ const [realNewPath, realOldPath] = await Promise.all([
+ getRealPath(event.newPath),
+ getRealPath(event.initialPath)
+ ])
+ if (!realNewPath || !realOldPath || await isOpenInEditor(realNewPath) || await isOpenInEditor(realOldPath)) return
+
+ this.onEvents([{action: 'renamed', path: realNewPath, oldPath: realOldPath}])
+ }))
+ }
+}
+
+// Private: Implement a native watcher by translating events from an NSFW watcher.
+class NSFWNativeWatcher extends NativeWatcher {
+ async doStart (rootPath, eventCallback, errorCallback) {
+ const handler = events => {
+ this.onEvents(events.map(event => {
+ const action = ACTION_MAP.get(event.action) || `unexpected (${event.action})`
+ const payload = {action}
+
+ if (event.file) {
+ payload.path = path.join(event.directory, event.file)
+ } else {
+ payload.oldPath = path.join(event.directory, event.oldFile)
+ payload.path = path.join(event.directory, event.newFile)
+ }
+
+ return payload
+ }))
+ }
+
+ this.watcher = await nsfw(
+ this.normalizedPath,
+ handler,
+ {debounceMS: 100, errorCallback: this.onError}
+ )
+
+ await this.watcher.start()
+ }
+
+ doStop () {
+ return this.watcher.stop()
+ }
+}
+
// Extended: Manage a subscription to filesystem events that occur beneath a root directory. Construct these by
// calling `watchPath`. To watch for events within active project directories, use {Project::onDidChangeFiles}
// instead.
@@ -386,6 +353,15 @@ class PathWatcher {
this.native = null
this.changeCallbacks = new Map()
+ this.attachedPromise = new Promise(resolve => {
+ this.resolveAttachedPromise = resolve
+ })
+
+ this.startPromise = new Promise((resolve, reject) => {
+ this.resolveStartPromise = resolve
+ this.rejectStartPromise = reject
+ })
+
this.normalizedPathPromise = new Promise((resolve, reject) => {
fs.realpath(watchedPath, (err, real) => {
if (err) {
@@ -397,13 +373,7 @@ class PathWatcher {
resolve(real)
})
})
-
- this.attachedPromise = new Promise(resolve => {
- this.resolveAttachedPromise = resolve
- })
- this.startPromise = new Promise(resolve => {
- this.resolveStartPromise = resolve
- })
+ this.normalizedPathPromise.catch(err => this.rejectStartPromise(err))
this.emitter = new Emitter()
this.subs = new CompositeDisposable()
@@ -526,7 +496,29 @@ class PathWatcher {
// events may include events for paths above this watcher's root path, so filter them to only include the relevant
// ones, then re-broadcast them to our subscribers.
onNativeEvents (events, callback) {
- const filtered = events.filter(event => event.path.startsWith(this.normalizedPath))
+ const isWatchedPath = eventPath => eventPath.startsWith(this.normalizedPath)
+
+ const filtered = []
+ for (let i = 0; i < events.length; i++) {
+ const event = events[i]
+
+ if (event.action === 'renamed') {
+ const srcWatched = isWatchedPath(event.oldPath)
+ const destWatched = isWatchedPath(event.path)
+
+ if (srcWatched && destWatched) {
+ filtered.push(event)
+ } else if (srcWatched && !destWatched) {
+ filtered.push({action: 'deleted', kind: event.kind, path: event.oldPath})
+ } else if (!srcWatched && destWatched) {
+ filtered.push({action: 'created', kind: event.kind, path: event.path})
+ }
+ } else {
+ if (isWatchedPath(event.path)) {
+ filtered.push(event)
+ }
+ }
+ }
if (filtered.length > 0) {
callback(filtered)
@@ -545,46 +537,139 @@ class PathWatcher {
}
}
-// Private: Globally tracked state used to de-duplicate related [PathWatchers]{PathWatcher}.
+// Private: Globally tracked state used to de-duplicate related [PathWatchers]{PathWatcher} backed by emulated Atom
+// events or NSFW.
class PathWatcherManager {
- // Private: Access or lazily initialize the singleton manager instance.
- //
- // Returns the one and only {PathWatcherManager}.
- static instance () {
- if (!PathWatcherManager.theManager) {
- PathWatcherManager.theManager = new PathWatcherManager()
+ // Private: Access the currently active manager instance, creating one if necessary.
+ static active () {
+ if (!this.activeManager) {
+ this.activeManager = new PathWatcherManager(atom.config.get('core.fileSystemWatcher'))
+ this.sub = atom.config.onDidChange('core.fileSystemWatcher', ({newValue}) => { this.transitionTo(newValue) })
}
- return PathWatcherManager.theManager
+ return this.activeManager
+ }
+
+ // Private: Replace the active {PathWatcherManager} with a new one that creates [NativeWatchers]{NativeWatcher}
+ // based on the value of `setting`.
+ static async transitionTo (setting) {
+ const current = this.active()
+
+ if (this.transitionPromise) {
+ await this.transitionPromise
+ }
+
+ if (current.setting === setting) {
+ return
+ }
+ current.isShuttingDown = true
+
+ let resolveTransitionPromise = () => {}
+ this.transitionPromise = new Promise(resolve => {
+ resolveTransitionPromise = resolve
+ })
+
+ const replacement = new PathWatcherManager(setting)
+ this.activeManager = replacement
+
+ await Promise.all(
+ Array.from(current.live, async ([root, native]) => {
+ const w = await replacement.createWatcher(root, {}, () => {})
+ native.reattachTo(w.native, root, w.native.options || {})
+ })
+ )
+
+ current.stopAllWatchers()
+
+ resolveTransitionPromise()
+ this.transitionPromise = null
}
// Private: Initialize global {PathWatcher} state.
- constructor () {
- this.live = new Set()
- this.nativeRegistry = new NativeWatcherRegistry(
- normalizedPath => {
- const nativeWatcher = new NativeWatcher(normalizedPath)
+ constructor (setting) {
+ this.setting = setting
+ this.live = new Map()
- this.live.add(nativeWatcher)
- const sub = nativeWatcher.onWillStop(() => {
- this.live.delete(nativeWatcher)
- sub.dispose()
- })
+ const initLocal = NativeConstructor => {
+ this.nativeRegistry = new NativeWatcherRegistry(
+ normalizedPath => {
+ const nativeWatcher = new NativeConstructor(normalizedPath)
- return nativeWatcher
- }
- )
+ this.live.set(normalizedPath, nativeWatcher)
+ const sub = nativeWatcher.onWillStop(() => {
+ this.live.delete(normalizedPath)
+ sub.dispose()
+ })
+
+ return nativeWatcher
+ }
+ )
+ }
+
+ if (setting === 'atom') {
+ initLocal(AtomNativeWatcher)
+ } else if (setting === 'experimental') {
+ //
+ } else if (setting === 'poll') {
+ //
+ } else {
+ initLocal(NSFWNativeWatcher)
+ }
+
+ this.isShuttingDown = false
+ }
+
+ useExperimentalWatcher () {
+ return this.setting === 'experimental' || this.setting === 'poll'
}
// Private: Create a {PathWatcher} tied to this global state. See {watchPath} for detailed arguments.
- createWatcher (rootPath, options, eventCallback) {
- const watcher = new PathWatcher(this.nativeRegistry, rootPath, options)
- watcher.onDidChange(eventCallback)
- return watcher
+ async createWatcher (rootPath, options, eventCallback) {
+ if (this.isShuttingDown) {
+ await this.constructor.transitionPromise
+ return PathWatcherManager.active().createWatcher(rootPath, options, eventCallback)
+ }
+
+ if (this.useExperimentalWatcher()) {
+ if (this.setting === 'poll') {
+ options.poll = true
+ }
+
+ const w = await watcher.watchPath(rootPath, options, eventCallback)
+ this.live.set(rootPath, w.native)
+ return w
+ }
+
+ const w = new PathWatcher(this.nativeRegistry, rootPath, options)
+ w.onDidChange(eventCallback)
+ await w.getStartPromise()
+ return w
+ }
+
+ // Private: Directly access the {NativeWatcherRegistry}.
+ getRegistry () {
+ if (this.useExperimentalWatcher()) {
+ return watcher.getRegistry()
+ }
+
+ return this.nativeRegistry
+ }
+
+ // Private: Sample watcher usage statistics. Only available for experimental watchers.
+ status () {
+ if (this.useExperimentalWatcher()) {
+ return watcher.status()
+ }
+
+ return {}
}
// Private: Return a {String} depicting the currently active native watchers.
print () {
+ if (this.useExperimentalWatcher()) {
+ return watcher.printWatchers()
+ }
+
return this.nativeRegistry.print()
}
@@ -592,8 +677,12 @@ class PathWatcherManager {
//
// Returns a {Promise} that resolves when all native watcher resources are disposed.
stopAllWatchers () {
+ if (this.useExperimentalWatcher()) {
+ return watcher.stopAllWatchers()
+ }
+
return Promise.all(
- Array.from(this.live, watcher => watcher.stop())
+ Array.from(this.live, ([, w]) => w.stop())
)
}
}
@@ -638,19 +727,33 @@ class PathWatcherManager {
// ```
//
function watchPath (rootPath, options, eventCallback) {
- const watcher = PathWatcherManager.instance().createWatcher(rootPath, options, eventCallback)
- return watcher.getStartPromise().then(() => watcher)
+ return PathWatcherManager.active().createWatcher(rootPath, options, eventCallback)
}
// Private: Return a Promise that resolves when all {NativeWatcher} instances associated with a FileSystemManager
// have stopped listening. This is useful for `afterEach()` blocks in unit tests.
function stopAllWatchers () {
- return PathWatcherManager.instance().stopAllWatchers()
+ return PathWatcherManager.active().stopAllWatchers()
}
-// Private: Show the currently active native watchers.
-function printWatchers () {
- return PathWatcherManager.instance().print()
+// Private: Show the currently active native watchers in a formatted {String}.
+watchPath.printWatchers = function () {
+ return PathWatcherManager.active().print()
}
-module.exports = {watchPath, stopAllWatchers, printWatchers}
+// Private: Access the active {NativeWatcherRegistry}.
+watchPath.getRegistry = function () {
+ return PathWatcherManager.active().getRegistry()
+}
+
+// Private: Sample usage statistics for the active watcher.
+watchPath.status = function () {
+ return PathWatcherManager.active().status()
+}
+
+// Private: Configure @atom/watcher ("experimental") directly.
+watchPath.configure = function (...args) {
+ return watcher.configure(...args)
+}
+
+module.exports = {watchPath, stopAllWatchers}
diff --git a/src/project.js b/src/project.js
index 8de92b97e..8ccf60c0b 100644
--- a/src/project.js
+++ b/src/project.js
@@ -77,6 +77,31 @@ class Project extends Model {
}
}
+ // Layers the contents of a project's file's config
+ // on top of the current global config.
+ replace (projectSpecification) {
+ if (projectSpecification == null) {
+ atom.config.clearProjectSettings()
+ this.setPaths([])
+ } else {
+ if (projectSpecification.originPath == null) {
+ return
+ }
+
+ // If no path is specified, set to directory of originPath.
+ if (!Array.isArray(projectSpecification.paths)) {
+ projectSpecification.paths = [path.dirname(projectSpecification.originPath)]
+ }
+ atom.config.resetProjectSettings(projectSpecification.config, projectSpecification.originPath)
+ this.setPaths(projectSpecification.paths)
+ }
+ this.emitter.emit('did-replace', projectSpecification)
+ }
+
+ onDidReplace (callback) {
+ return this.emitter.on('did-replace', callback)
+ }
+
/*
Section: Serialization
*/
@@ -174,7 +199,7 @@ class Project extends Model {
// const disposable = atom.project.onDidChangeFiles(events => {
// for (const event of events) {
// // "created", "modified", "deleted", or "renamed"
- // console.log(`Event action: ${event.type}`)
+ // console.log(`Event action: ${event.action}`)
//
// // absolute path to the filesystem entry that was touched
// console.log(`Event path: ${event.path}`)
@@ -191,7 +216,7 @@ class Project extends Model {
// To watch paths outside of open projects, use the `watchPaths` function instead; see {PathWatcher}.
//
// When writing tests against functionality that uses this method, be sure to wait for the
- // {Promise} returned by {getWatcherPromise()} before manipulating the filesystem to ensure that
+ // {Promise} returned by {::getWatcherPromise} before manipulating the filesystem to ensure that
// the watcher is receiving events.
//
// * `callback` {Function} to be called with batches of filesystem events reported by
@@ -209,6 +234,38 @@ class Project extends Model {
return this.emitter.on('did-change-files', callback)
}
+ // Public: Invoke the given callback with all current and future
+ // repositories in the project.
+ //
+ // * `callback` {Function} to be called with current and future
+ // repositories.
+ // * `repository` A {GitRepository} that is present at the time of
+ // subscription or that is added at some later time.
+ //
+ // Returns a {Disposable} on which `.dispose()` can be called to
+ // unsubscribe.
+ observeRepositories (callback) {
+ for (const repo of this.repositories) {
+ if (repo != null) {
+ callback(repo)
+ }
+ }
+
+ return this.onDidAddRepository(callback)
+ }
+
+ // Public: Invoke the given callback when a repository is added to the
+ // project.
+ //
+ // * `callback` {Function} to be called when a repository is added.
+ // * `repository` A {GitRepository}.
+ //
+ // Returns a {Disposable} on which `.dispose()` can be called to
+ // unsubscribe.
+ onDidAddRepository (callback) {
+ return this.emitter.on('did-add-repository', callback)
+ }
+
/*
Section: Accessing the git repository
*/
@@ -218,7 +275,7 @@ class Project extends Model {
//
// This method will be removed in 2.0 because it does synchronous I/O.
// Prefer the following, which evaluates to a {Promise} that resolves to an
- // {Array} of {Repository} objects:
+ // {Array} of {GitRepository} objects:
// ```
// Promise.all(atom.project.getDirectories().map(
// atom.project.repositoryForDirectory.bind(atom.project)))
@@ -229,10 +286,10 @@ class Project extends Model {
// Public: Get the repository for a given directory asynchronously.
//
- // * `directory` {Directory} for which to get a {Repository}.
+ // * `directory` {Directory} for which to get a {GitRepository}.
//
// Returns a {Promise} that resolves with either:
- // * {Repository} if a repository can be created for the given directory
+ // * {GitRepository} if a repository can be created for the given directory
// * `null` if no repository can be created for the given directory.
repositoryForDirectory (directory) {
const pathForDirectory = directory.getRealPathSync()
@@ -323,7 +380,6 @@ class Project extends Model {
// a file or does not exist, its parent directory will be added instead.
addPath (projectPath, options = {}) {
const directory = this.getDirectoryForProjectPath(projectPath)
-
let ok = true
if (options.exact === true) {
ok = (directory.getPath() === projectPath)
@@ -353,6 +409,7 @@ class Project extends Model {
this.emitter.emit('did-change-files', events)
}
}
+
// We'll use the directory's custom onDidChangeFiles callback, if available.
// CustomDirectory::onDidChangeFiles should match the signature of
// Project::onDidChangeFiles below (although it may resolve asynchronously)
@@ -375,6 +432,9 @@ class Project extends Model {
if (repo) { break }
}
this.repositories.push(repo != null ? repo : null)
+ if (repo != null) {
+ this.emitter.emit('did-add-repository', repo)
+ }
if (options.emitEvent !== false) {
this.emitter.emit('did-change-paths', this.getPaths())
@@ -637,27 +697,32 @@ class Project extends Model {
// * `text` The {String} text to use as a buffer.
//
// Returns a {Promise} that resolves to the {TextBuffer}.
- buildBuffer (absoluteFilePath) {
+ async buildBuffer (absoluteFilePath) {
const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}
- let promise
+ let buffer
if (absoluteFilePath != null) {
if (this.loadPromisesByPath[absoluteFilePath] == null) {
this.loadPromisesByPath[absoluteFilePath] =
- TextBuffer.load(absoluteFilePath, params).catch(error => {
- delete this.loadPromisesByPath[absoluteFilePath]
- throw error
- })
+ TextBuffer.load(absoluteFilePath, params)
+ .then(result => {
+ delete this.loadPromisesByPath[absoluteFilePath]
+ return result
+ })
+ .catch(error => {
+ delete this.loadPromisesByPath[absoluteFilePath]
+ throw error
+ })
}
- promise = this.loadPromisesByPath[absoluteFilePath]
+ buffer = await this.loadPromisesByPath[absoluteFilePath]
} else {
- promise = Promise.resolve(new TextBuffer(params))
+ buffer = new TextBuffer(params)
}
- return promise.then(buffer => {
- delete this.loadPromisesByPath[absoluteFilePath]
- this.addBuffer(buffer)
- return buffer
- })
+
+ this.grammarRegistry.autoAssignLanguageMode(buffer)
+
+ this.addBuffer(buffer)
+ return buffer
}
addBuffer (buffer, options = {}) {
@@ -695,7 +760,7 @@ class Project extends Model {
}
subscribeToBuffer (buffer) {
- buffer.onWillSave(({path}) => this.applicationDelegate.emitWillSavePath(path))
+ buffer.onWillSave(async ({path}) => this.applicationDelegate.emitWillSavePath(path))
buffer.onDidSave(({path}) => this.applicationDelegate.emitDidSavePath(path))
buffer.onDidDestroy(() => this.removeBuffer(buffer))
buffer.onDidChangePath(() => {
diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee
index a367e6188..badae227c 100644
--- a/src/register-default-commands.coffee
+++ b/src/register-default-commands.coffee
@@ -122,8 +122,6 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
commandRegistry.add(
'atom-text-editor',
stopEventPropagation({
- 'core:undo': -> @undo()
- 'core:redo': -> @redo()
'core:move-left': -> @moveLeft()
'core:move-right': -> @moveRight()
'core:select-left': -> @selectLeft()
@@ -166,15 +164,35 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
false
)
+ commandRegistry.add(
+ 'atom-text-editor:not([readonly])',
+ stopEventPropagation({
+ 'core:undo': -> @undo()
+ 'core:redo': -> @redo()
+ }),
+ false
+ )
+
commandRegistry.add(
'atom-text-editor',
+ stopEventPropagationAndGroupUndo(
+ config,
+ {
+ 'core:copy': -> @copySelectedText()
+ 'editor:copy-selection': -> @copyOnlySelectedText()
+ }
+ ),
+ false
+ )
+
+ commandRegistry.add(
+ 'atom-text-editor:not([readonly])',
stopEventPropagationAndGroupUndo(
config,
{
'core:backspace': -> @backspace()
'core:delete': -> @delete()
'core:cut': -> @cutSelectedText()
- 'core:copy': -> @copySelectedText()
'core:paste': -> @pasteText()
'editor:paste-without-reformatting': -> @pasteText({
normalizeLineEndings: false,
@@ -195,7 +213,6 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
'editor:transpose': -> @transpose()
'editor:upper-case': -> @upperCase()
'editor:lower-case': -> @lowerCase()
- 'editor:copy-selection': -> @copyOnlySelectedText()
}
),
false
@@ -266,7 +283,7 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage
)
commandRegistry.add(
- 'atom-text-editor:not([mini])',
+ 'atom-text-editor:not([mini]):not([readonly])',
stopEventPropagationAndGroupUndo(
config,
{
diff --git a/src/reopen-project-list-view.js b/src/reopen-project-list-view.js
index f08ee725a..d59577684 100644
--- a/src/reopen-project-list-view.js
+++ b/src/reopen-project-list-view.js
@@ -1,8 +1,7 @@
-/** @babel */
+const SelectListView = require('atom-select-list')
-import SelectListView from 'atom-select-list'
-
-export default class ReopenProjectListView {
+module.exports =
+class ReopenProjectListView {
constructor (callback) {
this.callback = callback
this.selectListView = new SelectListView({
diff --git a/src/reopen-project-menu-manager.js b/src/reopen-project-menu-manager.js
index 3f88e41f0..35564f705 100644
--- a/src/reopen-project-menu-manager.js
+++ b/src/reopen-project-menu-manager.js
@@ -1,9 +1,8 @@
-/** @babel */
+const {CompositeDisposable} = require('event-kit')
+const path = require('path')
-import {CompositeDisposable} from 'event-kit'
-import path from 'path'
-
-export default class ReopenProjectMenuManager {
+module.exports =
+class ReopenProjectMenuManager {
constructor ({menu, commands, history, config, open}) {
this.menuManager = menu
this.historyManager = history
diff --git a/src/scope-descriptor.coffee b/src/scope-descriptor.coffee
deleted file mode 100644
index 2085bd6b2..000000000
--- a/src/scope-descriptor.coffee
+++ /dev/null
@@ -1,63 +0,0 @@
-# Extended: Wraps an {Array} of `String`s. The Array describes a path from the
-# root of the syntax tree to a token including _all_ scope names for the entire
-# path.
-#
-# Methods that take a `ScopeDescriptor` will also accept an {Array} of {Strings}
-# scope names e.g. `['.source.js']`.
-#
-# You can use `ScopeDescriptor`s to get language-specific config settings via
-# {Config::get}.
-#
-# You should not need to create a `ScopeDescriptor` directly.
-#
-# * {TextEditor::getRootScopeDescriptor} to get the language's descriptor.
-# * {TextEditor::scopeDescriptorForBufferPosition} to get the descriptor at a
-# specific position in the buffer.
-# * {Cursor::getScopeDescriptor} to get a cursor's descriptor based on position.
-#
-# See the [scopes and scope descriptor guide](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
-# for more information.
-module.exports =
-class ScopeDescriptor
- @fromObject: (scopes) ->
- if scopes instanceof ScopeDescriptor
- scopes
- else
- new ScopeDescriptor({scopes})
-
- ###
- Section: Construction and Destruction
- ###
-
- # Public: Create a {ScopeDescriptor} object.
- #
- # * `object` {Object}
- # * `scopes` {Array} of {String}s
- constructor: ({@scopes}) ->
-
- # Public: Returns an {Array} of {String}s
- getScopesArray: -> @scopes
-
- getScopeChain: ->
- # For backward compatibility, prefix TextMate-style scope names with
- # leading dots (e.g. 'source.js' -> '.source.js').
- if @scopes[0].includes('.')
- result = ''
- for scope, i in @scopes
- result += ' ' if i > 0
- result += '.' if scope[0] isnt '.'
- result += scope
- result
- else
- @scopes.join(' ')
-
- toString: ->
- @getScopeChain()
-
- isEqual: (other) ->
- if @scopes.length isnt other.scopes.length
- return false
- for scope, i in @scopes
- if scope isnt other.scopes[i]
- return false
- true
diff --git a/src/scope-descriptor.js b/src/scope-descriptor.js
new file mode 100644
index 000000000..63075e8a1
--- /dev/null
+++ b/src/scope-descriptor.js
@@ -0,0 +1,80 @@
+// Extended: Wraps an {Array} of `String`s. The Array describes a path from the
+// root of the syntax tree to a token including _all_ scope names for the entire
+// path.
+//
+// Methods that take a `ScopeDescriptor` will also accept an {Array} of {String}
+// scope names e.g. `['.source.js']`.
+//
+// You can use `ScopeDescriptor`s to get language-specific config settings via
+// {Config::get}.
+//
+// You should not need to create a `ScopeDescriptor` directly.
+//
+// * {TextEditor::getRootScopeDescriptor} to get the language's descriptor.
+// * {TextEditor::scopeDescriptorForBufferPosition} to get the descriptor at a
+// specific position in the buffer.
+// * {Cursor::getScopeDescriptor} to get a cursor's descriptor based on position.
+//
+// See the [scopes and scope descriptor guide](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
+// for more information.
+module.exports =
+class ScopeDescriptor {
+ static fromObject (scopes) {
+ if (scopes instanceof ScopeDescriptor) {
+ return scopes
+ } else {
+ return new ScopeDescriptor({scopes})
+ }
+ }
+
+ /*
+ Section: Construction and Destruction
+ */
+
+ // Public: Create a {ScopeDescriptor} object.
+ //
+ // * `object` {Object}
+ // * `scopes` {Array} of {String}s
+ constructor ({scopes}) {
+ this.scopes = scopes
+ }
+
+ // Public: Returns an {Array} of {String}s
+ getScopesArray () {
+ return this.scopes
+ }
+
+ getScopeChain () {
+ // For backward compatibility, prefix TextMate-style scope names with
+ // leading dots (e.g. 'source.js' -> '.source.js').
+ if (this.scopes[0] != null && this.scopes[0].includes('.')) {
+ let result = ''
+ for (let i = 0; i < this.scopes.length; i++) {
+ const scope = this.scopes[i]
+ if (i > 0) { result += ' ' }
+ if (scope[0] !== '.') { result += '.' }
+ result += scope
+ }
+ return result
+ } else {
+ return this.scopes.join(' ')
+ }
+ }
+
+ toString () {
+ return this.getScopeChain()
+ }
+
+ isEqual (other) {
+ if (this.scopes.length !== other.scopes.length) {
+ return false
+ }
+ for (let i = 0; i < this.scopes.length; i++) {
+ const scope = this.scopes[i]
+ if (scope !== other.scopes[i]) {
+ return false
+ }
+ }
+ return true
+ }
+}
diff --git a/src/selection.js b/src/selection.js
index 2c64fa126..209036be3 100644
--- a/src/selection.js
+++ b/src/selection.js
@@ -407,6 +407,25 @@ class Selection {
if (autoscroll) this.cursor.autoscroll()
}
+ // Private: Ensure that the {TextEditor} is not marked read-only before allowing a buffer modification to occur. if
+ // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error.
+ ensureWritable (methodName, opts) {
+ if (!opts.bypassReadOnly && this.editor.isReadOnly()) {
+ if (atom.inDevMode() || atom.inSpecMode()) {
+ const e = new Error('Attempt to mutate a read-only TextEditor through a Selection')
+ e.detail =
+ `Your package is attempting to call ${methodName} on a selection within an editor that has been marked ` +
+ ' read-only. Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before ' +
+ ' attempting modifications.'
+ throw e
+ }
+
+ return false
+ }
+
+ return true
+ }
+
/*
Section: Modifying the selected text
*/
@@ -427,8 +446,11 @@ class Selection {
// 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.
+ // * `undo` *Deprecated* If `skip`, skips the undo stack for this operation. This property is deprecated. Call groupLastChanges() on the {TextBuffer} afterward instead.
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
insertText (text, options = {}) {
+ if (!this.ensureWritable('insertText', options)) return
+
let desiredIndentLevel, indentAdjustment
const oldBufferRange = this.getBufferRange()
const wasReversed = this.isReversed()
@@ -492,90 +514,134 @@ class Selection {
// Public: Removes the first character before the selection if the selection
// is empty otherwise it deletes the selection.
- backspace () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ backspace (options = {}) {
+ if (!this.ensureWritable('backspace', options)) return
if (this.isEmpty()) this.selectLeft()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToPreviousWordBoundary (options = {}) {
+ if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return
if (this.isEmpty()) this.selectToPreviousWordBoundary()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToNextWordBoundary (options = {}) {
+ if (!this.ensureWritable('deleteToNextWordBoundary', options)) return
if (this.isEmpty()) this.selectToNextWordBoundary()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToBeginningOfWord (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfWord', options)) return
if (this.isEmpty()) this.selectToBeginningOfWord()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// Public: Removes from the beginning of the line which the selection begins on
// all the way through to the end of the selection.
- deleteToBeginningOfLine () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToBeginningOfLine (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfLine', options)) return
if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) {
this.selectLeft()
} else {
this.selectToBeginningOfLine()
}
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// Public: Removes the selection or the next character after the start of the
// selection if the selection is empty.
- delete () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ delete (options = {}) {
+ if (!this.ensureWritable('delete', options)) return
if (this.isEmpty()) this.selectRight()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToEndOfLine (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfLine', options)) return
if (this.isEmpty()) {
if (this.cursor.isAtEndOfLine()) {
- this.delete()
+ this.delete(options)
return
}
this.selectToEndOfLine()
}
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToEndOfWord (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfWord', options)) return
if (this.isEmpty()) this.selectToEndOfWord()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToBeginningOfSubword (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return
if (this.isEmpty()) this.selectToPreviousSubwordBoundary()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteToEndOfSubword (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfSubword', options)) return
if (this.isEmpty()) this.selectToNextSubwordBoundary()
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
// Public: Removes only the selected text.
- deleteSelectedText () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteSelectedText (options = {}) {
+ if (!this.ensureWritable('deleteSelectedText', options)) return
const bufferRange = this.getBufferRange()
if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange)
if (this.cursor) this.cursor.setBufferPosition(bufferRange.start)
@@ -584,7 +650,11 @@ class Selection {
// 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 () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ deleteLine (options = {}) {
+ if (!this.ensureWritable('deleteLine', options)) return
const range = this.getBufferRange()
if (range.isEmpty()) {
const start = this.cursor.getScreenRow()
@@ -607,7 +677,11 @@ class Selection {
// be separated by a single space.
//
// If there selection spans more than one line, all the lines are joined together.
- joinLines () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ joinLines (options = {}) {
+ if (!this.ensureWritable('joinLines', options)) return
let joinMarker
const selectedRange = this.getBufferRange()
if (selectedRange.isEmpty()) {
@@ -629,7 +703,7 @@ class Selection {
})
if (trailingWhitespaceRange) {
this.setBufferRange(trailingWhitespaceRange)
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
}
const currentRow = selectedRange.start.row
@@ -638,7 +712,7 @@ class Selection {
(nextRow <= this.editor.buffer.getLastRow()) &&
(this.editor.buffer.lineLengthForRow(nextRow) > 0) &&
(this.editor.buffer.lineLengthForRow(currentRow) > 0)
- if (insertSpace) this.insertText(' ')
+ if (insertSpace) this.insertText(' ', options)
this.cursor.moveToEndOfLine()
@@ -647,7 +721,7 @@ class Selection {
this.cursor.moveRight()
this.cursor.moveToFirstCharacterOfLine()
})
- this.deleteSelectedText()
+ this.deleteSelectedText(options)
if (insertSpace) this.cursor.moveLeft()
}
@@ -660,7 +734,11 @@ class Selection {
}
// Public: Removes one level of indent from the currently selected rows.
- outdentSelectedRows () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ outdentSelectedRows (options = {}) {
+ if (!this.ensureWritable('outdentSelectedRows', options)) return
const [start, end] = this.getBufferRowRange()
const {buffer} = this.editor
const leadingTabRegex = new RegExp(`^( {1,${this.editor.getTabLength()}}|\t)`)
@@ -674,7 +752,11 @@ class Selection {
// Public: Sets the indentation level of all selected rows to values suggested
// by the relevant grammars.
- autoIndentSelectedRows () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ autoIndentSelectedRows (options = {}) {
+ if (!this.ensureWritable('autoIndentSelectedRows', options)) return
const [start, end] = this.getBufferRowRange()
return this.editor.autoIndentBufferRows(start, end)
}
@@ -683,29 +765,45 @@ class Selection {
// of a comment.
//
// Removes the comment if they are currently wrapped in a comment.
- toggleLineComments () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ toggleLineComments (options = {}) {
+ if (!this.ensureWritable('toggleLineComments', options)) return
this.editor.toggleLineCommentsForBufferRows(...(this.getBufferRowRange() || []))
}
// Public: Cuts the selection until the end of the screen line.
- cutToEndOfLine (maintainClipboard) {
+ //
+ // * `maintainClipboard` {Boolean}
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ cutToEndOfLine (maintainClipboard, options = {}) {
+ if (!this.ensureWritable('cutToEndOfLine', options)) return
if (this.isEmpty()) this.selectToEndOfLine()
- return this.cut(maintainClipboard)
+ return this.cut(maintainClipboard, false, options.bypassReadOnly)
}
// Public: Cuts the selection until the end of the buffer line.
- cutToEndOfBufferLine (maintainClipboard) {
+ //
+ // * `maintainClipboard` {Boolean}
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ cutToEndOfBufferLine (maintainClipboard, options = {}) {
+ if (!this.ensureWritable('cutToEndOfBufferLine', options)) return
if (this.isEmpty()) this.selectToEndOfBufferLine()
- this.cut(maintainClipboard)
+ this.cut(maintainClipboard, false, options.bypassReadOnly)
}
// 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) {
+ // * `bypassReadOnly` {Boolean} (default: false) Must be `true` to modify text within a read-only editor.
+ cut (maintainClipboard = false, fullLine = false, bypassReadOnly = false) {
+ if (!this.ensureWritable('cut', {bypassReadOnly})) return
this.copy(maintainClipboard, fullLine)
- this.delete()
+ this.delete({bypassReadOnly})
}
// Public: Copies the current selection to the clipboard.
@@ -783,7 +881,9 @@ class Selection {
// * `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} = {}) {
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ indent ({autoIndent, bypassReadOnly} = {}) {
+ if (!this.ensureWritable('indent', {bypassReadOnly})) return
const {row} = this.cursor.getBufferPosition()
if (this.isEmpty()) {
@@ -793,17 +893,21 @@ class Selection {
if (autoIndent && delta > 0) {
if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1)
- this.insertText(this.editor.buildIndentString(delta))
+ this.insertText(this.editor.buildIndentString(delta), {bypassReadOnly})
} else {
- this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn()))
+ this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn()), {bypassReadOnly})
}
} else {
- this.indentSelectedRows()
+ this.indentSelectedRows({bypassReadOnly})
}
}
// Public: If the selection spans multiple rows, indent all of them.
- indentSelectedRows () {
+ //
+ // * `options` (optional) {Object} with the keys:
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false)
+ indentSelectedRows (options = {}) {
+ if (!this.ensureWritable('indentSelectedRows', options)) return
const [start, end] = this.getBufferRowRange()
for (let row = start; row <= end; row++) {
if (this.editor.buffer.lineLengthForRow(row) !== 0) {
diff --git a/src/selectors.js b/src/selectors.js
new file mode 100644
index 000000000..ce03b80b4
--- /dev/null
+++ b/src/selectors.js
@@ -0,0 +1,38 @@
+module.exports = {selectorMatchesAnyScope, matcherForSelector}
+
+const {isSubset} = require('underscore-plus')
+
+// Private: Parse a selector into parts.
+// If already parsed, returns the selector unmodified.
+//
+// * `selector` a {String|Array} specifying what to match
+// Returns selector parts, an {Array}.
+function parse (selector) {
+ return typeof selector === 'string'
+ ? selector.replace(/^\./, '').split('.')
+ : selector
+}
+
+const always = scope => true
+
+// Essential: Return a matcher function for a selector.
+//
+// * selector, a {String} selector
+// Returns {(scope: String) -> Boolean}, a matcher function returning
+// true iff the scope matches the selector.
+function matcherForSelector (selector) {
+ const parts = parse(selector)
+ if (typeof parts === 'function') return parts
+ return selector
+ ? scope => isSubset(parts, parse(scope))
+ : always
+}
+
+// Essential: Return true iff the selector matches any provided scope.
+//
+// * {String} selector
+// * {Array} scopes
+// Returns {Boolean} true if any scope matches the selector.
+function selectorMatchesAnyScope (selector, scopes) {
+ return !selector || scopes.some(matcherForSelector(selector))
+}
diff --git a/src/storage-folder.coffee b/src/storage-folder.coffee
deleted file mode 100644
index 280eb8b5c..000000000
--- a/src/storage-folder.coffee
+++ /dev/null
@@ -1,39 +0,0 @@
-path = require "path"
-fs = require "fs-plus"
-
-module.exports =
-class StorageFolder
- constructor: (containingPath) ->
- @path = path.join(containingPath, "storage") if containingPath?
-
- clear: ->
- return unless @path?
-
- try
- fs.removeSync(@path)
- catch error
- console.warn "Error deleting #{@path}", error.stack, error
-
- storeSync: (name, object) ->
- return unless @path?
-
- fs.writeFileSync(@pathForKey(name), JSON.stringify(object), 'utf8')
-
- load: (name) ->
- return unless @path?
-
- statePath = @pathForKey(name)
- try
- stateString = fs.readFileSync(statePath, 'utf8')
- catch error
- unless error.code is 'ENOENT'
- console.warn "Error reading state file: #{statePath}", error.stack, error
- return undefined
-
- try
- JSON.parse(stateString)
- catch error
- console.warn "Error parsing state file: #{statePath}", error.stack, error
-
- pathForKey: (name) -> path.join(@getPath(), name)
- getPath: -> @path
diff --git a/src/storage-folder.js b/src/storage-folder.js
new file mode 100644
index 000000000..4931dab11
--- /dev/null
+++ b/src/storage-folder.js
@@ -0,0 +1,49 @@
+const path = require('path')
+const fs = require('fs-plus')
+
+module.exports =
+class StorageFolder {
+ constructor (containingPath) {
+ if (containingPath) {
+ this.path = path.join(containingPath, 'storage')
+ }
+ }
+
+ store (name, object) {
+ return new Promise((resolve, reject) => {
+ if (!this.path) return resolve()
+ fs.writeFile(this.pathForKey(name), JSON.stringify(object), 'utf8', error =>
+ error ? reject(error) : resolve()
+ )
+ })
+ }
+
+ load (name) {
+ return new Promise(resolve => {
+ if (!this.path) return resolve(null)
+ const statePath = this.pathForKey(name)
+ fs.readFile(statePath, 'utf8', (error, stateString) => {
+ if (error && error.code !== 'ENOENT') {
+ console.warn(`Error reading state file: ${statePath}`, error.stack, error)
+ }
+
+ if (!stateString) return resolve(null)
+
+ try {
+ resolve(JSON.parse(stateString))
+ } catch (error) {
+ console.warn(`Error parsing state file: ${statePath}`, error.stack, error)
+ resolve(null)
+ }
+ })
+ })
+ }
+
+ pathForKey (name) {
+ return path.join(this.getPath(), name)
+ }
+
+ getPath () {
+ return this.path
+ }
+}
diff --git a/src/test.ejs b/src/test.ejs
new file mode 100644
index 000000000..7b93c31b3
--- /dev/null
+++ b/src/test.ejs
@@ -0,0 +1,9 @@
+
+
+<% if something() { %>
+
+ <%= html `ok how about this` %>
+
+<% } %>
+
+
diff --git a/src/text-editor-component.js b/src/text-editor-component.js
index 48cb919d0..ffb55c454 100644
--- a/src/text-editor-component.js
+++ b/src/text-editor-component.js
@@ -148,12 +148,13 @@ class TextEditorComponent {
this.lineNumbersToRender = {
maxDigits: 2,
bufferRows: [],
+ screenRows: [],
keys: [],
softWrappedFlags: [],
foldableFlags: []
}
this.decorationsToRender = {
- lineNumbers: null,
+ lineNumbers: new Map(),
lines: null,
highlights: [],
cursors: [],
@@ -266,14 +267,22 @@ class TextEditorComponent {
if (useScheduler === true) {
const scheduler = etch.getScheduler()
scheduler.readDocument(() => {
- this.measureContentDuringUpdateSync()
+ const restartFrame = this.measureContentDuringUpdateSync()
scheduler.updateDocument(() => {
- this.updateSyncAfterMeasuringContent()
+ if (restartFrame) {
+ this.updateSync(true)
+ } else {
+ this.updateSyncAfterMeasuringContent()
+ }
})
})
} else {
- this.measureContentDuringUpdateSync()
- this.updateSyncAfterMeasuringContent()
+ const restartFrame = this.measureContentDuringUpdateSync()
+ if (restartFrame) {
+ this.updateSync(false)
+ } else {
+ this.updateSyncAfterMeasuringContent()
+ }
}
this.updateScheduled = false
@@ -391,15 +400,16 @@ class TextEditorComponent {
this.measureHorizontalPositions()
this.updateAbsolutePositionedDecorations()
+ const isHorizontalScrollbarVisible = (
+ this.canScrollHorizontally() &&
+ this.getHorizontalScrollbarHeight() > 0
+ )
+
if (this.pendingAutoscroll) {
this.derivedDimensionsCache = {}
const {screenRange, options} = this.pendingAutoscroll
this.autoscrollHorizontally(screenRange, options)
- const isHorizontalScrollbarVisible = (
- this.canScrollHorizontally() &&
- this.getHorizontalScrollbarHeight() > 0
- )
if (!wasHorizontalScrollbarVisible && isHorizontalScrollbarVisible) {
this.autoscrollVertically(screenRange, options)
}
@@ -408,6 +418,8 @@ class TextEditorComponent {
this.linesToMeasure.clear()
this.measuredContent = true
+
+ return wasHorizontalScrollbarVisible !== isHorizontalScrollbarVisible
}
updateSyncAfterMeasuringContent () {
@@ -447,15 +459,18 @@ class TextEditorComponent {
let clientContainerWidth = '100%'
if (this.hasInitialMeasurements) {
if (model.getAutoHeight()) {
- clientContainerHeight = this.getContentHeight()
- if (this.canScrollHorizontally()) clientContainerHeight += this.getHorizontalScrollbarHeight()
- clientContainerHeight += 'px'
+ clientContainerHeight =
+ this.getContentHeight() +
+ this.getHorizontalScrollbarHeight() +
+ 'px'
}
if (model.getAutoWidth()) {
style.width = 'min-content'
- clientContainerWidth = this.getGutterContainerWidth() + this.getContentWidth()
- if (this.canScrollVertically()) clientContainerWidth += this.getVerticalScrollbarWidth()
- clientContainerWidth += 'px'
+ clientContainerWidth =
+ this.getGutterContainerWidth() +
+ this.getContentWidth() +
+ this.getVerticalScrollbarWidth() +
+ 'px'
} else {
style.width = this.element.style.width
}
@@ -466,7 +481,7 @@ class TextEditorComponent {
attributes.mini = ''
}
- if (!this.isInputEnabled()) {
+ if (model.isReadOnly()) {
attributes.readonly = ''
}
@@ -740,20 +755,14 @@ class TextEditorComponent {
scrollLeft = this.getScrollLeft()
canScrollHorizontally = this.canScrollHorizontally()
canScrollVertically = this.canScrollVertically()
- horizontalScrollbarHeight =
- canScrollHorizontally
- ? this.getHorizontalScrollbarHeight()
- : 0
- verticalScrollbarWidth =
- canScrollVertically
- ? this.getVerticalScrollbarWidth()
- : 0
+ horizontalScrollbarHeight = this.getHorizontalScrollbarHeight()
+ verticalScrollbarWidth = this.getVerticalScrollbarWidth()
forceScrollbarVisible = this.remeasureScrollbars
} else {
forceScrollbarVisible = true
}
- const dummyScrollbarVnodes = [
+ return [
$(DummyScrollbarComponent, {
ref: 'verticalScrollbar',
orientation: 'vertical',
@@ -775,13 +784,10 @@ class TextEditorComponent {
scrollLeft,
verticalScrollbarWidth,
forceScrollbarVisible
- })
- ]
+ }),
- // If both scrollbars are visible, push a dummy element to force a "corner"
- // to render where the two scrollbars meet at the lower right
- if (verticalScrollbarWidth > 0 && horizontalScrollbarHeight > 0) {
- dummyScrollbarVnodes.push($.div(
+ // Force a "corner" to render where the two scrollbars meet at the lower right
+ $.div(
{
ref: 'scrollbarCorner',
className: 'scrollbar-corner',
@@ -794,10 +800,8 @@ class TextEditorComponent {
overflow: 'scroll'
}
}
- ))
- }
-
- return dummyScrollbarVnodes
+ )
+ ]
} else {
return null
}
@@ -845,10 +849,7 @@ class TextEditorComponent {
}
for (let i = 0; i < newClassList.length; i++) {
- const className = newClassList[i]
- if (!oldClassList || !oldClassList.includes(className)) {
- this.element.classList.add(className)
- }
+ this.element.classList.add(newClassList[i])
}
this.classList = newClassList
@@ -886,7 +887,7 @@ class TextEditorComponent {
queryLineNumbersToRender () {
const {model} = this.props
- if (!model.isLineNumberGutterVisible()) return
+ if (!model.anyLineNumberGutterVisible()) return
if (this.showLineNumbers !== model.doesShowLineNumbers()) {
this.remeasureGutterDimensions = true
this.showLineNumbers = model.doesShowLineNumbers()
@@ -942,7 +943,7 @@ class TextEditorComponent {
queryMaxLineNumberDigits () {
const {model} = this.props
- if (model.isLineNumberGutterVisible()) {
+ if (model.anyLineNumberGutterVisible()) {
const maxDigits = Math.max(2, model.getLineCount().toString().length)
if (maxDigits !== this.lineNumbersToRender.maxDigits) {
this.remeasureGutterDimensions = true
@@ -977,7 +978,7 @@ class TextEditorComponent {
}
queryDecorationsToRender () {
- this.decorationsToRender.lineNumbers = []
+ this.decorationsToRender.lineNumbers.clear()
this.decorationsToRender.lines = []
this.decorationsToRender.overlays.length = 0
this.decorationsToRender.customGutter.clear()
@@ -1040,7 +1041,17 @@ class TextEditorComponent {
}
addLineDecorationToRender (type, decoration, screenRange, reversed) {
- const decorationsToRender = (type === 'line') ? this.decorationsToRender.lines : this.decorationsToRender.lineNumbers
+ let decorationsToRender
+ if (type === 'line') {
+ decorationsToRender = this.decorationsToRender.lines
+ } else {
+ const gutterName = decoration.gutterName || 'line-number'
+ decorationsToRender = this.decorationsToRender.lineNumbers.get(gutterName)
+ if (!decorationsToRender) {
+ decorationsToRender = []
+ this.decorationsToRender.lineNumbers.set(gutterName, decorationsToRender)
+ }
+ }
let omitLastRow = false
if (screenRange.isEmpty()) {
@@ -1520,15 +1531,11 @@ class TextEditorComponent {
let {wheelDeltaX, wheelDeltaY} = event
if (Math.abs(wheelDeltaX) > Math.abs(wheelDeltaY)) {
- wheelDeltaX = (Math.sign(wheelDeltaX) === 1)
- ? Math.max(1, wheelDeltaX * scrollSensitivity)
- : Math.min(-1, wheelDeltaX * scrollSensitivity)
+ wheelDeltaX = wheelDeltaX * scrollSensitivity
wheelDeltaY = 0
} else {
wheelDeltaX = 0
- wheelDeltaY = (Math.sign(wheelDeltaY) === 1)
- ? Math.max(1, wheelDeltaY * scrollSensitivity)
- : Math.min(-1, wheelDeltaY * scrollSensitivity)
+ wheelDeltaY = wheelDeltaY * scrollSensitivity
}
if (this.getPlatform() !== 'darwin' && event.shiftKey) {
@@ -1752,28 +1759,28 @@ class TextEditorComponent {
const screenPosition = this.screenPositionForMouseEvent(event)
- if (button !== 0 || (platform === 'darwin' && ctrlKey)) {
- // Always set cursor position on middle-click
- // Only set cursor position on right-click if there is one cursor with no selection
- const ranges = model.getSelectedBufferRanges()
- if (button === 1 || (ranges.length === 1 && ranges[0].isEmpty())) {
- model.setCursorScreenPosition(screenPosition, {autoscroll: false})
- }
+ if (button === 1) {
+ model.setCursorScreenPosition(screenPosition, {autoscroll: false})
// On Linux, pasting happens on middle click. A textInput event with the
// contents of the selection clipboard will be dispatched by the browser
// automatically on mouseup.
- if (platform === 'linux' && button === 1) model.insertText(clipboard.readText('selection'))
+ if (platform === 'linux' && this.isInputEnabled()) model.insertText(clipboard.readText('selection'))
return
}
+ if (button !== 0) return
+
+ // Ctrl-click brings up the context menu on macOS
+ if (platform === 'darwin' && ctrlKey) return
+
if (target && target.matches('.fold-marker')) {
const bufferPosition = model.bufferPositionForScreenPosition(screenPosition)
model.destroyFoldsContainingBufferPositions([bufferPosition], false)
return
}
- const addOrRemoveSelection = metaKey || ctrlKey
+ const addOrRemoveSelection = metaKey || (ctrlKey && platform !== 'darwin')
switch (detail) {
case 1:
@@ -2619,37 +2626,25 @@ class TextEditorComponent {
getScrollContainerHeight () {
if (this.props.model.getAutoHeight()) {
- return this.getScrollHeight()
+ return this.getScrollHeight() + this.getHorizontalScrollbarHeight()
} else {
return this.getClientContainerHeight()
}
}
getScrollContainerClientWidth () {
- if (this.canScrollVertically()) {
- return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()
- } else {
- return this.getScrollContainerWidth()
- }
+ return this.getScrollContainerWidth() - this.getVerticalScrollbarWidth()
}
getScrollContainerClientHeight () {
- if (this.canScrollHorizontally()) {
- return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()
- } else {
- return this.getScrollContainerHeight()
- }
+ return this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight()
}
canScrollVertically () {
const {model} = this.props
if (model.isMini()) return false
if (model.getAutoHeight()) return false
- if (this.getContentHeight() > this.getScrollContainerHeight()) return true
- return (
- this.getContentWidth() > this.getScrollContainerWidth() &&
- this.getContentHeight() > (this.getScrollContainerHeight() - this.getHorizontalScrollbarHeight())
- )
+ return this.getContentHeight() > this.getScrollContainerClientHeight()
}
canScrollHorizontally () {
@@ -2657,11 +2652,7 @@ class TextEditorComponent {
if (model.isMini()) return false
if (model.getAutoWidth()) return false
if (model.isSoftWrapped()) return false
- if (this.getContentWidth() > this.getScrollContainerWidth()) return true
- return (
- this.getContentHeight() > this.getScrollContainerHeight() &&
- this.getContentWidth() > (this.getScrollContainerWidth() - this.getVerticalScrollbarWidth())
- )
+ return this.getContentWidth() > this.getScrollContainerClientWidth()
}
getScrollHeight () {
@@ -2694,7 +2685,7 @@ class TextEditorComponent {
}
getContentWidth () {
- return Math.round(this.getLongestLineWidth() + this.getBaseCharacterWidth())
+ return Math.ceil(this.getLongestLineWidth() + this.getBaseCharacterWidth())
}
getScrollContainerClientWidthInBaseCharacters () {
@@ -2804,7 +2795,7 @@ class TextEditorComponent {
setScrollTop (scrollTop) {
if (Number.isNaN(scrollTop) || scrollTop == null) return false
- scrollTop = Math.round(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop)))
+ scrollTop = roundToPhysicalPixelBoundary(Math.max(0, Math.min(this.getMaxScrollTop(), scrollTop)))
if (scrollTop !== this.scrollTop) {
this.derivedDimensionsCache = {}
this.scrollTopPending = true
@@ -2835,7 +2826,7 @@ class TextEditorComponent {
setScrollLeft (scrollLeft) {
if (Number.isNaN(scrollLeft) || scrollLeft == null) return false
- scrollLeft = Math.round(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft)))
+ scrollLeft = roundToPhysicalPixelBoundary(Math.max(0, Math.min(this.getMaxScrollLeft(), scrollLeft)))
if (scrollLeft !== this.scrollLeft) {
this.scrollLeftPending = true
this.scrollLeft = scrollLeft
@@ -2958,11 +2949,11 @@ class TextEditorComponent {
}
setInputEnabled (inputEnabled) {
- this.props.model.update({readOnly: !inputEnabled})
+ this.props.model.update({keyboardInputEnabled: inputEnabled})
}
- isInputEnabled (inputEnabled) {
- return !this.props.model.isReadOnly()
+ isInputEnabled () {
+ return !this.props.model.isReadOnly() && this.props.model.isKeyboardInputEnabled()
}
getHiddenInput () {
@@ -3119,7 +3110,7 @@ class GutterContainerComponent {
},
$.div({style: innerStyle},
guttersToRender.map((gutter) => {
- if (gutter.name === 'line-number') {
+ if (gutter.type === 'line-number') {
return this.renderLineNumberGutter(gutter)
} else {
return $(CustomGutterComponent, {
@@ -3138,18 +3129,29 @@ class GutterContainerComponent {
renderLineNumberGutter (gutter) {
const {
- rootComponent, isLineNumberGutterVisible, showLineNumbers, hasInitialMeasurements, lineNumbersToRender,
+ rootComponent, showLineNumbers, hasInitialMeasurements, lineNumbersToRender,
renderedStartRow, renderedEndRow, rowsPerTile, decorationsToRender, didMeasureVisibleBlockDecoration,
scrollHeight, lineNumberGutterWidth, lineHeight
} = this.props
- if (!isLineNumberGutterVisible) return null
+ if (!gutter.isVisible()) {
+ return null
+ }
+
+ const oneTrueLineNumberGutter = gutter.name === 'line-number'
+ const ref = oneTrueLineNumberGutter ? 'lineNumberGutter' : undefined
+ const width = oneTrueLineNumberGutter ? lineNumberGutterWidth : undefined
if (hasInitialMeasurements) {
const {maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags} = lineNumbersToRender
return $(LineNumberGutterComponent, {
- ref: 'lineNumberGutter',
+ ref,
element: gutter.getElement(),
+ name: gutter.name,
+ className: gutter.className,
+ labelFn: gutter.labelFn,
+ onMouseDown: gutter.onMouseDown,
+ onMouseMove: gutter.onMouseMove,
rootComponent: rootComponent,
startRow: renderedStartRow,
endRow: renderedEndRow,
@@ -3160,18 +3162,22 @@ class GutterContainerComponent {
screenRows: screenRows,
softWrappedFlags: softWrappedFlags,
foldableFlags: foldableFlags,
- decorations: decorationsToRender.lineNumbers,
+ decorations: decorationsToRender.lineNumbers.get(gutter.name) || [],
blockDecorations: decorationsToRender.blocks,
didMeasureVisibleBlockDecoration: didMeasureVisibleBlockDecoration,
height: scrollHeight,
- width: lineNumberGutterWidth,
+ width,
lineHeight: lineHeight,
showLineNumbers
})
} else {
return $(LineNumberGutterComponent, {
- ref: 'lineNumberGutter',
+ ref,
element: gutter.getElement(),
+ name: gutter.name,
+ className: gutter.className,
+ onMouseDown: gutter.onMouseDown,
+ onMouseMove: gutter.onMouseMove,
maxDigits: lineNumbersToRender.maxDigits,
showLineNumbers
})
@@ -3199,7 +3205,8 @@ class LineNumberGutterComponent {
render () {
const {
rootComponent, showLineNumbers, height, width, startRow, endRow, rowsPerTile,
- maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations
+ maxDigits, keys, bufferRows, screenRows, softWrappedFlags, foldableFlags, decorations,
+ className
} = this.props
let children = null
@@ -3227,8 +3234,12 @@ class LineNumberGutterComponent {
let number = null
if (showLineNumbers) {
- number = softWrapped ? '•' : bufferRow + 1
- number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number
+ if (this.props.labelFn == null) {
+ number = softWrapped ? '•' : bufferRow + 1
+ number = NBSP_CHARACTER.repeat(maxDigits - number.length) + number
+ } else {
+ number = this.props.labelFn({bufferRow, screenRow, foldable, softWrapped, maxDigits})
+ }
}
// We need to adjust the line number position to account for block
@@ -3255,6 +3266,7 @@ class LineNumberGutterComponent {
const tileTop = rootComponent.pixelPositionBeforeBlocksForRow(tileStartRow)
const tileBottom = rootComponent.pixelPositionBeforeBlocksForRow(tileEndRow)
const tileHeight = tileBottom - tileTop
+ const tileWidth = width != null && width > 0 ? width + 'px' : ''
children[i] = $.div({
key: rootComponent.idsByTileStartRow.get(tileStartRow),
@@ -3263,20 +3275,26 @@ class LineNumberGutterComponent {
position: 'absolute',
top: 0,
height: tileHeight + 'px',
- width: width + 'px',
+ width: tileWidth,
transform: `translateY(${tileTop}px)`
}
}, ...tileChildren)
}
}
+ let rootClassName = 'gutter line-numbers'
+ if (className) {
+ rootClassName += ' ' + className
+ }
+
return $.div(
{
- className: 'gutter line-numbers',
- attributes: {'gutter-name': 'line-number'},
+ className: rootClassName,
+ attributes: {'gutter-name': this.props.name},
style: {position: 'relative', height: ceilToPhysicalPixelBoundary(height) + 'px'},
on: {
- mousedown: this.didMouseDown
+ mousedown: this.didMouseDown,
+ mousemove: this.didMouseMove
}
},
$.div({key: 'placeholder', className: 'line-number dummy', style: {visibility: 'hidden'}},
@@ -3298,6 +3316,8 @@ class LineNumberGutterComponent {
if (oldProps.endRow !== newProps.endRow) return true
if (oldProps.rowsPerTile !== newProps.rowsPerTile) return true
if (oldProps.maxDigits !== newProps.maxDigits) return true
+ if (oldProps.labelFn !== newProps.labelFn) return true
+ if (oldProps.className !== newProps.className) return true
if (newProps.didMeasureVisibleBlockDecoration) return true
if (!arraysEqual(oldProps.keys, newProps.keys)) return true
if (!arraysEqual(oldProps.bufferRows, newProps.bufferRows)) return true
@@ -3344,7 +3364,27 @@ class LineNumberGutterComponent {
}
didMouseDown (event) {
- this.props.rootComponent.didMouseDownOnLineNumberGutter(event)
+ if (this.props.onMouseDown == null) {
+ this.props.rootComponent.didMouseDownOnLineNumberGutter(event)
+ } else {
+ const {bufferRow, screenRow} = event.target.dataset
+ this.props.onMouseDown({
+ bufferRow: parseInt(bufferRow, 10),
+ screenRow: parseInt(screenRow, 10),
+ domEvent: event
+ })
+ }
+ }
+
+ didMouseMove (event) {
+ if (this.props.onMouseMove != null) {
+ const {bufferRow, screenRow} = event.target.dataset
+ this.props.onMouseMove({
+ bufferRow: parseInt(bufferRow, 10),
+ screenRow: parseInt(screenRow, 10),
+ domEvent: event
+ })
+ }
}
}
@@ -3352,7 +3392,8 @@ class LineNumberComponent {
constructor (props) {
const {className, width, marginTop, bufferRow, screenRow, number, nodePool} = props
this.props = props
- const style = {width: width + 'px'}
+ const style = {}
+ if (width != null && width > 0) style.width = width + 'px'
if (marginTop != null && marginTop > 0) style.marginTop = marginTop + 'px'
this.element = nodePool.getElement('DIV', className, style)
this.element.dataset.bufferRow = bufferRow
@@ -3372,22 +3413,31 @@ class LineNumberComponent {
if (this.props.bufferRow !== bufferRow) this.element.dataset.bufferRow = bufferRow
if (this.props.screenRow !== screenRow) this.element.dataset.screenRow = screenRow
if (this.props.className !== className) this.element.className = className
- if (this.props.width !== width) this.element.style.width = width + 'px'
+ if (this.props.width !== width) {
+ if (width != null && width > 0) {
+ this.element.style.width = width + 'px'
+ } else {
+ this.element.style.width = ''
+ }
+ }
if (this.props.marginTop !== marginTop) {
- if (marginTop != null) {
+ if (marginTop != null && marginTop > 0) {
this.element.style.marginTop = marginTop + 'px'
} else {
this.element.style.marginTop = ''
}
}
+
if (this.props.number !== number) {
- if (number) {
- this.element.insertBefore(nodePool.getTextNode(number), this.element.firstChild)
- } else {
+ if (this.props.number != null) {
const numberNode = this.element.firstChild
numberNode.remove()
nodePool.release(numberNode)
}
+
+ if (number != null) {
+ this.element.insertBefore(nodePool.getTextNode(number), this.element.firstChild)
+ }
}
this.props = props
@@ -3413,9 +3463,13 @@ class CustomGutterComponent {
}
render () {
+ let className = 'gutter'
+ if (this.props.className) {
+ className += ' ' + this.props.className
+ }
return $.div(
{
- className: 'gutter',
+ className,
attributes: {'gutter-name': this.props.name},
style: {
display: this.props.visible ? '' : 'none'
@@ -3515,7 +3569,7 @@ class CursorsAndInputComponent {
const cursorStyle = {
height: cursorHeight,
- width: pixelWidth + 'px',
+ width: Math.min(pixelWidth, scrollWidth - pixelLeft) + 'px',
transform: `translate(${pixelLeft}px, ${pixelTop}px)`
}
if (extraCursorStyle) Object.assign(cursorStyle, extraCursorStyle)
diff --git a/src/text-editor-registry.js b/src/text-editor-registry.js
index 650d945fb..e9dbf2f5c 100644
--- a/src/text-editor-registry.js
+++ b/src/text-editor-registry.js
@@ -1,3 +1,4 @@
+const _ = require('underscore-plus')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const TextEditor = require('./text-editor')
const ScopeDescriptor = require('./scope-descriptor')
@@ -147,11 +148,11 @@ class TextEditorRegistry {
}
this.editorsWithMaintainedConfig.add(editor)
- this.subscribeToSettingsForEditorScope(editor)
- const grammarChangeSubscription = editor.onDidChangeGrammar(() => {
- this.subscribeToSettingsForEditorScope(editor)
+ this.updateAndMonitorEditorSettings(editor)
+ const languageChangeSubscription = editor.buffer.onDidChangeLanguageMode((newLanguageMode, oldLanguageMode) => {
+ this.updateAndMonitorEditorSettings(editor, oldLanguageMode)
})
- this.subscriptions.add(grammarChangeSubscription)
+ this.subscriptions.add(languageChangeSubscription)
const updateTabTypes = () => {
const configOptions = {scope: editor.getRootScopeDescriptor()}
@@ -169,8 +170,8 @@ class TextEditorRegistry {
return new Disposable(() => {
this.editorsWithMaintainedConfig.delete(editor)
tokenizeSubscription.dispose()
- grammarChangeSubscription.dispose()
- this.subscriptions.remove(grammarChangeSubscription)
+ languageChangeSubscription.dispose()
+ this.subscriptions.remove(languageChangeSubscription)
this.subscriptions.remove(tokenizeSubscription)
})
}
@@ -204,7 +205,7 @@ class TextEditorRegistry {
// Returns a {String} scope name, or `null` if no override has been set
// for the given editor.
getGrammarOverride (editor) {
- return editor.getBuffer().getLanguageMode().grammar.scopeName
+ return atom.grammars.getAssignedLanguageId(editor.getBuffer())
}
// Deprecated: Remove any grammar override that has been set for the given {TextEditor}.
@@ -214,14 +215,43 @@ class TextEditorRegistry {
atom.grammars.autoAssignLanguageMode(editor.getBuffer())
}
- async subscribeToSettingsForEditorScope (editor) {
+ async updateAndMonitorEditorSettings (editor, oldLanguageMode) {
await this.initialPackageActivationPromise
+ this.updateEditorSettingsForLanguageMode(editor, oldLanguageMode)
+ this.subscribeToSettingsForEditorScope(editor)
+ }
+
+ updateEditorSettingsForLanguageMode (editor, oldLanguageMode) {
+ const newLanguageMode = editor.buffer.getLanguageMode()
+
+ if (oldLanguageMode) {
+ const newSettings = this.textEditorParamsForScope(newLanguageMode.rootScopeDescriptor)
+ const oldSettings = this.textEditorParamsForScope(oldLanguageMode.rootScopeDescriptor)
+
+ const updatedSettings = {}
+ for (const [, paramName] of EDITOR_PARAMS_BY_SETTING_KEY) {
+ // Update the setting only if it has changed between the two language
+ // modes. This prevents user-modified settings in an editor (like
+ // 'softWrapped') from being reset when the language mode changes.
+ if (!_.isEqual(newSettings[paramName], oldSettings[paramName])) {
+ updatedSettings[paramName] = newSettings[paramName]
+ }
+ }
+
+ if (_.size(updatedSettings) > 0) {
+ editor.update(updatedSettings)
+ }
+ } else {
+ editor.update(this.textEditorParamsForScope(newLanguageMode.rootScopeDescriptor))
+ }
+ }
+
+ subscribeToSettingsForEditorScope (editor) {
+ if (!this.editorsWithMaintainedConfig) return
const scopeDescriptor = editor.getRootScopeDescriptor()
const scopeChain = scopeDescriptor.getScopeChain()
- editor.update(this.textEditorParamsForScope(scopeDescriptor))
-
if (!this.scopesWithConfigSubscriptions.has(scopeChain)) {
this.scopesWithConfigSubscriptions.add(scopeChain)
const configOptions = {scope: scopeDescriptor}
diff --git a/src/text-editor.js b/src/text-editor.js
index 47fb9f485..3616db28c 100644
--- a/src/text-editor.js
+++ b/src/text-editor.js
@@ -11,6 +11,7 @@ const Cursor = require('./cursor')
const Selection = require('./selection')
const NullGrammar = require('./null-grammar')
const TextMateLanguageMode = require('./text-mate-language-mode')
+const ScopeDescriptor = require('./scope-descriptor')
const TextMateScopeSelector = require('first-mate').ScopeSelector
const GutterContainer = require('./gutter-container')
@@ -41,9 +42,10 @@ const DEFAULT_NON_WORD_CHARACTERS = "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…"
// then be called with all current editor instances and also when any editor is
// created in the future.
//
-// ```coffee
-// atom.workspace.observeTextEditors (editor) ->
+// ```js
+// atom.workspace.observeTextEditors(editor => {
// editor.insertText('Hello World')
+// })
// ```
//
// ## Buffer vs. Screen Coordinates
@@ -105,6 +107,13 @@ class TextEditor {
}
state.assert = atomEnvironment.assert.bind(atomEnvironment)
+
+ // Semantics of the readOnly flag have changed since its introduction.
+ // Only respect readOnly2, which has been set with the current readOnly semantics.
+ delete state.readOnly
+ state.readOnly = state.readOnly2
+ delete state.readOnly2
+
const editor = new TextEditor(state)
if (state.registered) {
const disposable = atomEnvironment.textEditors.add(editor)
@@ -128,6 +137,7 @@ class TextEditor {
this.decorationManager = params.decorationManager
this.selectionsMarkerLayer = params.selectionsMarkerLayer
this.mini = (params.mini != null) ? params.mini : false
+ this.keyboardInputEnabled = (params.keyboardInputEnabled != null) ? params.keyboardInputEnabled : true
this.readOnly = (params.readOnly != null) ? params.readOnly : false
this.placeholderText = params.placeholderText
this.showLineNumbers = params.showLineNumbers
@@ -223,7 +233,7 @@ class TextEditor {
this.defaultMarkerLayer = this.displayLayer.addMarkerLayer()
if (!this.selectionsMarkerLayer) {
- this.selectionsMarkerLayer = this.addMarkerLayer({maintainHistory: true, persistent: true})
+ this.selectionsMarkerLayer = this.addMarkerLayer({maintainHistory: true, persistent: true, role: 'selections'})
}
this.decorationManager = new DecorationManager(this)
@@ -248,6 +258,7 @@ class TextEditor {
this.gutterContainer = new GutterContainer(this)
this.lineNumberGutter = this.gutterContainer.addGutter({
name: 'line-number',
+ type: 'line-number',
priority: 0,
visible: params.lineNumberGutterVisible
})
@@ -414,6 +425,15 @@ class TextEditor {
}
break
+ case 'keyboardInputEnabled':
+ if (value !== this.keyboardInputEnabled) {
+ this.keyboardInputEnabled = value
+ if (this.component != null) {
+ this.component.scheduleUpdate()
+ }
+ }
+ break
+
case 'placeholderText':
if (value !== this.placeholderText) {
this.placeholderText = value
@@ -544,7 +564,8 @@ class TextEditor {
softWrapAtPreferredLineLength: this.softWrapAtPreferredLineLength,
preferredLineLength: this.preferredLineLength,
mini: this.mini,
- readOnly: this.readOnly,
+ readOnly2: this.readOnly, // readOnly encompassed both readOnly and keyboardInputEnabled
+ keyboardInputEnabled: this.keyboardInputEnabled,
editorWidthInChars: this.editorWidthInChars,
width: this.width,
maxScreenLineLength: this.maxScreenLineLength,
@@ -986,6 +1007,12 @@ class TextEditor {
isReadOnly () { return this.readOnly }
+ enableKeyboardInput (enabled) {
+ this.update({keyboardInputEnabled: enabled})
+ }
+
+ isKeyboardInputEnabled () { return this.keyboardInputEnabled }
+
onDidChangeMini (callback) {
return this.emitter.on('did-change-mini', callback)
}
@@ -994,6 +1021,10 @@ class TextEditor {
isLineNumberGutterVisible () { return this.lineNumberGutter.isVisible() }
+ anyLineNumberGutterVisible () {
+ return this.getGutters().some(gutter => gutter.type === 'line-number' && gutter.visible)
+ }
+
onDidChangeLineNumberGutterVisible (callback) {
return this.emitter.on('did-change-line-number-gutter-visible', callback)
}
@@ -1305,7 +1336,12 @@ class TextEditor {
// Essential: Replaces the entire contents of the buffer with the given {String}.
//
// * `text` A {String} to replace with
- setText (text) { return this.buffer.setText(text) }
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ setText (text, options = {}) {
+ if (!this.ensureWritable('setText', options)) return
+ return this.buffer.setText(text)
+ }
// Essential: Set the text in the given {Range} in buffer coordinates.
//
@@ -1313,10 +1349,12 @@ class TextEditor {
// * `text` A {String}
// * `options` (optional) {Object}
// * `normalizeLineEndings` (optional) {Boolean} (default: true)
- // * `undo` (optional) {String} 'skip' will skip the undo system
+ // * `undo` (optional) *Deprecated* {String} 'skip' will skip the undo system. This property is deprecated. Call groupLastChanges() on the {TextBuffer} afterward instead.
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
//
// Returns the {Range} of the newly-inserted text.
- setTextInBufferRange (range, text, options) {
+ setTextInBufferRange (range, text, options = {}) {
+ if (!this.ensureWritable('setTextInBufferRange', options)) return
return this.getBuffer().setTextInRange(range, text, options)
}
@@ -1325,9 +1363,9 @@ class TextEditor {
// * `text` A {String} representing the text to insert.
// * `options` (optional) See {Selection::insertText}.
//
- // Returns a {Range} when the text has been inserted
- // Returns a {Boolean} false when the text has not been inserted
+ // Returns a {Range} when the text has been inserted. Returns a {Boolean} `false` when the text has not been inserted.
insertText (text, options = {}) {
+ if (!this.ensureWritable('insertText', options)) return
if (!this.emitWillInsertTextEvent(text)) return false
let groupLastChanges = false
@@ -1351,20 +1389,31 @@ class TextEditor {
}
// Essential: For each selection, replace the selected text with a newline.
- insertNewline (options) {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ insertNewline (options = {}) {
return this.insertText('\n', options)
}
// Essential: For each selection, if the selection is empty, delete the character
// following the cursor. Otherwise delete the selected text.
- delete () {
- return this.mutateSelectedText(selection => selection.delete())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ delete (options = {}) {
+ if (!this.ensureWritable('delete', options)) return
+ return this.mutateSelectedText(selection => selection.delete(options))
}
// Essential: For each selection, if the selection is empty, delete the character
// preceding the cursor. Otherwise delete the selected text.
- backspace () {
- return this.mutateSelectedText(selection => selection.backspace())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ backspace (options = {}) {
+ if (!this.ensureWritable('backspace', options)) return
+ return this.mutateSelectedText(selection => selection.backspace(options))
}
// Extended: Mutate the text of all the selections in a single transaction.
@@ -1385,7 +1434,12 @@ class TextEditor {
// Move lines intersecting the most recent selection or multiple selections
// up by one row in screen coordinates.
- moveLineUp () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ moveLineUp (options = {}) {
+ if (!this.ensureWritable('moveLineUp', options)) return
+
const selections = this.getSelectedBufferRanges().sort((a, b) => a.compare(b))
if (selections[0].start.row === 0) return
@@ -1453,7 +1507,12 @@ class TextEditor {
// Move lines intersecting the most recent selection or multiple selections
// down by one row in screen coordinates.
- moveLineDown () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ moveLineDown (options = {}) {
+ if (!this.ensureWritable('moveLineDown', options)) return
+
const selections = this.getSelectedBufferRanges()
selections.sort((a, b) => b.compare(a))
@@ -1525,7 +1584,11 @@ class TextEditor {
}
// Move any active selections one column to the left.
- moveSelectionLeft () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ moveSelectionLeft (options = {}) {
+ if (!this.ensureWritable('moveSelectionLeft', options)) return
const selections = this.getSelectedBufferRanges()
const noSelectionAtStartOfLine = selections.every(selection => selection.start.column !== 0)
@@ -1549,7 +1612,11 @@ class TextEditor {
}
// Move any active selections one column to the right.
- moveSelectionRight () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ moveSelectionRight (options = {}) {
+ if (!this.ensureWritable('moveSelectionRight', options)) return
const selections = this.getSelectedBufferRanges()
const noSelectionAtEndOfLine = selections.every(selection => {
return selection.end.column !== this.buffer.lineLengthForRow(selection.end.row)
@@ -1574,7 +1641,12 @@ class TextEditor {
}
}
- duplicateLines () {
+ // Duplicate all lines containing active selections.
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ duplicateLines (options = {}) {
+ if (!this.ensureWritable('duplicateLines', options)) return
this.transact(() => {
const selections = this.getSelectionsOrderedByBufferPosition()
const previousSelectionRanges = []
@@ -1661,7 +1733,11 @@ class TextEditor {
//
// If the selection is empty, the characters preceding and following the cursor
// are swapped. Otherwise, the selected characters are reversed.
- transpose () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ transpose (options = {}) {
+ if (!this.ensureWritable('transpose', options)) return
this.mutateSelectedText(selection => {
if (selection.isEmpty()) {
selection.selectRight()
@@ -1679,23 +1755,35 @@ class TextEditor {
//
// For each selection, if the selection is empty, converts the containing word
// to upper case. Otherwise convert the selected text to upper case.
- upperCase () {
- this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ upperCase (options = {}) {
+ if (!this.ensureWritable('upperCase', options)) return
+ this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase(options))
}
// Extended: Convert the selected text to lower case.
//
// For each selection, if the selection is empty, converts the containing word
// to upper case. Otherwise convert the selected text to upper case.
- lowerCase () {
- this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ lowerCase (options = {}) {
+ if (!this.ensureWritable('lowerCase', options)) return
+ this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase(options))
}
// Extended: Toggle line comments for rows intersecting selections.
//
// If the current grammar doesn't support comments, does nothing.
- toggleLineCommentsInSelection () {
- this.mutateSelectedText(selection => selection.toggleLineComments())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ toggleLineCommentsInSelection (options = {}) {
+ if (!this.ensureWritable('toggleLineCommentsInSelection', options)) return
+ this.mutateSelectedText(selection => selection.toggleLineComments(options))
}
// Convert multiple lines to a single line.
@@ -1706,20 +1794,32 @@ class TextEditor {
//
// Joining a line means that multiple lines are converted to a single line with
// the contents of each of the original non-empty lines separated by a space.
- joinLines () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ joinLines (options = {}) {
+ if (!this.ensureWritable('joinLines', options)) return
this.mutateSelectedText(selection => selection.joinLines())
}
// Extended: For each cursor, insert a newline at beginning the following line.
- insertNewlineBelow () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ insertNewlineBelow (options = {}) {
+ if (!this.ensureWritable('insertNewlineBelow', options)) return
this.transact(() => {
this.moveToEndOfLine()
- this.insertNewline()
+ this.insertNewline(options)
})
}
// Extended: For each cursor, insert a newline at the end of the preceding line.
- insertNewlineAbove () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ insertNewlineAbove (options = {}) {
+ if (!this.ensureWritable('insertNewlineAbove', options)) return
this.transact(() => {
const bufferRow = this.getCursorBufferPosition().row
const indentLevel = this.indentationForBufferRow(bufferRow)
@@ -1727,7 +1827,7 @@ class TextEditor {
this.moveToBeginningOfLine()
this.moveLeft()
- this.insertNewline()
+ this.insertNewline(options)
if (this.shouldAutoIndent() && (this.indentationForBufferRow(bufferRow) < indentLevel)) {
this.setIndentationForBufferRow(bufferRow, indentLevel)
@@ -1743,62 +1843,117 @@ class TextEditor {
// Extended: For each selection, if the selection is empty, delete all characters
// of the containing word that precede the cursor. Otherwise delete the
// selected text.
- deleteToBeginningOfWord () {
- this.mutateSelectedText(selection => selection.deleteToBeginningOfWord())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToBeginningOfWord (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfWord', options)) return
+ this.mutateSelectedText(selection => selection.deleteToBeginningOfWord(options))
}
// Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the
// previous word boundary.
- deleteToPreviousWordBoundary () {
- this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToPreviousWordBoundary (options = {}) {
+ if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return
+ this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary(options))
}
// Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the
// next word boundary.
- deleteToNextWordBoundary () {
- this.mutateSelectedText(selection => selection.deleteToNextWordBoundary())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToNextWordBoundary (options = {}) {
+ if (!this.ensureWritable('deleteToNextWordBoundary', options)) return
+ this.mutateSelectedText(selection => selection.deleteToNextWordBoundary(options))
}
// Extended: For each selection, if the selection is empty, delete all characters
// of the containing subword following the cursor. Otherwise delete the selected
// text.
- deleteToBeginningOfSubword () {
- this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToBeginningOfSubword (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return
+ this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword(options))
}
// Extended: For each selection, if the selection is empty, delete all characters
// of the containing subword following the cursor. Otherwise delete the selected
// text.
- deleteToEndOfSubword () {
- this.mutateSelectedText(selection => selection.deleteToEndOfSubword())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToEndOfSubword (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfSubword', options)) return
+ this.mutateSelectedText(selection => selection.deleteToEndOfSubword(options))
}
// Extended: For each selection, if the selection is empty, delete all characters
// of the containing line that precede the cursor. Otherwise delete the
// selected text.
- deleteToBeginningOfLine () {
- this.mutateSelectedText(selection => selection.deleteToBeginningOfLine())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToBeginningOfLine (options = {}) {
+ if (!this.ensureWritable('deleteToBeginningOfLine', options)) return
+ this.mutateSelectedText(selection => selection.deleteToBeginningOfLine(options))
}
// Extended: For each selection, if the selection is not empty, deletes the
// selection; otherwise, deletes all characters of the containing line
// following the cursor. If the cursor is already at the end of the line,
// deletes the following newline.
- deleteToEndOfLine () {
- this.mutateSelectedText(selection => selection.deleteToEndOfLine())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToEndOfLine (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfLine', options)) return
+ this.mutateSelectedText(selection => selection.deleteToEndOfLine(options))
}
// Extended: For each selection, if the selection is empty, delete all characters
// of the containing word following the cursor. Otherwise delete the selected
// text.
- deleteToEndOfWord () {
- this.mutateSelectedText(selection => selection.deleteToEndOfWord())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteToEndOfWord (options = {}) {
+ if (!this.ensureWritable('deleteToEndOfWord', options)) return
+ this.mutateSelectedText(selection => selection.deleteToEndOfWord(options))
}
// Extended: Delete all lines intersecting selections.
- deleteLine () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ deleteLine (options = {}) {
+ if (!this.ensureWritable('deleteLine', options)) return
this.mergeSelectionsOnSameRows()
- this.mutateSelectedText(selection => selection.deleteLine())
+ this.mutateSelectedText(selection => selection.deleteLine(options))
+ }
+
+ // Private: Ensure that this editor is not marked read-only before allowing a buffer modification to occur. If
+ // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error.
+ ensureWritable (methodName, opts) {
+ if (!opts.bypassReadOnly && this.isReadOnly()) {
+ if (atom.inDevMode() || atom.inSpecMode()) {
+ const e = new Error('Attempt to mutate a read-only TextEditor')
+ e.detail =
+ `Your package is attempting to call ${methodName} on an editor that has been marked read-only. ` +
+ 'Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before attempting ' +
+ 'modifications.'
+ throw e
+ }
+
+ return false
+ }
+
+ return true
}
/*
@@ -1806,14 +1961,22 @@ class TextEditor {
*/
// Essential: Undo the last change.
- undo () {
- this.avoidMergingSelections(() => this.buffer.undo())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ undo (options = {}) {
+ if (!this.ensureWritable('undo', options)) return
+ this.avoidMergingSelections(() => this.buffer.undo({selectionsMarkerLayer: this.selectionsMarkerLayer}))
this.getLastSelection().autoscroll()
}
// Essential: Redo the last change.
- redo () {
- this.avoidMergingSelections(() => this.buffer.redo())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false)
+ redo (options = {}) {
+ if (!this.ensureWritable('redo', options)) return
+ this.avoidMergingSelections(() => this.buffer.redo({selectionsMarkerLayer: this.selectionsMarkerLayer}))
this.getLastSelection().autoscroll()
}
@@ -1830,7 +1993,13 @@ class TextEditor {
// still 'groupable', the two transactions are merged with respect to undo and redo.
// * `fn` A {Function} to call inside the transaction.
transact (groupingInterval, fn) {
- return this.buffer.transact(groupingInterval, fn)
+ const options = {selectionsMarkerLayer: this.selectionsMarkerLayer}
+ if (typeof groupingInterval === 'function') {
+ fn = groupingInterval
+ } else {
+ options.groupingInterval = groupingInterval
+ }
+ return this.buffer.transact(options, fn)
}
// Extended: Abort an open transaction, undoing any operations performed so far
@@ -1841,7 +2010,9 @@ class TextEditor {
// with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}.
//
// Returns a checkpoint value.
- createCheckpoint () { return this.buffer.createCheckpoint() }
+ createCheckpoint () {
+ return this.buffer.createCheckpoint({selectionsMarkerLayer: this.selectionsMarkerLayer})
+ }
// Extended: Revert the buffer to the state it was in when the given
// checkpoint was created.
@@ -1865,7 +2036,9 @@ class TextEditor {
// * `checkpoint` The checkpoint from which to group changes.
//
// Returns a {Boolean} indicating whether the operation succeeded.
- groupChangesSinceCheckpoint (checkpoint) { return this.buffer.groupChangesSinceCheckpoint(checkpoint) }
+ groupChangesSinceCheckpoint (checkpoint) {
+ return this.buffer.groupChangesSinceCheckpoint(checkpoint, {selectionsMarkerLayer: this.selectionsMarkerLayer})
+ }
/*
Section: TextEditor Coordinates
@@ -1956,11 +2129,11 @@ class TextEditor {
//
// ## Examples
//
- // ```coffee
- // editor.clipBufferPosition([-1, -1]) # -> `[0, 0]`
+ // ```js
+ // editor.clipBufferPosition([-1, -1]) // -> `[0, 0]`
//
- // # When the line at buffer row 2 is 10 characters long
- // editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]`
+ // // When the line at buffer row 2 is 10 characters long
+ // editor.clipBufferPosition([2, Infinity]) // -> `[2, 10]`
// ```
//
// * `bufferPosition` The {Point} representing the position to clip.
@@ -1985,11 +2158,11 @@ class TextEditor {
//
// ## Examples
//
- // ```coffee
- // editor.clipScreenPosition([-1, -1]) # -> `[0, 0]`
+ // ```js
+ // editor.clipScreenPosition([-1, -1]) // -> `[0, 0]`
//
- // # When the line at screen row 2 is 10 characters long
- // editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]`
+ // // When the line at screen row 2 is 10 characters long
+ // editor.clipScreenPosition([2, Infinity]) // -> `[2, 10]`
// ```
//
// * `screenPosition` The {Point} representing the position to clip.
@@ -2673,7 +2846,7 @@ class TextEditor {
return this.cursors.slice()
}
- // Extended: Get all {Cursors}s, ordered by their position in the buffer
+ // Extended: Get all {Cursor}s, ordered by their position in the buffer
// instead of the order in which they were added.
//
// Returns an {Array} of {Selection}s.
@@ -3547,13 +3720,21 @@ class TextEditor {
}
// Extended: Indent rows intersecting selections by one level.
- indentSelectedRows () {
- return this.mutateSelectedText(selection => selection.indentSelectedRows())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ indentSelectedRows (options = {}) {
+ if (!this.ensureWritable('indentSelectedRows', options)) return
+ return this.mutateSelectedText(selection => selection.indentSelectedRows(options))
}
// Extended: Outdent rows intersecting selections by one level.
- outdentSelectedRows () {
- return this.mutateSelectedText(selection => selection.outdentSelectedRows())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ outdentSelectedRows (options = {}) {
+ if (!this.ensureWritable('outdentSelectedRows', options)) return
+ return this.mutateSelectedText(selection => selection.outdentSelectedRows(options))
}
// Extended: Get the indentation level of the given line of text.
@@ -3584,13 +3765,21 @@ class TextEditor {
// Extended: Indent rows intersecting selections based on the grammar's suggested
// indent level.
- autoIndentSelectedRows () {
- return this.mutateSelectedText(selection => selection.autoIndentSelectedRows())
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ autoIndentSelectedRows (options = {}) {
+ if (!this.ensureWritable('autoIndentSelectedRows', options)) return
+ return this.mutateSelectedText(selection => selection.autoIndentSelectedRows(options))
}
// Indent all lines intersecting selections. See {Selection::indent} for more
// information.
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
indent (options = {}) {
+ if (!this.ensureWritable('indent', options)) return
if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndent()
this.mutateSelectedText(selection => selection.indent(options))
}
@@ -3655,7 +3844,10 @@ class TextEditor {
//
// Returns a {ScopeDescriptor}.
scopeDescriptorForBufferPosition (bufferPosition) {
- return this.buffer.getLanguageMode().scopeDescriptorForPosition(bufferPosition)
+ const languageMode = this.buffer.getLanguageMode()
+ return languageMode.scopeDescriptorForPosition
+ ? languageMode.scopeDescriptorForPosition(bufferPosition)
+ : new ScopeDescriptor({scopes: ['text']})
}
// Extended: Get the range in buffer coordinates of all tokens surrounding the
@@ -3725,14 +3917,18 @@ class TextEditor {
}
// Essential: For each selection, cut the selected text.
- cutSelectedText () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ cutSelectedText (options = {}) {
+ if (!this.ensureWritable('cutSelectedText', options)) return
let maintainClipboard = false
this.mutateSelectedText(selection => {
if (selection.isEmpty()) {
selection.selectLine()
- selection.cut(maintainClipboard, true)
+ selection.cut(maintainClipboard, true, options.bypassReadOnly)
} else {
- selection.cut(maintainClipboard, false)
+ selection.cut(maintainClipboard, false, options.bypassReadOnly)
}
maintainClipboard = true
})
@@ -3746,7 +3942,8 @@ class TextEditor {
// corresponding clipboard selection text.
//
// * `options` (optional) See {Selection::insertText}.
- pasteText (options) {
+ pasteText (options = {}) {
+ if (!this.ensureWritable('parseText', options)) return
options = Object.assign({}, options)
let {text: clipboardText, metadata} = this.constructor.clipboard.readWithMetadata()
if (!this.emitWillInsertTextEvent(clipboardText)) return false
@@ -3787,10 +3984,14 @@ class TextEditor {
// Essential: For each selection, if the selection is empty, cut all characters
// of the containing screen line following the cursor. Otherwise cut the selected
// text.
- cutToEndOfLine () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ cutToEndOfLine (options = {}) {
+ if (!this.ensureWritable('cutToEndOfLine', options)) return
let maintainClipboard = false
this.mutateSelectedText(selection => {
- selection.cutToEndOfLine(maintainClipboard)
+ selection.cutToEndOfLine(maintainClipboard, options)
maintainClipboard = true
})
}
@@ -3798,10 +3999,14 @@ class TextEditor {
// Essential: For each selection, if the selection is empty, cut all characters
// of the containing buffer line following the cursor. Otherwise cut the
// selected text.
- cutToEndOfBufferLine () {
+ //
+ // * `options` (optional) {Object}
+ // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor.
+ cutToEndOfBufferLine (options = {}) {
+ if (!this.ensureWritable('cutToEndOfBufferLine', options)) return
let maintainClipboard = false
this.mutateSelectedText(selection => {
- selection.cutToEndOfBufferLine(maintainClipboard)
+ selection.cutToEndOfBufferLine(maintainClipboard, options)
maintainClipboard = true
})
}
@@ -3893,7 +4098,7 @@ class TextEditor {
// Extended: Unfold all existing folds.
unfoldAll () {
const result = this.displayLayer.destroyAllFolds()
- this.scrollToCursorPosition()
+ if (result.length > 0) this.scrollToCursorPosition()
return result
}
@@ -4011,6 +4216,29 @@ class TextEditor {
// window. (default: -100)
// * `visible` (optional) {Boolean} specifying whether the gutter is visible
// initially after being created. (default: true)
+ // * `type` (optional) {String} specifying the type of gutter to create. `'decorated'`
+ // gutters are useful as a destination for decorations created with {Gutter::decorateMarker}.
+ // `'line-number'` gutters.
+ // * `class` (optional) {String} added to the CSS classnames of the gutter's root DOM element.
+ // * `labelFn` (optional) {Function} called by a `'line-number'` gutter to generate the label for each line number
+ // element. Should return a {String} that will be used to label the corresponding line.
+ // * `lineData` an {Object} containing information about each line to label.
+ // * `bufferRow` {Number} indicating the zero-indexed buffer index of this line.
+ // * `screenRow` {Number} indicating the zero-indexed screen index.
+ // * `foldable` {Boolean} that is `true` if a fold may be created here.
+ // * `softWrapped` {Boolean} if this screen row is the soft-wrapped continuation of the same buffer row.
+ // * `maxDigits` {Number} the maximum number of digits necessary to represent any known screen row.
+ // * `onMouseDown` (optional) {Function} to be called when a mousedown event is received by a line-number
+ // element within this `type: 'line-number'` {Gutter}. If unspecified, the default behavior is to select the
+ // clicked buffer row.
+ // * `lineData` an {Object} containing information about the line that's being clicked.
+ // * `bufferRow` {Number} of the originating line element
+ // * `screenRow` {Number}
+ // * `onMouseMove` (optional) {Function} to be called when a mousemove event occurs on a line-number element within
+ // within this `type: 'line-number'` {Gutter}.
+ // * `lineData` an {Object} containing information about the line that's being clicked.
+ // * `bufferRow` {Number} of the originating line element
+ // * `screenRow` {Number}
//
// Returns the newly-created {Gutter}.
addGutter (options) {
@@ -4615,7 +4843,7 @@ class TextEditor {
let endRow = bufferRow
const rowCount = this.getLineCount()
- while (endRow < rowCount) {
+ while (endRow + 1 < rowCount) {
if (!NON_WHITESPACE_REGEXP.test(this.lineTextForBufferRow(endRow + 1))) break
if (languageMode.isRowCommented(endRow + 1) !== isCommented) break
endRow++
diff --git a/src/text-mate-language-mode.js b/src/text-mate-language-mode.js
index 1a7cb6d2e..471af9af2 100644
--- a/src/text-mate-language-mode.js
+++ b/src/text-mate-language-mode.js
@@ -7,6 +7,7 @@ const ScopeDescriptor = require('./scope-descriptor')
const NullGrammar = require('./null-grammar')
const {OnigRegExp} = require('oniguruma')
const {toFirstMateScopeId, fromFirstMateScopeId} = require('./first-mate-helpers')
+const {selectorMatchesAnyScope} = require('./selectors')
const NON_WHITESPACE_REGEX = /\S/
@@ -235,15 +236,18 @@ class TextMateLanguageMode {
return this.buffer.getTextInRange([[0, 0], [10, 0]])
}
- hasTokenForSelector (selector) {
+ updateForInjection (grammar) {
+ if (!grammar.injectionSelector) return
for (const tokenizedLine of this.tokenizedLines) {
if (tokenizedLine) {
for (let token of tokenizedLine.tokens) {
- if (selector.matches(token.scopes)) return true
+ if (grammar.injectionSelector.matches(token.scopes)) {
+ this.retokenizeLines()
+ return
+ }
}
}
}
- return false
}
retokenizeLines () {
@@ -605,7 +609,7 @@ class TextMateLanguageMode {
for (let row = point.row - 1; row >= 0; row--) {
const endRow = this.endRowForFoldAtRow(row, tabLength)
- if (endRow != null && endRow > point.row) {
+ if (endRow != null && endRow >= point.row) {
return Range(Point(row, Infinity), Point(endRow, Infinity))
}
}
@@ -723,14 +727,6 @@ class TextMateLanguageMode {
TextMateLanguageMode.prototype.chunkSize = 50
-function selectorMatchesAnyScope (selector, scopes) {
- const targetClasses = selector.replace(/^\./, '').split('.')
- return scopes.some((scope) => {
- const scopeClasses = scope.split('.')
- return _.isSubset(targetClasses, scopeClasses)
- })
-}
-
class TextMateHighlightIterator {
constructor (languageMode) {
this.languageMode = languageMode
diff --git a/src/theme-manager.js b/src/theme-manager.js
index 68a5eb45a..389dd1bc3 100644
--- a/src/theme-manager.js
+++ b/src/theme-manager.js
@@ -103,7 +103,7 @@ class ThemeManager {
warnForNonExistentThemes () {
let themeNames = this.config.get('core.themes') || []
- if (!_.isArray(themeNames)) { themeNames = [themeNames] }
+ if (!Array.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.`)
@@ -116,7 +116,7 @@ class ThemeManager {
// 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] }
+ if (!Array.isArray(themeNames)) { themeNames = [themeNames] }
themeNames = themeNames.filter((themeName) =>
(typeof themeName === 'string') && this.packageManager.resolvePackagePath(themeName)
)
@@ -138,7 +138,7 @@ class ThemeManager {
if (themeNames.length === 0) {
themeNames = ['one-dark-syntax', 'one-dark-ui']
} else if (themeNames.length === 1) {
- if (_.endsWith(themeNames[0], '-ui')) {
+ if (themeNames[0].endsWith('-ui')) {
themeNames.unshift('one-dark-syntax')
} else {
themeNames.push('one-dark-ui')
diff --git a/src/tooltip-manager.js b/src/tooltip-manager.js
index 34f96775b..07b4f196a 100644
--- a/src/tooltip-manager.js
+++ b/src/tooltip-manager.js
@@ -153,9 +153,11 @@ class TooltipManager {
}
window.addEventListener('resize', hideTooltip)
+ window.addEventListener('keydown', hideTooltip)
const disposable = new Disposable(() => {
window.removeEventListener('resize', hideTooltip)
+ window.removeEventListener('keydown', hideTooltip)
hideTooltip()
tooltip.destroy()
diff --git a/src/tree-sitter-grammar.js b/src/tree-sitter-grammar.js
index d00344fb1..fc572221a 100644
--- a/src/tree-sitter-grammar.js
+++ b/src/tree-sitter-grammar.js
@@ -6,12 +6,17 @@ module.exports =
class TreeSitterGrammar {
constructor (registry, filePath, params) {
this.registry = registry
- this.id = params.id
this.name = params.name
- this.legacyScopeName = params.legacyScopeName
- if (params.contentRegExp) this.contentRegExp = new RegExp(params.contentRegExp)
+ this.scopeName = params.scopeName
+
+ // TODO - Remove the `RegExp` spelling and only support `Regex`, once all of the existing
+ // Tree-sitter grammars are updated to spell it `Regex`.
+ this.contentRegex = buildRegex(params.contentRegex || params.contentRegExp)
+ this.injectionRegex = buildRegex(params.injectionRegex || params.injectionRegExp)
+ this.firstLineRegex = buildRegex(params.firstLineRegex)
this.folds = params.folds || []
+ this.folds.forEach(normalizeFoldSpecification)
this.commentStrings = {
commentStartString: params.comments && params.comments.start,
@@ -20,14 +25,22 @@ class TreeSitterGrammar {
const scopeSelectors = {}
for (const key in params.scopes || {}) {
- scopeSelectors[key] = params.scopes[key]
- .split('.')
- .map(s => `syntax--${s}`)
- .join(' ')
+ const classes = toSyntaxClasses(params.scopes[key])
+ const selectors = key.split(/,\s+/)
+ for (let selector of selectors) {
+ selector = selector.trim()
+ if (!selector) continue
+ if (scopeSelectors[selector]) {
+ scopeSelectors[selector] = [].concat(scopeSelectors[selector], classes)
+ } else {
+ scopeSelectors[selector] = classes
+ }
+ }
}
this.scopeMap = new SyntaxScopeMap(scopeSelectors)
- this.fileTypes = params.fileTypes
+ this.fileTypes = params.fileTypes || []
+ this.injectionPoints = params.injectionPoints || []
// TODO - When we upgrade to a new enough version of node, use `require.resolve`
// with the new `paths` option instead of this private API.
@@ -39,11 +52,16 @@ class TreeSitterGrammar {
this.languageModule = require(languageModulePath)
this.scopesById = new Map()
+ this.conciseScopesById = new Map()
this.idsByScope = {}
this.nextScopeId = 256 + 1
this.registration = null
}
+ inspect () {
+ return `TreeSitterGrammar {scopeName: ${this.scopeName}}`
+ }
+
idForScope (scope) {
let id = this.idsByScope[scope]
if (!id) {
@@ -58,8 +76,15 @@ class TreeSitterGrammar {
return this.scopesById.get(id)
}
- get scopeName () {
- return this.id
+ scopeNameForScopeId (id) {
+ let result = this.conciseScopesById.get(id)
+ if (!result) {
+ result = this.scopesById.get(id)
+ .slice('syntax--'.length)
+ .replace(/ syntax--/g, '.')
+ this.conciseScopesById.set(id, result)
+ }
+ return result
}
activate () {
@@ -70,3 +95,56 @@ class TreeSitterGrammar {
if (this.registration) this.registration.dispose()
}
}
+
+const toSyntaxClasses = scopes =>
+ typeof scopes === 'string'
+ ? scopes
+ .split('.')
+ .map(s => `syntax--${s}`)
+ .join(' ')
+ : Array.isArray(scopes)
+ ? scopes.map(toSyntaxClasses)
+ : scopes.match
+ ? {match: new RegExp(scopes.match), scopes: toSyntaxClasses(scopes.scopes)}
+ : Object.assign({}, scopes, {scopes: toSyntaxClasses(scopes.scopes)})
+
+const NODE_NAME_REGEX = /[\w_]+/
+
+function matcherForSpec (spec) {
+ if (typeof spec === 'string') {
+ if (spec[0] === '"' && spec[spec.length - 1] === '"') {
+ return {
+ type: spec.substr(1, spec.length - 2),
+ named: false
+ }
+ }
+
+ if (!NODE_NAME_REGEX.test(spec)) {
+ return {type: spec, named: false}
+ }
+
+ return {type: spec, named: true}
+ }
+ return spec
+}
+
+function normalizeFoldSpecification (spec) {
+ if (spec.type) {
+ if (Array.isArray(spec.type)) {
+ spec.matchers = spec.type.map(matcherForSpec)
+ } else {
+ spec.matchers = [matcherForSpec(spec.type)]
+ }
+ }
+
+ if (spec.start) normalizeFoldSpecification(spec.start)
+ if (spec.end) normalizeFoldSpecification(spec.end)
+}
+
+function buildRegex (value) {
+ // Allow multiple alternatives to be specified via an array, for
+ // readability of the grammar file
+ if (Array.isArray(value)) value = value.map(_ => `(${_})`).join('|')
+ if (typeof value === 'string') return new RegExp(value)
+ return null
+}
diff --git a/src/tree-sitter-language-mode.js b/src/tree-sitter-language-mode.js
index 41c87ba00..103b8816e 100644
--- a/src/tree-sitter-language-mode.js
+++ b/src/tree-sitter-language-mode.js
@@ -1,48 +1,117 @@
-const {Document} = require('tree-sitter')
-const {Point, Range, Emitter} = require('atom')
+const Parser = require('tree-sitter')
+const {Point, Range, spliceArray} = require('text-buffer')
+const {Patch} = require('superstring')
+const {Emitter} = require('event-kit')
const ScopeDescriptor = require('./scope-descriptor')
const TokenizedLine = require('./tokenized-line')
const TextMateLanguageMode = require('./text-mate-language-mode')
+const {matcherForSelector} = require('./selectors')
let nextId = 0
+const MAX_RANGE = new Range(Point.ZERO, Point.INFINITY).freeze()
+const PARSER_POOL = []
+const WORD_REGEX = /\w/
-module.exports =
class TreeSitterLanguageMode {
- constructor ({buffer, grammar, config}) {
+ static _patchSyntaxNode () {
+ if (!Parser.SyntaxNode.prototype.hasOwnProperty('range')) {
+ Object.defineProperty(Parser.SyntaxNode.prototype, 'range', {
+ get () {
+ return rangeForNode(this)
+ }
+ })
+ }
+ }
+
+ constructor ({buffer, grammar, config, grammars, syncOperationLimit}) {
+ TreeSitterLanguageMode._patchSyntaxNode()
this.id = nextId++
this.buffer = buffer
this.grammar = grammar
this.config = config
- this.document = new Document()
- this.document.setInput(new TreeSitterTextBufferInput(buffer))
- this.document.setLanguage(grammar.languageModule)
- this.document.parse()
- this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.id]})
+ this.grammarRegistry = grammars
+ this.parser = new Parser()
+ this.rootLanguageLayer = new LanguageLayer(this, grammar)
+ this.injectionsMarkerLayer = buffer.addMarkerLayer()
+
+ if (syncOperationLimit != null) {
+ this.syncOperationLimit = syncOperationLimit
+ }
+
+ this.rootScopeDescriptor = new ScopeDescriptor({scopes: [this.grammar.scopeName]})
this.emitter = new Emitter()
this.isFoldableCache = []
+ this.hasQueuedParse = false
+
+ this.grammarForLanguageString = this.grammarForLanguageString.bind(this)
+ this.emitRangeUpdate = this.emitRangeUpdate.bind(this)
+
+ this.subscription = this.buffer.onDidChangeText(({changes}) => {
+ for (let i = 0, {length} = changes; i < length; i++) {
+ const {oldRange, newRange} = changes[i]
+ spliceArray(
+ this.isFoldableCache,
+ newRange.start.row,
+ oldRange.end.row - oldRange.start.row,
+ {length: newRange.end.row - newRange.start.row}
+ )
+ }
+
+ this.rootLanguageLayer.update(null)
+ })
+
+ this.rootLanguageLayer.update(null).then(() =>
+ this.emitter.emit('did-tokenize')
+ )
// TODO: Remove this once TreeSitterLanguageMode implements its own auto-indentation system. This
// is temporarily needed in order to delegate to the TextMateLanguageMode's auto-indent system.
this.regexesByPattern = {}
}
- getLanguageId () {
- return this.grammar.id
+ destroy () {
+ this.injectionsMarkerLayer.destroy()
+ this.subscription.dispose()
+ this.rootLanguageLayer = null
+ this.parser = null
}
- bufferDidChange ({oldRange, newRange, oldText, newText}) {
- const startRow = oldRange.start.row
- const oldEndRow = oldRange.end.row
- const newEndRow = newRange.end.row
- this.isFoldableCache.splice(startRow, oldEndRow - startRow, ...new Array(newEndRow - startRow))
- this.document.edit({
- startIndex: this.buffer.characterIndexForPosition(oldRange.start),
- lengthRemoved: oldText.length,
- lengthAdded: newText.length,
- startPosition: oldRange.start,
- extentRemoved: oldRange.getExtent(),
- extentAdded: newRange.getExtent()
+ getLanguageId () {
+ return this.grammar.scopeName
+ }
+
+ bufferDidChange (change) {
+ this.rootLanguageLayer.handleTextChange(change)
+ for (const marker of this.injectionsMarkerLayer.getMarkers()) {
+ marker.languageLayer.handleTextChange(change)
+ }
+ }
+
+ parse (language, oldTree, ranges) {
+ const parser = PARSER_POOL.pop() || new Parser()
+ parser.setLanguage(language)
+ const result = parser.parseTextBuffer(this.buffer.buffer, oldTree, {
+ syncOperationLimit: this.syncOperationLimit,
+ includedRanges: ranges
})
+
+ if (result.then) {
+ return result.then(tree => {
+ PARSER_POOL.push(parser)
+ return tree
+ })
+ } else {
+ PARSER_POOL.push(parser)
+ return result
+ }
+ }
+
+ get tree () {
+ return this.rootLanguageLayer.tree
+ }
+
+ updateForInjection (grammar) {
+ this.rootLanguageLayer.updateInjections(grammar)
}
/*
@@ -50,21 +119,20 @@ class TreeSitterLanguageMode {
*/
buildHighlightIterator () {
- const invalidatedRanges = this.document.parse()
- for (let i = 0, n = invalidatedRanges.length; i < n; i++) {
- const range = invalidatedRanges[i]
- const startRow = range.start.row
- const endRow = range.end.row
- for (let row = startRow; row < endRow; row++) {
- this.isFoldableCache[row] = undefined
- }
- this.emitter.emit('did-change-highlighting', range)
- }
- return new TreeSitterHighlightIterator(this)
+ if (!this.rootLanguageLayer) return new NullHighlightIterator()
+ const layerIterators = [
+ this.rootLanguageLayer.buildHighlightIterator(),
+ ...this.injectionsMarkerLayer.getMarkers().map(m => m.languageLayer.buildHighlightIterator())
+ ]
+ return new HighlightIterator(this, layerIterators)
+ }
+
+ onDidTokenize (callback) {
+ return this.emitter.on('did-tokenize', callback)
}
onDidChangeHighlighting (callback) {
- return this.emitter.on('did-change-hightlighting', callback)
+ return this.emitter.on('did-change-highlighting', callback)
}
classNameForScopeId (scopeId) {
@@ -79,7 +147,15 @@ class TreeSitterLanguageMode {
return this.grammar.commentStrings
}
- isRowCommented () {
+ isRowCommented (row) {
+ const firstNonWhitespaceRange = this.buffer.findInRangeSync(
+ /\S/,
+ new Range(new Point(row, 0), new Point(row, Infinity))
+ )
+ if (firstNonWhitespaceRange) {
+ const firstNode = this.getSyntaxNodeContainingRange(firstNonWhitespaceRange)
+ if (firstNode) return firstNode.type.includes('comment')
+ }
return false
}
@@ -136,13 +212,17 @@ class TreeSitterLanguageMode {
return this.getFoldableRangesAtIndentLevel(null)
}
+ /**
+ * TODO: Make this method generate folds for nested languages (currently,
+ * folds are only generated for the root language layer).
+ */
getFoldableRangesAtIndentLevel (goalLevel) {
let result = []
- let stack = [{node: this.document.rootNode, level: 0}]
+ let stack = [{node: this.tree.rootNode, level: 0}]
while (stack.length > 0) {
const {node, level} = stack.pop()
- const range = this.getFoldableRangeForNode(node)
+ const range = this.getFoldableRangeForNode(node, this.grammar)
if (range) {
if (goalLevel == null || level === goalLevel) {
let updatedExistingRange = false
@@ -182,54 +262,71 @@ class TreeSitterLanguageMode {
}
getFoldableRangeContainingPoint (point, tabLength, existenceOnly = false) {
- let node = this.document.rootNode.descendantForPosition(this.buffer.clipPosition(point))
- while (node) {
- if (existenceOnly && node.startPosition.row < point.row) break
- if (node.endPosition.row > point.row) {
- const range = this.getFoldableRangeForNode(node, existenceOnly)
- if (range) return range
+ if (!this.tree) return null
+
+ let smallestRange
+ this._forEachTreeWithRange(new Range(point, point), (tree, grammar) => {
+ let node = tree.rootNode.descendantForPosition(this.buffer.clipPosition(point))
+ while (node) {
+ if (existenceOnly && node.startPosition.row < point.row) return
+ if (node.endPosition.row > point.row) {
+ const range = this.getFoldableRangeForNode(node, grammar)
+ if (range && rangeIsSmaller(range, smallestRange)) {
+ smallestRange = range
+ return
+ }
+ }
+ node = node.parent
}
- node = node.parent
+ })
+
+ return existenceOnly
+ ? smallestRange && smallestRange.start.row === point.row
+ : smallestRange
+ }
+
+ _forEachTreeWithRange (range, callback) {
+ if (this.rootLanguageLayer.tree) {
+ callback(this.rootLanguageLayer.tree, this.rootLanguageLayer.grammar)
+ }
+
+ const injectionMarkers = this.injectionsMarkerLayer.findMarkers({
+ intersectsRange: range
+ })
+
+ for (const injectionMarker of injectionMarkers) {
+ const {tree, grammar} = injectionMarker.languageLayer
+ if (tree) callback(tree, grammar)
}
}
- getFoldableRangeForNode (node, existenceOnly) {
- const {children, type: nodeType} = node
+ getFoldableRangeForNode (node, grammar, existenceOnly) {
+ const {children} = node
const childCount = children.length
- let childTypes
- for (var i = 0, {length} = this.grammar.folds; i < length; i++) {
- const foldEntry = this.grammar.folds[i]
+ for (var i = 0, {length} = grammar.folds; i < length; i++) {
+ const foldSpec = grammar.folds[i]
- if (foldEntry.type) {
- if (typeof foldEntry.type === 'string') {
- if (foldEntry.type !== nodeType) continue
- } else {
- if (!foldEntry.type.includes(nodeType)) continue
- }
- }
+ if (foldSpec.matchers && !hasMatchingFoldSpec(foldSpec.matchers, node)) continue
let foldStart
- const startEntry = foldEntry.start
+ const startEntry = foldSpec.start
if (startEntry) {
+ let foldStartNode
if (startEntry.index != null) {
- const child = children[startEntry.index]
- if (!child || (startEntry.type && startEntry.type !== child.type)) continue
- foldStart = child.endPosition
+ foldStartNode = children[startEntry.index]
+ if (!foldStartNode || startEntry.matchers && !hasMatchingFoldSpec(startEntry.matchers, foldStartNode)) continue
} else {
- if (!childTypes) childTypes = children.map(child => child.type)
- const index = typeof startEntry.type === 'string'
- ? childTypes.indexOf(startEntry.type)
- : childTypes.findIndex(type => startEntry.type.includes(type))
- if (index === -1) continue
- foldStart = children[index].endPosition
+ foldStartNode = children.find(child => hasMatchingFoldSpec(startEntry.matchers, child))
+ if (!foldStartNode) continue
}
+ foldStart = new Point(foldStartNode.endPosition.row, Infinity)
} else {
foldStart = new Point(node.startPosition.row, Infinity)
}
let foldEnd
- const endEntry = foldEntry.end
+ const endEntry = foldSpec.end
if (endEntry) {
let foldEndNode
if (endEntry.index != null) {
@@ -237,18 +334,17 @@ class TreeSitterLanguageMode {
foldEndNode = children[index]
if (!foldEndNode || (endEntry.type && endEntry.type !== foldEndNode.type)) continue
} else {
- if (!childTypes) childTypes = children.map(foldEndNode => foldEndNode.type)
- const index = typeof endEntry.type === 'string'
- ? childTypes.indexOf(endEntry.type)
- : childTypes.findIndex(type => endEntry.type.includes(type))
- if (index === -1) continue
- foldEndNode = children[index]
+ foldEndNode = children.find(child => hasMatchingFoldSpec(endEntry.matchers, child))
+ if (!foldEndNode) continue
}
- if (foldEndNode.endIndex - foldEndNode.startIndex > 1 && foldEndNode.startPosition.row > foldStart.row) {
- foldEnd = new Point(foldEndNode.startPosition.row - 1, Infinity)
- } else {
- foldEnd = foldEndNode.startPosition
+ if (foldEndNode.startPosition.row <= foldStart.row) continue
+
+ foldEnd = foldEndNode.startPosition
+ if (this.buffer.findInRangeSync(
+ WORD_REGEX, new Range(foldEnd, new Point(foldEnd.row, Infinity))
+ )) {
+ foldEnd = new Point(foldEnd.row - 1, Infinity)
}
} else {
const {endPosition} = node
@@ -266,17 +362,46 @@ class TreeSitterLanguageMode {
}
/*
- Syntax Tree APIs
+ Section - Syntax Tree APIs
*/
- getRangeForSyntaxNodeContainingRange (range) {
+ getSyntaxNodeContainingRange (range, where = _ => true) {
const startIndex = this.buffer.characterIndexForPosition(range.start)
const endIndex = this.buffer.characterIndexForPosition(range.end)
- let node = this.document.rootNode.descendantForIndex(startIndex, endIndex - 1)
- while (node && node.startIndex === startIndex && node.endIndex === endIndex) {
- node = node.parent
+ const searchEndIndex = Math.max(0, endIndex - 1)
+
+ let smallestNode
+ this._forEachTreeWithRange(range, tree => {
+ let node = tree.rootNode.descendantForIndex(startIndex, searchEndIndex)
+ while (node) {
+ if (nodeContainsIndices(node, startIndex, endIndex) && where(node)) {
+ if (nodeIsSmaller(node, smallestNode)) smallestNode = node
+ break
+ }
+ node = node.parent
+ }
+ })
+
+ return smallestNode
+ }
+
+ getRangeForSyntaxNodeContainingRange (range, where) {
+ const node = this.getSyntaxNodeContainingRange(range, where)
+ return node && node.range
+ }
+
+ getSyntaxNodeAtPosition (position, where) {
+ return this.getSyntaxNodeContainingRange(new Range(position, position), where)
+ }
+
+ bufferRangeForScopeAtPosition (selector, position) {
+ if (typeof selector === 'string') {
+ const match = matcherForSelector(selector)
+ selector = ({type}) => match(type)
}
- if (node) return new Range(node.startPosition, node.endPosition)
+ if (selector === null) selector = undefined
+ const node = this.getSyntaxNodeAtPosition(position, selector)
+ return node && node.range
}
/*
@@ -296,51 +421,331 @@ class TreeSitterLanguageMode {
}
scopeDescriptorForPosition (point) {
- const result = []
- let node = this.document.rootNode.descendantForPosition(point)
-
- // Don't include anonymous token types like '(' because they prevent scope chains
- // from being parsed as CSS selectors by the `slick` parser. Other css selector
- // parsers like `postcss-selector-parser` do allow arbitrary quoted strings in
- // selectors.
- if (!node.isNamed) node = node.parent
-
- while (node) {
- result.push(node.type)
- node = node.parent
+ const iterator = this.buildHighlightIterator()
+ const scopes = []
+ for (const scope of iterator.seek(point)) {
+ scopes.push(this.grammar.scopeNameForScopeId(scope, false))
}
- result.push(this.grammar.id)
- return new ScopeDescriptor({scopes: result.reverse()})
- }
-
- hasTokenForSelector (scopeSelector) {
- return false
+ for (const scope of iterator.getOpenScopeIds()) {
+ scopes.push(this.grammar.scopeNameForScopeId(scope, false))
+ }
+ return new ScopeDescriptor({scopes})
}
getGrammar () {
return this.grammar
}
+
+ /*
+ Section - Private
+ */
+
+ grammarForLanguageString (languageString) {
+ return this.grammarRegistry.treeSitterGrammarForLanguageString(languageString)
+ }
+
+ emitRangeUpdate (range) {
+ const startRow = range.start.row
+ const endRow = range.end.row
+ for (let row = startRow; row < endRow; row++) {
+ this.isFoldableCache[row] = undefined
+ }
+ this.emitter.emit('did-change-highlighting', range)
+ }
}
-class TreeSitterHighlightIterator {
- constructor (layer, document) {
- this.layer = layer
+class LanguageLayer {
+ constructor (languageMode, grammar, contentChildTypes) {
+ this.languageMode = languageMode
+ this.grammar = grammar
+ this.tree = null
+ this.currentParsePromise = null
+ this.patchSinceCurrentParseStarted = null
+ this.contentChildTypes = contentChildTypes
+ }
- // Conceptually, the iterator represents a single position in the text. It stores this
- // position both as a character index and as a `Point`. This position corresponds to a
- // leaf node of the syntax tree, which either contains or follows the iterator's
- // textual position. The `currentNode` property represents that leaf node, and
- // `currentChildIndex` represents the child index of that leaf node within its parent.
- this.currentIndex = null
- this.currentPosition = null
- this.currentNode = null
- this.currentChildIndex = null
+ buildHighlightIterator () {
+ if (this.tree) {
+ return new LayerHighlightIterator(this, this.tree.walk())
+ } else {
+ return new NullHighlightIterator()
+ }
+ }
+
+ handleTextChange ({oldRange, newRange, oldText, newText}) {
+ if (this.tree) {
+ this.tree.edit(this._treeEditForBufferChange(
+ oldRange.start, oldRange.end, newRange.end, oldText, newText
+ ))
+
+ if (this.editedRange) {
+ if (newRange.start.isLessThan(this.editedRange.start)) {
+ this.editedRange.start = newRange.start
+ }
+ if (oldRange.end.isLessThan(this.editedRange.end)) {
+ this.editedRange.end = newRange.end.traverse(this.editedRange.end.traversalFrom(oldRange.end))
+ } else {
+ this.editedRange.end = newRange.end
+ }
+ } else {
+ this.editedRange = newRange.copy()
+ }
+ }
+
+ if (this.patchSinceCurrentParseStarted) {
+ this.patchSinceCurrentParseStarted.splice(
+ oldRange.start,
+ oldRange.end,
+ newRange.end,
+ oldText,
+ newText
+ )
+ }
+ }
+
+ destroy () {
+ for (const marker of this.languageMode.injectionsMarkerLayer.getMarkers()) {
+ if (marker.parentLanguageLayer === this) {
+ marker.languageLayer.destroy()
+ marker.destroy()
+ }
+ }
+ }
+
+ async update (nodeRangeSet) {
+ if (!this.currentParsePromise) {
+ do {
+ this.currentParsePromise = this._performUpdate(nodeRangeSet)
+ await this.currentParsePromise
+ } while (this.tree && this.tree.rootNode.hasChanges())
+ this.currentParsePromise = null
+ }
+ }
+
+ updateInjections (grammar) {
+ if (grammar.injectionRegex) {
+ if (!this.currentParsePromise) this.currentParsePromise = Promise.resolve()
+ this.currentParsePromise = this.currentParsePromise.then(async () => {
+ await this._populateInjections(MAX_RANGE, null)
+ this.currentParsePromise = null
+ })
+ }
+ }
+
+ async _performUpdate (nodeRangeSet) {
+ let includedRanges = null
+ if (nodeRangeSet) {
+ includedRanges = nodeRangeSet.getRanges()
+ if (includedRanges.length === 0) {
+ this.tree = null
+ return
+ }
+ }
+
+ let affectedRange = this.editedRange
+ this.editedRange = null
+
+ this.patchSinceCurrentParseStarted = new Patch()
+ let tree = this.languageMode.parse(
+ this.grammar.languageModule,
+ this.tree,
+ includedRanges
+ )
+ if (tree.then) tree = await tree
+ tree.buffer = this.languageMode.buffer
+
+ const changes = this.patchSinceCurrentParseStarted.getChanges()
+ this.patchSinceCurrentParseStarted = null
+ for (let i = changes.length - 1; i >= 0; i--) {
+ const {oldStart, oldEnd, newEnd, oldText, newText} = changes[i]
+ tree.edit(this._treeEditForBufferChange(
+ oldStart, oldEnd, newEnd, oldText, newText
+ ))
+ }
+
+ if (this.tree) {
+ const rangesWithSyntaxChanges = this.tree.getChangedRanges(tree)
+ this.tree = tree
+
+ if (!affectedRange) return
+ if (rangesWithSyntaxChanges.length > 0) {
+ for (const range of rangesWithSyntaxChanges) {
+ this.languageMode.emitRangeUpdate(rangeForNode(range))
+ }
+
+ affectedRange = affectedRange.union(new Range(
+ rangesWithSyntaxChanges[0].startPosition,
+ last(rangesWithSyntaxChanges).endPosition
+ ))
+ } else {
+ this.languageMode.emitRangeUpdate(affectedRange)
+ }
+ } else {
+ this.tree = tree
+ this.languageMode.emitRangeUpdate(rangeForNode(tree.rootNode))
+ if (includedRanges) {
+ affectedRange = new Range(includedRanges[0].startPosition, last(includedRanges).endPosition)
+ } else {
+ affectedRange = MAX_RANGE
+ }
+ }
+
+ await this._populateInjections(affectedRange, nodeRangeSet)
+ }
+
+ _populateInjections (range, nodeRangeSet) {
+ const {injectionsMarkerLayer, grammarForLanguageString} = this.languageMode
+
+ const existingInjectionMarkers = injectionsMarkerLayer
+ .findMarkers({intersectsRange: range})
+ .filter(marker => marker.parentLanguageLayer === this)
+
+ if (existingInjectionMarkers.length > 0) {
+ range = range.union(new Range(
+ existingInjectionMarkers[0].getRange().start,
+ last(existingInjectionMarkers).getRange().end
+ ))
+ }
+
+ const markersToUpdate = new Map()
+ for (const injectionPoint of this.grammar.injectionPoints) {
+ const nodes = this.tree.rootNode.descendantsOfType(
+ injectionPoint.type,
+ range.start,
+ range.end
+ )
+
+ for (const node of nodes) {
+ const languageName = injectionPoint.language(node)
+ if (!languageName) continue
+
+ const grammar = grammarForLanguageString(languageName)
+ if (!grammar) continue
+
+ const contentNodes = injectionPoint.content(node)
+ if (!contentNodes) continue
+
+ const injectionNodes = [].concat(contentNodes)
+ if (!injectionNodes.length) continue
+
+ const injectionRange = rangeForNode(node)
+ let marker = existingInjectionMarkers.find(m =>
+ m.getRange().isEqual(injectionRange) &&
+ m.languageLayer.grammar === grammar
+ )
+ if (!marker) {
+ marker = injectionsMarkerLayer.markRange(injectionRange)
+ marker.languageLayer = new LanguageLayer(this.languageMode, grammar, injectionPoint.contentChildTypes)
+ marker.parentLanguageLayer = this
+ }
+
+ markersToUpdate.set(marker, new NodeRangeSet(nodeRangeSet, injectionNodes))
+ }
+ }
+
+ for (const marker of existingInjectionMarkers) {
+ if (!markersToUpdate.has(marker)) {
+ marker.languageLayer.destroy()
+ this.languageMode.emitRangeUpdate(marker.getRange())
+ marker.destroy()
+ }
+ }
+
+ const promises = []
+ for (const [marker, nodeRangeSet] of markersToUpdate) {
+ promises.push(marker.languageLayer.update(nodeRangeSet))
+ }
+ return Promise.all(promises)
+ }
+
+ _treeEditForBufferChange (start, oldEnd, newEnd, oldText, newText) {
+ const startIndex = this.languageMode.buffer.characterIndexForPosition(start)
+ return {
+ startIndex,
+ oldEndIndex: startIndex + oldText.length,
+ newEndIndex: startIndex + newText.length,
+ startPosition: start,
+ oldEndPosition: oldEnd,
+ newEndPosition: newEnd
+ }
+ }
+}
+
+class HighlightIterator {
+ constructor (languageMode, iterators) {
+ this.languageMode = languageMode
+ this.iterators = iterators.sort((a, b) => b.getIndex() - a.getIndex())
+ }
+
+ seek (targetPosition) {
+ const containingTags = []
+ const containingTagStartIndices = []
+ const targetIndex = this.languageMode.buffer.characterIndexForPosition(targetPosition)
+ for (let i = this.iterators.length - 1; i >= 0; i--) {
+ this.iterators[i].seek(targetIndex, containingTags, containingTagStartIndices)
+ }
+ this.iterators.sort((a, b) => b.getIndex() - a.getIndex())
+ return containingTags
+ }
+
+ moveToSuccessor () {
+ const lastIndex = this.iterators.length - 1
+ const leader = this.iterators[lastIndex]
+ leader.moveToSuccessor()
+ const leaderCharIndex = leader.getIndex()
+ let i = lastIndex
+ while (i > 0 && this.iterators[i - 1].getIndex() < leaderCharIndex) i--
+ if (i < lastIndex) this.iterators.splice(i, 0, this.iterators.pop())
+ }
+
+ getPosition () {
+ return last(this.iterators).getPosition()
+ }
+
+ getCloseScopeIds () {
+ return last(this.iterators).getCloseScopeIds()
+ }
+
+ getOpenScopeIds () {
+ return last(this.iterators).getOpenScopeIds()
+ }
+
+ logState () {
+ const iterator = last(this.iterators)
+ if (iterator.treeCursor) {
+ console.log(
+ iterator.getPosition(),
+ iterator.treeCursor.nodeType,
+ new Range(
+ iterator.languageLayer.tree.rootNode.startPosition,
+ iterator.languageLayer.tree.rootNode.endPosition
+ ).toString()
+ )
+ console.log('close', iterator.closeTags.map(id => this.shortClassNameForScopeId(id)))
+ console.log('open', iterator.openTags.map(id => this.shortClassNameForScopeId(id)))
+ }
+ }
+
+ shortClassNameForScopeId (id) {
+ return this.languageMode.classNameForScopeId(id).replace(/syntax--/g, '')
+ }
+}
+
+class LayerHighlightIterator {
+ constructor (languageLayer, treeCursor) {
+ this.languageLayer = languageLayer
+
+ // The iterator is always positioned at either the start or the end of some node
+ // in the syntax tree.
+ this.atEnd = false
+ this.treeCursor = treeCursor
// In order to determine which selectors match its current node, the iterator maintains
// a list of the current node's ancestors. Because the selectors can use the `:nth-child`
// pseudo-class, each node's child index is also stored.
this.containingNodeTypes = []
this.containingNodeChildIndices = []
+ this.containingNodeEndIndices = []
// At any given position, the iterator exposes the list of class names that should be
// *ended* at its current position and the list of class names that should be *started*
@@ -349,49 +754,59 @@ class TreeSitterHighlightIterator {
this.openTags = []
}
- seek (targetPosition) {
- const containingTags = []
+ seek (targetIndex, containingTags, containingTagStartIndices) {
+ while (this.treeCursor.gotoParent()) {}
+ this.done = false
+ this.atEnd = true
this.closeTags.length = 0
this.openTags.length = 0
this.containingNodeTypes.length = 0
this.containingNodeChildIndices.length = 0
- this.currentPosition = targetPosition
- this.currentIndex = this.layer.buffer.characterIndexForPosition(targetPosition)
+ this.containingNodeEndIndices.length = 0
- var node = this.layer.document.rootNode
- var childIndex = -1
- var done = false
- var nodeContainsTarget = true
- do {
- this.currentNode = node
- this.currentChildIndex = childIndex
- if (!nodeContainsTarget) break
- this.containingNodeTypes.push(node.type)
+ const containingTagEndIndices = []
+
+ if (targetIndex >= this.treeCursor.endIndex) {
+ this.done = true
+ return
+ }
+
+ let childIndex = -1
+ for (;;) {
+ this.containingNodeTypes.push(this.treeCursor.nodeType)
this.containingNodeChildIndices.push(childIndex)
+ this.containingNodeEndIndices.push(this.treeCursor.endIndex)
- const scopeName = this.currentScopeName()
- if (scopeName) {
- const id = this.layer.grammar.idForScope(scopeName)
- if (this.currentIndex === node.startIndex) {
- this.openTags.push(id)
+ const scopeId = this._currentScopeId()
+ if (scopeId) {
+ if (this.treeCursor.startIndex < targetIndex) {
+ insertContainingTag(
+ scopeId, this.treeCursor.startIndex,
+ containingTags, containingTagStartIndices
+ )
+ containingTagEndIndices.push(this.treeCursor.endIndex)
} else {
- containingTags.push(id)
- }
- }
-
- done = true
- for (var i = 0, {children} = node, childCount = children.length; i < childCount; i++) {
- const child = children[i]
- if (child.endIndex > this.currentIndex) {
- node = child
- childIndex = i
- done = false
- if (child.startIndex > this.currentIndex) nodeContainsTarget = false
+ this.atEnd = false
+ this.openTags.push(scopeId)
+ this._moveDown()
break
}
}
- } while (!done)
+
+ childIndex = this.treeCursor.gotoFirstChildForIndex(targetIndex)
+ if (childIndex === null) break
+ if (this.treeCursor.startIndex >= targetIndex) this.atEnd = false
+ }
+
+ if (this.atEnd) {
+ const currentIndex = this.treeCursor.endIndex
+ for (let i = 0, {length} = containingTags; i < length; i++) {
+ if (containingTagEndIndices[i] === currentIndex) {
+ this.closeTags.push(containingTags[i])
+ }
+ }
+ }
return containingTags
}
@@ -400,59 +815,45 @@ class TreeSitterHighlightIterator {
this.closeTags.length = 0
this.openTags.length = 0
- if (!this.currentNode) {
- this.currentPosition = {row: Infinity, column: Infinity}
- return false
- }
-
- do {
- if (this.currentIndex < this.currentNode.startIndex) {
- this.currentIndex = this.currentNode.startIndex
- this.currentPosition = this.currentNode.startPosition
- this.pushOpenTag()
- this.descendLeft()
- } else if (this.currentIndex < this.currentNode.endIndex) {
- while (true) {
- this.currentIndex = this.currentNode.endIndex
- this.currentPosition = this.currentNode.endPosition
- this.pushCloseTag()
-
- const {nextSibling} = this.currentNode
- if (nextSibling) {
- this.currentNode = nextSibling
- this.currentChildIndex++
- if (this.currentIndex === nextSibling.startIndex) {
- this.pushOpenTag()
- this.descendLeft()
- }
- break
- } else {
- this.currentNode = this.currentNode.parent
- this.currentChildIndex = last(this.containingNodeChildIndices)
- if (!this.currentNode) break
- }
+ while (!this.done && !this.closeTags.length && !this.openTags.length) {
+ if (this.atEnd) {
+ if (this._moveRight()) {
+ const scopeId = this._currentScopeId()
+ if (scopeId) this.openTags.push(scopeId)
+ this.atEnd = false
+ this._moveDown()
+ } else if (this._moveUp(true)) {
+ this.atEnd = true
+ } else {
+ this.done = true
}
- } else if (this.currentNode.startIndex < this.currentNode.endIndex) {
- this.currentNode = this.currentNode.nextSibling
- if (this.currentNode) {
- this.currentChildIndex++
- this.currentPosition = this.currentNode.startPosition
- this.currentIndex = this.currentNode.startIndex
- this.pushOpenTag()
- this.descendLeft()
- }
- } else {
- this.pushCloseTag()
- this.currentNode = this.currentNode.parent
- this.currentChildIndex = last(this.containingNodeChildIndices)
+ } else if (!this._moveDown()) {
+ const scopeId = this._currentScopeId()
+ if (scopeId) this.closeTags.push(scopeId)
+ this.atEnd = true
+ this._moveUp(false)
}
- } while (this.closeTags.length === 0 && this.openTags.length === 0 && this.currentNode)
-
- return true
+ }
}
getPosition () {
- return this.currentPosition
+ if (this.done) {
+ return Point.INFINITY
+ } else if (this.atEnd) {
+ return this.treeCursor.endPosition
+ } else {
+ return this.treeCursor.startPosition
+ }
+ }
+
+ getIndex () {
+ if (this.done) {
+ return Infinity
+ } else if (this.atEnd) {
+ return this.treeCursor.endIndex
+ } else {
+ return this.treeCursor.startIndex
+ }
}
getCloseScopeIds () {
@@ -464,61 +865,219 @@ class TreeSitterHighlightIterator {
}
// Private methods
+ _moveUp (atLastChild) {
+ let result = false
+ const {endIndex} = this.treeCursor
+ let depth = this.containingNodeEndIndices.length
- descendLeft () {
- let child
- while ((child = this.currentNode.firstChild) && this.currentIndex === child.startIndex) {
- this.currentNode = child
- this.currentChildIndex = 0
- this.pushOpenTag()
+ // The iterator should not move up until it has visited all of the children of this node.
+ while (depth > 1 && (atLastChild || this.containingNodeEndIndices[depth - 2] === endIndex)) {
+ atLastChild = false
+ result = true
+ this.treeCursor.gotoParent()
+ this.containingNodeTypes.pop()
+ this.containingNodeChildIndices.pop()
+ this.containingNodeEndIndices.pop()
+ --depth
+ const scopeId = this._currentScopeId()
+ if (scopeId) this.closeTags.push(scopeId)
+ }
+ return result
+ }
+
+ _moveDown () {
+ let result = false
+ const {startIndex} = this.treeCursor
+
+ // Once the iterator has found a scope boundary, it needs to stay at the same
+ // position, so it should not move down if the first child node starts later than the
+ // current node.
+ while (this.treeCursor.gotoFirstChild()) {
+ if ((this.closeTags.length || this.openTags.length) &&
+ this.treeCursor.startIndex > startIndex) {
+ this.treeCursor.gotoParent()
+ break
+ }
+
+ result = true
+ this.containingNodeTypes.push(this.treeCursor.nodeType)
+ this.containingNodeChildIndices.push(0)
+ this.containingNodeEndIndices.push(this.treeCursor.endIndex)
+
+ const scopeId = this._currentScopeId()
+ if (scopeId) this.openTags.push(scopeId)
+ }
+
+ return result
+ }
+
+ _moveRight () {
+ if (this.treeCursor.gotoNextSibling()) {
+ const depth = this.containingNodeTypes.length
+ this.containingNodeTypes[depth - 1] = this.treeCursor.nodeType
+ this.containingNodeChildIndices[depth - 1]++
+ this.containingNodeEndIndices[depth - 1] = this.treeCursor.endIndex
+ return true
}
}
- currentScopeName () {
- return this.layer.grammar.scopeMap.get(
+ _currentScopeId () {
+ const rules = this.languageLayer.grammar.scopeMap.get(
this.containingNodeTypes,
this.containingNodeChildIndices,
- this.currentNode.isNamed
+ this.treeCursor.nodeIsNamed
)
- }
-
- pushCloseTag () {
- const scopeName = this.currentScopeName()
- if (scopeName) this.closeTags.push(this.layer.grammar.idForScope(scopeName))
- this.containingNodeTypes.pop()
- this.containingNodeChildIndices.pop()
- }
-
- pushOpenTag () {
- this.containingNodeTypes.push(this.currentNode.type)
- this.containingNodeChildIndices.push(this.currentChildIndex)
- const scopeName = this.currentScopeName()
- if (scopeName) this.openTags.push(this.layer.grammar.idForScope(scopeName))
+ const scopes = applyLeafRules(rules, this.treeCursor)
+ if (scopes) {
+ return this.languageLayer.languageMode.grammar.idForScope(scopes)
+ }
}
}
-class TreeSitterTextBufferInput {
- constructor (buffer) {
- this.buffer = buffer
- this.seek(0)
+const applyLeafRules = (rules, cursor) => {
+ if (!rules || typeof rules === 'string') return rules
+ if (Array.isArray(rules)) {
+ for (let i = 0, {length} = rules; i !== length; ++i) {
+ const result = applyLeafRules(rules[i], cursor)
+ if (result) return result
+ }
+ return undefined
+ }
+ if (typeof rules === 'object') {
+ if (rules.exact) {
+ return cursor.nodeText === rules.exact
+ ? applyLeafRules(rules.scopes, cursor)
+ : undefined
+ }
+ if (rules.match) {
+ return rules.match.test(cursor.nodeText)
+ ? applyLeafRules(rules.scopes, cursor)
+ : undefined
+ }
+ }
+}
+
+class NullHighlightIterator {
+ seek () { return [] }
+ moveToSuccessor () {}
+ getIndex () { return Infinity }
+ getPosition () { return Point.INFINITY }
+ getOpenScopeIds () { return [] }
+ getCloseScopeIds () { return [] }
+}
+
+class NodeRangeSet {
+ constructor (previous, nodes) {
+ this.previous = previous
+ this.nodes = nodes
}
- seek (characterIndex) {
- this.position = this.buffer.positionForCharacterIndex(characterIndex)
+ getRanges () {
+ const previousRanges = this.previous && this.previous.getRanges()
+ const result = []
+
+ for (const node of this.nodes) {
+ let position = node.startPosition
+ let index = node.startIndex
+
+ for (const child of node.children) {
+ const nextPosition = child.startPosition
+ const nextIndex = child.startIndex
+ if (nextIndex > index) {
+ this._pushRange(previousRanges, result, {
+ startIndex: index,
+ endIndex: nextIndex,
+ startPosition: position,
+ endPosition: nextPosition
+ })
+ }
+ position = child.endPosition
+ index = child.endIndex
+ }
+
+ if (node.endIndex > index) {
+ this._pushRange(previousRanges, result, {
+ startIndex: index,
+ endIndex: node.endIndex,
+ startPosition: position,
+ endPosition: node.endPosition
+ })
+ }
+ }
+
+ return result
}
- read () {
- const endPosition = this.buffer.clipPosition(this.position.traverse({row: 1000, column: 0}))
- const text = this.buffer.getTextInRange([this.position, endPosition])
- this.position = endPosition
- return text
+ _pushRange (previousRanges, newRanges, newRange) {
+ if (!previousRanges) {
+ newRanges.push(newRange)
+ return
+ }
+
+ for (const previousRange of previousRanges) {
+ if (previousRange.endIndex <= newRange.startIndex) continue
+ if (previousRange.startIndex >= newRange.endIndex) break
+ newRanges.push({
+ startIndex: Math.max(previousRange.startIndex, newRange.startIndex),
+ endIndex: Math.min(previousRange.endIndex, newRange.endIndex),
+ startPosition: Point.max(previousRange.startPosition, newRange.startPosition),
+ endPosition: Point.min(previousRange.endPosition, newRange.endPosition)
+ })
+ }
}
}
+function insertContainingTag (tag, index, tags, indices) {
+ const i = indices.findIndex(existingIndex => existingIndex > index)
+ if (i === -1) {
+ tags.push(tag)
+ indices.push(index)
+ } else {
+ tags.splice(i, 0, tag)
+ indices.splice(i, 0, index)
+ }
+}
+
+// Return true iff `mouse` is smaller than `house`. Only correct if
+// mouse and house overlap.
+//
+// * `mouse` {Range}
+// * `house` {Range}
+function rangeIsSmaller (mouse, house) {
+ if (!house) return true
+ const mvec = vecFromRange(mouse)
+ const hvec = vecFromRange(house)
+ return Point.min(mvec, hvec) === mvec
+}
+
+function vecFromRange ({start, end}) {
+ return end.translate(start.negate())
+}
+
+function rangeForNode (node) {
+ return new Range(node.startPosition, node.endPosition)
+}
+
+function nodeContainsIndices (node, start, end) {
+ if (node.startIndex < start) return node.endIndex >= end
+ if (node.startIndex === start) return node.endIndex > end
+ return false
+}
+
+function nodeIsSmaller (left, right) {
+ if (!left) return false
+ if (!right) return true
+ return left.endIndex - left.startIndex < right.endIndex - right.startIndex
+}
+
function last (array) {
return array[array.length - 1]
}
+function hasMatchingFoldSpec (specs, node) {
+ return specs.some(({type, named}) => type === node.type && named === node.isNamed)
+}
+
// TODO: Remove this once TreeSitterLanguageMode implements its own auto-indent system.
[
'_suggestedIndentForLineWithScopeAtBufferRow',
@@ -526,7 +1085,13 @@ function last (array) {
'increaseIndentRegexForScopeDescriptor',
'decreaseIndentRegexForScopeDescriptor',
'decreaseNextIndentRegexForScopeDescriptor',
- 'regexForPattern'
+ 'regexForPattern',
+ 'getNonWordCharacters'
].forEach(methodName => {
- module.exports.prototype[methodName] = TextMateLanguageMode.prototype[methodName]
+ TreeSitterLanguageMode.prototype[methodName] = TextMateLanguageMode.prototype[methodName]
})
+
+TreeSitterLanguageMode.LanguageLayer = LanguageLayer
+TreeSitterLanguageMode.prototype.syncOperationLimit = 1000
+
+module.exports = TreeSitterLanguageMode
diff --git a/src/update-process-env.js b/src/update-process-env.js
index 00bb13927..20d937d96 100644
--- a/src/update-process-env.js
+++ b/src/update-process-env.js
@@ -1,7 +1,5 @@
-/** @babel */
-
-import fs from 'fs'
-import childProcess from 'child_process'
+const fs = require('fs')
+const childProcess = require('child_process')
const ENVIRONMENT_VARIABLES_TO_PRESERVE = new Set([
'NODE_ENV',
@@ -20,7 +18,7 @@ async function updateProcessEnv (launchEnv) {
if (launchEnv) {
if (shouldGetEnvFromShell(launchEnv)) {
envToAssign = await getEnvFromShell(launchEnv)
- } else if (launchEnv.PWD) {
+ } else if (launchEnv.PWD || launchEnv.PROMPT || launchEnv.PSModulePath) {
envToAssign = launchEnv
}
}
@@ -120,4 +118,4 @@ async function getEnvFromShell (env) {
return result
}
-export default { updateProcessEnv, shouldGetEnvFromShell }
+module.exports = {updateProcessEnv, shouldGetEnvFromShell}
diff --git a/src/workspace-element.js b/src/workspace-element.js
index bd0e1b971..f94dbd6e9 100644
--- a/src/workspace-element.js
+++ b/src/workspace-element.js
@@ -56,10 +56,10 @@ class WorkspaceElement extends HTMLElement {
}
updateGlobalTextEditorStyleSheet () {
- const styleSheetSource = `atom-text-editor {
- font-size: ${this.config.get('editor.fontSize')}px;
- font-family: ${this.config.get('editor.fontFamily')};
- line-height: ${this.config.get('editor.lineHeight')};
+ const styleSheetSource = `atom-workspace {
+ --editor-font-size: ${this.config.get('editor.fontSize')}px;
+ --editor-font-family: ${this.config.get('editor.fontFamily')};
+ --editor-line-height: ${this.config.get('editor.lineHeight')};
}`
this.styleManager.addStyleSheet(styleSheetSource, {sourcePath: 'global-text-editor-styles', priority: -1})
}
@@ -92,7 +92,13 @@ class WorkspaceElement extends HTMLElement {
window.removeEventListener('dragstart', this.handleDragStart)
window.removeEventListener('dragend', this.handleDragEnd, true)
window.removeEventListener('drop', this.handleDrop, true)
- })
+ }),
+ ...[this.model.getLeftDock(), this.model.getRightDock(), this.model.getBottomDock()]
+ .map(dock => dock.onDidChangeHovered(hovered => {
+ if (hovered) this.hoveredDock = dock
+ else if (dock === this.hoveredDock) this.hoveredDock = null
+ this.checkCleanupDockHoverEvents()
+ }))
)
this.initializeContent()
this.observeScrollbarStyle()
@@ -104,6 +110,7 @@ class WorkspaceElement extends HTMLElement {
this.addEventListener('mousewheel', this.handleMousewheel.bind(this), true)
window.addEventListener('dragstart', this.handleDragStart)
+ window.addEventListener('mousemove', this.handleEdgesMouseMove)
this.panelContainers = {
top: this.model.panelContainers.top.getElement(),
@@ -132,6 +139,10 @@ class WorkspaceElement extends HTMLElement {
return this
}
+ destroy () {
+ this.subscriptions.dispose()
+ }
+
getModel () { return this.model }
handleDragStart (event) {
@@ -169,7 +180,6 @@ class WorkspaceElement extends HTMLElement {
// being hovered.
this.cursorInCenter = false
this.updateHoveredDock({x: event.pageX, y: event.pageY})
- window.addEventListener('mousemove', this.handleEdgesMouseMove)
window.addEventListener('dragend', this.handleDockDragEnd)
}
@@ -182,24 +192,17 @@ class WorkspaceElement extends HTMLElement {
}
updateHoveredDock (mousePosition) {
- this.hoveredDock = null
- for (let location in this.model.paneContainers) {
- if (location !== 'center') {
- const dock = this.model.paneContainers[location]
- if (!this.hoveredDock && dock.pointWithinHoverArea(mousePosition)) {
- this.hoveredDock = dock
- dock.setHovered(true)
- } else {
- dock.setHovered(false)
- }
- }
- }
- this.checkCleanupDockHoverEvents()
+ // If we haven't left the currently hovered dock, don't change anything.
+ if (this.hoveredDock && this.hoveredDock.pointWithinHoverArea(mousePosition, true)) return
+
+ const docks = [this.model.getLeftDock(), this.model.getRightDock(), this.model.getBottomDock()]
+ const nextHoveredDock =
+ docks.find(dock => dock !== this.hoveredDock && dock.pointWithinHoverArea(mousePosition))
+ docks.forEach(dock => { dock.setHovered(dock === nextHoveredDock) })
}
checkCleanupDockHoverEvents () {
if (this.cursorInCenter && !this.hoveredDock) {
- window.removeEventListener('mousemove', this.handleEdgesMouseMove)
window.removeEventListener('dragend', this.handleDockDragEnd)
}
}
@@ -307,7 +310,7 @@ class WorkspaceElement extends HTMLElement {
}
}
- runPackageSpecs () {
+ runPackageSpecs (options = {}) {
const activePaneItem = this.model.getActivePaneItem()
const activePath = activePaneItem && typeof activePaneItem.getPath === 'function' ? activePaneItem.getPath() : null
let projectPath
@@ -323,7 +326,7 @@ class WorkspaceElement extends HTMLElement {
specPath = testPath
}
- ipcRenderer.send('run-package-specs', specPath)
+ ipcRenderer.send('run-package-specs', specPath, options)
}
}
diff --git a/src/workspace.js b/src/workspace.js
index 127168748..a3f85ddeb 100644
--- a/src/workspace.js
+++ b/src/workspace.js
@@ -1,5 +1,3 @@
-'use babel'
-
const _ = require('underscore-plus')
const url = require('url')
const path = require('path')
@@ -227,6 +225,8 @@ module.exports = class Workspace extends Model {
modal: new PanelContainer({viewRegistry: this.viewRegistry, location: 'modal'})
}
+ this.incoming = new Map()
+
this.subscribeToEvents()
}
@@ -310,7 +310,10 @@ module.exports = class Workspace extends Model {
this.originalFontSize = null
this.openers = []
this.destroyedItemURIs = []
- this.element = null
+ if (this.element) {
+ this.element.destroy()
+ this.element = null
+ }
this.consumeServices(this.packageManager)
}
@@ -494,14 +497,22 @@ module.exports = class Workspace extends Model {
if (item instanceof TextEditor) {
const subscriptions = new CompositeDisposable(
this.textEditorRegistry.add(item),
- this.textEditorRegistry.maintainConfig(item),
- item.observeGrammar(this.handleGrammarUsed.bind(this))
+ this.textEditorRegistry.maintainConfig(item)
)
if (!this.project.findBufferForId(item.buffer.id)) {
this.project.addBuffer(item.buffer)
}
item.onDidDestroy(() => { subscriptions.dispose() })
this.emitter.emit('did-add-text-editor', {textEditor: item, pane, index})
+ // It's important to call handleGrammarUsed after emitting the did-add event:
+ // if we activate a package between adding the editor to the registry and emitting
+ // the package may receive the editor twice from `observeTextEditors`.
+ // (Note that the item can be destroyed by an `observeTextEditors` handler.)
+ if (!item.isDestroyed()) {
+ subscriptions.add(
+ item.observeGrammar(this.handleGrammarUsed.bind(this))
+ )
+ }
}
})
}
@@ -920,133 +931,150 @@ module.exports = class Workspace extends Model {
if (typeof item.getURI === 'function') uri = item.getURI()
}
- if (!atom.config.get('core.allowPendingPaneItems')) {
- options.pending = false
- }
-
- // Avoid adding URLs as recent documents to work-around this Spotlight crash:
- // https://github.com/atom/atom/issues/10071
- if (uri && (!url.parse(uri).protocol || process.platform === 'win32')) {
- this.applicationDelegate.addRecentDocument(uri)
- }
-
- let pane, itemExistsInWorkspace
-
- // Try to find an existing item in the workspace.
- if (item || uri) {
- if (options.pane) {
- pane = options.pane
- } else if (options.searchAllPanes) {
- pane = item ? this.paneForItem(item) : this.paneForURI(uri)
+ let resolveItem = () => {}
+ if (uri) {
+ const incomingItem = this.incoming.get(uri)
+ if (!incomingItem) {
+ this.incoming.set(uri, new Promise(resolve => { resolveItem = resolve }))
} else {
- // If an item with the given URI is already in the workspace, assume
- // that item's pane container is the preferred location for that URI.
- let container
- if (uri) container = this.paneContainerForURI(uri)
- if (!container) container = this.getActivePaneContainer()
+ await incomingItem
+ }
+ }
- // The `split` option affects where we search for the item.
- pane = container.getActivePane()
- switch (options.split) {
- case 'left':
- pane = pane.findLeftmostSibling()
- break
- case 'right':
- pane = pane.findRightmostSibling()
- break
- case 'up':
- pane = pane.findTopmostSibling()
- break
- case 'down':
- pane = pane.findBottommostSibling()
- break
- }
+ try {
+ if (!atom.config.get('core.allowPendingPaneItems')) {
+ options.pending = false
}
- if (pane) {
- if (item) {
- itemExistsInWorkspace = pane.getItems().includes(item)
+ // Avoid adding URLs as recent documents to work-around this Spotlight crash:
+ // https://github.com/atom/atom/issues/10071
+ if (uri && (!url.parse(uri).protocol || process.platform === 'win32')) {
+ this.applicationDelegate.addRecentDocument(uri)
+ }
+
+ let pane, itemExistsInWorkspace
+
+ // Try to find an existing item in the workspace.
+ if (item || uri) {
+ if (options.pane) {
+ pane = options.pane
+ } else if (options.searchAllPanes) {
+ pane = item ? this.paneForItem(item) : this.paneForURI(uri)
} else {
- item = pane.itemForURI(uri)
- itemExistsInWorkspace = item != null
+ // If an item with the given URI is already in the workspace, assume
+ // that item's pane container is the preferred location for that URI.
+ let container
+ if (uri) container = this.paneContainerForURI(uri)
+ if (!container) container = this.getActivePaneContainer()
+
+ // The `split` option affects where we search for the item.
+ pane = container.getActivePane()
+ switch (options.split) {
+ case 'left':
+ pane = pane.findLeftmostSibling()
+ break
+ case 'right':
+ pane = pane.findRightmostSibling()
+ break
+ case 'up':
+ pane = pane.findTopmostSibling()
+ break
+ case 'down':
+ pane = pane.findBottommostSibling()
+ break
+ }
+ }
+
+ if (pane) {
+ if (item) {
+ itemExistsInWorkspace = pane.getItems().includes(item)
+ } else {
+ item = pane.itemForURI(uri)
+ itemExistsInWorkspace = item != null
+ }
}
}
- }
- // If we already have an item at this stage, we won't need to do an async
- // lookup of the URI, so we yield the event loop to ensure this method
- // is consistently asynchronous.
- if (item) await Promise.resolve()
+ // If we already have an item at this stage, we won't need to do an async
+ // lookup of the URI, so we yield the event loop to ensure this method
+ // is consistently asynchronous.
+ if (item) await Promise.resolve()
- if (!itemExistsInWorkspace) {
- item = item || await this.createItemForURI(uri, options)
- if (!item) return
+ if (!itemExistsInWorkspace) {
+ item = item || await this.createItemForURI(uri, options)
+ if (!item) return
- if (options.pane) {
- pane = options.pane
+ if (options.pane) {
+ pane = options.pane
+ } else {
+ let location = options.location
+ if (!location && !options.split && uri && this.enablePersistence) {
+ location = await this.itemLocationStore.load(uri)
+ }
+ if (!location && typeof item.getDefaultLocation === 'function') {
+ location = item.getDefaultLocation()
+ }
+
+ const allowedLocations = typeof item.getAllowedLocations === 'function' ? item.getAllowedLocations() : ALL_LOCATIONS
+ location = allowedLocations.includes(location) ? location : allowedLocations[0]
+
+ const container = this.paneContainers[location] || this.getCenter()
+ pane = container.getActivePane()
+ switch (options.split) {
+ case 'left':
+ pane = pane.findLeftmostSibling()
+ break
+ case 'right':
+ pane = pane.findOrCreateRightmostSibling()
+ break
+ case 'up':
+ pane = pane.findTopmostSibling()
+ break
+ case 'down':
+ pane = pane.findOrCreateBottommostSibling()
+ break
+ }
+ }
+ }
+
+ if (!options.pending && (pane.getPendingItem() === item)) {
+ pane.clearPendingItem()
+ }
+
+ this.itemOpened(item)
+
+ if (options.activateItem === false) {
+ pane.addItem(item, {pending: options.pending})
} else {
- let location = options.location
- if (!location && !options.split && uri && this.enablePersistence) {
- location = await this.itemLocationStore.load(uri)
- }
- if (!location && typeof item.getDefaultLocation === 'function') {
- location = item.getDefaultLocation()
- }
+ pane.activateItem(item, {pending: options.pending})
+ }
- const allowedLocations = typeof item.getAllowedLocations === 'function' ? item.getAllowedLocations() : ALL_LOCATIONS
- location = allowedLocations.includes(location) ? location : allowedLocations[0]
+ if (options.activatePane !== false) {
+ pane.activate()
+ }
- const container = this.paneContainers[location] || this.getCenter()
- pane = container.getActivePane()
- switch (options.split) {
- case 'left':
- pane = pane.findLeftmostSibling()
- break
- case 'right':
- pane = pane.findOrCreateRightmostSibling()
- break
- case 'up':
- pane = pane.findTopmostSibling()
- break
- case 'down':
- pane = pane.findOrCreateBottommostSibling()
- break
+ let initialColumn = 0
+ let initialLine = 0
+ if (!Number.isNaN(options.initialLine)) {
+ initialLine = options.initialLine
+ }
+ if (!Number.isNaN(options.initialColumn)) {
+ initialColumn = options.initialColumn
+ }
+ if (initialLine >= 0 || initialColumn >= 0) {
+ if (typeof item.setCursorBufferPosition === 'function') {
+ item.setCursorBufferPosition([initialLine, initialColumn])
}
}
- }
- if (!options.pending && (pane.getPendingItem() === item)) {
- pane.clearPendingItem()
- }
-
- this.itemOpened(item)
-
- if (options.activateItem === false) {
- pane.addItem(item, {pending: options.pending})
- } else {
- pane.activateItem(item, {pending: options.pending})
- }
-
- if (options.activatePane !== false) {
- pane.activate()
- }
-
- let initialColumn = 0
- let initialLine = 0
- if (!Number.isNaN(options.initialLine)) {
- initialLine = options.initialLine
- }
- if (!Number.isNaN(options.initialColumn)) {
- initialColumn = options.initialColumn
- }
- if (initialLine >= 0 || initialColumn >= 0) {
- if (typeof item.setCursorBufferPosition === 'function') {
- item.setCursorBufferPosition([initialLine, initialColumn])
+ const index = pane.getActiveItemIndex()
+ this.emitter.emit('did-open', {uri, pane, item, index})
+ if (uri) {
+ this.incoming.delete(uri)
}
+ } finally {
+ resolveItem()
}
-
- const index = pane.getActiveItemIndex()
- this.emitter.emit('did-open', {uri, pane, item, index})
return item
}
@@ -1216,42 +1244,32 @@ module.exports = class Workspace extends Model {
const fileSize = fs.getSizeSync(filePath)
- let [resolveConfirmFileOpenPromise, rejectConfirmFileOpenPromise] = []
- const confirmFileOpenPromise = new Promise((resolve, reject) => {
- resolveConfirmFileOpenPromise = resolve
- rejectConfirmFileOpenPromise = reject
- })
-
if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { // 40MB by default
- this.applicationDelegate.confirm({
- message: 'Atom will be unresponsive during the loading of very large files.',
- detail: 'Do you still want to load this file?',
- buttons: ['Proceed', 'Cancel']
- }, response => {
- if (response === 1) {
- rejectConfirmFileOpenPromise()
- } else {
- resolveConfirmFileOpenPromise()
- }
+ await new Promise((resolve, reject) => {
+ this.applicationDelegate.confirm({
+ message: 'Atom will be unresponsive during the loading of very large files.',
+ detail: 'Do you still want to load this file?',
+ buttons: ['Proceed', 'Cancel']
+ }, response => {
+ if (response === 1) {
+ const error = new Error()
+ error.code = 'CANCELLED'
+ reject(error)
+ } else {
+ resolve()
+ }
+ })
})
- } else {
- resolveConfirmFileOpenPromise()
}
- try {
- await confirmFileOpenPromise
- const buffer = await this.project.bufferForPath(filePath, options)
- return this.textEditorRegistry.build(Object.assign({buffer, autoHeight: false}, options))
- } catch (e) {
- const error = new Error()
- error.code = 'CANCELLED'
- throw error
- }
+ const buffer = await this.project.bufferForPath(filePath, options)
+ return this.textEditorRegistry.build(Object.assign({buffer, autoHeight: false}, options))
}
handleGrammarUsed (grammar) {
if (grammar == null) { return }
- return this.packageManager.triggerActivationHook(`${grammar.packageName}:grammar-used`)
+ this.packageManager.triggerActivationHook(`${grammar.scopeName}:root-scope-used`)
+ this.packageManager.triggerActivationHook(`${grammar.packageName}:grammar-used`)
}
// Public: Returns a {Boolean} that is `true` if `object` is a `TextEditor`.
@@ -1570,6 +1588,7 @@ module.exports = class Workspace extends Model {
if (this.activeItemSubscriptions != null) {
this.activeItemSubscriptions.dispose()
}
+ if (this.element) this.element.destroy()
}
/*
diff --git a/static/docks.less b/static/docks.less
index 301d7aee5..ccbb4e903 100644
--- a/static/docks.less
+++ b/static/docks.less
@@ -16,12 +16,6 @@ atom-dock {
.atom-dock-inner {
display: flex;
- // Keep the area at least 2 pixels wide so that you have something to hover
- // over to trigger the toggle button affordance even when fullscreen.
- // Needs to be 2 pixels to work on Windows when scaled to 150%. See atom/atom #15728
- &.left, &.right { min-width: 2px; }
- &.bottom { min-height: 1px; }
-
&.bottom { width: 100%; }
&.left, &.right { height: 100%; }
@@ -119,10 +113,16 @@ atom-dock {
// Promote to own layer, fixes rendering issue atom/atom#14915
will-change: transform;
- &.right { left: 0; }
- &.bottom { top: 0; }
- &.left { right: 0; }
- }
+ &.right {
+ left: 0;
+ }
+ &.bottom {
+ top: 0;
+ }
+ &.left {
+ right: 0;
+ }
+ }
// Hide the button.
&:not(.atom-dock-toggle-button-visible) {
diff --git a/static/text-editor.less b/static/text-editor.less
index 21cba8482..99f198512 100644
--- a/static/text-editor.less
+++ b/static/text-editor.less
@@ -2,10 +2,17 @@
@import "octicon-utf-codes";
@import "octicon-mixins";
+:root {
+ // Fixes specs
+ --editor-font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+}
+
atom-text-editor {
display: flex;
- font-family: Menlo, Consolas, 'DejaVu Sans Mono', monospace;
cursor: text;
+ font-family: var(--editor-font-family);
+ font-size: var(--editor-font-size);
+ line-height: var(--editor-line-height);
.gutter-container {
width: min-content;
diff --git a/static/variables/ui-variables.less b/static/variables/ui-variables.less
index 8ef4d48e7..7bea17e72 100644
--- a/static/variables/ui-variables.less
+++ b/static/variables/ui-variables.less
@@ -81,5 +81,5 @@
// Other
-@font-family: 'BlinkMacSystemFont', 'Lucida Grande', 'Segoe UI', Ubuntu, Cantarell, sans-serif;
+@font-family: system-ui;
@use-custom-controls: true; // false uses native controls
diff --git a/stylelint.config.js b/stylelint.config.js
new file mode 100644
index 000000000..136c754fa
--- /dev/null
+++ b/stylelint.config.js
@@ -0,0 +1,24 @@
+const path = require('path');
+
+module.exports = {
+ "extends": "stylelint-config-standard",
+ "ignoreFiles": [path.resolve(__dirname, "static", "atom.less")],
+ "rules": {
+ "color-hex-case": null, // TODO: enable?
+ "max-empty-lines": null, // TODO: enable?
+ "selector-type-no-unknown": null,
+ "function-comma-space-after": null, // TODO: enable?
+ "font-family-no-missing-generic-family-keyword": null, // needed for octicons (no sensible fallback)
+ "declaration-empty-line-before": null, // TODO: enable?
+ "declaration-block-trailing-semicolon": null, // TODO: enable
+ "no-descending-specificity": null,
+ "number-leading-zero": null, // TODO: enable?
+ "no-duplicate-selectors": null,
+ "selector-pseudo-element-colon-notation": null, // TODO: enable?
+ "selector-list-comma-newline-after": null, // TODO: enable?
+ "rule-empty-line-before": null, // TODO: enable?
+ "at-rule-empty-line-before": null, // TODO: enable?
+ "font-family-no-duplicate-names": null, // TODO: enable?
+ "unit-no-unknown": [true, {"ignoreUnits": [ "x" ]}], // Needed for -webkit-image-set 1x/2x units
+ }
+}
diff --git a/vendor/jasmine.js b/vendor/jasmine.js
index 1b80d662f..ac443e1c8 100644
--- a/vendor/jasmine.js
+++ b/vendor/jasmine.js
@@ -2317,6 +2317,14 @@ jasmine.Spec.prototype.waitsFor = function(latchFunction, optional_timeoutMessag
}
}
+ if (optional_timeoutMessage_ == null) {
+ const objectToCaptureStack = {}
+ Error.captureStackTrace(objectToCaptureStack, waitsFor)
+ const stack = objectToCaptureStack.stack
+ const line = stack.split('\n')[1]
+ optional_timeoutMessage_ = `condition ${line}`
+ }
+
var waitsForFunc = new jasmine.WaitsForBlock(this.env, optional_timeout_, latchFunction_, optional_timeoutMessage_, this);
this.addToQueue(waitsForFunc);
return this;