const Grim = require('grim') const {CompositeDisposable, Emitter} = require('event-kit') const PaneAxis = require('./pane-axis') const TextEditor = require('./text-editor') const PaneElement = require('./pane-element') let nextInstanceId = 1 class SaveCancelledError extends Error {} // Extended: A container for presenting content in the center of the workspace. // Panes can contain multiple items, one of which is *active* at a given time. // The view corresponding to the active item is displayed in the interface. In // the default configuration, tabs are also displayed for each item. // // Each pane may also contain one *pending* item. When a pending item is added // to a pane, it will replace the currently pending item, if any, instead of // simply being added. In the default configuration, the text in the tab for // pending items is shown in italics. module.exports = class Pane { inspect () { return `Pane ${this.id}` } static deserialize (state, {deserializers, applicationDelegate, config, notifications, views}) { const {activeItemIndex} = state const activeItemURI = state.activeItemURI || state.activeItemUri const items = [] for (const itemState of state.items) { const item = deserializers.deserialize(itemState) if (item) items.push(item) } state.items = items state.activeItem = items[activeItemIndex] if (!state.activeItem && activeItemURI) { state.activeItem = state.items.find((item) => typeof item.getURI === 'function' && item.getURI() === activeItemURI ) } return new Pane(Object.assign(state, { deserializerManager: deserializers, notificationManager: notifications, viewRegistry: views, config, applicationDelegate })) } constructor (params = {}) { this.setPendingItem = this.setPendingItem.bind(this) this.getPendingItem = this.getPendingItem.bind(this) this.clearPendingItem = this.clearPendingItem.bind(this) this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind(this) this.saveItem = this.saveItem.bind(this) this.saveItemAs = this.saveItemAs.bind(this) this.id = params.id if (this.id != null) { nextInstanceId = Math.max(nextInstanceId, this.id + 1) } else { this.id = nextInstanceId++ } this.activeItem = params.activeItem this.focused = params.focused != null ? params.focused : false this.applicationDelegate = params.applicationDelegate this.notificationManager = params.notificationManager this.config = params.config this.deserializerManager = params.deserializerManager this.viewRegistry = params.viewRegistry this.emitter = new Emitter() this.alive = true this.subscriptionsPerItem = new WeakMap() this.items = [] this.itemStack = [] this.container = null this.addItems((params.items || []).filter(item => item)) if (!this.getActiveItem()) this.setActiveItem(this.items[0]) this.addItemsToStack(params.itemStackIndices || []) this.setFlexScale(params.flexScale || 1) } getElement () { if (!this.element) { this.element = new PaneElement().initialize( this, {views: this.viewRegistry, applicationDelegate: this.applicationDelegate} ) } return this.element } serialize () { const itemsToBeSerialized = this.items.filter(item => item && typeof item.serialize === 'function') const itemStackIndices = [] for (const item of this.itemStack) { if (typeof item.serialize === 'function') { itemStackIndices.push(itemsToBeSerialized.indexOf(item)) } } const activeItemIndex = itemsToBeSerialized.indexOf(this.activeItem) return { deserializer: 'Pane', id: this.id, items: itemsToBeSerialized.map(item => item.serialize()), itemStackIndices, activeItemIndex, focused: this.focused, flexScale: this.flexScale } } getParent () { return this.parent } setParent (parent) { this.parent = parent } getContainer () { return this.container } setContainer (container) { if (container && container !== this.container) { this.container = container container.didAddPane({pane: this}) } } // Private: Determine whether the given item is allowed to exist in this pane. // // * `item` the Item // // Returns a {Boolean}. isItemAllowed (item) { if (typeof item.getAllowedLocations !== 'function') { return true } else { return item.getAllowedLocations().includes(this.getContainer().getLocation()) } } setFlexScale (flexScale) { this.flexScale = flexScale this.emitter.emit('did-change-flex-scale', this.flexScale) return this.flexScale } getFlexScale () { return this.flexScale } increaseSize () { this.setFlexScale(this.getFlexScale() * 1.1) } decreaseSize () { this.setFlexScale(this.getFlexScale() / 1.1) } /* Section: Event Subscription */ // Public: Invoke the given callback when the pane resizes // // The callback will be invoked when pane's flexScale property changes. // Use {::getFlexScale} to get the current value. // // * `callback` {Function} to be called when the pane is resized // * `flexScale` {Number} representing the panes `flex-grow`; ability for a // flex item to grow if necessary. // // Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. onDidChangeFlexScale (callback) { return this.emitter.on('did-change-flex-scale', callback) } // Public: Invoke the given callback with the current and future values of // {::getFlexScale}. // // * `callback` {Function} to be called with the current and future values of // the {::getFlexScale} property. // * `flexScale` {Number} representing the panes `flex-grow`; ability for a // flex item to grow if necessary. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. observeFlexScale (callback) { callback(this.flexScale) return this.onDidChangeFlexScale(callback) } // Public: Invoke the given callback when the pane is activated. // // The given callback will be invoked whenever {::activate} is called on the // pane, even if it is already active at the time. // // * `callback` {Function} to be called when the pane is activated. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidActivate (callback) { return this.emitter.on('did-activate', callback) } // Public: Invoke the given callback before the pane is destroyed. // // * `callback` {Function} to be called before the pane is destroyed. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onWillDestroy (callback) { return this.emitter.on('will-destroy', callback) } // Public: Invoke the given callback when the pane is destroyed. // // * `callback` {Function} to be called when the pane is destroyed. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidDestroy (callback) { return this.emitter.once('did-destroy', callback) } // Public: Invoke the given callback when the value of the {::isActive} // property changes. // // * `callback` {Function} to be called when the value of the {::isActive} // property changes. // * `active` {Boolean} indicating whether the pane is active. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangeActive (callback) { return this.container.onDidChangeActivePane(activePane => { const isActive = this === activePane callback(isActive) }) } // Public: Invoke the given callback with the current and future values of the // {::isActive} property. // // * `callback` {Function} to be called with the current and future values of // the {::isActive} property. // * `active` {Boolean} indicating whether the pane is active. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. observeActive (callback) { callback(this.isActive()) return this.onDidChangeActive(callback) } // Public: Invoke the given callback when an item is added to the pane. // // * `callback` {Function} to be called with when items are added. // * `event` {Object} with the following keys: // * `item` The added pane item. // * `index` {Number} indicating where the item is located. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidAddItem (callback) { return this.emitter.on('did-add-item', callback) } // Public: Invoke the given callback when an item is removed from the pane. // // * `callback` {Function} to be called with when items are removed. // * `event` {Object} with the following keys: // * `item` The removed pane item. // * `index` {Number} indicating where the item was located. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidRemoveItem (callback) { return this.emitter.on('did-remove-item', callback) } // Public: Invoke the given callback before an item is removed from the pane. // // * `callback` {Function} to be called with when items are removed. // * `event` {Object} with the following keys: // * `item` The pane item to be removed. // * `index` {Number} indicating where the item is located. onWillRemoveItem (callback) { return this.emitter.on('will-remove-item', callback) } // Public: Invoke the given callback when an item is moved within the pane. // // * `callback` {Function} to be called with when items are moved. // * `event` {Object} with the following keys: // * `item` The removed pane item. // * `oldIndex` {Number} indicating where the item was located. // * `newIndex` {Number} indicating where the item is now located. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidMoveItem (callback) { return this.emitter.on('did-move-item', callback) } // Public: Invoke the given callback with all current and future items. // // * `callback` {Function} to be called with current and future items. // * `item` An item that is present in {::getItems} at the time of // subscription or that is added at some later time. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. observeItems (callback) { for (let item of this.getItems()) { callback(item) } return this.onDidAddItem(({item}) => callback(item)) } // Public: Invoke the given callback when the value of {::getActiveItem} // changes. // // * `callback` {Function} to be called with when the active item changes. // * `activeItem` The current active item. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDidChangeActiveItem (callback) { return this.emitter.on('did-change-active-item', callback) } // Public: Invoke the given callback when {::activateNextRecentlyUsedItem} // has been called, either initiating or continuing a forward MRU traversal of // pane items. // // * `callback` {Function} to be called with when the active item changes. // * `nextRecentlyUsedItem` The next MRU item, now being set active // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onChooseNextMRUItem (callback) { return this.emitter.on('choose-next-mru-item', callback) } // Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem} // has been called, either initiating or continuing a reverse MRU traversal of // pane items. // // * `callback` {Function} to be called with when the active item changes. // * `previousRecentlyUsedItem` The previous MRU item, now being set active // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onChooseLastMRUItem (callback) { return this.emitter.on('choose-last-mru-item', callback) } // Public: Invoke the given callback when {::moveActiveItemToTopOfStack} // has been called, terminating an MRU traversal of pane items and moving the // current active item to the top of the stack. Typically bound to a modifier // (e.g. CTRL) key up event. // // * `callback` {Function} to be called with when the MRU traversal is done. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. onDoneChoosingMRUItem (callback) { return this.emitter.on('done-choosing-mru-item', callback) } // Public: Invoke the given callback with the current and future values of // {::getActiveItem}. // // * `callback` {Function} to be called with the current and future active // items. // * `activeItem` The current active item. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. observeActiveItem (callback) { callback(this.getActiveItem()) return this.onDidChangeActiveItem(callback) } // Public: Invoke the given callback before items are destroyed. // // * `callback` {Function} to be called before items are destroyed. // * `event` {Object} with the following keys: // * `item` The item that will be destroyed. // * `index` The location of the item. // // Returns a {Disposable} on which `.dispose()` can be called to // unsubscribe. onWillDestroyItem (callback) { return this.emitter.on('will-destroy-item', callback) } // Called by the view layer to indicate that the pane has gained focus. focus () { this.focused = true return this.activate() } // Called by the view layer to indicate that the pane has lost focus. blur () { this.focused = false return true // if this is called from an event handler, don't cancel it } isFocused () { return this.focused } getPanes () { return [this] } unsubscribeFromItem (item) { const subscription = this.subscriptionsPerItem.get(item) if (subscription) { subscription.dispose() this.subscriptionsPerItem.delete(item) } } /* Section: Items */ // Public: Get the items in this pane. // // Returns an {Array} of items. getItems () { return this.items.slice() } // Public: Get the active pane item in this pane. // // Returns a pane item. getActiveItem () { return this.activeItem } setActiveItem (activeItem, options) { const modifyStack = options && options.modifyStack if (activeItem !== this.activeItem) { if (modifyStack !== false) this.addItemToStack(activeItem) this.activeItem = activeItem this.emitter.emit('did-change-active-item', this.activeItem) if (this.container) this.container.didChangeActiveItemOnPane(this, this.activeItem) } return this.activeItem } // Build the itemStack after deserializing addItemsToStack (itemStackIndices) { if (this.items.length > 0) { if (itemStackIndices.length !== this.items.length || itemStackIndices.includes(-1)) { itemStackIndices = this.items.map((item, i) => i) } for (let itemIndex of itemStackIndices) { this.addItemToStack(this.items[itemIndex]) } } } // Add item (or move item) to the end of the itemStack addItemToStack (newItem) { if (newItem == null) { return } const index = this.itemStack.indexOf(newItem) if (index !== -1) this.itemStack.splice(index, 1) return this.itemStack.push(newItem) } // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. getActiveEditor () { if (this.activeItem instanceof TextEditor) return this.activeItem } // Public: Return the item at the given index. // // * `index` {Number} // // Returns an item or `null` if no item exists at the given index. itemAtIndex (index) { return this.items[index] } // Makes the next item in the itemStack active. activateNextRecentlyUsedItem () { if (this.items.length > 1) { if (this.itemStackIndex == null) this.itemStackIndex = this.itemStack.length - 1 if (this.itemStackIndex === 0) this.itemStackIndex = this.itemStack.length this.itemStackIndex-- const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex] this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem) this.setActiveItem(nextRecentlyUsedItem, {modifyStack: false}) } } // Makes the previous item in the itemStack active. activatePreviousRecentlyUsedItem () { if (this.items.length > 1) { if (this.itemStackIndex + 1 === this.itemStack.length || this.itemStackIndex == null) { this.itemStackIndex = -1 } this.itemStackIndex++ const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex] this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem) this.setActiveItem(previousRecentlyUsedItem, {modifyStack: false}) } } // Moves the active item to the end of the itemStack once the ctrl key is lifted moveActiveItemToTopOfStack () { delete this.itemStackIndex this.addItemToStack(this.activeItem) this.emitter.emit('done-choosing-mru-item') } // Public: Makes the next item active. activateNextItem () { const index = this.getActiveItemIndex() if (index < (this.items.length - 1)) { this.activateItemAtIndex(index + 1) } else { this.activateItemAtIndex(0) } } // Public: Makes the previous item active. activatePreviousItem () { const index = this.getActiveItemIndex() if (index > 0) { this.activateItemAtIndex(index - 1) } else { this.activateItemAtIndex(this.items.length - 1) } } activateLastItem () { this.activateItemAtIndex(this.items.length - 1) } // Public: Move the active tab to the right. moveItemRight () { const index = this.getActiveItemIndex() const rightItemIndex = index + 1 if (rightItemIndex <= this.items.length - 1) this.moveItem(this.getActiveItem(), rightItemIndex) } // Public: Move the active tab to the left moveItemLeft () { const index = this.getActiveItemIndex() const leftItemIndex = index - 1 if (leftItemIndex >= 0) return this.moveItem(this.getActiveItem(), leftItemIndex) } // Public: Get the index of the active item. // // Returns a {Number}. getActiveItemIndex () { return this.items.indexOf(this.activeItem) } // Public: Activate the item at the given index. // // * `index` {Number} activateItemAtIndex (index) { const item = this.itemAtIndex(index) || this.getActiveItem() return this.setActiveItem(item) } // Public: Make the given item *active*, causing it to be displayed by // the pane's view. // // * `item` The item to activate // * `options` (optional) {Object} // * `pending` (optional) {Boolean} indicating that the item should be added // in a pending state if it does not yet exist in the pane. Existing pending // items in a pane are replaced with new pending items when they are opened. activateItem (item, options = {}) { if (item) { const index = (this.getPendingItem() === this.activeItem) ? this.getActiveItemIndex() : this.getActiveItemIndex() + 1 this.addItem(item, Object.assign({}, options, {index})) this.setActiveItem(item) } } // Public: Add the given item to the pane. // // * `item` The item to add. It can be a model with an associated view or a // view. // * `options` (optional) {Object} // * `index` (optional) {Number} indicating the index at which to add the item. // If omitted, the item is added after the current active item. // * `pending` (optional) {Boolean} indicating that the item should be // added in a pending state. Existing pending items in a pane are replaced with // new pending items when they are opened. // // Returns the added item. addItem (item, options = {}) { // Backward compat with old API: // addItem(item, index=@getActiveItemIndex() + 1) if (typeof options === 'number') { Grim.deprecate(`Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})`) options = {index: options} } const index = options.index != null ? options.index : this.getActiveItemIndex() + 1 const moved = options.moved != null ? options.moved : false const pending = options.pending != null ? options.pending : false if (!item || typeof item !== 'object') { throw new Error(`Pane items must be objects. Attempted to add item ${item}.`) } if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { throw new Error(`Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed`) } if (this.items.includes(item)) return if (typeof item.onDidDestroy === 'function') { const itemSubscriptions = new CompositeDisposable() itemSubscriptions.add(item.onDidDestroy(() => this.removeItem(item, false))) if (typeof item.onDidTerminatePendingState === 'function') { itemSubscriptions.add(item.onDidTerminatePendingState(() => { if (this.getPendingItem() === item) this.clearPendingItem() })) } this.subscriptionsPerItem.set(item, itemSubscriptions) } this.items.splice(index, 0, item) const lastPendingItem = this.getPendingItem() const replacingPendingItem = lastPendingItem != null && !moved if (replacingPendingItem) this.pendingItem = null if (pending) this.setPendingItem(item) this.emitter.emit('did-add-item', {item, index, moved}) if (!moved) { if (this.container) this.container.didAddPaneItem(item, this, index) } if (replacingPendingItem) this.destroyItem(lastPendingItem) if (!this.getActiveItem()) this.setActiveItem(item) return item } setPendingItem (item) { if (this.pendingItem !== item) { const mostRecentPendingItem = this.pendingItem this.pendingItem = item if (mostRecentPendingItem) { this.emitter.emit('item-did-terminate-pending-state', mostRecentPendingItem) } } } getPendingItem () { return this.pendingItem || null } clearPendingItem () { this.setPendingItem(null) } onItemDidTerminatePendingState (callback) { return this.emitter.on('item-did-terminate-pending-state', callback) } // Public: Add the given items to the pane. // // * `items` An {Array} of items to add. Items can be views or models with // associated views. Any objects that are already present in the pane's // current items will not be added again. // * `index` (optional) {Number} index at which to add the items. If omitted, // the item is # added after the current active item. // // Returns an {Array} of added items. addItems (items, index = this.getActiveItemIndex() + 1) { items = items.filter(item => !this.items.includes(item)) for (let i = 0; i < items.length; i++) { const item = items[i] this.addItem(item, {index: index + i}) } return items } removeItem (item, moved) { const index = this.items.indexOf(item) if (index === -1) return if (this.getPendingItem() === item) this.pendingItem = null this.removeItemFromStack(item) this.emitter.emit('will-remove-item', {item, index, destroyed: !moved, moved}) this.unsubscribeFromItem(item) if (item === this.activeItem) { if (this.items.length === 1) { this.setActiveItem(undefined) } else if (index === 0) { this.activateNextItem() } else { this.activatePreviousItem() } } this.items.splice(index, 1) this.emitter.emit('did-remove-item', {item, index, destroyed: !moved, moved}) if (!moved && this.container) this.container.didDestroyPaneItem({item, index, pane: this}) if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) this.destroy() } // Remove the given item from the itemStack. // // * `item` The item to remove. // * `index` {Number} indicating the index to which to remove the item from the itemStack. removeItemFromStack (item) { const index = this.itemStack.indexOf(item) if (index !== -1) this.itemStack.splice(index, 1) } // Public: Move the given item to the given index. // // * `item` The item to move. // * `index` {Number} indicating the index to which to move the item. moveItem (item, newIndex) { const oldIndex = this.items.indexOf(item) this.items.splice(oldIndex, 1) this.items.splice(newIndex, 0, item) this.emitter.emit('did-move-item', {item, oldIndex, newIndex}) } // Public: Move the given item to the given index on another pane. // // * `item` The item to move. // * `pane` {Pane} to which to move the item. // * `index` {Number} indicating the index to which to move the item in the // given pane. moveItemToPane (item, pane, index) { this.removeItem(item, true) return pane.addItem(item, {index, moved: true}) } // Public: Destroy the active item and activate the next item. // // Returns a {Promise} that resolves when the item is destroyed. destroyActiveItem () { return this.destroyItem(this.activeItem) } // Public: Destroy the given item. // // If the item is active, the next item will be activated. If the item is the // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config // setting is `true`. // // * `item` Item to destroy // * `force` (optional) {Boolean} Destroy the item without prompting to save // it, even if the item's `isPermanentDockItem` method returns true. // // Returns a {Promise} that resolves with a {Boolean} indicating whether or not // the item was destroyed. async destroyItem (item, force) { const index = this.items.indexOf(item) if (index === -1) return false if (!force && typeof item.isPermanentDockItem === 'function' && item.isPermanentDockItem() && (!this.container || this.container.getLocation() !== 'center')) { return false } // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { await this.emitter.emitAsync('will-destroy-item', {item, index}) } if (this.container && this.container.emitter.listenerCountForEventName('will-destroy-pane-item') > 0) { await this.container.willDestroyPaneItem({item, index, pane: this}) } if (!force && typeof item.shouldPromptToSave === 'function' && item.shouldPromptToSave()) { if (!await this.promptToSaveItem(item)) return false } this.removeItem(item, false) if (typeof item.destroy === 'function') item.destroy() return true } // Public: Destroy all items. destroyItems () { return Promise.all( this.getItems().map(item => this.destroyItem(item)) ) } // Public: Destroy all items except for the active item. destroyInactiveItems () { return Promise.all( this.getItems() .filter(item => item !== this.activeItem) .map(item => this.destroyItem(item)) ) } promptToSaveItem (item, options = {}) { 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 saveItemAs (item, nextAction) { if (!item) return if (typeof item.saveAs !== 'function') return const saveOptions = typeof item.getSaveDialogOptions === 'function' ? item.getSaveDialogOptions() : {} const itemPath = item.getPath() if (itemPath && !saveOptions.defaultPath) saveOptions.defaultPath = itemPath const newItemPath = this.applicationDelegate.showSaveDialog(saveOptions) if (newItemPath) { return promisify(() => item.saveAs(newItemPath)) .then(() => { if (nextAction) nextAction() }) .catch(error => { if (nextAction) { nextAction(error) } else { this.handleSaveError(error, item) } }) } else if (nextAction) { return nextAction(new SaveCancelledError('Save Cancelled')) } } // Public: Save all items. saveItems () { for (let item of this.getItems()) { if (typeof item.isModified === 'function' && item.isModified()) { this.saveItem(item) } } } // Public: Return the first item that matches the given URI or undefined if // none exists. // // * `uri` {String} containing a URI. itemForURI (uri) { return this.items.find(item => { if (typeof item.getURI === 'function') { return item.getURI() === uri } else if (typeof item.getUri === 'function') { return item.getUri() === uri } }) } // Public: Activate the first item that matches the given URI. // // * `uri` {String} containing a URI. // // Returns a {Boolean} indicating whether an item matching the URI was found. activateItemForURI (uri) { const item = this.itemForURI(uri) if (item) { this.activateItem(item) return true } else { return false } } copyActiveItem () { if (this.activeItem && typeof this.activeItem.copy === 'function') { return this.activeItem.copy() } } /* Section: Lifecycle */ // Public: Determine whether the pane is active. // // Returns a {Boolean}. isActive () { return this.container && this.container.getActivePane() === this } // Public: Makes this pane the *active* pane, causing it to gain focus. activate () { if (this.isDestroyed()) throw new Error('Pane has been destroyed') if (this.container) this.container.didActivatePane(this) this.emitter.emit('did-activate') } // Public: Close the pane and destroy all its items. // // If this is the last pane, all the items will be destroyed but the pane // itself will not be destroyed. destroy () { if (this.container && this.container.isAlive() && this.container.getPanes().length === 1) { return this.destroyItems() } this.emitter.emit('will-destroy') this.alive = false if (this.container) { this.container.willDestroyPane({pane: this}) if (this.isActive()) this.container.activateNextPane() } this.emitter.emit('did-destroy') this.emitter.dispose() for (let item of this.items.slice()) { if (typeof item.destroy === 'function') item.destroy() } if (this.container) this.container.didDestroyPane({pane: this}) } isAlive () { return this.alive } // Public: Determine whether this pane has been destroyed. // // Returns a {Boolean}. isDestroyed () { return !this.isAlive() } /* Section: Splitting */ // Public: Create a new pane to the left of this pane. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitLeft (params) { return this.split('horizontal', 'before', params) } // Public: Create a new pane to the right of this pane. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitRight (params) { return this.split('horizontal', 'after', params) } // Public: Creates a new pane above the receiver. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitUp (params) { return this.split('vertical', 'before', params) } // Public: Creates a new pane below the receiver. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitDown (params) { return this.split('vertical', 'after', params) } split (orientation, side, params) { if (params && params.copyActiveItem) { if (!params.items) params.items = [] params.items.push(this.copyActiveItem()) } if (this.parent.orientation !== orientation) { this.parent.replaceChild(this, new PaneAxis({ container: this.container, orientation, children: [this], flexScale: this.flexScale}, this.viewRegistry )) this.setFlexScale(1) } const newPane = new Pane(Object.assign({ applicationDelegate: this.applicationDelegate, notificationManager: this.notificationManager, deserializerManager: this.deserializerManager, config: this.config, viewRegistry: this.viewRegistry }, params)) switch (side) { case 'before': this.parent.insertChildBefore(this, newPane); break case 'after': this.parent.insertChildAfter(this, newPane); break } if (params && params.moveActiveItem && this.activeItem) this.moveItemToPane(this.activeItem, newPane) newPane.activate() return newPane } // If the parent is a horizontal axis, returns its first child if it is a pane; // otherwise returns this pane. findLeftmostSibling () { if (this.parent.orientation === 'horizontal') { const [leftmostSibling] = this.parent.children if (leftmostSibling instanceof PaneAxis) { return this } else { return leftmostSibling } } else { return this } } findRightmostSibling () { if (this.parent.orientation === 'horizontal') { const rightmostSibling = this.parent.children[this.parent.children.length - 1] if (rightmostSibling instanceof PaneAxis) { return this } else { return rightmostSibling } } else { return this } } // If the parent is a horizontal axis, returns its last child if it is a pane; // otherwise returns a new pane created by splitting this pane rightward. findOrCreateRightmostSibling () { const rightmostSibling = this.findRightmostSibling() if (rightmostSibling === this) { return this.splitRight() } else { return rightmostSibling } } // If the parent is a vertical axis, returns its first child if it is a pane; // otherwise returns this pane. findTopmostSibling () { if (this.parent.orientation === 'vertical') { const [topmostSibling] = this.parent.children if (topmostSibling instanceof PaneAxis) { return this } else { return topmostSibling } } else { return this } } findBottommostSibling () { if (this.parent.orientation === 'vertical') { const bottommostSibling = this.parent.children[this.parent.children.length - 1] if (bottommostSibling instanceof PaneAxis) { return this } else { return bottommostSibling } } else { return this } } // If the parent is a vertical axis, returns its last child if it is a pane; // otherwise returns a new pane created by splitting this pane bottomward. findOrCreateBottommostSibling () { const bottommostSibling = this.findBottommostSibling() if (bottommostSibling === this) { return this.splitDown() } else { return bottommostSibling } } // Private: Close the pane unless the user cancels the action via a dialog. // // Returns a {Promise} that resolves once the pane is either closed, or the // closing has been cancelled. close () { return Promise.all(this.getItems().map(item => this.promptToSaveItem(item))) .then(results => { if (!results.includes(false)) return this.destroy() }) } handleSaveError (error, item) { const itemPath = error.path || (typeof item.getPath === 'function' && item.getPath()) const addWarningWithPath = (message, options) => { if (itemPath) message = `${message} '${itemPath}'` this.notificationManager.addWarning(message, options) } const customMessage = this.getMessageForErrorCode(error.code) if (customMessage != null) { addWarningWithPath(`Unable to save file: ${customMessage}`) } else if (error.code === 'EISDIR' || (error.message && error.message.endsWith('is a directory'))) { return this.notificationManager.addWarning(`Unable to save file: ${error.message}`) } else if (['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes(error.code)) { addWarningWithPath('Unable to save file', {detail: error.message}) } else { const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) if (errorMatch) { const fileName = errorMatch[1] this.notificationManager.addWarning(`Unable to save file: A directory in the path '${fileName}' could not be written to`) } else { throw error } } } getMessageForErrorCode (errorCode) { switch (errorCode) { case 'EACCES': return 'Permission denied' case 'ECONNRESET': return 'Connection reset' case 'EINTR': return 'Interrupted system call' case 'EIO': return 'I/O error writing file' case 'ENOSPC': return 'No space left on device' case 'ENOTSUP': return 'Operation not supported on socket' case 'ENXIO': return 'No such device or address' case 'EROFS': return 'Read-only file system' case 'ESPIPE': return 'Invalid seek' case 'ETIMEDOUT': return 'Connection timed out' } } } function promisify (callback) { try { return Promise.resolve(callback()) } catch (error) { return Promise.reject(error) } }