diff --git a/apm/package.json b/apm/package.json index ede2d1bd7..07931700d 100644 --- a/apm/package.json +++ b/apm/package.json @@ -6,6 +6,6 @@ "url": "https://github.com/atom/atom.git" }, "dependencies": { - "atom-package-manager": "1.15.3" + "atom-package-manager": "1.16.0" } } diff --git a/docs/README.md b/docs/README.md index e9b6ff120..c555306b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ In this directory you can only find very specific build and API level documentat Instructions for building Atom on various platforms from source. -* [macOS](./build-instructions/macos.md) +* [macOS](./build-instructions/macOS.md) * [Windows](./build-instructions/windows.md) * [Linux](./build-instructions/linux.md) * [FreeBSD](./build-instructions/freebsd.md) diff --git a/package.json b/package.json index 13812c36d..ab7962f6e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "electronVersion": "1.3.13", "dependencies": { "async": "0.2.6", - "atom-keymap": "7.1.21", + "atom-keymap": "7.1.22", "atom-select-list": "0.0.15", "atom-ui": "0.4.1", "babel-core": "6.22.1", @@ -40,8 +40,8 @@ "devtron": "1.3.0", "event-kit": "^2.1.0", "find-parent-dir": "^0.3.0", - "first-mate": "6.1.0", - "fs-plus": "2.9.2", + "first-mate": "6.3.0", + "fs-plus": "2.10.1", "fstream": "0.1.24", "fuzzaldrin": "^2.1", "git-utils": "4.1.2", @@ -60,7 +60,7 @@ "normalize-package-data": "^2.0.0", "nslog": "^3", "oniguruma": "6.1.0", - "pathwatcher": "6.8.0", + "pathwatcher": "6.9.0", "postcss": "5.2.4", "postcss-selector-parser": "2.2.1", "property-accessors": "^1.1.3", @@ -76,7 +76,7 @@ "sinon": "1.17.4", "source-map-support": "^0.3.2", "temp": "0.8.1", - "text-buffer": "10.3.12", + "text-buffer": "10.4.2", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -89,16 +89,16 @@ "atom-light-ui": "0.46.0", "base16-tomorrow-dark-theme": "1.5.0", "base16-tomorrow-light-theme": "1.5.0", - "one-dark-ui": "1.9.1", - "one-light-ui": "1.9.1", + "one-dark-ui": "1.9.2", + "one-light-ui": "1.9.2", "one-dark-syntax": "1.7.1", "one-light-syntax": "1.7.1", "solarized-dark-syntax": "1.1.2", "solarized-light-syntax": "1.1.2", - "about": "1.7.4", - "archive-view": "0.62.2", + "about": "1.7.5", + "archive-view": "0.63.0", "autocomplete-atom-api": "0.10.0", - "autocomplete-css": "0.15.0", + "autocomplete-css": "0.15.1", "autocomplete-html": "0.7.2", "autocomplete-plus": "2.34.2", "autocomplete-snippets": "1.11.0", @@ -112,10 +112,10 @@ "deprecation-cop": "0.56.2", "dev-live-reload": "0.47.0", "encoding-selector": "0.23.2", - "exception-reporting": "0.41.1", - "find-and-replace": "0.206.3", - "fuzzy-finder": "1.4.1", - "git-diff": "1.3.2", + "exception-reporting": "0.41.2", + "find-and-replace": "0.207.0", + "fuzzy-finder": "1.5.0", + "git-diff": "1.3.3", "go-to-line": "0.32.0", "grammar-selector": "0.49.3", "image-view": "0.61.1", @@ -128,51 +128,51 @@ "notifications": "0.66.2", "open-on-github": "1.2.1", "package-generator": "1.1.0", - "settings-view": "0.247.2", - "snippets": "1.0.5", + "settings-view": "0.248.0", + "snippets": "1.1.1", "spell-check": "0.71.1", - "status-bar": "1.8.1", + "status-bar": "1.8.3", "styleguide": "0.49.3", - "symbols-view": "0.114.0", - "tabs": "0.104.1", + "symbols-view": "0.115.2", + "tabs": "0.104.2", "timecop": "0.36.0", - "tree-view": "0.214.1", - "update-package-dependencies": "0.10.0", + "tree-view": "0.215.1", + "update-package-dependencies": "0.11.0", "welcome": "0.36.2", "whitespace": "0.36.2", - "wrap-guide": "0.39.1", - "language-c": "0.56.0", + "wrap-guide": "0.40.0", + "language-c": "0.57.0", "language-clojure": "0.22.2", - "language-coffee-script": "0.48.4", + "language-coffee-script": "0.48.5", "language-csharp": "0.14.2", "language-css": "0.42.0", - "language-gfm": "0.88.0", + "language-gfm": "0.88.1", "language-git": "0.19.0", "language-go": "0.43.1", "language-html": "0.47.2", "language-hyperlink": "0.16.1", - "language-java": "0.26.0", + "language-java": "0.27.0", "language-javascript": "0.126.1", - "language-json": "0.18.3", - "language-less": "0.30.1", + "language-json": "0.19.0", + "language-less": "0.31.0", "language-make": "0.22.3", "language-mustache": "0.13.1", "language-objective-c": "0.15.1", "language-perl": "0.37.0", - "language-php": "0.37.4", - "language-property-list": "0.9.0", + "language-php": "0.37.5", + "language-property-list": "0.9.1", "language-python": "0.45.2", "language-ruby": "0.70.5", "language-ruby-on-rails": "0.25.2", - "language-sass": "0.57.1", + "language-sass": "0.58.0", "language-shellscript": "0.25.0", "language-source": "0.9.0", "language-sql": "0.25.3", - "language-text": "0.7.1", + "language-text": "0.7.2", "language-todo": "0.29.1", "language-toml": "0.18.1", - "language-xml": "0.34.16", - "language-yaml": "0.28.0" + "language-xml": "0.35.0", + "language-yaml": "0.29.0" }, "private": true, "scripts": { diff --git a/resources/linux/redhat/atom.spec.in b/resources/linux/redhat/atom.spec.in index 82a5fbf9a..306f1029e 100644 --- a/resources/linux/redhat/atom.spec.in +++ b/resources/linux/redhat/atom.spec.in @@ -7,7 +7,11 @@ URL: https://atom.io/ AutoReqProv: no # Avoid libchromiumcontent.so missing dependency Prefix: <%= installDir %> +%ifarch i386 i486 i586 i686 +Requires: lsb-core-noarch, libXss.so.1 +%else Requires: lsb-core-noarch, libXss.so.1()(64bit) +%endif %description <%= description %> diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index d967fb97b..d1eabf2c8 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -126,6 +126,7 @@ describe "AtomEnvironment", -> beforeEach -> errors = [] + spyOn(atom, 'isReleasedVersion').andReturn(true) atom.onDidFailAssertion (error) -> errors.push(error) describe "if the condition is false", -> @@ -147,6 +148,11 @@ describe "AtomEnvironment", -> atom.assert(false, "a == b", {foo: 'bar'}) expect(errors[0].metadata).toEqual {foo: 'bar'} + describe "when Atom has been built from source", -> + it "throws an error", -> + atom.isReleasedVersion.andReturn(false) + expect(-> atom.assert(false, 'testing')).toThrow('Assertion failed: testing') + describe "if the condition is true", -> it "does nothing", -> result = atom.assert(true, "a == b") diff --git a/spec/dom-element-pool-spec.js b/spec/dom-element-pool-spec.js index 9de932e27..91120ee48 100644 --- a/spec/dom-element-pool-spec.js +++ b/spec/dom-element-pool-spec.js @@ -3,7 +3,10 @@ const DOMElementPool = require ('../src/dom-element-pool') describe('DOMElementPool', function () { let domElementPool - beforeEach(() => { domElementPool = new DOMElementPool() }) + beforeEach(() => { + domElementPool = new DOMElementPool() + spyOn(atom, 'isReleasedVersion').andReturn(true) + }) it('builds DOM nodes, recycling them when they are freed', function () { let elements diff --git a/spec/history-manager-spec.js b/spec/history-manager-spec.js index 425f1efe0..bc77cb9b8 100644 --- a/spec/history-manager-spec.js +++ b/spec/history-manager-spec.js @@ -4,27 +4,24 @@ import {it, fit, ffit, fffit, beforeEach, afterEach} from './async-spec-helpers' import {Emitter, Disposable, CompositeDisposable} from 'event-kit' import {HistoryManager, HistoryProject} from '../src/history-manager' +import StateStore from '../src/state-store' describe("HistoryManager", () => { - let historyManager, commandRegistry, project, localStorage, stateStore + let historyManager, commandRegistry, project, stateStore let commandDisposable, projectDisposable - beforeEach(() => { + beforeEach(async () => { commandDisposable = jasmine.createSpyObj('Disposable', ['dispose']) commandRegistry = jasmine.createSpyObj('CommandRegistry', ['add']) commandRegistry.add.andReturn(commandDisposable) - localStorage = jasmine.createSpyObj('LocalStorage', ['getItem', 'setItem']) - localStorage.items = { - history: JSON.stringify({ - projects: [ - { paths: ['/1', 'c:\\2'], lastOpened: new Date(2016, 9, 17, 17, 16, 23) }, - { paths: ['/test'], lastOpened: new Date(2016, 9, 17, 11, 12, 13) } - ] - }) - } - localStorage.getItem.andCallFake((key) => localStorage.items[key]) - localStorage.setItem.andCallFake((key, value) => localStorage.items[key] = value) + stateStore = new StateStore('history-manager-test', 1) + await stateStore.save('history-manager', { + projects: [ + {paths: ['/1', 'c:\\2'], lastOpened: new Date(2016, 9, 17, 17, 16, 23)}, + {paths: ['/test'], lastOpened: new Date(2016, 9, 17, 11, 12, 13)} + ] + }) projectDisposable = jasmine.createSpyObj('Disposable', ['dispose']) project = jasmine.createSpyObj('Project', ['onDidChangePaths']) @@ -33,7 +30,12 @@ describe("HistoryManager", () => { return projectDisposable }) - historyManager = new HistoryManager({project, commands:commandRegistry, localStorage}) + historyManager = new HistoryManager({stateStore, project, commands: commandRegistry}) + await historyManager.loadState() + }) + + afterEach(async () => { + await stateStore.clear() }) describe("constructor", () => { @@ -65,33 +67,28 @@ describe("HistoryManager", () => { }) describe("clearProjects", () => { - it("clears the list of projects", () => { + it("clears the list of projects", async () => { expect(historyManager.getProjects().length).not.toBe(0) - historyManager.clearProjects() + await historyManager.clearProjects() expect(historyManager.getProjects().length).toBe(0) }) - it("saves the state", () => { - expect(localStorage.setItem).not.toHaveBeenCalled() - historyManager.clearProjects() - expect(localStorage.setItem).toHaveBeenCalled() - expect(localStorage.setItem.calls[0].args[0]).toBe('history') + it("saves the state", async () => { + await historyManager.clearProjects() + const historyManager2 = new HistoryManager({stateStore, project, commands: commandRegistry}) + await historyManager2.loadState() expect(historyManager.getProjects().length).toBe(0) }) - it("fires the onDidChangeProjects event", () => { - expect(localStorage.setItem).not.toHaveBeenCalled() - historyManager.clearProjects() - expect(localStorage.setItem).toHaveBeenCalled() - expect(localStorage.setItem.calls[0].args[0]).toBe('history') + it("fires the onDidChangeProjects event", async () => { + const didChangeSpy = jasmine.createSpy() + historyManager.onDidChangeProjects(didChangeSpy) + await historyManager.clearProjects() expect(historyManager.getProjects().length).toBe(0) + expect(didChangeSpy).toHaveBeenCalled() }) }) - it("loads state", () => { - expect(localStorage.getItem).toHaveBeenCalledWith('history') - }) - it("listens to project.onDidChangePaths adding a new project", () => { const start = new Date() project.didChangePathsListener(['/a/new', '/path/or/two']) @@ -112,61 +109,61 @@ describe("HistoryManager", () => { }) describe("loadState", () => { - it("defaults to an empty array if no state", () => { - localStorage.items.history = null - historyManager.loadState() + it("defaults to an empty array if no state", async () => { + await stateStore.clear() + await historyManager.loadState() expect(historyManager.getProjects()).toEqual([]) }) - it("defaults to an empty array if no projects", () => { - localStorage.items.history = JSON.stringify('') - historyManager.loadState() + it("defaults to an empty array if no projects", async () => { + await stateStore.save('history-manager', {}) + await historyManager.loadState() expect(historyManager.getProjects()).toEqual([]) }) }) describe("addProject", () => { - it("adds a new project to the end", () => { + it("adds a new project to the end", async () => { const date = new Date(2010, 10, 9, 8, 7, 6) - historyManager.addProject(['/a/b'], date) + await historyManager.addProject(['/a/b'], date) const projects = historyManager.getProjects() expect(projects.length).toBe(3) expect(projects[2].paths).toEqual(['/a/b']) expect(projects[2].lastOpened).toBe(date) }) - it("adds a new project to the start", () => { + it("adds a new project to the start", async () => { const date = new Date() - historyManager.addProject(['/so/new'], date) + await historyManager.addProject(['/so/new'], date) const projects = historyManager.getProjects() expect(projects.length).toBe(3) expect(projects[0].paths).toEqual(['/so/new']) expect(projects[0].lastOpened).toBe(date) }) - it("updates an existing project and moves it to the start", () => { + it("updates an existing project and moves it to the start", async () => { const date = new Date() - historyManager.addProject(['/test'], date) + await historyManager.addProject(['/test'], date) const projects = historyManager.getProjects() expect(projects.length).toBe(2) expect(projects[0].paths).toEqual(['/test']) expect(projects[0].lastOpened).toBe(date) }) - it("fires the onDidChangeProjects event when adding a project", () => { + it("fires the onDidChangeProjects event when adding a project", async () => { const didChangeSpy = jasmine.createSpy() const beforeCount = historyManager.getProjects().length historyManager.onDidChangeProjects(didChangeSpy) - historyManager.addProject(['/test-new'], new Date()) + await historyManager.addProject(['/test-new'], new Date()) expect(didChangeSpy).toHaveBeenCalled() expect(historyManager.getProjects().length).toBe(beforeCount + 1) }) - it("fires the onDidChangeProjects event when updating a project", () => { + it("fires the onDidChangeProjects event when updating a project", async () => { const didChangeSpy = jasmine.createSpy() const beforeCount = historyManager.getProjects().length historyManager.onDidChangeProjects(didChangeSpy) - historyManager.addProject(['/test'], new Date()) + await historyManager.addProject(['/test'], new Date()) expect(didChangeSpy).toHaveBeenCalled() expect(historyManager.getProjects().length).toBe(beforeCount) }) @@ -186,14 +183,12 @@ describe("HistoryManager", () => { }) describe("saveState" ,() => { - it("saves the state", () => { - historyManager.addProject(["/save/state"]) - historyManager.saveState() - expect(localStorage.setItem).toHaveBeenCalled() - expect(localStorage.setItem.calls[0].args[0]).toBe('history') - expect(localStorage.items['history']).toContain('/save/state') - historyManager.loadState() - expect(historyManager.getProjects()[0].paths).toEqual(['/save/state']) + it("saves the state", async () => { + await historyManager.addProject(["/save/state"]) + await historyManager.saveState() + const historyManager2 = new HistoryManager({stateStore, project, commands: commandRegistry}) + await historyManager2.loadState() + expect(historyManager2.getProjects()[0].paths).toEqual(['/save/state']) }) }) }) diff --git a/spec/panel-container-spec.coffee b/spec/panel-container-spec.coffee index 08eaea92b..fbf1c3446 100644 --- a/spec/panel-container-spec.coffee +++ b/spec/panel-container-spec.coffee @@ -5,7 +5,7 @@ describe "PanelContainer", -> [container] = [] class TestPanelItem - constructior: -> + constructor: -> beforeEach -> container = new PanelContainer @@ -39,6 +39,23 @@ describe "PanelContainer", -> panel1.destroy() expect(removePanelSpy).toHaveBeenCalledWith({panel: panel1, index: 0}) + describe "::destroy()", -> + it "destroys the container and all of its panels", -> + destroyedPanels = [] + + panel1 = new Panel(item: new TestPanelItem()) + panel1.onDidDestroy -> destroyedPanels.push(panel1) + container.addPanel(panel1) + + panel2 = new Panel(item: new TestPanelItem()) + panel2.onDidDestroy -> destroyedPanels.push(panel2) + container.addPanel(panel2) + + container.destroy() + + expect(container.getPanels().length).toBe(0) + expect(destroyedPanels).toEqual([panel1, panel2]) + describe "panel priority", -> describe 'left / top panel container', -> [initialPanel] = [] diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 911270d16..81c69f63f 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -28,7 +28,13 @@ describe "TextEditor", -> editor.foldBufferRow(4) expect(editor.isFoldedAtBufferRow(4)).toBeTruthy() - editor2 = TextEditor.deserialize(editor.serialize(), atom) + editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: { + bufferForIdSync: (id) -> TextBuffer.deserialize(editor.buffer.serialize()) + } + }) expect(editor2.id).toBe editor.id expect(editor2.getBuffer().getPath()).toBe editor.getBuffer().getPath() diff --git a/spec/workspace-element-spec.coffee b/spec/workspace-element-spec.coffee index a741dbbd4..ec24242ac 100644 --- a/spec/workspace-element-spec.coffee +++ b/spec/workspace-element-spec.coffee @@ -54,12 +54,10 @@ describe "WorkspaceElement", -> it "updates the font-family based on the 'editor.fontFamily' config value", -> initialCharWidth = editor.getDefaultCharWidth() fontFamily = atom.config.get('editor.fontFamily') - fontFamily += ', "Apple Color Emoji"' if process.platform is 'darwin' expect(getComputedStyle(editorElement).fontFamily).toBe fontFamily atom.config.set('editor.fontFamily', 'sans-serif') fontFamily = atom.config.get('editor.fontFamily') - fontFamily += ', "Apple Color Emoji"' if process.platform is 'darwin' expect(getComputedStyle(editorElement).fontFamily).toBe fontFamily expect(editor.getDefaultCharWidth()).not.toBe initialCharWidth diff --git a/spec/workspace-spec.coffee b/spec/workspace-spec.coffee deleted file mode 100644 index 153cc5dc3..000000000 --- a/spec/workspace-spec.coffee +++ /dev/null @@ -1,1778 +0,0 @@ -path = require 'path' -temp = require('temp').track() -TextEditor = require '../src/text-editor' -Workspace = require '../src/workspace' -Project = require '../src/project' -platform = require './spec-helper-platform' -_ = require 'underscore-plus' -fstream = require 'fstream' -fs = require 'fs-plus' - -describe "Workspace", -> - [workspace, setDocumentEdited] = [] - - beforeEach -> - workspace = atom.workspace - workspace.resetFontSize() - spyOn(atom.applicationDelegate, "confirm") - setDocumentEdited = spyOn(atom.applicationDelegate, 'setWindowDocumentEdited') - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('dir')]) - waits(1) - - afterEach -> - temp.cleanupSync() - - describe "serialization", -> - simulateReload = -> - workspaceState = atom.workspace.serialize() - projectState = atom.project.serialize({isUnloading: true}) - atom.workspace.destroy() - atom.project.destroy() - atom.project = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm.bind(atom), applicationDelegate: atom.applicationDelegate}) - atom.project.deserialize(projectState) - atom.workspace = new Workspace({ - config: atom.config, project: atom.project, packageManager: atom.packages, - grammarRegistry: atom.grammars, deserializerManager: atom.deserializers, - notificationManager: atom.notifications, - applicationDelegate: atom.applicationDelegate, - viewRegistry: atom.views, assert: atom.assert.bind(atom), - textEditorRegistry: atom.textEditors - }) - atom.workspace.deserialize(workspaceState, atom.deserializers) - - describe "when the workspace contains text editors", -> - it "constructs the view with the same panes", -> - pane1 = atom.workspace.getActivePane() - pane2 = pane1.splitRight(copyActiveItem: true) - pane3 = pane2.splitRight(copyActiveItem: true) - pane4 = null - - waitsForPromise -> - atom.workspace.open(null).then (editor) -> editor.setText("An untitled editor.") - - waitsForPromise -> - atom.workspace.open('b').then (editor) -> - pane2.activateItem(editor.copy()) - - waitsForPromise -> - atom.workspace.open('../sample.js').then (editor) -> - pane3.activateItem(editor) - - runs -> - pane3.activeItem.setCursorScreenPosition([2, 4]) - pane4 = pane2.splitDown() - - waitsForPromise -> - atom.workspace.open('../sample.txt').then (editor) -> - pane4.activateItem(editor) - - runs -> - pane4.getActiveItem().setCursorScreenPosition([0, 2]) - pane2.activate() - - simulateReload() - - expect(atom.workspace.getTextEditors().length).toBe 5 - [editor1, editor2, untitledEditor, editor3, editor4] = atom.workspace.getTextEditors() - expect(editor1.getPath()).toBe atom.project.getDirectories()[0]?.resolve('b') - expect(editor2.getPath()).toBe atom.project.getDirectories()[0]?.resolve('../sample.txt') - expect(editor2.getCursorScreenPosition()).toEqual [0, 2] - expect(editor3.getPath()).toBe atom.project.getDirectories()[0]?.resolve('b') - expect(editor4.getPath()).toBe atom.project.getDirectories()[0]?.resolve('../sample.js') - expect(editor4.getCursorScreenPosition()).toEqual [2, 4] - expect(untitledEditor.getPath()).toBeUndefined() - expect(untitledEditor.getText()).toBe("An untitled editor.") - - expect(atom.workspace.getActiveTextEditor().getPath()).toBe editor3.getPath() - pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) - expect(document.title).toMatch ///^#{path.basename(editor3.getLongTitle())}\ \u2014\ #{pathEscaped}/// - - describe "where there are no open panes or editors", -> - it "constructs the view with no open editors", -> - atom.workspace.getActivePane().destroy() - expect(atom.workspace.getTextEditors().length).toBe 0 - simulateReload() - expect(atom.workspace.getTextEditors().length).toBe 0 - - describe "::open(uri, options)", -> - openEvents = null - - beforeEach -> - openEvents = [] - workspace.onDidOpen (event) -> openEvents.push(event) - spyOn(workspace.getActivePane(), 'activate').andCallThrough() - - describe "when the 'searchAllPanes' option is false (default)", -> - describe "when called without a uri", -> - it "adds and activates an empty editor on the active pane", -> - [editor1, editor2] = [] - - waitsForPromise -> - workspace.open().then (editor) -> editor1 = editor - - runs -> - expect(editor1.getPath()).toBeUndefined() - expect(workspace.getActivePane().items).toEqual [editor1] - expect(workspace.getActivePaneItem()).toBe editor1 - expect(workspace.getActivePane().activate).toHaveBeenCalled() - expect(openEvents).toEqual [{uri: undefined, pane: workspace.getActivePane(), item: editor1, index: 0}] - openEvents = [] - - waitsForPromise -> - workspace.open().then (editor) -> editor2 = editor - - runs -> - expect(editor2.getPath()).toBeUndefined() - expect(workspace.getActivePane().items).toEqual [editor1, editor2] - expect(workspace.getActivePaneItem()).toBe editor2 - expect(workspace.getActivePane().activate).toHaveBeenCalled() - expect(openEvents).toEqual [{uri: undefined, pane: workspace.getActivePane(), item: editor2, index: 1}] - - describe "when called with a uri", -> - describe "when the active pane already has an editor for the given uri", -> - it "activates the existing editor on the active pane", -> - editor = null - editor1 = null - editor2 = null - - waitsForPromise -> - workspace.open('a').then (o) -> - editor1 = o - workspace.open('b').then (o) -> - editor2 = o - workspace.open('a').then (o) -> - editor = o - - runs -> - expect(editor).toBe editor1 - expect(workspace.getActivePaneItem()).toBe editor - expect(workspace.getActivePane().activate).toHaveBeenCalled() - - expect(openEvents).toEqual [ - { - uri: atom.project.getDirectories()[0]?.resolve('a') - item: editor1 - pane: atom.workspace.getActivePane() - index: 0 - } - { - uri: atom.project.getDirectories()[0]?.resolve('b') - item: editor2 - pane: atom.workspace.getActivePane() - index: 1 - } - { - uri: atom.project.getDirectories()[0]?.resolve('a') - item: editor1 - pane: atom.workspace.getActivePane() - index: 0 - } - ] - - describe "when the active pane does not have an editor for the given uri", -> - it "adds and activates a new editor for the given path on the active pane", -> - editor = null - waitsForPromise -> - workspace.open('a').then (o) -> editor = o - - runs -> - expect(editor.getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(workspace.getActivePaneItem()).toBe editor - expect(workspace.getActivePane().items).toEqual [editor] - expect(workspace.getActivePane().activate).toHaveBeenCalled() - - describe "when the 'searchAllPanes' option is true", -> - describe "when an editor for the given uri is already open on an inactive pane", -> - it "activates the existing editor on the inactive pane, then activates that pane", -> - editor1 = null - editor2 = null - pane1 = workspace.getActivePane() - pane2 = workspace.getActivePane().splitRight() - - waitsForPromise -> - pane1.activate() - workspace.open('a').then (o) -> editor1 = o - - waitsForPromise -> - pane2.activate() - workspace.open('b').then (o) -> editor2 = o - - runs -> - expect(workspace.getActivePaneItem()).toBe editor2 - - waitsForPromise -> - workspace.open('a', searchAllPanes: true) - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(workspace.getActivePaneItem()).toBe editor1 - - describe "when no editor for the given uri is open in any pane", -> - it "opens an editor for the given uri in the active pane", -> - editor = null - waitsForPromise -> - workspace.open('a', searchAllPanes: true).then (o) -> editor = o - - runs -> - expect(workspace.getActivePaneItem()).toBe editor - - describe "when the 'split' option is set", -> - describe "when the 'split' option is 'left'", -> - it "opens the editor in the leftmost pane of the current pane axis", -> - pane1 = workspace.getActivePane() - pane2 = pane1.splitRight() - expect(workspace.getActivePane()).toBe pane2 - - editor = null - waitsForPromise -> - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - # Focus right pane and reopen the file on the left - waitsForPromise -> - pane2.focus() - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - describe "when a pane axis is the leftmost sibling of the current pane", -> - it "opens the new item in the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitLeft() - pane3 = pane2.splitDown() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - - waitsForPromise -> - workspace.open('a', split: 'left').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - - describe "when the 'split' option is 'right'", -> - it "opens the editor in the rightmost pane of the current pane axis", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = null - waitsForPromise -> - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - pane2 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - # Focus right pane and reopen the file on the right - waitsForPromise -> - pane1.focus() - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - describe "when a pane axis is the rightmost sibling of the current pane", -> - it "opens the new item in a new pane split to the right of the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitRight() - pane3 = pane2.splitDown() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - pane4 = null - - waitsForPromise -> - workspace.open('a', split: 'right').then (o) -> editor = o - - runs -> - pane4 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane4 - expect(pane4.items).toEqual [editor] - expect(workspace.paneContainer.root.children[0]).toBe pane1 - expect(workspace.paneContainer.root.children[1]).toBe pane4 - - describe "when the 'split' option is 'up'", -> - it "opens the editor in the topmost pane of the current pane axis", -> - pane1 = workspace.getActivePane() - pane2 = pane1.splitDown() - expect(workspace.getActivePane()).toBe pane2 - - editor = null - waitsForPromise -> - workspace.open('a', split: 'up').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - # Focus bottom pane and reopen the file on the top - waitsForPromise -> - pane2.focus() - workspace.open('a', split: 'up').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - expect(pane2.items).toEqual [] - - describe "when a pane axis is the topmost sibling of the current pane", -> - it "opens the new item in the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitUp() - pane3 = pane2.splitRight() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - - waitsForPromise -> - workspace.open('a', split: 'up').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane1 - expect(pane1.items).toEqual [editor] - - describe "when the 'split' option is 'down'", -> - it "opens the editor in the bottommost pane of the current pane axis", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = null - waitsForPromise -> - workspace.open('a', split: 'down').then (o) -> editor = o - - runs -> - pane2 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - # Focus bottom pane and reopen the file on the right - waitsForPromise -> - pane1.focus() - workspace.open('a', split: 'down').then (o) -> editor = o - - runs -> - expect(workspace.getActivePane()).toBe pane2 - expect(pane1.items).toEqual [] - expect(pane2.items).toEqual [editor] - - describe "when a pane axis is the bottommost sibling of the current pane", -> - it "opens the new item in a new pane split to the bottom of the current pane", -> - editor = null - pane1 = workspace.getActivePane() - pane2 = pane1.splitDown() - pane1.activate() - expect(workspace.getActivePane()).toBe pane1 - pane4 = null - - waitsForPromise -> - workspace.open('a', split: 'down').then (o) -> editor = o - - runs -> - pane4 = workspace.getPanes().filter((p) -> p isnt pane1)[0] - expect(workspace.getActivePane()).toBe pane4 - expect(pane4.items).toEqual [editor] - expect(workspace.paneContainer.root.children[0]).toBe pane1 - expect(workspace.paneContainer.root.children[1]).toBe pane2 - - describe "when an initialLine and initialColumn are specified", -> - it "moves the cursor to the indicated location", -> - waitsForPromise -> - workspace.open('a', initialLine: 1, initialColumn: 5) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [1, 5] - - waitsForPromise -> - workspace.open('a', initialLine: 2, initialColumn: 4) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [2, 4] - - waitsForPromise -> - workspace.open('a', initialLine: 0, initialColumn: 0) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [0, 0] - - waitsForPromise -> - workspace.open('a', initialLine: NaN, initialColumn: 4) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [0, 4] - - waitsForPromise -> - workspace.open('a', initialLine: 2, initialColumn: NaN) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [2, 0] - - waitsForPromise -> - workspace.open('a', initialLine: Infinity, initialColumn: Infinity) - - runs -> - expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual [2, 11] - - describe "when the file is over 2MB", -> - it "opens the editor with largeFileMode: true", -> - spyOn(fs, 'getSizeSync').andReturn 2 * 1048577 # 2MB - - editor = null - waitsForPromise -> - workspace.open('sample.js').then (e) -> editor = e - - runs -> - expect(editor.largeFileMode).toBe true - - describe "when the file is over user-defined limit", -> - shouldPromptForFileOfSize = (size, shouldPrompt) -> - spyOn(fs, 'getSizeSync').andReturn size * 1048577 - atom.applicationDelegate.confirm.andCallFake -> selectedButtonIndex - atom.applicationDelegate.confirm() - selectedButtonIndex = 1 # cancel - - editor = null - waitsForPromise -> - workspace.open('sample.js').then (e) -> editor = e - if shouldPrompt - runs -> - expect(editor).toBeUndefined() - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - - atom.applicationDelegate.confirm.reset() - selectedButtonIndex = 0 # open the file - - waitsForPromise -> - workspace.open('sample.js').then (e) -> editor = e - - runs -> - expect(atom.applicationDelegate.confirm).toHaveBeenCalled() - expect(editor.largeFileMode).toBe true - else - runs -> - expect(editor).not.toBeUndefined() - - it "prompts the user to make sure they want to open a file this big", -> - atom.config.set "core.warnOnLargeFileLimit", 20 - shouldPromptForFileOfSize 20, true - - it "doesn't prompt on files below the limit", -> - atom.config.set "core.warnOnLargeFileLimit", 30 - shouldPromptForFileOfSize 20, false - - it "prompts for smaller files with a lower limit", -> - atom.config.set "core.warnOnLargeFileLimit", 5 - shouldPromptForFileOfSize 10, true - - describe "when passed a path that matches a custom opener", -> - it "returns the resource returned by the custom opener", -> - fooOpener = (pathToOpen, options) -> {foo: pathToOpen, options} if pathToOpen?.match(/\.foo/) - barOpener = (pathToOpen) -> {bar: pathToOpen} if pathToOpen?.match(/^bar:\/\//) - workspace.addOpener(fooOpener) - workspace.addOpener(barOpener) - - waitsForPromise -> - pathToOpen = atom.project.getDirectories()[0]?.resolve('a.foo') - workspace.open(pathToOpen, hey: "there").then (item) -> - expect(item).toEqual {foo: pathToOpen, options: {hey: "there"}} - - waitsForPromise -> - workspace.open("bar://baz").then (item) -> - expect(item).toEqual {bar: "bar://baz"} - - it "adds the file to the application's recent documents list", -> - return unless process.platform is 'darwin' # Feature only supported on macOS - spyOn(atom.applicationDelegate, 'addRecentDocument') - - waitsForPromise -> - workspace.open() - - runs -> - expect(atom.applicationDelegate.addRecentDocument).not.toHaveBeenCalled() - - waitsForPromise -> - workspace.open('something://a/url') - - runs -> - expect(atom.applicationDelegate.addRecentDocument).not.toHaveBeenCalled() - - waitsForPromise -> - workspace.open(__filename) - - runs -> - expect(atom.applicationDelegate.addRecentDocument).toHaveBeenCalledWith(__filename) - - it "notifies ::onDidAddTextEditor observers", -> - absolutePath = require.resolve('./fixtures/dir/a') - newEditorHandler = jasmine.createSpy('newEditorHandler') - workspace.onDidAddTextEditor newEditorHandler - - editor = null - waitsForPromise -> - workspace.open(absolutePath).then (e) -> editor = e - - runs -> - expect(newEditorHandler.argsForCall[0][0].textEditor).toBe editor - - describe "when there is an error opening the file", -> - notificationSpy = null - beforeEach -> - atom.notifications.onDidAddNotification notificationSpy = jasmine.createSpy() - - describe "when a file does not exist", -> - it "creates an empty buffer for the specified path", -> - waitsForPromise -> - workspace.open('not-a-file.md') - - runs -> - editor = workspace.getActiveTextEditor() - expect(notificationSpy).not.toHaveBeenCalled() - expect(editor.getPath()).toContain 'not-a-file.md' - - describe "when the user does not have access to the file", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EACCES, permission denied '#{path}'") - error.path = path - error.code = 'EACCES' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Permission denied' - expect(notification.getMessage()).toContain 'file1' - - describe "when the the operation is not permitted", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EPERM, operation not permitted '#{path}'") - error.path = path - error.code = 'EPERM' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to open' - expect(notification.getMessage()).toContain 'file1' - - describe "when the the file is already open in windows", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - error = new Error("EBUSY, resource busy or locked '#{path}'") - error.path = path - error.code = 'EBUSY' - throw error - - it "creates a notification", -> - waitsForPromise -> - workspace.open('file1') - - runs -> - expect(notificationSpy).toHaveBeenCalled() - notification = notificationSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to open' - expect(notification.getMessage()).toContain 'file1' - - describe "when there is an unhandled error", -> - beforeEach -> - spyOn(fs, 'openSync').andCallFake (path) -> - throw new Error("I dont even know what is happening right now!!") - - it "creates a notification", -> - open = -> workspace.open('file1', workspace.getActivePane()) - expect(open).toThrow() - - describe "when the file is already open in pending state", -> - it "should terminate the pending state", -> - editor = null - pane = null - - waitsForPromise -> - atom.workspace.open('sample.js', pending: true).then (o) -> - editor = o - pane = atom.workspace.getActivePane() - - runs -> - expect(pane.getPendingItem()).toEqual editor - - waitsForPromise -> - atom.workspace.open('sample.js') - - runs -> - expect(pane.getPendingItem()).toBeNull() - - describe "when opening will switch from a pending tab to a permanent tab", -> - it "keeps the pending tab open", -> - editor1 = null - editor2 = null - - waitsForPromise -> - atom.workspace.open('sample.txt').then (o) -> - editor1 = o - - waitsForPromise -> - atom.workspace.open('sample2.txt', pending: true).then (o) -> - editor2 = o - - runs -> - pane = atom.workspace.getActivePane() - pane.activateItem(editor1) - expect(pane.getItems().length).toBe 2 - expect(pane.getItems()).toEqual [editor1, editor2] - - describe "when replacing a pending item which is the last item in a second pane", -> - it "does not destroy the pane even if core.destroyEmptyPanes is on", -> - atom.config.set('core.destroyEmptyPanes', true) - editor1 = null - editor2 = null - leftPane = atom.workspace.getActivePane() - rightPane = null - - waitsForPromise -> - atom.workspace.open('sample.js', pending: true, split: 'right').then (o) -> - editor1 = o - rightPane = atom.workspace.getActivePane() - spyOn rightPane, "destroyed" - - runs -> - expect(leftPane).not.toBe rightPane - expect(atom.workspace.getActivePane()).toBe rightPane - expect(atom.workspace.getActivePane().getItems().length).toBe 1 - expect(rightPane.getPendingItem()).toBe editor1 - - waitsForPromise -> - atom.workspace.open('sample.txt', pending: true).then (o) -> - editor2 = o - - runs -> - expect(rightPane.getPendingItem()).toBe editor2 - expect(rightPane.destroyed.callCount).toBe 0 - - describe 'the grammar-used hook', -> - it 'fires when opening a file or changing the grammar of an open file', -> - editor = null - javascriptGrammarUsed = false - coffeescriptGrammarUsed = false - - atom.packages.triggerDeferredActivationHooks() - - runs -> - atom.packages.onDidTriggerActivationHook 'language-javascript:grammar-used', -> javascriptGrammarUsed = true - atom.packages.onDidTriggerActivationHook 'language-coffee-script:grammar-used', -> coffeescriptGrammarUsed = true - - waitsForPromise -> - atom.workspace.open('sample.js', autoIndent: false).then (o) -> editor = o - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsFor -> javascriptGrammarUsed - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - runs -> - editor.setGrammar(atom.grammars.selectGrammar('.coffee')) - - waitsFor -> coffeescriptGrammarUsed - - describe "::reopenItem()", -> - it "opens the uri associated with the last closed pane that isn't currently open", -> - pane = workspace.getActivePane() - waitsForPromise -> - workspace.open('a').then -> - workspace.open('b').then -> - workspace.open('file1').then -> - workspace.open() - - runs -> - # does not reopen items with no uri - expect(workspace.getActivePaneItem().getURI()).toBeUndefined() - pane.destroyActiveItem() - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).not.toBeUndefined() - - # destroy all items - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('file1') - pane.destroyActiveItem() - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('b') - pane.destroyActiveItem() - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - pane.destroyActiveItem() - - # reopens items with uris - expect(workspace.getActivePaneItem()).toBeUndefined() - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('a') - - # does not reopen items that are already open - waitsForPromise -> - workspace.open('b') - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('b') - - waitsForPromise -> - workspace.reopenItem() - - runs -> - expect(workspace.getActivePaneItem().getURI()).toBe atom.project.getDirectories()[0]?.resolve('file1') - - describe "::increase/decreaseFontSize()", -> - it "increases/decreases the font size without going below 1", -> - atom.config.set('editor.fontSize', 1) - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 2 - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 3 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 2 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 1 - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe 1 - - describe "::resetFontSize()", -> - it "resets the font size to the window's starting font size", -> - originalFontSize = atom.config.get('editor.fontSize') - - workspace.increaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize + 1 - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - workspace.decreaseFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - 1 - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - it "does nothing if the font size has not been changed", -> - originalFontSize = atom.config.get('editor.fontSize') - - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - it "resets the font size when the editor's font size changes", -> - originalFontSize = atom.config.get('editor.fontSize') - - atom.config.set('editor.fontSize', originalFontSize + 1) - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - atom.config.set('editor.fontSize', originalFontSize - 1) - workspace.resetFontSize() - expect(atom.config.get('editor.fontSize')).toBe originalFontSize - - describe "::openLicense()", -> - it "opens the license as plain-text in a buffer", -> - waitsForPromise -> workspace.openLicense() - runs -> expect(workspace.getActivePaneItem().getText()).toMatch /Copyright/ - - describe "::isTextEditor(obj)", -> - it "returns true when the passed object is an instance of `TextEditor`", -> - expect(workspace.isTextEditor(new TextEditor)).toBe(true) - expect(workspace.isTextEditor({getText: -> null})).toBe(false) - expect(workspace.isTextEditor(null)).toBe(false) - expect(workspace.isTextEditor(undefined)).toBe(false) - - describe "::observeTextEditors()", -> - it "invokes the observer with current and future text editors", -> - observed = [] - - waitsForPromise -> workspace.open() - waitsForPromise -> workspace.open() - waitsForPromise -> workspace.openLicense() - - runs -> - workspace.observeTextEditors (editor) -> observed.push(editor) - - waitsForPromise -> workspace.open() - - expect(observed).toEqual workspace.getTextEditors() - - describe "when an editor is destroyed", -> - it "removes the editor", -> - editor = null - - waitsForPromise -> - workspace.open("a").then (e) -> editor = e - - runs -> - expect(workspace.getTextEditors()).toHaveLength 1 - editor.destroy() - expect(workspace.getTextEditors()).toHaveLength 0 - - describe "when an editor is copied because its pane is split", -> - it "sets up the new editor to be configured by the text editor registry", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - workspace.open('a').then (editor) -> - atom.textEditors.setGrammarOverride(editor, 'source.js') - expect(editor.getGrammar().name).toBe('JavaScript') - - workspace.getActivePane().splitRight(copyActiveItem: true) - newEditor = workspace.getActiveTextEditor() - expect(newEditor).not.toBe(editor) - expect(newEditor.getGrammar().name).toBe('JavaScript') - - it "stores the active grammars used by all the open editors", -> - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - atom.packages.activatePackage('language-coffee-script') - - waitsForPromise -> - atom.packages.activatePackage('language-todo') - - waitsForPromise -> - atom.workspace.open('sample.coffee') - - runs -> - atom.workspace.getActiveTextEditor().setText """ - i = /test/; #FIXME - """ - - state = atom.workspace.serialize() - expect(state.packagesWithActiveGrammars).toEqual ['language-coffee-script', 'language-javascript', 'language-todo'] - - jsPackage = atom.packages.getLoadedPackage('language-javascript') - coffeePackage = atom.packages.getLoadedPackage('language-coffee-script') - spyOn(jsPackage, 'loadGrammarsSync') - spyOn(coffeePackage, 'loadGrammarsSync') - - workspace2 = new Workspace({ - config: atom.config, project: atom.project, packageManager: atom.packages, - notificationManager: atom.notifications, deserializerManager: atom.deserializers, - viewRegistry: atom.views, grammarRegistry: atom.grammars, - applicationDelegate: atom.applicationDelegate, assert: atom.assert.bind(atom), - textEditorRegistry: atom.textEditors - }) - workspace2.deserialize(state, atom.deserializers) - expect(jsPackage.loadGrammarsSync.callCount).toBe 1 - expect(coffeePackage.loadGrammarsSync.callCount).toBe 1 - - describe "document.title", -> - describe "when there is no item open", -> - it "sets the title to the project path", -> - expect(document.title).toMatch escapeStringRegex(fs.tildify(atom.project.getPaths()[0])) - - it "sets the title to 'untitled' if there is no project path", -> - atom.project.setPaths([]) - expect(document.title).toMatch /^untitled/ - - describe "when the active pane item's path is not inside a project path", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('b').then -> - atom.project.setPaths([]) - - it "sets the title to the pane item's title plus the item's path", -> - item = atom.workspace.getActivePaneItem() - pathEscaped = fs.tildify(escapeStringRegex(path.dirname(item.getPath()))) - expect(document.title).toMatch ///^#{item.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when the title of the active pane item changes", -> - it "updates the window title based on the item's new title", -> - editor = atom.workspace.getActivePaneItem() - editor.buffer.setPath(path.join(temp.dir, 'hi')) - pathEscaped = fs.tildify(escapeStringRegex(path.dirname(editor.getPath()))) - expect(document.title).toMatch ///^#{editor.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when the active pane's item changes", -> - it "updates the title to the new item's title plus the project path", -> - atom.workspace.getActivePane().activateNextItem() - item = atom.workspace.getActivePaneItem() - pathEscaped = fs.tildify(escapeStringRegex(path.dirname(item.getPath()))) - expect(document.title).toMatch ///^#{item.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when an inactive pane's item changes", -> - it "does not update the title", -> - pane = atom.workspace.getActivePane() - pane.splitRight() - initialTitle = document.title - pane.activateNextItem() - expect(document.title).toBe initialTitle - - describe "when the active pane item is inside a project path", -> - beforeEach -> - waitsForPromise -> - atom.workspace.open('b') - - describe "when there is an active pane item", -> - it "sets the title to the pane item's title plus the project path", -> - item = atom.workspace.getActivePaneItem() - pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) - expect(document.title).toMatch ///^#{item.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when the title of the active pane item changes", -> - it "updates the window title based on the item's new title", -> - editor = atom.workspace.getActivePaneItem() - editor.buffer.setPath(path.join(atom.project.getPaths()[0], 'hi')) - pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) - expect(document.title).toMatch ///^#{editor.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when the active pane's item changes", -> - it "updates the title to the new item's title plus the project path", -> - atom.workspace.getActivePane().activateNextItem() - item = atom.workspace.getActivePaneItem() - pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) - expect(document.title).toMatch ///^#{item.getTitle()}\ \u2014\ #{pathEscaped}/// - - describe "when the last pane item is removed", -> - it "updates the title to the project's first path", -> - atom.workspace.getActivePane().destroy() - expect(atom.workspace.getActivePaneItem()).toBeUndefined() - expect(document.title).toMatch escapeStringRegex(fs.tildify(atom.project.getPaths()[0])) - - describe "when an inactive pane's item changes", -> - it "does not update the title", -> - pane = atom.workspace.getActivePane() - pane.splitRight() - initialTitle = document.title - pane.activateNextItem() - expect(document.title).toBe initialTitle - - describe "when the workspace is deserialized", -> - beforeEach -> - waitsForPromise -> atom.workspace.open('a') - - it "updates the title to contain the project's path", -> - document.title = null - workspace2 = new Workspace({ - config: atom.config, project: atom.project, packageManager: atom.packages, - notificationManager: atom.notifications, deserializerManager: atom.deserializers, - viewRegistry: atom.views, grammarRegistry: atom.grammars, - applicationDelegate: atom.applicationDelegate, assert: atom.assert.bind(atom), - textEditorRegistry: atom.textEditors - }) - workspace2.deserialize(atom.workspace.serialize(), atom.deserializers) - item = workspace2.getActivePaneItem() - pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) - expect(document.title).toMatch ///^#{item.getLongTitle()}\ \u2014\ #{pathEscaped}/// - workspace2.destroy() - - describe "document edited status", -> - [item1, item2] = [] - - beforeEach -> - waitsForPromise -> atom.workspace.open('a') - waitsForPromise -> atom.workspace.open('b') - runs -> - [item1, item2] = atom.workspace.getPaneItems() - - it "calls setDocumentEdited when the active item changes", -> - expect(atom.workspace.getActivePaneItem()).toBe item2 - item1.insertText('a') - expect(item1.isModified()).toBe true - atom.workspace.getActivePane().activateNextItem() - - expect(setDocumentEdited).toHaveBeenCalledWith(true) - - it "calls atom.setDocumentEdited when the active item's modified status changes", -> - expect(atom.workspace.getActivePaneItem()).toBe item2 - item2.insertText('a') - advanceClock(item2.getBuffer().getStoppedChangingDelay()) - - expect(item2.isModified()).toBe true - expect(setDocumentEdited).toHaveBeenCalledWith(true) - - item2.undo() - advanceClock(item2.getBuffer().getStoppedChangingDelay()) - - expect(item2.isModified()).toBe false - expect(setDocumentEdited).toHaveBeenCalledWith(false) - - describe "adding panels", -> - class TestItem - - class TestItemElement extends HTMLElement - constructor: -> - initialize: (@model) -> this - getModel: -> @model - - beforeEach -> - atom.views.addViewProvider TestItem, (model) -> - new TestItemElement().initialize(model) - - describe '::addLeftPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getLeftPanels().length).toBe(0) - atom.workspace.panelContainers.left.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addLeftPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getLeftPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addRightPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getRightPanels().length).toBe(0) - atom.workspace.panelContainers.right.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addRightPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getRightPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addTopPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getTopPanels().length).toBe(0) - atom.workspace.panelContainers.top.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addTopPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getTopPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addBottomPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getBottomPanels().length).toBe(0) - atom.workspace.panelContainers.bottom.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addBottomPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getBottomPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addHeaderPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getHeaderPanels().length).toBe(0) - atom.workspace.panelContainers.header.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addHeaderPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getHeaderPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addFooterPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getFooterPanels().length).toBe(0) - atom.workspace.panelContainers.footer.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addFooterPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getFooterPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe '::addModalPanel(model)', -> - it 'adds a panel to the correct panel container', -> - expect(atom.workspace.getModalPanels().length).toBe(0) - atom.workspace.panelContainers.modal.onDidAddPanel addPanelSpy = jasmine.createSpy() - - model = new TestItem - panel = atom.workspace.addModalPanel(item: model) - - expect(panel).toBeDefined() - expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) - - itemView = atom.views.getView(atom.workspace.getModalPanels()[0].getItem()) - expect(itemView instanceof TestItemElement).toBe(true) - expect(itemView.getModel()).toBe(model) - - describe "::panelForItem(item)", -> - it "returns the panel associated with the item", -> - item = new TestItem - panel = atom.workspace.addLeftPanel(item: item) - - itemWithNoPanel = new TestItem - - expect(atom.workspace.panelForItem(item)).toBe panel - expect(atom.workspace.panelForItem(itemWithNoPanel)).toBe null - - describe "::scan(regex, options, callback)", -> - describe "when called with a regex", -> - it "calls the callback with all regex results in all files in the project", -> - results = [] - waitsForPromise -> - atom.workspace.scan /(a)+/, (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength(3) - expect(results[0].filePath).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(results[0].matches).toHaveLength(3) - expect(results[0].matches[0]).toEqual - matchText: 'aaa' - lineText: 'aaa bbb' - lineTextOffset: 0 - range: [[0, 0], [0, 3]] - - it "works with with escaped literals (like $ and ^)", -> - results = [] - waitsForPromise -> - atom.workspace.scan /\$\w+/, (result) -> results.push(result) - - runs -> - expect(results.length).toBe 1 - - {filePath, matches} = results[0] - expect(filePath).toBe atom.project.getDirectories()[0]?.resolve('a') - expect(matches).toHaveLength 1 - expect(matches[0]).toEqual - matchText: '$bill' - lineText: 'dollar$bill' - lineTextOffset: 0 - range: [[2, 6], [2, 11]] - - it "works on evil filenames", -> - atom.config.set('core.excludeVcsIgnoredPaths', false) - platform.generateEvilFiles() - atom.project.setPaths([path.join(__dirname, 'fixtures', 'evil-files')]) - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /evil/, (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - _.each(matches, (m) -> expect(m.matchText).toEqual 'evil') - - if platform.isWindows() - expect(paths.length).toBe 3 - expect(paths[0]).toMatch /a_file_with_utf8.txt$/ - expect(paths[1]).toMatch /file with spaces.txt$/ - expect(path.basename(paths[2])).toBe "utfa\u0306.md" - else - expect(paths.length).toBe 5 - expect(paths[0]).toMatch /a_file_with_utf8.txt$/ - expect(paths[1]).toMatch /file with spaces.txt$/ - expect(paths[2]).toMatch /goddam\nnewlines$/m - expect(paths[3]).toMatch /quote".txt$/m - expect(path.basename(paths[4])).toBe "utfa\u0306.md" - - it "ignores case if the regex includes the `i` flag", -> - results = [] - waitsForPromise -> - atom.workspace.scan /DOLLAR/i, (result) -> results.push(result) - - runs -> - expect(results).toHaveLength 1 - - describe "when the core.excludeVcsIgnoredPaths config is truthy", -> - [projectPath, ignoredPath] = [] - - beforeEach -> - sourceProjectPath = path.join(__dirname, 'fixtures', 'git', 'working-dir') - projectPath = path.join(temp.mkdirSync("atom")) - - writerStream = fstream.Writer(projectPath) - fstream.Reader(sourceProjectPath).pipe(writerStream) - - waitsFor (done) -> - writerStream.on 'close', done - writerStream.on 'error', done - - runs -> - fs.rename(path.join(projectPath, 'git.git'), path.join(projectPath, '.git')) - ignoredPath = path.join(projectPath, 'ignored.txt') - fs.writeFileSync(ignoredPath, 'this match should not be included') - - afterEach -> - fs.removeSync(projectPath) if fs.existsSync(projectPath) - - it "excludes ignored files", -> - atom.project.setPaths([projectPath]) - atom.config.set('core.excludeVcsIgnoredPaths', true) - resultHandler = jasmine.createSpy("result found") - waitsForPromise -> - atom.workspace.scan /match/, (results) -> - resultHandler() - - runs -> - expect(resultHandler).not.toHaveBeenCalled() - - it "includes only files when a directory filter is specified", -> - projectPath = path.join(path.join(__dirname, 'fixtures', 'dir')) - atom.project.setPaths([projectPath]) - - filePath = path.join(projectPath, 'a-dir', 'oh-git') - - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /aaa/, paths: ["a-dir#{path.sep}"], (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - expect(paths.length).toBe 1 - expect(paths[0]).toBe filePath - expect(matches.length).toBe 1 - - it "includes files and folders that begin with a '.'", -> - projectPath = temp.mkdirSync('atom-spec-workspace') - filePath = path.join(projectPath, '.text') - fs.writeFileSync(filePath, 'match this') - atom.project.setPaths([projectPath]) - paths = [] - matches = [] - waitsForPromise -> - atom.workspace.scan /match this/, (result) -> - paths.push(result.filePath) - matches = matches.concat(result.matches) - - runs -> - expect(paths.length).toBe 1 - expect(paths[0]).toBe filePath - expect(matches.length).toBe 1 - - it "excludes values in core.ignoredNames", -> - ignoredNames = atom.config.get("core.ignoredNames") - ignoredNames.push("a") - atom.config.set("core.ignoredNames", ignoredNames) - - resultHandler = jasmine.createSpy("result found") - waitsForPromise -> - atom.workspace.scan /dollar/, (results) -> - resultHandler() - - runs -> - expect(resultHandler).not.toHaveBeenCalled() - - it "scans buffer contents if the buffer is modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.workspace.open('a').then (o) -> - editor = o - editor.setText("Elephant") - - waitsForPromise -> - atom.workspace.scan /a|Elephant/, (result) -> results.push result - - runs -> - expect(results).toHaveLength 3 - resultForA = _.find results, ({filePath}) -> path.basename(filePath) is 'a' - expect(resultForA.matches).toHaveLength 1 - expect(resultForA.matches[0].matchText).toBe 'Elephant' - - it "ignores buffers outside the project", -> - editor = null - results = [] - - waitsForPromise -> - atom.workspace.open(temp.openSync().path).then (o) -> - editor = o - editor.setText("Elephant") - - waitsForPromise -> - atom.workspace.scan /Elephant/, (result) -> results.push result - - runs -> - expect(results).toHaveLength 0 - - describe "when the project has multiple root directories", -> - [dir1, dir2, file1, file2] = [] - - beforeEach -> - [dir1] = atom.project.getPaths() - file1 = path.join(dir1, "a-dir", "oh-git") - - dir2 = temp.mkdirSync("a-second-dir") - aDir2 = path.join(dir2, "a-dir") - file2 = path.join(aDir2, "a-file") - fs.mkdirSync(aDir2) - fs.writeFileSync(file2, "ccc aaaa") - - atom.project.addPath(dir2) - - it "searches matching files in all of the project's root directories", -> - resultPaths = [] - waitsForPromise -> - atom.workspace.scan /aaaa/, ({filePath}) -> - resultPaths.push(filePath) - - runs -> - expect(resultPaths.sort()).toEqual([file1, file2].sort()) - - describe "when an inclusion path starts with the basename of a root directory", -> - it "interprets the inclusion path as starting from that directory", -> - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: ["dir"], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file1]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.join("dir", "a-dir")], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file1]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.basename(dir2)], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file2]) - - waitsForPromise -> - resultPaths = [] - atom.workspace - .scan /aaaa/, paths: [path.join(path.basename(dir2), "a-dir")], ({filePath}) -> - resultPaths.push(filePath) unless filePath in resultPaths - .then -> - expect(resultPaths).toEqual([file2]) - - describe "when a custom directory searcher is registered", -> - fakeSearch = null - # Function that is invoked once all of the fields on fakeSearch are set. - onFakeSearchCreated = null - - class FakeSearch - constructor: (@options) -> - # Note that hoisting resolve and reject in this way is generally frowned upon. - @promise = new Promise (resolve, reject) => - @hoistedResolve = resolve - @hoistedReject = reject - onFakeSearchCreated?(this) - then: (args...) -> - @promise.then.apply(@promise, args) - cancel: -> - @cancelled = true - # According to the spec for a DirectorySearcher, invoking `cancel()` should - # resolve the thenable rather than reject it. - @hoistedResolve() - - beforeEach -> - fakeSearch = null - onFakeSearchCreated = null - atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { - canSearchDirectory: (directory) -> directory.getPath() is dir1 - search: (directory, regex, options) -> fakeSearch = new FakeSearch(options) - }) - - waitsFor -> - atom.workspace.directorySearchers.length > 0 - - it "can override the DefaultDirectorySearcher on a per-directory basis", -> - foreignFilePath = 'ssh://foreign-directory:8080/hello.txt' - numPathsSearchedInDir2 = 1 - numPathsToPretendToSearchInCustomDirectorySearcher = 10 - searchResult = - filePath: foreignFilePath, - matches: [ - { - lineText: 'Hello world', - lineTextOffset: 0, - matchText: 'Hello', - range: [[0, 0], [0, 5]], - }, - ] - onFakeSearchCreated = (fakeSearch) -> - fakeSearch.options.didMatch(searchResult) - fakeSearch.options.didSearchPaths(numPathsToPretendToSearchInCustomDirectorySearcher) - fakeSearch.hoistedResolve() - - resultPaths = [] - onPathsSearched = jasmine.createSpy('onPathsSearched') - waitsForPromise -> - atom.workspace.scan /aaaa/, {onPathsSearched}, ({filePath}) -> - resultPaths.push(filePath) - - runs -> - expect(resultPaths.sort()).toEqual([foreignFilePath, file2].sort()) - # onPathsSearched should be called once by each DirectorySearcher. The order is not - # guaranteed, so we can only verify the total number of paths searched is correct - # after the second call. - expect(onPathsSearched.callCount).toBe(2) - expect(onPathsSearched.mostRecentCall.args[0]).toBe( - numPathsToPretendToSearchInCustomDirectorySearcher + numPathsSearchedInDir2) - - it "can be cancelled when the object returned by scan() has its cancel() method invoked", -> - thenable = atom.workspace.scan /aaaa/, -> - resultOfPromiseSearch = null - - waitsFor 'fakeSearch to be defined', -> fakeSearch? - - runs -> - expect(fakeSearch.cancelled).toBe(undefined) - thenable.cancel() - expect(fakeSearch.cancelled).toBe(true) - - - waitsForPromise -> - thenable.then (promiseResult) -> resultOfPromiseSearch = promiseResult - - runs -> - expect(resultOfPromiseSearch).toBe('cancelled') - - it "will have the side-effect of failing the overall search if it fails", -> - # This provider's search should be cancelled when the first provider fails - fakeSearch2 = null - atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { - canSearchDirectory: (directory) -> directory.getPath() is dir2 - search: (directory, regex, options) -> fakeSearch2 = new FakeSearch(options) - }) - - didReject = false - promise = cancelableSearch = atom.workspace.scan /aaaa/, -> - waitsFor 'fakeSearch to be defined', -> fakeSearch? - - runs -> - fakeSearch.hoistedReject() - - waitsForPromise -> - cancelableSearch.catch -> didReject = true - - waitsFor (done) -> promise.then(null, done) - - runs -> - expect(didReject).toBe(true) - expect(fakeSearch2.cancelled).toBe true # Cancels other ongoing searches - - describe "::replace(regex, replacementText, paths, iterator)", -> - [filePath, commentFilePath, sampleContent, sampleCommentContent] = [] - - beforeEach -> - atom.project.setPaths([atom.project.getDirectories()[0]?.resolve('../')]) - - filePath = atom.project.getDirectories()[0]?.resolve('sample.js') - commentFilePath = atom.project.getDirectories()[0]?.resolve('sample-with-comments.js') - sampleContent = fs.readFileSync(filePath).toString() - sampleCommentContent = fs.readFileSync(commentFilePath).toString() - - afterEach -> - fs.writeFileSync(filePath, sampleContent) - fs.writeFileSync(commentFilePath, sampleCommentContent) - - describe "when a file doesn't exist", -> - it "calls back with an error", -> - errors = [] - missingPath = path.resolve('/not-a-file.js') - expect(fs.existsSync(missingPath)).toBeFalsy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [missingPath], (result, error) -> - errors.push(error) - - runs -> - expect(errors).toHaveLength 1 - expect(errors[0].path).toBe missingPath - - describe "when called with unopened files", -> - it "replaces properly", -> - results = [] - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - describe "when a buffer is already open", -> - it "replaces properly and saves when not modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - expect(editor.isModified()).toBeFalsy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - expect(editor.isModified()).toBeFalsy() - - it "does not replace when the path is not specified", -> - editor = null - results = [] - - waitsForPromise -> - atom.workspace.open('sample-with-comments.js').then (o) -> editor = o - - waitsForPromise -> - atom.workspace.replace /items/gi, 'items', [commentFilePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe commentFilePath - - it "does NOT save when modified", -> - editor = null - results = [] - - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - runs -> - editor.buffer.setTextInRange([[0, 0], [0, 0]], 'omg') - expect(editor.isModified()).toBeTruthy() - - waitsForPromise -> - atom.workspace.replace /items/gi, 'okthen', [filePath], (result) -> - results.push(result) - - runs -> - expect(results).toHaveLength 1 - expect(results[0].filePath).toBe filePath - expect(results[0].replacements).toBe 6 - - expect(editor.isModified()).toBeTruthy() - - describe "::saveActivePaneItem()", -> - editor = null - beforeEach -> - waitsForPromise -> - atom.workspace.open('sample.js').then (o) -> editor = o - - describe "when there is an error", -> - it "emits a warning notification when the file cannot be saved", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("'/some/file' is a directory") - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the directory cannot be written to", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'") - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the user does not have permission", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EACCES, permission denied '/Some/dir/and-a-file.js'") - error.code = 'EACCES' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - expect(addedSpy.mostRecentCall.args[0].getType()).toBe 'warning' - - it "emits a warning notification when the operation is not permitted", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EPERM, operation not permitted '/Some/dir/and-a-file.js'") - error.code = 'EPERM' - error.path = '/Some/dir/and-a-file.js' - throw error - - it "emits a warning notification when the file is already open by another app", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EBUSY, resource busy or locked '/Some/dir/and-a-file.js'") - error.code = 'EBUSY' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - - notificaiton = addedSpy.mostRecentCall.args[0] - expect(notificaiton.getType()).toBe 'warning' - expect(notificaiton.getMessage()).toContain 'Unable to save' - - it "emits a warning notification when the file system is read-only", -> - spyOn(editor, 'save').andCallFake -> - error = new Error("EROFS, read-only file system '/Some/dir/and-a-file.js'") - error.code = 'EROFS' - error.path = '/Some/dir/and-a-file.js' - throw error - - atom.notifications.onDidAddNotification addedSpy = jasmine.createSpy() - atom.workspace.saveActivePaneItem() - expect(addedSpy).toHaveBeenCalled() - - notification = addedSpy.mostRecentCall.args[0] - expect(notification.getType()).toBe 'warning' - expect(notification.getMessage()).toContain 'Unable to save' - - it "emits a warning notification when the file cannot be saved", -> - spyOn(editor, 'save').andCallFake -> - throw new Error("no one knows") - - save = -> atom.workspace.saveActivePaneItem() - expect(save).toThrow() - - describe "::closeActivePaneItemOrEmptyPaneOrWindow", -> - beforeEach -> - spyOn(atom, 'close') - waitsForPromise -> atom.workspace.open() - - it "closes the active pane item, or the active pane if it is empty, or the current window if there is only the empty root pane", -> - atom.config.set('core.destroyEmptyPanes', false) - - pane1 = atom.workspace.getActivePane() - pane2 = pane1.splitRight(copyActiveItem: true) - - expect(atom.workspace.getPanes().length).toBe 2 - expect(pane2.getItems().length).toBe 1 - atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - - expect(atom.workspace.getPanes().length).toBe 2 - expect(pane2.getItems().length).toBe 0 - - atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - - expect(atom.workspace.getPanes().length).toBe 1 - expect(pane1.getItems().length).toBe 1 - - atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe 1 - expect(pane1.getItems().length).toBe 0 - - atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe 1 - - atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.close).toHaveBeenCalled() - - describe "when the core.allowPendingPaneItems option is falsey", -> - it "does not open item with `pending: true` option as pending", -> - pane = null - atom.config.set('core.allowPendingPaneItems', false) - - waitsForPromise -> - atom.workspace.open('sample.js', pending: true).then -> - pane = atom.workspace.getActivePane() - - runs -> - expect(pane.getPendingItem()).toBeFalsy() - - describe "grammar activation", -> - it "notifies the workspace of which grammar is used", -> - editor = null - atom.packages.triggerDeferredActivationHooks() - - javascriptGrammarUsed = jasmine.createSpy('js grammar used') - rubyGrammarUsed = jasmine.createSpy('ruby grammar used') - cGrammarUsed = jasmine.createSpy('c grammar used') - - atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', javascriptGrammarUsed) - atom.packages.onDidTriggerActivationHook('language-ruby:grammar-used', rubyGrammarUsed) - atom.packages.onDidTriggerActivationHook('language-c:grammar-used', cGrammarUsed) - - waitsForPromise -> atom.packages.activatePackage('language-ruby') - waitsForPromise -> atom.packages.activatePackage('language-javascript') - waitsForPromise -> atom.packages.activatePackage('language-c') - waitsForPromise -> atom.workspace.open('sample-with-comments.js') - - runs -> - # Hooks are triggered when opening new editors - expect(javascriptGrammarUsed).toHaveBeenCalled() - - # Hooks are triggered when changing existing editors grammars - atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.c')) - expect(cGrammarUsed).toHaveBeenCalled() - - # Hooks are triggered when editors are added in other ways. - atom.workspace.getActivePane().splitRight(copyActiveItem: true) - atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.ruby')) - expect(rubyGrammarUsed).toHaveBeenCalled() - - describe ".checkoutHeadRevision()", -> - editor = null - beforeEach -> - atom.config.set("editor.confirmCheckoutHeadRevision", false) - - waitsForPromise -> atom.workspace.open('sample-with-comments.js').then (o) -> editor = o - - it "reverts to the version of its file checked into the project repository", -> - editor.setCursorBufferPosition([0, 0]) - editor.insertText("---\n") - expect(editor.lineTextForBufferRow(0)).toBe "---" - - waitsForPromise -> - atom.workspace.checkoutHeadRevision(editor) - - runs -> - expect(editor.lineTextForBufferRow(0)).toBe "" - - describe "when there's no repository for the editor's file", -> - it "doesn't do anything", -> - editor = new TextEditor - editor.setText("stuff") - atom.workspace.checkoutHeadRevision(editor) - - waitsForPromise -> atom.workspace.checkoutHeadRevision(editor) - - escapeStringRegex = (str) -> - str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js new file mode 100644 index 000000000..270f81526 --- /dev/null +++ b/spec/workspace-spec.js @@ -0,0 +1,2082 @@ +'use strict' + +/* global advanceClock, HTMLElement, waits */ + +const path = require('path') +const temp = require('temp').track() +const TextEditor = require('../src/text-editor') +const Workspace = require('../src/workspace') +const Project = require('../src/project') +const platform = require('./spec-helper-platform') +const _ = require('underscore-plus') +const fstream = require('fstream') +const fs = require('fs-plus') +const AtomEnvironment = require('../src/atom-environment') + +describe('Workspace', () => { + let workspace + let setDocumentEdited + + beforeEach(() => { + workspace = atom.workspace + workspace.resetFontSize() + spyOn(atom.applicationDelegate, 'confirm') + setDocumentEdited = spyOn(atom.applicationDelegate, 'setWindowDocumentEdited') + atom.project.setPaths([atom.project.getDirectories()[0].resolve('dir')]) + waits(1) + }) + + afterEach(() => temp.cleanupSync()) + + describe('serialization', () => { + const simulateReload = () => { + const workspaceState = atom.workspace.serialize() + const projectState = atom.project.serialize({isUnloading: true}) + atom.workspace.destroy() + atom.project.destroy() + atom.project = new Project({ + notificationManager: atom.notifications, + packageManager: atom.packages, + confirm: atom.confirm.bind(atom), + applicationDelegate: atom.applicationDelegate + }) + atom.project.deserialize(projectState) + atom.workspace = new Workspace({ + config: atom.config, + project: atom.project, + packageManager: atom.packages, + grammarRegistry: atom.grammars, + deserializerManager: atom.deserializers, + notificationManager: atom.notifications, + applicationDelegate: atom.applicationDelegate, + viewRegistry: atom.views, + assert: atom.assert.bind(atom), + textEditorRegistry: atom.textEditors + }) + return atom.workspace.deserialize(workspaceState, atom.deserializers) + } + + describe('when the workspace contains text editors', () => { + it('constructs the view with the same panes', () => { + const pane1 = atom.workspace.getActivePane() + const pane2 = pane1.splitRight({copyActiveItem: true}) + const pane3 = pane2.splitRight({copyActiveItem: true}) + let pane4 = null + + waitsForPromise(() => atom.workspace.open(null).then(editor => editor.setText('An untitled editor.'))) + + waitsForPromise(() => + atom.workspace.open('b').then(editor => pane2.activateItem(editor.copy())) + ) + + waitsForPromise(() => + atom.workspace.open('../sample.js').then(editor => pane3.activateItem(editor)) + ) + + runs(() => { + pane3.activeItem.setCursorScreenPosition([2, 4]) + pane4 = pane2.splitDown() + }) + + waitsForPromise(() => + atom.workspace.open('../sample.txt').then(editor => pane4.activateItem(editor)) + ) + + runs(() => { + pane4.getActiveItem().setCursorScreenPosition([0, 2]) + pane2.activate() + + simulateReload() + + expect(atom.workspace.getTextEditors().length).toBe(5) + const [editor1, editor2, untitledEditor, editor3, editor4] = atom.workspace.getTextEditors() + const firstDirectory = atom.project.getDirectories()[0] + expect(firstDirectory).toBeDefined() + expect(editor1.getPath()).toBe(firstDirectory.resolve('b')) + expect(editor2.getPath()).toBe(firstDirectory.resolve('../sample.txt')) + expect(editor2.getCursorScreenPosition()).toEqual([0, 2]) + expect(editor3.getPath()).toBe(firstDirectory.resolve('b')) + expect(editor4.getPath()).toBe(firstDirectory.resolve('../sample.js')) + expect(editor4.getCursorScreenPosition()).toEqual([2, 4]) + expect(untitledEditor.getPath()).toBeUndefined() + expect(untitledEditor.getText()).toBe('An untitled editor.') + + expect(atom.workspace.getActiveTextEditor().getPath()).toBe(editor3.getPath()) + const pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) + expect(document.title).toMatch(new RegExp(`^${path.basename(editor3.getLongTitle())} \\u2014 ${pathEscaped}`)) + }) + }) + }) + + describe('where there are no open panes or editors', () => { + it('constructs the view with no open editors', () => { + atom.workspace.getActivePane().destroy() + expect(atom.workspace.getTextEditors().length).toBe(0) + simulateReload() + expect(atom.workspace.getTextEditors().length).toBe(0) + }) + }) + }) + + describe('::open(uri, options)', () => { + let openEvents = null + + beforeEach(() => { + openEvents = [] + workspace.onDidOpen(event => openEvents.push(event)) + spyOn(workspace.getActivePane(), 'activate').andCallThrough() + }) + + describe("when the 'searchAllPanes' option is false (default)", () => { + describe('when called without a uri', () => { + it('adds and activates an empty editor on the active pane', () => { + let editor1 + let editor2 + + waitsForPromise(() => workspace.open().then(editor => { editor1 = editor })) + + runs(() => { + expect(editor1.getPath()).toBeUndefined() + expect(workspace.getActivePane().items).toEqual([editor1]) + expect(workspace.getActivePaneItem()).toBe(editor1) + expect(workspace.getActivePane().activate).toHaveBeenCalled() + expect(openEvents).toEqual([{uri: undefined, pane: workspace.getActivePane(), item: editor1, index: 0}]) + openEvents = [] + }) + + waitsForPromise(() => workspace.open().then(editor => { editor2 = editor })) + + runs(() => { + expect(editor2.getPath()).toBeUndefined() + expect(workspace.getActivePane().items).toEqual([editor1, editor2]) + expect(workspace.getActivePaneItem()).toBe(editor2) + expect(workspace.getActivePane().activate).toHaveBeenCalled() + expect(openEvents).toEqual([{uri: undefined, pane: workspace.getActivePane(), item: editor2, index: 1}]) + }) + }) + }) + + describe('when called with a uri', () => { + describe('when the active pane already has an editor for the given uri', () => { + it('activates the existing editor on the active pane', () => { + let editor = null + let editor1 = null + let editor2 = null + + waitsForPromise(() => + workspace.open('a').then(o => { + editor1 = o + return workspace.open('b').then(o => { + editor2 = o + return workspace.open('a').then(o => { editor = o }) + }) + }) + ) + + runs(() => { + expect(editor).toBe(editor1) + expect(workspace.getActivePaneItem()).toBe(editor) + expect(workspace.getActivePane().activate).toHaveBeenCalled() + const firstDirectory = atom.project.getDirectories()[0] + expect(firstDirectory).toBeDefined() + expect(openEvents).toEqual([ + { + uri: firstDirectory.resolve('a'), + item: editor1, + pane: atom.workspace.getActivePane(), + index: 0 + }, + { + uri: firstDirectory.resolve('b'), + item: editor2, + pane: atom.workspace.getActivePane(), + index: 1 + }, + { + uri: firstDirectory.resolve('a'), + item: editor1, + pane: atom.workspace.getActivePane(), + index: 0 + } + ]) + }) + }) + }) + + describe('when the active pane does not have an editor for the given uri', () => { + it('adds and activates a new editor for the given path on the active pane', () => { + let editor = null + waitsForPromise(() => workspace.open('a').then(o => { editor = o })) + + runs(() => { + const firstDirectory = atom.project.getDirectories()[0] + expect(firstDirectory).toBeDefined() + expect(editor.getURI()).toBe(firstDirectory.resolve('a')) + expect(workspace.getActivePaneItem()).toBe(editor) + expect(workspace.getActivePane().items).toEqual([editor]) + expect(workspace.getActivePane().activate).toHaveBeenCalled() + }) + }) + }) + }) + }) + + describe("when the 'searchAllPanes' option is true", () => { + describe('when an editor for the given uri is already open on an inactive pane', () => { + it('activates the existing editor on the inactive pane, then activates that pane', () => { + let editor1 = null + let editor2 = null + const pane1 = workspace.getActivePane() + const pane2 = workspace.getActivePane().splitRight() + + waitsForPromise(() => { + pane1.activate() + return workspace.open('a').then(o => { editor1 = o }) + }) + + waitsForPromise(() => { + pane2.activate() + return workspace.open('b').then(o => { editor2 = o }) + }) + + runs(() => expect(workspace.getActivePaneItem()).toBe(editor2)) + + waitsForPromise(() => workspace.open('a', {searchAllPanes: true})) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(workspace.getActivePaneItem()).toBe(editor1) + }) + }) + }) + + describe('when no editor for the given uri is open in any pane', () => { + it('opens an editor for the given uri in the active pane', () => { + let editor = null + waitsForPromise(() => workspace.open('a', {searchAllPanes: true}).then(o => { editor = o })) + + runs(() => expect(workspace.getActivePaneItem()).toBe(editor)) + }) + }) + }) + + describe("when the 'split' option is set", () => { + describe("when the 'split' option is 'left'", () => { + it('opens the editor in the leftmost pane of the current pane axis', () => { + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitRight() + expect(workspace.getActivePane()).toBe(pane2) + + let editor = null + waitsForPromise(() => workspace.open('a', {split: 'left'}).then(o => { editor = o })) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + expect(pane2.items).toEqual([]) + }) + + // Focus right pane and reopen the file on the left + waitsForPromise(() => { + pane2.focus() + return workspace.open('a', {split: 'left'}).then(o => { editor = o }) + }) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + expect(pane2.items).toEqual([]) + }) + }) + }) + + describe('when a pane axis is the leftmost sibling of the current pane', () => { + it('opens the new item in the current pane', () => { + let editor = null + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitLeft() + pane2.splitDown() + pane1.activate() + expect(workspace.getActivePane()).toBe(pane1) + + waitsForPromise(() => workspace.open('a', {split: 'left'}).then(o => { editor = o })) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + }) + }) + }) + + describe("when the 'split' option is 'right'", () => { + it('opens the editor in the rightmost pane of the current pane axis', () => { + let editor = null + const pane1 = workspace.getActivePane() + let pane2 = null + waitsForPromise(() => workspace.open('a', {split: 'right'}).then(o => { editor = o })) + + runs(() => { + pane2 = workspace.getPanes().filter(p => p !== pane1)[0] + expect(workspace.getActivePane()).toBe(pane2) + expect(pane1.items).toEqual([]) + expect(pane2.items).toEqual([editor]) + }) + + // Focus right pane and reopen the file on the right + waitsForPromise(() => { + pane1.focus() + return workspace.open('a', {split: 'right'}).then(o => { editor = o }) + }) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane2) + expect(pane1.items).toEqual([]) + expect(pane2.items).toEqual([editor]) + }) + }) + + describe('when a pane axis is the rightmost sibling of the current pane', () => { + it('opens the new item in a new pane split to the right of the current pane', () => { + let editor = null + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitRight() + pane2.splitDown() + pane1.activate() + expect(workspace.getActivePane()).toBe(pane1) + let pane4 = null + + waitsForPromise(() => workspace.open('a', {split: 'right'}).then(o => { editor = o })) + + runs(() => { + pane4 = workspace.getPanes().filter(p => p !== pane1)[0] + expect(workspace.getActivePane()).toBe(pane4) + expect(pane4.items).toEqual([editor]) + expect(workspace.paneContainer.root.children[0]).toBe(pane1) + expect(workspace.paneContainer.root.children[1]).toBe(pane4) + }) + }) + }) + }) + + describe("when the 'split' option is 'up'", () => { + it('opens the editor in the topmost pane of the current pane axis', () => { + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitDown() + expect(workspace.getActivePane()).toBe(pane2) + + let editor = null + waitsForPromise(() => workspace.open('a', {split: 'up'}).then(o => { editor = o })) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + expect(pane2.items).toEqual([]) + }) + + // Focus bottom pane and reopen the file on the top + waitsForPromise(() => { + pane2.focus() + return workspace.open('a', {split: 'up'}).then(o => { editor = o }) + }) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + expect(pane2.items).toEqual([]) + }) + }) + }) + + describe('when a pane axis is the topmost sibling of the current pane', () => { + it('opens the new item in the current pane', () => { + let editor = null + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitUp() + pane2.splitRight() + pane1.activate() + expect(workspace.getActivePane()).toBe(pane1) + + waitsForPromise(() => workspace.open('a', {split: 'up'}).then(o => { editor = o })) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane1) + expect(pane1.items).toEqual([editor]) + }) + }) + }) + + describe("when the 'split' option is 'down'", () => { + it('opens the editor in the bottommost pane of the current pane axis', () => { + let editor = null + const pane1 = workspace.getActivePane() + let pane2 = null + waitsForPromise(() => workspace.open('a', {split: 'down'}).then(o => { editor = o })) + + runs(() => { + pane2 = workspace.getPanes().filter(p => p !== pane1)[0] + expect(workspace.getActivePane()).toBe(pane2) + expect(pane1.items).toEqual([]) + expect(pane2.items).toEqual([editor]) + }) + + // Focus bottom pane and reopen the file on the right + waitsForPromise(() => { + pane1.focus() + return workspace.open('a', {split: 'down'}).then(o => { editor = o }) + }) + + runs(() => { + expect(workspace.getActivePane()).toBe(pane2) + expect(pane1.items).toEqual([]) + expect(pane2.items).toEqual([editor]) + }) + }) + + describe('when a pane axis is the bottommost sibling of the current pane', () => { + it('opens the new item in a new pane split to the bottom of the current pane', () => { + let editor = null + const pane1 = workspace.getActivePane() + const pane2 = pane1.splitDown() + pane1.activate() + expect(workspace.getActivePane()).toBe(pane1) + let pane4 = null + + waitsForPromise(() => workspace.open('a', {split: 'down'}).then(o => { editor = o })) + + runs(() => { + pane4 = workspace.getPanes().filter(p => p !== pane1)[0] + expect(workspace.getActivePane()).toBe(pane4) + expect(pane4.items).toEqual([editor]) + expect(workspace.paneContainer.root.children[0]).toBe(pane1) + expect(workspace.paneContainer.root.children[1]).toBe(pane2) + }) + }) + }) + }) + }) + + describe('when an initialLine and initialColumn are specified', () => { + it('moves the cursor to the indicated location', () => { + waitsForPromise(() => workspace.open('a', {initialLine: 1, initialColumn: 5})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([1, 5])) + + waitsForPromise(() => workspace.open('a', {initialLine: 2, initialColumn: 4})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([2, 4])) + + waitsForPromise(() => workspace.open('a', {initialLine: 0, initialColumn: 0})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([0, 0])) + + waitsForPromise(() => workspace.open('a', {initialLine: NaN, initialColumn: 4})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([0, 4])) + + waitsForPromise(() => workspace.open('a', {initialLine: 2, initialColumn: NaN})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([2, 0])) + + waitsForPromise(() => workspace.open('a', {initialLine: Infinity, initialColumn: Infinity})) + + runs(() => expect(workspace.getActiveTextEditor().getCursorBufferPosition()).toEqual([2, 11])) + }) + }) + + describe('when the file is over 2MB', () => { + it('opens the editor with largeFileMode: true', () => { + spyOn(fs, 'getSizeSync').andReturn(2 * 1048577) // 2MB + + let editor = null + waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + + runs(() => expect(editor.largeFileMode).toBe(true)) + }) + }) + + describe('when the file is over user-defined limit', () => { + const shouldPromptForFileOfSize = (size, shouldPrompt) => { + spyOn(fs, 'getSizeSync').andReturn(size * 1048577) + atom.applicationDelegate.confirm.andCallFake(() => selectedButtonIndex) + atom.applicationDelegate.confirm() + var selectedButtonIndex = 1 // cancel + + let editor = null + waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + if (shouldPrompt) { + runs(() => { + expect(editor).toBeUndefined() + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() + + atom.applicationDelegate.confirm.reset() + selectedButtonIndex = 0 + }) // open the file + + waitsForPromise(() => workspace.open('sample.js').then(e => { editor = e })) + + runs(() => { + expect(atom.applicationDelegate.confirm).toHaveBeenCalled() + expect(editor.largeFileMode).toBe(true) + }) + } else { + runs(() => expect(editor).not.toBeUndefined()) + } + } + + it('prompts the user to make sure they want to open a file this big', () => { + atom.config.set('core.warnOnLargeFileLimit', 20) + shouldPromptForFileOfSize(20, true) + }) + + it("doesn't prompt on files below the limit", () => { + atom.config.set('core.warnOnLargeFileLimit', 30) + shouldPromptForFileOfSize(20, false) + }) + + it('prompts for smaller files with a lower limit', () => { + atom.config.set('core.warnOnLargeFileLimit', 5) + shouldPromptForFileOfSize(10, true) + }) + }) + + describe('when passed a path that matches a custom opener', () => { + it('returns the resource returned by the custom opener', () => { + const fooOpener = (pathToOpen, options) => { + if (pathToOpen != null ? pathToOpen.match(/\.foo/) : undefined) { + return {foo: pathToOpen, options} + } + } + const barOpener = (pathToOpen) => { + if (pathToOpen != null ? pathToOpen.match(/^bar:\/\//) : undefined) { + return {bar: pathToOpen} + } + } + workspace.addOpener(fooOpener) + workspace.addOpener(barOpener) + + waitsForPromise(() => { + const pathToOpen = atom.project.getDirectories()[0].resolve('a.foo') + return workspace.open(pathToOpen, {hey: 'there'}).then(item => expect(item).toEqual({foo: pathToOpen, options: {hey: 'there'}})) + }) + + waitsForPromise(() => + workspace.open('bar://baz').then(item => expect(item).toEqual({bar: 'bar://baz'}))) + }) + }) + + it("adds the file to the application's recent documents list", () => { + if (process.platform !== 'darwin') { return } // Feature only supported on macOS + spyOn(atom.applicationDelegate, 'addRecentDocument') + + waitsForPromise(() => workspace.open()) + + runs(() => expect(atom.applicationDelegate.addRecentDocument).not.toHaveBeenCalled()) + + waitsForPromise(() => workspace.open('something://a/url')) + + runs(() => expect(atom.applicationDelegate.addRecentDocument).not.toHaveBeenCalled()) + + waitsForPromise(() => workspace.open(__filename)) + + runs(() => expect(atom.applicationDelegate.addRecentDocument).toHaveBeenCalledWith(__filename)) + }) + + it('notifies ::onDidAddTextEditor observers', () => { + const absolutePath = require.resolve('./fixtures/dir/a') + const newEditorHandler = jasmine.createSpy('newEditorHandler') + workspace.onDidAddTextEditor(newEditorHandler) + + let editor = null + waitsForPromise(() => workspace.open(absolutePath).then(e => { editor = e })) + + runs(() => expect(newEditorHandler.argsForCall[0][0].textEditor).toBe(editor)) + }) + + describe('when there is an error opening the file', () => { + let notificationSpy = null + beforeEach(() => atom.notifications.onDidAddNotification(notificationSpy = jasmine.createSpy())) + + describe('when a file does not exist', () => { + it('creates an empty buffer for the specified path', () => { + waitsForPromise(() => workspace.open('not-a-file.md')) + + runs(() => { + const editor = workspace.getActiveTextEditor() + expect(notificationSpy).not.toHaveBeenCalled() + expect(editor.getPath()).toContain('not-a-file.md') + }) + }) + }) + + describe('when the user does not have access to the file', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EACCES, permission denied '${path}'`) + error.path = path + error.code = 'EACCES' + throw error + }) + ) + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')) + + runs(() => { + expect(notificationSpy).toHaveBeenCalled() + const notification = notificationSpy.mostRecentCall.args[0] + expect(notification.getType()).toBe('warning') + expect(notification.getMessage()).toContain('Permission denied') + expect(notification.getMessage()).toContain('file1') + }) + }) + }) + + describe('when the the operation is not permitted', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EPERM, operation not permitted '${path}'`) + error.path = path + error.code = 'EPERM' + throw error + }) + ) + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')) + + runs(() => { + expect(notificationSpy).toHaveBeenCalled() + const notification = notificationSpy.mostRecentCall.args[0] + expect(notification.getType()).toBe('warning') + expect(notification.getMessage()).toContain('Unable to open') + expect(notification.getMessage()).toContain('file1') + }) + }) + }) + + describe('when the the file is already open in windows', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + const error = new Error(`EBUSY, resource busy or locked '${path}'`) + error.path = path + error.code = 'EBUSY' + throw error + }) + ) + + it('creates a notification', () => { + waitsForPromise(() => workspace.open('file1')) + + runs(() => { + expect(notificationSpy).toHaveBeenCalled() + const notification = notificationSpy.mostRecentCall.args[0] + expect(notification.getType()).toBe('warning') + expect(notification.getMessage()).toContain('Unable to open') + expect(notification.getMessage()).toContain('file1') + }) + }) + }) + + describe('when there is an unhandled error', () => { + beforeEach(() => + spyOn(fs, 'openSync').andCallFake(path => { + throw new Error('I dont even know what is happening right now!!') + }) + ) + + it('creates a notification', () => { + const open = () => workspace.open('file1', workspace.getActivePane()) + expect(open).toThrow() + }) + }) + }) + + describe('when the file is already open in pending state', () => { + it('should terminate the pending state', () => { + let editor = null + let pane = null + + waitsForPromise(() => + atom.workspace.open('sample.js', {pending: true}).then(o => { + editor = o + pane = atom.workspace.getActivePane() + }) + ) + + runs(() => expect(pane.getPendingItem()).toEqual(editor)) + + waitsForPromise(() => atom.workspace.open('sample.js')) + + runs(() => expect(pane.getPendingItem()).toBeNull()) + }) + }) + + describe('when opening will switch from a pending tab to a permanent tab', () => { + it('keeps the pending tab open', () => { + let editor1 = null + let editor2 = null + + waitsForPromise(() => + atom.workspace.open('sample.txt').then(o => { editor1 = o }) + ) + + waitsForPromise(() => + atom.workspace.open('sample2.txt', {pending: true}).then(o => { editor2 = o }) + ) + + runs(() => { + const pane = atom.workspace.getActivePane() + pane.activateItem(editor1) + expect(pane.getItems().length).toBe(2) + expect(pane.getItems()).toEqual([editor1, editor2]) + }) + }) + }) + + describe('when replacing a pending item which is the last item in a second pane', () => { + it('does not destroy the pane even if core.destroyEmptyPanes is on', () => { + atom.config.set('core.destroyEmptyPanes', true) + let editor1 = null + let editor2 = null + const leftPane = atom.workspace.getActivePane() + let rightPane = null + + waitsForPromise(() => + atom.workspace.open('sample.js', {pending: true, split: 'right'}).then(o => { + editor1 = o + rightPane = atom.workspace.getActivePane() + spyOn(rightPane, 'destroyed') + }) + ) + + runs(() => { + expect(leftPane).not.toBe(rightPane) + expect(atom.workspace.getActivePane()).toBe(rightPane) + expect(atom.workspace.getActivePane().getItems().length).toBe(1) + expect(rightPane.getPendingItem()).toBe(editor1) + }) + + waitsForPromise(() => + atom.workspace.open('sample.txt', {pending: true}).then(o => { editor2 = o }) + ) + + runs(() => { + expect(rightPane.getPendingItem()).toBe(editor2) + expect(rightPane.destroyed.callCount).toBe(0) + }) + }) + }) + }) + + describe('the grammar-used hook', () => { + it('fires when opening a file or changing the grammar of an open file', () => { + let editor = null + let javascriptGrammarUsed = false + let coffeescriptGrammarUsed = false + + atom.packages.triggerDeferredActivationHooks() + + runs(() => { + atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', () => { javascriptGrammarUsed = true }) + atom.packages.onDidTriggerActivationHook('language-coffee-script:grammar-used', () => { coffeescriptGrammarUsed = true }) + }) + + waitsForPromise(() => atom.workspace.open('sample.js', {autoIndent: false}).then(o => { editor = o })) + + waitsForPromise(() => atom.packages.activatePackage('language-javascript')) + + waitsFor(() => javascriptGrammarUsed) + + waitsForPromise(() => atom.packages.activatePackage('language-coffee-script')) + + runs(() => editor.setGrammar(atom.grammars.selectGrammar('.coffee'))) + + waitsFor(() => coffeescriptGrammarUsed) + }) + }) + + describe('::reopenItem()', () => { + it("opens the uri associated with the last closed pane that isn't currently open", () => { + const pane = workspace.getActivePane() + waitsForPromise(() => + workspace.open('a').then(() => + workspace.open('b').then(() => + workspace.open('file1').then(() => workspace.open()) + ) + ) + ) + + runs(() => { + // does not reopen items with no uri + expect(workspace.getActivePaneItem().getURI()).toBeUndefined() + pane.destroyActiveItem() + }) + + waitsForPromise(() => workspace.reopenItem()) + + const firstDirectory = atom.project.getDirectories()[0] + expect(firstDirectory).toBeDefined() + + runs(() => { + expect(workspace.getActivePaneItem().getURI()).not.toBeUndefined() + + // destroy all items + expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('file1')) + pane.destroyActiveItem() + expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('b')) + pane.destroyActiveItem() + expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('a')) + pane.destroyActiveItem() + + // reopens items with uris + expect(workspace.getActivePaneItem()).toBeUndefined() + }) + + waitsForPromise(() => workspace.reopenItem()) + + runs(() => expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('a'))) + + // does not reopen items that are already open + waitsForPromise(() => workspace.open('b')) + + runs(() => expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('b'))) + + waitsForPromise(() => workspace.reopenItem()) + + runs(() => expect(workspace.getActivePaneItem().getURI()).toBe(firstDirectory.resolve('file1'))) + }) + }) + + describe('::increase/decreaseFontSize()', () => { + it('increases/decreases the font size without going below 1', () => { + atom.config.set('editor.fontSize', 1) + workspace.increaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(2) + workspace.increaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(3) + workspace.decreaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(2) + workspace.decreaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(1) + workspace.decreaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(1) + }) + }) + + describe('::resetFontSize()', () => { + it("resets the font size to the window's starting font size", () => { + const originalFontSize = atom.config.get('editor.fontSize') + + workspace.increaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize + 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize) + workspace.decreaseFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize - 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize) + }) + + it('does nothing if the font size has not been changed', () => { + const originalFontSize = atom.config.get('editor.fontSize') + + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize) + }) + + it("resets the font size when the editor's font size changes", () => { + const originalFontSize = atom.config.get('editor.fontSize') + + atom.config.set('editor.fontSize', originalFontSize + 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize) + atom.config.set('editor.fontSize', originalFontSize - 1) + workspace.resetFontSize() + expect(atom.config.get('editor.fontSize')).toBe(originalFontSize) + }) + }) + + describe('::openLicense()', () => { + it('opens the license as plain-text in a buffer', () => { + waitsForPromise(() => workspace.openLicense()) + runs(() => expect(workspace.getActivePaneItem().getText()).toMatch(/Copyright/)) + }) + }) + + describe('::isTextEditor(obj)', () => { + it('returns true when the passed object is an instance of `TextEditor`', () => { + expect(workspace.isTextEditor(new TextEditor())).toBe(true) + expect(workspace.isTextEditor({getText: () => null})).toBe(false) + expect(workspace.isTextEditor(null)).toBe(false) + expect(workspace.isTextEditor(undefined)).toBe(false) + }) + }) + + describe('::observeTextEditors()', () => { + it('invokes the observer with current and future text editors', () => { + const observed = [] + + waitsForPromise(() => workspace.open()) + waitsForPromise(() => workspace.open()) + waitsForPromise(() => workspace.openLicense()) + + runs(() => workspace.observeTextEditors(editor => observed.push(editor))) + + waitsForPromise(() => workspace.open()) + + expect(observed).toEqual(workspace.getTextEditors()) + }) + }) + + describe('when an editor is destroyed', () => { + it('removes the editor', () => { + let editor = null + + waitsForPromise(() => workspace.open('a').then(e => { editor = e })) + + runs(() => { + expect(workspace.getTextEditors()).toHaveLength(1) + editor.destroy() + expect(workspace.getTextEditors()).toHaveLength(0) + }) + }) + }) + + describe('when an editor is copied because its pane is split', () => { + it('sets up the new editor to be configured by the text editor registry', () => { + waitsForPromise(() => atom.packages.activatePackage('language-javascript')) + + waitsForPromise(() => + workspace.open('a').then(editor => { + atom.textEditors.setGrammarOverride(editor, 'source.js') + expect(editor.getGrammar().name).toBe('JavaScript') + + workspace.getActivePane().splitRight({copyActiveItem: true}) + const newEditor = workspace.getActiveTextEditor() + expect(newEditor).not.toBe(editor) + expect(newEditor.getGrammar().name).toBe('JavaScript') + }) + ) + }) + }) + + it('stores the active grammars used by all the open editors', () => { + waitsForPromise(() => atom.packages.activatePackage('language-javascript')) + + waitsForPromise(() => atom.packages.activatePackage('language-coffee-script')) + + waitsForPromise(() => atom.packages.activatePackage('language-todo')) + + waitsForPromise(() => atom.workspace.open('sample.coffee')) + + runs(function () { + atom.workspace.getActiveTextEditor().setText(`\ +i = /test/; #FIXME\ +` + ) + + const atom2 = new AtomEnvironment({ + applicationDelegate: atom.applicationDelegate, + window: document.createElement('div'), + document: Object.assign( + document.createElement('div'), + { + body: document.createElement('div'), + head: document.createElement('div') + } + ) + }) + + atom2.packages.loadPackage('language-javascript') + atom2.packages.loadPackage('language-coffee-script') + atom2.packages.loadPackage('language-todo') + atom2.project.deserialize(atom.project.serialize()) + atom2.workspace.deserialize(atom.workspace.serialize(), atom2.deserializers) + + expect(atom2.grammars.getGrammars().map(grammar => grammar.name).sort()).toEqual([ + 'CoffeeScript', + 'CoffeeScript (Literate)', + 'JavaScript', + 'Null Grammar', + 'Regular Expression Replacement (JavaScript)', + 'Regular Expressions (JavaScript)', + 'TODO' + ]) + + atom2.destroy() + }) + }) + + describe('document.title', () => { + describe('when there is no item open', () => { + it('sets the title to the project path', () => expect(document.title).toMatch(escapeStringRegex(fs.tildify(atom.project.getPaths()[0])))) + + it("sets the title to 'untitled' if there is no project path", () => { + atom.project.setPaths([]) + expect(document.title).toMatch(/^untitled/) + }) + }) + + describe("when the active pane item's path is not inside a project path", () => { + beforeEach(() => + waitsForPromise(() => + atom.workspace.open('b').then(() => atom.project.setPaths([])) + ) + ) + + it("sets the title to the pane item's title plus the item's path", () => { + const item = atom.workspace.getActivePaneItem() + const pathEscaped = fs.tildify(escapeStringRegex(path.dirname(item.getPath()))) + expect(document.title).toMatch(new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`)) + }) + + describe('when the title of the active pane item changes', () => { + it("updates the window title based on the item's new title", () => { + const editor = atom.workspace.getActivePaneItem() + editor.buffer.setPath(path.join(temp.dir, 'hi')) + const pathEscaped = fs.tildify(escapeStringRegex(path.dirname(editor.getPath()))) + expect(document.title).toMatch(new RegExp(`^${editor.getTitle()} \\u2014 ${pathEscaped}`)) + }) + }) + + describe("when the active pane's item changes", () => { + it("updates the title to the new item's title plus the project path", () => { + atom.workspace.getActivePane().activateNextItem() + const item = atom.workspace.getActivePaneItem() + const pathEscaped = fs.tildify(escapeStringRegex(path.dirname(item.getPath()))) + expect(document.title).toMatch(new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`)) + }) + }) + + describe("when an inactive pane's item changes", () => { + it('does not update the title', () => { + const pane = atom.workspace.getActivePane() + pane.splitRight() + const initialTitle = document.title + pane.activateNextItem() + expect(document.title).toBe(initialTitle) + }) + }) + }) + + describe('when the active pane item is inside a project path', () => { + beforeEach(() => + waitsForPromise(() => atom.workspace.open('b')) + ) + + describe('when there is an active pane item', () => { + it("sets the title to the pane item's title plus the project path", () => { + const item = atom.workspace.getActivePaneItem() + const pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) + expect(document.title).toMatch(new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`)) + }) + }) + + describe('when the title of the active pane item changes', () => { + it("updates the window title based on the item's new title", () => { + const editor = atom.workspace.getActivePaneItem() + editor.buffer.setPath(path.join(atom.project.getPaths()[0], 'hi')) + const pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) + expect(document.title).toMatch(new RegExp(`^${editor.getTitle()} \\u2014 ${pathEscaped}`)) + }) + }) + + describe("when the active pane's item changes", () => { + it("updates the title to the new item's title plus the project path", () => { + atom.workspace.getActivePane().activateNextItem() + const item = atom.workspace.getActivePaneItem() + const pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) + expect(document.title).toMatch(new RegExp(`^${item.getTitle()} \\u2014 ${pathEscaped}`)) + }) + }) + + describe('when the last pane item is removed', () => { + it("updates the title to the project's first path", () => { + atom.workspace.getActivePane().destroy() + expect(atom.workspace.getActivePaneItem()).toBeUndefined() + expect(document.title).toMatch(escapeStringRegex(fs.tildify(atom.project.getPaths()[0]))) + }) + }) + + describe("when an inactive pane's item changes", () => { + it('does not update the title', () => { + const pane = atom.workspace.getActivePane() + pane.splitRight() + const initialTitle = document.title + pane.activateNextItem() + expect(document.title).toBe(initialTitle) + }) + }) + }) + + describe('when the workspace is deserialized', () => { + beforeEach(() => waitsForPromise(() => atom.workspace.open('a'))) + + it("updates the title to contain the project's path", () => { + document.title = null + + const atom2 = new AtomEnvironment({ + applicationDelegate: atom.applicationDelegate, + window: document.createElement('div'), + document: Object.assign( + document.createElement('div'), + { + body: document.createElement('div'), + head: document.createElement('div') + } + ) + }) + + atom2.project.deserialize(atom.project.serialize()) + atom2.workspace.deserialize(atom.workspace.serialize(), atom2.deserializers) + const item = atom2.workspace.getActivePaneItem() + const pathEscaped = fs.tildify(escapeStringRegex(atom.project.getPaths()[0])) + expect(document.title).toMatch(new RegExp(`^${item.getLongTitle()} \\u2014 ${pathEscaped}`)) + + atom2.destroy() + }) + }) + }) + + describe('document edited status', () => { + let item1 + let item2 + + beforeEach(() => { + waitsForPromise(() => atom.workspace.open('a')) + waitsForPromise(() => atom.workspace.open('b')) + runs(() => { + [item1, item2] = atom.workspace.getPaneItems() + }) + }) + + it('calls setDocumentEdited when the active item changes', () => { + expect(atom.workspace.getActivePaneItem()).toBe(item2) + item1.insertText('a') + expect(item1.isModified()).toBe(true) + atom.workspace.getActivePane().activateNextItem() + + expect(setDocumentEdited).toHaveBeenCalledWith(true) + }) + + it("calls atom.setDocumentEdited when the active item's modified status changes", () => { + expect(atom.workspace.getActivePaneItem()).toBe(item2) + item2.insertText('a') + advanceClock(item2.getBuffer().getStoppedChangingDelay()) + + expect(item2.isModified()).toBe(true) + expect(setDocumentEdited).toHaveBeenCalledWith(true) + + item2.undo() + advanceClock(item2.getBuffer().getStoppedChangingDelay()) + + expect(item2.isModified()).toBe(false) + expect(setDocumentEdited).toHaveBeenCalledWith(false) + }) + }) + + describe('adding panels', () => { + class TestItem {} + + // Don't use ES6 classes because then we'll have to call `super()` which we can't do with + // HTMLElement + function TestItemElement () { this.constructor = TestItemElement } + function Ctor () { this.constructor = TestItemElement } + Ctor.prototype = HTMLElement.prototype + TestItemElement.prototype = new Ctor() + TestItemElement.__super__ = HTMLElement.prototype + TestItemElement.prototype.initialize = function (model) { this.model = model; return this } + TestItemElement.prototype.getModel = function () { return this.model } + + beforeEach(() => + atom.views.addViewProvider(TestItem, model => new TestItemElement().initialize(model)) + ) + + describe('::addLeftPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getLeftPanels().length).toBe(0) + atom.workspace.panelContainers.left.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addLeftPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getLeftPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addRightPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getRightPanels().length).toBe(0) + atom.workspace.panelContainers.right.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addRightPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getRightPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addTopPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getTopPanels().length).toBe(0) + atom.workspace.panelContainers.top.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addTopPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getTopPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addBottomPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getBottomPanels().length).toBe(0) + atom.workspace.panelContainers.bottom.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addBottomPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getBottomPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addHeaderPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getHeaderPanels().length).toBe(0) + atom.workspace.panelContainers.header.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addHeaderPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getHeaderPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addFooterPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getFooterPanels().length).toBe(0) + atom.workspace.panelContainers.footer.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addFooterPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getFooterPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::addModalPanel(model)', () => { + it('adds a panel to the correct panel container', () => { + let addPanelSpy + expect(atom.workspace.getModalPanels().length).toBe(0) + atom.workspace.panelContainers.modal.onDidAddPanel(addPanelSpy = jasmine.createSpy()) + + const model = new TestItem() + const panel = atom.workspace.addModalPanel({item: model}) + + expect(panel).toBeDefined() + expect(addPanelSpy).toHaveBeenCalledWith({panel, index: 0}) + + const itemView = atom.views.getView(atom.workspace.getModalPanels()[0].getItem()) + expect(itemView instanceof TestItemElement).toBe(true) + expect(itemView.getModel()).toBe(model) + }) + }) + + describe('::panelForItem(item)', () => { + it('returns the panel associated with the item', () => { + const item = new TestItem() + const panel = atom.workspace.addLeftPanel({item}) + + const itemWithNoPanel = new TestItem() + + expect(atom.workspace.panelForItem(item)).toBe(panel) + expect(atom.workspace.panelForItem(itemWithNoPanel)).toBe(null) + }) + }) + }) + + describe('::scan(regex, options, callback)', () => { + describe('when called with a regex', () => { + it('calls the callback with all regex results in all files in the project', () => { + const results = [] + waitsForPromise(() => + atom.workspace.scan(/(a)+/, result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(3) + expect(results[0].filePath).toBe(atom.project.getDirectories()[0].resolve('a')) + expect(results[0].matches).toHaveLength(3) + expect(results[0].matches[0]).toEqual({ + matchText: 'aaa', + lineText: 'aaa bbb', + lineTextOffset: 0, + range: [[0, 0], [0, 3]] + }) + }) + }) + + it('works with with escaped literals (like $ and ^)', () => { + const results = [] + waitsForPromise(() => atom.workspace.scan(/\$\w+/, result => results.push(result))) + + runs(() => { + expect(results.length).toBe(1) + const {filePath, matches} = results[0] + expect(filePath).toBe(atom.project.getDirectories()[0].resolve('a')) + expect(matches).toHaveLength(1) + expect(matches[0]).toEqual({ + matchText: '$bill', + lineText: 'dollar$bill', + lineTextOffset: 0, + range: [[2, 6], [2, 11]] + }) + }) + }) + + it('works on evil filenames', () => { + atom.config.set('core.excludeVcsIgnoredPaths', false) + platform.generateEvilFiles() + atom.project.setPaths([path.join(__dirname, 'fixtures', 'evil-files')]) + const paths = [] + let matches = [] + waitsForPromise(() => + atom.workspace.scan(/evil/, result => { + paths.push(result.filePath) + matches = matches.concat(result.matches) + }) + ) + + runs(() => { + _.each(matches, m => expect(m.matchText).toEqual('evil')) + + if (platform.isWindows()) { + expect(paths.length).toBe(3) + expect(paths[0]).toMatch(/a_file_with_utf8.txt$/) + expect(paths[1]).toMatch(/file with spaces.txt$/) + expect(path.basename(paths[2])).toBe('utfa\u0306.md') + } else { + expect(paths.length).toBe(5) + expect(paths[0]).toMatch(/a_file_with_utf8.txt$/) + expect(paths[1]).toMatch(/file with spaces.txt$/) + expect(paths[2]).toMatch(/goddam\nnewlines$/m) + expect(paths[3]).toMatch(/quote".txt$/m) + expect(path.basename(paths[4])).toBe('utfa\u0306.md') + } + }) + }) + + it('ignores case if the regex includes the `i` flag', () => { + const results = [] + waitsForPromise(() => atom.workspace.scan(/DOLLAR/i, result => results.push(result))) + + runs(() => expect(results).toHaveLength(1)) + }) + + describe('when the core.excludeVcsIgnoredPaths config is truthy', () => { + let projectPath + let ignoredPath + + beforeEach(() => { + const sourceProjectPath = path.join(__dirname, 'fixtures', 'git', 'working-dir') + projectPath = path.join(temp.mkdirSync('atom')) + + const writerStream = fstream.Writer(projectPath) + fstream.Reader(sourceProjectPath).pipe(writerStream) + + waitsFor(done => { + writerStream.on('close', done) + writerStream.on('error', done) + }) + + runs(() => { + fs.rename(path.join(projectPath, 'git.git'), path.join(projectPath, '.git')) + ignoredPath = path.join(projectPath, 'ignored.txt') + fs.writeFileSync(ignoredPath, 'this match should not be included') + }) + }) + + afterEach(() => { + if (fs.existsSync(projectPath)) { + fs.removeSync(projectPath) + } + }) + + it('excludes ignored files', () => { + atom.project.setPaths([projectPath]) + atom.config.set('core.excludeVcsIgnoredPaths', true) + const resultHandler = jasmine.createSpy('result found') + waitsForPromise(() => + atom.workspace.scan(/match/, results => resultHandler()) + ) + + runs(() => expect(resultHandler).not.toHaveBeenCalled()) + }) + }) + + it('includes only files when a directory filter is specified', () => { + const projectPath = path.join(path.join(__dirname, 'fixtures', 'dir')) + atom.project.setPaths([projectPath]) + + const filePath = path.join(projectPath, 'a-dir', 'oh-git') + + const paths = [] + let matches = [] + waitsForPromise(() => + atom.workspace.scan(/aaa/, {paths: [`a-dir${path.sep}`]}, result => { + paths.push(result.filePath) + matches = matches.concat(result.matches) + }) + ) + + runs(() => { + expect(paths.length).toBe(1) + expect(paths[0]).toBe(filePath) + expect(matches.length).toBe(1) + }) + }) + + it("includes files and folders that begin with a '.'", () => { + const projectPath = temp.mkdirSync('atom-spec-workspace') + const filePath = path.join(projectPath, '.text') + fs.writeFileSync(filePath, 'match this') + atom.project.setPaths([projectPath]) + const paths = [] + let matches = [] + waitsForPromise(() => + atom.workspace.scan(/match this/, result => { + paths.push(result.filePath) + matches = matches.concat(result.matches) + }) + ) + + runs(() => { + expect(paths.length).toBe(1) + expect(paths[0]).toBe(filePath) + expect(matches.length).toBe(1) + }) + }) + + it('excludes values in core.ignoredNames', () => { + const ignoredNames = atom.config.get('core.ignoredNames') + ignoredNames.push('a') + atom.config.set('core.ignoredNames', ignoredNames) + + const resultHandler = jasmine.createSpy('result found') + waitsForPromise(() => + atom.workspace.scan(/dollar/, results => resultHandler()) + ) + + runs(() => expect(resultHandler).not.toHaveBeenCalled()) + }) + + it('scans buffer contents if the buffer is modified', () => { + let editor = null + const results = [] + + waitsForPromise(() => + atom.workspace.open('a').then(o => { + editor = o + editor.setText('Elephant') + }) + ) + + waitsForPromise(() => atom.workspace.scan(/a|Elephant/, result => results.push(result))) + + runs(() => { + expect(results).toHaveLength(3) + const resultForA = _.find(results, ({filePath}) => path.basename(filePath) === 'a') + expect(resultForA.matches).toHaveLength(1) + expect(resultForA.matches[0].matchText).toBe('Elephant') + }) + }) + + it('ignores buffers outside the project', () => { + let editor = null + const results = [] + + waitsForPromise(() => + atom.workspace.open(temp.openSync().path).then(o => { + editor = o + editor.setText('Elephant') + }) + ) + + waitsForPromise(() => atom.workspace.scan(/Elephant/, result => results.push(result))) + + runs(() => expect(results).toHaveLength(0)) + }) + + describe('when the project has multiple root directories', () => { + let dir1 + let dir2 + let file1 + let file2 + + beforeEach(() => { + dir1 = atom.project.getPaths()[0] + file1 = path.join(dir1, 'a-dir', 'oh-git') + + dir2 = temp.mkdirSync('a-second-dir') + const aDir2 = path.join(dir2, 'a-dir') + file2 = path.join(aDir2, 'a-file') + fs.mkdirSync(aDir2) + fs.writeFileSync(file2, 'ccc aaaa') + + atom.project.addPath(dir2) + }) + + it("searches matching files in all of the project's root directories", () => { + const resultPaths = [] + waitsForPromise(() => + atom.workspace.scan(/aaaa/, ({filePath}) => resultPaths.push(filePath)) + ) + + runs(() => expect(resultPaths.sort()).toEqual([file1, file2].sort())) + }) + + describe('when an inclusion path starts with the basename of a root directory', () => { + it('interprets the inclusion path as starting from that directory', () => { + waitsForPromise(() => { + const resultPaths = [] + return atom.workspace + .scan(/aaaa/, {paths: ['dir']}, ({filePath}) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath) + } + }) + .then(() => expect(resultPaths).toEqual([file1])) + }) + + waitsForPromise(() => { + const resultPaths = [] + return atom.workspace + .scan(/aaaa/, {paths: [path.join('dir', 'a-dir')]}, ({filePath}) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath) + } + }) + .then(() => expect(resultPaths).toEqual([file1])) + }) + + waitsForPromise(() => { + const resultPaths = [] + return atom.workspace + .scan(/aaaa/, {paths: [path.basename(dir2)]}, ({filePath}) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath) + } + }) + .then(() => expect(resultPaths).toEqual([file2])) + }) + + waitsForPromise(() => { + const resultPaths = [] + return atom.workspace + .scan(/aaaa/, {paths: [path.join(path.basename(dir2), 'a-dir')]}, ({filePath}) => { + if (!resultPaths.includes(filePath)) { + resultPaths.push(filePath) + } + }) + .then(() => expect(resultPaths).toEqual([file2])) + }) + }) + }) + + describe('when a custom directory searcher is registered', () => { + let fakeSearch = null + // Function that is invoked once all of the fields on fakeSearch are set. + let onFakeSearchCreated = null + + class FakeSearch { + constructor (options) { + // Note that hoisting resolve and reject in this way is generally frowned upon. + this.options = options + this.promise = new Promise((resolve, reject) => { + this.hoistedResolve = resolve + this.hoistedReject = reject + if (typeof onFakeSearchCreated === 'function') { + onFakeSearchCreated(this) + } + }) + } + then (...args) { + return this.promise.then.apply(this.promise, args) + } + cancel () { + this.cancelled = true + // According to the spec for a DirectorySearcher, invoking `cancel()` should + // resolve the thenable rather than reject it. + this.hoistedResolve() + } + } + + beforeEach(() => { + fakeSearch = null + onFakeSearchCreated = null + atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { + canSearchDirectory (directory) { return directory.getPath() === dir1 }, + search (directory, regex, options) { + fakeSearch = new FakeSearch(options) + return fakeSearch + } + }) + + waitsFor(() => atom.workspace.directorySearchers.length > 0) + }) + + it('can override the DefaultDirectorySearcher on a per-directory basis', () => { + const foreignFilePath = 'ssh://foreign-directory:8080/hello.txt' + const numPathsSearchedInDir2 = 1 + const numPathsToPretendToSearchInCustomDirectorySearcher = 10 + const searchResult = { + filePath: foreignFilePath, + matches: [ + { + lineText: 'Hello world', + lineTextOffset: 0, + matchText: 'Hello', + range: [[0, 0], [0, 5]] + } + ] + } + onFakeSearchCreated = fakeSearch => { + fakeSearch.options.didMatch(searchResult) + fakeSearch.options.didSearchPaths(numPathsToPretendToSearchInCustomDirectorySearcher) + fakeSearch.hoistedResolve() + } + + const resultPaths = [] + const onPathsSearched = jasmine.createSpy('onPathsSearched') + waitsForPromise(() => + atom.workspace.scan(/aaaa/, {onPathsSearched}, ({filePath}) => resultPaths.push(filePath)) + ) + + runs(() => { + expect(resultPaths.sort()).toEqual([foreignFilePath, file2].sort()) + // onPathsSearched should be called once by each DirectorySearcher. The order is not + // guaranteed, so we can only verify the total number of paths searched is correct + // after the second call. + expect(onPathsSearched.callCount).toBe(2) + expect(onPathsSearched.mostRecentCall.args[0]).toBe( + numPathsToPretendToSearchInCustomDirectorySearcher + numPathsSearchedInDir2) + }) + }) + + it('can be cancelled when the object returned by scan() has its cancel() method invoked', () => { + const thenable = atom.workspace.scan(/aaaa/, () => {}) + let resultOfPromiseSearch = null + + waitsFor('fakeSearch to be defined', () => fakeSearch != null) + + runs(() => { + expect(fakeSearch.cancelled).toBe(undefined) + thenable.cancel() + expect(fakeSearch.cancelled).toBe(true) + }) + + waitsForPromise(() => thenable.then(promiseResult => { resultOfPromiseSearch = promiseResult })) + + runs(() => expect(resultOfPromiseSearch).toBe('cancelled')) + }) + + it('will have the side-effect of failing the overall search if it fails', () => { + // This provider's search should be cancelled when the first provider fails + let cancelableSearch + let fakeSearch2 = null + atom.packages.serviceHub.provide('atom.directory-searcher', '0.1.0', { + canSearchDirectory (directory) { return directory.getPath() === dir2 }, + search (directory, regex, options) { + fakeSearch2 = new FakeSearch(options) + return fakeSearch2 + } + }) + + let didReject = false + const promise = cancelableSearch = atom.workspace.scan(/aaaa/, () => {}) + waitsFor('fakeSearch to be defined', () => fakeSearch != null) + + runs(() => fakeSearch.hoistedReject()) + + waitsForPromise(() => cancelableSearch.catch(() => { didReject = true })) + + waitsFor(done => promise.then(null, done)) + + runs(() => { + expect(didReject).toBe(true) + expect(fakeSearch2.cancelled).toBe(true) + }) + }) + }) + }) + }) + }) // Cancels other ongoing searches + + describe('::replace(regex, replacementText, paths, iterator)', () => { + let filePath + let commentFilePath + let sampleContent + let sampleCommentContent + + beforeEach(() => { + atom.project.setPaths([atom.project.getDirectories()[0].resolve('../')]) + + filePath = atom.project.getDirectories()[0].resolve('sample.js') + commentFilePath = atom.project.getDirectories()[0].resolve('sample-with-comments.js') + sampleContent = fs.readFileSync(filePath).toString() + sampleCommentContent = fs.readFileSync(commentFilePath).toString() + }) + + afterEach(() => { + fs.writeFileSync(filePath, sampleContent) + fs.writeFileSync(commentFilePath, sampleCommentContent) + }) + + describe("when a file doesn't exist", () => { + it('calls back with an error', () => { + const errors = [] + const missingPath = path.resolve('/not-a-file.js') + expect(fs.existsSync(missingPath)).toBeFalsy() + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [missingPath], (result, error) => errors.push(error)) + ) + + runs(() => { + expect(errors).toHaveLength(1) + expect(errors[0].path).toBe(missingPath) + }) + }) + }) + + describe('when called with unopened files', () => { + it('replaces properly', () => { + const results = [] + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [filePath], result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(1) + expect(results[0].filePath).toBe(filePath) + expect(results[0].replacements).toBe(6) + }) + }) + }) + + describe('when a buffer is already open', () => { + it('replaces properly and saves when not modified', () => { + let editor = null + const results = [] + + waitsForPromise(() => atom.workspace.open('sample.js').then(o => { editor = o })) + + runs(() => expect(editor.isModified()).toBeFalsy()) + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [filePath], result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(1) + expect(results[0].filePath).toBe(filePath) + expect(results[0].replacements).toBe(6) + + expect(editor.isModified()).toBeFalsy() + }) + }) + + it('does not replace when the path is not specified', () => { + const results = [] + + waitsForPromise(() => atom.workspace.open('sample-with-comments.js')) + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'items', [commentFilePath], result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(1) + expect(results[0].filePath).toBe(commentFilePath) + }) + }) + + it('does NOT save when modified', () => { + let editor = null + const results = [] + + waitsForPromise(() => atom.workspace.open('sample.js').then(o => { editor = o })) + + runs(() => { + editor.buffer.setTextInRange([[0, 0], [0, 0]], 'omg') + expect(editor.isModified()).toBeTruthy() + }) + + waitsForPromise(() => + atom.workspace.replace(/items/gi, 'okthen', [filePath], result => results.push(result)) + ) + + runs(() => { + expect(results).toHaveLength(1) + expect(results[0].filePath).toBe(filePath) + expect(results[0].replacements).toBe(6) + + expect(editor.isModified()).toBeTruthy() + }) + }) + }) + }) + + describe('::saveActivePaneItem()', () => { + let editor = null + beforeEach(() => + waitsForPromise(() => atom.workspace.open('sample.js').then(o => { editor = o })) + ) + + describe('when there is an error', () => { + it('emits a warning notification when the file cannot be saved', () => { + let addedSpy + spyOn(editor, 'save').andCallFake(() => { + throw new Error("'/some/file' is a directory") + }) + + atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) + atom.workspace.saveActivePaneItem() + expect(addedSpy).toHaveBeenCalled() + expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') + }) + + it('emits a warning notification when the directory cannot be written to', () => { + let addedSpy + spyOn(editor, 'save').andCallFake(() => { + throw new Error("ENOTDIR, not a directory '/Some/dir/and-a-file.js'") + }) + + atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) + atom.workspace.saveActivePaneItem() + expect(addedSpy).toHaveBeenCalled() + expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') + }) + + it('emits a warning notification when the user does not have permission', () => { + let addedSpy + spyOn(editor, 'save').andCallFake(() => { + const error = new Error("EACCES, permission denied '/Some/dir/and-a-file.js'") + error.code = 'EACCES' + error.path = '/Some/dir/and-a-file.js' + throw error + }) + + atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) + atom.workspace.saveActivePaneItem() + expect(addedSpy).toHaveBeenCalled() + expect(addedSpy.mostRecentCall.args[0].getType()).toBe('warning') + }) + + it('emits a warning notification when the operation is not permitted', () => { + spyOn(editor, 'save').andCallFake(() => { + const error = new Error("EPERM, operation not permitted '/Some/dir/and-a-file.js'") + error.code = 'EPERM' + error.path = '/Some/dir/and-a-file.js' + throw error + }) + }) + + it('emits a warning notification when the file is already open by another app', () => { + let addedSpy + spyOn(editor, 'save').andCallFake(() => { + const error = new Error("EBUSY, resource busy or locked '/Some/dir/and-a-file.js'") + error.code = 'EBUSY' + error.path = '/Some/dir/and-a-file.js' + throw error + }) + + atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) + atom.workspace.saveActivePaneItem() + expect(addedSpy).toHaveBeenCalled() + + const notificaiton = addedSpy.mostRecentCall.args[0] + expect(notificaiton.getType()).toBe('warning') + expect(notificaiton.getMessage()).toContain('Unable to save') + }) + + it('emits a warning notification when the file system is read-only', () => { + let addedSpy + spyOn(editor, 'save').andCallFake(() => { + const error = new Error("EROFS, read-only file system '/Some/dir/and-a-file.js'") + error.code = 'EROFS' + error.path = '/Some/dir/and-a-file.js' + throw error + }) + + atom.notifications.onDidAddNotification(addedSpy = jasmine.createSpy()) + atom.workspace.saveActivePaneItem() + expect(addedSpy).toHaveBeenCalled() + + const notification = addedSpy.mostRecentCall.args[0] + expect(notification.getType()).toBe('warning') + expect(notification.getMessage()).toContain('Unable to save') + }) + + it('emits a warning notification when the file cannot be saved', () => { + spyOn(editor, 'save').andCallFake(() => { + throw new Error('no one knows') + }) + + const save = () => atom.workspace.saveActivePaneItem() + expect(save).toThrow() + }) + }) + }) + + describe('::closeActivePaneItemOrEmptyPaneOrWindow', () => { + beforeEach(() => { + spyOn(atom, 'close') + waitsForPromise(() => atom.workspace.open()) + }) + + it('closes the active pane item, or the active pane if it is empty, or the current window if there is only the empty root pane', () => { + atom.config.set('core.destroyEmptyPanes', false) + + const pane1 = atom.workspace.getActivePane() + const pane2 = pane1.splitRight({copyActiveItem: true}) + + expect(atom.workspace.getPanes().length).toBe(2) + expect(pane2.getItems().length).toBe(1) + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + + expect(atom.workspace.getPanes().length).toBe(2) + expect(pane2.getItems().length).toBe(0) + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + + expect(atom.workspace.getPanes().length).toBe(1) + expect(pane1.getItems().length).toBe(1) + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + expect(atom.workspace.getPanes().length).toBe(1) + expect(pane1.getItems().length).toBe(0) + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + expect(atom.workspace.getPanes().length).toBe(1) + + atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() + expect(atom.close).toHaveBeenCalled() + }) + }) + + describe('when the core.allowPendingPaneItems option is falsey', () => { + it('does not open item with `pending: true` option as pending', () => { + let pane = null + atom.config.set('core.allowPendingPaneItems', false) + + waitsForPromise(() => + atom.workspace.open('sample.js', {pending: true}).then(() => { + pane = atom.workspace.getActivePane() + }) + ) + + runs(() => expect(pane.getPendingItem()).toBeFalsy()) + }) + }) + + describe('grammar activation', () => { + it('notifies the workspace of which grammar is used', () => { + atom.packages.triggerDeferredActivationHooks() + + const javascriptGrammarUsed = jasmine.createSpy('js grammar used') + const rubyGrammarUsed = jasmine.createSpy('ruby grammar used') + const cGrammarUsed = jasmine.createSpy('c grammar used') + + atom.packages.onDidTriggerActivationHook('language-javascript:grammar-used', javascriptGrammarUsed) + atom.packages.onDidTriggerActivationHook('language-ruby:grammar-used', rubyGrammarUsed) + atom.packages.onDidTriggerActivationHook('language-c:grammar-used', cGrammarUsed) + + waitsForPromise(() => atom.packages.activatePackage('language-ruby')) + waitsForPromise(() => atom.packages.activatePackage('language-javascript')) + waitsForPromise(() => atom.packages.activatePackage('language-c')) + waitsForPromise(() => atom.workspace.open('sample-with-comments.js')) + + runs(() => { + // Hooks are triggered when opening new editors + expect(javascriptGrammarUsed).toHaveBeenCalled() + + // Hooks are triggered when changing existing editors grammars + atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.c')) + expect(cGrammarUsed).toHaveBeenCalled() + + // Hooks are triggered when editors are added in other ways. + atom.workspace.getActivePane().splitRight({copyActiveItem: true}) + atom.workspace.getActiveTextEditor().setGrammar(atom.grammars.grammarForScopeName('source.ruby')) + expect(rubyGrammarUsed).toHaveBeenCalled() + }) + }) + }) + + describe('.checkoutHeadRevision()', () => { + let editor = null + beforeEach(() => { + atom.config.set('editor.confirmCheckoutHeadRevision', false) + + waitsForPromise(() => atom.workspace.open('sample-with-comments.js').then(o => { editor = o })) + }) + + it('reverts to the version of its file checked into the project repository', () => { + editor.setCursorBufferPosition([0, 0]) + editor.insertText('---\n') + expect(editor.lineTextForBufferRow(0)).toBe('---') + + waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + + runs(() => expect(editor.lineTextForBufferRow(0)).toBe('')) + }) + + describe("when there's no repository for the editor's file", () => { + it("doesn't do anything", () => { + editor = new TextEditor() + editor.setText('stuff') + atom.workspace.checkoutHeadRevision(editor) + + waitsForPromise(() => atom.workspace.checkoutHeadRevision(editor)) + }) + }) + }) +}) + +const escapeStringRegex = str => str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') diff --git a/src/application-delegate.coffee b/src/application-delegate.coffee index 766ba7aa8..b247fe7c2 100644 --- a/src/application-delegate.coffee +++ b/src/application-delegate.coffee @@ -1,5 +1,5 @@ _ = require 'underscore-plus' -{screen, ipcRenderer, remote, shell, webFrame} = require 'electron' +{ipcRenderer, remote, shell} = require 'electron' ipcHelpers = require './ipc-helpers' {Disposable} = require 'event-kit' getWindowLoadSettings = require './get-window-load-settings' @@ -80,6 +80,12 @@ class ApplicationDelegate setWindowFullScreen: (fullScreen=false) -> ipcHelpers.call('window-method', 'setFullScreen', fullScreen) + onDidEnterFullScreen: (callback) -> + ipcHelpers.on(ipcRenderer, 'did-enter-full-screen', callback) + + onDidLeaveFullScreen: (callback) -> + ipcHelpers.on(ipcRenderer, 'did-leave-full-screen', callback) + openWindowDevTools: -> # Defer DevTools interaction to the next tick, because using them during # event handling causes some wrong input events to be triggered on @@ -254,20 +260,6 @@ class ApplicationDelegate openExternal: (url) -> shell.openExternal(url) - disableZoom: -> - outerCallback = -> - webFrame.setZoomLevelLimits(1, 1) - - outerCallback() - # Set the limits every time a display is added or removed, otherwise the - # configuration gets reset to the default, which allows zooming the - # webframe. - screen.on('display-added', outerCallback) - screen.on('display-removed', outerCallback) - new Disposable -> - screen.removeListener('display-added', outerCallback) - screen.removeListener('display-removed', outerCallback) - checkForUpdate: -> ipcRenderer.send('command', 'application:check-for-update') diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index 2dfa736a2..b642cbe5f 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -215,8 +215,6 @@ class AtomEnvironment extends Model @stylesElement = @styles.buildStylesElement() @document.head.appendChild(@stylesElement) - @disposables.add(@applicationDelegate.disableZoom()) - @keymaps.subscribeToFileReadFailure() @keymaps.loadBundledKeymaps() @@ -231,14 +229,12 @@ class AtomEnvironment extends Model @observeAutoHideMenuBar() - @history = new HistoryManager({@project, @commands, localStorage}) + @history = new HistoryManager({@project, @commands, @stateStore}) # Keep instances of HistoryManager in sync - @history.onDidChangeProjects (e) => + @disposables.add @history.onDidChangeProjects (e) => @applicationDelegate.didChangeHistoryManager() unless e.reloaded @disposables.add @applicationDelegate.onDidChangeHistoryManager(=> @history.loadState()) - new ReopenProjectMenuManager({@menu, @commands, @history, @config, open: (paths) => @open(pathsToOpen: paths)}) - attachSaveStateListeners: -> saveState = _.debounce((=> window.requestIdleCallback => @saveState({isUnloading: false}) unless @unloaded @@ -716,7 +712,14 @@ class AtomEnvironment extends Model @openInitialEmptyEditorIfNecessary() - Promise.all([loadStatePromise, updateProcessEnvPromise]) + loadHistoryPromise = @history.loadState().then => + @reopenProjectMenuManager = new ReopenProjectMenuManager({ + @menu, @commands, @history, @config, + open: (paths) => @open(pathsToOpen: paths) + }) + @reopenProjectMenuManager.update() + + Promise.all([loadStatePromise, loadHistoryPromise, updateProcessEnvPromise]) serialize: (options) -> version: @constructor.version @@ -844,6 +847,8 @@ class AtomEnvironment extends Model error.metadata = callbackOrMetadata @emitter.emit 'did-fail-assertion', error + unless @isReleasedVersion() + throw error false diff --git a/src/config.coffee b/src/config.coffee index e873a1348..e7d05da6d 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -839,7 +839,7 @@ class Config relativePath = sourcePath.substring(templateConfigDirPath.length + 1) destinationPath = path.join(@configDirPath, relativePath) queue.push({sourcePath, destinationPath}) - fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true) + fs.traverseTree(templateConfigDirPath, onConfigDirFile, ((path) -> true), (->)) loadUserConfig: -> return if @shouldNotAccessFileSystem() diff --git a/src/get-window-load-settings.js b/src/get-window-load-settings.js index 7ee465141..d35b24213 100644 --- a/src/get-window-load-settings.js +++ b/src/get-window-load-settings.js @@ -4,7 +4,7 @@ let windowLoadSettings = null module.exports = () => { if (!windowLoadSettings) { - windowLoadSettings = remote.getCurrentWindow().loadSettings + windowLoadSettings = JSON.parse(remote.getCurrentWindow().loadSettingsJSON) } return windowLoadSettings } diff --git a/src/grammar-registry.coffee b/src/grammar-registry.coffee index 899bb4cff..a2341c967 100644 --- a/src/grammar-registry.coffee +++ b/src/grammar-registry.coffee @@ -15,7 +15,7 @@ PathSplitRegex = new RegExp("[/.]") module.exports = class GrammarRegistry extends FirstMate.GrammarRegistry constructor: ({@config}={}) -> - super(maxTokensPerLine: 100) + super(maxTokensPerLine: 100, maxLineLength: 1000) createToken: (value, scopes) -> new Token({value, scopes}) diff --git a/src/history-manager.js b/src/history-manager.js index f013957b9..5087c3bf9 100644 --- a/src/history-manager.js +++ b/src/history-manager.js @@ -1,6 +1,6 @@ /** @babel */ -import {Emitter} from 'event-kit' +import {Emitter, CompositeDisposable} from 'event-kit' // Extended: History manager for remembering which projects have been opened. // @@ -8,12 +8,17 @@ import {Emitter} from 'event-kit' // // The project history is used to enable the 'Reopen Project' menu. export class HistoryManager { - constructor ({project, commands, localStorage}) { - this.localStorage = localStorage - commands.add('atom-workspace', {'application:clear-project-history': this.clearProjects.bind(this)}) + constructor ({stateStore, project, commands}) { + this.stateStore = stateStore this.emitter = new Emitter() - this.loadState() - project.onDidChangePaths((projectPaths) => this.addProject(projectPaths)) + this.projects = [] + this.disposables = new CompositeDisposable() + this.disposables.add(commands.add('atom-workspace', {'application:clear-project-history': this.clearProjects.bind(this)})) + this.disposables.add(project.onDidChangePaths((projectPaths) => this.addProject(projectPaths))) + } + + destroy () { + this.disposables.dispose() } // Public: Obtain a list of previously opened projects. @@ -27,9 +32,12 @@ export class HistoryManager { // // Note: This is not a privacy function - other traces will still exist, // e.g. window state. - clearProjects () { + // + // Return a {Promise} that resolves when the history has been successfully + // cleared. + async clearProjects () { this.projects = [] - this.saveState() + await this.saveState() this.didChangeProjects() } @@ -46,7 +54,7 @@ export class HistoryManager { this.emitter.emit('did-change-projects', args || { reloaded: false }) } - addProject (paths, lastOpened) { + async addProject (paths, lastOpened) { if (paths.length === 0) return let project = this.getProject(paths) @@ -57,11 +65,11 @@ export class HistoryManager { project.lastOpened = lastOpened || new Date() this.projects.sort((a, b) => b.lastOpened - a.lastOpened) - this.saveState() + await this.saveState() this.didChangeProjects() } - removeProject (paths) { + async removeProject (paths) { if (paths.length === 0) return let project = this.getProject(paths) @@ -70,7 +78,7 @@ export class HistoryManager { let index = this.projects.indexOf(project) this.projects.splice(index, 1) - this.saveState() + await this.saveState() this.didChangeProjects() } @@ -84,31 +92,25 @@ export class HistoryManager { return null } - loadState () { - const state = JSON.parse(this.localStorage.getItem('history')) - if (state && state.projects) { - this.projects = state.projects.filter(p => Array.isArray(p.paths) && p.paths.length > 0).map(p => new HistoryProject(p.paths, new Date(p.lastOpened))) - this.didChangeProjects({ reloaded: true }) + async loadState () { + const history = await this.stateStore.load('history-manager') + if (history && history.projects) { + this.projects = history.projects.filter(p => Array.isArray(p.paths) && p.paths.length > 0).map(p => new HistoryProject(p.paths, new Date(p.lastOpened))) + this.didChangeProjects({reloaded: true}) } else { this.projects = [] } } - saveState () { - const state = JSON.stringify({ - projects: this.projects.map(p => ({ - paths: p.paths, lastOpened: p.lastOpened - })) - }) - this.localStorage.setItem('history', state) + async saveState () { + const projects = this.projects.map(p => ({paths: p.paths, lastOpened: p.lastOpened})) + await this.stateStore.save('history-manager', {projects}) } async importProjectHistory () { for (let project of await HistoryImporter.getAllProjects()) { - this.addProject(project.paths, project.lastOpened) + await this.addProject(project.paths, project.lastOpened) } - this.saveState() - this.didChangeProjects() } } diff --git a/src/main-process/atom-application.coffee b/src/main-process/atom-application.coffee index 93e9e3395..295343100 100644 --- a/src/main-process/atom-application.coffee +++ b/src/main-process/atom-application.coffee @@ -6,8 +6,8 @@ StorageFolder = require '../storage-folder' Config = require '../config' FileRecoveryService = require './file-recovery-service' ipcHelpers = require '../ipc-helpers' -{BrowserWindow, Menu, app, dialog, ipcMain, shell} = require 'electron' -{CompositeDisposable} = require 'event-kit' +{BrowserWindow, Menu, app, dialog, ipcMain, shell, screen} = require 'electron' +{CompositeDisposable, Disposable} = require 'event-kit' fs = require 'fs-plus' path = require 'path' os = require 'os' @@ -89,7 +89,7 @@ class AtomApplication if process.platform is 'darwin' and @config.get('core.useCustomTitleBar') @config.unset('core.useCustomTitleBar') @config.set('core.titleBar', 'custom') - + @config.onDidChange 'core.titleBar', @promptForRestart.bind(this) @autoUpdateManager = new AutoUpdateManager( @@ -394,6 +394,8 @@ class AtomApplication @disposable.add ipcHelpers.on ipcMain, 'did-change-paths', => @saveState(false) + @disposable.add(@disableZoomOnDisplayChange()) + setupDockMenu: -> if process.platform is 'darwin' dockMenu = Menu.buildFromTemplate [ @@ -812,3 +814,17 @@ class AtomApplication args.push("--resource-path=#{@resourcePath}") app.relaunch({args}) app.quit() + + disableZoomOnDisplayChange: -> + outerCallback = => + for window in @windows + window.disableZoom() + + # Set the limits every time a display is added or removed, otherwise the + # configuration gets reset to the default, which allows zooming the + # webframe. + screen.on('display-added', outerCallback) + screen.on('display-removed', outerCallback) + new Disposable -> + screen.removeListener('display-added', outerCallback) + screen.removeListener('display-removed', outerCallback) diff --git a/src/main-process/atom-window.coffee b/src/main-process/atom-window.coffee index 03386d31a..bbc235bc5 100644 --- a/src/main-process/atom-window.coffee +++ b/src/main-process/atom-window.coffee @@ -83,12 +83,18 @@ class AtomWindow @representedDirectoryPaths = loadSettings.initialPaths @env = loadSettings.env if loadSettings.env? - @browserWindow.loadSettings = loadSettings + @browserWindow.loadSettingsJSON = JSON.stringify(loadSettings) @browserWindow.on 'window:loaded', => @emit 'window:loaded' @resolveLoadedPromise() + @browserWindow.on 'enter-full-screen', => + @browserWindow.webContents.send('did-enter-full-screen') + + @browserWindow.on 'leave-full-screen', => + @browserWindow.webContents.send('did-leave-full-screen') + @browserWindow.loadURL url.format protocol: 'file' pathname: "#{@resourcePath}/static/index.html" @@ -101,6 +107,7 @@ class AtomWindow hasPathToOpen = not (locationsToOpen.length is 1 and not locationsToOpen[0].pathToOpen?) @openLocations(locationsToOpen) if hasPathToOpen and not @isSpecWindow() + @disableZoom() @atomApplication.addWindow(this) @@ -303,3 +310,6 @@ class AtomWindow @atomApplication.saveState() copy: -> @browserWindow.copy() + + disableZoom: -> + @browserWindow.webContents.setZoomLevelLimits(1, 1) diff --git a/src/panel-container.coffee b/src/panel-container.coffee index 322773f69..d109210a7 100644 --- a/src/panel-container.coffee +++ b/src/panel-container.coffee @@ -34,7 +34,7 @@ class PanelContainer isModal: -> @location is 'modal' - getPanels: -> @panels + getPanels: -> @panels.slice() addPanel: (panel) -> @subscriptions.add panel.onDidDestroy(@panelDestroyed.bind(this)) diff --git a/src/reopen-project-menu-manager.js b/src/reopen-project-menu-manager.js index 79acbba66..3f88e41f0 100644 --- a/src/reopen-project-menu-manager.js +++ b/src/reopen-project-menu-manager.js @@ -58,7 +58,7 @@ export default class ReopenProjectMenuManager { // Windows users can right-click Atom taskbar and remove project from the jump list. // We have to honor that or the group stops working. As we only get a partial list // each time we remove them from history entirely. - applyWindowsJumpListRemovals () { + async applyWindowsJumpListRemovals () { if (process.platform !== 'win32') return if (this.app === undefined) { this.app = require('remote').app @@ -68,7 +68,7 @@ export default class ReopenProjectMenuManager { if (removed.length === 0) return for (let project of this.historyManager.getProjects()) { if (removed.includes(ReopenProjectMenuManager.taskDescription(project.paths))) { - this.historyManager.removeProject(project.paths) + await this.historyManager.removeProject(project.paths) } } } diff --git a/src/text-editor.coffee b/src/text-editor.coffee index 3cdd363a7..8095632fd 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -192,6 +192,9 @@ class TextEditor extends Model @displayLayer.setTextDecorationLayer(@tokenizedBuffer) @defaultMarkerLayer = @displayLayer.addMarkerLayer() + @disposables.add(@defaultMarkerLayer.onDidDestroy => + @assert(false, "defaultMarkerLayer destroyed at an unexpected time") + ) @selectionsMarkerLayer ?= @addMarkerLayer(maintainHistory: true, persistent: true) @selectionsMarkerLayer.trackDestructionInOnDidCreateMarkerCallbacks = true @@ -384,7 +387,7 @@ class TextEditor extends Model softWrapHangingIndentLength: @displayLayer.softWrapHangingIndent @id, @softTabs, @softWrapped, @softWrapAtPreferredLineLength, - @preferredLineLength, @mini, @editorWidthInChars, @width, @largeFileMode, + @preferredLineLength, @mini, @editorWidthInChars, @width, @largeFileMode, @registered, @invisibles, @showInvisibles, @showIndentGuide, @autoHeight, @autoWidth } diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 77221f52e..234f82be9 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -8,8 +8,6 @@ ScopeDescriptor = require './scope-descriptor' TokenizedBufferIterator = require './tokenized-buffer-iterator' NullGrammar = require './null-grammar' -MAX_LINE_LENGTH_TO_TOKENIZE = 500 - module.exports = class TokenizedBuffer extends Model grammar: null @@ -253,8 +251,6 @@ class TokenizedBuffer extends Model buildTokenizedLineForRowWithText: (row, text, ruleStack = @stackForRow(row - 1), openScopes = @openScopesForRow(row)) -> lineEnding = @buffer.lineEndingForRow(row) - if text.length > MAX_LINE_LENGTH_TO_TOKENIZE - text = text.slice(0, MAX_LINE_LENGTH_TO_TOKENIZE) {tags, ruleStack} = @grammar.tokenizeLine(text, ruleStack, row is 0, false) new TokenizedLine({openScopes, text, tags, ruleStack, lineEnding, @tokenIterator}) diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index 62ce4527a..95cd45de9 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -20,14 +20,8 @@ class WindowEventHandler @subscriptions.add listen(@document, 'click', 'a', @handleLinkClick) @subscriptions.add listen(@document, 'submit', 'form', @handleFormSubmit) - browserWindow = @applicationDelegate.getCurrentWindow() - browserWindow.on 'enter-full-screen', @handleEnterFullScreen - @subscriptions.add new Disposable => - browserWindow.removeListener('enter-full-screen', @handleEnterFullScreen) - - browserWindow.on 'leave-full-screen', @handleLeaveFullScreen - @subscriptions.add new Disposable => - browserWindow.removeListener('leave-full-screen', @handleLeaveFullScreen) + @subscriptions.add(@applicationDelegate.onDidEnterFullScreen(@handleEnterFullScreen)) + @subscriptions.add(@applicationDelegate.onDidLeaveFullScreen(@handleLeaveFullScreen)) @subscriptions.add @atomEnvironment.commands.add @window, 'window:toggle-full-screen': @handleWindowToggleFullScreen diff --git a/src/workspace-element.coffee b/src/workspace-element.coffee index f598bef0b..6defe33da 100644 --- a/src/workspace-element.coffee +++ b/src/workspace-element.coffee @@ -44,15 +44,10 @@ class WorkspaceElement extends HTMLElement @subscriptions.add @config.onDidChange 'editor.lineHeight', @updateGlobalTextEditorStyleSheet.bind(this) updateGlobalTextEditorStyleSheet: -> - fontFamily = @config.get('editor.fontFamily') - # TODO: There is a bug in how some emojis (e.g. ❤️) are rendered on macOS. - # This workaround should be removed once we update to Chromium 51, where the - # problem was fixed. - fontFamily += ', "Apple Color Emoji"' if process.platform is 'darwin' styleSheetSource = """ atom-text-editor { font-size: #{@config.get('editor.fontSize')}px; - font-family: #{fontFamily}; + font-family: #{@config.get('editor.fontFamily')}; line-height: #{@config.get('editor.lineHeight')}; } """ diff --git a/src/workspace.coffee b/src/workspace.coffee deleted file mode 100644 index 2a46ce57a..000000000 --- a/src/workspace.coffee +++ /dev/null @@ -1,1121 +0,0 @@ -_ = require 'underscore-plus' -url = require 'url' -path = require 'path' -{Emitter, Disposable, CompositeDisposable} = require 'event-kit' -fs = require 'fs-plus' -{Directory} = require 'pathwatcher' -DefaultDirectorySearcher = require './default-directory-searcher' -Model = require './model' -TextEditor = require './text-editor' -PaneContainer = require './pane-container' -Panel = require './panel' -PanelContainer = require './panel-container' -Task = require './task' - -# Essential: Represents the state of the user interface for the entire window. -# An instance of this class is available via the `atom.workspace` global. -# -# Interact with this object to open files, be notified of current and future -# editors, and manipulate panes. To add panels, use {Workspace::addTopPanel} -# and friends. -# -# * `editor` {TextEditor} the new editor -# -module.exports = -class Workspace extends Model - constructor: (params) -> - super - - { - @packageManager, @config, @project, @grammarRegistry, @notificationManager, - @viewRegistry, @grammarRegistry, @applicationDelegate, @assert, - @deserializerManager, @textEditorRegistry - } = params - - @emitter = new Emitter - @openers = [] - @destroyedItemURIs = [] - - @paneContainer = new PaneContainer({@config, @applicationDelegate, @notificationManager, @deserializerManager}) - @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) - - @defaultDirectorySearcher = new DefaultDirectorySearcher() - @consumeServices(@packageManager) - - # One cannot simply .bind here since it could be used as a component with - # Etch, in which case it'd be `new`d. And when it's `new`d, `this` is always - # the newly created object. - realThis = this - @buildTextEditor = -> Workspace.prototype.buildTextEditor.apply(realThis, arguments) - - @panelContainers = - top: new PanelContainer({location: 'top'}) - left: new PanelContainer({location: 'left'}) - right: new PanelContainer({location: 'right'}) - bottom: new PanelContainer({location: 'bottom'}) - header: new PanelContainer({location: 'header'}) - footer: new PanelContainer({location: 'footer'}) - modal: new PanelContainer({location: 'modal'}) - - @subscribeToEvents() - - reset: (@packageManager) -> - @emitter.dispose() - @emitter = new Emitter - - @paneContainer.destroy() - panelContainer.destroy() for panelContainer in @panelContainers - - @paneContainer = new PaneContainer({@config, @applicationDelegate, @notificationManager, @deserializerManager}) - @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) - - @panelContainers = - top: new PanelContainer({location: 'top'}) - left: new PanelContainer({location: 'left'}) - right: new PanelContainer({location: 'right'}) - bottom: new PanelContainer({location: 'bottom'}) - header: new PanelContainer({location: 'header'}) - footer: new PanelContainer({location: 'footer'}) - modal: new PanelContainer({location: 'modal'}) - - @originalFontSize = null - @openers = [] - @destroyedItemURIs = [] - @consumeServices(@packageManager) - - subscribeToEvents: -> - @subscribeToActiveItem() - @subscribeToFontSize() - @subscribeToAddedItems() - - consumeServices: ({serviceHub}) -> - @directorySearchers = [] - serviceHub.consume( - 'atom.directory-searcher', - '^0.1.0', - (provider) => @directorySearchers.unshift(provider)) - - # Called by the Serializable mixin during serialization. - serialize: -> - deserializer: 'Workspace' - paneContainer: @paneContainer.serialize() - packagesWithActiveGrammars: @getPackageNamesWithActiveGrammars() - destroyedItemURIs: @destroyedItemURIs.slice() - - deserialize: (state, deserializerManager) -> - for packageName in state.packagesWithActiveGrammars ? [] - @packageManager.getLoadedPackage(packageName)?.loadGrammarsSync() - if state.destroyedItemURIs? - @destroyedItemURIs = state.destroyedItemURIs - @paneContainer.deserialize(state.paneContainer, deserializerManager) - - getPackageNamesWithActiveGrammars: -> - packageNames = [] - addGrammar = ({includedGrammarScopes, packageName}={}) => - return unless packageName - # Prevent cycles - return if packageNames.indexOf(packageName) isnt -1 - - packageNames.push(packageName) - for scopeName in includedGrammarScopes ? [] - addGrammar(@grammarRegistry.grammarForScopeName(scopeName)) - return - - editors = @getTextEditors() - addGrammar(editor.getGrammar()) for editor in editors - - if editors.length > 0 - for grammar in @grammarRegistry.getGrammars() when grammar.injectionSelector - addGrammar(grammar) - - _.uniq(packageNames) - - subscribeToActiveItem: -> - @updateWindowTitle() - @updateDocumentEdited() - @project.onDidChangePaths @updateWindowTitle - - @observeActivePaneItem (item) => - @updateWindowTitle() - @updateDocumentEdited() - - @activeItemSubscriptions?.dispose() - @activeItemSubscriptions = new CompositeDisposable - - if typeof item?.onDidChangeTitle is 'function' - titleSubscription = item.onDidChangeTitle(@updateWindowTitle) - else if typeof item?.on is 'function' - titleSubscription = item.on('title-changed', @updateWindowTitle) - unless typeof titleSubscription?.dispose is 'function' - titleSubscription = new Disposable => item.off('title-changed', @updateWindowTitle) - - if typeof item?.onDidChangeModified is 'function' - modifiedSubscription = item.onDidChangeModified(@updateDocumentEdited) - else if typeof item?.on? is 'function' - modifiedSubscription = item.on('modified-status-changed', @updateDocumentEdited) - unless typeof modifiedSubscription?.dispose is 'function' - modifiedSubscription = new Disposable => item.off('modified-status-changed', @updateDocumentEdited) - - @activeItemSubscriptions.add(titleSubscription) if titleSubscription? - @activeItemSubscriptions.add(modifiedSubscription) if modifiedSubscription? - - subscribeToAddedItems: -> - @onDidAddPaneItem ({item, pane, index}) => - if item instanceof TextEditor - subscriptions = new CompositeDisposable( - @textEditorRegistry.add(item) - @textEditorRegistry.maintainGrammar(item) - @textEditorRegistry.maintainConfig(item) - item.observeGrammar(@handleGrammarUsed.bind(this)) - ) - item.onDidDestroy -> subscriptions.dispose() - @emitter.emit 'did-add-text-editor', {textEditor: item, pane, index} - - # Updates the application's title and proxy icon based on whichever file is - # open. - updateWindowTitle: => - appName = 'Atom' - projectPaths = @project.getPaths() ? [] - if item = @getActivePaneItem() - itemPath = item.getPath?() - itemTitle = item.getLongTitle?() ? item.getTitle?() - projectPath = _.find projectPaths, (projectPath) -> - itemPath is projectPath or itemPath?.startsWith(projectPath + path.sep) - itemTitle ?= "untitled" - projectPath ?= if itemPath then path.dirname(itemPath) else projectPaths[0] - if projectPath? - projectPath = fs.tildify(projectPath) - - titleParts = [] - if item? and projectPath? - titleParts.push itemTitle, projectPath - representedPath = itemPath ? projectPath - else if projectPath? - titleParts.push projectPath - representedPath = projectPath - else - titleParts.push itemTitle - representedPath = "" - - unless process.platform is 'darwin' - titleParts.push appName - - document.title = titleParts.join(" \u2014 ") - @applicationDelegate.setRepresentedFilename(representedPath) - - # On macOS, fades the application window's proxy icon when the current file - # has been modified. - updateDocumentEdited: => - modified = @getActivePaneItem()?.isModified?() ? false - @applicationDelegate.setWindowDocumentEdited(modified) - - ### - Section: Event Subscription - ### - - # Essential: Invoke the given callback with all current and future text - # editors in the workspace. - # - # * `callback` {Function} to be called with current and future text editors. - # * `editor` An {TextEditor} that is present in {::getTextEditors} at the time - # of subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeTextEditors: (callback) -> - callback(textEditor) for textEditor in @getTextEditors() - @onDidAddTextEditor ({textEditor}) -> callback(textEditor) - - # Essential: Invoke the given callback with all current and future panes items - # in the workspace. - # - # * `callback` {Function} to be called with current and future pane items. - # * `item` An item that is present in {::getPaneItems} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observePaneItems: (callback) -> @paneContainer.observePaneItems(callback) - - # Essential: Invoke the given callback when the active pane item changes. - # - # Because observers are invoked synchronously, it's important not to perform - # any expensive operations via this method. Consider - # {::onDidStopChangingActivePaneItem} to delay operations until after changes - # stop occurring. - # - # * `callback` {Function} to be called when the active pane item changes. - # * `item` The active pane item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActivePaneItem: (callback) -> - @paneContainer.onDidChangeActivePaneItem(callback) - - # Essential: Invoke the given callback when the active pane item stops - # changing. - # - # Observers are called asynchronously 100ms after the last active pane item - # change. Handling changes here rather than in the synchronous - # {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly - # changing or closing tabs and ensures critical UI feedback, like changing the - # highlighted tab, gets priority over work that can be done asynchronously. - # - # * `callback` {Function} to be called when the active pane item stopts - # changing. - # * `item` The active pane item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidStopChangingActivePaneItem: (callback) -> - @paneContainer.onDidStopChangingActivePaneItem(callback) - - # Essential: Invoke the given callback with the current active pane item and - # with all future active pane items in the workspace. - # - # * `callback` {Function} to be called when the active pane item changes. - # * `item` The current active pane item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActivePaneItem: (callback) -> @paneContainer.observeActivePaneItem(callback) - - # Essential: Invoke the given callback whenever an item is opened. Unlike - # {::onDidAddPaneItem}, observers will be notified for items that are already - # present in the workspace when they are reopened. - # - # * `callback` {Function} to be called whenever an item is opened. - # * `event` {Object} with the following keys: - # * `uri` {String} representing the opened URI. Could be `undefined`. - # * `item` The opened item. - # * `pane` The pane in which the item was opened. - # * `index` The index of the opened item on its pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidOpen: (callback) -> - @emitter.on 'did-open', callback - - # Extended: Invoke the given callback when a pane is added to the workspace. - # - # * `callback` {Function} to be called panes are added. - # * `event` {Object} with the following keys: - # * `pane` The added pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddPane: (callback) -> @paneContainer.onDidAddPane(callback) - - # Extended: Invoke the given callback before a pane is destroyed in the - # workspace. - # - # * `callback` {Function} to be called before panes are destroyed. - # * `event` {Object} with the following keys: - # * `pane` The pane to be destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillDestroyPane: (callback) -> @paneContainer.onWillDestroyPane(callback) - - # Extended: Invoke the given callback when a pane is destroyed in the - # workspace. - # - # * `callback` {Function} to be called panes are destroyed. - # * `event` {Object} with the following keys: - # * `pane` The destroyed pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroyPane: (callback) -> @paneContainer.onDidDestroyPane(callback) - - # Extended: Invoke the given callback with all current and future panes in the - # workspace. - # - # * `callback` {Function} to be called with current and future panes. - # * `pane` A {Pane} that is present in {::getPanes} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observePanes: (callback) -> @paneContainer.observePanes(callback) - - # Extended: Invoke the given callback when the active pane changes. - # - # * `callback` {Function} to be called when the active pane changes. - # * `pane` A {Pane} that is the current return value of {::getActivePane}. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActivePane: (callback) -> @paneContainer.onDidChangeActivePane(callback) - - # Extended: Invoke the given callback with the current active pane and when - # the active pane changes. - # - # * `callback` {Function} to be called with the current and future active# - # panes. - # * `pane` A {Pane} that is the current return value of {::getActivePane}. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActivePane: (callback) -> @paneContainer.observeActivePane(callback) - - # Extended: Invoke the given callback when a pane item is added to the - # workspace. - # - # * `callback` {Function} to be called when pane items are added. - # * `event` {Object} with the following keys: - # * `item` The added pane item. - # * `pane` {Pane} containing the added item. - # * `index` {Number} indicating the index of the added item in its pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddPaneItem: (callback) -> @paneContainer.onDidAddPaneItem(callback) - - # Extended: Invoke the given callback when a pane item is about to be - # destroyed, before the user is prompted to save it. - # - # * `callback` {Function} to be called before pane items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The item to be destroyed. - # * `pane` {Pane} containing the item to be destroyed. - # * `index` {Number} indicating the index of the item to be destroyed in - # its pane. - # - # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onWillDestroyPaneItem: (callback) -> @paneContainer.onWillDestroyPaneItem(callback) - - # Extended: Invoke the given callback when a pane item is destroyed. - # - # * `callback` {Function} to be called when pane items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The destroyed item. - # * `pane` {Pane} containing the destroyed item. - # * `index` {Number} indicating the index of the destroyed item in its - # pane. - # - # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onDidDestroyPaneItem: (callback) -> @paneContainer.onDidDestroyPaneItem(callback) - - # Extended: Invoke the given callback when a text editor is added to the - # workspace. - # - # * `callback` {Function} to be called panes are added. - # * `event` {Object} with the following keys: - # * `textEditor` {TextEditor} that was added. - # * `pane` {Pane} containing the added text editor. - # * `index` {Number} indicating the index of the added text editor in its - # pane. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddTextEditor: (callback) -> - @emitter.on 'did-add-text-editor', callback - - ### - Section: Opening - ### - - # Essential: Opens the given URI in Atom asynchronously. - # If the URI is already open, the existing item for that URI will be - # activated. If no URI is given, or no registered opener can open - # the URI, a new empty {TextEditor} will be created. - # - # * `uri` (optional) A {String} containing a URI. - # * `options` (optional) {Object} - # * `initialLine` A {Number} indicating which row to move the cursor to - # initially. Defaults to `0`. - # * `initialColumn` A {Number} indicating which column to move the cursor to - # initially. Defaults to `0`. - # * `split` Either 'left', 'right', 'up' or 'down'. - # If 'left', the item will be opened in leftmost pane of the current active pane's row. - # If 'right', the item will be opened in the rightmost pane of the current active pane's row. If only one pane exists in the row, a new pane will be created. - # If 'up', the item will be opened in topmost pane of the current active pane's column. - # If 'down', the item will be opened in the bottommost pane of the current active pane's column. If only one pane exists in the column, a new pane will be created. - # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on - # containing pane. Defaults to `true`. - # * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} - # on containing pane. Defaults to `true`. - # * `pending` A {Boolean} indicating whether or not the item should be opened - # in a pending state. Existing pending items in a pane are replaced with - # new pending items when they are opened. - # * `searchAllPanes` A {Boolean}. If `true`, the workspace will attempt to - # activate an existing item for the given URI on any pane. - # If `false`, only the active pane will be searched for - # an existing item for the same URI. Defaults to `false`. - # - # Returns a {Promise} that resolves to the {TextEditor} for the file URI. - open: (uri, options={}) -> - searchAllPanes = options.searchAllPanes - split = options.split - uri = @project.resolvePath(uri) - - if not atom.config.get('core.allowPendingPaneItems') - options.pending = false - - # Avoid adding URLs as recent documents to work-around this Spotlight crash: - # https://github.com/atom/atom/issues/10071 - if uri? and (not url.parse(uri).protocol? or process.platform is 'win32') - @applicationDelegate.addRecentDocument(uri) - - pane = @paneContainer.paneForURI(uri) if searchAllPanes - pane ?= switch split - when 'left' - @getActivePane().findLeftmostSibling() - when 'right' - @getActivePane().findOrCreateRightmostSibling() - when 'up' - @getActivePane().findTopmostSibling() - when 'down' - @getActivePane().findOrCreateBottommostSibling() - else - @getActivePane() - - @openURIInPane(uri, pane, options) - - # Open Atom's license in the active pane. - openLicense: -> - @open(path.join(process.resourcesPath, 'LICENSE.md')) - - # Synchronously open the given URI in the active pane. **Only use this method - # in specs. Calling this in production code will block the UI thread and - # everyone will be mad at you.** - # - # * `uri` A {String} containing a URI. - # * `options` An optional options {Object} - # * `initialLine` A {Number} indicating which row to move the cursor to - # initially. Defaults to `0`. - # * `initialColumn` A {Number} indicating which column to move the cursor to - # initially. Defaults to `0`. - # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on - # the containing pane. Defaults to `true`. - # * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} - # on containing pane. Defaults to `true`. - openSync: (uri='', options={}) -> - {initialLine, initialColumn} = options - activatePane = options.activatePane ? true - activateItem = options.activateItem ? true - - uri = @project.resolvePath(uri) - item = @getActivePane().itemForURI(uri) - if uri - item ?= opener(uri, options) for opener in @getOpeners() when not item - item ?= @project.openSync(uri, {initialLine, initialColumn}) - - @getActivePane().activateItem(item) if activateItem - @itemOpened(item) - @getActivePane().activate() if activatePane - item - - openURIInPane: (uri, pane, options={}) -> - activatePane = options.activatePane ? true - activateItem = options.activateItem ? true - - if uri? - if item = pane.itemForURI(uri) - pane.clearPendingItem() if not options.pending and pane.getPendingItem() is item - item ?= opener(uri, options) for opener in @getOpeners() when not item - - try - item ?= @openTextFile(uri, options) - catch error - switch error.code - when 'CANCELLED' - return Promise.resolve() - when 'EACCES' - @notificationManager.addWarning("Permission denied '#{error.path}'") - return Promise.resolve() - when 'EPERM', 'EBUSY', 'ENXIO', 'EIO', 'ENOTCONN', 'UNKNOWN', 'ECONNRESET', 'EINVAL', 'EMFILE', 'ENOTDIR', 'EAGAIN' - @notificationManager.addWarning("Unable to open '#{error.path ? uri}'", detail: error.message) - return Promise.resolve() - else - throw error - - Promise.resolve(item) - .then (item) => - return item if pane.isDestroyed() - - @itemOpened(item) - pane.activateItem(item, {pending: options.pending}) if activateItem - pane.activate() if activatePane - - initialLine = initialColumn = 0 - unless Number.isNaN(options.initialLine) - initialLine = options.initialLine - unless Number.isNaN(options.initialColumn) - initialColumn = options.initialColumn - if initialLine >= 0 or initialColumn >= 0 - item.setCursorBufferPosition?([initialLine, initialColumn]) - - index = pane.getActiveItemIndex() - @emitter.emit 'did-open', {uri, pane, item, index} - item - - openTextFile: (uri, options) -> - filePath = @project.resolvePath(uri) - - if filePath? - try - fs.closeSync(fs.openSync(filePath, 'r')) - catch error - # allow ENOENT errors to create an editor for paths that dont exist - throw error unless error.code is 'ENOENT' - - fileSize = fs.getSizeSync(filePath) - - largeFileMode = fileSize >= 2 * 1048576 # 2MB - if fileSize >= @config.get('core.warnOnLargeFileLimit') * 1048576 # 20MB by default - choice = @applicationDelegate.confirm - message: 'Atom will be unresponsive during the loading of very large files.' - detailedMessage: "Do you still want to load this file?" - buttons: ["Proceed", "Cancel"] - if choice is 1 - error = new Error - error.code = 'CANCELLED' - throw error - - @project.bufferForPath(filePath, options).then (buffer) => - @textEditorRegistry.build(Object.assign({buffer, largeFileMode, autoHeight: false}, options)) - - handleGrammarUsed: (grammar) -> - return unless grammar? - - @packageManager.triggerActivationHook("#{grammar.packageName}:grammar-used") - - # Public: Returns a {Boolean} that is `true` if `object` is a `TextEditor`. - # - # * `object` An {Object} you want to perform the check against. - isTextEditor: (object) -> - object instanceof TextEditor - - # Extended: Create a new text editor. - # - # Returns a {TextEditor}. - buildTextEditor: (params) -> - editor = @textEditorRegistry.build(params) - subscriptions = new CompositeDisposable( - @textEditorRegistry.maintainGrammar(editor) - @textEditorRegistry.maintainConfig(editor), - ) - editor.onDidDestroy -> subscriptions.dispose() - editor - - # Public: Asynchronously reopens the last-closed item's URI if it hasn't already been - # reopened. - # - # Returns a {Promise} that is resolved when the item is opened - reopenItem: -> - if uri = @destroyedItemURIs.pop() - @open(uri) - else - Promise.resolve() - - # Public: Register an opener for a uri. - # - # When a URI is opened via {Workspace::open}, Atom loops through its registered - # opener functions until one returns a value for the given uri. - # Openers are expected to return an object that inherits from HTMLElement or - # a model which has an associated view in the {ViewRegistry}. - # A {TextEditor} will be used if no opener returns a value. - # - # ## Examples - # - # ```coffee - # atom.workspace.addOpener (uri) -> - # if path.extname(uri) is '.toml' - # return new TomlEditor(uri) - # ``` - # - # * `opener` A {Function} to be called when a path is being opened. - # - # Returns a {Disposable} on which `.dispose()` can be called to remove the - # opener. - # - # Note that the opener will be called if and only if the URI is not already open - # in the current pane. The searchAllPanes flag expands the search from the - # current pane to all panes. If you wish to open a view of a different type for - # a file that is already open, consider changing the protocol of the URI. For - # example, perhaps you wish to preview a rendered version of the file `/foo/bar/baz.quux` - # that is already open in a text editor view. You could signal this by calling - # {Workspace::open} on the URI `quux-preview://foo/bar/baz.quux`. Then your opener - # can check the protocol for quux-preview and only handle those URIs that match. - addOpener: (opener) -> - @openers.push(opener) - new Disposable => _.remove(@openers, opener) - - getOpeners: -> - @openers - - ### - Section: Pane Items - ### - - # Essential: Get all pane items in the workspace. - # - # Returns an {Array} of items. - getPaneItems: -> - @paneContainer.getPaneItems() - - # Essential: Get the active {Pane}'s active item. - # - # Returns an pane item {Object}. - getActivePaneItem: -> - @paneContainer.getActivePaneItem() - - # Essential: Get all text editors in the workspace. - # - # Returns an {Array} of {TextEditor}s. - getTextEditors: -> - @getPaneItems().filter (item) -> item instanceof TextEditor - - # Essential: Get the active item if it is an {TextEditor}. - # - # Returns an {TextEditor} or `undefined` if the current active item is not an - # {TextEditor}. - getActiveTextEditor: -> - activeItem = @getActivePaneItem() - activeItem if activeItem instanceof TextEditor - - # Save all pane items. - saveAll: -> - @paneContainer.saveAll() - - confirmClose: (options) -> - @paneContainer.confirmClose(options) - - # Save the active pane item. - # - # If the active pane item currently has a URI according to the item's - # `.getURI` method, calls `.save` on the item. Otherwise - # {::saveActivePaneItemAs} # will be called instead. This method does nothing - # if the active item does not implement a `.save` method. - saveActivePaneItem: -> - @getActivePane().saveActiveItem() - - # Prompt the user for a path and save the active pane item to it. - # - # Opens a native dialog where the user selects a path on disk, then calls - # `.saveAs` on the item with the selected path. This method does nothing if - # the active item does not implement a `.saveAs` method. - saveActivePaneItemAs: -> - @getActivePane().saveActiveItemAs() - - # Destroy (close) the active pane item. - # - # Removes the active pane item and calls the `.destroy` method on it if one is - # defined. - destroyActivePaneItem: -> - @getActivePane().destroyActiveItem() - - ### - Section: Panes - ### - - # Extended: Get all panes in the workspace. - # - # Returns an {Array} of {Pane}s. - getPanes: -> - @paneContainer.getPanes() - - # Extended: Get the active {Pane}. - # - # Returns a {Pane}. - getActivePane: -> - @paneContainer.getActivePane() - - # Extended: Make the next pane active. - activateNextPane: -> - @paneContainer.activateNextPane() - - # Extended: Make the previous pane active. - activatePreviousPane: -> - @paneContainer.activatePreviousPane() - - # Extended: Get the first {Pane} with an item for the given URI. - # - # * `uri` {String} uri - # - # Returns a {Pane} or `undefined` if no pane exists for the given URI. - paneForURI: (uri) -> - @paneContainer.paneForURI(uri) - - # Extended: Get the {Pane} containing the given item. - # - # * `item` Item the returned pane contains. - # - # Returns a {Pane} or `undefined` if no pane exists for the given item. - paneForItem: (item) -> - @paneContainer.paneForItem(item) - - # Destroy (close) the active pane. - destroyActivePane: -> - @getActivePane()?.destroy() - - # Close the active pane item, or the active pane if it is empty, - # or the current window if there is only the empty root pane. - closeActivePaneItemOrEmptyPaneOrWindow: -> - if @getActivePaneItem()? - @destroyActivePaneItem() - else if @getPanes().length > 1 - @destroyActivePane() - else if @config.get('core.closeEmptyWindows') - atom.close() - - # Increase the editor font size by 1px. - increaseFontSize: -> - @config.set("editor.fontSize", @config.get("editor.fontSize") + 1) - - # Decrease the editor font size by 1px. - decreaseFontSize: -> - fontSize = @config.get("editor.fontSize") - @config.set("editor.fontSize", fontSize - 1) if fontSize > 1 - - # Restore to the window's original editor font size. - resetFontSize: -> - if @originalFontSize - @config.set("editor.fontSize", @originalFontSize) - - subscribeToFontSize: -> - @config.onDidChange 'editor.fontSize', ({oldValue}) => - @originalFontSize ?= oldValue - - # Removes the item's uri from the list of potential items to reopen. - itemOpened: (item) -> - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - - if uri? - _.remove(@destroyedItemURIs, uri) - - # Adds the destroyed item's uri to the list of items to reopen. - didDestroyPaneItem: ({item}) => - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - - if uri? - @destroyedItemURIs.push(uri) - - # Called by Model superclass when destroyed - destroyed: -> - @paneContainer.destroy() - @activeItemSubscriptions?.dispose() - - - ### - Section: Panels - - Panels are used to display UI related to an editor window. They are placed at one of the four - edges of the window: left, right, top or bottom. If there are multiple panels on the same window - edge they are stacked in order of priority: higher priority is closer to the center, lower - priority towards the edge. - - *Note:* If your panel changes its size throughout its lifetime, consider giving it a higher - priority, allowing fixed size panels to be closer to the edge. This allows control targets to - remain more static for easier targeting by users that employ mice or trackpads. (See - [atom/atom#4834](https://github.com/atom/atom/issues/4834) for discussion.) - ### - - # Essential: Get an {Array} of all the panel items at the bottom of the editor window. - getBottomPanels: -> - @getPanels('bottom') - - # Essential: Adds a panel item to the bottom of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addBottomPanel: (options) -> - @addPanel('bottom', options) - - # Essential: Get an {Array} of all the panel items to the left of the editor window. - getLeftPanels: -> - @getPanels('left') - - # Essential: Adds a panel item to the left of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addLeftPanel: (options) -> - @addPanel('left', options) - - # Essential: Get an {Array} of all the panel items to the right of the editor window. - getRightPanels: -> - @getPanels('right') - - # Essential: Adds a panel item to the right of the editor window. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addRightPanel: (options) -> - @addPanel('right', options) - - # Essential: Get an {Array} of all the panel items at the top of the editor window. - getTopPanels: -> - @getPanels('top') - - # Essential: Adds a panel item to the top of the editor window above the tabs. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addTopPanel: (options) -> - @addPanel('top', options) - - # Essential: Get an {Array} of all the panel items in the header. - getHeaderPanels: -> - @getPanels('header') - - # Essential: Adds a panel item to the header. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addHeaderPanel: (options) -> - @addPanel('header', options) - - # Essential: Get an {Array} of all the panel items in the footer. - getFooterPanels: -> - @getPanels('footer') - - # Essential: Adds a panel item to the footer. - # - # * `options` {Object} - # * `item` Your panel content. It can be DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # latter. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addFooterPanel: (options) -> - @addPanel('footer', options) - - # Essential: Get an {Array} of all the modal panel items - getModalPanels: -> - @getPanels('modal') - - # Essential: Adds a panel item as a modal dialog. - # - # * `options` {Object} - # * `item` Your panel content. It can be a DOM element, a jQuery element, or - # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the - # model option. See {ViewRegistry::addViewProvider} for more information. - # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden - # (default: true) - # * `priority` (optional) {Number} Determines stacking order. Lower priority items are - # forced closer to the edges of the window. (default: 100) - # - # Returns a {Panel} - addModalPanel: (options={}) -> - @addPanel('modal', options) - - # Essential: Returns the {Panel} associated with the given item. Returns - # `null` when the item has no panel. - # - # * `item` Item the panel contains - panelForItem: (item) -> - for location, container of @panelContainers - panel = container.panelForItem(item) - return panel if panel? - null - - getPanels: (location) -> - @panelContainers[location].getPanels() - - addPanel: (location, options) -> - options ?= {} - @panelContainers[location].addPanel(new Panel(options)) - - ### - Section: Searching and Replacing - ### - - # Public: Performs a search across all files in the workspace. - # - # * `regex` {RegExp} to search with. - # * `options` (optional) {Object} - # * `paths` An {Array} of glob patterns to search within. - # * `onPathsSearched` (optional) {Function} to be periodically called - # with number of paths searched. - # * `iterator` {Function} callback on each file found. - # - # Returns a {Promise} with a `cancel()` method that will cancel all - # of the underlying searches that were started as part of this scan. - scan: (regex, options={}, iterator) -> - if _.isFunction(options) - iterator = options - options = {} - - # Find a searcher for every Directory in the project. Each searcher that is matched - # will be associated with an Array of Directory objects in the Map. - directoriesForSearcher = new Map() - for directory in @project.getDirectories() - searcher = @defaultDirectorySearcher - for directorySearcher in @directorySearchers - if directorySearcher.canSearchDirectory(directory) - searcher = directorySearcher - break - directories = directoriesForSearcher.get(searcher) - unless directories - directories = [] - directoriesForSearcher.set(searcher, directories) - directories.push(directory) - - # Define the onPathsSearched callback. - if _.isFunction(options.onPathsSearched) - # Maintain a map of directories to the number of search results. When notified of a new count, - # replace the entry in the map and update the total. - onPathsSearchedOption = options.onPathsSearched - totalNumberOfPathsSearched = 0 - numberOfPathsSearchedForSearcher = new Map() - onPathsSearched = (searcher, numberOfPathsSearched) -> - oldValue = numberOfPathsSearchedForSearcher.get(searcher) - if oldValue - totalNumberOfPathsSearched -= oldValue - numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched) - totalNumberOfPathsSearched += numberOfPathsSearched - onPathsSearchedOption(totalNumberOfPathsSearched) - else - onPathsSearched = -> - - # Kick off all of the searches and unify them into one Promise. - allSearches = [] - directoriesForSearcher.forEach (directories, searcher) => - searchOptions = - inclusions: options.paths or [] - includeHidden: true - excludeVcsIgnores: @config.get('core.excludeVcsIgnoredPaths') - exclusions: @config.get('core.ignoredNames') - follow: @config.get('core.followSymlinks') - didMatch: (result) => - iterator(result) unless @project.isPathModified(result.filePath) - didError: (error) -> - iterator(null, error) - didSearchPaths: (count) -> onPathsSearched(searcher, count) - directorySearcher = searcher.search(directories, regex, searchOptions) - allSearches.push(directorySearcher) - searchPromise = Promise.all(allSearches) - - for buffer in @project.getBuffers() when buffer.isModified() - filePath = buffer.getPath() - continue unless @project.contains(filePath) - matches = [] - buffer.scan regex, (match) -> matches.push match - iterator {filePath, matches} if matches.length > 0 - - # Make sure the Promise that is returned to the client is cancelable. To be consistent - # with the existing behavior, instead of cancel() rejecting the promise, it should - # resolve it with the special value 'cancelled'. At least the built-in find-and-replace - # package relies on this behavior. - isCancelled = false - cancellablePromise = new Promise (resolve, reject) -> - onSuccess = -> - if isCancelled - resolve('cancelled') - else - resolve(null) - - onFailure = -> - promise.cancel() for promise in allSearches - reject() - - searchPromise.then(onSuccess, onFailure) - cancellablePromise.cancel = -> - isCancelled = true - # Note that cancelling all of the members of allSearches will cause all of the searches - # to resolve, which causes searchPromise to resolve, which is ultimately what causes - # cancellablePromise to resolve. - promise.cancel() for promise in allSearches - - # Although this method claims to return a `Promise`, the `ResultsPaneView.onSearch()` - # method in the find-and-replace package expects the object returned by this method to have a - # `done()` method. Include a done() method until find-and-replace can be updated. - cancellablePromise.done = (onSuccessOrFailure) -> - cancellablePromise.then(onSuccessOrFailure, onSuccessOrFailure) - cancellablePromise - - # Public: Performs a replace across all the specified files in the project. - # - # * `regex` A {RegExp} to search with. - # * `replacementText` {String} to replace all matches of regex with. - # * `filePaths` An {Array} of file path strings to run the replace on. - # * `iterator` A {Function} callback on each file with replacements: - # * `options` {Object} with keys `filePath` and `replacements`. - # - # Returns a {Promise}. - replace: (regex, replacementText, filePaths, iterator) -> - new Promise (resolve, reject) => - openPaths = (buffer.getPath() for buffer in @project.getBuffers()) - outOfProcessPaths = _.difference(filePaths, openPaths) - - inProcessFinished = not openPaths.length - outOfProcessFinished = not outOfProcessPaths.length - checkFinished = -> - resolve() if outOfProcessFinished and inProcessFinished - - unless outOfProcessFinished.length - flags = 'g' - flags += 'i' if regex.ignoreCase - - task = Task.once require.resolve('./replace-handler'), outOfProcessPaths, regex.source, flags, replacementText, -> - outOfProcessFinished = true - checkFinished() - - task.on 'replace:path-replaced', iterator - task.on 'replace:file-error', (error) -> iterator(null, error) - - for buffer in @project.getBuffers() - continue unless buffer.getPath() in filePaths - replacements = buffer.replace(regex, replacementText, iterator) - iterator({filePath: buffer.getPath(), replacements}) if replacements - - inProcessFinished = true - checkFinished() - - checkoutHeadRevision: (editor) -> - if editor.getPath() - checkoutHead = => - @project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) - .then (repository) -> - repository?.checkoutHeadForEditor(editor) - - if @config.get('editor.confirmCheckoutHeadRevision') - @applicationDelegate.confirm - message: 'Confirm Checkout HEAD Revision' - detailedMessage: "Are you sure you want to discard all changes to \"#{editor.getFileName()}\" since the last Git commit?" - buttons: - OK: checkoutHead - Cancel: null - else - checkoutHead() - else - Promise.resolve(false) diff --git a/src/workspace.js b/src/workspace.js new file mode 100644 index 000000000..f411cf823 --- /dev/null +++ b/src/workspace.js @@ -0,0 +1,1402 @@ +'use strict' + +const _ = require('underscore-plus') +const url = require('url') +const path = require('path') +const {Emitter, Disposable, CompositeDisposable} = require('event-kit') +const fs = require('fs-plus') +const {Directory} = require('pathwatcher') +const DefaultDirectorySearcher = require('./default-directory-searcher') +const Model = require('./model') +const TextEditor = require('./text-editor') +const PaneContainer = require('./pane-container') +const Panel = require('./panel') +const PanelContainer = require('./panel-container') +const Task = require('./task') + +// Essential: Represents the state of the user interface for the entire window. +// An instance of this class is available via the `atom.workspace` global. +// +// Interact with this object to open files, be notified of current and future +// editors, and manipulate panes. To add panels, use {Workspace::addTopPanel} +// and friends. +// +// * `editor` {TextEditor} the new editor +// +module.exports = class Workspace extends Model { + constructor (params) { + super(...arguments) + + this.updateWindowTitle = this.updateWindowTitle.bind(this) + this.updateDocumentEdited = this.updateDocumentEdited.bind(this) + this.didDestroyPaneItem = this.didDestroyPaneItem.bind(this) + + this.packageManager = params.packageManager + this.config = params.config + this.project = params.project + this.notificationManager = params.notificationManager + this.viewRegistry = params.viewRegistry + this.grammarRegistry = params.grammarRegistry + this.applicationDelegate = params.applicationDelegate + this.assert = params.assert + this.deserializerManager = params.deserializerManager + this.textEditorRegistry = params.textEditorRegistry + + this.emitter = new Emitter() + this.openers = [] + this.destroyedItemURIs = [] + + this.paneContainer = new PaneContainer({ + config: this.config, + applicationDelegate: this.applicationDelegate, + notificationManager: this.notificationManager, + deserializerManager: this.deserializerManager + }) + this.paneContainer.onDidDestroyPaneItem(this.didDestroyPaneItem) + + this.defaultDirectorySearcher = new DefaultDirectorySearcher() + this.consumeServices(this.packageManager) + + // One cannot simply .bind here since it could be used as a component with + // Etch, in which case it'd be `new`d. And when it's `new`d, `this` is always + // the newly created object. + const realThis = this + this.buildTextEditor = (params) => Workspace.prototype.buildTextEditor.call(realThis, params) + + this.panelContainers = { + top: new PanelContainer({location: 'top'}), + left: new PanelContainer({location: 'left'}), + right: new PanelContainer({location: 'right'}), + bottom: new PanelContainer({location: 'bottom'}), + header: new PanelContainer({location: 'header'}), + footer: new PanelContainer({location: 'footer'}), + modal: new PanelContainer({location: 'modal'}) + } + + this.subscribeToEvents() + } + + reset (packageManager) { + this.packageManager = packageManager + this.emitter.dispose() + this.emitter = new Emitter() + + this.paneContainer.destroy() + _.values(this.panelContainers).forEach(panelContainer => { panelContainer.destroy() }) + + this.paneContainer = new PaneContainer({ + config: this.config, + applicationDelegate: this.applicationDelegate, + notificationManager: this.notificationManager, + deserializerManager: this.deserializerManager + }) + this.paneContainer.onDidDestroyPaneItem(this.didDestroyPaneItem) + + this.panelContainers = { + top: new PanelContainer({location: 'top'}), + left: new PanelContainer({location: 'left'}), + right: new PanelContainer({location: 'right'}), + bottom: new PanelContainer({location: 'bottom'}), + header: new PanelContainer({location: 'header'}), + footer: new PanelContainer({location: 'footer'}), + modal: new PanelContainer({location: 'modal'}) + } + + this.originalFontSize = null + this.openers = [] + this.destroyedItemURIs = [] + this.consumeServices(this.packageManager) + } + + subscribeToEvents () { + this.subscribeToActiveItem() + this.subscribeToFontSize() + this.subscribeToAddedItems() + } + + consumeServices ({serviceHub}) { + this.directorySearchers = [] + serviceHub.consume( + 'atom.directory-searcher', + '^0.1.0', + provider => this.directorySearchers.unshift(provider) + ) + } + + // Called by the Serializable mixin during serialization. + serialize () { + return { + deserializer: 'Workspace', + paneContainer: this.paneContainer.serialize(), + packagesWithActiveGrammars: this.getPackageNamesWithActiveGrammars(), + destroyedItemURIs: this.destroyedItemURIs.slice() + } + } + + deserialize (state, deserializerManager) { + const packagesWithActiveGrammars = + state.packagesWithActiveGrammars != null ? state.packagesWithActiveGrammars : [] + for (let packageName of packagesWithActiveGrammars) { + const pkg = this.packageManager.getLoadedPackage(packageName) + if (pkg != null) { + pkg.loadGrammarsSync() + } + } + if (state.destroyedItemURIs != null) { + this.destroyedItemURIs = state.destroyedItemURIs + } + return this.paneContainer.deserialize(state.paneContainer, deserializerManager) + } + + getPackageNamesWithActiveGrammars () { + const packageNames = [] + const addGrammar = ({includedGrammarScopes, packageName} = {}) => { + if (!packageName) { return } + // Prevent cycles + if (packageNames.indexOf(packageName) !== -1) { return } + + packageNames.push(packageName) + for (let scopeName of includedGrammarScopes != null ? includedGrammarScopes : []) { + addGrammar(this.grammarRegistry.grammarForScopeName(scopeName)) + } + } + + const editors = this.getTextEditors() + for (let editor of editors) { addGrammar(editor.getGrammar()) } + + if (editors.length > 0) { + for (let grammar of this.grammarRegistry.getGrammars()) { + if (grammar.injectionSelector) { + addGrammar(grammar) + } + } + } + + return _.uniq(packageNames) + } + + subscribeToActiveItem () { + this.updateWindowTitle() + this.updateDocumentEdited() + this.project.onDidChangePaths(this.updateWindowTitle) + + this.observeActivePaneItem(item => { + this.updateWindowTitle() + this.updateDocumentEdited() + + if (this.activeItemSubscriptions != null) { + this.activeItemSubscriptions.dispose() + } + this.activeItemSubscriptions = new CompositeDisposable() + + let modifiedSubscription, titleSubscription + + if (item != null && typeof item.onDidChangeTitle === 'function') { + titleSubscription = item.onDidChangeTitle(this.updateWindowTitle) + } else if (item != null && typeof item.on === 'function') { + titleSubscription = item.on('title-changed', this.updateWindowTitle) + if (titleSubscription == null || typeof titleSubscription.dispose !== 'function') { + titleSubscription = new Disposable(() => { + item.off('title-changed', this.updateWindowTitle) + }) + } + } + + if (item != null && typeof item.onDidChangeModified === 'function') { + modifiedSubscription = item.onDidChangeModified(this.updateDocumentEdited) + } else if (item != null && typeof item.on === 'function') { + modifiedSubscription = item.on('modified-status-changed', this.updateDocumentEdited) + if (modifiedSubscription == null || typeof modifiedSubscription.dispose !== 'function') { + modifiedSubscription = new Disposable(() => { + item.off('modified-status-changed', this.updateDocumentEdited) + }) + } + } + + if (titleSubscription != null) { this.activeItemSubscriptions.add(titleSubscription) } + if (modifiedSubscription != null) { this.activeItemSubscriptions.add(modifiedSubscription) } + }) + } + + subscribeToAddedItems () { + this.onDidAddPaneItem(({item, pane, index}) => { + if (item instanceof TextEditor) { + const subscriptions = new CompositeDisposable( + this.textEditorRegistry.add(item), + this.textEditorRegistry.maintainGrammar(item), + this.textEditorRegistry.maintainConfig(item), + item.observeGrammar(this.handleGrammarUsed.bind(this)) + ) + item.onDidDestroy(() => { subscriptions.dispose() }) + this.emitter.emit('did-add-text-editor', {textEditor: item, pane, index}) + } + }) + } + + // Updates the application's title and proxy icon based on whichever file is + // open. + updateWindowTitle () { + let itemPath, itemTitle, projectPath, representedPath + const appName = 'Atom' + const left = this.project.getPaths() + const projectPaths = left != null ? left : [] + const item = this.getActivePaneItem() + if (item) { + itemPath = typeof item.getPath === 'function' ? item.getPath() : undefined + const longTitle = typeof item.getLongTitle === 'function' ? item.getLongTitle() : undefined + itemTitle = longTitle == null + ? (typeof item.getTitle === 'function' ? item.getTitle() : undefined) + : longTitle + projectPath = _.find( + projectPaths, + projectPath => + (itemPath === projectPath) || (itemPath != null ? itemPath.startsWith(projectPath + path.sep) : undefined) + ) + } + if (itemTitle == null) { itemTitle = 'untitled' } + if (projectPath == null) { projectPath = itemPath ? path.dirname(itemPath) : projectPaths[0] } + if (projectPath != null) { + projectPath = fs.tildify(projectPath) + } + + const titleParts = [] + if ((item != null) && (projectPath != null)) { + titleParts.push(itemTitle, projectPath) + representedPath = itemPath != null ? itemPath : projectPath + } else if (projectPath != null) { + titleParts.push(projectPath) + representedPath = projectPath + } else { + titleParts.push(itemTitle) + representedPath = '' + } + + if (process.platform !== 'darwin') { + titleParts.push(appName) + } + + document.title = titleParts.join(' \u2014 ') + this.applicationDelegate.setRepresentedFilename(representedPath) + } + + // On macOS, fades the application window's proxy icon when the current file + // has been modified. + updateDocumentEdited () { + const activePaneItem = this.getActivePaneItem() + const modified = activePaneItem != null && typeof activePaneItem.isModified === 'function' + ? activePaneItem.isModified() || false + : false + this.applicationDelegate.setWindowDocumentEdited(modified) + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback with all current and future text + // editors in the workspace. + // + // * `callback` {Function} to be called with current and future text editors. + // * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + // of subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors (callback) { + for (let textEditor of this.getTextEditors()) { callback(textEditor) } + return this.onDidAddTextEditor(({textEditor}) => callback(textEditor)) + } + + // Essential: Invoke the given callback with all current and future panes items + // in the workspace. + // + // * `callback` {Function} to be called with current and future pane items. + // * `item` An item that is present in {::getPaneItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePaneItems (callback) { return this.paneContainer.observePaneItems(callback) } + + // Essential: Invoke the given callback when the active pane item changes. + // + // Because observers are invoked synchronously, it's important not to perform + // any expensive operations via this method. Consider + // {::onDidStopChangingActivePaneItem} to delay operations until after changes + // stop occurring. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePaneItem (callback) { + return this.paneContainer.onDidChangeActivePaneItem(callback) + } + + // Essential: Invoke the given callback when the active pane item stops + // changing. + // + // Observers are called asynchronously 100ms after the last active pane item + // change. Handling changes here rather than in the synchronous + // {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly + // changing or closing tabs and ensures critical UI feedback, like changing the + // highlighted tab, gets priority over work that can be done asynchronously. + // + // * `callback` {Function} to be called when the active pane item stopts + // changing. + // * `item` The active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChangingActivePaneItem (callback) { + return this.paneContainer.onDidStopChangingActivePaneItem(callback) + } + + // Essential: Invoke the given callback with the current active pane item and + // with all future active pane items in the workspace. + // + // * `callback` {Function} to be called when the active pane item changes. + // * `item` The current active pane item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePaneItem (callback) { return this.paneContainer.observeActivePaneItem(callback) } + + // Essential: Invoke the given callback whenever an item is opened. Unlike + // {::onDidAddPaneItem}, observers will be notified for items that are already + // present in the workspace when they are reopened. + // + // * `callback` {Function} to be called whenever an item is opened. + // * `event` {Object} with the following keys: + // * `uri` {String} representing the opened URI. Could be `undefined`. + // * `item` The opened item. + // * `pane` The pane in which the item was opened. + // * `index` The index of the opened item on its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidOpen (callback) { + return this.emitter.on('did-open', callback) + } + + // Extended: Invoke the given callback when a pane is added to the workspace. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `pane` The added pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPane (callback) { return this.paneContainer.onDidAddPane(callback) } + + // Extended: Invoke the given callback before a pane is destroyed in the + // workspace. + // + // * `callback` {Function} to be called before panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The pane to be destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroyPane (callback) { return this.paneContainer.onWillDestroyPane(callback) } + + // Extended: Invoke the given callback when a pane is destroyed in the + // workspace. + // + // * `callback` {Function} to be called panes are destroyed. + // * `event` {Object} with the following keys: + // * `pane` The destroyed pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroyPane (callback) { return this.paneContainer.onDidDestroyPane(callback) } + + // Extended: Invoke the given callback with all current and future panes in the + // workspace. + // + // * `callback` {Function} to be called with current and future panes. + // * `pane` A {Pane} that is present in {::getPanes} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePanes (callback) { return this.paneContainer.observePanes(callback) } + + // Extended: Invoke the given callback when the active pane changes. + // + // * `callback` {Function} to be called when the active pane changes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePane (callback) { return this.paneContainer.onDidChangeActivePane(callback) } + + // Extended: Invoke the given callback with the current active pane and when + // the active pane changes. + // + // * `callback` {Function} to be called with the current and future active# + // panes. + // * `pane` A {Pane} that is the current return value of {::getActivePane}. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePane (callback) { return this.paneContainer.observeActivePane(callback) } + + // Extended: Invoke the given callback when a pane item is added to the + // workspace. + // + // * `callback` {Function} to be called when pane items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `pane` {Pane} containing the added item. + // * `index` {Number} indicating the index of the added item in its pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPaneItem (callback) { return this.paneContainer.onDidAddPaneItem(callback) } + + // Extended: Invoke the given callback when a pane item is about to be + // destroyed, before the user is prompted to save it. + // + // * `callback` {Function} to be called before pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The item to be destroyed. + // * `pane` {Pane} containing the item to be destroyed. + // * `index` {Number} indicating the index of the item to be destroyed in + // its pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onWillDestroyPaneItem (callback) { return this.paneContainer.onWillDestroyPaneItem(callback) } + + // Extended: Invoke the given callback when a pane item is destroyed. + // + // * `callback` {Function} to be called when pane items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The destroyed item. + // * `pane` {Pane} containing the destroyed item. + // * `index` {Number} indicating the index of the destroyed item in its + // pane. + // + // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onDidDestroyPaneItem (callback) { return this.paneContainer.onDidDestroyPaneItem(callback) } + + // Extended: Invoke the given callback when a text editor is added to the + // workspace. + // + // * `callback` {Function} to be called panes are added. + // * `event` {Object} with the following keys: + // * `textEditor` {TextEditor} that was added. + // * `pane` {Pane} containing the added text editor. + // * `index` {Number} indicating the index of the added text editor in its + // pane. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddTextEditor (callback) { + return this.emitter.on('did-add-text-editor', callback) + } + + /* + Section: Opening + */ + + // Essential: Opens the given URI in Atom asynchronously. + // If the URI is already open, the existing item for that URI will be + // activated. If no URI is given, or no registered opener can open + // the URI, a new empty {TextEditor} will be created. + // + // * `uri` (optional) A {String} containing a URI. + // * `options` (optional) {Object} + // * `initialLine` A {Number} indicating which row to move the cursor to + // initially. Defaults to `0`. + // * `initialColumn` A {Number} indicating which column to move the cursor to + // initially. Defaults to `0`. + // * `split` Either 'left', 'right', 'up' or 'down'. + // If 'left', the item will be opened in leftmost pane of the current active pane's row. + // If 'right', the item will be opened in the rightmost pane of the current active pane's row. If only one pane exists in the row, a new pane will be created. + // If 'up', the item will be opened in topmost pane of the current active pane's column. + // If 'down', the item will be opened in the bottommost pane of the current active pane's column. If only one pane exists in the column, a new pane will be created. + // * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + // containing pane. Defaults to `true`. + // * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + // on containing pane. Defaults to `true`. + // * `pending` A {Boolean} indicating whether or not the item should be opened + // in a pending state. Existing pending items in a pane are replaced with + // new pending items when they are opened. + // * `searchAllPanes` A {Boolean}. If `true`, the workspace will attempt to + // activate an existing item for the given URI on any pane. + // If `false`, only the active pane will be searched for + // an existing item for the same URI. Defaults to `false`. + // + // Returns a {Promise} that resolves to the {TextEditor} for the file URI. + open (uri_, options = {}) { + const { searchAllPanes } = options + const { split } = options + const uri = this.project.resolvePath(uri_) + + if (!atom.config.get('core.allowPendingPaneItems')) { + options.pending = false + } + + // Avoid adding URLs as recent documents to work-around this Spotlight crash: + // https://github.com/atom/atom/issues/10071 + if ((uri != null) && ((url.parse(uri).protocol == null) || (process.platform === 'win32'))) { + this.applicationDelegate.addRecentDocument(uri) + } + + let pane + if (searchAllPanes) { pane = this.paneContainer.paneForURI(uri) } + if (pane == null) { + switch (split) { + case 'left': + pane = this.getActivePane().findLeftmostSibling() + break + case 'right': + pane = this.getActivePane().findOrCreateRightmostSibling() + break + case 'up': + pane = this.getActivePane().findTopmostSibling() + break + case 'down': + pane = this.getActivePane().findOrCreateBottommostSibling() + break + default: + pane = this.getActivePane() + break + } + } + + return this.openURIInPane(uri, pane, options) + } + + // Open Atom's license in the active pane. + openLicense () { + return this.open(path.join(process.resourcesPath, 'LICENSE.md')) + } + + // Synchronously open the given URI in the active pane. **Only use this method + // in specs. Calling this in production code will block the UI thread and + // everyone will be mad at you.** + // + // * `uri` A {String} containing a URI. + // * `options` An optional options {Object} + // * `initialLine` A {Number} indicating which row to move the cursor to + // initially. Defaults to `0`. + // * `initialColumn` A {Number} indicating which column to move the cursor to + // initially. Defaults to `0`. + // * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + // the containing pane. Defaults to `true`. + // * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + // on containing pane. Defaults to `true`. + openSync (uri_ = '', options = {}) { + const {initialLine, initialColumn} = options + const activatePane = options.activatePane != null ? options.activatePane : true + const activateItem = options.activateItem != null ? options.activateItem : true + + const uri = this.project.resolvePath(uri) + let item = this.getActivePane().itemForURI(uri) + if (uri && (item == null)) { + for (const opener of this.getOpeners()) { + item = opener(uri, options) + if (item) break + } + } + if (item == null) { + item = this.project.openSync(uri, {initialLine, initialColumn}) + } + + if (activateItem) { + this.getActivePane().activateItem(item) + } + this.itemOpened(item) + if (activatePane) { + this.getActivePane().activate() + } + return item + } + + openURIInPane (uri, pane, options = {}) { + const activatePane = options.activatePane != null ? options.activatePane : true + const activateItem = options.activateItem != null ? options.activateItem : true + + let item + if (uri != null) { + item = pane.itemForURI(uri) + if (item == null) { + for (let opener of this.getOpeners()) { + item = opener(uri, options) + if (item != null) break + } + } else if (!options.pending && (pane.getPendingItem() === item)) { + pane.clearPendingItem() + } + } + + try { + if (item == null) { + item = this.openTextFile(uri, options) + } + } catch (error) { + switch (error.code) { + case 'CANCELLED': + return Promise.resolve() + case 'EACCES': + this.notificationManager.addWarning(`Permission denied '${error.path}'`) + return Promise.resolve() + case 'EPERM': + case 'EBUSY': + case 'ENXIO': + case 'EIO': + case 'ENOTCONN': + case 'UNKNOWN': + case 'ECONNRESET': + case 'EINVAL': + case 'EMFILE': + case 'ENOTDIR': + case 'EAGAIN': + this.notificationManager.addWarning( + `Unable to open '${error.path != null ? error.path : uri}'`, + {detail: error.message} + ) + return Promise.resolve() + default: + throw error + } + } + + return Promise.resolve(item) + .then(item => { + let initialColumn + if (pane.isDestroyed()) { + return item + } + + this.itemOpened(item) + if (activateItem) { + pane.activateItem(item, {pending: options.pending}) + } + if (activatePane) { + pane.activate() + } + + let initialLine = initialColumn = 0 + if (!Number.isNaN(options.initialLine)) { + initialLine = options.initialLine + } + if (!Number.isNaN(options.initialColumn)) { + initialColumn = options.initialColumn + } + if ((initialLine >= 0) || (initialColumn >= 0)) { + if (typeof item.setCursorBufferPosition === 'function') { + item.setCursorBufferPosition([initialLine, initialColumn]) + } + } + + const index = pane.getActiveItemIndex() + this.emitter.emit('did-open', {uri, pane, item, index}) + return item + } + ) + } + + openTextFile (uri, options) { + const filePath = this.project.resolvePath(uri) + + if (filePath != null) { + try { + fs.closeSync(fs.openSync(filePath, 'r')) + } catch (error) { + // allow ENOENT errors to create an editor for paths that dont exist + if (error.code !== 'ENOENT') { + throw error + } + } + } + + const fileSize = fs.getSizeSync(filePath) + + const largeFileMode = fileSize >= (2 * 1048576) // 2MB + if (fileSize >= (this.config.get('core.warnOnLargeFileLimit') * 1048576)) { // 20MB by default + const choice = this.applicationDelegate.confirm({ + message: 'Atom will be unresponsive during the loading of very large files.', + detailedMessage: 'Do you still want to load this file?', + buttons: ['Proceed', 'Cancel'] + }) + if (choice === 1) { + const error = new Error() + error.code = 'CANCELLED' + throw error + } + } + + return this.project.bufferForPath(filePath, options) + .then(buffer => { + return this.textEditorRegistry.build(Object.assign({buffer, largeFileMode, autoHeight: false}, options)) + }) + } + + handleGrammarUsed (grammar) { + if (grammar == null) { return } + return this.packageManager.triggerActivationHook(`${grammar.packageName}:grammar-used`) + } + + // Public: Returns a {Boolean} that is `true` if `object` is a `TextEditor`. + // + // * `object` An {Object} you want to perform the check against. + isTextEditor (object) { + return object instanceof TextEditor + } + + // Extended: Create a new text editor. + // + // Returns a {TextEditor}. + buildTextEditor (params) { + const editor = this.textEditorRegistry.build(params) + const subscriptions = new CompositeDisposable( + this.textEditorRegistry.maintainGrammar(editor), + this.textEditorRegistry.maintainConfig(editor) + ) + editor.onDidDestroy(() => { subscriptions.dispose() }) + return editor + } + + // Public: Asynchronously reopens the last-closed item's URI if it hasn't already been + // reopened. + // + // Returns a {Promise} that is resolved when the item is opened + reopenItem () { + const uri = this.destroyedItemURIs.pop() + if (uri) { + return this.open(uri) + } else { + return Promise.resolve() + } + } + + // Public: Register an opener for a uri. + // + // When a URI is opened via {Workspace::open}, Atom loops through its registered + // opener functions until one returns a value for the given uri. + // Openers are expected to return an object that inherits from HTMLElement or + // a model which has an associated view in the {ViewRegistry}. + // A {TextEditor} will be used if no opener returns a value. + // + // ## Examples + // + // ```coffee + // atom.workspace.addOpener (uri) -> + // if path.extname(uri) is '.toml' + // return new TomlEditor(uri) + // ``` + // + // * `opener` A {Function} to be called when a path is being opened. + // + // Returns a {Disposable} on which `.dispose()` can be called to remove the + // opener. + // + // Note that the opener will be called if and only if the URI is not already open + // in the current pane. The searchAllPanes flag expands the search from the + // current pane to all panes. If you wish to open a view of a different type for + // a file that is already open, consider changing the protocol of the URI. For + // example, perhaps you wish to preview a rendered version of the file `/foo/bar/baz.quux` + // that is already open in a text editor view. You could signal this by calling + // {Workspace::open} on the URI `quux-preview://foo/bar/baz.quux`. Then your opener + // can check the protocol for quux-preview and only handle those URIs that match. + addOpener (opener) { + this.openers.push(opener) + return new Disposable(() => { _.remove(this.openers, opener) }) + } + + getOpeners () { + return this.openers + } + + /* + Section: Pane Items + */ + + // Essential: Get all pane items in the workspace. + // + // Returns an {Array} of items. + getPaneItems () { + return this.paneContainer.getPaneItems() + } + + // Essential: Get the active {Pane}'s active item. + // + // Returns an pane item {Object}. + getActivePaneItem () { + return this.paneContainer.getActivePaneItem() + } + + // Essential: Get all text editors in the workspace. + // + // Returns an {Array} of {TextEditor}s. + getTextEditors () { + return this.getPaneItems().filter(item => item instanceof TextEditor) + } + + // Essential: Get the active item if it is an {TextEditor}. + // + // Returns an {TextEditor} or `undefined` if the current active item is not an + // {TextEditor}. + getActiveTextEditor () { + const activeItem = this.getActivePaneItem() + if (activeItem instanceof TextEditor) { return activeItem } + } + + // Save all pane items. + saveAll () { + return this.paneContainer.saveAll() + } + + confirmClose (options) { + return this.paneContainer.confirmClose(options) + } + + // Save the active pane item. + // + // If the active pane item currently has a URI according to the item's + // `.getURI` method, calls `.save` on the item. Otherwise + // {::saveActivePaneItemAs} # will be called instead. This method does nothing + // if the active item does not implement a `.save` method. + saveActivePaneItem () { + return this.getActivePane().saveActiveItem() + } + + // Prompt the user for a path and save the active pane item to it. + // + // Opens a native dialog where the user selects a path on disk, then calls + // `.saveAs` on the item with the selected path. This method does nothing if + // the active item does not implement a `.saveAs` method. + saveActivePaneItemAs () { + return this.getActivePane().saveActiveItemAs() + } + + // Destroy (close) the active pane item. + // + // Removes the active pane item and calls the `.destroy` method on it if one is + // defined. + destroyActivePaneItem () { + return this.getActivePane().destroyActiveItem() + } + + /* + Section: Panes + */ + + // Extended: Get all panes in the workspace. + // + // Returns an {Array} of {Pane}s. + getPanes () { + return this.paneContainer.getPanes() + } + + // Extended: Get the active {Pane}. + // + // Returns a {Pane}. + getActivePane () { + return this.paneContainer.getActivePane() + } + + // Extended: Make the next pane active. + activateNextPane () { + return this.paneContainer.activateNextPane() + } + + // Extended: Make the previous pane active. + activatePreviousPane () { + return this.paneContainer.activatePreviousPane() + } + + // Extended: Get the first {Pane} with an item for the given URI. + // + // * `uri` {String} uri + // + // Returns a {Pane} or `undefined` if no pane exists for the given URI. + paneForURI (uri) { + return this.paneContainer.paneForURI(uri) + } + + // Extended: Get the {Pane} containing the given item. + // + // * `item` Item the returned pane contains. + // + // Returns a {Pane} or `undefined` if no pane exists for the given item. + paneForItem (item) { + return this.paneContainer.paneForItem(item) + } + + // Destroy (close) the active pane. + destroyActivePane () { + const activePane = this.getActivePane() + if (activePane != null) { + activePane.destroy() + } + } + + // Close the active pane item, or the active pane if it is empty, + // or the current window if there is only the empty root pane. + closeActivePaneItemOrEmptyPaneOrWindow () { + if (this.getActivePaneItem() != null) { + this.destroyActivePaneItem() + } else if (this.getPanes().length > 1) { + this.destroyActivePane() + } else if (this.config.get('core.closeEmptyWindows')) { + atom.close() + } + } + + // Increase the editor font size by 1px. + increaseFontSize () { + this.config.set('editor.fontSize', this.config.get('editor.fontSize') + 1) + } + + // Decrease the editor font size by 1px. + decreaseFontSize () { + const fontSize = this.config.get('editor.fontSize') + if (fontSize > 1) { + this.config.set('editor.fontSize', fontSize - 1) + } + } + + // Restore to the window's original editor font size. + resetFontSize () { + if (this.originalFontSize) { + this.config.set('editor.fontSize', this.originalFontSize) + } + } + + subscribeToFontSize () { + return this.config.onDidChange('editor.fontSize', ({oldValue}) => { + if (this.originalFontSize == null) { + this.originalFontSize = oldValue + } + }) + } + + // Removes the item's uri from the list of potential items to reopen. + itemOpened (item) { + let uri + if (typeof item.getURI === 'function') { + uri = item.getURI() + } else if (typeof item.getUri === 'function') { + uri = item.getUri() + } + + if (uri != null) { + _.remove(this.destroyedItemURIs, uri) + } + } + + // Adds the destroyed item's uri to the list of items to reopen. + didDestroyPaneItem ({item}) { + let uri + if (typeof item.getURI === 'function') { + uri = item.getURI() + } else if (typeof item.getUri === 'function') { + uri = item.getUri() + } + + if (uri != null) { + this.destroyedItemURIs.push(uri) + } + } + + // Called by Model superclass when destroyed + destroyed () { + this.paneContainer.destroy() + if (this.activeItemSubscriptions != null) { + this.activeItemSubscriptions.dispose() + } + } + + /* + Section: Panels + + Panels are used to display UI related to an editor window. They are placed at one of the four + edges of the window: left, right, top or bottom. If there are multiple panels on the same window + edge they are stacked in order of priority: higher priority is closer to the center, lower + priority towards the edge. + + *Note:* If your panel changes its size throughout its lifetime, consider giving it a higher + priority, allowing fixed size panels to be closer to the edge. This allows control targets to + remain more static for easier targeting by users that employ mice or trackpads. (See + [atom/atom#4834](https://github.com/atom/atom/issues/4834) for discussion.) + */ + + // Essential: Get an {Array} of all the panel items at the bottom of the editor window. + getBottomPanels () { + return this.getPanels('bottom') + } + + // Essential: Adds a panel item to the bottom of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addBottomPanel (options) { + return this.addPanel('bottom', options) + } + + // Essential: Get an {Array} of all the panel items to the left of the editor window. + getLeftPanels () { + return this.getPanels('left') + } + + // Essential: Adds a panel item to the left of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addLeftPanel (options) { + return this.addPanel('left', options) + } + + // Essential: Get an {Array} of all the panel items to the right of the editor window. + getRightPanels () { + return this.getPanels('right') + } + + // Essential: Adds a panel item to the right of the editor window. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addRightPanel (options) { + return this.addPanel('right', options) + } + + // Essential: Get an {Array} of all the panel items at the top of the editor window. + getTopPanels () { + return this.getPanels('top') + } + + // Essential: Adds a panel item to the top of the editor window above the tabs. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addTopPanel (options) { + return this.addPanel('top', options) + } + + // Essential: Get an {Array} of all the panel items in the header. + getHeaderPanels () { + return this.getPanels('header') + } + + // Essential: Adds a panel item to the header. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addHeaderPanel (options) { + return this.addPanel('header', options) + } + + // Essential: Get an {Array} of all the panel items in the footer. + getFooterPanels () { + return this.getPanels('footer') + } + + // Essential: Adds a panel item to the footer. + // + // * `options` {Object} + // * `item` Your panel content. It can be DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // latter. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addFooterPanel (options) { + return this.addPanel('footer', options) + } + + // Essential: Get an {Array} of all the modal panel items + getModalPanels () { + return this.getPanels('modal') + } + + // Essential: Adds a panel item as a modal dialog. + // + // * `options` {Object} + // * `item` Your panel content. It can be a DOM element, a jQuery element, or + // a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + // model option. See {ViewRegistry::addViewProvider} for more information. + // * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + // (default: true) + // * `priority` (optional) {Number} Determines stacking order. Lower priority items are + // forced closer to the edges of the window. (default: 100) + // + // Returns a {Panel} + addModalPanel (options = {}) { + return this.addPanel('modal', options) + } + + // Essential: Returns the {Panel} associated with the given item. Returns + // `null` when the item has no panel. + // + // * `item` Item the panel contains + panelForItem (item) { + for (let location in this.panelContainers) { + const container = this.panelContainers[location] + const panel = container.panelForItem(item) + if (panel != null) { return panel } + } + return null + } + + getPanels (location) { + return this.panelContainers[location].getPanels() + } + + addPanel (location, options) { + if (options == null) { options = {} } + return this.panelContainers[location].addPanel(new Panel(options)) + } + + /* + Section: Searching and Replacing + */ + + // Public: Performs a search across all files in the workspace. + // + // * `regex` {RegExp} to search with. + // * `options` (optional) {Object} + // * `paths` An {Array} of glob patterns to search within. + // * `onPathsSearched` (optional) {Function} to be periodically called + // with number of paths searched. + // * `iterator` {Function} callback on each file found. + // + // Returns a {Promise} with a `cancel()` method that will cancel all + // of the underlying searches that were started as part of this scan. + scan (regex, options = {}, iterator) { + if (_.isFunction(options)) { + iterator = options + options = {} + } + + // Find a searcher for every Directory in the project. Each searcher that is matched + // will be associated with an Array of Directory objects in the Map. + const directoriesForSearcher = new Map() + for (const directory of this.project.getDirectories()) { + let searcher = this.defaultDirectorySearcher + for (const directorySearcher of this.directorySearchers) { + if (directorySearcher.canSearchDirectory(directory)) { + searcher = directorySearcher + break + } + } + let directories = directoriesForSearcher.get(searcher) + if (!directories) { + directories = [] + directoriesForSearcher.set(searcher, directories) + } + directories.push(directory) + } + + // Define the onPathsSearched callback. + let onPathsSearched + if (_.isFunction(options.onPathsSearched)) { + // Maintain a map of directories to the number of search results. When notified of a new count, + // replace the entry in the map and update the total. + const onPathsSearchedOption = options.onPathsSearched + let totalNumberOfPathsSearched = 0 + const numberOfPathsSearchedForSearcher = new Map() + onPathsSearched = function (searcher, numberOfPathsSearched) { + const oldValue = numberOfPathsSearchedForSearcher.get(searcher) + if (oldValue) { + totalNumberOfPathsSearched -= oldValue + } + numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched) + totalNumberOfPathsSearched += numberOfPathsSearched + return onPathsSearchedOption(totalNumberOfPathsSearched) + } + } else { + onPathsSearched = function () {} + } + + // Kick off all of the searches and unify them into one Promise. + const allSearches = [] + directoriesForSearcher.forEach((directories, searcher) => { + const searchOptions = { + inclusions: options.paths || [], + includeHidden: true, + excludeVcsIgnores: this.config.get('core.excludeVcsIgnoredPaths'), + exclusions: this.config.get('core.ignoredNames'), + follow: this.config.get('core.followSymlinks'), + didMatch: result => { + if (!this.project.isPathModified(result.filePath)) { + return iterator(result) + } + }, + didError (error) { + return iterator(null, error) + }, + didSearchPaths (count) { + return onPathsSearched(searcher, count) + } + } + const directorySearcher = searcher.search(directories, regex, searchOptions) + allSearches.push(directorySearcher) + }) + const searchPromise = Promise.all(allSearches) + + for (let buffer of this.project.getBuffers()) { + if (buffer.isModified()) { + const filePath = buffer.getPath() + if (!this.project.contains(filePath)) { + continue + } + var matches = [] + buffer.scan(regex, match => matches.push(match)) + if (matches.length > 0) { + iterator({filePath, matches}) + } + } + } + + // Make sure the Promise that is returned to the client is cancelable. To be consistent + // with the existing behavior, instead of cancel() rejecting the promise, it should + // resolve it with the special value 'cancelled'. At least the built-in find-and-replace + // package relies on this behavior. + let isCancelled = false + const cancellablePromise = new Promise((resolve, reject) => { + const onSuccess = function () { + if (isCancelled) { + resolve('cancelled') + } else { + resolve(null) + } + } + + const onFailure = function () { + for (let promise of allSearches) { promise.cancel() } + reject() + } + + searchPromise.then(onSuccess, onFailure) + }) + cancellablePromise.cancel = () => { + isCancelled = true + // Note that cancelling all of the members of allSearches will cause all of the searches + // to resolve, which causes searchPromise to resolve, which is ultimately what causes + // cancellablePromise to resolve. + allSearches.map((promise) => promise.cancel()) + } + + // Although this method claims to return a `Promise`, the `ResultsPaneView.onSearch()` + // method in the find-and-replace package expects the object returned by this method to have a + // `done()` method. Include a done() method until find-and-replace can be updated. + cancellablePromise.done = onSuccessOrFailure => { + cancellablePromise.then(onSuccessOrFailure, onSuccessOrFailure) + } + return cancellablePromise + } + + // Public: Performs a replace across all the specified files in the project. + // + // * `regex` A {RegExp} to search with. + // * `replacementText` {String} to replace all matches of regex with. + // * `filePaths` An {Array} of file path strings to run the replace on. + // * `iterator` A {Function} callback on each file with replacements: + // * `options` {Object} with keys `filePath` and `replacements`. + // + // Returns a {Promise}. + replace (regex, replacementText, filePaths, iterator) { + return new Promise((resolve, reject) => { + let buffer + const openPaths = this.project.getBuffers().map(buffer => buffer.getPath()) + const outOfProcessPaths = _.difference(filePaths, openPaths) + + let inProcessFinished = !openPaths.length + let outOfProcessFinished = !outOfProcessPaths.length + const checkFinished = () => { + if (outOfProcessFinished && inProcessFinished) { + resolve() + } + } + + if (!outOfProcessFinished.length) { + let flags = 'g' + if (regex.ignoreCase) { flags += 'i' } + + const task = Task.once( + require.resolve('./replace-handler'), + outOfProcessPaths, + regex.source, + flags, + replacementText, + () => { + outOfProcessFinished = true + checkFinished() + } + ) + + task.on('replace:path-replaced', iterator) + task.on('replace:file-error', error => { iterator(null, error) }) + } + + for (buffer of this.project.getBuffers()) { + if (!filePaths.includes(buffer.getPath())) { continue } + const replacements = buffer.replace(regex, replacementText, iterator) + if (replacements) { + iterator({filePath: buffer.getPath(), replacements}) + } + } + + inProcessFinished = true + checkFinished() + }) + } + + checkoutHeadRevision (editor) { + if (editor.getPath()) { + const checkoutHead = () => { + return this.project.repositoryForDirectory(new Directory(editor.getDirectoryPath())) + .then(repository => repository != null ? repository.checkoutHeadForEditor(editor) : undefined) + } + + if (this.config.get('editor.confirmCheckoutHeadRevision')) { + this.applicationDelegate.confirm({ + message: 'Confirm Checkout HEAD Revision', + detailedMessage: `Are you sure you want to discard all changes to "${editor.getFileName()}" since the last Git commit?`, + buttons: { + OK: checkoutHead, + Cancel: null + } + }) + } else { + return checkoutHead() + } + } else { + return Promise.resolve(false) + } + } +}