diff --git a/package.json b/package.json index 2edaeb0a5..933de24b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atom", "productName": "Atom", - "version": "1.23.0-dev", + "version": "1.24.0-dev", "description": "A hackable text editor for the 21st Century.", "main": "./src/main-process/main.js", "repository": { @@ -101,15 +101,15 @@ "background-tips": "0.27.1", "bookmarks": "0.44.4", "bracket-matcher": "0.88.0", - "command-palette": "0.41.1", + "command-palette": "0.42.0", "dalek": "0.2.1", "deprecation-cop": "0.56.9", "dev-live-reload": "0.47.1", "encoding-selector": "0.23.7", "exception-reporting": "0.41.5", "find-and-replace": "0.213.0", - "fuzzy-finder": "1.7.2", - "github": "0.8.0", + "fuzzy-finder": "1.7.3", + "github": "0.8.1", "git-diff": "1.3.6", "go-to-line": "0.32.1", "grammar-selector": "0.49.8", @@ -121,17 +121,17 @@ "markdown-preview": "0.159.18", "metrics": "1.2.6", "notifications": "0.69.2", - "open-on-github": "1.2.1", + "open-on-github": "1.3.0", "package-generator": "1.1.1", - "settings-view": "0.252.2", + "settings-view": "0.253.0", "snippets": "1.1.9", "spell-check": "0.72.3", - "status-bar": "1.8.14", - "styleguide": "0.49.8", + "status-bar": "1.8.15", + "styleguide": "0.49.9", "symbols-view": "0.118.1", "tabs": "0.109.1", - "timecop": "0.36.0", - "tree-view": "0.221.1", + "timecop": "0.36.2", + "tree-view": "0.221.2", "update-package-dependencies": "0.12.0", "welcome": "0.36.5", "whitespace": "0.37.5", diff --git a/spec/async-spec-helpers.js b/spec/async-spec-helpers.js index 56550cd9f..73002c049 100644 --- a/spec/async-spec-helpers.js +++ b/spec/async-spec-helpers.js @@ -34,7 +34,7 @@ export function afterEach (fn) { } }) -export async function conditionPromise (condition) { +export async function conditionPromise (condition, description = 'anonymous condition') { const startTime = Date.now() while (true) { @@ -45,7 +45,7 @@ export async function conditionPromise (condition) { } if (Date.now() - startTime > 5000) { - throw new Error('Timed out waiting on condition') + throw new Error('Timed out waiting on ' + description) } } } diff --git a/spec/git-repository-spec.coffee b/spec/git-repository-spec.coffee deleted file mode 100644 index c33d8b039..000000000 --- a/spec/git-repository-spec.coffee +++ /dev/null @@ -1,377 +0,0 @@ -temp = require('temp').track() -GitRepository = require '../src/git-repository' -fs = require 'fs-plus' -path = require 'path' -Project = require '../src/project' - -copyRepository = -> - workingDirPath = temp.mkdirSync('atom-spec-git') - fs.copySync(path.join(__dirname, 'fixtures', 'git', 'working-dir'), workingDirPath) - fs.renameSync(path.join(workingDirPath, 'git.git'), path.join(workingDirPath, '.git')) - workingDirPath - -describe "GitRepository", -> - repo = null - - beforeEach -> - gitPath = path.join(temp.dir, '.git') - fs.removeSync(gitPath) if fs.isDirectorySync(gitPath) - - afterEach -> - repo.destroy() if repo?.repo? - try - temp.cleanupSync() # These tests sometimes lag at shutting down resources - - describe "@open(path)", -> - it "returns null when no repository is found", -> - expect(GitRepository.open(path.join(temp.dir, 'nogit.txt'))).toBeNull() - - describe "new GitRepository(path)", -> - it "throws an exception when no repository is found", -> - expect(-> new GitRepository(path.join(temp.dir, 'nogit.txt'))).toThrow() - - describe ".getPath()", -> - it "returns the repository path for a .git directory path with a directory", -> - repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects')) - expect(repo.getPath()).toBe path.join(__dirname, 'fixtures', 'git', 'master.git') - - it "returns the repository path for a repository path", -> - repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git')) - expect(repo.getPath()).toBe path.join(__dirname, 'fixtures', 'git', 'master.git') - - describe ".isPathIgnored(path)", -> - it "returns true for an ignored path", -> - repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git')) - expect(repo.isPathIgnored('a.txt')).toBeTruthy() - - it "returns false for a non-ignored path", -> - repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git')) - expect(repo.isPathIgnored('b.txt')).toBeFalsy() - - describe ".isPathModified(path)", -> - [repo, filePath, newPath] = [] - - beforeEach -> - workingDirPath = copyRepository() - repo = new GitRepository(workingDirPath) - filePath = path.join(workingDirPath, 'a.txt') - newPath = path.join(workingDirPath, 'new-path.txt') - - describe "when the path is unstaged", -> - it "returns false if the path has not been modified", -> - expect(repo.isPathModified(filePath)).toBeFalsy() - - it "returns true if the path is modified", -> - fs.writeFileSync(filePath, "change") - expect(repo.isPathModified(filePath)).toBeTruthy() - - it "returns true if the path is deleted", -> - fs.removeSync(filePath) - expect(repo.isPathModified(filePath)).toBeTruthy() - - it "returns false if the path is new", -> - expect(repo.isPathModified(newPath)).toBeFalsy() - - describe ".isPathNew(path)", -> - [filePath, newPath] = [] - - beforeEach -> - workingDirPath = copyRepository() - repo = new GitRepository(workingDirPath) - filePath = path.join(workingDirPath, 'a.txt') - newPath = path.join(workingDirPath, 'new-path.txt') - fs.writeFileSync(newPath, "i'm new here") - - describe "when the path is unstaged", -> - it "returns true if the path is new", -> - expect(repo.isPathNew(newPath)).toBeTruthy() - - it "returns false if the path isn't new", -> - expect(repo.isPathNew(filePath)).toBeFalsy() - - describe ".checkoutHead(path)", -> - [filePath] = [] - - beforeEach -> - workingDirPath = copyRepository() - repo = new GitRepository(workingDirPath) - filePath = path.join(workingDirPath, 'a.txt') - - it "no longer reports a path as modified after checkout", -> - expect(repo.isPathModified(filePath)).toBeFalsy() - fs.writeFileSync(filePath, 'ch ch changes') - expect(repo.isPathModified(filePath)).toBeTruthy() - expect(repo.checkoutHead(filePath)).toBeTruthy() - expect(repo.isPathModified(filePath)).toBeFalsy() - - it "restores the contents of the path to the original text", -> - fs.writeFileSync(filePath, 'ch ch changes') - expect(repo.checkoutHead(filePath)).toBeTruthy() - expect(fs.readFileSync(filePath, 'utf8')).toBe '' - - it "fires a status-changed event if the checkout completes successfully", -> - fs.writeFileSync(filePath, 'ch ch changes') - repo.getPathStatus(filePath) - statusHandler = jasmine.createSpy('statusHandler') - repo.onDidChangeStatus statusHandler - repo.checkoutHead(filePath) - expect(statusHandler.callCount).toBe 1 - expect(statusHandler.argsForCall[0][0]).toEqual {path: filePath, pathStatus: 0} - - repo.checkoutHead(filePath) - expect(statusHandler.callCount).toBe 1 - - describe ".checkoutHeadForEditor(editor)", -> - [filePath, editor] = [] - - beforeEach -> - spyOn(atom, "confirm") - - workingDirPath = copyRepository() - repo = new GitRepository(workingDirPath, {project: atom.project, config: atom.config, confirm: atom.confirm}) - filePath = path.join(workingDirPath, 'a.txt') - fs.writeFileSync(filePath, 'ch ch changes') - - waitsForPromise -> - atom.workspace.open(filePath) - - runs -> - editor = atom.workspace.getActiveTextEditor() - - it "displays a confirmation dialog by default", -> - return if process.platform is 'win32' # Permissions issues with this test on Windows - - atom.confirm.andCallFake ({buttons}) -> buttons.OK() - atom.config.set('editor.confirmCheckoutHeadRevision', true) - - repo.checkoutHeadForEditor(editor) - - expect(fs.readFileSync(filePath, 'utf8')).toBe '' - - it "does not display a dialog when confirmation is disabled", -> - return if process.platform is 'win32' # Flakey EPERM opening a.txt on Win32 - atom.config.set('editor.confirmCheckoutHeadRevision', false) - - repo.checkoutHeadForEditor(editor) - - expect(fs.readFileSync(filePath, 'utf8')).toBe '' - expect(atom.confirm).not.toHaveBeenCalled() - - describe ".destroy()", -> - it "throws an exception when any method is called after it is called", -> - repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git')) - repo.destroy() - expect(-> repo.getShortHead()).toThrow() - - describe ".getPathStatus(path)", -> - [filePath] = [] - - beforeEach -> - workingDirectory = copyRepository() - repo = new GitRepository(workingDirectory) - filePath = path.join(workingDirectory, 'file.txt') - - it "trigger a status-changed event when the new status differs from the last cached one", -> - statusHandler = jasmine.createSpy("statusHandler") - repo.onDidChangeStatus statusHandler - fs.writeFileSync(filePath, '') - status = repo.getPathStatus(filePath) - expect(statusHandler.callCount).toBe 1 - expect(statusHandler.argsForCall[0][0]).toEqual {path: filePath, pathStatus: status} - - fs.writeFileSync(filePath, 'abc') - status = repo.getPathStatus(filePath) - expect(statusHandler.callCount).toBe 1 - - describe ".getDirectoryStatus(path)", -> - [directoryPath, filePath] = [] - - beforeEach -> - workingDirectory = copyRepository() - repo = new GitRepository(workingDirectory) - directoryPath = path.join(workingDirectory, 'dir') - filePath = path.join(directoryPath, 'b.txt') - - it "gets the status based on the files inside the directory", -> - expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe false - fs.writeFileSync(filePath, 'abc') - repo.getPathStatus(filePath) - expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe true - - describe ".refreshStatus()", -> - [newPath, modifiedPath, cleanPath, workingDirectory] = [] - - beforeEach -> - workingDirectory = copyRepository() - repo = new GitRepository(workingDirectory, {project: atom.project, config: atom.config}) - modifiedPath = path.join(workingDirectory, 'file.txt') - newPath = path.join(workingDirectory, 'untracked.txt') - cleanPath = path.join(workingDirectory, 'other.txt') - fs.writeFileSync(cleanPath, 'Full of text') - fs.writeFileSync(newPath, '') - newPath = fs.absolute newPath # specs could be running under symbol path. - - it "returns status information for all new and modified files", -> - fs.writeFileSync(modifiedPath, 'making this path modified') - statusHandler = jasmine.createSpy('statusHandler') - repo.onDidChangeStatuses statusHandler - repo.refreshStatus() - - waitsFor -> - statusHandler.callCount > 0 - - runs -> - expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined() - expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy() - expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy() - - it 'caches the proper statuses when a subdir is open', -> - subDir = path.join(workingDirectory, 'dir') - fs.mkdirSync(subDir) - - filePath = path.join(subDir, 'b.txt') - fs.writeFileSync(filePath, '') - - atom.project.setPaths([subDir]) - - waitsForPromise -> - atom.workspace.open('b.txt') - - statusHandler = null - runs -> - repo = atom.project.getRepositories()[0] - - statusHandler = jasmine.createSpy('statusHandler') - repo.onDidChangeStatuses statusHandler - repo.refreshStatus() - - waitsFor -> - statusHandler.callCount > 0 - - runs -> - status = repo.getCachedPathStatus(filePath) - expect(repo.isStatusModified(status)).toBe false - expect(repo.isStatusNew(status)).toBe false - - it "works correctly when the project has multiple folders (regression)", -> - atom.project.addPath(workingDirectory) - atom.project.addPath(path.join(__dirname, 'fixtures', 'dir')) - statusHandler = jasmine.createSpy('statusHandler') - repo.onDidChangeStatuses statusHandler - - repo.refreshStatus() - - waitsFor -> - statusHandler.callCount > 0 - - runs -> - expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined() - expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy() - expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy() - - it 'caches statuses that were looked up synchronously', -> - originalContent = 'undefined' - fs.writeFileSync(modifiedPath, 'making this path modified') - repo.getPathStatus('file.txt') - - fs.writeFileSync(modifiedPath, originalContent) - waitsForPromise -> repo.refreshStatus() - runs -> - expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeFalsy() - - describe "buffer events", -> - [editor] = [] - - beforeEach -> - statusRefreshed = false - atom.project.setPaths([copyRepository()]) - atom.project.getRepositories()[0].onDidChangeStatuses -> statusRefreshed = true - - waitsForPromise -> - atom.workspace.open('other.txt').then (o) -> editor = o - - waitsFor 'repo to refresh', -> statusRefreshed - - it "emits a status-changed event when a buffer is saved", -> - editor.insertNewline() - - statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepositories()[0].onDidChangeStatus statusHandler - - waitsForPromise -> - editor.save() - - runs -> - expect(statusHandler.callCount).toBe 1 - expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} - - it "emits a status-changed event when a buffer is reloaded", -> - fs.writeFileSync(editor.getPath(), 'changed') - - statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepositories()[0].onDidChangeStatus statusHandler - - waitsForPromise -> - editor.getBuffer().reload() - - runs -> - expect(statusHandler.callCount).toBe 1 - expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} - - waitsForPromise -> - editor.getBuffer().reload() - - runs -> - expect(statusHandler.callCount).toBe 1 - - it "emits a status-changed event when a buffer's path changes", -> - fs.writeFileSync(editor.getPath(), 'changed') - - statusHandler = jasmine.createSpy('statusHandler') - atom.project.getRepositories()[0].onDidChangeStatus statusHandler - editor.getBuffer().emitter.emit 'did-change-path' - expect(statusHandler.callCount).toBe 1 - expect(statusHandler).toHaveBeenCalledWith {path: editor.getPath(), pathStatus: 256} - editor.getBuffer().emitter.emit 'did-change-path' - expect(statusHandler.callCount).toBe 1 - - it "stops listening to the buffer when the repository is destroyed (regression)", -> - atom.project.getRepositories()[0].destroy() - expect(-> editor.save()).not.toThrow() - - describe "when a project is deserialized", -> - [buffer, project2, statusHandler] = [] - - afterEach -> - project2?.destroy() - - it "subscribes to all the serialized buffers in the project", -> - atom.project.setPaths([copyRepository()]) - - waitsForPromise -> - atom.workspace.open('file.txt') - - waitsForPromise -> - project2 = new Project({ - notificationManager: atom.notifications, - packageManager: atom.packages, - confirm: atom.confirm, - applicationDelegate: atom.applicationDelegate, - grammarRegistry: atom.grammars - }) - project2.deserialize(atom.project.serialize({isUnloading: false})) - - waitsFor -> - buffer = project2.getBuffers()[0] - - waitsForPromise -> - originalContent = buffer.getText() - buffer.append('changes') - - statusHandler = jasmine.createSpy('statusHandler') - project2.getRepositories()[0].onDidChangeStatus statusHandler - buffer.save() - - runs -> - expect(statusHandler.callCount).toBe 1 - expect(statusHandler).toHaveBeenCalledWith {path: buffer.getPath(), pathStatus: 256} diff --git a/spec/git-repository-spec.js b/spec/git-repository-spec.js new file mode 100644 index 000000000..61c80ee48 --- /dev/null +++ b/spec/git-repository-spec.js @@ -0,0 +1,394 @@ +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') +const path = require('path') +const fs = require('fs-plus') +const temp = require('temp').track() +const GitRepository = require('../src/git-repository') +const Project = require('../src/project') + +describe('GitRepository', () => { + let repo + + beforeEach(() => { + const gitPath = path.join(temp.dir, '.git') + if (fs.isDirectorySync(gitPath)) fs.removeSync(gitPath) + }) + + afterEach(() => { + if (repo && !repo.isDestroyed()) repo.destroy() + + // These tests sometimes lag at shutting down resources + try { + temp.cleanupSync() + } catch (error) {} + }) + + describe('@open(path)', () => { + it('returns null when no repository is found', () => { + expect(GitRepository.open(path.join(temp.dir, 'nogit.txt'))).toBeNull() + }) + }) + + describe('new GitRepository(path)', () => { + it('throws an exception when no repository is found', () => { + expect(() => new GitRepository(path.join(temp.dir, 'nogit.txt'))).toThrow() + }) + }) + + describe('.getPath()', () => { + it('returns the repository path for a .git directory path with a directory', () => { + repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects')) + expect(repo.getPath()).toBe(path.join(__dirname, 'fixtures', 'git', 'master.git')) + }) + + it('returns the repository path for a repository path', () => { + repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git')) + expect(repo.getPath()).toBe(path.join(__dirname, 'fixtures', 'git', 'master.git')) + }) + }) + + describe('.isPathIgnored(path)', () => { + it('returns true for an ignored path', () => { + repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git')) + expect(repo.isPathIgnored('a.txt')).toBeTruthy() + }) + + it('returns false for a non-ignored path', () => { + repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'ignore.git')) + expect(repo.isPathIgnored('b.txt')).toBeFalsy() + }) + }) + + describe('.isPathModified(path)', () => { + let filePath, newPath + + beforeEach(() => { + const workingDirPath = copyRepository() + repo = new GitRepository(workingDirPath) + filePath = path.join(workingDirPath, 'a.txt') + newPath = path.join(workingDirPath, 'new-path.txt') + }) + + describe('when the path is unstaged', () => { + it('returns false if the path has not been modified', () => { + expect(repo.isPathModified(filePath)).toBeFalsy() + }) + + it('returns true if the path is modified', () => { + fs.writeFileSync(filePath, 'change') + expect(repo.isPathModified(filePath)).toBeTruthy() + }) + + it('returns true if the path is deleted', () => { + fs.removeSync(filePath) + expect(repo.isPathModified(filePath)).toBeTruthy() + }) + + it('returns false if the path is new', () => { + expect(repo.isPathModified(newPath)).toBeFalsy() + }) + }) + }) + + describe('.isPathNew(path)', () => { + let filePath, newPath + + beforeEach(() => { + const workingDirPath = copyRepository() + repo = new GitRepository(workingDirPath) + filePath = path.join(workingDirPath, 'a.txt') + newPath = path.join(workingDirPath, 'new-path.txt') + fs.writeFileSync(newPath, "i'm new here") + }) + + describe('when the path is unstaged', () => { + it('returns true if the path is new', () => { + expect(repo.isPathNew(newPath)).toBeTruthy() + }) + + it("returns false if the path isn't new", () => { + expect(repo.isPathNew(filePath)).toBeFalsy() + }) + }) + }) + + describe('.checkoutHead(path)', () => { + let filePath + + beforeEach(() => { + const workingDirPath = copyRepository() + repo = new GitRepository(workingDirPath) + filePath = path.join(workingDirPath, 'a.txt') + }) + + it('no longer reports a path as modified after checkout', () => { + expect(repo.isPathModified(filePath)).toBeFalsy() + fs.writeFileSync(filePath, 'ch ch changes') + expect(repo.isPathModified(filePath)).toBeTruthy() + expect(repo.checkoutHead(filePath)).toBeTruthy() + expect(repo.isPathModified(filePath)).toBeFalsy() + }) + + it('restores the contents of the path to the original text', () => { + fs.writeFileSync(filePath, 'ch ch changes') + expect(repo.checkoutHead(filePath)).toBeTruthy() + expect(fs.readFileSync(filePath, 'utf8')).toBe('') + }) + + it('fires a status-changed event if the checkout completes successfully', () => { + fs.writeFileSync(filePath, 'ch ch changes') + repo.getPathStatus(filePath) + const statusHandler = jasmine.createSpy('statusHandler') + repo.onDidChangeStatus(statusHandler) + repo.checkoutHead(filePath) + expect(statusHandler.callCount).toBe(1) + expect(statusHandler.argsForCall[0][0]).toEqual({path: filePath, pathStatus: 0}) + + repo.checkoutHead(filePath) + expect(statusHandler.callCount).toBe(1) + }) + }) + + describe('.checkoutHeadForEditor(editor)', () => { + let filePath, editor + + beforeEach(async () => { + spyOn(atom, 'confirm') + + const workingDirPath = copyRepository() + repo = new GitRepository(workingDirPath, {project: atom.project, config: atom.config, confirm: atom.confirm}) + filePath = path.join(workingDirPath, 'a.txt') + fs.writeFileSync(filePath, 'ch ch changes') + + editor = await atom.workspace.open(filePath) + }) + + it('displays a confirmation dialog by default', () => { + // Permissions issues with this test on Windows + if (process.platform === 'win32') return + + atom.confirm.andCallFake(({buttons}) => buttons.OK()) + atom.config.set('editor.confirmCheckoutHeadRevision', true) + + repo.checkoutHeadForEditor(editor) + + expect(fs.readFileSync(filePath, 'utf8')).toBe('') + }) + + it('does not display a dialog when confirmation is disabled', () => { + // Flakey EPERM opening a.txt on Win32 + if (process.platform === 'win32') return + atom.config.set('editor.confirmCheckoutHeadRevision', false) + + repo.checkoutHeadForEditor(editor) + + expect(fs.readFileSync(filePath, 'utf8')).toBe('') + expect(atom.confirm).not.toHaveBeenCalled() + }) + }) + + describe('.destroy()', () => { + it('throws an exception when any method is called after it is called', () => { + repo = new GitRepository(path.join(__dirname, 'fixtures', 'git', 'master.git')) + repo.destroy() + expect(() => repo.getShortHead()).toThrow() + }) + }) + + describe('.getPathStatus(path)', () => { + let filePath + + beforeEach(() => { + const workingDirectory = copyRepository() + repo = new GitRepository(workingDirectory) + filePath = path.join(workingDirectory, 'file.txt') + }) + + it('trigger a status-changed event when the new status differs from the last cached one', () => { + const statusHandler = jasmine.createSpy('statusHandler') + repo.onDidChangeStatus(statusHandler) + fs.writeFileSync(filePath, '') + let status = repo.getPathStatus(filePath) + expect(statusHandler.callCount).toBe(1) + expect(statusHandler.argsForCall[0][0]).toEqual({path: filePath, pathStatus: status}) + + fs.writeFileSync(filePath, 'abc') + status = repo.getPathStatus(filePath) + expect(statusHandler.callCount).toBe(1) + }) + }) + + describe('.getDirectoryStatus(path)', () => { + let directoryPath, filePath + + beforeEach(() => { + const workingDirectory = copyRepository() + repo = new GitRepository(workingDirectory) + directoryPath = path.join(workingDirectory, 'dir') + filePath = path.join(directoryPath, 'b.txt') + }) + + it('gets the status based on the files inside the directory', () => { + expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe(false) + fs.writeFileSync(filePath, 'abc') + repo.getPathStatus(filePath) + expect(repo.isStatusModified(repo.getDirectoryStatus(directoryPath))).toBe(true) + }) + }) + + describe('.refreshStatus()', () => { + let newPath, modifiedPath, cleanPath, workingDirectory + + beforeEach(() => { + workingDirectory = copyRepository() + repo = new GitRepository(workingDirectory, {project: atom.project, config: atom.config}) + modifiedPath = path.join(workingDirectory, 'file.txt') + newPath = path.join(workingDirectory, 'untracked.txt') + cleanPath = path.join(workingDirectory, 'other.txt') + fs.writeFileSync(cleanPath, 'Full of text') + fs.writeFileSync(newPath, '') + newPath = fs.absolute(newPath) + }) + + it('returns status information for all new and modified files', async () => { + const statusHandler = jasmine.createSpy('statusHandler') + repo.onDidChangeStatuses(statusHandler) + fs.writeFileSync(modifiedPath, 'making this path modified') + + await repo.refreshStatus() + expect(statusHandler.callCount).toBe(1) + expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined() + expect(repo.isStatusNew(repo.getCachedPathStatus(newPath) )).toBeTruthy() + expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy() + }) + + it('caches the proper statuses when a subdir is open', async () => { + const subDir = path.join(workingDirectory, 'dir') + fs.mkdirSync(subDir) + const filePath = path.join(subDir, 'b.txt') + fs.writeFileSync(filePath, '') + atom.project.setPaths([subDir]) + await atom.workspace.open('b.txt') + repo = atom.project.getRepositories()[0] + + await repo.refreshStatus() + const status = repo.getCachedPathStatus(filePath) + expect(repo.isStatusModified(status)).toBe(false) + expect(repo.isStatusNew(status)).toBe(false) + }) + + it('works correctly when the project has multiple folders (regression)', async () => { + atom.project.addPath(workingDirectory) + atom.project.addPath(path.join(__dirname, 'fixtures', 'dir')) + + await repo.refreshStatus() + expect(repo.getCachedPathStatus(cleanPath)).toBeUndefined() + expect(repo.isStatusNew(repo.getCachedPathStatus(newPath))).toBeTruthy() + expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeTruthy() + }) + + it('caches statuses that were looked up synchronously', async () => { + const originalContent = 'undefined' + fs.writeFileSync(modifiedPath, 'making this path modified') + repo.getPathStatus('file.txt') + + fs.writeFileSync(modifiedPath, originalContent) + await repo.refreshStatus() + expect(repo.isStatusModified(repo.getCachedPathStatus(modifiedPath))).toBeFalsy() + }) + }) + + describe('buffer events', () => { + let editor + + beforeEach(async () => { + atom.project.setPaths([copyRepository()]) + const refreshPromise = new Promise(resolve => atom.project.getRepositories()[0].onDidChangeStatuses(resolve)) + editor = await atom.workspace.open('other.txt') + await refreshPromise + }) + + it('emits a status-changed event when a buffer is saved', async () => { + editor.insertNewline() + + const statusHandler = jasmine.createSpy('statusHandler') + atom.project.getRepositories()[0].onDidChangeStatus(statusHandler) + + await editor.save() + expect(statusHandler.callCount).toBe(1) + expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256}) + }) + + it('emits a status-changed event when a buffer is reloaded', async () => { + fs.writeFileSync(editor.getPath(), 'changed') + + const statusHandler = jasmine.createSpy('statusHandler') + atom.project.getRepositories()[0].onDidChangeStatus(statusHandler) + + await editor.getBuffer().reload() + expect(statusHandler.callCount).toBe(1) + expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256}) + + await editor.getBuffer().reload() + expect(statusHandler.callCount).toBe(1) + }) + + it("emits a status-changed event when a buffer's path changes", () => { + fs.writeFileSync(editor.getPath(), 'changed') + + const statusHandler = jasmine.createSpy('statusHandler') + atom.project.getRepositories()[0].onDidChangeStatus(statusHandler) + editor.getBuffer().emitter.emit('did-change-path') + expect(statusHandler.callCount).toBe(1) + expect(statusHandler).toHaveBeenCalledWith({path: editor.getPath(), pathStatus: 256}) + editor.getBuffer().emitter.emit('did-change-path') + expect(statusHandler.callCount).toBe(1) + }) + + it('stops listening to the buffer when the repository is destroyed (regression)', () => { + atom.project.getRepositories()[0].destroy() + expect(() => editor.save()).not.toThrow() + }) + }) + + describe('when a project is deserialized', () => { + let buffer, project2, statusHandler + + afterEach(() => { + if (project2) project2.destroy() + }) + + it('subscribes to all the serialized buffers in the project', async () => { + atom.project.setPaths([copyRepository()]) + + await atom.workspace.open('file.txt') + + project2 = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm, + grammarRegistry: atom.grammars, + applicationDelegate: atom.applicationDelegate + }) + await project2.deserialize(atom.project.serialize({isUnloading: false})) + + buffer = project2.getBuffers()[0] + + const originalContent = buffer.getText() + buffer.append('changes') + + statusHandler = jasmine.createSpy('statusHandler') + project2.getRepositories()[0].onDidChangeStatus(statusHandler) + await buffer.save() + + expect(statusHandler.callCount).toBe(1) + expect(statusHandler).toHaveBeenCalledWith({path: buffer.getPath(), pathStatus: 256}) + }) + }) +}) + +function copyRepository () { + const workingDirPath = temp.mkdirSync('atom-spec-git') + fs.copySync(path.join(__dirname, 'fixtures', 'git', 'working-dir'), workingDirPath) + fs.renameSync(path.join(workingDirPath, 'git.git'), path.join(workingDirPath, '.git')) + return workingDirPath +} diff --git a/spec/main-process/atom-application.test.js b/spec/main-process/atom-application.test.js index 62fae82b3..01d052b96 100644 --- a/spec/main-process/atom-application.test.js +++ b/spec/main-process/atom-application.test.js @@ -5,6 +5,7 @@ import dedent from 'dedent' import electron from 'electron' import fs from 'fs-plus' import path from 'path' +import sinon from 'sinon' import AtomApplication from '../../src/main-process/atom-application' import parseCommandLine from '../../src/main-process/parse-command-line' import {timeoutPromise, conditionPromise, emitterEventPromise} from '../async-spec-helpers' @@ -137,7 +138,7 @@ describe('AtomApplication', function () { // Does not change the project paths when doing so. const reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath])) assert.equal(reusedWindow, window1) - assert.deepEqual(atomApplication.windows, [window1]) + assert.deepEqual(atomApplication.getAllWindows(), [window1]) activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { sendBackToMainProcess(textEditor.getPath()) @@ -177,7 +178,7 @@ describe('AtomApplication', function () { // parent directory to the project let reusedWindow = atomApplication.launch(parseCommandLine([existingDirCFilePath, '--add'])) assert.equal(reusedWindow, window1) - assert.deepEqual(atomApplication.windows, [window1]) + assert.deepEqual(atomApplication.getAllWindows(), [window1]) activeEditorPath = await evalInWebContents(window1.browserWindow.webContents, function (sendBackToMainProcess) { const subscription = atom.workspace.onDidChangeActivePaneItem(function (textEditor) { sendBackToMainProcess(textEditor.getPath()) @@ -191,7 +192,7 @@ describe('AtomApplication', function () { // the directory to the project reusedWindow = atomApplication.launch(parseCommandLine([dirBPath, '-a'])) assert.equal(reusedWindow, window1) - assert.deepEqual(atomApplication.windows, [window1]) + assert.deepEqual(atomApplication.getAllWindows(), [window1]) await conditionPromise(async () => (await getTreeViewRootDirectories(reusedWindow)).length === 3) assert.deepEqual(await getTreeViewRootDirectories(window1), [dirAPath, dirCPath, dirBPath]) @@ -276,7 +277,7 @@ describe('AtomApplication', function () { }) assert.equal(window2EditorTitle, 'untitled') - assert.deepEqual(atomApplication.windows, [window1, window2]) + assert.deepEqual(atomApplication.getAllWindows(), [window2, window1]) }) it('does not open an empty editor when opened with no path if the core.openEmptyEditorOnStart config setting is false', async function () { @@ -461,6 +462,31 @@ describe('AtomApplication', function () { assert.equal(reached, true); windows[0].close(); }) + + it('triggers /core/open/file in the correct window', async function() { + const dirAPath = makeTempDir('a') + const dirBPath = makeTempDir('b') + + const atomApplication = buildAtomApplication() + const window1 = atomApplication.launch(parseCommandLine([path.join(dirAPath)])) + await focusWindow(window1) + const window2 = atomApplication.launch(parseCommandLine([path.join(dirBPath)])) + await focusWindow(window2) + + const fileA = path.join(dirAPath, 'file-a') + const uriA = `atom://core/open/file?filename=${fileA}` + const fileB = path.join(dirBPath, 'file-b') + const uriB = `atom://core/open/file?filename=${fileB}` + + sinon.spy(window1, 'sendURIMessage') + sinon.spy(window2, 'sendURIMessage') + + atomApplication.launch(parseCommandLine(['--uri-handler', uriA])) + await conditionPromise(() => window1.sendURIMessage.calledWith(uriA), `window1 to be focused from ${fileA}`) + + atomApplication.launch(parseCommandLine(['--uri-handler', uriB])) + await conditionPromise(() => window2.sendURIMessage.calledWith(uriB), `window2 to be focused from ${fileB}`) + }) }) }) @@ -514,7 +540,7 @@ describe('AtomApplication', function () { async function focusWindow (window) { window.focus() await window.loadedPromise - await conditionPromise(() => window.atomApplication.lastFocusedWindow === window) + await conditionPromise(() => window.atomApplication.getLastFocusedWindow() === window) } function mockElectronAppQuit () { diff --git a/spec/project-spec.js b/spec/project-spec.js index 103a9e403..bd6bb1fa6 100644 --- a/spec/project-spec.js +++ b/spec/project-spec.js @@ -385,9 +385,14 @@ describe('Project', () => { isRoot () { return true } existsSync () { return this.path.endsWith('does-exist') } contains (filePath) { return filePath.startsWith(this.path) } + onDidChangeFiles (callback) { + onDidChangeFilesCallback = callback + return {dispose: () => {}} + } } let serviceDisposable = null + let onDidChangeFilesCallback = null beforeEach(() => { serviceDisposable = atom.packages.serviceHub.provide('atom.directory-provider', '0.1.0', { @@ -399,6 +404,7 @@ describe('Project', () => { } } }) + onDidChangeFilesCallback = null waitsFor(() => atom.project.directoryProviders.length > 0) }) @@ -433,6 +439,28 @@ describe('Project', () => { atom.project.setPaths(['ssh://foreign-directory:8080/does-exist']) expect(atom.project.getDirectories().length).toBe(0) }) + + it('uses the custom onDidChangeFiles as the watcher if available', () => { + // Ensure that all preexisting watchers are stopped + waitsForPromise(() => stopAllWatchers()) + + const remotePath = 'ssh://another-directory:8080/does-exist' + runs(() => atom.project.setPaths([remotePath])) + waitsForPromise(() => atom.project.getWatcherPromise(remotePath)) + + runs(() => { + expect(onDidChangeFilesCallback).not.toBeNull() + + const changeSpy = jasmine.createSpy('atom.project.onDidChangeFiles') + const disposable = atom.project.onDidChangeFiles(changeSpy) + + const events = [{action: 'created', path: remotePath + '/test.txt'}] + onDidChangeFilesCallback(events) + + expect(changeSpy).toHaveBeenCalledWith(events) + disposable.dispose() + }) + }) }) describe('.open(path)', () => { diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index cece5d753..382d020d4 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -2007,13 +2007,17 @@ describe('TextEditor', () => { describe('when the cursor is between two words', () => { it('selects the word the cursor is on', () => { - editor.setCursorScreenPosition([0, 4]) + editor.setCursorBufferPosition([0, 4]) editor.selectWordsContainingCursors() expect(editor.getSelectedText()).toBe('quicksort') - editor.setCursorScreenPosition([0, 3]) + editor.setCursorBufferPosition([0, 3]) editor.selectWordsContainingCursors() expect(editor.getSelectedText()).toBe('var') + + editor.setCursorBufferPosition([1, 22]) + editor.selectWordsContainingCursors() + expect(editor.getSelectedText()).toBe('items') }) }) diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee deleted file mode 100644 index 86237b71d..000000000 --- a/spec/theme-manager-spec.coffee +++ /dev/null @@ -1,437 +0,0 @@ -path = require 'path' -fs = require 'fs-plus' -temp = require('temp').track() - -describe "atom.themes", -> - beforeEach -> - spyOn(atom, 'inSpecMode').andReturn(false) - spyOn(console, 'warn') - - afterEach -> - waitsForPromise -> - atom.themes.deactivateThemes() - runs -> - try - temp.cleanupSync() - - describe "theme getters and setters", -> - beforeEach -> - jasmine.snapshotDeprecations() - atom.packages.loadPackages() - - afterEach -> - jasmine.restoreDeprecationsSnapshot() - - describe 'getLoadedThemes', -> - it 'gets all the loaded themes', -> - themes = atom.themes.getLoadedThemes() - expect(themes.length).toBeGreaterThan(2) - - describe "getActiveThemes", -> - it 'gets all the active themes', -> - waitsForPromise -> atom.themes.activateThemes() - - runs -> - names = atom.config.get('core.themes') - expect(names.length).toBeGreaterThan(0) - themes = atom.themes.getActiveThemes() - expect(themes).toHaveLength(names.length) - - describe "when the core.themes config value contains invalid entry", -> - it "ignores theme", -> - atom.config.set 'core.themes', [ - 'atom-light-ui' - null - undefined - '' - false - 4 - {} - [] - 'atom-dark-ui' - ] - - expect(atom.themes.getEnabledThemeNames()).toEqual ['atom-dark-ui', 'atom-light-ui'] - - describe "::getImportPaths()", -> - it "returns the theme directories before the themes are loaded", -> - atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui', 'atom-light-ui']) - - paths = atom.themes.getImportPaths() - - # syntax theme is not a dir at this time, so only two. - expect(paths.length).toBe 2 - expect(paths[0]).toContain 'atom-light-ui' - expect(paths[1]).toContain 'atom-dark-ui' - - it "ignores themes that cannot be resolved to a directory", -> - atom.config.set('core.themes', ['definitely-not-a-theme']) - expect(-> atom.themes.getImportPaths()).not.toThrow() - - describe "when the core.themes config value changes", -> - it "add/removes stylesheets to reflect the new config value", -> - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() - spyOn(atom.styles, 'getUserStyleSheetPath').andCallFake -> null - - waitsForPromise -> - atom.themes.activateThemes() - - runs -> - didChangeActiveThemesHandler.reset() - atom.config.set('core.themes', []) - - waitsFor 'a', -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - didChangeActiveThemesHandler.reset() - expect(document.querySelectorAll('style.theme')).toHaveLength 0 - atom.config.set('core.themes', ['atom-dark-ui']) - - waitsFor 'b', -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - didChangeActiveThemesHandler.reset() - expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2 - expect(document.querySelector('style[priority="1"]').getAttribute('source-path')).toMatch /atom-dark-ui/ - atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-ui']) - - waitsFor 'c', -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - didChangeActiveThemesHandler.reset() - expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2 - expect(document.querySelectorAll('style[priority="1"]')[0].getAttribute('source-path')).toMatch /atom-dark-ui/ - expect(document.querySelectorAll('style[priority="1"]')[1].getAttribute('source-path')).toMatch /atom-light-ui/ - atom.config.set('core.themes', []) - - waitsFor -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - didChangeActiveThemesHandler.reset() - expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2 - # atom-dark-ui has an directory path, the syntax one doesn't - atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui']) - - waitsFor -> - didChangeActiveThemesHandler.callCount is 1 - - runs -> - expect(document.querySelectorAll('style[priority="1"]')).toHaveLength 2 - importPaths = atom.themes.getImportPaths() - expect(importPaths.length).toBe 1 - expect(importPaths[0]).toContain 'atom-dark-ui' - - it 'adds theme-* classes to the workspace for each active theme', -> - atom.config.set('core.themes', ['atom-dark-ui', 'atom-dark-syntax']) - workspaceElement = atom.workspace.getElement() - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() - - waitsForPromise -> - atom.themes.activateThemes() - - runs -> - expect(workspaceElement).toHaveClass 'theme-atom-dark-ui' - - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() - atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables']) - - waitsFor -> - didChangeActiveThemesHandler.callCount > 0 - - runs -> - # `theme-` twice as it prefixes the name with `theme-` - expect(workspaceElement).toHaveClass 'theme-theme-with-ui-variables' - expect(workspaceElement).toHaveClass 'theme-theme-with-syntax-variables' - expect(workspaceElement).not.toHaveClass 'theme-atom-dark-ui' - expect(workspaceElement).not.toHaveClass 'theme-atom-dark-syntax' - - describe "when a theme fails to load", -> - it "logs a warning", -> - console.warn.reset() - atom.packages.activatePackage('a-theme-that-will-not-be-found').then((->), (->)) - expect(console.warn.callCount).toBe 1 - expect(console.warn.argsForCall[0][0]).toContain "Could not resolve 'a-theme-that-will-not-be-found'" - - describe "::requireStylesheet(path)", -> - beforeEach -> - jasmine.snapshotDeprecations() - - afterEach -> - jasmine.restoreDeprecationsSnapshot() - - it "synchronously loads css at the given path and installs a style tag for it in the head", -> - atom.styles.onDidAddStyleElement styleElementAddedHandler = jasmine.createSpy("styleElementAddedHandler") - - cssPath = atom.project.getDirectories()[0]?.resolve('css.css') - lengthBefore = document.querySelectorAll('head style').length - - atom.themes.requireStylesheet(cssPath) - expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1 - - expect(styleElementAddedHandler).toHaveBeenCalled() - - element = document.querySelector('head style[source-path*="css.css"]') - expect(element.getAttribute('source-path')).toEqualPath cssPath - expect(element.textContent).toBe fs.readFileSync(cssPath, 'utf8') - - # doesn't append twice - styleElementAddedHandler.reset() - atom.themes.requireStylesheet(cssPath) - expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1 - expect(styleElementAddedHandler).not.toHaveBeenCalled() - - for styleElement in document.querySelectorAll('head style[id*="css.css"]') - styleElement.remove() - - it "synchronously loads and parses less files at the given path and installs a style tag for it in the head", -> - lessPath = atom.project.getDirectories()[0]?.resolve('sample.less') - lengthBefore = document.querySelectorAll('head style').length - atom.themes.requireStylesheet(lessPath) - expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1 - - element = document.querySelector('head style[source-path*="sample.less"]') - expect(element.getAttribute('source-path')).toEqualPath lessPath - expect(element.textContent.toLowerCase()).toBe """ - #header { - color: #4d926f; - } - h2 { - color: #4d926f; - } - - """ - - # doesn't append twice - atom.themes.requireStylesheet(lessPath) - expect(document.querySelectorAll('head style').length).toBe lengthBefore + 1 - for styleElement in document.querySelectorAll('head style[id*="sample.less"]') - styleElement.remove() - - it "supports requiring css and less stylesheets without an explicit extension", -> - atom.themes.requireStylesheet path.join(__dirname, 'fixtures', 'css') - expect(document.querySelector('head style[source-path*="css.css"]').getAttribute('source-path')).toEqualPath atom.project.getDirectories()[0]?.resolve('css.css') - atom.themes.requireStylesheet path.join(__dirname, 'fixtures', 'sample') - expect(document.querySelector('head style[source-path*="sample.less"]').getAttribute('source-path')).toEqualPath atom.project.getDirectories()[0]?.resolve('sample.less') - - document.querySelector('head style[source-path*="css.css"]').remove() - document.querySelector('head style[source-path*="sample.less"]').remove() - - it "returns a disposable allowing styles applied by the given path to be removed", -> - cssPath = require.resolve('./fixtures/css.css') - - expect(getComputedStyle(document.body).fontWeight).not.toBe("bold") - disposable = atom.themes.requireStylesheet(cssPath) - expect(getComputedStyle(document.body).fontWeight).toBe("bold") - - atom.styles.onDidRemoveStyleElement styleElementRemovedHandler = jasmine.createSpy("styleElementRemovedHandler") - - disposable.dispose() - - expect(getComputedStyle(document.body).fontWeight).not.toBe("bold") - - expect(styleElementRemovedHandler).toHaveBeenCalled() - - - describe "base style sheet loading", -> - beforeEach -> - workspaceElement = atom.workspace.getElement() - jasmine.attachToDOM(atom.workspace.getElement()) - workspaceElement.appendChild document.createElement('atom-text-editor') - - waitsForPromise -> - atom.themes.activateThemes() - - it "loads the correct values from the theme's ui-variables file", -> - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() - atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables']) - - waitsFor -> - didChangeActiveThemesHandler.callCount > 0 - - runs -> - # an override loaded in the base css - expect(getComputedStyle(atom.workspace.getElement())["background-color"]).toBe "rgb(0, 0, 255)" - - # from within the theme itself - expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingTop).toBe "150px" - expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingRight).toBe "150px" - expect(getComputedStyle(document.querySelector("atom-text-editor")).paddingBottom).toBe "150px" - - describe "when there is a theme with incomplete variables", -> - it "loads the correct values from the fallback ui-variables", -> - atom.themes.onDidChangeActiveThemes didChangeActiveThemesHandler = jasmine.createSpy() - atom.config.set('core.themes', ['theme-with-incomplete-ui-variables', 'theme-with-syntax-variables']) - - waitsFor -> - didChangeActiveThemesHandler.callCount > 0 - - runs -> - # an override loaded in the base css - expect(getComputedStyle(atom.workspace.getElement())["background-color"]).toBe "rgb(0, 0, 255)" - - # from within the theme itself - expect(getComputedStyle(document.querySelector("atom-text-editor")).backgroundColor).toBe "rgb(0, 152, 255)" - - describe "user stylesheet", -> - userStylesheetPath = null - beforeEach -> - userStylesheetPath = path.join(temp.mkdirSync("atom"), 'styles.less') - fs.writeFileSync(userStylesheetPath, 'body {border-style: dotted !important;}') - spyOn(atom.styles, 'getUserStyleSheetPath').andReturn userStylesheetPath - - describe "when the user stylesheet changes", -> - beforeEach -> - jasmine.snapshotDeprecations() - - afterEach -> - jasmine.restoreDeprecationsSnapshot() - - it "reloads it", -> - [styleElementAddedHandler, styleElementRemovedHandler] = [] - - waitsForPromise -> - atom.themes.activateThemes() - - runs -> - atom.styles.onDidRemoveStyleElement styleElementRemovedHandler = jasmine.createSpy("styleElementRemovedHandler") - atom.styles.onDidAddStyleElement styleElementAddedHandler = jasmine.createSpy("styleElementAddedHandler") - - spyOn(atom.themes, 'loadUserStylesheet').andCallThrough() - - expect(getComputedStyle(document.body).borderStyle).toBe 'dotted' - fs.writeFileSync(userStylesheetPath, 'body {border-style: dashed}') - - waitsFor -> - atom.themes.loadUserStylesheet.callCount is 1 - - runs -> - expect(getComputedStyle(document.body).borderStyle).toBe 'dashed' - - expect(styleElementRemovedHandler).toHaveBeenCalled() - expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain 'dotted' - - expect(styleElementAddedHandler).toHaveBeenCalled() - expect(styleElementAddedHandler.argsForCall[0][0].textContent).toContain 'dashed' - - styleElementRemovedHandler.reset() - fs.removeSync(userStylesheetPath) - - waitsFor -> - atom.themes.loadUserStylesheet.callCount is 2 - - runs -> - expect(styleElementRemovedHandler).toHaveBeenCalled() - expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain 'dashed' - expect(getComputedStyle(document.body).borderStyle).toBe 'none' - - describe "when there is an error reading the stylesheet", -> - addErrorHandler = null - beforeEach -> - atom.themes.loadUserStylesheet() - spyOn(atom.themes.lessCache, 'cssForFile').andCallFake -> - throw new Error('EACCES permission denied "styles.less"') - atom.notifications.onDidAddNotification addErrorHandler = jasmine.createSpy() - - it "creates an error notification and does not add the stylesheet", -> - atom.themes.loadUserStylesheet() - expect(addErrorHandler).toHaveBeenCalled() - note = addErrorHandler.mostRecentCall.args[0] - expect(note.getType()).toBe 'error' - expect(note.getMessage()).toContain 'Error loading' - expect(atom.styles.styleElementsBySourcePath[atom.styles.getUserStyleSheetPath()]).toBeUndefined() - - describe "when there is an error watching the user stylesheet", -> - addErrorHandler = null - beforeEach -> - {File} = require 'pathwatcher' - spyOn(File::, 'on').andCallFake (event) -> - if event.indexOf('contents-changed') > -1 - throw new Error('Unable to watch path') - spyOn(atom.themes, 'loadStylesheet').andReturn '' - atom.notifications.onDidAddNotification addErrorHandler = jasmine.createSpy() - - it "creates an error notification", -> - atom.themes.loadUserStylesheet() - expect(addErrorHandler).toHaveBeenCalled() - note = addErrorHandler.mostRecentCall.args[0] - expect(note.getType()).toBe 'error' - expect(note.getMessage()).toContain 'Unable to watch path' - - it "adds a notification when a theme's stylesheet is invalid", -> - addErrorHandler = jasmine.createSpy() - atom.notifications.onDidAddNotification(addErrorHandler) - expect(-> atom.packages.activatePackage('theme-with-invalid-styles').then((->), (->))).not.toThrow() - expect(addErrorHandler.callCount).toBe 2 - expect(addErrorHandler.argsForCall[1][0].message).toContain("Failed to activate the theme-with-invalid-styles theme") - - describe "when a non-existent theme is present in the config", -> - beforeEach -> - console.warn.reset() - atom.config.set('core.themes', ['non-existent-dark-ui', 'non-existent-dark-syntax']) - - waitsForPromise -> - atom.themes.activateThemes() - - it 'uses the default dark UI and syntax themes and logs a warning', -> - activeThemeNames = atom.themes.getActiveThemeNames() - expect(console.warn.callCount).toBe 2 - expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') - - describe "when in safe mode", -> - describe 'when the enabled UI and syntax themes are bundled with Atom', -> - beforeEach -> - atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-syntax']) - - waitsForPromise -> - atom.themes.activateThemes() - - it 'uses the enabled themes', -> - activeThemeNames = atom.themes.getActiveThemeNames() - expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-light-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') - - describe 'when the enabled UI and syntax themes are not bundled with Atom', -> - beforeEach -> - atom.config.set('core.themes', ['installed-dark-ui', 'installed-dark-syntax']) - - waitsForPromise -> - atom.themes.activateThemes() - - it 'uses the default dark UI and syntax themes', -> - activeThemeNames = atom.themes.getActiveThemeNames() - expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') - - describe 'when the enabled UI theme is not bundled with Atom', -> - beforeEach -> - atom.config.set('core.themes', ['installed-dark-ui', 'atom-light-syntax']) - - waitsForPromise -> - atom.themes.activateThemes() - - it 'uses the default dark UI theme', -> - activeThemeNames = atom.themes.getActiveThemeNames() - expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-dark-ui') - expect(activeThemeNames).toContain('atom-light-syntax') - - describe 'when the enabled syntax theme is not bundled with Atom', -> - beforeEach -> - atom.config.set('core.themes', ['atom-light-ui', 'installed-dark-syntax']) - - waitsForPromise -> - atom.themes.activateThemes() - - it 'uses the default dark syntax theme', -> - activeThemeNames = atom.themes.getActiveThemeNames() - expect(activeThemeNames.length).toBe(2) - expect(activeThemeNames).toContain('atom-light-ui') - expect(activeThemeNames).toContain('atom-dark-syntax') diff --git a/spec/theme-manager-spec.js b/spec/theme-manager-spec.js new file mode 100644 index 000000000..f4ed3b9f5 --- /dev/null +++ b/spec/theme-manager-spec.js @@ -0,0 +1,503 @@ +const path = require('path') +const fs = require('fs-plus') +const temp = require('temp').track() + +describe('atom.themes', function () { + beforeEach(function () { + spyOn(atom, 'inSpecMode').andReturn(false) + spyOn(console, 'warn') + }) + + afterEach(function () { + waitsForPromise(() => atom.themes.deactivateThemes()) + runs(function () { + try { + temp.cleanupSync() + } catch (error) {} + }) + }) + + describe('theme getters and setters', function () { + beforeEach(function () { + jasmine.snapshotDeprecations() + atom.packages.loadPackages() + }) + + afterEach(() => jasmine.restoreDeprecationsSnapshot()) + + describe('getLoadedThemes', () => + it('gets all the loaded themes', function () { + const themes = atom.themes.getLoadedThemes() + expect(themes.length).toBeGreaterThan(2) + }) + ) + + describe('getActiveThemes', () => + it('gets all the active themes', function () { + waitsForPromise(() => atom.themes.activateThemes()) + + runs(function () { + const names = atom.config.get('core.themes') + expect(names.length).toBeGreaterThan(0) + const themes = atom.themes.getActiveThemes() + expect(themes).toHaveLength(names.length) + }) + }) + ) + }) + + describe('when the core.themes config value contains invalid entry', () => + it('ignores theme', function () { + atom.config.set('core.themes', [ + 'atom-light-ui', + null, + undefined, + '', + false, + 4, + {}, + [], + 'atom-dark-ui' + ]) + + expect(atom.themes.getEnabledThemeNames()).toEqual(['atom-dark-ui', 'atom-light-ui']) + }) +) + + describe('::getImportPaths()', function () { + it('returns the theme directories before the themes are loaded', function () { + atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui', 'atom-light-ui']) + + const paths = atom.themes.getImportPaths() + + // syntax theme is not a dir at this time, so only two. + expect(paths.length).toBe(2) + expect(paths[0]).toContain('atom-light-ui') + expect(paths[1]).toContain('atom-dark-ui') + }) + + it('ignores themes that cannot be resolved to a directory', function () { + atom.config.set('core.themes', ['definitely-not-a-theme']) + expect(() => atom.themes.getImportPaths()).not.toThrow() + }) + }) + + describe('when the core.themes config value changes', function () { + it('add/removes stylesheets to reflect the new config value', function () { + let didChangeActiveThemesHandler + atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy()) + spyOn(atom.styles, 'getUserStyleSheetPath').andCallFake(() => null) + + waitsForPromise(() => atom.themes.activateThemes()) + + runs(function () { + didChangeActiveThemesHandler.reset() + atom.config.set('core.themes', []) + }) + + waitsFor('a', () => didChangeActiveThemesHandler.callCount === 1) + + runs(function () { + didChangeActiveThemesHandler.reset() + expect(document.querySelectorAll('style.theme')).toHaveLength(0) + atom.config.set('core.themes', ['atom-dark-ui']) + }) + + waitsFor('b', () => didChangeActiveThemesHandler.callCount === 1) + + runs(function () { + didChangeActiveThemesHandler.reset() + expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2) + expect(document.querySelector('style[priority="1"]').getAttribute('source-path')).toMatch(/atom-dark-ui/) + atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-ui']) + }) + + waitsFor('c', () => didChangeActiveThemesHandler.callCount === 1) + + runs(function () { + didChangeActiveThemesHandler.reset() + expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2) + expect(document.querySelectorAll('style[priority="1"]')[0].getAttribute('source-path')).toMatch(/atom-dark-ui/) + expect(document.querySelectorAll('style[priority="1"]')[1].getAttribute('source-path')).toMatch(/atom-light-ui/) + atom.config.set('core.themes', []) + }) + + waitsFor(() => didChangeActiveThemesHandler.callCount === 1) + + runs(function () { + didChangeActiveThemesHandler.reset() + expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2) + // atom-dark-ui has a directory path, the syntax one doesn't + atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui']) + }) + + waitsFor(() => didChangeActiveThemesHandler.callCount === 1) + + runs(function () { + expect(document.querySelectorAll('style[priority="1"]')).toHaveLength(2) + const importPaths = atom.themes.getImportPaths() + expect(importPaths.length).toBe(1) + expect(importPaths[0]).toContain('atom-dark-ui') + }) + }) + + it('adds theme-* classes to the workspace for each active theme', function () { + atom.config.set('core.themes', ['atom-dark-ui', 'atom-dark-syntax']) + + let didChangeActiveThemesHandler + atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy()) + waitsForPromise(() => atom.themes.activateThemes()) + + const workspaceElement = atom.workspace.getElement() + runs(function () { + expect(workspaceElement).toHaveClass('theme-atom-dark-ui') + + atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy()) + atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables']) + }) + + waitsFor(() => didChangeActiveThemesHandler.callCount > 0) + + runs(function () { + // `theme-` twice as it prefixes the name with `theme-` + expect(workspaceElement).toHaveClass('theme-theme-with-ui-variables') + expect(workspaceElement).toHaveClass('theme-theme-with-syntax-variables') + expect(workspaceElement).not.toHaveClass('theme-atom-dark-ui') + expect(workspaceElement).not.toHaveClass('theme-atom-dark-syntax') + }) + }) + }) + + describe('when a theme fails to load', () => + it('logs a warning', function () { + console.warn.reset() + atom.packages.activatePackage('a-theme-that-will-not-be-found').then(function () {}, function () {}) + expect(console.warn.callCount).toBe(1) + expect(console.warn.argsForCall[0][0]).toContain("Could not resolve 'a-theme-that-will-not-be-found'") + }) + ) + + describe('::requireStylesheet(path)', function () { + beforeEach(() => jasmine.snapshotDeprecations()) + + afterEach(() => jasmine.restoreDeprecationsSnapshot()) + + it('synchronously loads css at the given path and installs a style tag for it in the head', function () { + let styleElementAddedHandler + atom.styles.onDidAddStyleElement(styleElementAddedHandler = jasmine.createSpy('styleElementAddedHandler')) + + const cssPath = getAbsolutePath(atom.project.getDirectories()[0], 'css.css') + const lengthBefore = document.querySelectorAll('head style').length + + atom.themes.requireStylesheet(cssPath) + expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1) + + expect(styleElementAddedHandler).toHaveBeenCalled() + + const element = document.querySelector('head style[source-path*="css.css"]') + expect(element.getAttribute('source-path')).toEqualPath(cssPath) + expect(element.textContent).toBe(fs.readFileSync(cssPath, 'utf8')) + + // doesn't append twice + styleElementAddedHandler.reset() + atom.themes.requireStylesheet(cssPath) + expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1) + expect(styleElementAddedHandler).not.toHaveBeenCalled() + + document.querySelectorAll('head style[id*="css.css"]').forEach((styleElement) => { + styleElement.remove() + }) + }) + + it('synchronously loads and parses less files at the given path and installs a style tag for it in the head', function () { + const lessPath = getAbsolutePath(atom.project.getDirectories()[0], 'sample.less') + const lengthBefore = document.querySelectorAll('head style').length + atom.themes.requireStylesheet(lessPath) + expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1) + + const element = document.querySelector('head style[source-path*="sample.less"]') + expect(element.getAttribute('source-path')).toEqualPath(lessPath) + expect(element.textContent.toLowerCase()).toBe(`\ +#header { + color: #4d926f; +} +h2 { + color: #4d926f; +} +\ +` + ) + + // doesn't append twice + atom.themes.requireStylesheet(lessPath) + expect(document.querySelectorAll('head style').length).toBe(lengthBefore + 1) + document.querySelectorAll('head style[id*="sample.less"]').forEach((styleElement) => { + styleElement.remove() + }) + }) + + it('supports requiring css and less stylesheets without an explicit extension', function () { + atom.themes.requireStylesheet(path.join(__dirname, 'fixtures', 'css')) + expect(document.querySelector('head style[source-path*="css.css"]').getAttribute('source-path')) + .toEqualPath(getAbsolutePath(atom.project.getDirectories()[0], 'css.css')) + atom.themes.requireStylesheet(path.join(__dirname, 'fixtures', 'sample')) + expect(document.querySelector('head style[source-path*="sample.less"]').getAttribute('source-path')) + .toEqualPath(getAbsolutePath(atom.project.getDirectories()[0], 'sample.less')) + + document.querySelector('head style[source-path*="css.css"]').remove() + document.querySelector('head style[source-path*="sample.less"]').remove() + }) + + it('returns a disposable allowing styles applied by the given path to be removed', function () { + const cssPath = require.resolve('./fixtures/css.css') + + expect(getComputedStyle(document.body).fontWeight).not.toBe('bold') + const disposable = atom.themes.requireStylesheet(cssPath) + expect(getComputedStyle(document.body).fontWeight).toBe('bold') + + let styleElementRemovedHandler + atom.styles.onDidRemoveStyleElement(styleElementRemovedHandler = jasmine.createSpy('styleElementRemovedHandler')) + + disposable.dispose() + + expect(getComputedStyle(document.body).fontWeight).not.toBe('bold') + + expect(styleElementRemovedHandler).toHaveBeenCalled() + }) + }) + + describe('base style sheet loading', function () { + beforeEach(function () { + const workspaceElement = atom.workspace.getElement() + jasmine.attachToDOM(atom.workspace.getElement()) + workspaceElement.appendChild(document.createElement('atom-text-editor')) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it("loads the correct values from the theme's ui-variables file", function () { + let didChangeActiveThemesHandler + atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy()) + atom.config.set('core.themes', ['theme-with-ui-variables', 'theme-with-syntax-variables']) + + waitsFor(() => didChangeActiveThemesHandler.callCount > 0) + + runs(function () { + // an override loaded in the base css + expect(getComputedStyle(atom.workspace.getElement())['background-color']).toBe('rgb(0, 0, 255)') + + // from within the theme itself + expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingTop).toBe('150px') + expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingRight).toBe('150px') + expect(getComputedStyle(document.querySelector('atom-text-editor')).paddingBottom).toBe('150px') + }) + }) + + describe('when there is a theme with incomplete variables', () => + it('loads the correct values from the fallback ui-variables', function () { + let didChangeActiveThemesHandler + atom.themes.onDidChangeActiveThemes(didChangeActiveThemesHandler = jasmine.createSpy()) + atom.config.set('core.themes', ['theme-with-incomplete-ui-variables', 'theme-with-syntax-variables']) + + waitsFor(() => didChangeActiveThemesHandler.callCount > 0) + + runs(function () { + // an override loaded in the base css + expect(getComputedStyle(atom.workspace.getElement())['background-color']).toBe('rgb(0, 0, 255)') + + // from within the theme itself + expect(getComputedStyle(document.querySelector('atom-text-editor')).backgroundColor).toBe('rgb(0, 152, 255)') + }) + }) + ) + }) + + describe('user stylesheet', function () { + let userStylesheetPath + beforeEach(function () { + userStylesheetPath = path.join(temp.mkdirSync('atom'), 'styles.less') + fs.writeFileSync(userStylesheetPath, 'body {border-style: dotted !important;}') + spyOn(atom.styles, 'getUserStyleSheetPath').andReturn(userStylesheetPath) + }) + + describe('when the user stylesheet changes', function () { + beforeEach(() => jasmine.snapshotDeprecations()) + + afterEach(() => jasmine.restoreDeprecationsSnapshot()) + + it('reloads it', function () { + let styleElementAddedHandler, styleElementRemovedHandler + + waitsForPromise(() => atom.themes.activateThemes()) + + runs(function () { + atom.styles.onDidRemoveStyleElement(styleElementRemovedHandler = jasmine.createSpy('styleElementRemovedHandler')) + atom.styles.onDidAddStyleElement(styleElementAddedHandler = jasmine.createSpy('styleElementAddedHandler')) + + spyOn(atom.themes, 'loadUserStylesheet').andCallThrough() + + expect(getComputedStyle(document.body).borderStyle).toBe('dotted') + fs.writeFileSync(userStylesheetPath, 'body {border-style: dashed}') + }) + + waitsFor(() => atom.themes.loadUserStylesheet.callCount === 1) + + runs(function () { + expect(getComputedStyle(document.body).borderStyle).toBe('dashed') + + expect(styleElementRemovedHandler).toHaveBeenCalled() + expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain('dotted') + + expect(styleElementAddedHandler).toHaveBeenCalled() + expect(styleElementAddedHandler.argsForCall[0][0].textContent).toContain('dashed') + + styleElementRemovedHandler.reset() + fs.removeSync(userStylesheetPath) + }) + + waitsFor(() => atom.themes.loadUserStylesheet.callCount === 2) + + runs(function () { + expect(styleElementRemovedHandler).toHaveBeenCalled() + expect(styleElementRemovedHandler.argsForCall[0][0].textContent).toContain('dashed') + expect(getComputedStyle(document.body).borderStyle).toBe('none') + }) + }) + }) + + describe('when there is an error reading the stylesheet', function () { + let addErrorHandler = null + beforeEach(function () { + atom.themes.loadUserStylesheet() + spyOn(atom.themes.lessCache, 'cssForFile').andCallFake(function () { + throw new Error('EACCES permission denied "styles.less"') + }) + atom.notifications.onDidAddNotification(addErrorHandler = jasmine.createSpy()) + }) + + it('creates an error notification and does not add the stylesheet', function () { + atom.themes.loadUserStylesheet() + expect(addErrorHandler).toHaveBeenCalled() + const note = addErrorHandler.mostRecentCall.args[0] + expect(note.getType()).toBe('error') + expect(note.getMessage()).toContain('Error loading') + expect(atom.styles.styleElementsBySourcePath[atom.styles.getUserStyleSheetPath()]).toBeUndefined() + }) + }) + + describe('when there is an error watching the user stylesheet', function () { + let addErrorHandler = null + beforeEach(function () { + const {File} = require('pathwatcher') + spyOn(File.prototype, 'on').andCallFake(function (event) { + if (event.indexOf('contents-changed') > -1) { + throw new Error('Unable to watch path') + } + }) + spyOn(atom.themes, 'loadStylesheet').andReturn('') + atom.notifications.onDidAddNotification(addErrorHandler = jasmine.createSpy()) + }) + + it('creates an error notification', function () { + atom.themes.loadUserStylesheet() + expect(addErrorHandler).toHaveBeenCalled() + const note = addErrorHandler.mostRecentCall.args[0] + expect(note.getType()).toBe('error') + expect(note.getMessage()).toContain('Unable to watch path') + }) + }) + + it("adds a notification when a theme's stylesheet is invalid", function () { + const addErrorHandler = jasmine.createSpy() + atom.notifications.onDidAddNotification(addErrorHandler) + expect(() => atom.packages.activatePackage('theme-with-invalid-styles').then(function () {}, function () {})).not.toThrow() + expect(addErrorHandler.callCount).toBe(2) + expect(addErrorHandler.argsForCall[1][0].message).toContain('Failed to activate the theme-with-invalid-styles theme') + }) + }) + + describe('when a non-existent theme is present in the config', function () { + beforeEach(function () { + console.warn.reset() + atom.config.set('core.themes', ['non-existent-dark-ui', 'non-existent-dark-syntax']) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it('uses the default dark UI and syntax themes and logs a warning', function () { + const activeThemeNames = atom.themes.getActiveThemeNames() + expect(console.warn.callCount).toBe(2) + expect(activeThemeNames.length).toBe(2) + expect(activeThemeNames).toContain('atom-dark-ui') + expect(activeThemeNames).toContain('atom-dark-syntax') + }) + }) + + describe('when in safe mode', function () { + describe('when the enabled UI and syntax themes are bundled with Atom', function () { + beforeEach(function () { + atom.config.set('core.themes', ['atom-light-ui', 'atom-dark-syntax']) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it('uses the enabled themes', function () { + const activeThemeNames = atom.themes.getActiveThemeNames() + expect(activeThemeNames.length).toBe(2) + expect(activeThemeNames).toContain('atom-light-ui') + expect(activeThemeNames).toContain('atom-dark-syntax') + }) + }) + + describe('when the enabled UI and syntax themes are not bundled with Atom', function () { + beforeEach(function () { + atom.config.set('core.themes', ['installed-dark-ui', 'installed-dark-syntax']) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it('uses the default dark UI and syntax themes', function () { + const activeThemeNames = atom.themes.getActiveThemeNames() + expect(activeThemeNames.length).toBe(2) + expect(activeThemeNames).toContain('atom-dark-ui') + expect(activeThemeNames).toContain('atom-dark-syntax') + }) + }) + + describe('when the enabled UI theme is not bundled with Atom', function () { + beforeEach(function () { + atom.config.set('core.themes', ['installed-dark-ui', 'atom-light-syntax']) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it('uses the default dark UI theme', function () { + const activeThemeNames = atom.themes.getActiveThemeNames() + expect(activeThemeNames.length).toBe(2) + expect(activeThemeNames).toContain('atom-dark-ui') + expect(activeThemeNames).toContain('atom-light-syntax') + }) + }) + + describe('when the enabled syntax theme is not bundled with Atom', function () { + beforeEach(function () { + atom.config.set('core.themes', ['atom-light-ui', 'installed-dark-syntax']) + + waitsForPromise(() => atom.themes.activateThemes()) + }) + + it('uses the default dark syntax theme', function () { + const activeThemeNames = atom.themes.getActiveThemeNames() + expect(activeThemeNames.length).toBe(2) + expect(activeThemeNames).toContain('atom-light-ui') + expect(activeThemeNames).toContain('atom-dark-syntax') + }) + }) + }) +}) + +function getAbsolutePath (directory, relativePath) { + if (directory) { + return directory.resolve(relativePath) + } +} diff --git a/spec/token-iterator-spec.coffee b/spec/token-iterator-spec.coffee deleted file mode 100644 index 6ae01cd30..000000000 --- a/spec/token-iterator-spec.coffee +++ /dev/null @@ -1,37 +0,0 @@ -TextBuffer = require 'text-buffer' -TokenizedBuffer = require '../src/tokenized-buffer' - -describe "TokenIterator", -> - it "correctly terminates scopes at the beginning of the line (regression)", -> - grammar = atom.grammars.createGrammar('test', { - 'scopeName': 'text.broken' - 'name': 'Broken grammar' - 'patterns': [ - { - 'begin': 'start' - 'end': '(?=end)' - 'name': 'blue.broken' - } - { - 'match': '.' - 'name': 'yellow.broken' - } - ] - }) - - buffer = new TextBuffer(text: """ - start x - end x - x - """) - tokenizedBuffer = new TokenizedBuffer({ - buffer, config: atom.config, grammarRegistry: atom.grammars, packageManager: atom.packages, assert: atom.assert - }) - tokenizedBuffer.setGrammar(grammar) - - tokenIterator = tokenizedBuffer.tokenizedLines[1].getTokenIterator() - tokenIterator.next() - - expect(tokenIterator.getBufferStart()).toBe 0 - expect(tokenIterator.getScopeEnds()).toEqual [] - expect(tokenIterator.getScopeStarts()).toEqual ['text.broken', 'yellow.broken'] diff --git a/spec/token-iterator-spec.js b/spec/token-iterator-spec.js new file mode 100644 index 000000000..f6d43395c --- /dev/null +++ b/spec/token-iterator-spec.js @@ -0,0 +1,43 @@ +const TextBuffer = require('text-buffer') +const TokenizedBuffer = require('../src/tokenized-buffer') + +describe('TokenIterator', () => + it('correctly terminates scopes at the beginning of the line (regression)', () => { + const grammar = atom.grammars.createGrammar('test', { + 'scopeName': 'text.broken', + 'name': 'Broken grammar', + 'patterns': [ + { + 'begin': 'start', + 'end': '(?=end)', + 'name': 'blue.broken' + }, + { + 'match': '.', + 'name': 'yellow.broken' + } + ] + }) + + const buffer = new TextBuffer({text: `\ +start x +end x +x\ +`}) + const tokenizedBuffer = new TokenizedBuffer({ + buffer, + config: atom.config, + grammarRegistry: atom.grammars, + packageManager: atom.packages, + assert: atom.assert + }) + tokenizedBuffer.setGrammar(grammar) + + const tokenIterator = tokenizedBuffer.tokenizedLines[1].getTokenIterator() + tokenIterator.next() + + expect(tokenIterator.getBufferStart()).toBe(0) + expect(tokenIterator.getScopeEnds()).toEqual([]) + expect(tokenIterator.getScopeStarts()).toEqual(['text.broken', 'yellow.broken']) + }) +) diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 714d8f1fc..c13fd4304 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -32,6 +32,7 @@ ThemeManager = require './theme-manager' MenuManager = require './menu-manager' ContextMenuManager = require './context-menu-manager' CommandInstaller = require './command-installer' +CoreURIHandlers = require './core-uri-handlers' ProtocolHandlerInstaller = require './protocol-handler-installer' Project = require './project' TitleBar = require './title-bar' @@ -240,6 +241,7 @@ class AtomEnvironment extends Model @commandInstaller.initialize(@getVersion()) @protocolHandlerInstaller.initialize(@config, @notifications) + @uriHandlerRegistry.registerHostHandler('core', CoreURIHandlers.create(this)) @autoUpdater.initialize() @config.load() diff --git a/src/command-registry.js b/src/command-registry.js index ba75918ab..9e6d8c2e1 100644 --- a/src/command-registry.js +++ b/src/command-registry.js @@ -107,6 +107,13 @@ module.exports = class CommandRegistry { // otherwise be generated from the event name. // * `description`: Used by consumers to display detailed information about // the command. + // * `hiddenInCommandPalette`: If `true`, this command will not appear in + // the bundled command palette by default, but can still be shown with. + // the `Command Palette: Show Hidden Commands` command. This is a good + // option when you need to register large numbers of commands that don't + // make sense to be executed from the command palette. Please use this + // option conservatively, as it could reduce the discoverability of your + // package's commands. // // ## Arguments: Registering Multiple Commands // diff --git a/src/core-uri-handlers.js b/src/core-uri-handlers.js new file mode 100644 index 000000000..2af00f610 --- /dev/null +++ b/src/core-uri-handlers.js @@ -0,0 +1,38 @@ +function openFile (atom, {query}) { + const {filename, line, column} = query + + atom.workspace.open(filename, { + initialLine: parseInt(line || 0, 10), + initialColumn: parseInt(column || 0, 10), + searchAllPanes: true + }) +} + +function windowShouldOpenFile ({query}) { + const {filename} = query + return (win) => win.containsPath(filename) +} + +const ROUTER = { + '/open/file': { handler: openFile, getWindowPredicate: windowShouldOpenFile } +} + +module.exports = { + create (atomEnv) { + return function coreURIHandler (parsed) { + const config = ROUTER[parsed.pathname] + if (config) { + config.handler(atomEnv, parsed) + } + } + }, + + windowPredicate (parsed) { + const config = ROUTER[parsed.pathname] + if (config && config.getWindowPredicate) { + return config.getWindowPredicate(parsed) + } else { + return (win) => true + } + } +} diff --git a/src/cursor.js b/src/cursor.js index 6cd0cc623..10bdef804 100644 --- a/src/cursor.js +++ b/src/cursor.js @@ -594,7 +594,7 @@ class Cursor extends Model { getCurrentWordBufferRange (options = {}) { const position = this.getBufferPosition() const ranges = this.editor.buffer.findAllInRangeSync( - options.wordRegex || this.wordRegExp(), + options.wordRegex || this.wordRegExp(options), new Range(new Point(position.row, 0), new Point(position.row, Infinity)) ) const range = ranges.find(range => diff --git a/src/git-repository.js b/src/git-repository.js index 057c5fcb7..55d70c12c 100644 --- a/src/git-repository.js +++ b/src/git-repository.js @@ -1,15 +1,7 @@ -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS104: Avoid inline assignments - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const {join} = require('path') +const path = require('path') +const fs = require('fs-plus') const _ = require('underscore-plus') const {Emitter, Disposable, CompositeDisposable} = require('event-kit') -const fs = require('fs-plus') -const path = require('path') const GitUtils = require('git-utils') let nextId = 0 @@ -241,15 +233,15 @@ class GitRepository { // * `path` The {String} path to check. // // Returns a {Boolean}. - isSubmodule (path) { - if (!path) return false + isSubmodule (filePath) { + if (!filePath) return false - const repo = this.getRepo(path) - if (repo.isSubmodule(repo.relativize(path))) { + const repo = this.getRepo(filePath) + if (repo.isSubmodule(repo.relativize(filePath))) { return true } else { - // Check if the path is a working directory in a repo that isn't the root. - return repo !== this.getRepo() && repo.relativize(join(path, 'dir')) === 'dir' + // Check if the filePath is a working directory in a repo that isn't the root. + return repo !== this.getRepo() && repo.relativize(path.join(filePath, 'dir')) === 'dir' } } diff --git a/src/main-process/application-menu.coffee b/src/main-process/application-menu.coffee index 681677603..35bc7d66c 100644 --- a/src/main-process/application-menu.coffee +++ b/src/main-process/application-menu.coffee @@ -128,7 +128,7 @@ class ApplicationMenu ] focusedWindow: -> - _.find global.atomApplication.windows, (atomWindow) -> atomWindow.isFocused() + _.find global.atomApplication.getAllWindows(), (atomWindow) -> atomWindow.isFocused() # Combines a menu template with the appropriate keystroke. # diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index 0c587020e..f6802705e 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -67,7 +67,7 @@ class AtomApplication {@resourcePath, @devResourcePath, @version, @devMode, @safeMode, @socketPath, @logFile, @userDataDir} = options @socketPath = null if options.test or options.benchmark or options.benchmarkTest @pidsToOpenWindows = {} - @windows = [] + @windowStack = new WindowStack() @config = new Config({enablePersistence: true}) @config.setSchema null, {type: 'object', properties: _.clone(ConfigSchema)} @@ -114,7 +114,7 @@ class AtomApplication @launch(options) destroy: -> - windowsClosePromises = @windows.map (window) -> + windowsClosePromises = @getAllWindows().map (window) -> window.close() window.closedPromise Promise.all(windowsClosePromises).then(=> @disposable.dispose()) @@ -162,8 +162,8 @@ class AtomApplication # Public: Removes the {AtomWindow} from the global window list. removeWindow: (window) -> - @windows.splice(@windows.indexOf(window), 1) - if @windows.length is 0 + @windowStack.removeWindow(window) + if @getAllWindows().length is 0 @applicationMenu?.enableWindowSpecificItems(false) if process.platform in ['win32', 'linux'] app.quit() @@ -172,22 +172,28 @@ class AtomApplication # Public: Adds the {AtomWindow} to the global window list. addWindow: (window) -> - @windows.push window + @windowStack.addWindow(window) @applicationMenu?.addWindow(window.browserWindow) window.once 'window:loaded', => @autoUpdateManager?.emitUpdateAvailableEvent(window) unless window.isSpec - focusHandler = => @lastFocusedWindow = window + focusHandler = => @windowStack.touch(window) blurHandler = => @saveState(false) window.browserWindow.on 'focus', focusHandler window.browserWindow.on 'blur', blurHandler window.browserWindow.once 'closed', => - @lastFocusedWindow = null if window is @lastFocusedWindow + @windowStack.removeWindow(window) window.browserWindow.removeListener 'focus', focusHandler window.browserWindow.removeListener 'blur', blurHandler window.browserWindow.webContents.once 'did-finish-load', => @saveState(false) + getAllWindows: => + @windowStack.all().slice() + + getLastFocusedWindow: (predicate) => + @windowStack.getLastFocusedWindow(predicate) + # Creates server to listen for additional atom application launches. # # You can run the atom command multiple times, but after the first launch @@ -276,7 +282,7 @@ class AtomApplication else event.preventDefault() @quitting = true - windowUnloadPromises = @windows.map((window) -> window.prepareToUnload()) + windowUnloadPromises = @getAllWindows().map((window) -> window.prepareToUnload()) Promise.all(windowUnloadPromises).then((windowUnloadedResults) -> didUnloadAllWindows = windowUnloadedResults.every((didUnloadWindow) -> didUnloadWindow) app.quit() if didUnloadAllWindows @@ -309,7 +315,7 @@ class AtomApplication event.sender.send('did-resolve-proxy', requestId, proxy) @disposable.add ipcHelpers.on ipcMain, 'did-change-history-manager', (event) => - for atomWindow in @windows + for atomWindow in @getAllWindows() webContents = atomWindow.browserWindow.webContents if webContents isnt event.sender webContents.send('did-change-history-manager') @@ -483,7 +489,7 @@ class AtomApplication # Returns the {AtomWindow} for the given paths. windowForPaths: (pathsToOpen, devMode) -> - _.find @windows, (atomWindow) -> + _.find @getAllWindows(), (atomWindow) -> atomWindow.devMode is devMode and atomWindow.containsPaths(pathsToOpen) # Returns the {AtomWindow} for the given ipcMain event. @@ -491,11 +497,11 @@ class AtomApplication @atomWindowForBrowserWindow(BrowserWindow.fromWebContents(sender)) atomWindowForBrowserWindow: (browserWindow) -> - @windows.find((atomWindow) -> atomWindow.browserWindow is browserWindow) + @getAllWindows().find((atomWindow) -> atomWindow.browserWindow is browserWindow) # Public: Returns the currently focused {AtomWindow} or undefined if none. focusedWindow: -> - _.find @windows, (atomWindow) -> atomWindow.isFocused() + _.find @getAllWindows(), (atomWindow) -> atomWindow.isFocused() # Get the platform-specific window offset for new windows. getWindowOffsetForCurrentPlatform: -> @@ -507,8 +513,8 @@ class AtomApplication # Get the dimensions for opening a new window by cascading as appropriate to # the platform. getDimensionsForNewWindow: -> - return if (@focusedWindow() ? @lastFocusedWindow)?.isMaximized() - dimensions = (@focusedWindow() ? @lastFocusedWindow)?.getDimensions() + return if (@focusedWindow() ? @getLastFocusedWindow())?.isMaximized() + dimensions = (@focusedWindow() ? @getLastFocusedWindow())?.getDimensions() offset = @getWindowOffsetForCurrentPlatform() if dimensions? and offset? dimensions.x += offset @@ -554,7 +560,7 @@ class AtomApplication existingWindow = @windowForPaths(pathsToOpen, devMode) stats = (fs.statSyncNoException(pathToOpen) for pathToOpen in pathsToOpen) unless existingWindow? - if currentWindow = window ? @lastFocusedWindow + if currentWindow = window ? @getLastFocusedWindow() existingWindow = currentWindow if ( addToLastWindow or currentWindow.devMode is devMode and @@ -583,7 +589,7 @@ class AtomApplication windowDimensions ?= @getDimensionsForNewWindow() openedWindow = new AtomWindow(this, @fileRecoveryService, {initialPaths, locationsToOpen, windowInitializationScript, resourcePath, devMode, safeMode, windowDimensions, profileStartup, clearWindowState, env}) openedWindow.focus() - @lastFocusedWindow = openedWindow + @windowStack.addWindow(openedWindow) if pidToKillWhenClosed? @pidsToOpenWindows[pidToKillWhenClosed] = openedWindow @@ -617,9 +623,10 @@ class AtomApplication saveState: (allowEmpty=false) -> return if @quitting states = [] - for window in @windows + for window in @getAllWindows() unless window.isSpec states.push({initialPaths: window.representedDirectoryPaths}) + states.reverse() if states.length > 0 or allowEmpty @storageFolder.storeSync('application.json', states) @emit('application:did-save-state') @@ -648,30 +655,39 @@ class AtomApplication # :devMode - Boolean to control the opened window's dev mode. # :safeMode - Boolean to control the opened window's safe mode. openUrl: ({urlToOpen, devMode, safeMode, env}) -> - parsedUrl = url.parse(urlToOpen) + parsedUrl = url.parse(urlToOpen, true) return unless parsedUrl.protocol is "atom:" pack = @findPackageWithName(parsedUrl.host, devMode) if pack?.urlMain @openPackageUrlMain(parsedUrl.host, pack.urlMain, urlToOpen, devMode, safeMode, env) else - @openPackageUriHandler(urlToOpen, devMode, safeMode, env) + @openPackageUriHandler(urlToOpen, parsedUrl, devMode, safeMode, env) - openPackageUriHandler: (url, devMode, safeMode, env) -> - resourcePath = @resourcePath - if devMode - try - windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) - resourcePath = @devResourcePath + openPackageUriHandler: (url, parsedUrl, devMode, safeMode, env) -> + bestWindow = null + if parsedUrl.host is 'core' + predicate = require('../core-uri-handlers').windowPredicate(parsedUrl) + bestWindow = @getLastFocusedWindow (win) -> + not win.isSpecWindow() and predicate(win) - windowInitializationScript ?= require.resolve('../initialize-application-window') - if @lastFocusedWindow? - @lastFocusedWindow.sendURIMessage url + bestWindow ?= @getLastFocusedWindow (win) -> not win.isSpecWindow() + if bestWindow? + bestWindow.sendURIMessage url + bestWindow.focus() else + resourcePath = @resourcePath + if devMode + try + windowInitializationScript = require.resolve(path.join(@devResourcePath, 'src', 'initialize-application-window')) + resourcePath = @devResourcePath + + windowInitializationScript ?= require.resolve('../initialize-application-window') windowDimensions = @getDimensionsForNewWindow() - @lastFocusedWindow = new AtomWindow(this, @fileRecoveryService, {resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env}) - @lastFocusedWindow.on 'window:loaded', => - @lastFocusedWindow.sendURIMessage url + win = new AtomWindow(this, @fileRecoveryService, {resourcePath, windowInitializationScript, devMode, safeMode, windowDimensions, env}) + @windowStack.addWindow(win) + win.on 'window:loaded', -> + win.sendURIMessage url findPackageWithName: (packageName, devMode) -> _.find @getPackageManager(devMode).getAvailablePackageMetadata(), ({name}) -> name is packageName @@ -867,7 +883,7 @@ class AtomApplication disableZoomOnDisplayChange: -> outerCallback = => - for window in @windows + for window in @getAllWindows() window.disableZoom() # Set the limits every time a display is added or removed, otherwise the @@ -878,3 +894,24 @@ class AtomApplication new Disposable -> screen.removeListener('display-added', outerCallback) screen.removeListener('display-removed', outerCallback) + +class WindowStack + constructor: (@windows = []) -> + + addWindow: (window) => + @removeWindow(window) + @windows.unshift(window) + + touch: (window) => + @addWindow(window) + + removeWindow: (window) => + currentIndex = @windows.indexOf(window) + @windows.splice(currentIndex, 1) if currentIndex > -1 + + getLastFocusedWindow: (predicate) => + predicate ?= (win) -> true + @windows.find(predicate) + + all: => + @windows diff --git a/src/main-process/auto-update-manager.coffee b/src/main-process/auto-update-manager.coffee index 2ff2852cb..0e4144c1a 100644 --- a/src/main-process/auto-update-manager.coffee +++ b/src/main-process/auto-update-manager.coffee @@ -138,4 +138,4 @@ class AutoUpdateManager detail: message getWindows: -> - global.atomApplication.windows + global.atomApplication.getAllWindows() diff --git a/src/project.js b/src/project.js index e5afd9eee..dec5c4db5 100644 --- a/src/project.js +++ b/src/project.js @@ -341,13 +341,21 @@ class Project extends Model { } this.rootDirectories.push(directory) - this.watcherPromisesByPath[directory.getPath()] = watchPath(directory.getPath(), {}, events => { + + const didChangeCallback = events => { // Stop event delivery immediately on removal of a rootDirectory, even if its watcher // promise has yet to resolve at the time of removal if (this.rootDirectories.includes(directory)) { 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) + this.watcherPromisesByPath[directory.getPath()] = + directory.onDidChangeFiles != null + ? Promise.resolve(directory.onDidChangeFiles(didChangeCallback)) + : watchPath(directory.getPath(), {}, didChangeCallback) for (let watchedPath in this.watcherPromisesByPath) { if (!this.rootDirectories.find(dir => dir.getPath() === watchedPath)) { diff --git a/src/text-editor-component.js b/src/text-editor-component.js index 2a77e30f8..91ea18361 100644 --- a/src/text-editor-component.js +++ b/src/text-editor-component.js @@ -126,7 +126,6 @@ class TextEditorComponent { this.blockDecorationResizeObserver = new ResizeObserver(this.didResizeBlockDecorations.bind(this)) this.lineComponentsByScreenLineId = new Map() this.overlayComponents = new Set() - this.overlayDimensionsByElement = new WeakMap() this.shouldRenderDummyScrollbars = true this.remeasureScrollbars = false this.pendingAutoscroll = null @@ -803,15 +802,9 @@ class TextEditorComponent { { key: overlayProps.element, overlayComponents: this.overlayComponents, - measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element), didResize: (overlayComponent) => { this.updateOverlayToRender(overlayProps) - overlayComponent.update(Object.assign( - { - measuredDimensions: this.overlayDimensionsByElement.get(overlayProps.element) - }, - overlayProps - )) + overlayComponent.update(overlayProps) } }, overlayProps @@ -1357,7 +1350,6 @@ class TextEditorComponent { let wrapperTop = contentClientRect.top + this.pixelPositionAfterBlocksForRow(row) + this.getLineHeight() let wrapperLeft = contentClientRect.left + this.pixelLeftForRowAndColumn(row, column) const clientRect = element.getBoundingClientRect() - this.overlayDimensionsByElement.set(element, clientRect) if (avoidOverflow !== false) { const computedStyle = window.getComputedStyle(element) @@ -4226,17 +4218,26 @@ class OverlayComponent { this.element.style.zIndex = 4 this.element.style.top = (this.props.pixelTop || 0) + 'px' this.element.style.left = (this.props.pixelLeft || 0) + 'px' + this.currentContentRect = null // Synchronous DOM updates in response to resize events might trigger a // "loop limit exceeded" error. We disconnect the observer before // potentially mutating the DOM, and then reconnect it on the next tick. + // Note: ResizeObserver calls its callback when .observe is called this.resizeObserver = new ResizeObserver((entries) => { const {contentRect} = entries[0] - if (contentRect.width !== this.props.measuredDimensions.width || contentRect.height !== this.props.measuredDimensions.height) { + + if ( + this.currentContentRect && + (this.currentContentRect.width !== contentRect.width || + this.currentContentRect.height !== contentRect.height) + ) { this.resizeObserver.disconnect() this.props.didResize(this) process.nextTick(() => { this.resizeObserver.observe(this.props.element) }) } + + this.currentContentRect = contentRect }) this.didAttach() this.props.overlayComponents.add(this) diff --git a/src/token-iterator.coffee b/src/token-iterator.coffee deleted file mode 100644 index f836d33d4..000000000 --- a/src/token-iterator.coffee +++ /dev/null @@ -1,56 +0,0 @@ -module.exports = -class TokenIterator - constructor: (@tokenizedBuffer) -> - - reset: (@line) -> - @index = null - @startColumn = 0 - @endColumn = 0 - @scopes = @line.openScopes.map (id) => @tokenizedBuffer.grammar.scopeForId(id) - @scopeStarts = @scopes.slice() - @scopeEnds = [] - this - - next: -> - {tags} = @line - - if @index? - @startColumn = @endColumn - @scopeEnds.length = 0 - @scopeStarts.length = 0 - @index++ - else - @index = 0 - - while @index < tags.length - tag = tags[@index] - if tag < 0 - scope = @tokenizedBuffer.grammar.scopeForId(tag) - if tag % 2 is 0 - if @scopeStarts[@scopeStarts.length - 1] is scope - @scopeStarts.pop() - else - @scopeEnds.push(scope) - @scopes.pop() - else - @scopeStarts.push(scope) - @scopes.push(scope) - @index++ - else - @endColumn += tag - @text = @line.text.substring(@startColumn, @endColumn) - return true - - false - - getScopes: -> @scopes - - getScopeStarts: -> @scopeStarts - - getScopeEnds: -> @scopeEnds - - getText: -> @text - - getBufferStart: -> @startColumn - - getBufferEnd: -> @endColumn diff --git a/src/token-iterator.js b/src/token-iterator.js new file mode 100644 index 000000000..a698fc748 --- /dev/null +++ b/src/token-iterator.js @@ -0,0 +1,79 @@ +module.exports = +class TokenIterator { + constructor (tokenizedBuffer) { + this.tokenizedBuffer = tokenizedBuffer + } + + reset (line) { + this.line = line + this.index = null + this.startColumn = 0 + this.endColumn = 0 + this.scopes = this.line.openScopes.map(id => this.tokenizedBuffer.grammar.scopeForId(id)) + this.scopeStarts = this.scopes.slice() + this.scopeEnds = [] + return this + } + + next () { + const {tags} = this.line + + if (this.index != null) { + this.startColumn = this.endColumn + this.scopeEnds.length = 0 + this.scopeStarts.length = 0 + this.index++ + } else { + this.index = 0 + } + + while (this.index < tags.length) { + const tag = tags[this.index] + if (tag < 0) { + const scope = this.tokenizedBuffer.grammar.scopeForId(tag) + if ((tag % 2) === 0) { + if (this.scopeStarts[this.scopeStarts.length - 1] === scope) { + this.scopeStarts.pop() + } else { + this.scopeEnds.push(scope) + } + this.scopes.pop() + } else { + this.scopeStarts.push(scope) + this.scopes.push(scope) + } + this.index++ + } else { + this.endColumn += tag + this.text = this.line.text.substring(this.startColumn, this.endColumn) + return true + } + } + + return false + } + + getScopes () { + return this.scopes + } + + getScopeStarts () { + return this.scopeStarts + } + + getScopeEnds () { + return this.scopeEnds + } + + getText () { + return this.text + } + + getBufferStart () { + return this.startColumn + } + + getBufferEnd () { + return this.endColumn + } +}