const {extend} = require('underscore-plus') const {Emitter} = require('event-kit') const Grim = require('grim') const Pane = require('../src/pane') const PaneContainer = require('../src/pane-container') const {it, fit, ffit, fffit, beforeEach, conditionPromise, timeoutPromise} = require('./async-spec-helpers') describe('Pane', () => { let confirm, showSaveDialog, deserializerDisposable class Item { static deserialize ({name, uri}) { return new Item(name, uri) } constructor (name, uri) { this.name = name this.uri = uri this.emitter = new Emitter() this.destroyed = false } getURI () { return this.uri } getPath () { return this.path } isEqual (other) { return this.name === (other && other.name) } isPermanentDockItem () { return false } isDestroyed () { return this.destroyed } serialize () { return {deserializer: 'Item', name: this.name, uri: this.uri} } copy () { return new Item(this.name, this.uri) } destroy () { this.destroyed = true return this.emitter.emit('did-destroy') } onDidDestroy (fn) { return this.emitter.on('did-destroy', fn) } onDidTerminatePendingState (callback) { return this.emitter.on('terminate-pending-state', callback) } terminatePendingState () { return this.emitter.emit('terminate-pending-state') } } beforeEach(() => { confirm = spyOn(atom.applicationDelegate, 'confirm') showSaveDialog = spyOn(atom.applicationDelegate, 'showSaveDialog') deserializerDisposable = atom.deserializers.add(Item) }) afterEach(() => { deserializerDisposable.dispose() }) function paneParams (params) { return extend({ applicationDelegate: atom.applicationDelegate, config: atom.config, deserializerManager: atom.deserializers, notificationManager: atom.notifications }, params) } describe('construction', () => { it('sets the active item to the first item', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) expect(pane.getActiveItem()).toBe(pane.itemAtIndex(0)) }) it('compacts the items array', () => { const pane = new Pane(paneParams({items: [undefined, new Item('A'), null, new Item('B')]})) expect(pane.getItems().length).toBe(2) expect(pane.getActiveItem()).toBe(pane.itemAtIndex(0)) }) }) describe('::activate()', () => { let container, pane1, pane2 beforeEach(() => { container = new PaneContainer({ location: 'center', config: atom.config, applicationDelegate: atom.applicationDelegate }) container.getActivePane().splitRight() ;[pane1, pane2] = container.getPanes() }) it('changes the active pane on the container', () => { expect(container.getActivePane()).toBe(pane2) pane1.activate() expect(container.getActivePane()).toBe(pane1) pane2.activate() expect(container.getActivePane()).toBe(pane2) }) it('invokes ::onDidChangeActivePane observers on the container', () => { const observed = [] container.onDidChangeActivePane(activePane => observed.push(activePane)) pane1.activate() pane1.activate() pane2.activate() pane1.activate() expect(observed).toEqual([pane1, pane2, pane1]) }) it('invokes ::onDidChangeActive observers on the relevant panes', () => { const observed = [] pane1.onDidChangeActive(active => observed.push(active)) pane1.activate() pane2.activate() expect(observed).toEqual([true, false]) }) it('invokes ::onDidActivate() observers', () => { let eventCount = 0 pane1.onDidActivate(() => eventCount++) pane1.activate() pane1.activate() pane2.activate() expect(eventCount).toBe(2) }) }) describe('::addItem(item, index)', () => { it('adds the item at the given index', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const [item1, item2] = pane.getItems() const item3 = new Item('C') pane.addItem(item3, {index: 1}) expect(pane.getItems()).toEqual([item1, item3, item2]) }) it('adds the item after the active item if no index is provided', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1, item2, item3] = pane.getItems() pane.activateItem(item2) const item4 = new Item('D') pane.addItem(item4) expect(pane.getItems()).toEqual([item1, item2, item4, item3]) }) it('sets the active item after adding the first item', () => { const pane = new Pane(paneParams()) const item = new Item('A') pane.addItem(item) expect(pane.getActiveItem()).toBe(item) }) it('invokes ::onDidAddItem() observers', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const events = [] pane.onDidAddItem(event => events.push(event)) const item = new Item('C') pane.addItem(item, {index: 1}) expect(events).toEqual([{item, index: 1, moved: false}]) }) it('throws an exception if the item is already present on a pane', () => { const item = new Item('A') const container = new PaneContainer({config: atom.config, applicationDelegate: atom.applicationDelegate}) const pane1 = container.getActivePane() pane1.addItem(item) const pane2 = pane1.splitRight() expect(() => pane2.addItem(item)).toThrow() }) it("throws an exception if the item isn't an object", () => { const pane = new Pane(paneParams({items: []})) expect(() => pane.addItem(null)).toThrow() expect(() => pane.addItem('foo')).toThrow() expect(() => pane.addItem(1)).toThrow() }) it('destroys any existing pending item', () => { const pane = new Pane(paneParams({items: []})) const itemA = new Item('A') const itemB = new Item('B') const itemC = new Item('C') pane.addItem(itemA, {pending: false}) pane.addItem(itemB, {pending: true}) pane.addItem(itemC, {pending: false}) expect(itemB.isDestroyed()).toBe(true) }) it('adds the new item before destroying any existing pending item', () => { const eventOrder = [] const pane = new Pane(paneParams({items: []})) const itemA = new Item('A') const itemB = new Item('B') pane.addItem(itemA, {pending: true}) pane.onDidAddItem(function ({item}) { if (item === itemB) eventOrder.push('add') }) pane.onDidRemoveItem(function ({item}) { if (item === itemA) eventOrder.push('remove') }) pane.addItem(itemB) waitsFor(() => eventOrder.length === 2) runs(() => expect(eventOrder).toEqual(['add', 'remove'])) }) describe('when using the old API of ::addItem(item, index)', () => { beforeEach(() => spyOn(Grim, 'deprecate')) it('supports the older public API', () => { const pane = new Pane(paneParams({items: []})) const itemA = new Item('A') const itemB = new Item('B') const itemC = new Item('C') pane.addItem(itemA, 0) pane.addItem(itemB, 0) pane.addItem(itemC, 0) expect(pane.getItems()).toEqual([itemC, itemB, itemA]) }) it('shows a deprecation warning', () => { const pane = new Pane(paneParams({items: []})) pane.addItem(new Item(), 2) expect(Grim.deprecate).toHaveBeenCalledWith('Pane::addItem(item, 2) is deprecated in favor of Pane::addItem(item, {index: 2})') }) }) }) describe('::activateItem(item)', () => { let pane = null beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) }) it('changes the active item to the current item', () => { expect(pane.getActiveItem()).toBe(pane.itemAtIndex(0)) pane.activateItem(pane.itemAtIndex(1)) expect(pane.getActiveItem()).toBe(pane.itemAtIndex(1)) }) it("adds the given item if it isn't present in ::items", () => { const item = new Item('C') pane.activateItem(item) expect(pane.getItems().includes(item)).toBe(true) expect(pane.getActiveItem()).toBe(item) }) it('invokes ::onDidChangeActiveItem() observers', () => { const observed = [] pane.onDidChangeActiveItem(item => observed.push(item)) pane.activateItem(pane.itemAtIndex(1)) expect(observed).toEqual([pane.itemAtIndex(1)]) }) describe('when the item being activated is pending', () => { let itemC = null let itemD = null beforeEach(() => { itemC = new Item('C') itemD = new Item('D') }) it('replaces the active item if it is pending', () => { pane.activateItem(itemC, {pending: true}) expect(pane.getItems().map(item => item.name)).toEqual(['A', 'C', 'B']) pane.activateItem(itemD, {pending: true}) expect(pane.getItems().map(item => item.name)).toEqual(['A', 'D', 'B']) }) it('adds the item after the active item if it is not pending', () => { pane.activateItem(itemC, {pending: true}) pane.activateItemAtIndex(2) pane.activateItem(itemD, {pending: true}) expect(pane.getItems().map(item => item.name)).toEqual(['A', 'B', 'D']) }) }) }) describe('::setPendingItem', () => { let pane = null beforeEach(() => { pane = atom.workspace.getActivePane() }) it('changes the pending item', () => { expect(pane.getPendingItem()).toBeNull() pane.setPendingItem('fake item') expect(pane.getPendingItem()).toEqual('fake item') }) }) describe('::onItemDidTerminatePendingState callback', () => { let pane = null let callbackCalled = false beforeEach(() => { pane = atom.workspace.getActivePane() callbackCalled = false }) it('is called when the pending item changes', () => { pane.setPendingItem('fake item one') pane.onItemDidTerminatePendingState(function (item) { callbackCalled = true expect(item).toEqual('fake item one') }) pane.setPendingItem('fake item two') expect(callbackCalled).toBeTruthy() }) it('has access to the new pending item via ::getPendingItem', () => { pane.setPendingItem('fake item one') pane.onItemDidTerminatePendingState(function (item) { callbackCalled = true expect(pane.getPendingItem()).toEqual('fake item two') }) pane.setPendingItem('fake item two') expect(callbackCalled).toBeTruthy() }) it("isn't called when a pending item is replaced with a new one", async () => { pane = null const pendingSpy = jasmine.createSpy('onItemDidTerminatePendingState') const destroySpy = jasmine.createSpy('onWillDestroyItem') await atom.workspace.open('sample.txt', {pending: true}).then(() => { pane = atom.workspace.getActivePane() }) pane.onItemDidTerminatePendingState(pendingSpy) pane.onWillDestroyItem(destroySpy) await atom.workspace.open('sample.js', {pending: true}) expect(destroySpy).toHaveBeenCalled() expect(pendingSpy).not.toHaveBeenCalled() }) }) describe('::activateNextRecentlyUsedItem() and ::activatePreviousRecentlyUsedItem()', () => { it('sets the active item to the next/previous item in the itemStack, looping around at either end', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C'), new Item('D'), new Item('E')]})) const [item1, item2, item3, item4, item5] = pane.getItems() pane.itemStack = [item3, item1, item2, item5, item4] pane.activateItem(item4) expect(pane.getActiveItem()).toBe(item4) pane.activateNextRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item5) pane.activateNextRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item2) pane.activatePreviousRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item5) pane.activatePreviousRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item4) pane.activatePreviousRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item3) pane.activatePreviousRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item1) pane.activateNextRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item3) pane.activateNextRecentlyUsedItem() expect(pane.getActiveItem()).toBe(item4) pane.activateNextRecentlyUsedItem() pane.moveActiveItemToTopOfStack() expect(pane.getActiveItem()).toBe(item5) expect(pane.itemStack[4]).toBe(item5) }) }) describe('::activateNextItem() and ::activatePreviousItem()', () => { it('sets the active item to the next/previous item, looping around at either end', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1, item2, item3] = pane.getItems() expect(pane.getActiveItem()).toBe(item1) pane.activatePreviousItem() expect(pane.getActiveItem()).toBe(item3) pane.activatePreviousItem() expect(pane.getActiveItem()).toBe(item2) pane.activateNextItem() expect(pane.getActiveItem()).toBe(item3) pane.activateNextItem() expect(pane.getActiveItem()).toBe(item1) }) }) describe('::activateLastItem()', () => { it('sets the active item to the last item', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1,, item3] = pane.getItems() expect(pane.getActiveItem()).toBe(item1) pane.activateLastItem() expect(pane.getActiveItem()).toBe(item3) }) }) describe('::moveItemRight() and ::moveItemLeft()', () => { it('moves the active item to the right and left, without looping around at either end', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1, item2, item3] = pane.getItems() pane.activateItemAtIndex(0) expect(pane.getActiveItem()).toBe(item1) pane.moveItemLeft() expect(pane.getItems()).toEqual([item1, item2, item3]) pane.moveItemRight() expect(pane.getItems()).toEqual([item2, item1, item3]) pane.moveItemLeft() expect(pane.getItems()).toEqual([item1, item2, item3]) pane.activateItemAtIndex(2) expect(pane.getActiveItem()).toBe(item3) pane.moveItemRight() expect(pane.getItems()).toEqual([item1, item2, item3]) }) }) describe('::activateItemAtIndex(index)', () => { it('activates the item at the given index', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1, item2, item3] = pane.getItems() pane.activateItemAtIndex(2) expect(pane.getActiveItem()).toBe(item3) pane.activateItemAtIndex(1) expect(pane.getActiveItem()).toBe(item2) pane.activateItemAtIndex(0) expect(pane.getActiveItem()).toBe(item1) // Doesn't fail with out-of-bounds indices pane.activateItemAtIndex(100) expect(pane.getActiveItem()).toBe(item1) pane.activateItemAtIndex(-1) expect(pane.getActiveItem()).toBe(item1) }) }) describe('::destroyItem(item)', () => { let pane, item1, item2, item3 beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) ;[item1, item2, item3] = pane.getItems() }) it('removes the item from the items list and destroys it', () => { expect(pane.getActiveItem()).toBe(item1) pane.destroyItem(item2) expect(pane.getItems().includes(item2)).toBe(false) expect(item2.isDestroyed()).toBe(true) expect(pane.getActiveItem()).toBe(item1) pane.destroyItem(item1) expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) }) it('removes the item from the itemStack', () => { pane.itemStack = [item2, item3, item1] pane.activateItem(item1) expect(pane.getActiveItem()).toBe(item1) pane.destroyItem(item3) expect(pane.itemStack).toEqual([item2, item1]) expect(pane.getActiveItem()).toBe(item1) pane.destroyItem(item1) expect(pane.itemStack).toEqual([item2]) expect(pane.getActiveItem()).toBe(item2) pane.destroyItem(item2) expect(pane.itemStack).toEqual([]) expect(pane.getActiveItem()).toBeUndefined() }) it('invokes ::onWillDestroyItem() and PaneContainer::onWillDestroyPaneItem observers before destroying the item', async () => { jasmine.useRealClock() pane.container = new PaneContainer({config: atom.config, confirm}) const events = [] pane.onWillDestroyItem(async (event) => { expect(item2.isDestroyed()).toBe(false) await timeoutPromise(50) expect(item2.isDestroyed()).toBe(false) 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(item2.isDestroyed()).toBe(true) expect(events).toEqual([ ['will-destroy-item', {item: item2, index: 1}], ['will-destroy-pane-item', {item: item2, index: 1, pane}] ]) }) it('invokes ::onWillRemoveItem() observers', () => { const events = [] pane.onWillRemoveItem(event => events.push(event)) pane.destroyItem(item2) expect(events).toEqual([{item: item2, index: 1, moved: false, destroyed: true}]) }) it('invokes ::onDidRemoveItem() observers', () => { const events = [] pane.onDidRemoveItem(event => events.push(event)) pane.destroyItem(item2) expect(events).toEqual([{item: item2, index: 1, moved: false, destroyed: true}]) }) describe('when the destroyed item is the active item and is the first item', () => { it('activates the next item', () => { expect(pane.getActiveItem()).toBe(item1) pane.destroyItem(item1) expect(pane.getActiveItem()).toBe(item2) }) }) describe('when the destroyed item is the active item and is not the first item', () => { beforeEach(() => pane.activateItem(item2)) it('activates the previous item', () => { expect(pane.getActiveItem()).toBe(item2) pane.destroyItem(item2) expect(pane.getActiveItem()).toBe(item1) }) }) describe('if the item is modified', () => { let itemURI = null beforeEach(() => { item1.shouldPromptToSave = () => true item1.save = jasmine.createSpy('save') item1.saveAs = jasmine.createSpy('saveAs') item1.getURI = () => itemURI }) describe('if the [Save] option is selected', () => { describe('when the item has a uri', () => { it('saves the item before destroying it', async () => { itemURI = 'test' confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) expect(item1.save).toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) expect(success).toBe(true) }) }) describe('when the item has no uri', () => { it('presents a save-as dialog, then saves the item with the given uri before removing and destroying it', async () => { jasmine.useRealClock() itemURI = null showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) confirm.andCallFake((options, callback) => callback(0)) const success = await pane.destroyItem(item1) expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) await conditionPromise(() => item1.saveAs.callCount === 1) expect(item1.saveAs).toHaveBeenCalledWith('/selected/path') expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) expect(success).toBe(true) }) }) }) describe("if the [Don't Save] option is selected", () => { it('removes and destroys the item without saving it', async () => { confirm.andCallFake((options, callback) => callback(2)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) expect(success).toBe(true); }) }) describe('if the [Cancel] option is selected', () => { it('does not save, remove, or destroy the item', async () => { confirm.andCallFake((options, callback) => callback(1)) const success = await pane.destroyItem(item1) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(true) expect(item1.isDestroyed()).toBe(false) expect(success).toBe(false) }) }) describe('when force=true', () => { it('destroys the item immediately', async () => { const success = await pane.destroyItem(item1, true) expect(item1.save).not.toHaveBeenCalled() expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) expect(success).toBe(true) }) }) }) describe('when the last item is destroyed', () => { describe("when the 'core.destroyEmptyPanes' config option is false (the default)", () => { it('does not destroy the pane, but leaves it in place with empty items', () => { expect(atom.config.get('core.destroyEmptyPanes')).toBe(false) for (let item of pane.getItems()) { pane.destroyItem(item) } expect(pane.isDestroyed()).toBe(false) expect(pane.getActiveItem()).toBeUndefined() expect(() => pane.saveActiveItem()).not.toThrow() expect(() => pane.saveActiveItemAs()).not.toThrow() }) }) describe("when the 'core.destroyEmptyPanes' config option is true", () => { it('destroys the pane', () => { atom.config.set('core.destroyEmptyPanes', true) for (let item of pane.getItems()) { pane.destroyItem(item) } expect(pane.isDestroyed()).toBe(true) }) }) }) describe('when passed a permanent dock item', () => { it("doesn't destroy the item", async () => { spyOn(item1, 'isPermanentDockItem').andReturn(true) const success = await pane.destroyItem(item1) expect(pane.getItems().includes(item1)).toBe(true) expect(item1.isDestroyed()).toBe(false) expect(success).toBe(false); }) it('destroy the item if force=true', async () => { spyOn(item1, 'isPermanentDockItem').andReturn(true) const success = await pane.destroyItem(item1, true) expect(pane.getItems().includes(item1)).toBe(false) expect(item1.isDestroyed()).toBe(true) expect(success).toBe(true) }) }) }) describe('::destroyActiveItem()', () => { it('destroys the active item', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const activeItem = pane.getActiveItem() pane.destroyActiveItem() expect(activeItem.isDestroyed()).toBe(true) expect(pane.getItems().includes(activeItem)).toBe(false) }) it('does not throw an exception if there are no more items', () => { const pane = new Pane(paneParams()) pane.destroyActiveItem() }) }) describe('::destroyItems()', () => { it('destroys all items', async () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1, item2, item3] = pane.getItems() await pane.destroyItems() expect(item1.isDestroyed()).toBe(true) expect(item2.isDestroyed()).toBe(true) expect(item3.isDestroyed()).toBe(true) expect(pane.getItems()).toEqual([]) }) }) describe('::observeItems()', () => { it('invokes the observer with all current and future items', () => { const pane = new Pane(paneParams({items: [new Item(), new Item()]})) const [item1, item2] = pane.getItems() const observed = [] pane.observeItems(item => observed.push(item)) const item3 = new Item() pane.addItem(item3) expect(observed).toEqual([item1, item2, item3]) }) }) describe('when an item emits a destroyed event', () => { it('removes it from the list of items', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [item1,, item3] = pane.getItems() pane.itemAtIndex(1).destroy() expect(pane.getItems()).toEqual([item1, item3]) }) }) describe('::destroyInactiveItems()', () => { it('destroys all items but the active item', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C')]})) const [, item2] = pane.getItems() pane.activateItem(item2) pane.destroyInactiveItems() expect(pane.getItems()).toEqual([item2]) }) }) describe('::saveActiveItem()', () => { let pane beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the active item has a uri', () => { beforeEach(() => { pane.getActiveItem().uri = 'test' }) describe('when the active item has a save method', () => { it('saves the current item', () => { pane.getActiveItem().save = jasmine.createSpy('save') pane.saveActiveItem() expect(pane.getActiveItem().save).toHaveBeenCalled() }) }) describe('when the current item has no save method', () => { it('does nothing', () => { expect(pane.getActiveItem().save).toBeUndefined() pane.saveActiveItem() }) }) }) describe('when the current item has no uri', () => { describe('when the current item has a saveAs method', () => { it('opens a save dialog and saves the current item as the selected path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') await pane.saveActiveItem() expect(showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) describe('when the current item has no saveAs method', () => { it('does nothing', async () => { expect(pane.getActiveItem().saveAs).toBeUndefined() await pane.saveActiveItem() expect(showSaveDialog).not.toHaveBeenCalled() }) }) it('does nothing if the user cancels choosing a path', async () => { pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.saveActiveItem() expect(pane.getActiveItem().saveAs).not.toHaveBeenCalled() }) }) describe("when the item's saveAs rejects with a well-known IO error", () => { it('creates a notification', () => { pane.getActiveItem().saveAs = () => { const error = new Error("EACCES, permission denied '/foo'") error.path = '/foo' error.code = 'EACCES' return Promise.reject(error) } waitsFor((done) => { const subscription = atom.notifications.onDidAddNotification(function (notification) { expect(notification.getType()).toBe('warning') expect(notification.getMessage()).toContain('Permission denied') expect(notification.getMessage()).toContain('/foo') subscription.dispose() done() }) pane.saveActiveItem() }) }) }) describe("when the item's saveAs throws a well-known IO error", () => { it('creates a notification', () => { pane.getActiveItem().saveAs = () => { const error = new Error("EACCES, permission denied '/foo'") error.path = '/foo' error.code = 'EACCES' throw error } waitsFor((done) => { const subscription = atom.notifications.onDidAddNotification(function (notification) { expect(notification.getType()).toBe('warning') expect(notification.getMessage()).toContain('Permission denied') expect(notification.getMessage()).toContain('/foo') subscription.dispose() done() }) pane.saveActiveItem() }) }) }) }) describe('::saveActiveItemAs()', () => { let pane = null beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A')]})) showSaveDialog.andCallFake((options, callback) => callback('/selected/path')) }) describe('when the current item has a saveAs method', () => { it('opens the save dialog and calls saveAs on the item with the selected path', async () => { jasmine.useRealClock() pane.getActiveItem().path = __filename pane.getActiveItem().saveAs = jasmine.createSpy('saveAs') pane.saveActiveItemAs() expect(showSaveDialog.mostRecentCall.args[0]).toEqual({defaultPath: __filename}) await conditionPromise(() => pane.getActiveItem().saveAs.callCount === 1) expect(pane.getActiveItem().saveAs).toHaveBeenCalledWith('/selected/path') }) }) describe('when the current item does not have a saveAs method', () => { it('does nothing', () => { expect(pane.getActiveItem().saveAs).toBeUndefined() pane.saveActiveItemAs() expect(showSaveDialog).not.toHaveBeenCalled() }) }) describe("when the item's saveAs method throws a well-known IO error", () => { it('creates a notification', () => { pane.getActiveItem().saveAs = () => { const error = new Error("EACCES, permission denied '/foo'") error.path = '/foo' error.code = 'EACCES' return Promise.reject(error) } waitsFor((done) => { const subscription = atom.notifications.onDidAddNotification(function (notification) { expect(notification.getType()).toBe('warning') expect(notification.getMessage()).toContain('Permission denied') expect(notification.getMessage()).toContain('/foo') subscription.dispose() done() }) pane.saveActiveItemAs() }) }) }) }) describe('::itemForURI(uri)', () => { it('returns the item for which a call to .getURI() returns the given uri', () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C'), new Item('D')]})) const [item1, item2] = pane.getItems() item1.uri = 'a' item2.uri = 'b' expect(pane.itemForURI('a')).toBe(item1) expect(pane.itemForURI('b')).toBe(item2) expect(pane.itemForURI('bogus')).toBeUndefined() }) }) describe('::moveItem(item, index)', () => { let pane, item1, item2, item3, item4 beforeEach(() => { pane = new Pane(paneParams({items: [new Item('A'), new Item('B'), new Item('C'), new Item('D')]})) ;[item1, item2, item3, item4] = pane.getItems() }) it('moves the item to the given index and invokes ::onDidMoveItem observers', () => { pane.moveItem(item1, 2) expect(pane.getItems()).toEqual([item2, item3, item1, item4]) pane.moveItem(item2, 3) expect(pane.getItems()).toEqual([item3, item1, item4, item2]) pane.moveItem(item2, 1) expect(pane.getItems()).toEqual([item3, item2, item1, item4]) }) it('invokes ::onDidMoveItem() observers', () => { const events = [] pane.onDidMoveItem(event => events.push(event)) pane.moveItem(item1, 2) pane.moveItem(item2, 3) expect(events).toEqual([ {item: item1, oldIndex: 0, newIndex: 2}, {item: item2, oldIndex: 0, newIndex: 3} ]) }) }) describe('::moveItemToPane(item, pane, index)', () => { let container, pane1, pane2 let item1, item2, item3, item4, item5 beforeEach(() => { container = new PaneContainer({config: atom.config, confirm}) pane1 = container.getActivePane() pane1.addItems([new Item('A'), new Item('B'), new Item('C')]) pane2 = pane1.splitRight({items: [new Item('D'), new Item('E')]}); [item1, item2, item3] = pane1.getItems(); [item4, item5] = pane2.getItems() }) it('moves the item to the given pane at the given index', () => { pane1.moveItemToPane(item2, pane2, 1) expect(pane1.getItems()).toEqual([item1, item3]) expect(pane2.getItems()).toEqual([item4, item2, item5]) }) it('invokes ::onWillRemoveItem() observers', () => { const events = [] pane1.onWillRemoveItem(event => events.push(event)) pane1.moveItemToPane(item2, pane2, 1) expect(events).toEqual([{item: item2, index: 1, moved: true, destroyed: false}]) }) it('invokes ::onDidRemoveItem() observers', () => { const events = [] pane1.onDidRemoveItem(event => events.push(event)) pane1.moveItemToPane(item2, pane2, 1) expect(events).toEqual([{item: item2, index: 1, moved: true, destroyed: false}]) }) it('does not invoke ::onDidAddPaneItem observers on the container', () => { const addedItems = [] container.onDidAddPaneItem(item => addedItems.push(item)) pane1.moveItemToPane(item2, pane2, 1) expect(addedItems).toEqual([]) }) describe('when the moved item the last item in the source pane', () => { beforeEach(() => item5.destroy()) describe("when the 'core.destroyEmptyPanes' config option is false (the default)", () => { it('does not destroy the pane or the item', () => { pane2.moveItemToPane(item4, pane1, 0) expect(pane2.isDestroyed()).toBe(false) expect(item4.isDestroyed()).toBe(false) }) }) describe("when the 'core.destroyEmptyPanes' config option is true", () => { it('destroys the pane, but not the item', () => { atom.config.set('core.destroyEmptyPanes', true) pane2.moveItemToPane(item4, pane1, 0) expect(pane2.isDestroyed()).toBe(true) expect(item4.isDestroyed()).toBe(false) }) }) }) describe('when the item being moved is pending', () => { it('is made permanent in the new pane', () => { const item6 = new Item('F') pane1.addItem(item6, {pending: true}) expect(pane1.getPendingItem()).toEqual(item6) pane1.moveItemToPane(item6, pane2, 0) expect(pane2.getPendingItem()).not.toEqual(item6) }) }) describe('when the target pane has a pending item', () => { it('does not destroy the pending item', () => { const item6 = new Item('F') pane1.addItem(item6, {pending: true}) expect(pane1.getPendingItem()).toEqual(item6) pane2.moveItemToPane(item5, pane1, 0) expect(pane1.getPendingItem()).toEqual(item6) }) }) }) describe('split methods', () => { let pane1, item1, container beforeEach(() => { container = new PaneContainer({config: atom.config, confirm, deserializerManager: atom.deserializers}) pane1 = container.getActivePane() item1 = new Item('A') pane1.addItem(item1) }) describe('::splitLeft(params)', () => { describe('when the parent is the container root', () => { it('replaces itself with a row and inserts a new pane to the left of itself', () => { const pane2 = pane1.splitLeft({items: [new Item('B')]}) const pane3 = pane1.splitLeft({items: [new Item('C')]}) expect(container.root.orientation).toBe('horizontal') expect(container.root.children).toEqual([pane2, pane3, pane1]) }) }) describe('when `moveActiveItem: true` is passed in the params', () => { it('moves the active item', () => { const pane2 = pane1.splitLeft({moveActiveItem: true}) expect(pane2.getActiveItem()).toBe(item1) }) }) describe('when `copyActiveItem: true` is passed in the params', () => { it('duplicates the active item', () => { const pane2 = pane1.splitLeft({copyActiveItem: true}) expect(pane2.getActiveItem()).toEqual(pane1.getActiveItem()) }) it("does nothing if the active item doesn't implement .copy()", () => { item1.copy = null const pane2 = pane1.splitLeft({copyActiveItem: true}) expect(pane2.getActiveItem()).toBeUndefined() }) }) describe('when the parent is a column', () => { it('replaces itself with a row and inserts a new pane to the left of itself', () => { pane1.splitDown() const pane2 = pane1.splitLeft({items: [new Item('B')]}) const pane3 = pane1.splitLeft({items: [new Item('C')]}) const row = container.root.children[0] expect(row.orientation).toBe('horizontal') expect(row.children).toEqual([pane2, pane3, pane1]) }) }) }) describe('::splitRight(params)', () => { describe('when the parent is the container root', () => { it('replaces itself with a row and inserts a new pane to the right of itself', () => { const pane2 = pane1.splitRight({items: [new Item('B')]}) const pane3 = pane1.splitRight({items: [new Item('C')]}) expect(container.root.orientation).toBe('horizontal') expect(container.root.children).toEqual([pane1, pane3, pane2]) }) }) describe('when `moveActiveItem: true` is passed in the params', () => { it('moves the active item', () => { const pane2 = pane1.splitRight({moveActiveItem: true}) expect(pane2.getActiveItem()).toBe(item1) }) }) describe('when `copyActiveItem: true` is passed in the params', () => { it('duplicates the active item', () => { const pane2 = pane1.splitRight({copyActiveItem: true}) expect(pane2.getActiveItem()).toEqual(pane1.getActiveItem()) }) }) describe('when the parent is a column', () => { it('replaces itself with a row and inserts a new pane to the right of itself', () => { pane1.splitDown() const pane2 = pane1.splitRight({items: [new Item('B')]}) const pane3 = pane1.splitRight({items: [new Item('C')]}) const row = container.root.children[0] expect(row.orientation).toBe('horizontal') expect(row.children).toEqual([pane1, pane3, pane2]) }) }) }) describe('::splitUp(params)', () => { describe('when the parent is the container root', () => { it('replaces itself with a column and inserts a new pane above itself', () => { const pane2 = pane1.splitUp({items: [new Item('B')]}) const pane3 = pane1.splitUp({items: [new Item('C')]}) expect(container.root.orientation).toBe('vertical') expect(container.root.children).toEqual([pane2, pane3, pane1]) }) }) describe('when `moveActiveItem: true` is passed in the params', () => { it('moves the active item', () => { const pane2 = pane1.splitUp({moveActiveItem: true}) expect(pane2.getActiveItem()).toBe(item1) }) }) describe('when `copyActiveItem: true` is passed in the params', () => { it('duplicates the active item', () => { const pane2 = pane1.splitUp({copyActiveItem: true}) expect(pane2.getActiveItem()).toEqual(pane1.getActiveItem()) }) }) describe('when the parent is a row', () => { it('replaces itself with a column and inserts a new pane above itself', () => { pane1.splitRight() const pane2 = pane1.splitUp({items: [new Item('B')]}) const pane3 = pane1.splitUp({items: [new Item('C')]}) const column = container.root.children[0] expect(column.orientation).toBe('vertical') expect(column.children).toEqual([pane2, pane3, pane1]) }) }) }) describe('::splitDown(params)', () => { describe('when the parent is the container root', () => { it('replaces itself with a column and inserts a new pane below itself', () => { const pane2 = pane1.splitDown({items: [new Item('B')]}) const pane3 = pane1.splitDown({items: [new Item('C')]}) expect(container.root.orientation).toBe('vertical') expect(container.root.children).toEqual([pane1, pane3, pane2]) }) }) describe('when `moveActiveItem: true` is passed in the params', () => { it('moves the active item', () => { const pane2 = pane1.splitDown({moveActiveItem: true}) expect(pane2.getActiveItem()).toBe(item1) }) }) describe('when `copyActiveItem: true` is passed in the params', () => { it('duplicates the active item', () => { const pane2 = pane1.splitDown({copyActiveItem: true}) expect(pane2.getActiveItem()).toEqual(pane1.getActiveItem()) }) }) describe('when the parent is a row', () => { it('replaces itself with a column and inserts a new pane below itself', () => { pane1.splitRight() const pane2 = pane1.splitDown({items: [new Item('B')]}) const pane3 = pane1.splitDown({items: [new Item('C')]}) const column = container.root.children[0] expect(column.orientation).toBe('vertical') expect(column.children).toEqual([pane1, pane3, pane2]) }) }) }) describe('when the pane is empty', () => { describe('when `moveActiveItem: true` is passed in the params', () => { it('gracefully ignores the moveActiveItem parameter', () => { pane1.destroyItem(item1) expect(pane1.getActiveItem()).toBe(undefined) const pane2 = pane1.split('horizontal', 'before', {moveActiveItem: true}) expect(container.root.children).toEqual([pane2, pane1]) expect(pane2.getActiveItem()).toBe(undefined) }) }) describe('when `copyActiveItem: true` is passed in the params', () => { it('gracefully ignores the copyActiveItem parameter', () => { pane1.destroyItem(item1) expect(pane1.getActiveItem()).toBe(undefined) const pane2 = pane1.split('horizontal', 'before', {copyActiveItem: true}) expect(container.root.children).toEqual([pane2, pane1]) expect(pane2.getActiveItem()).toBe(undefined) }) }) }) it('activates the new pane', () => { expect(pane1.isActive()).toBe(true) const pane2 = pane1.splitRight() expect(pane1.isActive()).toBe(false) expect(pane2.isActive()).toBe(true) }) }) describe('::close()', () => { it('prompts to save unsaved items before destroying the pane', async () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const [item1] = pane.getItems() item1.shouldPromptToSave = () => true item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') confirm.andCallFake((options, callback) => callback(0)) await pane.close() expect(confirm).toHaveBeenCalled() expect(item1.save).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) }) it('does not destroy the pane if the user clicks cancel', async () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const [item1] = pane.getItems() item1.shouldPromptToSave = () => true item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save') confirm.andCallFake((options, callback) => callback(1)) await pane.close() expect(confirm).toHaveBeenCalled() expect(item1.save).not.toHaveBeenCalled() expect(pane.isDestroyed()).toBe(false) }) it('does not destroy the pane if the user starts to save but then does not choose a path', async () => { const pane = new Pane(paneParams({items: [new Item('A'), new Item('B')]})) const [item1] = pane.getItems() item1.shouldPromptToSave = () => true item1.saveAs = jasmine.createSpy('saveAs') confirm.andCallFake((options, callback) => callback(0)) showSaveDialog.andCallFake((options, callback) => callback(undefined)) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirm.callCount).toBe(1) expect(item1.saveAs).not.toHaveBeenCalled() expect(pane.isDestroyed()).toBe(false) }) describe('when item fails to save', () => { let pane, item1 beforeEach(() => { pane = new Pane({items: [new Item('A'), new Item('B')], applicationDelegate: atom.applicationDelegate, config: atom.config}); [item1] = pane.getItems() item1.shouldPromptToSave = () => true item1.getURI = () => '/test/path' item1.save = jasmine.createSpy('save').andCallFake(() => { const error = new Error("EACCES, permission denied '/test/path'") error.path = '/test/path' error.code = 'EACCES' throw error }) }) it('does not destroy the pane if save fails and user clicks cancel', async () => { let confirmations = 0 confirm.andCallFake((options, callback) => { confirmations++ if (confirmations === 1) { callback(0) // click save } else { callback(1) } }) // click cancel await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(2) expect(item1.save).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(false) }) it('does destroy the pane if the user saves the file under a new name', async () => { item1.saveAs = jasmine.createSpy('saveAs').andReturn(true) let confirmations = 0 confirm.andCallFake((options, callback) => { confirmations++ callback(0) }) // save and then save as showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(2) expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) }) it('asks again if the saveAs also fails', async () => { item1.saveAs = jasmine.createSpy('saveAs').andCallFake(() => { const error = new Error("EACCES, permission denied '/test/path'") error.path = '/test/path' error.code = 'EACCES' throw error }) let confirmations = 0 confirm.andCallFake((options, callback) => { confirmations++ if (confirmations < 3) { callback(0) // save, save as, save as } else { callback(2) // don't save } }) showSaveDialog.andCallFake((options, callback) => callback('new/path')) await pane.close() expect(atom.applicationDelegate.confirm).toHaveBeenCalled() expect(confirmations).toBe(3) expect(atom.applicationDelegate.showSaveDialog.mostRecentCall.args[0]).toEqual({}) expect(item1.save).toHaveBeenCalled() expect(item1.saveAs).toHaveBeenCalled() expect(pane.isDestroyed()).toBe(true) }) }) }) describe('::destroy()', () => { let container, pane1, pane2 beforeEach(() => { container = new PaneContainer({config: atom.config, confirm}) pane1 = container.root pane1.addItems([new Item('A'), new Item('B')]) pane2 = pane1.splitRight() }) it('invokes ::onWillDestroy observers before destroying items', () => { let itemsDestroyed = null pane1.onWillDestroy(() => { itemsDestroyed = (pane1.getItems().map((item) => item.isDestroyed())) }) pane1.destroy() expect(itemsDestroyed).toEqual([false, false]) }) it("destroys the pane's destroyable items", () => { const [item1, item2] = pane1.getItems() pane1.destroy() expect(item1.isDestroyed()).toBe(true) expect(item2.isDestroyed()).toBe(true) }) describe('if the pane is active', () => { it('makes the next pane active', () => { expect(pane2.isActive()).toBe(true) pane2.destroy() expect(pane1.isActive()).toBe(true) }) }) describe("if the pane's parent has more than two children", () => { it('removes the pane from its parent', () => { const pane3 = pane2.splitRight() expect(container.root.children).toEqual([pane1, pane2, pane3]) pane2.destroy() expect(container.root.children).toEqual([pane1, pane3]) }) }) describe("if the pane's parent has two children", () => { it('replaces the parent with its last remaining child', () => { const pane3 = pane2.splitDown() expect(container.root.children[0]).toBe(pane1) expect(container.root.children[1].children).toEqual([pane2, pane3]) pane3.destroy() expect(container.root.children).toEqual([pane1, pane2]) pane2.destroy() expect(container.root).toBe(pane1) }) }) }) describe('pending state', () => { let editor1, pane, eventCount beforeEach(async () => { editor1 = await atom.workspace.open('sample.txt', {pending: true}) pane = atom.workspace.getActivePane() eventCount = 0 editor1.onDidTerminatePendingState(() => eventCount++) }) it('does not open file in pending state by default', async () => { await atom.workspace.open('sample.js') expect(pane.getPendingItem()).toBeNull() }) it("opens file in pending state if 'pending' option is true", () => { expect(pane.getPendingItem()).toEqual(editor1) }) it('terminates pending state if ::terminatePendingState is invoked', () => { editor1.terminatePendingState() expect(pane.getPendingItem()).toBeNull() expect(eventCount).toBe(1) }) it('terminates pending state when buffer is changed', () => { editor1.insertText('I\'ll be back!') advanceClock(editor1.getBuffer().stoppedChangingDelay) expect(pane.getPendingItem()).toBeNull() expect(eventCount).toBe(1) }) it('only calls terminate handler once when text is modified twice', async () => { const originalText = editor1.getText() editor1.insertText('Some text') advanceClock(editor1.getBuffer().stoppedChangingDelay) await editor1.save() editor1.insertText('More text') advanceClock(editor1.getBuffer().stoppedChangingDelay) expect(pane.getPendingItem()).toBeNull() expect(eventCount).toBe(1) // Reset fixture back to original state editor1.setText(originalText) await editor1.save() }) it('only calls clearPendingItem if there is a pending item to clear', () => { spyOn(pane, 'clearPendingItem').andCallThrough() editor1.terminatePendingState() editor1.terminatePendingState() expect(pane.getPendingItem()).toBeNull() expect(pane.clearPendingItem.callCount).toBe(1) }) }) describe('serialization', () => { let pane = null beforeEach(() => { pane = new Pane(paneParams({ items: [new Item('A', 'a'), new Item('B', 'b'), new Item('C', 'c')], flexScale: 2 })) }) it('can serialize and deserialize the pane and all its items', () => { const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getItems()).toEqual(pane.getItems()) }) it('restores the active item on deserialization', () => { pane.activateItemAtIndex(1) const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getActiveItem()).toEqual(newPane.itemAtIndex(1)) }) it("restores the active item when it doesn't implement getURI()", () => { pane.items[1].getURI = null pane.activateItemAtIndex(1) const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getActiveItem()).toEqual(newPane.itemAtIndex(1)) }) it("restores the correct item when it doesn't implement getURI() and some items weren't deserialized", () => { const unserializable = {} pane.addItem(unserializable, {index: 0}) pane.items[2].getURI = null pane.activateItemAtIndex(2) const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getActiveItem()).toEqual(newPane.itemAtIndex(1)) }) it('does not include items that cannot be deserialized', () => { spyOn(console, 'warn') const unserializable = {} pane.activateItem(unserializable) const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getActiveItem()).toEqual(pane.itemAtIndex(0)) expect(newPane.getItems().length).toBe(pane.getItems().length - 1) }) it("includes the pane's focus state in the serialized state", () => { pane.focus() const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.focused).toBe(true) }) it('can serialize and deserialize the order of the items in the itemStack', () => { const [item1, item2, item3] = pane.getItems() pane.itemStack = [item3, item1, item2] const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.itemStack).toEqual(pane.itemStack) expect(newPane.itemStack[2]).toEqual(item2) }) it('builds the itemStack if the itemStack is not serialized', () => { const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getItems()).toEqual(newPane.itemStack) }) it('rebuilds the itemStack if items.length does not match itemStack.length', () => { const [, item2, item3] = pane.getItems() pane.itemStack = [item2, item3] const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.getItems()).toEqual(newPane.itemStack) }) it('does not serialize the reference to the items in the itemStack for pane items that will not be serialized', () => { const [item1, item2, item3] = pane.getItems() pane.itemStack = [item2, item1, item3] const unserializable = {} pane.activateItem(unserializable) const newPane = Pane.deserialize(pane.serialize(), atom) expect(newPane.itemStack).toEqual([item2, item1, item3]) }) }) })