Files
atom/src/pane.coffee
2015-11-12 17:34:48 -07:00

751 lines
25 KiB
CoffeeScript

{find, compact, extend, last} = require 'underscore-plus'
{Emitter} = require 'event-kit'
Model = require './model'
PaneAxis = require './pane-axis'
TextEditor = require './text-editor'
# 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.
module.exports =
class Pane extends Model
container: undefined
activeItem: undefined
focused: false
@deserialize: (state, {deserializers, applicationDelegate, config, notifications}) ->
{items, activeItemURI, activeItemUri} = state
activeItemURI ?= activeItemUri
state.items = compact(items.map (itemState) -> deserializers.deserialize(itemState))
state.activeItem = find state.items, (item) ->
if typeof item.getURI is 'function'
itemURI = item.getURI()
itemURI is activeItemURI
new Pane(extend(state, {
deserializerManager: deserializers,
notificationManager: notifications,
config, applicationDelegate
}))
constructor: (params) ->
super
{
@activeItem, @focused, @applicationDelegate, @notificationManager, @config,
@deserializerManager
} = params
@emitter = new Emitter
@itemSubscriptions = new WeakMap
@items = []
@addItems(compact(params?.items ? []))
@setActiveItem(@items[0]) unless @getActiveItem()?
@setFlexScale(params?.flexScale ? 1)
serialize: ->
if typeof @activeItem?.getURI is 'function'
activeItemURI = @activeItem.getURI()
deserializer: 'Pane'
id: @id
items: compact(@items.map((item) -> item.serialize?()))
activeItemURI: activeItemURI
focused: @focused
flexScale: @flexScale
getParent: -> @parent
setParent: (@parent) -> @parent
getContainer: -> @container
setContainer: (container) ->
if container and container isnt @container
@container = container
container.didAddPane({pane: this})
setFlexScale: (@flexScale) ->
@emitter.emit 'did-change-flex-scale', @flexScale
@flexScale
getFlexScale: -> @flexScale
increaseSize: -> @setFlexScale(@getFlexScale() * 1.1)
decreaseSize: -> @setFlexScale(@getFlexScale() / 1.1)
###
Section: Event Subscription
###
# Public: Invoke the given callback when the pane resizes
#
# The callback will be invoked when pane's flexScale property changes.
# Use {::getFlexScale} to get the current value.
#
# * `callback` {Function} to be called when the pane is resized
# * `flexScale` {Number} representing the panes `flex-grow`; ability for a
# flex item to grow if necessary.
#
# Returns a {Disposable} on which '.dispose()' can be called to unsubscribe.
onDidChangeFlexScale: (callback) ->
@emitter.on 'did-change-flex-scale', callback
# Public: Invoke the given callback with the current and future values of
# {::getFlexScale}.
#
# * `callback` {Function} to be called with the current and future values of
# the {::getFlexScale} property.
# * `flexScale` {Number} representing the panes `flex-grow`; ability for a
# flex item to grow if necessary.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeFlexScale: (callback) ->
callback(@flexScale)
@onDidChangeFlexScale(callback)
# Public: Invoke the given callback when the pane is activated.
#
# The given callback will be invoked whenever {::activate} is called on the
# pane, even if it is already active at the time.
#
# * `callback` {Function} to be called when the pane is activated.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidActivate: (callback) ->
@emitter.on 'did-activate', callback
# Public: Invoke the given callback before the pane is destroyed.
#
# * `callback` {Function} to be called before the pane is destroyed.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillDestroy: (callback) ->
@emitter.on 'will-destroy', callback
# Public: Invoke the given callback when the pane is destroyed.
#
# * `callback` {Function} to be called when the pane is destroyed.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDestroy: (callback) ->
@emitter.on 'did-destroy', callback
# Public: Invoke the given callback when the value of the {::isActive}
# property changes.
#
# * `callback` {Function} to be called when the value of the {::isActive}
# property changes.
# * `active` {Boolean} indicating whether the pane is active.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeActive: (callback) ->
@container.onDidChangeActivePane (activePane) =>
callback(this is activePane)
# Public: Invoke the given callback with the current and future values of the
# {::isActive} property.
#
# * `callback` {Function} to be called with the current and future values of
# the {::isActive} property.
# * `active` {Boolean} indicating whether the pane is active.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeActive: (callback) ->
callback(@isActive())
@onDidChangeActive(callback)
# Public: Invoke the given callback when an item is added to the pane.
#
# * `callback` {Function} to be called with when items are added.
# * `event` {Object} with the following keys:
# * `item` The added pane item.
# * `index` {Number} indicating where the item is located.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidAddItem: (callback) ->
@emitter.on 'did-add-item', callback
# Public: Invoke the given callback when an item is removed from the pane.
#
# * `callback` {Function} to be called with when items are removed.
# * `event` {Object} with the following keys:
# * `item` The removed pane item.
# * `index` {Number} indicating where the item was located.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidRemoveItem: (callback) ->
@emitter.on 'did-remove-item', callback
# Public: Invoke the given callback before an item is removed from the pane.
#
# * `callback` {Function} to be called with when items are removed.
# * `event` {Object} with the following keys:
# * `item` The pane item to be removed.
# * `index` {Number} indicating where the item is located.
onWillRemoveItem: (callback) ->
@emitter.on 'will-remove-item', callback
# Public: Invoke the given callback when an item is moved within the pane.
#
# * `callback` {Function} to be called with when items are moved.
# * `event` {Object} with the following keys:
# * `item` The removed pane item.
# * `oldIndex` {Number} indicating where the item was located.
# * `newIndex` {Number} indicating where the item is now located.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidMoveItem: (callback) ->
@emitter.on 'did-move-item', callback
# Public: Invoke the given callback with all current and future items.
#
# * `callback` {Function} to be called with current and future items.
# * `item` An item that is present in {::getItems} at the time of
# subscription or that is added at some later time.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeItems: (callback) ->
callback(item) for item in @getItems()
@onDidAddItem ({item}) -> callback(item)
# Public: Invoke the given callback when the value of {::getActiveItem}
# changes.
#
# * `callback` {Function} to be called with when the active item changes.
# * `activeItem` The current active item.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeActiveItem: (callback) ->
@emitter.on 'did-change-active-item', callback
# Public: Invoke the given callback with the current and future values of
# {::getActiveItem}.
#
# * `callback` {Function} to be called with the current and future active
# items.
# * `activeItem` The current active item.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeActiveItem: (callback) ->
callback(@getActiveItem())
@onDidChangeActiveItem(callback)
# Public: Invoke the given callback before items are destroyed.
#
# * `callback` {Function} to be called before items are destroyed.
# * `event` {Object} with the following keys:
# * `item` The item that will be destroyed.
# * `index` The location of the item.
#
# Returns a {Disposable} on which `.dispose()` can be called to
# unsubscribe.
onWillDestroyItem: (callback) ->
@emitter.on 'will-destroy-item', callback
# Called by the view layer to indicate that the pane has gained focus.
focus: ->
@focused = true
@activate() unless @isActive() or @isDestroyed()
# Called by the view layer to indicate that the pane has lost focus.
blur: ->
@focused = false
true # if this is called from an event handler, don't cancel it
isFocused: -> @focused
getPanes: -> [this]
unsubscribeFromItem: (item) ->
@itemSubscriptions.get(item)?.dispose()
@itemSubscriptions.delete(item)
###
Section: Items
###
# Public: Get the items in this pane.
#
# Returns an {Array} of items.
getItems: ->
@items.slice()
# Public: Get the active pane item in this pane.
#
# Returns a pane item.
getActiveItem: -> @activeItem
setActiveItem: (activeItem) ->
unless activeItem is @activeItem
@activeItem = activeItem
@emitter.emit 'did-change-active-item', @activeItem
@activeItem
# Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise.
getActiveEditor: ->
@activeItem if @activeItem instanceof TextEditor
# Public: Return the item at the given index.
#
# * `index` {Number}
#
# Returns an item or `null` if no item exists at the given index.
itemAtIndex: (index) ->
@items[index]
# Public: Makes the next item active.
activateNextItem: ->
index = @getActiveItemIndex()
if index < @items.length - 1
@activateItemAtIndex(index + 1)
else
@activateItemAtIndex(0)
# Public: Makes the previous item active.
activatePreviousItem: ->
index = @getActiveItemIndex()
if index > 0
@activateItemAtIndex(index - 1)
else
@activateItemAtIndex(@items.length - 1)
activateLastItem: ->
@activateItemAtIndex(@items.length - 1)
# Public: Move the active tab to the right.
moveItemRight: ->
index = @getActiveItemIndex()
rightItemIndex = index + 1
@moveItem(@getActiveItem(), rightItemIndex) unless rightItemIndex > @items.length - 1
# Public: Move the active tab to the left
moveItemLeft: ->
index = @getActiveItemIndex()
leftItemIndex = index - 1
@moveItem(@getActiveItem(), leftItemIndex) unless leftItemIndex < 0
# Public: Get the index of the active item.
#
# Returns a {Number}.
getActiveItemIndex: ->
@items.indexOf(@activeItem)
# Public: Activate the item at the given index.
#
# * `index` {Number}
activateItemAtIndex: (index) ->
@activateItem(@itemAtIndex(index))
# Public: Make the given item *active*, causing it to be displayed by
# the pane's view.
activateItem: (item) ->
if item?
@addItem(item, @getActiveItemIndex() + 1, false)
@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.
# * `index` (optional) {Number} indicating the index at which to add the item.
# If omitted, the item is added after the current active item.
#
# Returns the added item.
addItem: (item, index=@getActiveItemIndex() + 1, moved=false) ->
throw new Error("Pane items must be objects. Attempted to add item #{item}.") unless item? and typeof item is 'object'
throw new Error("Adding a pane item with URI '#{item.getURI?()}' that has already been destroyed") if item.isDestroyed?()
return if item in @items
if typeof item.onDidDestroy is 'function'
@itemSubscriptions.set item, item.onDidDestroy => @removeItem(item, false)
@items.splice(index, 0, item)
@emitter.emit 'did-add-item', {item, index, moved}
@setActiveItem(item) unless @getActiveItem()?
item
# Public: Add the given items to the pane.
#
# * `items` An {Array} of items to add. Items can be views or models with
# associated views. Any objects that are already present in the pane's
# current items will not be added again.
# * `index` (optional) {Number} index at which to add the items. If omitted,
# the item is # added after the current active item.
#
# Returns an {Array} of added items.
addItems: (items, index=@getActiveItemIndex() + 1) ->
items = items.filter (item) => not (item in @items)
@addItem(item, index + i, false) for item, i in items
items
removeItem: (item, moved) ->
index = @items.indexOf(item)
return if index is -1
@emitter.emit 'will-remove-item', {item, index, destroyed: not moved, moved}
@unsubscribeFromItem(item)
if item is @activeItem
if @items.length is 1
@setActiveItem(undefined)
else if index is 0
@activateNextItem()
else
@activatePreviousItem()
@items.splice(index, 1)
@emitter.emit 'did-remove-item', {item, index, destroyed: not moved, moved}
@container?.didDestroyPaneItem({item, index, pane: this}) unless moved
@destroy() if @items.length is 0 and @config.get('core.destroyEmptyPanes')
# Public: Move the given item to the given index.
#
# * `item` The item to move.
# * `index` {Number} indicating the index to which to move the item.
moveItem: (item, newIndex) ->
oldIndex = @items.indexOf(item)
@items.splice(oldIndex, 1)
@items.splice(newIndex, 0, item)
@emitter.emit 'did-move-item', {item, oldIndex, newIndex}
# Public: Move the given item to the given index on another pane.
#
# * `item` The item to move.
# * `pane` {Pane} to which to move the item.
# * `index` {Number} indicating the index to which to move the item in the
# given pane.
moveItemToPane: (item, pane, index) ->
@removeItem(item, true)
pane.addItem(item, index, true)
# Public: Destroy the active item and activate the next item.
destroyActiveItem: ->
@destroyItem(@activeItem)
false
# Public: Destroy the given item.
#
# If the item is active, the next item will be activated. If the item is the
# last item, the pane will be destroyed if the `core.destroyEmptyPanes` config
# setting is `true`.
#
# * `item` Item to destroy
destroyItem: (item) ->
index = @items.indexOf(item)
if index isnt -1
@emitter.emit 'will-destroy-item', {item, index}
@container?.willDestroyPaneItem({item, index, pane: this})
if @promptToSaveItem(item)
@removeItem(item, false)
item.destroy?()
true
else
false
# Public: Destroy all items.
destroyItems: ->
@destroyItem(item) for item in @getItems()
return
# Public: Destroy all items except for the active item.
destroyInactiveItems: ->
@destroyItem(item) for item in @getItems() when item isnt @activeItem
return
promptToSaveItem: (item, options={}) ->
return true unless item.shouldPromptToSave?(options)
if typeof item.getURI is 'function'
uri = item.getURI()
else if typeof item.getUri is 'function'
uri = item.getUri()
else
return true
chosen = @applicationDelegate.confirm
message: "'#{item.getTitle?() ? uri}' has changes, do you want to save them?"
detailedMessage: "Your changes will be lost if you close this item without saving."
buttons: ["Save", "Cancel", "Don't Save"]
switch chosen
when 0 then @saveItem(item, -> true)
when 1 then false
when 2 then true
# Public: Save the active item.
saveActiveItem: (nextAction) ->
@saveItem(@getActiveItem(), nextAction)
# Public: Prompt the user for a location and save the active item with the
# path they select.
#
# * `nextAction` (optional) {Function} which will be called after the item is
# successfully saved.
saveActiveItemAs: (nextAction) ->
@saveItemAs(@getActiveItem(), nextAction)
# Public: Save the given item.
#
# * `item` The item to save.
# * `nextAction` (optional) {Function} which will be called after the item is
# successfully saved.
saveItem: (item, nextAction) ->
if typeof item?.getURI is 'function'
itemURI = item.getURI()
else if typeof item?.getUri is 'function'
itemURI = item.getUri()
if itemURI?
try
item.save?()
catch error
@handleSaveError(error, item)
nextAction?()
else
@saveItemAs(item, nextAction)
# Public: Prompt the user for a location and save the active item with the
# path they select.
#
# * `item` The item to save.
# * `nextAction` (optional) {Function} which will be called after the item is
# successfully saved.
saveItemAs: (item, nextAction) ->
return unless item?.saveAs?
saveOptions = item.getSaveDialogOptions?() ? {}
saveOptions.defaultPath ?= item.getPath()
newItemPath = @applicationDelegate.showSaveDialog(saveOptions)
if newItemPath
try
item.saveAs(newItemPath)
catch error
@handleSaveError(error, item)
nextAction?()
# Public: Save all items.
saveItems: ->
for item in @getItems()
@saveItem(item) if item.isModified?()
return
# Public: Return the first item that matches the given URI or undefined if
# none exists.
#
# * `uri` {String} containing a URI.
itemForURI: (uri) ->
find @items, (item) ->
if typeof item.getURI is 'function'
itemUri = item.getURI()
else if typeof item.getUri is 'function'
itemUri = item.getUri()
itemUri is uri
# Public: Activate the first item that matches the given URI.
#
# * `uri` {String} containing a URI.
#
# Returns a {Boolean} indicating whether an item matching the URI was found.
activateItemForURI: (uri) ->
if item = @itemForURI(uri)
@activateItem(item)
true
else
false
copyActiveItem: ->
if @activeItem?
@activeItem.copy?() ? @deserializerManager.deserialize(@activeItem.serialize())
###
Section: Lifecycle
###
# Public: Determine whether the pane is active.
#
# Returns a {Boolean}.
isActive: ->
@container?.getActivePane() is this
# Public: Makes this pane the *active* pane, causing it to gain focus.
activate: ->
throw new Error("Pane has been destroyed") if @isDestroyed()
@container?.setActivePane(this)
@emitter.emit 'did-activate'
# Public: Close the pane and destroy all its items.
#
# If this is the last pane, all the items will be destroyed but the pane
# itself will not be destroyed.
destroy: ->
if @container?.isAlive() and @container.getPanes().length is 1
@destroyItems()
else
@emitter.emit 'will-destroy'
@container?.willDestroyPane(pane: this)
super
# Called by model superclass.
destroyed: ->
@container.activateNextPane() if @isActive()
@emitter.emit 'did-destroy'
@emitter.dispose()
item.destroy?() for item in @items.slice()
@container?.didDestroyPane(pane: this)
###
Section: Splitting
###
# Public: Create a new pane to the left of this pane.
#
# * `params` (optional) {Object} with the following keys:
# * `items` (optional) {Array} of items to add to the new pane.
# * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
#
# Returns the new {Pane}.
splitLeft: (params) ->
@split('horizontal', 'before', params)
# Public: Create a new pane to the right of this pane.
#
# * `params` (optional) {Object} with the following keys:
# * `items` (optional) {Array} of items to add to the new pane.
# * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
#
# Returns the new {Pane}.
splitRight: (params) ->
@split('horizontal', 'after', params)
# Public: Creates a new pane above the receiver.
#
# * `params` (optional) {Object} with the following keys:
# * `items` (optional) {Array} of items to add to the new pane.
# * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
#
# Returns the new {Pane}.
splitUp: (params) ->
@split('vertical', 'before', params)
# Public: Creates a new pane below the receiver.
#
# * `params` (optional) {Object} with the following keys:
# * `items` (optional) {Array} of items to add to the new pane.
# * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
#
# Returns the new {Pane}.
splitDown: (params) ->
@split('vertical', 'after', params)
split: (orientation, side, params) ->
if params?.copyActiveItem
params.items ?= []
params.items.push(@copyActiveItem())
if @parent.orientation isnt orientation
@parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this], @flexScale}))
@setFlexScale(1)
newPane = new Pane(extend({@applicationDelegate, @deserializerManager, @config}, params))
switch side
when 'before' then @parent.insertChildBefore(this, newPane)
when 'after' then @parent.insertChildAfter(this, newPane)
newPane.activate()
newPane
# If the parent is a horizontal axis, returns its first child if it is a pane;
# otherwise returns this pane.
findLeftmostSibling: ->
if @parent.orientation is 'horizontal'
[leftmostSibling] = @parent.children
if leftmostSibling instanceof PaneAxis
this
else
leftmostSibling
else
this
# 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: ->
if @parent.orientation is 'horizontal'
rightmostSibling = last(@parent.children)
if rightmostSibling instanceof PaneAxis
@splitRight()
else
rightmostSibling
else
@splitRight()
# If the parent is a vertical axis, returns its first child if it is a pane;
# otherwise returns this pane.
findTopmostSibling: ->
if @parent.orientation is 'vertical'
[topmostSibling] = @parent.children
if topmostSibling instanceof PaneAxis
this
else
topmostSibling
else
this
# 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: ->
if @parent.orientation is 'vertical'
bottommostSibling = last(@parent.children)
if bottommostSibling instanceof PaneAxis
@splitRight()
else
bottommostSibling
else
@splitDown()
close: ->
@destroy() if @confirmClose()
confirmClose: ->
for item in @getItems()
return false unless @promptToSaveItem(item)
true
handleSaveError: (error, item) ->
itemPath = error.path ? item?.getPath?()
addWarningWithPath = (message, options) =>
message = "#{message} '#{itemPath}'" if itemPath
@notificationManager.addWarning(message, options)
if error.code is 'EISDIR' or error.message?.endsWith?('is a directory')
@notificationManager.addWarning("Unable to save file: #{error.message}")
else if error.code is 'EACCES'
addWarningWithPath('Unable to save file: Permission denied')
else if error.code in ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN']
addWarningWithPath('Unable to save file', detail: error.message)
else if error.code is 'EROFS'
addWarningWithPath('Unable to save file: Read-only file system')
else if error.code is 'ENOSPC'
addWarningWithPath('Unable to save file: No space left on device')
else if error.code is 'ENXIO'
addWarningWithPath('Unable to save file: No such device or address')
else if error.code is 'ENOTSUP'
addWarningWithPath('Unable to save file: Operation not supported on socket')
else if error.code is 'EIO'
addWarningWithPath('Unable to save file: I/O error writing file')
else if error.code is 'EINTR'
addWarningWithPath('Unable to save file: Interrupted system call')
else if error.code is 'ECONNRESET'
addWarningWithPath('Unable to save file: Connection reset')
else if error.code is 'ESPIPE'
addWarningWithPath('Unable to save file: Invalid seek')
else if errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message)
fileName = errorMatch[1]
@notificationManager.addWarning("Unable to save file: A directory in the path '#{fileName}' could not be written to")
else
throw error