src/pane.js

Summary

Maintainability
F
1 wk
Test Coverage
const Grim = require('grim');
const { CompositeDisposable, Emitter } = require('event-kit');
const PaneAxis = require('./pane-axis');
const TextEditor = require('./text-editor');
const { createPaneElement } = 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(
        {
          deserializerManager: deserializers,
          notificationManager: notifications,
          viewRegistry: views,
          config,
          applicationDelegate
        },
        state
      )
    );
  }

  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 = createPaneElement().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() {
    if (this.getContainer().getPanes().length > 1) {
      this.setFlexScale(this.getFlexScale() * 1.1);
    }
  }

  decreaseSize() {
    if (this.getContainer().getPanes().length > 1) {
      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() {
    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;

    const itemSubscriptions = new CompositeDisposable();
    this.subscriptionsPerItem.set(item, itemSubscriptions);
    if (typeof item.onDidDestroy === 'function') {
      itemSubscriptions.add(
        item.onDidDestroy(() => this.removeItem(item, false))
      );
    }
    if (typeof item.onDidTerminatePendingState === 'function') {
      itemSubscriptions.add(
        item.onDidTerminatePendingState(() => {
          if (this.getPendingItem() === item) this.clearPendingItem();
        })
      );
    }

    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`.
  //
  // This action can be prevented by onWillDestroyPaneItem callbacks in which
  // case nothing happens.
  //
  // * `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
    ) {
      let preventClosing = false;
      await this.container.willDestroyPaneItem({
        item,
        index,
        pane: this,
        prevent: () => {
          preventClosing = true;
        }
      });
      if (preventClosing) return false;
    }

    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 = {}) {
    return new Promise((resolve, reject) => {
      if (
        typeof item.shouldPromptToSave !== 'function' ||
        !item.shouldPromptToSave(options)
      ) {
        return resolve(true);
      }

      let uri;
      if (typeof item.getURI === 'function') {
        uri = item.getURI();
      } else if (typeof item.getUri === 'function') {
        uri = item.getUri();
      } else {
        return resolve(true);
      }

      const title =
        (typeof item.getTitle === 'function' && item.getTitle()) || uri;

      const saveDialog = (saveButtonText, saveFn, message) => {
        this.applicationDelegate.confirm(
          {
            message,
            detail:
              'Your changes will be lost if you close this item without saving.',
            buttons: [saveButtonText, 'Cancel', "&Don't Save"]
          },
          response => {
            switch (response) {
              case 0:
                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
                      )}`
                    );
                  } else {
                    resolve(true);
                  }
                });
              case 1:
                return resolve(false);
              case 2:
                return resolve(true);
            }
          }
        );
      };

      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
  async 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;

    let resolveSaveDialogPromise = null;
    const saveDialogPromise = new Promise(resolve => {
      resolveSaveDialogPromise = resolve;
    });
    this.applicationDelegate.showSaveDialog(saveOptions, newItemPath => {
      if (newItemPath) {
        promisify(() => item.saveAs(newItemPath))
          .then(() => {
            if (nextAction) {
              resolveSaveDialogPromise(nextAction());
            } else {
              resolveSaveDialogPromise();
            }
          })
          .catch(error => {
            if (nextAction) {
              resolveSaveDialogPromise(nextAction(error));
            } else {
              this.handleSaveError(error, item);
              resolveSaveDialogPromise();
            }
          });
      } else if (nextAction) {
        resolveSaveDialogPromise(
          nextAction(new SaveCancelledError('Save Cancelled'))
        );
      } else {
        resolveSaveDialogPromise();
      }
    });

    return saveDialogPromise;
  }

  // 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');
    this.focused = true;

    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);
  }
}