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( { deserializerManager: deserializers, notificationManager: notifications, viewRegistry: views, config, applicationDelegate }, state ) ); } constructor(params = {}) { this.setPendingItem = this.setPendingItem.bind(this); this.getPendingItem = this.getPendingItem.bind(this); this.clearPendingItem = this.clearPendingItem.bind(this); this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind( this ); this.saveItem = this.saveItem.bind(this); this.saveItemAs = this.saveItemAs.bind(this); this.id = params.id; if (this.id != null) { nextInstanceId = Math.max(nextInstanceId, this.id + 1); } else { this.id = nextInstanceId++; } this.activeItem = params.activeItem; this.focused = params.focused != null ? params.focused : false; this.applicationDelegate = params.applicationDelegate; this.notificationManager = params.notificationManager; this.config = params.config; this.deserializerManager = params.deserializerManager; this.viewRegistry = params.viewRegistry; this.emitter = new Emitter(); this.alive = true; this.subscriptionsPerItem = new WeakMap(); this.items = []; this.itemStack = []; this.container = null; this.addItems((params.items || []).filter(item => item)); if (!this.getActiveItem()) this.setActiveItem(this.items[0]); this.addItemsToStack(params.itemStackIndices || []); this.setFlexScale(params.flexScale || 1); } getElement() { if (!this.element) { this.element = 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() { return this.activate(); } // Called by the view layer to indicate that the pane has lost focus. blur() { this.focused = false; return true; // if this is called from an event handler, don't cancel it } isFocused() { return this.focused; } getPanes() { return [this]; } unsubscribeFromItem(item) { const subscription = this.subscriptionsPerItem.get(item); if (subscription) { subscription.dispose(); this.subscriptionsPerItem.delete(item); } } /* Section: Items */ // Public: Get the items in this pane. // // Returns an {Array} of items. getItems() { return this.items.slice(); } // Public: Get the active pane item in this pane. // // Returns a pane item. getActiveItem() { return this.activeItem; } setActiveItem(activeItem, options) { const modifyStack = options && options.modifyStack; if (activeItem !== this.activeItem) { if (modifyStack !== false) this.addItemToStack(activeItem); this.activeItem = activeItem; this.emitter.emit('did-change-active-item', this.activeItem); if (this.container) this.container.didChangeActiveItemOnPane(this, this.activeItem); } return this.activeItem; } // Build the itemStack after deserializing addItemsToStack(itemStackIndices) { if (this.items.length > 0) { if ( itemStackIndices.length !== this.items.length || itemStackIndices.includes(-1) ) { itemStackIndices = this.items.map((item, i) => i); } for (let itemIndex of itemStackIndices) { this.addItemToStack(this.items[itemIndex]); } } } // Add item (or move item) to the end of the itemStack addItemToStack(newItem) { if (newItem == null) { return; } const index = this.itemStack.indexOf(newItem); if (index !== -1) this.itemStack.splice(index, 1); return this.itemStack.push(newItem); } // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. getActiveEditor() { if (this.activeItem instanceof TextEditor) return this.activeItem; } // Public: Return the item at the given index. // // * `index` {Number} // // Returns an item or `null` if no item exists at the given index. itemAtIndex(index) { return this.items[index]; } // Makes the next item in the itemStack active. activateNextRecentlyUsedItem() { if (this.items.length > 1) { if (this.itemStackIndex == null) this.itemStackIndex = this.itemStack.length - 1; if (this.itemStackIndex === 0) this.itemStackIndex = this.itemStack.length; this.itemStackIndex--; const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex]; this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem); this.setActiveItem(nextRecentlyUsedItem, { modifyStack: false }); } } // Makes the previous item in the itemStack active. activatePreviousRecentlyUsedItem() { if (this.items.length > 1) { if ( this.itemStackIndex + 1 === this.itemStack.length || this.itemStackIndex == null ) { this.itemStackIndex = -1; } this.itemStackIndex++; const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex]; this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem); this.setActiveItem(previousRecentlyUsedItem, { modifyStack: false }); } } // Moves the active item to the end of the itemStack once the ctrl key is lifted moveActiveItemToTopOfStack() { delete this.itemStackIndex; this.addItemToStack(this.activeItem); this.emitter.emit('done-choosing-mru-item'); } // Public: Makes the next item active. activateNextItem() { const index = this.getActiveItemIndex(); if (index < this.items.length - 1) { this.activateItemAtIndex(index + 1); } else { this.activateItemAtIndex(0); } } // Public: Makes the previous item active. activatePreviousItem() { const index = this.getActiveItemIndex(); if (index > 0) { this.activateItemAtIndex(index - 1); } else { this.activateItemAtIndex(this.items.length - 1); } } activateLastItem() { this.activateItemAtIndex(this.items.length - 1); } // Public: Move the active tab to the right. moveItemRight() { const index = this.getActiveItemIndex(); const rightItemIndex = index + 1; if (rightItemIndex <= this.items.length - 1) this.moveItem(this.getActiveItem(), rightItemIndex); } // Public: Move the active tab to the left moveItemLeft() { const index = this.getActiveItemIndex(); const leftItemIndex = index - 1; if (leftItemIndex >= 0) return this.moveItem(this.getActiveItem(), leftItemIndex); } // Public: Get the index of the active item. // // Returns a {Number}. getActiveItemIndex() { return this.items.indexOf(this.activeItem); } // Public: Activate the item at the given index. // // * `index` {Number} activateItemAtIndex(index) { const item = this.itemAtIndex(index) || this.getActiveItem(); return this.setActiveItem(item); } // Public: Make the given item *active*, causing it to be displayed by // the pane's view. // // * `item` The item to activate // * `options` (optional) {Object} // * `pending` (optional) {Boolean} indicating that the item should be added // in a pending state if it does not yet exist in the pane. Existing pending // items in a pane are replaced with new pending items when they are opened. activateItem(item, options = {}) { if (item) { const index = this.getPendingItem() === this.activeItem ? this.getActiveItemIndex() : this.getActiveItemIndex() + 1; this.addItem(item, Object.assign({}, options, { index })); this.setActiveItem(item); } } // Public: Add the given item to the pane. // // * `item` The item to add. It can be a model with an associated view or a // view. // * `options` (optional) {Object} // * `index` (optional) {Number} indicating the index at which to add the item. // If omitted, the item is added after the current active item. // * `pending` (optional) {Boolean} indicating that the item should be // added in a pending state. Existing pending items in a pane are replaced with // new pending items when they are opened. // // Returns the added item. addItem(item, options = {}) { // Backward compat with old API: // addItem(item, index=@getActiveItemIndex() + 1) if (typeof options === 'number') { Grim.deprecate( `Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})` ); options = { index: options }; } const index = options.index != null ? options.index : this.getActiveItemIndex() + 1; const moved = options.moved != null ? options.moved : false; const pending = options.pending != null ? options.pending : false; if (!item || typeof item !== 'object') { throw new Error( `Pane items must be objects. Attempted to add item ${item}.` ); } if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { throw new Error( `Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed` ); } if (this.items.includes(item)) return; const itemSubscriptions = new CompositeDisposable(); this.subscriptionsPerItem.set(item, itemSubscriptions); if (typeof item.onDidDestroy === 'function') { itemSubscriptions.add( item.onDidDestroy(() => this.removeItem(item, false)) ); } if (typeof item.onDidTerminatePendingState === 'function') { itemSubscriptions.add( item.onDidTerminatePendingState(() => { if (this.getPendingItem() === item) this.clearPendingItem(); }) ); } this.items.splice(index, 0, item); const lastPendingItem = this.getPendingItem(); const replacingPendingItem = lastPendingItem != null && !moved; if (replacingPendingItem) this.pendingItem = null; if (pending) this.setPendingItem(item); this.emitter.emit('did-add-item', { item, index, moved }); if (!moved) { if (this.container) this.container.didAddPaneItem(item, this, index); } if (replacingPendingItem) this.destroyItem(lastPendingItem); if (!this.getActiveItem()) this.setActiveItem(item); return item; } setPendingItem(item) { if (this.pendingItem !== item) { const mostRecentPendingItem = this.pendingItem; this.pendingItem = item; if (mostRecentPendingItem) { this.emitter.emit( 'item-did-terminate-pending-state', mostRecentPendingItem ); } } } getPendingItem() { return this.pendingItem || null; } clearPendingItem() { this.setPendingItem(null); } onItemDidTerminatePendingState(callback) { return this.emitter.on('item-did-terminate-pending-state', callback); } // Public: Add the given items to the pane. // // * `items` An {Array} of items to add. Items can be views or models with // associated views. Any objects that are already present in the pane's // current items will not be added again. // * `index` (optional) {Number} index at which to add the items. If omitted, // the item is # added after the current active item. // // Returns an {Array} of added items. addItems(items, index = this.getActiveItemIndex() + 1) { items = items.filter(item => !this.items.includes(item)); for (let i = 0; i < items.length; i++) { const item = items[i]; this.addItem(item, { index: index + i }); } return items; } removeItem(item, moved) { const index = this.items.indexOf(item); if (index === -1) return; if (this.getPendingItem() === item) this.pendingItem = null; this.removeItemFromStack(item); this.emitter.emit('will-remove-item', { item, index, destroyed: !moved, moved }); this.unsubscribeFromItem(item); if (item === this.activeItem) { if (this.items.length === 1) { this.setActiveItem(undefined); } else if (index === 0) { this.activateNextItem(); } else { this.activatePreviousItem(); } } this.items.splice(index, 1); this.emitter.emit('did-remove-item', { item, index, destroyed: !moved, moved }); if (!moved && this.container) this.container.didDestroyPaneItem({ item, index, pane: this }); if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) this.destroy(); } // Remove the given item from the itemStack. // // * `item` The item to remove. // * `index` {Number} indicating the index to which to remove the item from the itemStack. removeItemFromStack(item) { const index = this.itemStack.indexOf(item); if (index !== -1) this.itemStack.splice(index, 1); } // Public: Move the given item to the given index. // // * `item` The item to move. // * `index` {Number} indicating the index to which to move the item. moveItem(item, newIndex) { const oldIndex = this.items.indexOf(item); this.items.splice(oldIndex, 1); this.items.splice(newIndex, 0, item); this.emitter.emit('did-move-item', { item, oldIndex, newIndex }); } // Public: Move the given item to the given index on another pane. // // * `item` The item to move. // * `pane` {Pane} to which to move the item. // * `index` {Number} indicating the index to which to move the item in the // given pane. moveItemToPane(item, pane, index) { this.removeItem(item, true); return pane.addItem(item, { index, moved: true }); } // Public: Destroy the active item and activate the next item. // // Returns a {Promise} that resolves when the item is destroyed. destroyActiveItem() { return this.destroyItem(this.activeItem); } // Public: Destroy the given item. // // If the item is active, the next item will be activated. If the item is the // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config // setting is `true`. // // * `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 saveDialogPromise; } // Public: Save all items. saveItems() { for (let item of this.getItems()) { if (typeof item.isModified === 'function' && item.isModified()) { this.saveItem(item); } } } // Public: Return the first item that matches the given URI or undefined if // none exists. // // * `uri` {String} containing a URI. itemForURI(uri) { return this.items.find(item => { if (typeof item.getURI === 'function') { return item.getURI() === uri; } else if (typeof item.getUri === 'function') { return item.getUri() === uri; } }); } // Public: Activate the first item that matches the given URI. // // * `uri` {String} containing a URI. // // Returns a {Boolean} indicating whether an item matching the URI was found. activateItemForURI(uri) { const item = this.itemForURI(uri); if (item) { this.activateItem(item); return true; } else { return false; } } copyActiveItem() { if (this.activeItem && typeof this.activeItem.copy === 'function') { return this.activeItem.copy(); } } /* Section: Lifecycle */ // Public: Determine whether the pane is active. // // Returns a {Boolean}. isActive() { return this.container && this.container.getActivePane() === this; } // Public: Makes this pane the *active* pane, causing it to gain focus. activate() { if (this.isDestroyed()) throw new Error('Pane has been destroyed'); this.focused = true; if (this.container) this.container.didActivatePane(this); this.emitter.emit('did-activate'); } // Public: Close the pane and destroy all its items. // // If this is the last pane, all the items will be destroyed but the pane // itself will not be destroyed. destroy() { if ( this.container && this.container.isAlive() && this.container.getPanes().length === 1 ) { return this.destroyItems(); } this.emitter.emit('will-destroy'); this.alive = false; if (this.container) { this.container.willDestroyPane({ pane: this }); if (this.isActive()) this.container.activateNextPane(); } this.emitter.emit('did-destroy'); this.emitter.dispose(); for (let item of this.items.slice()) { if (typeof item.destroy === 'function') item.destroy(); } if (this.container) this.container.didDestroyPane({ pane: this }); } isAlive() { return this.alive; } // Public: Determine whether this pane has been destroyed. // // Returns a {Boolean}. isDestroyed() { return !this.isAlive(); } /* Section: Splitting */ // Public: Create a new pane to the left of this pane. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitLeft(params) { return this.split('horizontal', 'before', params); } // Public: Create a new pane to the right of this pane. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitRight(params) { return this.split('horizontal', 'after', params); } // Public: Creates a new pane above the receiver. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitUp(params) { return this.split('vertical', 'before', params); } // Public: Creates a new pane below the receiver. // // * `params` (optional) {Object} with the following keys: // * `items` (optional) {Array} of items to add to the new pane. // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane // // Returns the new {Pane}. splitDown(params) { return this.split('vertical', 'after', params); } split(orientation, side, params) { if (params && params.copyActiveItem) { if (!params.items) params.items = []; params.items.push(this.copyActiveItem()); } if (this.parent.orientation !== orientation) { this.parent.replaceChild( this, new PaneAxis( { container: this.container, orientation, children: [this], flexScale: this.flexScale }, this.viewRegistry ) ); this.setFlexScale(1); } const newPane = new Pane( Object.assign( { applicationDelegate: this.applicationDelegate, notificationManager: this.notificationManager, deserializerManager: this.deserializerManager, config: this.config, viewRegistry: this.viewRegistry }, params ) ); switch (side) { case 'before': this.parent.insertChildBefore(this, newPane); break; case 'after': this.parent.insertChildAfter(this, newPane); break; } if (params && params.moveActiveItem && this.activeItem) this.moveItemToPane(this.activeItem, newPane); newPane.activate(); return newPane; } // If the parent is a horizontal axis, returns its first child if it is a pane; // otherwise returns this pane. findLeftmostSibling() { if (this.parent.orientation === 'horizontal') { const [leftmostSibling] = this.parent.children; if (leftmostSibling instanceof PaneAxis) { return this; } else { return leftmostSibling; } } else { return this; } } findRightmostSibling() { if (this.parent.orientation === 'horizontal') { const rightmostSibling = this.parent.children[ this.parent.children.length - 1 ]; if (rightmostSibling instanceof PaneAxis) { return this; } else { return rightmostSibling; } } else { return this; } } // If the parent is a horizontal axis, returns its last child if it is a pane; // otherwise returns a new pane created by splitting this pane rightward. findOrCreateRightmostSibling() { const rightmostSibling = this.findRightmostSibling(); if (rightmostSibling === this) { return this.splitRight(); } else { return rightmostSibling; } } // If the parent is a vertical axis, returns its first child if it is a pane; // otherwise returns this pane. findTopmostSibling() { if (this.parent.orientation === 'vertical') { const [topmostSibling] = this.parent.children; if (topmostSibling instanceof PaneAxis) { return this; } else { return topmostSibling; } } else { return this; } } findBottommostSibling() { if (this.parent.orientation === 'vertical') { const bottommostSibling = this.parent.children[ this.parent.children.length - 1 ]; if (bottommostSibling instanceof PaneAxis) { return this; } else { return bottommostSibling; } } else { return this; } } // If the parent is a vertical axis, returns its last child if it is a pane; // otherwise returns a new pane created by splitting this pane bottomward. findOrCreateBottommostSibling() { const bottommostSibling = this.findBottommostSibling(); if (bottommostSibling === this) { return this.splitDown(); } else { return bottommostSibling; } } // Private: Close the pane unless the user cancels the action via a dialog. // // Returns a {Promise} that resolves once the pane is either closed, or the // closing has been cancelled. close() { return Promise.all( this.getItems().map(item => this.promptToSaveItem(item)) ).then(results => { if (!results.includes(false)) return this.destroy(); }); } handleSaveError(error, item) { const itemPath = error.path || (typeof item.getPath === 'function' && item.getPath()); const addWarningWithPath = (message, options) => { if (itemPath) message = `${message} '${itemPath}'`; this.notificationManager.addWarning(message, options); }; const customMessage = this.getMessageForErrorCode(error.code); if (customMessage != null) { addWarningWithPath(`Unable to save file: ${customMessage}`); } else if ( error.code === 'EISDIR' || (error.message && error.message.endsWith('is a directory')) ) { return this.notificationManager.addWarning( `Unable to save file: ${error.message}` ); } else if ( ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes( error.code ) ) { addWarningWithPath('Unable to save file', { detail: error.message }); } else { const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec( error.message ); if (errorMatch) { const fileName = errorMatch[1]; this.notificationManager.addWarning( `Unable to save file: A directory in the path '${fileName}' could not be written to` ); } else { throw error; } } } getMessageForErrorCode(errorCode) { switch (errorCode) { case 'EACCES': return 'Permission denied'; case 'ECONNRESET': return 'Connection reset'; case 'EINTR': return 'Interrupted system call'; case 'EIO': return 'I/O error writing file'; case 'ENOSPC': return 'No space left on device'; case 'ENOTSUP': return 'Operation not supported on socket'; case 'ENXIO': return 'No such device or address'; case 'EROFS': return 'Read-only file system'; case 'ESPIPE': return 'Invalid seek'; case 'ETIMEDOUT': return 'Connection timed out'; } } }; function promisify(callback) { try { return Promise.resolve(callback()); } catch (error) { return Promise.reject(error); } }