diff --git a/package.json b/package.json index 4af711b98..fa3a5bb37 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "service-hub": "^0.7.4", "sinon": "1.17.4", "temp": "^0.8.3", - "text-buffer": "13.3.1", + "text-buffer": "13.3.4", "typescript-simple": "1.0.0", "underscore-plus": "^1.6.6", "winreg": "^1.2.1", @@ -97,7 +97,7 @@ "autocomplete-plus": "2.35.10", "autocomplete-snippets": "1.11.1", "autoflow": "0.29.0", - "autosave": "0.24.3", + "autosave": "0.24.4", "background-tips": "0.27.1", "bookmarks": "0.44.4", "bracket-matcher": "0.88.0", @@ -112,7 +112,7 @@ "github": "0.6.2", "git-diff": "1.3.6", "go-to-line": "0.32.1", - "grammar-selector": "0.49.5", + "grammar-selector": "0.49.6", "image-view": "0.62.3", "incompatible-packages": "0.27.3", "keybinding-resolver": "0.38.0", @@ -123,22 +123,22 @@ "notifications": "0.69.2", "open-on-github": "1.2.1", "package-generator": "1.1.1", - "settings-view": "0.251.8", + "settings-view": "0.251.9", "snippets": "1.1.4", "spell-check": "0.72.2", "status-bar": "1.8.13", "styleguide": "0.49.7", "symbols-view": "0.118.0", - "tabs": "0.107.2", + "tabs": "0.107.3", "timecop": "0.36.0", "tree-view": "0.218.0", "update-package-dependencies": "0.12.0", "welcome": "0.36.5", - "whitespace": "0.37.3", + "whitespace": "0.37.4", "wrap-guide": "0.40.2", "language-c": "0.58.1", "language-clojure": "0.22.4", - "language-coffee-script": "0.49.0", + "language-coffee-script": "0.49.1", "language-csharp": "0.14.2", "language-css": "0.42.6", "language-gfm": "0.90.1", diff --git a/spec/atom-environment-spec.coffee b/spec/atom-environment-spec.coffee index 8a3e4e0fb..f178bbb6c 100644 --- a/spec/atom-environment-spec.coffee +++ b/spec/atom-environment-spec.coffee @@ -322,6 +322,44 @@ describe "AtomEnvironment", -> expect(atom2.textEditors.getGrammarOverride(editor)).toBe('text.plain') atom2.destroy() + describe "deserialization failures", -> + + it "propagates project state restoration failures", -> + spyOn(atom.project, 'deserialize').andCallFake -> + err = new Error('deserialization failure') + err.missingProjectPaths = ['/foo'] + Promise.reject(err) + spyOn(atom.notifications, 'addError') + + waitsForPromise -> atom.deserialize({project: 'should work'}) + runs -> + expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open project directory', + {description: 'Project directory `/foo` is no longer on disk.'} + + it "accumulates and reports two errors with one notification", -> + spyOn(atom.project, 'deserialize').andCallFake -> + err = new Error('deserialization failure') + err.missingProjectPaths = ['/foo', '/wat'] + Promise.reject(err) + spyOn(atom.notifications, 'addError') + + waitsForPromise -> atom.deserialize({project: 'should work'}) + runs -> + expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 2 project directories', + {description: 'Project directories `/foo` and `/wat` are no longer on disk.'} + + it "accumulates and reports three+ errors with one notification", -> + spyOn(atom.project, 'deserialize').andCallFake -> + err = new Error('deserialization failure') + err.missingProjectPaths = ['/foo', '/wat', '/stuff', '/things'] + Promise.reject(err) + spyOn(atom.notifications, 'addError') + + waitsForPromise -> atom.deserialize({project: 'should work'}) + runs -> + expect(atom.notifications.addError).toHaveBeenCalledWith 'Unable to open 4 project directories', + {description: 'Project directories `/foo`, `/wat`, `/stuff`, and `/things` are no longer on disk.'} + describe "openInitialEmptyEditorIfNecessary", -> describe "when there are no paths set", -> beforeEach -> diff --git a/spec/pane-container-spec.coffee b/spec/pane-container-spec.coffee deleted file mode 100644 index 1fa113b29..000000000 --- a/spec/pane-container-spec.coffee +++ /dev/null @@ -1,409 +0,0 @@ -PaneContainer = require '../src/pane-container' -Pane = require '../src/pane' - -describe "PaneContainer", -> - [confirm, params] = [] - - beforeEach -> - confirm = spyOn(atom.applicationDelegate, 'confirm').andReturn(0) - params = { - location: 'center', - config: atom.config, - deserializerManager: atom.deserializers - applicationDelegate: atom.applicationDelegate, - viewRegistry: atom.views - } - - describe "serialization", -> - [containerA, pane1A, pane2A, pane3A] = [] - - beforeEach -> - # This is a dummy item to prevent panes from being empty on deserialization - class Item - atom.deserializers.add(this) - @deserialize: -> new this - serialize: -> deserializer: 'Item' - - containerA = new PaneContainer(params) - pane1A = containerA.getActivePane() - pane1A.addItem(new Item) - pane2A = pane1A.splitRight(items: [new Item]) - pane3A = pane2A.splitDown(items: [new Item]) - pane3A.focus() - - it "preserves the focused pane across serialization", -> - expect(pane3A.focused).toBe true - - containerB = new PaneContainer(params) - containerB.deserialize(containerA.serialize(), atom.deserializers) - [pane1B, pane2B, pane3B] = containerB.getPanes() - expect(pane3B.focused).toBe true - - it "preserves the active pane across serialization, independent of focus", -> - pane3A.activate() - expect(containerA.getActivePane()).toBe pane3A - - containerB = new PaneContainer(params) - containerB.deserialize(containerA.serialize(), atom.deserializers) - [pane1B, pane2B, pane3B] = containerB.getPanes() - expect(containerB.getActivePane()).toBe pane3B - - it "makes the first pane active if no pane exists for the activePaneId", -> - pane3A.activate() - state = containerA.serialize() - state.activePaneId = -22 - containerB = new PaneContainer(params) - containerB.deserialize(state, atom.deserializers) - expect(containerB.getActivePane()).toBe containerB.getPanes()[0] - - describe "if there are empty panes after deserialization", -> - beforeEach -> - pane3A.getItems()[0].serialize = -> deserializer: 'Bogus' - - describe "if the 'core.destroyEmptyPanes' config option is false (the default)", -> - it "leaves the empty panes intact", -> - state = containerA.serialize() - containerB = new PaneContainer(params) - containerB.deserialize(state, atom.deserializers) - [leftPane, column] = containerB.getRoot().getChildren() - [topPane, bottomPane] = column.getChildren() - - expect(leftPane.getItems().length).toBe 1 - expect(topPane.getItems().length).toBe 1 - expect(bottomPane.getItems().length).toBe 0 - - describe "if the 'core.destroyEmptyPanes' config option is true", -> - it "removes empty panes on deserialization", -> - atom.config.set('core.destroyEmptyPanes', true) - - state = containerA.serialize() - containerB = new PaneContainer(params) - containerB.deserialize(state, atom.deserializers) - [leftPane, rightPane] = containerB.getRoot().getChildren() - - expect(leftPane.getItems().length).toBe 1 - expect(rightPane.getItems().length).toBe 1 - - it "does not allow the root pane to be destroyed", -> - container = new PaneContainer(params) - container.getRoot().destroy() - expect(container.getRoot()).toBeDefined() - expect(container.getRoot().isDestroyed()).toBe false - - describe "::getActivePane()", -> - [container, pane1, pane2] = [] - - beforeEach -> - container = new PaneContainer(params) - pane1 = container.getRoot() - - it "returns the first pane if no pane has been made active", -> - expect(container.getActivePane()).toBe pane1 - expect(pane1.isActive()).toBe true - - it "returns the most pane on which ::activate() was most recently called", -> - pane2 = pane1.splitRight() - pane2.activate() - expect(container.getActivePane()).toBe pane2 - expect(pane1.isActive()).toBe false - expect(pane2.isActive()).toBe true - pane1.activate() - expect(container.getActivePane()).toBe pane1 - expect(pane1.isActive()).toBe true - expect(pane2.isActive()).toBe false - - it "returns the next pane if the current active pane is destroyed", -> - pane2 = pane1.splitRight() - pane2.activate() - pane2.destroy() - expect(container.getActivePane()).toBe pane1 - expect(pane1.isActive()).toBe true - - describe "::onDidChangeActivePane()", -> - [container, pane1, pane2, observed] = [] - - beforeEach -> - container = new PaneContainer(params) - container.getRoot().addItems([new Object, new Object]) - container.getRoot().splitRight(items: [new Object, new Object]) - [pane1, pane2] = container.getPanes() - - observed = [] - container.onDidChangeActivePane (pane) -> observed.push(pane) - - it "invokes observers when the active pane changes", -> - pane1.activate() - pane2.activate() - expect(observed).toEqual [pane1, pane2] - - describe "::onDidChangeActivePaneItem()", -> - [container, pane1, pane2, observed] = [] - - beforeEach -> - container = new PaneContainer(params) - container.getRoot().addItems([new Object, new Object]) - container.getRoot().splitRight(items: [new Object, new Object]) - [pane1, pane2] = container.getPanes() - - observed = [] - container.onDidChangeActivePaneItem (item) -> observed.push(item) - - it "invokes observers when the active item of the active pane changes", -> - pane2.activateNextItem() - pane2.activateNextItem() - expect(observed).toEqual [pane2.itemAtIndex(1), pane2.itemAtIndex(0)] - - it "invokes observers when the active pane changes", -> - pane1.activate() - pane2.activate() - expect(observed).toEqual [pane1.itemAtIndex(0), pane2.itemAtIndex(0)] - - describe "::onDidStopChangingActivePaneItem()", -> - [container, pane1, pane2, observed] = [] - - beforeEach -> - container = new PaneContainer(params) - container.getRoot().addItems([new Object, new Object]) - container.getRoot().splitRight(items: [new Object, new Object]) - [pane1, pane2] = container.getPanes() - - observed = [] - container.onDidStopChangingActivePaneItem (item) -> observed.push(item) - - it "invokes observers once when the active item of the active pane changes", -> - pane2.activateNextItem() - pane2.activateNextItem() - expect(observed).toEqual [] - advanceClock 100 - expect(observed).toEqual [pane2.itemAtIndex(0)] - - it "invokes observers once when the active pane changes", -> - pane1.activate() - pane2.activate() - expect(observed).toEqual [] - advanceClock 100 - expect(observed).toEqual [pane2.itemAtIndex(0)] - - describe "::onDidActivatePane", -> - it "invokes observers when a pane is activated (even if it was already active)", -> - container = new PaneContainer(params) - container.getRoot().splitRight() - [pane1, pane2] = container.getPanes() - - activatedPanes = [] - container.onDidActivatePane (pane) -> activatedPanes.push(pane) - - pane1.activate() - pane1.activate() - pane2.activate() - pane2.activate() - expect(activatedPanes).toEqual([pane1, pane1, pane2, pane2]) - - describe "::observePanes()", -> - it "invokes observers with all current and future panes", -> - container = new PaneContainer(params) - container.getRoot().splitRight() - [pane1, pane2] = container.getPanes() - - observed = [] - container.observePanes (pane) -> observed.push(pane) - - pane3 = pane2.splitDown() - pane4 = pane2.splitRight() - - expect(observed).toEqual [pane1, pane2, pane3, pane4] - - describe "::observePaneItems()", -> - it "invokes observers with all current and future pane items", -> - container = new PaneContainer(params) - container.getRoot().addItems([new Object, new Object]) - container.getRoot().splitRight(items: [new Object]) - [pane1, pane2] = container.getPanes() - observed = [] - container.observePaneItems (pane) -> observed.push(pane) - - pane3 = pane2.splitDown(items: [new Object]) - pane3.addItems([new Object, new Object]) - - expect(observed).toEqual container.getPaneItems() - - describe "::confirmClose()", -> - [container, pane1, pane2] = [] - - beforeEach -> - class TestItem - shouldPromptToSave: -> true - getURI: -> 'test' - - container = new PaneContainer(params) - container.getRoot().splitRight() - [pane1, pane2] = container.getPanes() - pane1.addItem(new TestItem) - pane2.addItem(new TestItem) - - it "returns true if the user saves all modified files when prompted", -> - confirm.andReturn(0) - waitsForPromise -> - container.confirmClose().then (saved) -> - expect(confirm).toHaveBeenCalled() - expect(saved).toBeTruthy() - - it "returns false if the user cancels saving any modified file", -> - confirm.andReturn(1) - waitsForPromise -> - container.confirmClose().then (saved) -> - expect(confirm).toHaveBeenCalled() - expect(saved).toBeFalsy() - - describe "::onDidAddPane(callback)", -> - it "invokes the given callback when panes are added", -> - container = new PaneContainer(params) - events = [] - container.onDidAddPane (event) -> - expect(event.pane in container.getPanes()).toBe true - events.push(event) - - pane1 = container.getActivePane() - pane2 = pane1.splitRight() - pane3 = pane2.splitDown() - - expect(events).toEqual [{pane: pane2}, {pane: pane3}] - - describe "::onWillDestroyPane(callback)", -> - it "invokes the given callback before panes or their items are destroyed", -> - class TestItem - constructor: -> @_isDestroyed = false - destroy: -> @_isDestroyed = true - isDestroyed: -> @_isDestroyed - - container = new PaneContainer(params) - events = [] - container.onWillDestroyPane (event) -> - itemsDestroyed = (item.isDestroyed() for item in event.pane.getItems()) - events.push([event, itemsDestroyed: itemsDestroyed]) - - pane1 = container.getActivePane() - pane2 = pane1.splitRight() - pane2.addItem(new TestItem) - - pane2.destroy() - - expect(events).toEqual [[{pane: pane2}, itemsDestroyed: [false]]] - - describe "::onDidDestroyPane(callback)", -> - it "invokes the given callback when panes are destroyed", -> - container = new PaneContainer(params) - events = [] - container.onDidDestroyPane (event) -> - expect(event.pane in container.getPanes()).toBe false - events.push(event) - - pane1 = container.getActivePane() - pane2 = pane1.splitRight() - pane3 = pane2.splitDown() - - pane2.destroy() - pane3.destroy() - - expect(events).toEqual [{pane: pane2}, {pane: pane3}] - - it "invokes the given callback when the container is destroyed", -> - container = new PaneContainer(params) - events = [] - container.onDidDestroyPane (event) -> - expect(event.pane in container.getPanes()).toBe false - events.push(event) - - pane1 = container.getActivePane() - pane2 = pane1.splitRight() - pane3 = pane2.splitDown() - - container.destroy() - - expect(events).toEqual [{pane: pane1}, {pane: pane2}, {pane: pane3}] - - describe "::onWillDestroyPaneItem() and ::onDidDestroyPaneItem", -> - it "invokes the given callbacks when an item will be destroyed on any pane", -> - container = new PaneContainer(params) - pane1 = container.getRoot() - item1 = new Object - item2 = new Object - item3 = new Object - - pane1.addItem(item1) - events = [] - container.onWillDestroyPaneItem (event) -> events.push(['will', event]) - container.onDidDestroyPaneItem (event) -> events.push(['did', event]) - pane2 = pane1.splitRight(items: [item2, item3]) - - pane1.destroyItem(item1) - pane2.destroyItem(item3) - pane2.destroyItem(item2) - - expect(events).toEqual [ - ['will', {item: item1, pane: pane1, index: 0}] - ['did', {item: item1, pane: pane1, index: 0}] - ['will', {item: item3, pane: pane2, index: 1}] - ['did', {item: item3, pane: pane2, index: 1}] - ['will', {item: item2, pane: pane2, index: 0}] - ['did', {item: item2, pane: pane2, index: 0}] - ] - - describe "::saveAll()", -> - it "saves all modified pane items", -> - container = new PaneContainer(params) - pane1 = container.getRoot() - pane2 = pane1.splitRight() - - item1 = { - saved: false - getURI: -> '' - isModified: -> true, - save: -> @saved = true - } - item2 = { - saved: false - getURI: -> '' - isModified: -> false, - save: -> @saved = true - } - item3 = { - saved: false - getURI: -> '' - isModified: -> true, - save: -> @saved = true - } - - pane1.addItem(item1) - pane1.addItem(item2) - pane1.addItem(item3) - - container.saveAll() - - expect(item1.saved).toBe true - expect(item2.saved).toBe false - expect(item3.saved).toBe true - - describe "::moveActiveItemToPane(destPane) and ::copyActiveItemToPane(destPane)", -> - [container, pane1, pane2, item1] = [] - - beforeEach -> - class TestItem - constructor: (id) -> @id = id - copy: -> new TestItem(@id) - - container = new PaneContainer(params) - pane1 = container.getRoot() - item1 = new TestItem('1') - pane2 = pane1.splitRight(items: [item1]) - - describe "::::moveActiveItemToPane(destPane)", -> - it "moves active item to given pane and focuses it", -> - container.moveActiveItemToPane(pane1) - expect(pane1.getActiveItem()).toBe item1 - - describe "::::copyActiveItemToPane(destPane)", -> - it "copies active item to given pane and focuses it", -> - container.copyActiveItemToPane(pane1) - expect(container.paneForItem(item1)).toBe pane2 - expect(pane1.getActiveItem().id).toBe item1.id diff --git a/spec/pane-container-spec.js b/spec/pane-container-spec.js new file mode 100644 index 000000000..1918364f9 --- /dev/null +++ b/spec/pane-container-spec.js @@ -0,0 +1,472 @@ +const PaneContainer = require('../src/pane-container') +const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') + +describe('PaneContainer', () => { + let confirm, params + + beforeEach(() => { + confirm = spyOn(atom.applicationDelegate, 'confirm').andReturn(0) + params = { + location: 'center', + config: atom.config, + deserializerManager: atom.deserializers, + applicationDelegate: atom.applicationDelegate, + viewRegistry: atom.views + } + }) + + describe('serialization', () => { + let containerA, pane1A, pane2A, pane3A + + beforeEach(() => { + // This is a dummy item to prevent panes from being empty on deserialization + class Item { + static deserialize () { return new (this)() } + serialize () { return {deserializer: 'Item'} } + } + atom.deserializers.add(Item) + + containerA = new PaneContainer(params) + pane1A = containerA.getActivePane() + pane1A.addItem(new Item()) + pane2A = pane1A.splitRight({items: [new Item()]}) + pane3A = pane2A.splitDown({items: [new Item()]}) + pane3A.focus() + }) + + it('preserves the focused pane across serialization', () => { + expect(pane3A.focused).toBe(true) + + const containerB = new PaneContainer(params) + containerB.deserialize(containerA.serialize(), atom.deserializers) + const pane3B = containerB.getPanes()[2] + expect(pane3B.focused).toBe(true) + }) + + it('preserves the active pane across serialization, independent of focus', () => { + pane3A.activate() + expect(containerA.getActivePane()).toBe(pane3A) + + const containerB = new PaneContainer(params) + containerB.deserialize(containerA.serialize(), atom.deserializers) + const pane3B = containerB.getPanes()[2] + expect(containerB.getActivePane()).toBe(pane3B) + }) + + it('makes the first pane active if no pane exists for the activePaneId', () => { + pane3A.activate() + const state = containerA.serialize() + state.activePaneId = -22 + const containerB = new PaneContainer(params) + containerB.deserialize(state, atom.deserializers) + expect(containerB.getActivePane()).toBe(containerB.getPanes()[0]) + }) + + describe('if there are empty panes after deserialization', () => { + beforeEach(() => { + pane3A.getItems()[0].serialize = () => ({deserializer: 'Bogus'}) + }) + + describe("if the 'core.destroyEmptyPanes' config option is false (the default)", () => + it('leaves the empty panes intact', () => { + const state = containerA.serialize() + const containerB = new PaneContainer(params) + containerB.deserialize(state, atom.deserializers) + const [leftPane, column] = containerB.getRoot().getChildren() + const [topPane, bottomPane] = column.getChildren() + + expect(leftPane.getItems().length).toBe(1) + expect(topPane.getItems().length).toBe(1) + expect(bottomPane.getItems().length).toBe(0) + }) + ) + + describe("if the 'core.destroyEmptyPanes' config option is true", () => + it('removes empty panes on deserialization', () => { + atom.config.set('core.destroyEmptyPanes', true) + + const state = containerA.serialize() + const containerB = new PaneContainer(params) + containerB.deserialize(state, atom.deserializers) + const [leftPane, rightPane] = containerB.getRoot().getChildren() + + expect(leftPane.getItems().length).toBe(1) + expect(rightPane.getItems().length).toBe(1) + }) + ) + }) + }) + + it('does not allow the root pane to be destroyed', () => { + const container = new PaneContainer(params) + container.getRoot().destroy() + expect(container.getRoot()).toBeDefined() + expect(container.getRoot().isDestroyed()).toBe(false) + }) + + describe('::getActivePane()', () => { + let container, pane1, pane2 + + beforeEach(() => { + container = new PaneContainer(params) + pane1 = container.getRoot() + }) + + it('returns the first pane if no pane has been made active', () => { + expect(container.getActivePane()).toBe(pane1) + expect(pane1.isActive()).toBe(true) + }) + + it('returns the most pane on which ::activate() was most recently called', () => { + pane2 = pane1.splitRight() + pane2.activate() + expect(container.getActivePane()).toBe(pane2) + expect(pane1.isActive()).toBe(false) + expect(pane2.isActive()).toBe(true) + pane1.activate() + expect(container.getActivePane()).toBe(pane1) + expect(pane1.isActive()).toBe(true) + expect(pane2.isActive()).toBe(false) + }) + + it('returns the next pane if the current active pane is destroyed', () => { + pane2 = pane1.splitRight() + pane2.activate() + pane2.destroy() + expect(container.getActivePane()).toBe(pane1) + expect(pane1.isActive()).toBe(true) + }) + }) + + describe('::onDidChangeActivePane()', () => { + let container, pane1, pane2, observed + + beforeEach(() => { + container = new PaneContainer(params) + container.getRoot().addItems([{}, {}]) + container.getRoot().splitRight({items: [{}, {}]}); + [pane1, pane2] = container.getPanes() + + observed = [] + container.onDidChangeActivePane(pane => observed.push(pane)) + }) + + it('invokes observers when the active pane changes', () => { + pane1.activate() + pane2.activate() + expect(observed).toEqual([pane1, pane2]) + }) + }) + + describe('::onDidChangeActivePaneItem()', () => { + let container, pane1, pane2, observed + + beforeEach(() => { + container = new PaneContainer(params) + container.getRoot().addItems([{}, {}]) + container.getRoot().splitRight({items: [{}, {}]}); + [pane1, pane2] = container.getPanes() + + observed = [] + container.onDidChangeActivePaneItem(item => observed.push(item)) + }) + + it('invokes observers when the active item of the active pane changes', () => { + pane2.activateNextItem() + pane2.activateNextItem() + expect(observed).toEqual([pane2.itemAtIndex(1), pane2.itemAtIndex(0)]) + }) + + it('invokes observers when the active pane changes', () => { + pane1.activate() + pane2.activate() + expect(observed).toEqual([pane1.itemAtIndex(0), pane2.itemAtIndex(0)]) + }) + }) + + describe('::onDidStopChangingActivePaneItem()', () => { + let container, pane1, pane2, observed + + beforeEach(() => { + container = new PaneContainer(params) + container.getRoot().addItems([{}, {}]) + container.getRoot().splitRight({items: [{}, {}]}); + [pane1, pane2] = container.getPanes() + + observed = [] + container.onDidStopChangingActivePaneItem(item => observed.push(item)) + }) + + it('invokes observers once when the active item of the active pane changes', () => { + pane2.activateNextItem() + pane2.activateNextItem() + expect(observed).toEqual([]) + advanceClock(100) + expect(observed).toEqual([pane2.itemAtIndex(0)]) + }) + + it('invokes observers once when the active pane changes', () => { + pane1.activate() + pane2.activate() + expect(observed).toEqual([]) + advanceClock(100) + expect(observed).toEqual([pane2.itemAtIndex(0)]) + }) + }) + + describe('::onDidActivatePane', () => { + it('invokes observers when a pane is activated (even if it was already active)', () => { + const container = new PaneContainer(params) + container.getRoot().splitRight() + const [pane1, pane2] = container.getPanes() + + const activatedPanes = [] + container.onDidActivatePane(pane => activatedPanes.push(pane)) + + pane1.activate() + pane1.activate() + pane2.activate() + pane2.activate() + expect(activatedPanes).toEqual([pane1, pane1, pane2, pane2]) + }) + }) + + describe('::observePanes()', () => { + it('invokes observers with all current and future panes', () => { + const container = new PaneContainer(params) + container.getRoot().splitRight() + const [pane1, pane2] = container.getPanes() + + const observed = [] + container.observePanes(pane => observed.push(pane)) + + const pane3 = pane2.splitDown() + const pane4 = pane2.splitRight() + + expect(observed).toEqual([pane1, pane2, pane3, pane4]) + }) + }) + + describe('::observePaneItems()', () => + it('invokes observers with all current and future pane items', () => { + const container = new PaneContainer(params) + container.getRoot().addItems([{}, {}]) + container.getRoot().splitRight({items: [{}]}) + const pane2 = container.getPanes()[1] + const observed = [] + container.observePaneItems(pane => observed.push(pane)) + + const pane3 = pane2.splitDown({items: [{}]}) + pane3.addItems([{}, {}]) + + expect(observed).toEqual(container.getPaneItems()) + }) + ) + + describe('::confirmClose()', () => { + let container, pane1, pane2 + + beforeEach(() => { + class TestItem { + shouldPromptToSave () { return true } + getURI () { return 'test' } + } + + container = new PaneContainer(params) + container.getRoot().splitRight(); + [pane1, pane2] = container.getPanes() + pane1.addItem(new TestItem()) + pane2.addItem(new TestItem()) + }) + + it('returns true if the user saves all modified files when prompted', async () => { + confirm.andReturn(0) + const saved = await container.confirmClose() + expect(confirm).toHaveBeenCalled() + expect(saved).toBeTruthy() + }) + + it('returns false if the user cancels saving any modified file', async () => { + confirm.andReturn(1) + const saved = await container.confirmClose() + expect(confirm).toHaveBeenCalled() + expect(saved).toBeFalsy() + }) + }) + + describe('::onDidAddPane(callback)', () => { + it('invokes the given callback when panes are added', () => { + const container = new PaneContainer(params) + const events = [] + container.onDidAddPane((event) => { + expect(container.getPanes().includes(event.pane)).toBe(true) + events.push(event) + }) + + const pane1 = container.getActivePane() + const pane2 = pane1.splitRight() + const pane3 = pane2.splitDown() + + expect(events).toEqual([{pane: pane2}, {pane: pane3}]) + }) + }) + + describe('::onWillDestroyPane(callback)', () => { + it('invokes the given callback before panes or their items are destroyed', () => { + class TestItem { + constructor () { this._isDestroyed = false } + destroy () { this._isDestroyed = true } + isDestroyed () { return this._isDestroyed } + } + + const container = new PaneContainer(params) + const events = [] + container.onWillDestroyPane((event) => { + const itemsDestroyed = event.pane.getItems().map((item) => item.isDestroyed()) + events.push([event, {itemsDestroyed}]) + }) + + const pane1 = container.getActivePane() + const pane2 = pane1.splitRight() + pane2.addItem(new TestItem()) + + pane2.destroy() + + expect(events).toEqual([[{pane: pane2}, {itemsDestroyed: [false]}]]) + }) + }) + + describe('::onDidDestroyPane(callback)', () => { + it('invokes the given callback when panes are destroyed', () => { + const container = new PaneContainer(params) + const events = [] + container.onDidDestroyPane((event) => { + expect(container.getPanes().includes(event.pane)).toBe(false) + events.push(event) + }) + + const pane1 = container.getActivePane() + const pane2 = pane1.splitRight() + const pane3 = pane2.splitDown() + + pane2.destroy() + pane3.destroy() + + expect(events).toEqual([{pane: pane2}, {pane: pane3}]) + }) + + it('invokes the given callback when the container is destroyed', () => { + const container = new PaneContainer(params) + const events = [] + container.onDidDestroyPane((event) => { + expect(container.getPanes().includes(event.pane)).toBe(false) + events.push(event) + }) + + const pane1 = container.getActivePane() + const pane2 = pane1.splitRight() + const pane3 = pane2.splitDown() + + container.destroy() + + expect(events).toEqual([{pane: pane1}, {pane: pane2}, {pane: pane3}]) + }) + }) + + describe('::onWillDestroyPaneItem() and ::onDidDestroyPaneItem', () => { + it('invokes the given callbacks when an item will be destroyed on any pane', async () => { + const container = new PaneContainer(params) + const pane1 = container.getRoot() + const item1 = {} + const item2 = {} + const item3 = {} + + pane1.addItem(item1) + const events = [] + container.onWillDestroyPaneItem(event => events.push(['will', event])) + container.onDidDestroyPaneItem(event => events.push(['did', event])) + const pane2 = pane1.splitRight({items: [item2, item3]}) + + await pane1.destroyItem(item1) + await pane2.destroyItem(item3) + await pane2.destroyItem(item2) + + expect(events).toEqual([ + ['will', {item: item1, pane: pane1, index: 0}], + ['did', {item: item1, pane: pane1, index: 0}], + ['will', {item: item3, pane: pane2, index: 1}], + ['did', {item: item3, pane: pane2, index: 1}], + ['will', {item: item2, pane: pane2, index: 0}], + ['did', {item: item2, pane: pane2, index: 0}] + ]) + }) + }) + + describe('::saveAll()', () => + it('saves all modified pane items', async () => { + const container = new PaneContainer(params) + const pane1 = container.getRoot() + pane1.splitRight() + + const item1 = { + saved: false, + getURI () { return '' }, + isModified () { return true }, + save () { this.saved = true } + } + const item2 = { + saved: false, + getURI () { return '' }, + isModified () { return false }, + save () { this.saved = true } + } + const item3 = { + saved: false, + getURI () { return '' }, + isModified () { return true }, + save () { this.saved = true } + } + + pane1.addItem(item1) + pane1.addItem(item2) + pane1.addItem(item3) + + container.saveAll() + + expect(item1.saved).toBe(true) + expect(item2.saved).toBe(false) + expect(item3.saved).toBe(true) + }) + ) + + describe('::moveActiveItemToPane(destPane) and ::copyActiveItemToPane(destPane)', () => { + let container, pane1, pane2, item1 + + beforeEach(() => { + class TestItem { + constructor (id) { this.id = id } + copy () { return new TestItem(this.id) } + } + + container = new PaneContainer(params) + pane1 = container.getRoot() + item1 = new TestItem('1') + pane2 = pane1.splitRight({items: [item1]}) + }) + + describe('::::moveActiveItemToPane(destPane)', () => + it('moves active item to given pane and focuses it', () => { + container.moveActiveItemToPane(pane1) + expect(pane1.getActiveItem()).toBe(item1) + }) + ) + + describe('::::copyActiveItemToPane(destPane)', () => + it('copies active item to given pane and focuses it', () => { + container.copyActiveItemToPane(pane1) + expect(container.paneForItem(item1)).toBe(pane2) + expect(pane1.getActiveItem().id).toBe(item1.id) + }) + ) + }) +}) diff --git a/spec/pane-spec.js b/spec/pane-spec.js index 75df672b3..e448f992f 100644 --- a/spec/pane-spec.js +++ b/spec/pane-spec.js @@ -491,23 +491,31 @@ describe('Pane', () => { expect(pane.getActiveItem()).toBeUndefined() }) - it('invokes ::onWillDestroyItem() observers before destroying the item', async () => { + it('invokes ::onWillDestroyItem() and PaneContainer::onWillDestroyPaneItem observers before destroying the item', async () => { jasmine.useRealClock() - - let handlerDidFinish = false + pane.container = new PaneContainer({config: atom.config, confirm}) const events = [] + pane.onWillDestroyItem(async (event) => { expect(item2.isDestroyed()).toBe(false) - events.push(event) await timeoutPromise(50) expect(item2.isDestroyed()).toBe(false) - handlerDidFinish = true + events.push(['will-destroy-item', event]) + }) + + pane.container.onWillDestroyPaneItem(async (event) => { + expect(item2.isDestroyed()).toBe(false) + await timeoutPromise(50) + expect(item2.isDestroyed()).toBe(false) + events.push(['will-destroy-pane-item', event]) }) await pane.destroyItem(item2) - expect(handlerDidFinish).toBe(true) expect(item2.isDestroyed()).toBe(true) - expect(events).toEqual([{item: item2, index: 1}]) + expect(events).toEqual([ + ['will-destroy-item', {item: item2, index: 1}], + ['will-destroy-pane-item', {item: item2, index: 1, pane}] + ]) }) it('invokes ::onWillRemoveItem() observers', () => { diff --git a/spec/project-spec.coffee b/spec/project-spec.coffee index 4ce84617a..1f5eb54a4 100644 --- a/spec/project-spec.coffee +++ b/spec/project-spec.coffee @@ -16,20 +16,47 @@ describe "Project", -> describe "serialization", -> deserializedProject = null + notQuittingProject = null + quittingProject = null afterEach -> deserializedProject?.destroy() + notQuittingProject?.destroy() + quittingProject?.destroy() - it "does not deserialize paths to non directories", -> + it "does not deserialize paths to directories that don't exist", -> deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) state = atom.project.serialize() state.paths.push('/directory/that/does/not/exist') + err = null waitsForPromise -> deserializedProject.deserialize(state, atom.deserializers) + .catch (e) -> err = e runs -> expect(deserializedProject.getPaths()).toEqual(atom.project.getPaths()) + expect(err.missingProjectPaths).toEqual ['/directory/that/does/not/exist'] + + it "does not deserialize paths that are now files", -> + childPath = path.join(temp.mkdirSync('atom-spec-project'), 'child') + fs.mkdirSync(childPath) + + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + atom.project.setPaths([childPath]) + state = atom.project.serialize() + + fs.rmdirSync(childPath) + fs.writeFileSync(childPath, 'surprise!\n') + + err = null + waitsForPromise -> + deserializedProject.deserialize(state, atom.deserializers) + .catch (e) -> err = e + + runs -> + expect(deserializedProject.getPaths()).toEqual([]) + expect(err.missingProjectPaths).toEqual [childPath] it "does not include unretained buffers in the serialized state", -> waitsForPromise -> @@ -62,7 +89,7 @@ describe "Project", -> deserializedProject.getBuffers()[0].destroy() expect(deserializedProject.getBuffers().length).toBe 0 - it "does not deserialize buffers when their path is a directory that exists", -> + it "does not deserialize buffers when their path is now a directory", -> pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') waitsForPromise -> @@ -72,7 +99,11 @@ describe "Project", -> expect(atom.project.getBuffers().length).toBe 1 fs.mkdirSync(pathToOpen) deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + + waitsForPromise -> deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) + + runs -> expect(deserializedProject.getBuffers().length).toBe 0 it "does not deserialize buffers when their path is inaccessible", -> @@ -87,12 +118,53 @@ describe "Project", -> expect(atom.project.getBuffers().length).toBe 1 fs.chmodSync(pathToOpen, '000') deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + + waitsForPromise -> deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) + + runs -> expect(deserializedProject.getBuffers().length).toBe 0 - it "serializes marker layers and history only if Atom is quitting", -> + it "does not deserialize buffers with their path is no longer present", -> + pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + fs.writeFileSync(pathToOpen, '') + waitsForPromise -> - atom.workspace.open('a') + atom.workspace.open(pathToOpen) + + runs -> + expect(atom.project.getBuffers().length).toBe 1 + fs.unlinkSync(pathToOpen) + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + + waitsForPromise -> + deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) + + runs -> + expect(deserializedProject.getBuffers().length).toBe 0 + + it "deserializes buffers that have never been saved before", -> + pathToOpen = path.join(temp.mkdirSync('atom-spec-project'), 'file.txt') + + waitsForPromise -> + atom.workspace.open(pathToOpen) + + runs -> + atom.workspace.getActiveTextEditor().setText('unsaved\n') + expect(atom.project.getBuffers().length).toBe 1 + + deserializedProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) + + waitsForPromise -> + deserializedProject.deserialize(atom.project.serialize({isUnloading: false})) + + runs -> + expect(deserializedProject.getBuffers().length).toBe 1 + expect(deserializedProject.getBuffers()[0].getPath()).toBe pathToOpen + expect(deserializedProject.getBuffers()[0].getText()).toBe 'unsaved\n' + + it "serializes marker layers and history only if Atom is quitting", -> + waitsForPromise -> atom.workspace.open('a') bufferA = null layerA = null @@ -103,18 +175,20 @@ describe "Project", -> layerA = bufferA.addMarkerLayer(persistent: true) markerA = layerA.markPosition([0, 3]) bufferA.append('!') - - waitsForPromise -> notQuittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})).then -> - expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() - expect(notQuittingProject.getBuffers()[0].undo()).toBe(false) - waitsForPromise -> + waitsForPromise -> notQuittingProject.deserialize(atom.project.serialize({isUnloading: false})) + + runs -> + expect(notQuittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).toBeUndefined() + expect(notQuittingProject.getBuffers()[0].undo()).toBe(false) quittingProject = new Project({notificationManager: atom.notifications, packageManager: atom.packages, confirm: atom.confirm}) - quittingProject.deserialize(atom.project.serialize({isUnloading: true})).then -> - expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined() - expect(quittingProject.getBuffers()[0].undo()).toBe(true) + + waitsForPromise -> quittingProject.deserialize(atom.project.serialize({isUnloading: true})) + + runs -> + expect(quittingProject.getBuffers()[0].getMarkerLayer(layerA.id)?.getMarker(markerA.id)).not.toBeUndefined() + expect(quittingProject.getBuffers()[0].undo()).toBe(true) describe "when an editor is saved and the project has no path", -> it "sets the project's path to the saved file's parent directory", -> @@ -411,9 +485,9 @@ describe "Project", -> runs -> expect(repository.isDestroyed()).toBe(false) - describe ".setPaths(paths)", -> + describe ".setPaths(paths, options)", -> describe "when path is a file", -> - it "sets its path to the files parent directory and updates the root directory", -> + it "sets its path to the file's parent directory and updates the root directory", -> filePath = require.resolve('./fixtures/dir/a') atom.project.setPaths([filePath]) expect(atom.project.getPaths()[0]).toEqual path.dirname(filePath) @@ -448,6 +522,17 @@ describe "Project", -> expect(onDidChangePathsSpy.callCount).toBe 1 expect(onDidChangePathsSpy.mostRecentCall.args[0]).toEqual(paths) + it "optionally throws an error with any paths that did not exist", -> + paths = [temp.mkdirSync("exists0"), "/doesnt-exists/0", temp.mkdirSync("exists1"), "/doesnt-exists/1"] + + try + atom.project.setPaths paths, mustExist: true + expect('no exception thrown').toBeUndefined() + catch e + expect(e.missingProjectPaths).toEqual [paths[1], paths[3]] + + expect(atom.project.getPaths()).toEqual [paths[0], paths[2]] + describe "when no paths are given", -> it "clears its path", -> atom.project.setPaths([]) @@ -459,7 +544,7 @@ describe "Project", -> expect(atom.project.getPaths()[0]).toEqual path.dirname(require.resolve('./fixtures/dir/a')) expect(atom.project.getDirectories()[0].path).toEqual path.dirname(require.resolve('./fixtures/dir/a')) - describe ".addPath(path)", -> + describe ".addPath(path, options)", -> it "calls callbacks registered with ::onDidChangePaths", -> onDidChangePathsSpy = jasmine.createSpy('onDidChangePaths spy') atom.project.onDidChangePaths(onDidChangePathsSpy) @@ -498,6 +583,11 @@ describe "Project", -> atom.project.addPath('/this-definitely/does-not-exist') expect(atom.project.getPaths()).toEqual(previousPaths) + it "optionally throws on non-existent directories", -> + expect -> + atom.project.addPath '/this-definitely/does-not-exist', mustExist: true + .toThrow() + describe ".removePath(path)", -> onDidChangePathsSpy = null diff --git a/spec/text-editor-spec.coffee b/spec/text-editor-spec.coffee index 47b85bf1f..cb70d030c 100644 --- a/spec/text-editor-spec.coffee +++ b/spec/text-editor-spec.coffee @@ -76,6 +76,15 @@ describe "TextEditor", -> expect(editor2.displayLayer.tabLength).toBe(editor2.getTabLength()) expect(editor2.displayLayer.softWrapColumn).toBe(editor2.getSoftWrapColumn()) + it "ignores buffers with retired IDs", -> + editor2 = TextEditor.deserialize(editor.serialize(), { + assert: atom.assert, + textEditors: atom.textEditors, + project: {bufferForIdSync: -> null} + }) + + expect(editor2).toBeNull() + describe "when the editor is constructed with the largeFileMode option set to true", -> it "loads the editor but doesn't tokenize", -> editor = null diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index f532ffcea..08a3a5dc3 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -831,6 +831,9 @@ class AtomEnvironment extends Model # Essential: A flexible way to open a dialog akin to an alert dialog. # + # If the dialog is closed (via `Esc` key or `X` in the top corner) without selecting a button + # the first button will be clicked unless a "Cancel" or "No" button is provided. + # # ## Examples # # ```coffee @@ -848,7 +851,7 @@ class AtomEnvironment extends Model # * `buttons` (optional) Either an array of strings or an object where keys are # button names and the values are callbacks to invoke when clicked. # - # Returns the chosen button index {Number} if the buttons option was an array. + # Returns the chosen button index {Number} if the buttons option is an array or the return value of the callback if the buttons option is an object. confirm: (params={}) -> @applicationDelegate.confirm(params) @@ -1002,11 +1005,18 @@ class AtomEnvironment extends Model @setFullScreen(state.fullScreen) + missingProjectPaths = [] + @packages.packageStates = state.packageStates ? {} startTime = Date.now() if state.project? projectPromise = @project.deserialize(state.project, @deserializers) + .catch (err) => + if err.missingProjectPaths? + missingProjectPaths.push(err.missingProjectPaths...) + else + @notifications.addError "Unable to deserialize project", description: err.message, stack: err.stack else projectPromise = Promise.resolve() @@ -1019,6 +1029,19 @@ class AtomEnvironment extends Model @workspace.deserialize(state.workspace, @deserializers) if state.workspace? @deserializeTimings.workspace = Date.now() - startTime + if missingProjectPaths.length > 0 + count = if missingProjectPaths.length is 1 then '' else missingProjectPaths.length + ' ' + noun = if missingProjectPaths.length is 1 then 'directory' else 'directories' + toBe = if missingProjectPaths.length is 1 then 'is' else 'are' + escaped = missingProjectPaths.map (projectPath) -> "`#{projectPath}`" + group = switch escaped.length + when 1 then escaped[0] + when 2 then "#{escaped[0]} and #{escaped[1]}" + else escaped[..-2].join(", ") + ", and #{escaped[escaped.length - 1]}" + + @notifications.addError "Unable to open #{count}project #{noun}", + description: "Project #{noun} #{group} #{toBe} no longer on disk." + getStateKey: (paths) -> if paths?.length > 0 sha1 = crypto.createHash('sha1').update(paths.slice().sort().join("\n")).digest('hex') diff --git a/src/pane.coffee b/src/pane.coffee deleted file mode 100644 index 64b9383f8..000000000 --- a/src/pane.coffee +++ /dev/null @@ -1,1003 +0,0 @@ -Grim = require 'grim' -{find, compact, extend, last} = require 'underscore-plus' -{CompositeDisposable, Emitter} = require 'event-kit' -PaneAxis = require './pane-axis' -TextEditor = require './text-editor' -PaneElement = require './pane-element' - -nextInstanceId = 1 - -class SaveCancelledError extends Error - constructor: -> super - -# Extended: A container for presenting content in the center of the workspace. -# Panes can contain multiple items, one of which is *active* at a given time. -# The view corresponding to the active item is displayed in the interface. In -# the default configuration, tabs are also displayed for each item. -# -# Each pane may also contain one *pending* item. When a pending item is added -# to a pane, it will replace the currently pending item, if any, instead of -# simply being added. In the default configuration, the text in the tab for -# pending items is shown in italics. -module.exports = -class Pane - inspect: -> "Pane #{@id}" - - @deserialize: (state, {deserializers, applicationDelegate, config, notifications, views}) -> - {items, activeItemIndex, activeItemURI, activeItemUri} = state - activeItemURI ?= activeItemUri - items = items.map (itemState) -> deserializers.deserialize(itemState) - state.activeItem = items[activeItemIndex] - state.items = compact(items) - if activeItemURI? - state.activeItem ?= find state.items, (item) -> - if typeof item.getURI is 'function' - itemURI = item.getURI() - itemURI is activeItemURI - new Pane(extend(state, { - deserializerManager: deserializers, - notificationManager: notifications, - viewRegistry: views, - config, applicationDelegate - })) - - constructor: (params) -> - { - @id, @activeItem, @focused, @applicationDelegate, @notificationManager, @config, - @deserializerManager, @viewRegistry - } = params - - if @id? - nextInstanceId = Math.max(nextInstanceId, @id + 1) - else - @id = nextInstanceId++ - @emitter = new Emitter - @alive = true - @subscriptionsPerItem = new WeakMap - @items = [] - @itemStack = [] - @container = null - @activeItem ?= undefined - @focused ?= false - - @addItems(compact(params?.items ? [])) - @setActiveItem(@items[0]) unless @getActiveItem()? - @addItemsToStack(params?.itemStackIndices ? []) - @setFlexScale(params?.flexScale ? 1) - - getElement: -> - @element ?= new PaneElement().initialize(this, {views: @viewRegistry, @applicationDelegate}) - - serialize: -> - itemsToBeSerialized = compact(@items.map((item) -> item if typeof item.serialize is 'function')) - itemStackIndices = (itemsToBeSerialized.indexOf(item) for item in @itemStack when typeof item.serialize is 'function') - activeItemIndex = itemsToBeSerialized.indexOf(@activeItem) - - { - deserializer: 'Pane', - id: @id, - items: itemsToBeSerialized.map((item) -> item.serialize()) - itemStackIndices: itemStackIndices - activeItemIndex: activeItemIndex - focused: @focused - flexScale: @flexScale - } - - getParent: -> @parent - - setParent: (@parent) -> @parent - - getContainer: -> @container - - setContainer: (container) -> - if container and container isnt @container - @container = container - container.didAddPane({pane: this}) - - # Private: Determine whether the given item is allowed to exist in this pane. - # - # * `item` the Item - # - # Returns a {Boolean}. - isItemAllowed: (item) -> - if (typeof item.getAllowedLocations isnt 'function') - true - else - item.getAllowedLocations().includes(@getContainer().getLocation()) - - setFlexScale: (@flexScale) -> - @emitter.emit 'did-change-flex-scale', @flexScale - @flexScale - - getFlexScale: -> @flexScale - - increaseSize: -> @setFlexScale(@getFlexScale() * 1.1) - - decreaseSize: -> @setFlexScale(@getFlexScale() / 1.1) - - ### - Section: Event Subscription - ### - - # Public: Invoke the given callback when the pane resizes - # - # The callback will be invoked when pane's flexScale property changes. - # Use {::getFlexScale} to get the current value. - # - # * `callback` {Function} to be called when the pane is resized - # * `flexScale` {Number} representing the panes `flex-grow`; ability for a - # flex item to grow if necessary. - # - # Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. - onDidChangeFlexScale: (callback) -> - @emitter.on 'did-change-flex-scale', callback - - # Public: Invoke the given callback with the current and future values of - # {::getFlexScale}. - # - # * `callback` {Function} to be called with the current and future values of - # the {::getFlexScale} property. - # * `flexScale` {Number} representing the panes `flex-grow`; ability for a - # flex item to grow if necessary. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeFlexScale: (callback) -> - callback(@flexScale) - @onDidChangeFlexScale(callback) - - # Public: Invoke the given callback when the pane is activated. - # - # The given callback will be invoked whenever {::activate} is called on the - # pane, even if it is already active at the time. - # - # * `callback` {Function} to be called when the pane is activated. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidActivate: (callback) -> - @emitter.on 'did-activate', callback - - # Public: Invoke the given callback before the pane is destroyed. - # - # * `callback` {Function} to be called before the pane is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillDestroy: (callback) -> - @emitter.on 'will-destroy', callback - - # Public: Invoke the given callback when the pane is destroyed. - # - # * `callback` {Function} to be called when the pane is destroyed. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroy: (callback) -> - @emitter.once 'did-destroy', callback - - # Public: Invoke the given callback when the value of the {::isActive} - # property changes. - # - # * `callback` {Function} to be called when the value of the {::isActive} - # property changes. - # * `active` {Boolean} indicating whether the pane is active. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActive: (callback) -> - @container.onDidChangeActivePane (activePane) => - callback(this is activePane) - - # Public: Invoke the given callback with the current and future values of the - # {::isActive} property. - # - # * `callback` {Function} to be called with the current and future values of - # the {::isActive} property. - # * `active` {Boolean} indicating whether the pane is active. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActive: (callback) -> - callback(@isActive()) - @onDidChangeActive(callback) - - # Public: Invoke the given callback when an item is added to the pane. - # - # * `callback` {Function} to be called with when items are added. - # * `event` {Object} with the following keys: - # * `item` The added pane item. - # * `index` {Number} indicating where the item is located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddItem: (callback) -> - @emitter.on 'did-add-item', callback - - # Public: Invoke the given callback when an item is removed from the pane. - # - # * `callback` {Function} to be called with when items are removed. - # * `event` {Object} with the following keys: - # * `item` The removed pane item. - # * `index` {Number} indicating where the item was located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidRemoveItem: (callback) -> - @emitter.on 'did-remove-item', callback - - # Public: Invoke the given callback before an item is removed from the pane. - # - # * `callback` {Function} to be called with when items are removed. - # * `event` {Object} with the following keys: - # * `item` The pane item to be removed. - # * `index` {Number} indicating where the item is located. - onWillRemoveItem: (callback) -> - @emitter.on 'will-remove-item', callback - - # Public: Invoke the given callback when an item is moved within the pane. - # - # * `callback` {Function} to be called with when items are moved. - # * `event` {Object} with the following keys: - # * `item` The removed pane item. - # * `oldIndex` {Number} indicating where the item was located. - # * `newIndex` {Number} indicating where the item is now located. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidMoveItem: (callback) -> - @emitter.on 'did-move-item', callback - - # Public: Invoke the given callback with all current and future items. - # - # * `callback` {Function} to be called with current and future items. - # * `item` An item that is present in {::getItems} at the time of - # subscription or that is added at some later time. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeItems: (callback) -> - callback(item) for item in @getItems() - @onDidAddItem ({item}) -> callback(item) - - # Public: Invoke the given callback when the value of {::getActiveItem} - # changes. - # - # * `callback` {Function} to be called with when the active item changes. - # * `activeItem` The current active item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidChangeActiveItem: (callback) -> - @emitter.on 'did-change-active-item', callback - - # Public: Invoke the given callback when {::activateNextRecentlyUsedItem} - # has been called, either initiating or continuing a forward MRU traversal of - # pane items. - # - # * `callback` {Function} to be called with when the active item changes. - # * `nextRecentlyUsedItem` The next MRU item, now being set active - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onChooseNextMRUItem: (callback) -> - @emitter.on 'choose-next-mru-item', callback - - # Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem} - # has been called, either initiating or continuing a reverse MRU traversal of - # pane items. - # - # * `callback` {Function} to be called with when the active item changes. - # * `previousRecentlyUsedItem` The previous MRU item, now being set active - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onChooseLastMRUItem: (callback) -> - @emitter.on 'choose-last-mru-item', callback - - # Public: Invoke the given callback when {::moveActiveItemToTopOfStack} - # has been called, terminating an MRU traversal of pane items and moving the - # current active item to the top of the stack. Typically bound to a modifier - # (e.g. CTRL) key up event. - # - # * `callback` {Function} to be called with when the MRU traversal is done. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDoneChoosingMRUItem: (callback) -> - @emitter.on 'done-choosing-mru-item', callback - - # Public: Invoke the given callback with the current and future values of - # {::getActiveItem}. - # - # * `callback` {Function} to be called with the current and future active - # items. - # * `activeItem` The current active item. - # - # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - observeActiveItem: (callback) -> - callback(@getActiveItem()) - @onDidChangeActiveItem(callback) - - # Public: Invoke the given callback before items are destroyed. - # - # * `callback` {Function} to be called before items are destroyed. - # * `event` {Object} with the following keys: - # * `item` The item that will be destroyed. - # * `index` The location of the item. - # - # Returns a {Disposable} on which `.dispose()` can be called to - # unsubscribe. - onWillDestroyItem: (callback) -> - @emitter.on 'will-destroy-item', callback - - # Called by the view layer to indicate that the pane has gained focus. - focus: -> - @focused = true - @activate() - - # Called by the view layer to indicate that the pane has lost focus. - blur: -> - @focused = false - true # if this is called from an event handler, don't cancel it - - isFocused: -> @focused - - getPanes: -> [this] - - unsubscribeFromItem: (item) -> - @subscriptionsPerItem.get(item)?.dispose() - @subscriptionsPerItem.delete(item) - - ### - Section: Items - ### - - # Public: Get the items in this pane. - # - # Returns an {Array} of items. - getItems: -> - @items.slice() - - # Public: Get the active pane item in this pane. - # - # Returns a pane item. - getActiveItem: -> @activeItem - - setActiveItem: (activeItem, options) -> - {modifyStack} = options if options? - unless activeItem is @activeItem - @addItemToStack(activeItem) unless modifyStack is false - @activeItem = activeItem - @emitter.emit 'did-change-active-item', @activeItem - @container?.didChangeActiveItemOnPane(this, @activeItem) - @activeItem - - # Build the itemStack after deserializing - addItemsToStack: (itemStackIndices) -> - if @items.length > 0 - if itemStackIndices.length is 0 or itemStackIndices.length isnt @items.length or itemStackIndices.indexOf(-1) >= 0 - itemStackIndices = (i for i in [0..@items.length-1]) - for itemIndex in itemStackIndices - @addItemToStack(@items[itemIndex]) - return - - # Add item (or move item) to the end of the itemStack - addItemToStack: (newItem) -> - return unless newItem? - index = @itemStack.indexOf(newItem) - @itemStack.splice(index, 1) unless index is -1 - @itemStack.push(newItem) - - # Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. - getActiveEditor: -> - @activeItem if @activeItem instanceof TextEditor - - # Public: Return the item at the given index. - # - # * `index` {Number} - # - # Returns an item or `null` if no item exists at the given index. - itemAtIndex: (index) -> - @items[index] - - # Makes the next item in the itemStack active. - activateNextRecentlyUsedItem: -> - if @items.length > 1 - @itemStackIndex = @itemStack.length - 1 unless @itemStackIndex? - @itemStackIndex = @itemStack.length if @itemStackIndex is 0 - @itemStackIndex = @itemStackIndex - 1 - nextRecentlyUsedItem = @itemStack[@itemStackIndex] - @emitter.emit 'choose-next-mru-item', nextRecentlyUsedItem - @setActiveItem(nextRecentlyUsedItem, modifyStack: false) - - # Makes the previous item in the itemStack active. - activatePreviousRecentlyUsedItem: -> - if @items.length > 1 - if @itemStackIndex + 1 is @itemStack.length or not @itemStackIndex? - @itemStackIndex = -1 - @itemStackIndex = @itemStackIndex + 1 - previousRecentlyUsedItem = @itemStack[@itemStackIndex] - @emitter.emit 'choose-last-mru-item', previousRecentlyUsedItem - @setActiveItem(previousRecentlyUsedItem, modifyStack: false) - - # Moves the active item to the end of the itemStack once the ctrl key is lifted - moveActiveItemToTopOfStack: -> - delete @itemStackIndex - @addItemToStack(@activeItem) - @emitter.emit 'done-choosing-mru-item' - - # Public: Makes the next item active. - activateNextItem: -> - index = @getActiveItemIndex() - if index < @items.length - 1 - @activateItemAtIndex(index + 1) - else - @activateItemAtIndex(0) - - # Public: Makes the previous item active. - activatePreviousItem: -> - index = @getActiveItemIndex() - if index > 0 - @activateItemAtIndex(index - 1) - else - @activateItemAtIndex(@items.length - 1) - - activateLastItem: -> - @activateItemAtIndex(@items.length - 1) - - # Public: Move the active tab to the right. - moveItemRight: -> - index = @getActiveItemIndex() - rightItemIndex = index + 1 - @moveItem(@getActiveItem(), rightItemIndex) unless rightItemIndex > @items.length - 1 - - # Public: Move the active tab to the left - moveItemLeft: -> - index = @getActiveItemIndex() - leftItemIndex = index - 1 - @moveItem(@getActiveItem(), leftItemIndex) unless leftItemIndex < 0 - - # Public: Get the index of the active item. - # - # Returns a {Number}. - getActiveItemIndex: -> - @items.indexOf(@activeItem) - - # Public: Activate the item at the given index. - # - # * `index` {Number} - activateItemAtIndex: (index) -> - item = @itemAtIndex(index) or @getActiveItem() - @setActiveItem(item) - - # Public: Make the given item *active*, causing it to be displayed by - # the pane's view. - # - # * `item` The item to activate - # * `options` (optional) {Object} - # * `pending` (optional) {Boolean} indicating that the item should be added - # in a pending state if it does not yet exist in the pane. Existing pending - # items in a pane are replaced with new pending items when they are opened. - activateItem: (item, options={}) -> - if item? - if @getPendingItem() is @activeItem - index = @getActiveItemIndex() - else - index = @getActiveItemIndex() + 1 - @addItem(item, extend({}, options, {index: index})) - @setActiveItem(item) - - # Public: Add the given item to the pane. - # - # * `item` The item to add. It can be a model with an associated view or a - # view. - # * `options` (optional) {Object} - # * `index` (optional) {Number} indicating the index at which to add the item. - # If omitted, the item is added after the current active item. - # * `pending` (optional) {Boolean} indicating that the item should be - # added in a pending state. Existing pending items in a pane are replaced with - # new pending items when they are opened. - # - # Returns the added item. - addItem: (item, options={}) -> - # Backward compat with old API: - # addItem(item, index=@getActiveItemIndex() + 1) - if typeof options is "number" - Grim.deprecate("Pane::addItem(item, #{options}) is deprecated in favor of Pane::addItem(item, {index: #{options}})") - options = index: options - - index = options.index ? @getActiveItemIndex() + 1 - moved = options.moved ? false - pending = options.pending ? false - - throw new Error("Pane items must be objects. Attempted to add item #{item}.") unless item? and typeof item is 'object' - throw new Error("Adding a pane item with URI '#{item.getURI?()}' that has already been destroyed") if item.isDestroyed?() - - return if item in @items - - if typeof item.onDidDestroy is 'function' - itemSubscriptions = new CompositeDisposable - itemSubscriptions.add item.onDidDestroy => @removeItem(item, false) - if typeof item.onDidTerminatePendingState is "function" - itemSubscriptions.add item.onDidTerminatePendingState => - @clearPendingItem() if @getPendingItem() is item - @subscriptionsPerItem.set item, itemSubscriptions - - @items.splice(index, 0, item) - lastPendingItem = @getPendingItem() - replacingPendingItem = lastPendingItem? and not moved - @pendingItem = null if replacingPendingItem - @setPendingItem(item) if pending - - @emitter.emit 'did-add-item', {item, index, moved} - @container?.didAddPaneItem(item, this, index) unless moved - - @destroyItem(lastPendingItem) if replacingPendingItem - @setActiveItem(item) unless @getActiveItem()? - item - - setPendingItem: (item) => - if @pendingItem isnt item - mostRecentPendingItem = @pendingItem - @pendingItem = item - if mostRecentPendingItem? - @emitter.emit 'item-did-terminate-pending-state', mostRecentPendingItem - - getPendingItem: => - @pendingItem or null - - clearPendingItem: => - @setPendingItem(null) - - onItemDidTerminatePendingState: (callback) => - @emitter.on 'item-did-terminate-pending-state', callback - - # Public: Add the given items to the pane. - # - # * `items` An {Array} of items to add. Items can be views or models with - # associated views. Any objects that are already present in the pane's - # current items will not be added again. - # * `index` (optional) {Number} index at which to add the items. If omitted, - # the item is # added after the current active item. - # - # Returns an {Array} of added items. - addItems: (items, index=@getActiveItemIndex() + 1) -> - items = items.filter (item) => not (item in @items) - @addItem(item, {index: index + i}) for item, i in items - items - - removeItem: (item, moved) -> - index = @items.indexOf(item) - return if index is -1 - @pendingItem = null if @getPendingItem() is item - @removeItemFromStack(item) - @emitter.emit 'will-remove-item', {item, index, destroyed: not moved, moved} - @unsubscribeFromItem(item) - - if item is @activeItem - if @items.length is 1 - @setActiveItem(undefined) - else if index is 0 - @activateNextItem() - else - @activatePreviousItem() - @items.splice(index, 1) - @emitter.emit 'did-remove-item', {item, index, destroyed: not moved, moved} - @container?.didDestroyPaneItem({item, index, pane: this}) unless moved - @destroy() if @items.length is 0 and @config.get('core.destroyEmptyPanes') - - # Remove the given item from the itemStack. - # - # * `item` The item to remove. - # * `index` {Number} indicating the index to which to remove the item from the itemStack. - removeItemFromStack: (item) -> - index = @itemStack.indexOf(item) - @itemStack.splice(index, 1) unless index is -1 - - # Public: Move the given item to the given index. - # - # * `item` The item to move. - # * `index` {Number} indicating the index to which to move the item. - moveItem: (item, newIndex) -> - oldIndex = @items.indexOf(item) - @items.splice(oldIndex, 1) - @items.splice(newIndex, 0, item) - @emitter.emit 'did-move-item', {item, oldIndex, newIndex} - - # Public: Move the given item to the given index on another pane. - # - # * `item` The item to move. - # * `pane` {Pane} to which to move the item. - # * `index` {Number} indicating the index to which to move the item in the - # given pane. - moveItemToPane: (item, pane, index) -> - @removeItem(item, true) - pane.addItem(item, {index: index, moved: true}) - - # Public: Destroy the active item and activate the next item. - destroyActiveItem: -> - @destroyItem(@activeItem) - false - - # Public: Destroy the given item. - # - # If the item is active, the next item will be activated. If the item is the - # last item, the pane will be destroyed if the `core.destroyEmptyPanes` config - # setting is `true`. - # - # * `item` Item to destroy - # * `force` (optional) {Boolean} Destroy the item without prompting to save - # it, even if the item's `isPermanentDockItem` method returns true. - # - # Returns a {Promise} that resolves with a {Boolean} indicating whether or not - # the item was destroyed. - destroyItem: (item, force) -> - index = @items.indexOf(item) - if index isnt -1 - if not force and @getContainer()?.getLocation() isnt 'center' and item.isPermanentDockItem?() - return Promise.resolve(false) - - callback = => - @container?.willDestroyPaneItem({item, index, pane: this}) - if force or not item?.shouldPromptToSave?() - @removeItem(item, false) - item.destroy?() - true - else - @promptToSaveItem(item).then (result) => - if result - @removeItem(item, false) - item.destroy?() - result - - # In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior - # where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. - if @emitter.listenerCountForEventName('will-destroy-item') is 0 - return Promise.resolve(callback()) - else - @emitter.emitAsync('will-destroy-item', {item, index}).then(callback) - - # Public: Destroy all items. - destroyItems: -> - Promise.all( - @getItems().map((item) => @destroyItem(item)) - ) - - # Public: Destroy all items except for the active item. - destroyInactiveItems: -> - Promise.all( - @getItems() - .filter((item) => item isnt @activeItem) - .map((item) => @destroyItem(item)) - ) - - promptToSaveItem: (item, options={}) -> - return Promise.resolve(true) unless item.shouldPromptToSave?(options) - - if typeof item.getURI is 'function' - uri = item.getURI() - else if typeof item.getUri is 'function' - uri = item.getUri() - else - return Promise.resolve(true) - - saveDialog = (saveButtonText, saveFn, message) => - chosen = @applicationDelegate.confirm - message: message - detailedMessage: "Your changes will be lost if you close this item without saving." - buttons: [saveButtonText, "Cancel", "&Don't Save"] - switch chosen - when 0 - new Promise (resolve) -> - saveFn item, (error) -> - if error instanceof SaveCancelledError - resolve(false) - else - saveError(error).then(resolve) - when 1 - Promise.resolve(false) - when 2 - Promise.resolve(true) - - saveError = (error) => - if error - saveDialog("Save as", @saveItemAs, "'#{item.getTitle?() ? uri}' could not be saved.\nError: #{@getMessageForErrorCode(error.code)}") - else - Promise.resolve(true) - - saveDialog("Save", @saveItem, "'#{item.getTitle?() ? uri}' has changes, do you want to save them?") - - # Public: Save the active item. - saveActiveItem: (nextAction) -> - @saveItem(@getActiveItem(), nextAction) - - # Public: Prompt the user for a location and save the active item with the - # path they select. - # - # * `nextAction` (optional) {Function} which will be called after the item is - # successfully saved. - # - # Returns a {Promise} that resolves when the save is complete - saveActiveItemAs: (nextAction) -> - @saveItemAs(@getActiveItem(), nextAction) - - # Public: Save the given item. - # - # * `item` The item to save. - # * `nextAction` (optional) {Function} which will be called with no argument - # after the item is successfully saved, or with the error if it failed. - # The return value will be that of `nextAction` or `undefined` if it was not - # provided - # - # Returns a {Promise} that resolves when the save is complete - saveItem: (item, nextAction) => - if typeof item?.getURI is 'function' - itemURI = item.getURI() - else if typeof item?.getUri is 'function' - itemURI = item.getUri() - - if itemURI? - if item.save? - promisify -> item.save() - .then -> nextAction?() - .catch (error) => - if nextAction - nextAction(error) - else - @handleSaveError(error, item) - else - nextAction?() - else - @saveItemAs(item, nextAction) - - # Public: Prompt the user for a location and save the active item with the - # path they select. - # - # * `item` The item to save. - # * `nextAction` (optional) {Function} which will be called with no argument - # after the item is successfully saved, or with the error if it failed. - # The return value will be that of `nextAction` or `undefined` if it was not - # provided - saveItemAs: (item, nextAction) => - return unless item?.saveAs? - - saveOptions = item.getSaveDialogOptions?() ? {} - itemPath = item.getPath() - saveOptions.defaultPath ?= itemPath if itemPath - newItemPath = @applicationDelegate.showSaveDialog(saveOptions) - if newItemPath - promisify -> item.saveAs(newItemPath) - .then -> nextAction?() - .catch (error) => - if nextAction? - nextAction(error) - else - @handleSaveError(error, item) - else if nextAction? - nextAction(new SaveCancelledError('Save Cancelled')) - - # Public: Save all items. - saveItems: -> - for item in @getItems() - @saveItem(item) if item.isModified?() - return - - # Public: Return the first item that matches the given URI or undefined if - # none exists. - # - # * `uri` {String} containing a URI. - itemForURI: (uri) -> - find @items, (item) -> - if typeof item.getURI is 'function' - itemUri = item.getURI() - else if typeof item.getUri is 'function' - itemUri = item.getUri() - - itemUri is uri - - # Public: Activate the first item that matches the given URI. - # - # * `uri` {String} containing a URI. - # - # Returns a {Boolean} indicating whether an item matching the URI was found. - activateItemForURI: (uri) -> - if item = @itemForURI(uri) - @activateItem(item) - true - else - false - - copyActiveItem: -> - @activeItem?.copy?() - - ### - Section: Lifecycle - ### - - # Public: Determine whether the pane is active. - # - # Returns a {Boolean}. - isActive: -> - @container?.getActivePane() is this - - # Public: Makes this pane the *active* pane, causing it to gain focus. - activate: -> - throw new Error("Pane has been destroyed") if @isDestroyed() - @container?.didActivatePane(this) - @emitter.emit 'did-activate' - - # Public: Close the pane and destroy all its items. - # - # If this is the last pane, all the items will be destroyed but the pane - # itself will not be destroyed. - destroy: -> - if @container?.isAlive() and @container.getPanes().length is 1 - @destroyItems() - else - @emitter.emit 'will-destroy' - @alive = false - @container?.willDestroyPane(pane: this) - @container.activateNextPane() if @isActive() - @emitter.emit 'did-destroy' - @emitter.dispose() - item.destroy?() for item in @items.slice() - @container?.didDestroyPane(pane: this) - - isAlive: -> @alive - - # Public: Determine whether this pane has been destroyed. - # - # Returns a {Boolean}. - isDestroyed: -> not @isAlive() - - ### - Section: Splitting - ### - - # Public: Create a new pane to the left of this pane. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitLeft: (params) -> - @split('horizontal', 'before', params) - - # Public: Create a new pane to the right of this pane. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitRight: (params) -> - @split('horizontal', 'after', params) - - # Public: Creates a new pane above the receiver. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitUp: (params) -> - @split('vertical', 'before', params) - - # Public: Creates a new pane below the receiver. - # - # * `params` (optional) {Object} with the following keys: - # * `items` (optional) {Array} of items to add to the new pane. - # * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane - # - # Returns the new {Pane}. - splitDown: (params) -> - @split('vertical', 'after', params) - - split: (orientation, side, params) -> - if params?.copyActiveItem - params.items ?= [] - params.items.push(@copyActiveItem()) - - if @parent.orientation isnt orientation - @parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this], @flexScale}, @viewRegistry)) - @setFlexScale(1) - - newPane = new Pane(extend({@applicationDelegate, @notificationManager, @deserializerManager, @config, @viewRegistry}, params)) - switch side - when 'before' then @parent.insertChildBefore(this, newPane) - when 'after' then @parent.insertChildAfter(this, newPane) - - @moveItemToPane(@activeItem, newPane) if params?.moveActiveItem and @activeItem - - newPane.activate() - newPane - - # If the parent is a horizontal axis, returns its first child if it is a pane; - # otherwise returns this pane. - findLeftmostSibling: -> - if @parent.orientation is 'horizontal' - [leftmostSibling] = @parent.children - if leftmostSibling instanceof PaneAxis - this - else - leftmostSibling - else - this - - findRightmostSibling: -> - if @parent.orientation is 'horizontal' - rightmostSibling = last(@parent.children) - if rightmostSibling instanceof PaneAxis - this - else - rightmostSibling - else - this - - # If the parent is a horizontal axis, returns its last child if it is a pane; - # otherwise returns a new pane created by splitting this pane rightward. - findOrCreateRightmostSibling: -> - rightmostSibling = @findRightmostSibling() - if rightmostSibling is this then @splitRight() else rightmostSibling - - # If the parent is a vertical axis, returns its first child if it is a pane; - # otherwise returns this pane. - findTopmostSibling: -> - if @parent.orientation is 'vertical' - [topmostSibling] = @parent.children - if topmostSibling instanceof PaneAxis - this - else - topmostSibling - else - this - - findBottommostSibling: -> - if @parent.orientation is 'vertical' - bottommostSibling = last(@parent.children) - if bottommostSibling instanceof PaneAxis - this - else - bottommostSibling - else - this - - # If the parent is a vertical axis, returns its last child if it is a pane; - # otherwise returns a new pane created by splitting this pane bottomward. - findOrCreateBottommostSibling: -> - bottommostSibling = @findBottommostSibling() - if bottommostSibling is this then @splitDown() else bottommostSibling - - # Private: Close the pane unless the user cancels the action via a dialog. - # - # Returns a {Promise} that resolves once the pane is either closed, or the - # closing has been cancelled. - close: -> - Promise.all(@getItems().map((item) => @promptToSaveItem(item))).then (results) => - @destroy() unless results.includes(false) - - handleSaveError: (error, item) -> - itemPath = error.path ? item?.getPath?() - addWarningWithPath = (message, options) => - message = "#{message} '#{itemPath}'" if itemPath - @notificationManager.addWarning(message, options) - - customMessage = @getMessageForErrorCode(error.code) - if customMessage? - addWarningWithPath("Unable to save file: #{customMessage}") - else if error.code is 'EISDIR' or error.message?.endsWith?('is a directory') - @notificationManager.addWarning("Unable to save file: #{error.message}") - else if error.code in ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'] - addWarningWithPath('Unable to save file', detail: error.message) - else if errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) - fileName = errorMatch[1] - @notificationManager.addWarning("Unable to save file: A directory in the path '#{fileName}' could not be written to") - else - throw error - - getMessageForErrorCode: (errorCode) -> - switch errorCode - when 'EACCES' then 'Permission denied' - when 'ECONNRESET' then 'Connection reset' - when 'EINTR' then 'Interrupted system call' - when 'EIO' then 'I/O error writing file' - when 'ENOSPC' then 'No space left on device' - when 'ENOTSUP' then 'Operation not supported on socket' - when 'ENXIO' then 'No such device or address' - when 'EROFS' then 'Read-only file system' - when 'ESPIPE' then 'Invalid seek' - when 'ETIMEDOUT' then 'Connection timed out' - -promisify = (callback) -> - try - Promise.resolve(callback()) - catch error - Promise.reject(error) diff --git a/src/pane.js b/src/pane.js new file mode 100644 index 000000000..0305b39dd --- /dev/null +++ b/src/pane.js @@ -0,0 +1,1252 @@ +const Grim = require('grim') +const {CompositeDisposable, Emitter} = require('event-kit') +const PaneAxis = require('./pane-axis') +const TextEditor = require('./text-editor') +const PaneElement = require('./pane-element') + +let nextInstanceId = 1 + +class SaveCancelledError extends Error {} + +// Extended: A container for presenting content in the center of the workspace. +// Panes can contain multiple items, one of which is *active* at a given time. +// The view corresponding to the active item is displayed in the interface. In +// the default configuration, tabs are also displayed for each item. +// +// Each pane may also contain one *pending* item. When a pending item is added +// to a pane, it will replace the currently pending item, if any, instead of +// simply being added. In the default configuration, the text in the tab for +// pending items is shown in italics. +module.exports = +class Pane { + inspect () { + return `Pane ${this.id}` + } + + static deserialize (state, {deserializers, applicationDelegate, config, notifications, views}) { + const {activeItemIndex} = state + const activeItemURI = state.activeItemURI || state.activeItemUri + + const items = [] + for (const itemState of state.items) { + const item = deserializers.deserialize(itemState) + if (item) items.push(item) + } + state.items = items + + state.activeItem = items[activeItemIndex] + if (!state.activeItem && activeItemURI) { + state.activeItem = state.items.find((item) => + typeof item.getURI === 'function' && item.getURI() === activeItemURI + ) + } + + return new Pane(Object.assign(state, { + deserializerManager: deserializers, + notificationManager: notifications, + viewRegistry: views, + config, + applicationDelegate + })) + } + + constructor (params = {}) { + this.setPendingItem = this.setPendingItem.bind(this) + this.getPendingItem = this.getPendingItem.bind(this) + this.clearPendingItem = this.clearPendingItem.bind(this) + this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind(this) + this.saveItem = this.saveItem.bind(this) + this.saveItemAs = this.saveItemAs.bind(this) + + this.id = params.id + if (this.id != null) { + nextInstanceId = Math.max(nextInstanceId, this.id + 1) + } else { + this.id = nextInstanceId++ + } + + this.activeItem = params.activeItem + this.focused = params.focused != null ? params.focused : false + this.applicationDelegate = params.applicationDelegate + this.notificationManager = params.notificationManager + this.config = params.config + this.deserializerManager = params.deserializerManager + this.viewRegistry = params.viewRegistry + + this.emitter = new Emitter() + this.alive = true + this.subscriptionsPerItem = new WeakMap() + this.items = [] + this.itemStack = [] + this.container = null + + this.addItems((params.items || []).filter(item => item)) + if (!this.getActiveItem()) this.setActiveItem(this.items[0]) + this.addItemsToStack(params.itemStackIndices || []) + this.setFlexScale(params.flexScale || 1) + } + + getElement () { + if (!this.element) { + this.element = new PaneElement().initialize( + this, + {views: this.viewRegistry, applicationDelegate: this.applicationDelegate} + ) + } + return this.element + } + + serialize () { + const itemsToBeSerialized = this.items.filter(item => item && typeof item.serialize === 'function') + + const itemStackIndices = [] + for (const item of this.itemStack) { + if (typeof item.serialize === 'function') { + itemStackIndices.push(itemsToBeSerialized.indexOf(item)) + } + } + + const activeItemIndex = itemsToBeSerialized.indexOf(this.activeItem) + + return { + deserializer: 'Pane', + id: this.id, + items: itemsToBeSerialized.map(item => item.serialize()), + itemStackIndices, + activeItemIndex, + focused: this.focused, + flexScale: this.flexScale + } + } + + getParent () { return this.parent } + + setParent (parent) { + this.parent = parent + } + + getContainer () { return this.container } + + setContainer (container) { + if (container && container !== this.container) { + this.container = container + container.didAddPane({pane: this}) + } + } + + // Private: Determine whether the given item is allowed to exist in this pane. + // + // * `item` the Item + // + // Returns a {Boolean}. + isItemAllowed (item) { + if (typeof item.getAllowedLocations !== 'function') { + return true + } else { + return item.getAllowedLocations().includes(this.getContainer().getLocation()) + } + } + + setFlexScale (flexScale) { + this.flexScale = flexScale + this.emitter.emit('did-change-flex-scale', this.flexScale) + return this.flexScale + } + + getFlexScale () { return this.flexScale } + + increaseSize () { this.setFlexScale(this.getFlexScale() * 1.1) } + + decreaseSize () { this.setFlexScale(this.getFlexScale() / 1.1) } + + /* + Section: Event Subscription + */ + + // Public: Invoke the given callback when the pane resizes + // + // The callback will be invoked when pane's flexScale property changes. + // Use {::getFlexScale} to get the current value. + // + // * `callback` {Function} to be called when the pane is resized + // * `flexScale` {Number} representing the panes `flex-grow`; ability for a + // flex item to grow if necessary. + // + // Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. + onDidChangeFlexScale (callback) { + return this.emitter.on('did-change-flex-scale', callback) + } + + // Public: Invoke the given callback with the current and future values of + // {::getFlexScale}. + // + // * `callback` {Function} to be called with the current and future values of + // the {::getFlexScale} property. + // * `flexScale` {Number} representing the panes `flex-grow`; ability for a + // flex item to grow if necessary. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeFlexScale (callback) { + callback(this.flexScale) + return this.onDidChangeFlexScale(callback) + } + + // Public: Invoke the given callback when the pane is activated. + // + // The given callback will be invoked whenever {::activate} is called on the + // pane, even if it is already active at the time. + // + // * `callback` {Function} to be called when the pane is activated. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidActivate (callback) { + return this.emitter.on('did-activate', callback) + } + + // Public: Invoke the given callback before the pane is destroyed. + // + // * `callback` {Function} to be called before the pane is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroy (callback) { + return this.emitter.on('will-destroy', callback) + } + + // Public: Invoke the given callback when the pane is destroyed. + // + // * `callback` {Function} to be called when the pane is destroyed. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroy (callback) { + return this.emitter.once('did-destroy', callback) + } + + // Public: Invoke the given callback when the value of the {::isActive} + // property changes. + // + // * `callback` {Function} to be called when the value of the {::isActive} + // property changes. + // * `active` {Boolean} indicating whether the pane is active. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActive (callback) { + return this.container.onDidChangeActivePane(activePane => { + const isActive = this === activePane + callback(isActive) + }) + } + + // Public: Invoke the given callback with the current and future values of the + // {::isActive} property. + // + // * `callback` {Function} to be called with the current and future values of + // the {::isActive} property. + // * `active` {Boolean} indicating whether the pane is active. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActive (callback) { + callback(this.isActive()) + return this.onDidChangeActive(callback) + } + + // Public: Invoke the given callback when an item is added to the pane. + // + // * `callback` {Function} to be called with when items are added. + // * `event` {Object} with the following keys: + // * `item` The added pane item. + // * `index` {Number} indicating where the item is located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddItem (callback) { + return this.emitter.on('did-add-item', callback) + } + + // Public: Invoke the given callback when an item is removed from the pane. + // + // * `callback` {Function} to be called with when items are removed. + // * `event` {Object} with the following keys: + // * `item` The removed pane item. + // * `index` {Number} indicating where the item was located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidRemoveItem (callback) { + return this.emitter.on('did-remove-item', callback) + } + + // Public: Invoke the given callback before an item is removed from the pane. + // + // * `callback` {Function} to be called with when items are removed. + // * `event` {Object} with the following keys: + // * `item` The pane item to be removed. + // * `index` {Number} indicating where the item is located. + onWillRemoveItem (callback) { + return this.emitter.on('will-remove-item', callback) + } + + // Public: Invoke the given callback when an item is moved within the pane. + // + // * `callback` {Function} to be called with when items are moved. + // * `event` {Object} with the following keys: + // * `item` The removed pane item. + // * `oldIndex` {Number} indicating where the item was located. + // * `newIndex` {Number} indicating where the item is now located. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidMoveItem (callback) { + return this.emitter.on('did-move-item', callback) + } + + // Public: Invoke the given callback with all current and future items. + // + // * `callback` {Function} to be called with current and future items. + // * `item` An item that is present in {::getItems} at the time of + // subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeItems (callback) { + for (let item of this.getItems()) { + callback(item) + } + return this.onDidAddItem(({item}) => callback(item)) + } + + // Public: Invoke the given callback when the value of {::getActiveItem} + // changes. + // + // * `callback` {Function} to be called with when the active item changes. + // * `activeItem` The current active item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActiveItem (callback) { + return this.emitter.on('did-change-active-item', callback) + } + + // Public: Invoke the given callback when {::activateNextRecentlyUsedItem} + // has been called, either initiating or continuing a forward MRU traversal of + // pane items. + // + // * `callback` {Function} to be called with when the active item changes. + // * `nextRecentlyUsedItem` The next MRU item, now being set active + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onChooseNextMRUItem (callback) { + return this.emitter.on('choose-next-mru-item', callback) + } + + // Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem} + // has been called, either initiating or continuing a reverse MRU traversal of + // pane items. + // + // * `callback` {Function} to be called with when the active item changes. + // * `previousRecentlyUsedItem` The previous MRU item, now being set active + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onChooseLastMRUItem (callback) { + return this.emitter.on('choose-last-mru-item', callback) + } + + // Public: Invoke the given callback when {::moveActiveItemToTopOfStack} + // has been called, terminating an MRU traversal of pane items and moving the + // current active item to the top of the stack. Typically bound to a modifier + // (e.g. CTRL) key up event. + // + // * `callback` {Function} to be called with when the MRU traversal is done. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDoneChoosingMRUItem (callback) { + return this.emitter.on('done-choosing-mru-item', callback) + } + + // Public: Invoke the given callback with the current and future values of + // {::getActiveItem}. + // + // * `callback` {Function} to be called with the current and future active + // items. + // * `activeItem` The current active item. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActiveItem (callback) { + callback(this.getActiveItem()) + return this.onDidChangeActiveItem(callback) + } + + // Public: Invoke the given callback before items are destroyed. + // + // * `callback` {Function} to be called before items are destroyed. + // * `event` {Object} with the following keys: + // * `item` The item that will be destroyed. + // * `index` The location of the item. + // + // Returns a {Disposable} on which `.dispose()` can be called to + // unsubscribe. + onWillDestroyItem (callback) { + return this.emitter.on('will-destroy-item', callback) + } + + // Called by the view layer to indicate that the pane has gained focus. + focus () { + this.focused = true + return this.activate() + } + + // Called by the view layer to indicate that the pane has lost focus. + blur () { + this.focused = false + return true // if this is called from an event handler, don't cancel it + } + + isFocused () { return this.focused } + + getPanes () { return [this] } + + unsubscribeFromItem (item) { + const subscription = this.subscriptionsPerItem.get(item) + if (subscription) { + subscription.dispose() + this.subscriptionsPerItem.delete(item) + } + } + + /* + Section: Items + */ + + // Public: Get the items in this pane. + // + // Returns an {Array} of items. + getItems () { + return this.items.slice() + } + + // Public: Get the active pane item in this pane. + // + // Returns a pane item. + getActiveItem () { return this.activeItem } + + setActiveItem (activeItem, options) { + const modifyStack = options && options.modifyStack + if (activeItem !== this.activeItem) { + if (modifyStack !== false) this.addItemToStack(activeItem) + this.activeItem = activeItem + this.emitter.emit('did-change-active-item', this.activeItem) + if (this.container) this.container.didChangeActiveItemOnPane(this, this.activeItem) + } + return this.activeItem + } + + // Build the itemStack after deserializing + addItemsToStack (itemStackIndices) { + if (this.items.length > 0) { + if (itemStackIndices.length !== this.items.length || itemStackIndices.includes(-1)) { + itemStackIndices = this.items.map((item, i) => i) + } + + for (let itemIndex of itemStackIndices) { + this.addItemToStack(this.items[itemIndex]) + } + } + } + + // Add item (or move item) to the end of the itemStack + addItemToStack (newItem) { + if (newItem == null) { return } + const index = this.itemStack.indexOf(newItem) + if (index !== -1) this.itemStack.splice(index, 1) + return this.itemStack.push(newItem) + } + + // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. + getActiveEditor () { + if (this.activeItem instanceof TextEditor) return this.activeItem + } + + // Public: Return the item at the given index. + // + // * `index` {Number} + // + // Returns an item or `null` if no item exists at the given index. + itemAtIndex (index) { + return this.items[index] + } + + // Makes the next item in the itemStack active. + activateNextRecentlyUsedItem () { + if (this.items.length > 1) { + if (this.itemStackIndex == null) this.itemStackIndex = this.itemStack.length - 1 + if (this.itemStackIndex === 0) this.itemStackIndex = this.itemStack.length + this.itemStackIndex-- + const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex] + this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem) + this.setActiveItem(nextRecentlyUsedItem, {modifyStack: false}) + } + } + + // Makes the previous item in the itemStack active. + activatePreviousRecentlyUsedItem () { + if (this.items.length > 1) { + if (this.itemStackIndex + 1 === this.itemStack.length || this.itemStackIndex == null) { + this.itemStackIndex = -1 + } + this.itemStackIndex++ + const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex] + this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem) + this.setActiveItem(previousRecentlyUsedItem, {modifyStack: false}) + } + } + + // Moves the active item to the end of the itemStack once the ctrl key is lifted + moveActiveItemToTopOfStack () { + delete this.itemStackIndex + this.addItemToStack(this.activeItem) + this.emitter.emit('done-choosing-mru-item') + } + + // Public: Makes the next item active. + activateNextItem () { + const index = this.getActiveItemIndex() + if (index < (this.items.length - 1)) { + this.activateItemAtIndex(index + 1) + } else { + this.activateItemAtIndex(0) + } + } + + // Public: Makes the previous item active. + activatePreviousItem () { + const index = this.getActiveItemIndex() + if (index > 0) { + this.activateItemAtIndex(index - 1) + } else { + this.activateItemAtIndex(this.items.length - 1) + } + } + + activateLastItem () { + this.activateItemAtIndex(this.items.length - 1) + } + + // Public: Move the active tab to the right. + moveItemRight () { + const index = this.getActiveItemIndex() + const rightItemIndex = index + 1 + if (rightItemIndex <= this.items.length - 1) this.moveItem(this.getActiveItem(), rightItemIndex) + } + + // Public: Move the active tab to the left + moveItemLeft () { + const index = this.getActiveItemIndex() + const leftItemIndex = index - 1 + if (leftItemIndex >= 0) return this.moveItem(this.getActiveItem(), leftItemIndex) + } + + // Public: Get the index of the active item. + // + // Returns a {Number}. + getActiveItemIndex () { + return this.items.indexOf(this.activeItem) + } + + // Public: Activate the item at the given index. + // + // * `index` {Number} + activateItemAtIndex (index) { + const item = this.itemAtIndex(index) || this.getActiveItem() + return this.setActiveItem(item) + } + + // Public: Make the given item *active*, causing it to be displayed by + // the pane's view. + // + // * `item` The item to activate + // * `options` (optional) {Object} + // * `pending` (optional) {Boolean} indicating that the item should be added + // in a pending state if it does not yet exist in the pane. Existing pending + // items in a pane are replaced with new pending items when they are opened. + activateItem (item, options = {}) { + if (item) { + const index = (this.getPendingItem() === this.activeItem) + ? this.getActiveItemIndex() + : this.getActiveItemIndex() + 1 + this.addItem(item, Object.assign({}, options, {index})) + this.setActiveItem(item) + } + } + + // Public: Add the given item to the pane. + // + // * `item` The item to add. It can be a model with an associated view or a + // view. + // * `options` (optional) {Object} + // * `index` (optional) {Number} indicating the index at which to add the item. + // If omitted, the item is added after the current active item. + // * `pending` (optional) {Boolean} indicating that the item should be + // added in a pending state. Existing pending items in a pane are replaced with + // new pending items when they are opened. + // + // Returns the added item. + addItem (item, options = {}) { + // Backward compat with old API: + // addItem(item, index=@getActiveItemIndex() + 1) + if (typeof options === 'number') { + Grim.deprecate(`Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})`) + options = {index: options} + } + + const index = options.index != null ? options.index : this.getActiveItemIndex() + 1 + const moved = options.moved != null ? options.moved : false + const pending = options.pending != null ? options.pending : false + + if (!item || typeof item !== 'object') { + throw new Error(`Pane items must be objects. Attempted to add item ${item}.`) + } + + if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { + throw new Error(`Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed`) + } + + if (this.items.includes(item)) return + + if (typeof item.onDidDestroy === 'function') { + const itemSubscriptions = new CompositeDisposable() + itemSubscriptions.add(item.onDidDestroy(() => this.removeItem(item, false))) + if (typeof item.onDidTerminatePendingState === 'function') { + itemSubscriptions.add(item.onDidTerminatePendingState(() => { + if (this.getPendingItem() === item) this.clearPendingItem() + })) + } + this.subscriptionsPerItem.set(item, itemSubscriptions) + } + + this.items.splice(index, 0, item) + const lastPendingItem = this.getPendingItem() + const replacingPendingItem = lastPendingItem != null && !moved + if (replacingPendingItem) this.pendingItem = null + if (pending) this.setPendingItem(item) + + this.emitter.emit('did-add-item', {item, index, moved}) + if (!moved) { + if (this.container) this.container.didAddPaneItem(item, this, index) + } + + if (replacingPendingItem) this.destroyItem(lastPendingItem) + if (!this.getActiveItem()) this.setActiveItem(item) + return item + } + + setPendingItem (item) { + if (this.pendingItem !== item) { + const mostRecentPendingItem = this.pendingItem + this.pendingItem = item + if (mostRecentPendingItem) { + this.emitter.emit('item-did-terminate-pending-state', mostRecentPendingItem) + } + } + } + + getPendingItem () { + return this.pendingItem || null + } + + clearPendingItem () { + this.setPendingItem(null) + } + + onItemDidTerminatePendingState (callback) { + return this.emitter.on('item-did-terminate-pending-state', callback) + } + + // Public: Add the given items to the pane. + // + // * `items` An {Array} of items to add. Items can be views or models with + // associated views. Any objects that are already present in the pane's + // current items will not be added again. + // * `index` (optional) {Number} index at which to add the items. If omitted, + // the item is # added after the current active item. + // + // Returns an {Array} of added items. + addItems (items, index = this.getActiveItemIndex() + 1) { + items = items.filter(item => !this.items.includes(item)) + for (let i = 0; i < items.length; i++) { + const item = items[i] + this.addItem(item, {index: index + i}) + } + return items + } + + removeItem (item, moved) { + const index = this.items.indexOf(item) + if (index === -1) return + if (this.getPendingItem() === item) this.pendingItem = null + this.removeItemFromStack(item) + this.emitter.emit('will-remove-item', {item, index, destroyed: !moved, moved}) + this.unsubscribeFromItem(item) + + if (item === this.activeItem) { + if (this.items.length === 1) { + this.setActiveItem(undefined) + } else if (index === 0) { + this.activateNextItem() + } else { + this.activatePreviousItem() + } + } + this.items.splice(index, 1) + this.emitter.emit('did-remove-item', {item, index, destroyed: !moved, moved}) + if (!moved && this.container) this.container.didDestroyPaneItem({item, index, pane: this}) + if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) this.destroy() + } + + // Remove the given item from the itemStack. + // + // * `item` The item to remove. + // * `index` {Number} indicating the index to which to remove the item from the itemStack. + removeItemFromStack (item) { + const index = this.itemStack.indexOf(item) + if (index !== -1) this.itemStack.splice(index, 1) + } + + // Public: Move the given item to the given index. + // + // * `item` The item to move. + // * `index` {Number} indicating the index to which to move the item. + moveItem (item, newIndex) { + const oldIndex = this.items.indexOf(item) + this.items.splice(oldIndex, 1) + this.items.splice(newIndex, 0, item) + this.emitter.emit('did-move-item', {item, oldIndex, newIndex}) + } + + // Public: Move the given item to the given index on another pane. + // + // * `item` The item to move. + // * `pane` {Pane} to which to move the item. + // * `index` {Number} indicating the index to which to move the item in the + // given pane. + moveItemToPane (item, pane, index) { + this.removeItem(item, true) + return pane.addItem(item, {index, moved: true}) + } + + // Public: Destroy the active item and activate the next item. + // + // Returns a {Promise} that resolves when the item is destroyed. + destroyActiveItem () { + return this.destroyItem(this.activeItem) + } + + // Public: Destroy the given item. + // + // If the item is active, the next item will be activated. If the item is the + // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config + // setting is `true`. + // + // * `item` Item to destroy + // * `force` (optional) {Boolean} Destroy the item without prompting to save + // it, even if the item's `isPermanentDockItem` method returns true. + // + // Returns a {Promise} that resolves with a {Boolean} indicating whether or not + // the item was destroyed. + async destroyItem (item, force) { + const index = this.items.indexOf(item) + if (index === -1) return false + + if (!force && + typeof item.isPermanentDockItem === 'function' && item.isPermanentDockItem() && + (!this.container || this.container.getLocation() !== 'center')) { + return false + } + + // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior + // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. + if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { + await this.emitter.emitAsync('will-destroy-item', {item, index}) + } + if (this.container && this.container.emitter.listenerCountForEventName('will-destroy-pane-item') > 0) { + await this.container.willDestroyPaneItem({item, index, pane: this}) + } + + if (!force && typeof item.shouldPromptToSave === 'function' && item.shouldPromptToSave()) { + if (!await this.promptToSaveItem(item)) return false + } + this.removeItem(item, false) + if (typeof item.destroy === 'function') item.destroy() + return true + } + + // Public: Destroy all items. + destroyItems () { + return Promise.all( + this.getItems().map(item => this.destroyItem(item)) + ) + } + + // Public: Destroy all items except for the active item. + destroyInactiveItems () { + return Promise.all( + this.getItems() + .filter(item => item !== this.activeItem) + .map(item => this.destroyItem(item)) + ) + } + + promptToSaveItem (item, options = {}) { + if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) { + return Promise.resolve(true) + } + + let uri + if (typeof item.getURI === 'function') { + uri = item.getURI() + } else if (typeof item.getUri === 'function') { + uri = item.getUri() + } else { + return Promise.resolve(true) + } + + const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri + + const saveDialog = (saveButtonText, saveFn, message) => { + const chosen = this.applicationDelegate.confirm({ + message, + detailedMessage: 'Your changes will be lost if you close this item without saving.', + buttons: [saveButtonText, 'Cancel', "&Don't Save"]} + ) + + switch (chosen) { + case 0: + return new Promise(resolve => { + return saveFn(item, error => { + if (error instanceof SaveCancelledError) { + resolve(false) + } else if (error) { + saveDialog( + 'Save as', + this.saveItemAs, + `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}` + ).then(resolve) + } else { + resolve(true) + } + }) + }) + case 1: + return Promise.resolve(false) + case 2: + return Promise.resolve(true) + } + } + + return saveDialog( + 'Save', + this.saveItem, + `'${title}' has changes, do you want to save them?` + ) + } + + // Public: Save the active item. + saveActiveItem (nextAction) { + return this.saveItem(this.getActiveItem(), nextAction) + } + + // Public: Prompt the user for a location and save the active item with the + // path they select. + // + // * `nextAction` (optional) {Function} which will be called after the item is + // successfully saved. + // + // Returns a {Promise} that resolves when the save is complete + saveActiveItemAs (nextAction) { + return this.saveItemAs(this.getActiveItem(), nextAction) + } + + // Public: Save the given item. + // + // * `item` The item to save. + // * `nextAction` (optional) {Function} which will be called with no argument + // after the item is successfully saved, or with the error if it failed. + // The return value will be that of `nextAction` or `undefined` if it was not + // provided + // + // Returns a {Promise} that resolves when the save is complete + saveItem (item, nextAction) { + if (!item) return Promise.resolve() + + let itemURI + if (typeof item.getURI === 'function') { + itemURI = item.getURI() + } else if (typeof item.getUri === 'function') { + itemURI = item.getUri() + } + + if (itemURI != null) { + if (typeof item.save === 'function') { + return promisify(() => item.save()) + .then(() => { + if (nextAction) nextAction() + }) + .catch(error => { + if (nextAction) { + nextAction(error) + } else { + this.handleSaveError(error, item) + } + }) + } else if (nextAction) { + nextAction() + return Promise.resolve() + } + } else { + return this.saveItemAs(item, nextAction) + } + } + + // Public: Prompt the user for a location and save the active item with the + // path they select. + // + // * `item` The item to save. + // * `nextAction` (optional) {Function} which will be called with no argument + // after the item is successfully saved, or with the error if it failed. + // The return value will be that of `nextAction` or `undefined` if it was not + // provided + saveItemAs (item, nextAction) { + if (!item) return + if (typeof item.saveAs !== 'function') return + + const saveOptions = typeof item.getSaveDialogOptions === 'function' + ? item.getSaveDialogOptions() + : {} + + const itemPath = item.getPath() + if (itemPath && !saveOptions.defaultPath) saveOptions.defaultPath = itemPath + + const newItemPath = this.applicationDelegate.showSaveDialog(saveOptions) + if (newItemPath) { + return promisify(() => item.saveAs(newItemPath)) + .then(() => { + if (nextAction) nextAction() + }) + .catch(error => { + if (nextAction) { + nextAction(error) + } else { + this.handleSaveError(error, item) + } + }) + } else if (nextAction) { + return nextAction(new SaveCancelledError('Save Cancelled')) + } + } + + // Public: Save all items. + saveItems () { + for (let item of this.getItems()) { + if (typeof item.isModified === 'function' && item.isModified()) { + this.saveItem(item) + } + } + } + + // Public: Return the first item that matches the given URI or undefined if + // none exists. + // + // * `uri` {String} containing a URI. + itemForURI (uri) { + return this.items.find(item => { + if (typeof item.getURI === 'function') { + return item.getURI() === uri + } else if (typeof item.getUri === 'function') { + return item.getUri() === uri + } + }) + } + + // Public: Activate the first item that matches the given URI. + // + // * `uri` {String} containing a URI. + // + // Returns a {Boolean} indicating whether an item matching the URI was found. + activateItemForURI (uri) { + const item = this.itemForURI(uri) + if (item) { + this.activateItem(item) + return true + } else { + return false + } + } + + copyActiveItem () { + if (this.activeItem && typeof this.activeItem.copy === 'function') { + return this.activeItem.copy() + } + } + + /* + Section: Lifecycle + */ + + // Public: Determine whether the pane is active. + // + // Returns a {Boolean}. + isActive () { + return this.container && this.container.getActivePane() === this + } + + // Public: Makes this pane the *active* pane, causing it to gain focus. + activate () { + if (this.isDestroyed()) throw new Error('Pane has been destroyed') + if (this.container) this.container.didActivatePane(this) + this.emitter.emit('did-activate') + } + + // Public: Close the pane and destroy all its items. + // + // If this is the last pane, all the items will be destroyed but the pane + // itself will not be destroyed. + destroy () { + if (this.container && this.container.isAlive() && this.container.getPanes().length === 1) { + return this.destroyItems() + } + + this.emitter.emit('will-destroy') + this.alive = false + if (this.container) { + this.container.willDestroyPane({pane: this}) + if (this.isActive()) this.container.activateNextPane() + } + this.emitter.emit('did-destroy') + this.emitter.dispose() + for (let item of this.items.slice()) { + if (typeof item.destroy === 'function') item.destroy() + } + if (this.container) this.container.didDestroyPane({pane: this}) + } + + isAlive () { return this.alive } + + // Public: Determine whether this pane has been destroyed. + // + // Returns a {Boolean}. + isDestroyed () { return !this.isAlive() } + + /* + Section: Splitting + */ + + // Public: Create a new pane to the left of this pane. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitLeft (params) { + return this.split('horizontal', 'before', params) + } + + // Public: Create a new pane to the right of this pane. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitRight (params) { + return this.split('horizontal', 'after', params) + } + + // Public: Creates a new pane above the receiver. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitUp (params) { + return this.split('vertical', 'before', params) + } + + // Public: Creates a new pane below the receiver. + // + // * `params` (optional) {Object} with the following keys: + // * `items` (optional) {Array} of items to add to the new pane. + // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane + // + // Returns the new {Pane}. + splitDown (params) { + return this.split('vertical', 'after', params) + } + + split (orientation, side, params) { + if (params && params.copyActiveItem) { + if (!params.items) params.items = [] + params.items.push(this.copyActiveItem()) + } + + if (this.parent.orientation !== orientation) { + this.parent.replaceChild(this, new PaneAxis({ + container: this.container, + orientation, + children: [this], + flexScale: this.flexScale}, + this.viewRegistry + )) + this.setFlexScale(1) + } + + const newPane = new Pane(Object.assign({ + applicationDelegate: this.applicationDelegate, + notificationManager: this.notificationManager, + deserializerManager: this.deserializerManager, + config: this.config, + viewRegistry: this.viewRegistry + }, params)) + + switch (side) { + case 'before': this.parent.insertChildBefore(this, newPane); break + case 'after': this.parent.insertChildAfter(this, newPane); break + } + + if (params && params.moveActiveItem && this.activeItem) this.moveItemToPane(this.activeItem, newPane) + + newPane.activate() + return newPane + } + + // If the parent is a horizontal axis, returns its first child if it is a pane; + // otherwise returns this pane. + findLeftmostSibling () { + if (this.parent.orientation === 'horizontal') { + const [leftmostSibling] = this.parent.children + if (leftmostSibling instanceof PaneAxis) { + return this + } else { + return leftmostSibling + } + } else { + return this + } + } + + findRightmostSibling () { + if (this.parent.orientation === 'horizontal') { + const rightmostSibling = this.parent.children[this.parent.children.length - 1] + if (rightmostSibling instanceof PaneAxis) { + return this + } else { + return rightmostSibling + } + } else { + return this + } + } + + // If the parent is a horizontal axis, returns its last child if it is a pane; + // otherwise returns a new pane created by splitting this pane rightward. + findOrCreateRightmostSibling () { + const rightmostSibling = this.findRightmostSibling() + if (rightmostSibling === this) { + return this.splitRight() + } else { + return rightmostSibling + } + } + + // If the parent is a vertical axis, returns its first child if it is a pane; + // otherwise returns this pane. + findTopmostSibling () { + if (this.parent.orientation === 'vertical') { + const [topmostSibling] = this.parent.children + if (topmostSibling instanceof PaneAxis) { + return this + } else { + return topmostSibling + } + } else { + return this + } + } + + findBottommostSibling () { + if (this.parent.orientation === 'vertical') { + const bottommostSibling = this.parent.children[this.parent.children.length - 1] + if (bottommostSibling instanceof PaneAxis) { + return this + } else { + return bottommostSibling + } + } else { + return this + } + } + + // If the parent is a vertical axis, returns its last child if it is a pane; + // otherwise returns a new pane created by splitting this pane bottomward. + findOrCreateBottommostSibling () { + const bottommostSibling = this.findBottommostSibling() + if (bottommostSibling === this) { + return this.splitDown() + } else { + return bottommostSibling + } + } + + // Private: Close the pane unless the user cancels the action via a dialog. + // + // Returns a {Promise} that resolves once the pane is either closed, or the + // closing has been cancelled. + close () { + return Promise.all(this.getItems().map(item => this.promptToSaveItem(item))) + .then(results => { + if (!results.includes(false)) return this.destroy() + }) + } + + handleSaveError (error, item) { + const itemPath = error.path || (typeof item.getPath === 'function' && item.getPath()) + const addWarningWithPath = (message, options) => { + if (itemPath) message = `${message} '${itemPath}'` + this.notificationManager.addWarning(message, options) + } + + const customMessage = this.getMessageForErrorCode(error.code) + if (customMessage != null) { + addWarningWithPath(`Unable to save file: ${customMessage}`) + } else if (error.code === 'EISDIR' || (error.message && error.message.endsWith('is a directory'))) { + return this.notificationManager.addWarning(`Unable to save file: ${error.message}`) + } else if (['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes(error.code)) { + addWarningWithPath('Unable to save file', {detail: error.message}) + } else { + const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) + if (errorMatch) { + const fileName = errorMatch[1] + this.notificationManager.addWarning(`Unable to save file: A directory in the path '${fileName}' could not be written to`) + } else { + throw error + } + } + } + + getMessageForErrorCode (errorCode) { + switch (errorCode) { + case 'EACCES': return 'Permission denied' + case 'ECONNRESET': return 'Connection reset' + case 'EINTR': return 'Interrupted system call' + case 'EIO': return 'I/O error writing file' + case 'ENOSPC': return 'No space left on device' + case 'ENOTSUP': return 'Operation not supported on socket' + case 'ENXIO': return 'No such device or address' + case 'EROFS': return 'Read-only file system' + case 'ESPIPE': return 'Invalid seek' + case 'ETIMEDOUT': return 'Connection timed out' + } + } +} + +function promisify (callback) { + try { + return Promise.resolve(callback()) + } catch (error) { + return Promise.reject(error) + } +} diff --git a/src/project.coffee b/src/project.coffee index cad5f03ac..ab41f9eb3 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -30,6 +30,8 @@ class Project extends Model @repositoryProviders = [new GitRepositoryProvider(this, config)] @loadPromisesByPath = {} @watcherPromisesByPath = {} + @retiredBufferIDs = new Set() + @retiredBufferPaths = new Set() @consumeServices(packageManager) destroyed: -> @@ -47,6 +49,8 @@ class Project extends Model @buffers = [] @setPaths([]) @loadPromisesByPath = {} + @retiredBufferIDs = new Set() + @retiredBufferPaths = new Set() @consumeServices(packageManager) destroyUnretainedBuffers: -> @@ -58,21 +62,28 @@ class Project extends Model ### deserialize: (state) -> - bufferPromises = [] - for bufferState in state.buffers - continue if fs.isDirectorySync(bufferState.filePath) - if bufferState.filePath - try - fs.closeSync(fs.openSync(bufferState.filePath, 'r')) - catch error - continue unless error.code is 'ENOENT' - unless bufferState.shouldDestroyOnFileDelete? - bufferState.shouldDestroyOnFileDelete = -> - atom.config.get('core.closeDeletedFileTabs') - bufferPromises.push(TextBuffer.deserialize(bufferState)) - Promise.all(bufferPromises).then (@buffers) => + @retiredBufferIDs = new Set() + @retiredBufferPaths = new Set() + + handleBufferState = (bufferState) => + bufferState.shouldDestroyOnFileDelete ?= -> atom.config.get('core.closeDeletedFileTabs') + + # Use a little guilty knowledge of the way TextBuffers are serialized. + # This allows TextBuffers that have never been saved (but have filePaths) to be deserialized, but prevents + # TextBuffers backed by files that have been deleted from being saved. + bufferState.mustExist = bufferState.digestWhenLastPersisted isnt false + + TextBuffer.deserialize(bufferState).catch (err) => + @retiredBufferIDs.add(bufferState.id) + @retiredBufferPaths.add(bufferState.filePath) + null + + bufferPromises = (handleBufferState(bufferState) for bufferState in state.buffers) + + Promise.all(bufferPromises).then (buffers) => + @buffers = buffers.filter(Boolean) @subscribeToBuffer(buffer) for buffer in @buffers - @setPaths(state.paths) + @setPaths(state.paths or [], mustExist: true, exact: true) serialize: (options={}) -> deserializer: 'Project' @@ -211,7 +222,12 @@ class Project extends Model # Public: Set the paths of the project's directories. # # * `projectPaths` {Array} of {String} paths. - setPaths: (projectPaths) -> + # * `options` An optional {Object} that may contain the following keys: + # * `mustExist` If `true`, throw an Error if any `projectPaths` do not exist. Any remaining `projectPaths` that + # do exist will still be added to the project. Default: `false`. + # * `exact` If `true`, only add a `projectPath` if it names an existing directory. If `false` and any `projectPath` + # is a file or does not exist, its parent directory will be added instead. Default: `false`. + setPaths: (projectPaths, options = {}) -> repository?.destroy() for repository in @repositories @rootDirectories = [] @repositories = [] @@ -219,16 +235,46 @@ class Project extends Model watcher.then((w) -> w.dispose()) for _, watcher in @watcherPromisesByPath @watcherPromisesByPath = {} - @addPath(projectPath, emitEvent: false) for projectPath in projectPaths + missingProjectPaths = [] + for projectPath in projectPaths + try + @addPath projectPath, emitEvent: false, mustExist: true, exact: options.exact is true + catch e + if e.missingProjectPaths? + missingProjectPaths.push e.missingProjectPaths... + else + throw e @emitter.emit 'did-change-paths', projectPaths + if options.mustExist is true and missingProjectPaths.length > 0 + err = new Error "One or more project directories do not exist" + err.missingProjectPaths = missingProjectPaths + throw err + # Public: Add a path to the project's list of root paths # # * `projectPath` {String} The path to the directory to add. - addPath: (projectPath, options) -> + # * `options` An optional {Object} that may contain the following keys: + # * `mustExist` If `true`, throw an Error if the `projectPath` does not exist. If `false`, a `projectPath` that does + # not exist is ignored. Default: `false`. + # * `exact` If `true`, only add `projectPath` if it names an existing directory. If `false`, if `projectPath` is a + # a file or does not exist, its parent directory will be added instead. + addPath: (projectPath, options = {}) -> directory = @getDirectoryForProjectPath(projectPath) - return unless directory.existsSync() + + ok = true + ok = ok and directory.getPath() is projectPath if options.exact is true + ok = ok and directory.existsSync() + + unless ok + if options.mustExist is true + err = new Error "Project directory #{directory} does not exist" + err.missingProjectPaths = [projectPath] + throw err + else + return + for existingDirectory in @getDirectories() return if existingDirectory.getPath() is directory.getPath() @@ -248,7 +294,7 @@ class Project extends Model break if repo = provider.repositoryForDirectorySync?(directory) @repositories.push(repo ? null) - unless options?.emitEvent is false + unless options.emitEvent is false @emitter.emit 'did-change-paths', @getPaths() getDirectoryForProjectPath: (projectPath) -> @@ -412,11 +458,13 @@ class Project extends Model # Only to be used in specs bufferForPathSync: (filePath) -> absoluteFilePath = @resolvePath(filePath) + return null if @retiredBufferPaths.has absoluteFilePath existingBuffer = @findBufferForPath(absoluteFilePath) if filePath existingBuffer ? @buildBufferSync(absoluteFilePath) # Only to be used when deserializing bufferForIdSync: (id) -> + return null if @retiredBufferIDs.has id existingBuffer = @findBufferForId(id) if id existingBuffer ? @buildBufferSync() diff --git a/src/text-editor.coffee b/src/text-editor.coffee index b97e63957..a84f6f631 100644 --- a/src/text-editor.coffee +++ b/src/text-editor.coffee @@ -128,7 +128,10 @@ class TextEditor extends Model state.tokenizedBuffer = state.displayBuffer.tokenizedBuffer try - state.tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) + tokenizedBuffer = TokenizedBuffer.deserialize(state.tokenizedBuffer, atomEnvironment) + return null unless tokenizedBuffer? + + state.tokenizedBuffer = tokenizedBuffer state.tabLength = state.tokenizedBuffer.getTabLength() catch error if error.syscall is 'read' diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 8fca6c06b..e4d954a59 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -23,11 +23,15 @@ class TokenizedBuffer extends Model changeCount: 0 @deserialize: (state, atomEnvironment) -> + buffer = null if state.bufferId - state.buffer = atomEnvironment.project.bufferForIdSync(state.bufferId) + buffer = atomEnvironment.project.bufferForIdSync(state.bufferId) else # TODO: remove this fallback after everyone transitions to the latest version. - state.buffer = atomEnvironment.project.bufferForPathSync(state.bufferPath) + buffer = atomEnvironment.project.bufferForPathSync(state.bufferPath) + return null unless buffer? + + state.buffer = buffer state.assert = atomEnvironment.assert new this(state)