pane.js
1 const Grim = require('grim'); 2 const { CompositeDisposable, Emitter } = require('event-kit'); 3 const PaneAxis = require('./pane-axis'); 4 const TextEditor = require('./text-editor'); 5 const PaneElement = require('./pane-element'); 6 7 let nextInstanceId = 1; 8 9 class SaveCancelledError extends Error {} 10 11 // Extended: A container for presenting content in the center of the workspace. 12 // Panes can contain multiple items, one of which is *active* at a given time. 13 // The view corresponding to the active item is displayed in the interface. In 14 // the default configuration, tabs are also displayed for each item. 15 // 16 // Each pane may also contain one *pending* item. When a pending item is added 17 // to a pane, it will replace the currently pending item, if any, instead of 18 // simply being added. In the default configuration, the text in the tab for 19 // pending items is shown in italics. 20 module.exports = class Pane { 21 inspect() { 22 return `Pane ${this.id}`; 23 } 24 25 static deserialize( 26 state, 27 { deserializers, applicationDelegate, config, notifications, views } 28 ) { 29 const { activeItemIndex } = state; 30 const activeItemURI = state.activeItemURI || state.activeItemUri; 31 32 const items = []; 33 for (const itemState of state.items) { 34 const item = deserializers.deserialize(itemState); 35 if (item) items.push(item); 36 } 37 state.items = items; 38 39 state.activeItem = items[activeItemIndex]; 40 if (!state.activeItem && activeItemURI) { 41 state.activeItem = state.items.find( 42 item => 43 typeof item.getURI === 'function' && item.getURI() === activeItemURI 44 ); 45 } 46 47 return new Pane( 48 Object.assign( 49 { 50 deserializerManager: deserializers, 51 notificationManager: notifications, 52 viewRegistry: views, 53 config, 54 applicationDelegate 55 }, 56 state 57 ) 58 ); 59 } 60 61 constructor(params = {}) { 62 this.setPendingItem = this.setPendingItem.bind(this); 63 this.getPendingItem = this.getPendingItem.bind(this); 64 this.clearPendingItem = this.clearPendingItem.bind(this); 65 this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind( 66 this 67 ); 68 this.saveItem = this.saveItem.bind(this); 69 this.saveItemAs = this.saveItemAs.bind(this); 70 71 this.id = params.id; 72 if (this.id != null) { 73 nextInstanceId = Math.max(nextInstanceId, this.id + 1); 74 } else { 75 this.id = nextInstanceId++; 76 } 77 78 this.activeItem = params.activeItem; 79 this.focused = params.focused != null ? params.focused : false; 80 this.applicationDelegate = params.applicationDelegate; 81 this.notificationManager = params.notificationManager; 82 this.config = params.config; 83 this.deserializerManager = params.deserializerManager; 84 this.viewRegistry = params.viewRegistry; 85 86 this.emitter = new Emitter(); 87 this.alive = true; 88 this.subscriptionsPerItem = new WeakMap(); 89 this.items = []; 90 this.itemStack = []; 91 this.container = null; 92 93 this.addItems((params.items || []).filter(item => item)); 94 if (!this.getActiveItem()) this.setActiveItem(this.items[0]); 95 this.addItemsToStack(params.itemStackIndices || []); 96 this.setFlexScale(params.flexScale || 1); 97 } 98 99 getElement() { 100 if (!this.element) { 101 this.element = new PaneElement().initialize(this, { 102 views: this.viewRegistry, 103 applicationDelegate: this.applicationDelegate 104 }); 105 } 106 return this.element; 107 } 108 109 serialize() { 110 const itemsToBeSerialized = this.items.filter( 111 item => item && typeof item.serialize === 'function' 112 ); 113 114 const itemStackIndices = []; 115 for (const item of this.itemStack) { 116 if (typeof item.serialize === 'function') { 117 itemStackIndices.push(itemsToBeSerialized.indexOf(item)); 118 } 119 } 120 121 const activeItemIndex = itemsToBeSerialized.indexOf(this.activeItem); 122 123 return { 124 deserializer: 'Pane', 125 id: this.id, 126 items: itemsToBeSerialized.map(item => item.serialize()), 127 itemStackIndices, 128 activeItemIndex, 129 focused: this.focused, 130 flexScale: this.flexScale 131 }; 132 } 133 134 getParent() { 135 return this.parent; 136 } 137 138 setParent(parent) { 139 this.parent = parent; 140 } 141 142 getContainer() { 143 return this.container; 144 } 145 146 setContainer(container) { 147 if (container && container !== this.container) { 148 this.container = container; 149 container.didAddPane({ pane: this }); 150 } 151 } 152 153 // Private: Determine whether the given item is allowed to exist in this pane. 154 // 155 // * `item` the Item 156 // 157 // Returns a {Boolean}. 158 isItemAllowed(item) { 159 if (typeof item.getAllowedLocations !== 'function') { 160 return true; 161 } else { 162 return item 163 .getAllowedLocations() 164 .includes(this.getContainer().getLocation()); 165 } 166 } 167 168 setFlexScale(flexScale) { 169 this.flexScale = flexScale; 170 this.emitter.emit('did-change-flex-scale', this.flexScale); 171 return this.flexScale; 172 } 173 174 getFlexScale() { 175 return this.flexScale; 176 } 177 178 increaseSize() { 179 if (this.getContainer().getPanes().length > 1) { 180 this.setFlexScale(this.getFlexScale() * 1.1); 181 } 182 } 183 184 decreaseSize() { 185 if (this.getContainer().getPanes().length > 1) { 186 this.setFlexScale(this.getFlexScale() / 1.1); 187 } 188 } 189 190 /* 191 Section: Event Subscription 192 */ 193 194 // Public: Invoke the given callback when the pane resizes 195 // 196 // The callback will be invoked when pane's flexScale property changes. 197 // Use {::getFlexScale} to get the current value. 198 // 199 // * `callback` {Function} to be called when the pane is resized 200 // * `flexScale` {Number} representing the panes `flex-grow`; ability for a 201 // flex item to grow if necessary. 202 // 203 // Returns a {Disposable} on which '.dispose()' can be called to unsubscribe. 204 onDidChangeFlexScale(callback) { 205 return this.emitter.on('did-change-flex-scale', callback); 206 } 207 208 // Public: Invoke the given callback with the current and future values of 209 // {::getFlexScale}. 210 // 211 // * `callback` {Function} to be called with the current and future values of 212 // the {::getFlexScale} property. 213 // * `flexScale` {Number} representing the panes `flex-grow`; ability for a 214 // flex item to grow if necessary. 215 // 216 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 217 observeFlexScale(callback) { 218 callback(this.flexScale); 219 return this.onDidChangeFlexScale(callback); 220 } 221 222 // Public: Invoke the given callback when the pane is activated. 223 // 224 // The given callback will be invoked whenever {::activate} is called on the 225 // pane, even if it is already active at the time. 226 // 227 // * `callback` {Function} to be called when the pane is activated. 228 // 229 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 230 onDidActivate(callback) { 231 return this.emitter.on('did-activate', callback); 232 } 233 234 // Public: Invoke the given callback before the pane is destroyed. 235 // 236 // * `callback` {Function} to be called before the pane is destroyed. 237 // 238 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 239 onWillDestroy(callback) { 240 return this.emitter.on('will-destroy', callback); 241 } 242 243 // Public: Invoke the given callback when the pane is destroyed. 244 // 245 // * `callback` {Function} to be called when the pane is destroyed. 246 // 247 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 248 onDidDestroy(callback) { 249 return this.emitter.once('did-destroy', callback); 250 } 251 252 // Public: Invoke the given callback when the value of the {::isActive} 253 // property changes. 254 // 255 // * `callback` {Function} to be called when the value of the {::isActive} 256 // property changes. 257 // * `active` {Boolean} indicating whether the pane is active. 258 // 259 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 260 onDidChangeActive(callback) { 261 return this.container.onDidChangeActivePane(activePane => { 262 const isActive = this === activePane; 263 callback(isActive); 264 }); 265 } 266 267 // Public: Invoke the given callback with the current and future values of the 268 // {::isActive} property. 269 // 270 // * `callback` {Function} to be called with the current and future values of 271 // the {::isActive} property. 272 // * `active` {Boolean} indicating whether the pane is active. 273 // 274 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 275 observeActive(callback) { 276 callback(this.isActive()); 277 return this.onDidChangeActive(callback); 278 } 279 280 // Public: Invoke the given callback when an item is added to the pane. 281 // 282 // * `callback` {Function} to be called with when items are added. 283 // * `event` {Object} with the following keys: 284 // * `item` The added pane item. 285 // * `index` {Number} indicating where the item is located. 286 // 287 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 288 onDidAddItem(callback) { 289 return this.emitter.on('did-add-item', callback); 290 } 291 292 // Public: Invoke the given callback when an item is removed from the pane. 293 // 294 // * `callback` {Function} to be called with when items are removed. 295 // * `event` {Object} with the following keys: 296 // * `item` The removed pane item. 297 // * `index` {Number} indicating where the item was located. 298 // 299 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 300 onDidRemoveItem(callback) { 301 return this.emitter.on('did-remove-item', callback); 302 } 303 304 // Public: Invoke the given callback before an item is removed from the pane. 305 // 306 // * `callback` {Function} to be called with when items are removed. 307 // * `event` {Object} with the following keys: 308 // * `item` The pane item to be removed. 309 // * `index` {Number} indicating where the item is located. 310 onWillRemoveItem(callback) { 311 return this.emitter.on('will-remove-item', callback); 312 } 313 314 // Public: Invoke the given callback when an item is moved within the pane. 315 // 316 // * `callback` {Function} to be called with when items are moved. 317 // * `event` {Object} with the following keys: 318 // * `item` The removed pane item. 319 // * `oldIndex` {Number} indicating where the item was located. 320 // * `newIndex` {Number} indicating where the item is now located. 321 // 322 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 323 onDidMoveItem(callback) { 324 return this.emitter.on('did-move-item', callback); 325 } 326 327 // Public: Invoke the given callback with all current and future items. 328 // 329 // * `callback` {Function} to be called with current and future items. 330 // * `item` An item that is present in {::getItems} at the time of 331 // subscription or that is added at some later time. 332 // 333 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 334 observeItems(callback) { 335 for (let item of this.getItems()) { 336 callback(item); 337 } 338 return this.onDidAddItem(({ item }) => callback(item)); 339 } 340 341 // Public: Invoke the given callback when the value of {::getActiveItem} 342 // changes. 343 // 344 // * `callback` {Function} to be called with when the active item changes. 345 // * `activeItem` The current active item. 346 // 347 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 348 onDidChangeActiveItem(callback) { 349 return this.emitter.on('did-change-active-item', callback); 350 } 351 352 // Public: Invoke the given callback when {::activateNextRecentlyUsedItem} 353 // has been called, either initiating or continuing a forward MRU traversal of 354 // pane items. 355 // 356 // * `callback` {Function} to be called with when the active item changes. 357 // * `nextRecentlyUsedItem` The next MRU item, now being set active 358 // 359 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 360 onChooseNextMRUItem(callback) { 361 return this.emitter.on('choose-next-mru-item', callback); 362 } 363 364 // Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem} 365 // has been called, either initiating or continuing a reverse MRU traversal of 366 // pane items. 367 // 368 // * `callback` {Function} to be called with when the active item changes. 369 // * `previousRecentlyUsedItem` The previous MRU item, now being set active 370 // 371 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 372 onChooseLastMRUItem(callback) { 373 return this.emitter.on('choose-last-mru-item', callback); 374 } 375 376 // Public: Invoke the given callback when {::moveActiveItemToTopOfStack} 377 // has been called, terminating an MRU traversal of pane items and moving the 378 // current active item to the top of the stack. Typically bound to a modifier 379 // (e.g. CTRL) key up event. 380 // 381 // * `callback` {Function} to be called with when the MRU traversal is done. 382 // 383 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 384 onDoneChoosingMRUItem(callback) { 385 return this.emitter.on('done-choosing-mru-item', callback); 386 } 387 388 // Public: Invoke the given callback with the current and future values of 389 // {::getActiveItem}. 390 // 391 // * `callback` {Function} to be called with the current and future active 392 // items. 393 // * `activeItem` The current active item. 394 // 395 // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. 396 observeActiveItem(callback) { 397 callback(this.getActiveItem()); 398 return this.onDidChangeActiveItem(callback); 399 } 400 401 // Public: Invoke the given callback before items are destroyed. 402 // 403 // * `callback` {Function} to be called before items are destroyed. 404 // * `event` {Object} with the following keys: 405 // * `item` The item that will be destroyed. 406 // * `index` The location of the item. 407 // 408 // Returns a {Disposable} on which `.dispose()` can be called to 409 // unsubscribe. 410 onWillDestroyItem(callback) { 411 return this.emitter.on('will-destroy-item', callback); 412 } 413 414 // Called by the view layer to indicate that the pane has gained focus. 415 focus() { 416 return this.activate(); 417 } 418 419 // Called by the view layer to indicate that the pane has lost focus. 420 blur() { 421 this.focused = false; 422 return true; // if this is called from an event handler, don't cancel it 423 } 424 425 isFocused() { 426 return this.focused; 427 } 428 429 getPanes() { 430 return [this]; 431 } 432 433 unsubscribeFromItem(item) { 434 const subscription = this.subscriptionsPerItem.get(item); 435 if (subscription) { 436 subscription.dispose(); 437 this.subscriptionsPerItem.delete(item); 438 } 439 } 440 441 /* 442 Section: Items 443 */ 444 445 // Public: Get the items in this pane. 446 // 447 // Returns an {Array} of items. 448 getItems() { 449 return this.items.slice(); 450 } 451 452 // Public: Get the active pane item in this pane. 453 // 454 // Returns a pane item. 455 getActiveItem() { 456 return this.activeItem; 457 } 458 459 setActiveItem(activeItem, options) { 460 const modifyStack = options && options.modifyStack; 461 if (activeItem !== this.activeItem) { 462 if (modifyStack !== false) this.addItemToStack(activeItem); 463 this.activeItem = activeItem; 464 this.emitter.emit('did-change-active-item', this.activeItem); 465 if (this.container) 466 this.container.didChangeActiveItemOnPane(this, this.activeItem); 467 } 468 return this.activeItem; 469 } 470 471 // Build the itemStack after deserializing 472 addItemsToStack(itemStackIndices) { 473 if (this.items.length > 0) { 474 if ( 475 itemStackIndices.length !== this.items.length || 476 itemStackIndices.includes(-1) 477 ) { 478 itemStackIndices = this.items.map((item, i) => i); 479 } 480 481 for (let itemIndex of itemStackIndices) { 482 this.addItemToStack(this.items[itemIndex]); 483 } 484 } 485 } 486 487 // Add item (or move item) to the end of the itemStack 488 addItemToStack(newItem) { 489 if (newItem == null) { 490 return; 491 } 492 const index = this.itemStack.indexOf(newItem); 493 if (index !== -1) this.itemStack.splice(index, 1); 494 return this.itemStack.push(newItem); 495 } 496 497 // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise. 498 getActiveEditor() { 499 if (this.activeItem instanceof TextEditor) return this.activeItem; 500 } 501 502 // Public: Return the item at the given index. 503 // 504 // * `index` {Number} 505 // 506 // Returns an item or `null` if no item exists at the given index. 507 itemAtIndex(index) { 508 return this.items[index]; 509 } 510 511 // Makes the next item in the itemStack active. 512 activateNextRecentlyUsedItem() { 513 if (this.items.length > 1) { 514 if (this.itemStackIndex == null) 515 this.itemStackIndex = this.itemStack.length - 1; 516 if (this.itemStackIndex === 0) 517 this.itemStackIndex = this.itemStack.length; 518 this.itemStackIndex--; 519 const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex]; 520 this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem); 521 this.setActiveItem(nextRecentlyUsedItem, { modifyStack: false }); 522 } 523 } 524 525 // Makes the previous item in the itemStack active. 526 activatePreviousRecentlyUsedItem() { 527 if (this.items.length > 1) { 528 if ( 529 this.itemStackIndex + 1 === this.itemStack.length || 530 this.itemStackIndex == null 531 ) { 532 this.itemStackIndex = -1; 533 } 534 this.itemStackIndex++; 535 const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex]; 536 this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem); 537 this.setActiveItem(previousRecentlyUsedItem, { modifyStack: false }); 538 } 539 } 540 541 // Moves the active item to the end of the itemStack once the ctrl key is lifted 542 moveActiveItemToTopOfStack() { 543 delete this.itemStackIndex; 544 this.addItemToStack(this.activeItem); 545 this.emitter.emit('done-choosing-mru-item'); 546 } 547 548 // Public: Makes the next item active. 549 activateNextItem() { 550 const index = this.getActiveItemIndex(); 551 if (index < this.items.length - 1) { 552 this.activateItemAtIndex(index + 1); 553 } else { 554 this.activateItemAtIndex(0); 555 } 556 } 557 558 // Public: Makes the previous item active. 559 activatePreviousItem() { 560 const index = this.getActiveItemIndex(); 561 if (index > 0) { 562 this.activateItemAtIndex(index - 1); 563 } else { 564 this.activateItemAtIndex(this.items.length - 1); 565 } 566 } 567 568 activateLastItem() { 569 this.activateItemAtIndex(this.items.length - 1); 570 } 571 572 // Public: Move the active tab to the right. 573 moveItemRight() { 574 const index = this.getActiveItemIndex(); 575 const rightItemIndex = index + 1; 576 if (rightItemIndex <= this.items.length - 1) 577 this.moveItem(this.getActiveItem(), rightItemIndex); 578 } 579 580 // Public: Move the active tab to the left 581 moveItemLeft() { 582 const index = this.getActiveItemIndex(); 583 const leftItemIndex = index - 1; 584 if (leftItemIndex >= 0) 585 return this.moveItem(this.getActiveItem(), leftItemIndex); 586 } 587 588 // Public: Get the index of the active item. 589 // 590 // Returns a {Number}. 591 getActiveItemIndex() { 592 return this.items.indexOf(this.activeItem); 593 } 594 595 // Public: Activate the item at the given index. 596 // 597 // * `index` {Number} 598 activateItemAtIndex(index) { 599 const item = this.itemAtIndex(index) || this.getActiveItem(); 600 return this.setActiveItem(item); 601 } 602 603 // Public: Make the given item *active*, causing it to be displayed by 604 // the pane's view. 605 // 606 // * `item` The item to activate 607 // * `options` (optional) {Object} 608 // * `pending` (optional) {Boolean} indicating that the item should be added 609 // in a pending state if it does not yet exist in the pane. Existing pending 610 // items in a pane are replaced with new pending items when they are opened. 611 activateItem(item, options = {}) { 612 if (item) { 613 const index = 614 this.getPendingItem() === this.activeItem 615 ? this.getActiveItemIndex() 616 : this.getActiveItemIndex() + 1; 617 this.addItem(item, Object.assign({}, options, { index })); 618 this.setActiveItem(item); 619 } 620 } 621 622 // Public: Add the given item to the pane. 623 // 624 // * `item` The item to add. It can be a model with an associated view or a 625 // view. 626 // * `options` (optional) {Object} 627 // * `index` (optional) {Number} indicating the index at which to add the item. 628 // If omitted, the item is added after the current active item. 629 // * `pending` (optional) {Boolean} indicating that the item should be 630 // added in a pending state. Existing pending items in a pane are replaced with 631 // new pending items when they are opened. 632 // 633 // Returns the added item. 634 addItem(item, options = {}) { 635 // Backward compat with old API: 636 // addItem(item, index=@getActiveItemIndex() + 1) 637 if (typeof options === 'number') { 638 Grim.deprecate( 639 `Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})` 640 ); 641 options = { index: options }; 642 } 643 644 const index = 645 options.index != null ? options.index : this.getActiveItemIndex() + 1; 646 const moved = options.moved != null ? options.moved : false; 647 const pending = options.pending != null ? options.pending : false; 648 649 if (!item || typeof item !== 'object') { 650 throw new Error( 651 `Pane items must be objects. Attempted to add item ${item}.` 652 ); 653 } 654 655 if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { 656 throw new Error( 657 `Adding a pane item with URI '${typeof item.getURI === 'function' && 658 item.getURI()}' that has already been destroyed` 659 ); 660 } 661 662 if (this.items.includes(item)) return; 663 664 const itemSubscriptions = new CompositeDisposable(); 665 this.subscriptionsPerItem.set(item, itemSubscriptions); 666 if (typeof item.onDidDestroy === 'function') { 667 itemSubscriptions.add( 668 item.onDidDestroy(() => this.removeItem(item, false)) 669 ); 670 } 671 if (typeof item.onDidTerminatePendingState === 'function') { 672 itemSubscriptions.add( 673 item.onDidTerminatePendingState(() => { 674 if (this.getPendingItem() === item) this.clearPendingItem(); 675 }) 676 ); 677 } 678 679 this.items.splice(index, 0, item); 680 const lastPendingItem = this.getPendingItem(); 681 const replacingPendingItem = lastPendingItem != null && !moved; 682 if (replacingPendingItem) this.pendingItem = null; 683 if (pending) this.setPendingItem(item); 684 685 this.emitter.emit('did-add-item', { item, index, moved }); 686 if (!moved) { 687 if (this.container) this.container.didAddPaneItem(item, this, index); 688 } 689 690 if (replacingPendingItem) this.destroyItem(lastPendingItem); 691 if (!this.getActiveItem()) this.setActiveItem(item); 692 return item; 693 } 694 695 setPendingItem(item) { 696 if (this.pendingItem !== item) { 697 const mostRecentPendingItem = this.pendingItem; 698 this.pendingItem = item; 699 if (mostRecentPendingItem) { 700 this.emitter.emit( 701 'item-did-terminate-pending-state', 702 mostRecentPendingItem 703 ); 704 } 705 } 706 } 707 708 getPendingItem() { 709 return this.pendingItem || null; 710 } 711 712 clearPendingItem() { 713 this.setPendingItem(null); 714 } 715 716 onItemDidTerminatePendingState(callback) { 717 return this.emitter.on('item-did-terminate-pending-state', callback); 718 } 719 720 // Public: Add the given items to the pane. 721 // 722 // * `items` An {Array} of items to add. Items can be views or models with 723 // associated views. Any objects that are already present in the pane's 724 // current items will not be added again. 725 // * `index` (optional) {Number} index at which to add the items. If omitted, 726 // the item is # added after the current active item. 727 // 728 // Returns an {Array} of added items. 729 addItems(items, index = this.getActiveItemIndex() + 1) { 730 items = items.filter(item => !this.items.includes(item)); 731 for (let i = 0; i < items.length; i++) { 732 const item = items[i]; 733 this.addItem(item, { index: index + i }); 734 } 735 return items; 736 } 737 738 removeItem(item, moved) { 739 const index = this.items.indexOf(item); 740 if (index === -1) return; 741 if (this.getPendingItem() === item) this.pendingItem = null; 742 this.removeItemFromStack(item); 743 this.emitter.emit('will-remove-item', { 744 item, 745 index, 746 destroyed: !moved, 747 moved 748 }); 749 this.unsubscribeFromItem(item); 750 751 if (item === this.activeItem) { 752 if (this.items.length === 1) { 753 this.setActiveItem(undefined); 754 } else if (index === 0) { 755 this.activateNextItem(); 756 } else { 757 this.activatePreviousItem(); 758 } 759 } 760 this.items.splice(index, 1); 761 this.emitter.emit('did-remove-item', { 762 item, 763 index, 764 destroyed: !moved, 765 moved 766 }); 767 if (!moved && this.container) 768 this.container.didDestroyPaneItem({ item, index, pane: this }); 769 if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) 770 this.destroy(); 771 } 772 773 // Remove the given item from the itemStack. 774 // 775 // * `item` The item to remove. 776 // * `index` {Number} indicating the index to which to remove the item from the itemStack. 777 removeItemFromStack(item) { 778 const index = this.itemStack.indexOf(item); 779 if (index !== -1) this.itemStack.splice(index, 1); 780 } 781 782 // Public: Move the given item to the given index. 783 // 784 // * `item` The item to move. 785 // * `index` {Number} indicating the index to which to move the item. 786 moveItem(item, newIndex) { 787 const oldIndex = this.items.indexOf(item); 788 this.items.splice(oldIndex, 1); 789 this.items.splice(newIndex, 0, item); 790 this.emitter.emit('did-move-item', { item, oldIndex, newIndex }); 791 } 792 793 // Public: Move the given item to the given index on another pane. 794 // 795 // * `item` The item to move. 796 // * `pane` {Pane} to which to move the item. 797 // * `index` {Number} indicating the index to which to move the item in the 798 // given pane. 799 moveItemToPane(item, pane, index) { 800 this.removeItem(item, true); 801 return pane.addItem(item, { index, moved: true }); 802 } 803 804 // Public: Destroy the active item and activate the next item. 805 // 806 // Returns a {Promise} that resolves when the item is destroyed. 807 destroyActiveItem() { 808 return this.destroyItem(this.activeItem); 809 } 810 811 // Public: Destroy the given item. 812 // 813 // If the item is active, the next item will be activated. If the item is the 814 // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config 815 // setting is `true`. 816 // 817 // * `item` Item to destroy 818 // * `force` (optional) {Boolean} Destroy the item without prompting to save 819 // it, even if the item's `isPermanentDockItem` method returns true. 820 // 821 // Returns a {Promise} that resolves with a {Boolean} indicating whether or not 822 // the item was destroyed. 823 async destroyItem(item, force) { 824 const index = this.items.indexOf(item); 825 if (index === -1) return false; 826 827 if ( 828 !force && 829 typeof item.isPermanentDockItem === 'function' && 830 item.isPermanentDockItem() && 831 (!this.container || this.container.getLocation() !== 'center') 832 ) { 833 return false; 834 } 835 836 // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior 837 // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. 838 if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { 839 await this.emitter.emitAsync('will-destroy-item', { item, index }); 840 } 841 if ( 842 this.container && 843 this.container.emitter.listenerCountForEventName( 844 'will-destroy-pane-item' 845 ) > 0 846 ) { 847 await this.container.willDestroyPaneItem({ item, index, pane: this }); 848 } 849 850 if ( 851 !force && 852 typeof item.shouldPromptToSave === 'function' && 853 item.shouldPromptToSave() 854 ) { 855 if (!(await this.promptToSaveItem(item))) return false; 856 } 857 this.removeItem(item, false); 858 if (typeof item.destroy === 'function') item.destroy(); 859 return true; 860 } 861 862 // Public: Destroy all items. 863 destroyItems() { 864 return Promise.all(this.getItems().map(item => this.destroyItem(item))); 865 } 866 867 // Public: Destroy all items except for the active item. 868 destroyInactiveItems() { 869 return Promise.all( 870 this.getItems() 871 .filter(item => item !== this.activeItem) 872 .map(item => this.destroyItem(item)) 873 ); 874 } 875 876 promptToSaveItem(item, options = {}) { 877 return new Promise((resolve, reject) => { 878 if ( 879 typeof item.shouldPromptToSave !== 'function' || 880 !item.shouldPromptToSave(options) 881 ) { 882 return resolve(true); 883 } 884 885 let uri; 886 if (typeof item.getURI === 'function') { 887 uri = item.getURI(); 888 } else if (typeof item.getUri === 'function') { 889 uri = item.getUri(); 890 } else { 891 return resolve(true); 892 } 893 894 const title = 895 (typeof item.getTitle === 'function' && item.getTitle()) || uri; 896 897 const saveDialog = (saveButtonText, saveFn, message) => { 898 this.applicationDelegate.confirm( 899 { 900 message, 901 detail: 902 'Your changes will be lost if you close this item without saving.', 903 buttons: [saveButtonText, 'Cancel', "&Don't Save"] 904 }, 905 response => { 906 switch (response) { 907 case 0: 908 return saveFn(item, error => { 909 if (error instanceof SaveCancelledError) { 910 resolve(false); 911 } else if (error) { 912 saveDialog( 913 'Save as', 914 this.saveItemAs, 915 `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode( 916 error.code 917 )}` 918 ); 919 } else { 920 resolve(true); 921 } 922 }); 923 case 1: 924 return resolve(false); 925 case 2: 926 return resolve(true); 927 } 928 } 929 ); 930 }; 931 932 saveDialog( 933 'Save', 934 this.saveItem, 935 `'${title}' has changes, do you want to save them?` 936 ); 937 }); 938 } 939 940 // Public: Save the active item. 941 saveActiveItem(nextAction) { 942 return this.saveItem(this.getActiveItem(), nextAction); 943 } 944 945 // Public: Prompt the user for a location and save the active item with the 946 // path they select. 947 // 948 // * `nextAction` (optional) {Function} which will be called after the item is 949 // successfully saved. 950 // 951 // Returns a {Promise} that resolves when the save is complete 952 saveActiveItemAs(nextAction) { 953 return this.saveItemAs(this.getActiveItem(), nextAction); 954 } 955 956 // Public: Save the given item. 957 // 958 // * `item` The item to save. 959 // * `nextAction` (optional) {Function} which will be called with no argument 960 // after the item is successfully saved, or with the error if it failed. 961 // The return value will be that of `nextAction` or `undefined` if it was not 962 // provided 963 // 964 // Returns a {Promise} that resolves when the save is complete 965 saveItem(item, nextAction) { 966 if (!item) return Promise.resolve(); 967 968 let itemURI; 969 if (typeof item.getURI === 'function') { 970 itemURI = item.getURI(); 971 } else if (typeof item.getUri === 'function') { 972 itemURI = item.getUri(); 973 } 974 975 if (itemURI != null) { 976 if (typeof item.save === 'function') { 977 return promisify(() => item.save()) 978 .then(() => { 979 if (nextAction) nextAction(); 980 }) 981 .catch(error => { 982 if (nextAction) { 983 nextAction(error); 984 } else { 985 this.handleSaveError(error, item); 986 } 987 }); 988 } else if (nextAction) { 989 nextAction(); 990 return Promise.resolve(); 991 } 992 } else { 993 return this.saveItemAs(item, nextAction); 994 } 995 } 996 997 // Public: Prompt the user for a location and save the active item with the 998 // path they select. 999 // 1000 // * `item` The item to save. 1001 // * `nextAction` (optional) {Function} which will be called with no argument 1002 // after the item is successfully saved, or with the error if it failed. 1003 // The return value will be that of `nextAction` or `undefined` if it was not 1004 // provided 1005 async saveItemAs(item, nextAction) { 1006 if (!item) return; 1007 if (typeof item.saveAs !== 'function') return; 1008 1009 const saveOptions = 1010 typeof item.getSaveDialogOptions === 'function' 1011 ? item.getSaveDialogOptions() 1012 : {}; 1013 1014 const itemPath = item.getPath(); 1015 if (itemPath && !saveOptions.defaultPath) 1016 saveOptions.defaultPath = itemPath; 1017 1018 let resolveSaveDialogPromise = null; 1019 const saveDialogPromise = new Promise(resolve => { 1020 resolveSaveDialogPromise = resolve; 1021 }); 1022 this.applicationDelegate.showSaveDialog(saveOptions, newItemPath => { 1023 if (newItemPath) { 1024 promisify(() => item.saveAs(newItemPath)) 1025 .then(() => { 1026 if (nextAction) { 1027 resolveSaveDialogPromise(nextAction()); 1028 } else { 1029 resolveSaveDialogPromise(); 1030 } 1031 }) 1032 .catch(error => { 1033 if (nextAction) { 1034 resolveSaveDialogPromise(nextAction(error)); 1035 } else { 1036 this.handleSaveError(error, item); 1037 resolveSaveDialogPromise(); 1038 } 1039 }); 1040 } else if (nextAction) { 1041 resolveSaveDialogPromise( 1042 nextAction(new SaveCancelledError('Save Cancelled')) 1043 ); 1044 } else { 1045 resolveSaveDialogPromise(); 1046 } 1047 }); 1048 1049 return saveDialogPromise; 1050 } 1051 1052 // Public: Save all items. 1053 saveItems() { 1054 for (let item of this.getItems()) { 1055 if (typeof item.isModified === 'function' && item.isModified()) { 1056 this.saveItem(item); 1057 } 1058 } 1059 } 1060 1061 // Public: Return the first item that matches the given URI or undefined if 1062 // none exists. 1063 // 1064 // * `uri` {String} containing a URI. 1065 itemForURI(uri) { 1066 return this.items.find(item => { 1067 if (typeof item.getURI === 'function') { 1068 return item.getURI() === uri; 1069 } else if (typeof item.getUri === 'function') { 1070 return item.getUri() === uri; 1071 } 1072 }); 1073 } 1074 1075 // Public: Activate the first item that matches the given URI. 1076 // 1077 // * `uri` {String} containing a URI. 1078 // 1079 // Returns a {Boolean} indicating whether an item matching the URI was found. 1080 activateItemForURI(uri) { 1081 const item = this.itemForURI(uri); 1082 if (item) { 1083 this.activateItem(item); 1084 return true; 1085 } else { 1086 return false; 1087 } 1088 } 1089 1090 copyActiveItem() { 1091 if (this.activeItem && typeof this.activeItem.copy === 'function') { 1092 return this.activeItem.copy(); 1093 } 1094 } 1095 1096 /* 1097 Section: Lifecycle 1098 */ 1099 1100 // Public: Determine whether the pane is active. 1101 // 1102 // Returns a {Boolean}. 1103 isActive() { 1104 return this.container && this.container.getActivePane() === this; 1105 } 1106 1107 // Public: Makes this pane the *active* pane, causing it to gain focus. 1108 activate() { 1109 if (this.isDestroyed()) throw new Error('Pane has been destroyed'); 1110 this.focused = true; 1111 1112 if (this.container) this.container.didActivatePane(this); 1113 this.emitter.emit('did-activate'); 1114 } 1115 1116 // Public: Close the pane and destroy all its items. 1117 // 1118 // If this is the last pane, all the items will be destroyed but the pane 1119 // itself will not be destroyed. 1120 destroy() { 1121 if ( 1122 this.container && 1123 this.container.isAlive() && 1124 this.container.getPanes().length === 1 1125 ) { 1126 return this.destroyItems(); 1127 } 1128 1129 this.emitter.emit('will-destroy'); 1130 this.alive = false; 1131 if (this.container) { 1132 this.container.willDestroyPane({ pane: this }); 1133 if (this.isActive()) this.container.activateNextPane(); 1134 } 1135 this.emitter.emit('did-destroy'); 1136 this.emitter.dispose(); 1137 for (let item of this.items.slice()) { 1138 if (typeof item.destroy === 'function') item.destroy(); 1139 } 1140 if (this.container) this.container.didDestroyPane({ pane: this }); 1141 } 1142 1143 isAlive() { 1144 return this.alive; 1145 } 1146 1147 // Public: Determine whether this pane has been destroyed. 1148 // 1149 // Returns a {Boolean}. 1150 isDestroyed() { 1151 return !this.isAlive(); 1152 } 1153 1154 /* 1155 Section: Splitting 1156 */ 1157 1158 // Public: Create a new pane to the left of this pane. 1159 // 1160 // * `params` (optional) {Object} with the following keys: 1161 // * `items` (optional) {Array} of items to add to the new pane. 1162 // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane 1163 // 1164 // Returns the new {Pane}. 1165 splitLeft(params) { 1166 return this.split('horizontal', 'before', params); 1167 } 1168 1169 // Public: Create a new pane to the right of this pane. 1170 // 1171 // * `params` (optional) {Object} with the following keys: 1172 // * `items` (optional) {Array} of items to add to the new pane. 1173 // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane 1174 // 1175 // Returns the new {Pane}. 1176 splitRight(params) { 1177 return this.split('horizontal', 'after', params); 1178 } 1179 1180 // Public: Creates a new pane above the receiver. 1181 // 1182 // * `params` (optional) {Object} with the following keys: 1183 // * `items` (optional) {Array} of items to add to the new pane. 1184 // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane 1185 // 1186 // Returns the new {Pane}. 1187 splitUp(params) { 1188 return this.split('vertical', 'before', params); 1189 } 1190 1191 // Public: Creates a new pane below the receiver. 1192 // 1193 // * `params` (optional) {Object} with the following keys: 1194 // * `items` (optional) {Array} of items to add to the new pane. 1195 // * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane 1196 // 1197 // Returns the new {Pane}. 1198 splitDown(params) { 1199 return this.split('vertical', 'after', params); 1200 } 1201 1202 split(orientation, side, params) { 1203 if (params && params.copyActiveItem) { 1204 if (!params.items) params.items = []; 1205 params.items.push(this.copyActiveItem()); 1206 } 1207 1208 if (this.parent.orientation !== orientation) { 1209 this.parent.replaceChild( 1210 this, 1211 new PaneAxis( 1212 { 1213 container: this.container, 1214 orientation, 1215 children: [this], 1216 flexScale: this.flexScale 1217 }, 1218 this.viewRegistry 1219 ) 1220 ); 1221 this.setFlexScale(1); 1222 } 1223 1224 const newPane = new Pane( 1225 Object.assign( 1226 { 1227 applicationDelegate: this.applicationDelegate, 1228 notificationManager: this.notificationManager, 1229 deserializerManager: this.deserializerManager, 1230 config: this.config, 1231 viewRegistry: this.viewRegistry 1232 }, 1233 params 1234 ) 1235 ); 1236 1237 switch (side) { 1238 case 'before': 1239 this.parent.insertChildBefore(this, newPane); 1240 break; 1241 case 'after': 1242 this.parent.insertChildAfter(this, newPane); 1243 break; 1244 } 1245 1246 if (params && params.moveActiveItem && this.activeItem) 1247 this.moveItemToPane(this.activeItem, newPane); 1248 1249 newPane.activate(); 1250 return newPane; 1251 } 1252 1253 // If the parent is a horizontal axis, returns its first child if it is a pane; 1254 // otherwise returns this pane. 1255 findLeftmostSibling() { 1256 if (this.parent.orientation === 'horizontal') { 1257 const [leftmostSibling] = this.parent.children; 1258 if (leftmostSibling instanceof PaneAxis) { 1259 return this; 1260 } else { 1261 return leftmostSibling; 1262 } 1263 } else { 1264 return this; 1265 } 1266 } 1267 1268 findRightmostSibling() { 1269 if (this.parent.orientation === 'horizontal') { 1270 const rightmostSibling = this.parent.children[ 1271 this.parent.children.length - 1 1272 ]; 1273 if (rightmostSibling instanceof PaneAxis) { 1274 return this; 1275 } else { 1276 return rightmostSibling; 1277 } 1278 } else { 1279 return this; 1280 } 1281 } 1282 1283 // If the parent is a horizontal axis, returns its last child if it is a pane; 1284 // otherwise returns a new pane created by splitting this pane rightward. 1285 findOrCreateRightmostSibling() { 1286 const rightmostSibling = this.findRightmostSibling(); 1287 if (rightmostSibling === this) { 1288 return this.splitRight(); 1289 } else { 1290 return rightmostSibling; 1291 } 1292 } 1293 1294 // If the parent is a vertical axis, returns its first child if it is a pane; 1295 // otherwise returns this pane. 1296 findTopmostSibling() { 1297 if (this.parent.orientation === 'vertical') { 1298 const [topmostSibling] = this.parent.children; 1299 if (topmostSibling instanceof PaneAxis) { 1300 return this; 1301 } else { 1302 return topmostSibling; 1303 } 1304 } else { 1305 return this; 1306 } 1307 } 1308 1309 findBottommostSibling() { 1310 if (this.parent.orientation === 'vertical') { 1311 const bottommostSibling = this.parent.children[ 1312 this.parent.children.length - 1 1313 ]; 1314 if (bottommostSibling instanceof PaneAxis) { 1315 return this; 1316 } else { 1317 return bottommostSibling; 1318 } 1319 } else { 1320 return this; 1321 } 1322 } 1323 1324 // If the parent is a vertical axis, returns its last child if it is a pane; 1325 // otherwise returns a new pane created by splitting this pane bottomward. 1326 findOrCreateBottommostSibling() { 1327 const bottommostSibling = this.findBottommostSibling(); 1328 if (bottommostSibling === this) { 1329 return this.splitDown(); 1330 } else { 1331 return bottommostSibling; 1332 } 1333 } 1334 1335 // Private: Close the pane unless the user cancels the action via a dialog. 1336 // 1337 // Returns a {Promise} that resolves once the pane is either closed, or the 1338 // closing has been cancelled. 1339 close() { 1340 return Promise.all( 1341 this.getItems().map(item => this.promptToSaveItem(item)) 1342 ).then(results => { 1343 if (!results.includes(false)) return this.destroy(); 1344 }); 1345 } 1346 1347 handleSaveError(error, item) { 1348 const itemPath = 1349 error.path || (typeof item.getPath === 'function' && item.getPath()); 1350 const addWarningWithPath = (message, options) => { 1351 if (itemPath) message = `${message} '${itemPath}'`; 1352 this.notificationManager.addWarning(message, options); 1353 }; 1354 1355 const customMessage = this.getMessageForErrorCode(error.code); 1356 if (customMessage != null) { 1357 addWarningWithPath(`Unable to save file: ${customMessage}`); 1358 } else if ( 1359 error.code === 'EISDIR' || 1360 (error.message && error.message.endsWith('is a directory')) 1361 ) { 1362 return this.notificationManager.addWarning( 1363 `Unable to save file: ${error.message}` 1364 ); 1365 } else if ( 1366 ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes( 1367 error.code 1368 ) 1369 ) { 1370 addWarningWithPath('Unable to save file', { detail: error.message }); 1371 } else { 1372 const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec( 1373 error.message 1374 ); 1375 if (errorMatch) { 1376 const fileName = errorMatch[1]; 1377 this.notificationManager.addWarning( 1378 `Unable to save file: A directory in the path '${fileName}' could not be written to` 1379 ); 1380 } else { 1381 throw error; 1382 } 1383 } 1384 } 1385 1386 getMessageForErrorCode(errorCode) { 1387 switch (errorCode) { 1388 case 'EACCES': 1389 return 'Permission denied'; 1390 case 'ECONNRESET': 1391 return 'Connection reset'; 1392 case 'EINTR': 1393 return 'Interrupted system call'; 1394 case 'EIO': 1395 return 'I/O error writing file'; 1396 case 'ENOSPC': 1397 return 'No space left on device'; 1398 case 'ENOTSUP': 1399 return 'Operation not supported on socket'; 1400 case 'ENXIO': 1401 return 'No such device or address'; 1402 case 'EROFS': 1403 return 'Read-only file system'; 1404 case 'ESPIPE': 1405 return 'Invalid seek'; 1406 case 'ETIMEDOUT': 1407 return 'Connection timed out'; 1408 } 1409 } 1410 }; 1411 1412 function promisify(callback) { 1413 try { 1414 return Promise.resolve(callback()); 1415 } catch (error) { 1416 return Promise.reject(error); 1417 } 1418 }