Files
atom/src/pane.js
2018-03-09 08:38:44 +01:00

1269 lines
41 KiB
JavaScript

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 () {
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 () {
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 = {}) {
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 await 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')
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)
}
}