1// Copyright (c) 2012 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5'use strict'; 6 7// If directory files changes too often, don't rescan directory more than once 8// per specified interval 9var SIMULTANEOUS_RESCAN_INTERVAL = 500; 10// Used for operations that require almost instant rescan. 11var SHORT_RESCAN_INTERVAL = 100; 12 13/** 14 * Data model of the file manager. 15 * 16 * @param {boolean} singleSelection True if only one file could be selected 17 * at the time. 18 * @param {FileFilter} fileFilter Instance of FileFilter. 19 * @param {FileWatcher} fileWatcher Instance of FileWatcher. 20 * @param {MetadataCache} metadataCache The metadata cache service. 21 * @param {VolumeManagerWrapper} volumeManager The volume manager. 22 * @constructor 23 */ 24function DirectoryModel(singleSelection, fileFilter, fileWatcher, 25 metadataCache, volumeManager) { 26 this.fileListSelection_ = singleSelection ? 27 new cr.ui.ListSingleSelectionModel() : new cr.ui.ListSelectionModel(); 28 29 this.runningScan_ = null; 30 this.pendingScan_ = null; 31 this.rescanTime_ = null; 32 this.scanFailures_ = 0; 33 this.changeDirectorySequence_ = 0; 34 35 this.directoryChangeQueue_ = new AsyncUtil.Queue(); 36 this.rescanAggregator_ = new AsyncUtil.Aggregator( 37 this.rescanSoon.bind(this, true), 500); 38 39 this.fileFilter_ = fileFilter; 40 this.fileFilter_.addEventListener('changed', 41 this.onFilterChanged_.bind(this)); 42 43 this.currentFileListContext_ = new FileListContext( 44 fileFilter, metadataCache); 45 this.currentDirContents_ = 46 DirectoryContents.createForDirectory(this.currentFileListContext_, null); 47 48 this.metadataCache_ = metadataCache; 49 50 this.volumeManager_ = volumeManager; 51 this.volumeManager_.volumeInfoList.addEventListener( 52 'splice', this.onVolumeInfoListUpdated_.bind(this)); 53 54 this.fileWatcher_ = fileWatcher; 55 this.fileWatcher_.addEventListener( 56 'watcher-directory-changed', 57 this.onWatcherDirectoryChanged_.bind(this)); 58} 59 60/** 61 * DirectoryModel extends cr.EventTarget. 62 */ 63DirectoryModel.prototype.__proto__ = cr.EventTarget.prototype; 64 65/** 66 * Disposes the directory model by removing file watchers. 67 */ 68DirectoryModel.prototype.dispose = function() { 69 this.fileWatcher_.dispose(); 70}; 71 72/** 73 * @return {cr.ui.ArrayDataModel} Files in the current directory. 74 */ 75DirectoryModel.prototype.getFileList = function() { 76 return this.currentFileListContext_.fileList; 77}; 78 79/** 80 * @return {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} Selection 81 * in the fileList. 82 */ 83DirectoryModel.prototype.getFileListSelection = function() { 84 return this.fileListSelection_; 85}; 86 87/** 88 * @return {?VolumeManagerCommon.RootType} Root type of current root, or null if 89 * not found. 90 */ 91DirectoryModel.prototype.getCurrentRootType = function() { 92 var entry = this.currentDirContents_.getDirectoryEntry(); 93 if (!entry) 94 return null; 95 96 var locationInfo = this.volumeManager_.getLocationInfo(entry); 97 if (!locationInfo) 98 return null; 99 100 return locationInfo.rootType; 101}; 102 103/** 104 * @return {boolean} True if the current directory is read only. If there is 105 * no entry set, then returns true. 106 */ 107DirectoryModel.prototype.isReadOnly = function() { 108 var currentDirEntry = this.getCurrentDirEntry(); 109 if (currentDirEntry) { 110 var locationInfo = this.volumeManager_.getLocationInfo(currentDirEntry); 111 if (locationInfo) 112 return locationInfo.isReadOnly; 113 } 114 return true; 115}; 116 117/** 118 * @return {boolean} True if the a scan is active. 119 */ 120DirectoryModel.prototype.isScanning = function() { 121 return this.currentDirContents_.isScanning(); 122}; 123 124/** 125 * @return {boolean} True if search is in progress. 126 */ 127DirectoryModel.prototype.isSearching = function() { 128 return this.currentDirContents_.isSearch(); 129}; 130 131/** 132 * Updates the selection by using the updateFunc and publish the change event. 133 * If updateFunc returns true, it force to dispatch the change event even if the 134 * selection index is not changed. 135 * 136 * @param {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} selection 137 * Selection to be updated. 138 * @param {function(): boolean} updateFunc Function updating the selection. 139 * @private 140 */ 141DirectoryModel.prototype.updateSelectionAndPublishEvent_ = 142 function(selection, updateFunc) { 143 // Begin change. 144 selection.beginChange(); 145 146 // If dispatchNeeded is true, we should ensure the change event is 147 // dispatched. 148 var dispatchNeeded = updateFunc(); 149 150 // Check if the change event is dispatched in the endChange function 151 // or not. 152 var eventDispatched = function() { dispatchNeeded = false; }; 153 selection.addEventListener('change', eventDispatched); 154 selection.endChange(); 155 selection.removeEventListener('change', eventDispatched); 156 157 // If the change event have been already dispatched, dispatchNeeded is false. 158 if (dispatchNeeded) { 159 var event = new Event('change'); 160 // The selection status (selected or not) is not changed because 161 // this event is caused by the change of selected item. 162 event.changes = []; 163 selection.dispatchEvent(event); 164 } 165}; 166 167/** 168 * Invoked when a change in the directory is detected by the watcher. 169 * @param {Event} event Event object. 170 * @private 171 */ 172DirectoryModel.prototype.onWatcherDirectoryChanged_ = function(event) { 173 var directoryEntry = this.getCurrentDirEntry(); 174 175 if (event.changedFiles) { 176 var addedOrUpdatedFileUrls = []; 177 var deletedFileUrls = []; 178 event.changedFiles.forEach(function(change) { 179 if (change.changes.length === 1 && change.changes[0] === 'delete') 180 deletedFileUrls.push(change.url); 181 else 182 addedOrUpdatedFileUrls.push(change.url); 183 }); 184 185 util.URLsToEntries(addedOrUpdatedFileUrls).then(function(result) { 186 deletedFileUrls = deletedFileUrls.concat(result.failureUrls); 187 188 // Passing the resolved entries and failed URLs as the removed files. 189 // The URLs are removed files and they chan't be resolved. 190 this.partialUpdate_(result.entries, deletedFileUrls); 191 }.bind(this)).catch(function(error) { 192 console.error('Error in proceeding the changed event.', error, 193 'Fallback to force-refresh'); 194 this.rescanAggregator_.run(); 195 }.bind(this)); 196 } else { 197 // Invokes force refresh if the detailed information isn't provided. 198 // This can occur very frequently (e.g. when copying files into Downlaods) 199 // and rescan is heavy operation, so we keep some interval for each rescan. 200 this.rescanAggregator_.run(); 201 } 202}; 203 204/** 205 * Invoked when filters are changed. 206 * @private 207 */ 208DirectoryModel.prototype.onFilterChanged_ = function() { 209 this.rescanSoon(false); 210}; 211 212/** 213 * Returns the filter. 214 * @return {FileFilter} The file filter. 215 */ 216DirectoryModel.prototype.getFileFilter = function() { 217 return this.fileFilter_; 218}; 219 220/** 221 * @return {DirectoryEntry} Current directory. 222 */ 223DirectoryModel.prototype.getCurrentDirEntry = function() { 224 return this.currentDirContents_.getDirectoryEntry(); 225}; 226 227/** 228 * @return {Array.<Entry>} Array of selected entries. 229 * @private 230 */ 231DirectoryModel.prototype.getSelectedEntries_ = function() { 232 var indexes = this.fileListSelection_.selectedIndexes; 233 var fileList = this.getFileList(); 234 if (fileList) { 235 return indexes.map(function(i) { 236 return fileList.item(i); 237 }); 238 } 239 return []; 240}; 241 242/** 243 * @param {Array.<Entry>} value List of selected entries. 244 * @private 245 */ 246DirectoryModel.prototype.setSelectedEntries_ = function(value) { 247 var indexes = []; 248 var fileList = this.getFileList(); 249 var urls = util.entriesToURLs(value); 250 251 for (var i = 0; i < fileList.length; i++) { 252 if (urls.indexOf(fileList.item(i).toURL()) !== -1) 253 indexes.push(i); 254 } 255 this.fileListSelection_.selectedIndexes = indexes; 256}; 257 258/** 259 * @return {Entry} Lead entry. 260 * @private 261 */ 262DirectoryModel.prototype.getLeadEntry_ = function() { 263 var index = this.fileListSelection_.leadIndex; 264 return index >= 0 && this.getFileList().item(index); 265}; 266 267/** 268 * @param {Entry} value The new lead entry. 269 * @private 270 */ 271DirectoryModel.prototype.setLeadEntry_ = function(value) { 272 var fileList = this.getFileList(); 273 for (var i = 0; i < fileList.length; i++) { 274 if (util.isSameEntry(fileList.item(i), value)) { 275 this.fileListSelection_.leadIndex = i; 276 return; 277 } 278 } 279}; 280 281/** 282 * Schedule rescan with short delay. 283 * @param {boolean} refresh True to refresh metadata, or false to use cached 284 * one. 285 */ 286DirectoryModel.prototype.rescanSoon = function(refresh) { 287 this.scheduleRescan(SHORT_RESCAN_INTERVAL, refresh); 288}; 289 290/** 291 * Schedule rescan with delay. Designed to handle directory change 292 * notification. 293 * @param {boolean} refresh True to refresh metadata, or false to use cached 294 * one. 295 */ 296DirectoryModel.prototype.rescanLater = function(refresh) { 297 this.scheduleRescan(SIMULTANEOUS_RESCAN_INTERVAL, refresh); 298}; 299 300/** 301 * Schedule rescan with delay. If another rescan has been scheduled does 302 * nothing. File operation may cause a few notifications what should cause 303 * a single refresh. 304 * @param {number} delay Delay in ms after which the rescan will be performed. 305 * @param {boolean} refresh True to refresh metadata, or false to use cached 306 * one. 307 */ 308DirectoryModel.prototype.scheduleRescan = function(delay, refresh) { 309 if (this.rescanTime_) { 310 if (this.rescanTime_ <= Date.now() + delay) 311 return; 312 clearTimeout(this.rescanTimeoutId_); 313 } 314 315 var sequence = this.changeDirectorySequence_; 316 317 this.rescanTime_ = Date.now() + delay; 318 this.rescanTimeoutId_ = setTimeout(function() { 319 this.rescanTimeoutId_ = null; 320 if (sequence === this.changeDirectorySequence_) 321 this.rescan(refresh); 322 }.bind(this), delay); 323}; 324 325/** 326 * Cancel a rescan on timeout if it is scheduled. 327 * @private 328 */ 329DirectoryModel.prototype.clearRescanTimeout_ = function() { 330 this.rescanTime_ = null; 331 if (this.rescanTimeoutId_) { 332 clearTimeout(this.rescanTimeoutId_); 333 this.rescanTimeoutId_ = null; 334 } 335}; 336 337/** 338 * Rescan current directory. May be called indirectly through rescanLater or 339 * directly in order to reflect user action. Will first cache all the directory 340 * contents in an array, then seamlessly substitute the fileList contents, 341 * preserving the select element etc. 342 * 343 * This should be to scan the contents of current directory (or search). 344 * 345 * @param {boolean} refresh True to refresh metadata, or false to use cached 346 * one. 347 */ 348DirectoryModel.prototype.rescan = function(refresh) { 349 this.clearRescanTimeout_(); 350 if (this.runningScan_) { 351 this.pendingRescan_ = true; 352 return; 353 } 354 355 var dirContents = this.currentDirContents_.clone(); 356 dirContents.setFileList([]); 357 358 var sequence = this.changeDirectorySequence_; 359 360 var successCallback = (function() { 361 if (sequence === this.changeDirectorySequence_) { 362 this.replaceDirectoryContents_(dirContents); 363 cr.dispatchSimpleEvent(this, 'rescan-completed'); 364 } 365 }).bind(this); 366 367 this.scan_(dirContents, 368 refresh, 369 successCallback, function() {}, function() {}, function() {}); 370}; 371 372/** 373 * Run scan on the current DirectoryContents. The active fileList is cleared and 374 * the entries are added directly. 375 * 376 * This should be used when changing directory or initiating a new search. 377 * 378 * @param {DirectoryContentes} newDirContents New DirectoryContents instance to 379 * replace currentDirContents_. 380 * @param {function(boolean)} callback Callback with result. True if the scan 381 * is completed successfully, false if the scan is failed. 382 * @private 383 */ 384DirectoryModel.prototype.clearAndScan_ = function(newDirContents, 385 callback) { 386 if (this.currentDirContents_.isScanning()) 387 this.currentDirContents_.cancelScan(); 388 this.currentDirContents_.dispose(); 389 this.currentDirContents_ = newDirContents; 390 this.clearRescanTimeout_(); 391 392 if (this.pendingScan_) 393 this.pendingScan_ = false; 394 395 if (this.runningScan_) { 396 if (this.runningScan_.isScanning()) 397 this.runningScan_.cancelScan(); 398 this.runningScan_ = null; 399 } 400 401 var sequence = this.changeDirectorySequence_; 402 var cancelled = false; 403 404 var onDone = function() { 405 if (cancelled) 406 return; 407 408 cr.dispatchSimpleEvent(this, 'scan-completed'); 409 callback(true); 410 }.bind(this); 411 412 var onFailed = function() { 413 if (cancelled) 414 return; 415 416 cr.dispatchSimpleEvent(this, 'scan-failed'); 417 callback(false); 418 }.bind(this); 419 420 var onUpdated = function() { 421 if (cancelled) 422 return; 423 424 if (this.changeDirectorySequence_ !== sequence) { 425 cancelled = true; 426 cr.dispatchSimpleEvent(this, 'scan-cancelled'); 427 callback(false); 428 return; 429 } 430 431 cr.dispatchSimpleEvent(this, 'scan-updated'); 432 }.bind(this); 433 434 var onCancelled = function() { 435 if (cancelled) 436 return; 437 438 cancelled = true; 439 cr.dispatchSimpleEvent(this, 'scan-cancelled'); 440 callback(false); 441 }.bind(this); 442 443 // Clear the table, and start scanning. 444 cr.dispatchSimpleEvent(this, 'scan-started'); 445 var fileList = this.getFileList(); 446 fileList.splice(0, fileList.length); 447 this.scan_(this.currentDirContents_, false, 448 onDone, onFailed, onUpdated, onCancelled); 449}; 450 451/** 452 * Adds/removes/updates items of file list. 453 * @param {Array.<Entry>} changedEntries Entries of updated/added files. 454 * @param {Array.<string>} removedUrls URLs of removed files. 455 * @private 456 */ 457DirectoryModel.prototype.partialUpdate_ = 458 function(changedEntries, removedUrls) { 459 // This update should be included in the current running update. 460 if (this.pendingScan_) 461 return; 462 463 if (this.runningScan_) { 464 // Do update after the current scan is finished. 465 var previousScan = this.runningScan_; 466 var onPreviousScanCompleted = function() { 467 previousScan.removeEventListener('scan-completed', 468 onPreviousScanCompleted); 469 // Run the update asynchronously. 470 Promise.resolve().then(function() { 471 this.partialUpdate_(changedEntries, removedUrls); 472 }.bind(this)); 473 }.bind(this); 474 previousScan.addEventListener('scan-completed', onPreviousScanCompleted); 475 return; 476 } 477 478 var onFinish = function() { 479 this.runningScan_ = null; 480 481 this.currentDirContents_.removeEventListener( 482 'scan-completed', onCompleted); 483 this.currentDirContents_.removeEventListener('scan-failed', onFailure); 484 this.currentDirContents_.removeEventListener( 485 'scan-cancelled', onCancelled); 486 }.bind(this); 487 488 var onCompleted = function() { 489 onFinish(); 490 cr.dispatchSimpleEvent(this, 'rescan-completed'); 491 }.bind(this); 492 493 var onFailure = function() { 494 onFinish(); 495 }; 496 497 var onCancelled = function() { 498 onFinish(); 499 }; 500 501 this.runningScan_ = this.currentDirContents_; 502 this.currentDirContents_.addEventListener('scan-completed', onCompleted); 503 this.currentDirContents_.addEventListener('scan-failed', onFailure); 504 this.currentDirContents_.addEventListener('scan-cancelled', onCancelled); 505 this.currentDirContents_.update(changedEntries, removedUrls); 506}; 507 508/** 509 * Perform a directory contents scan. Should be called only from rescan() and 510 * clearAndScan_(). 511 * 512 * @param {DirectoryContents} dirContents DirectoryContents instance on which 513 * the scan will be run. 514 * @param {boolean} refresh True to refresh metadata, or false to use cached 515 * one. 516 * @param {function()} successCallback Callback on success. 517 * @param {function()} failureCallback Callback on failure. 518 * @param {function()} updatedCallback Callback on update. Only on the last 519 * update, {@code successCallback} is called instead of this. 520 * @param {function()} cancelledCallback Callback on cancel. 521 * @private 522 */ 523DirectoryModel.prototype.scan_ = function( 524 dirContents, 525 refresh, 526 successCallback, failureCallback, updatedCallback, cancelledCallback) { 527 var self = this; 528 529 /** 530 * Runs pending scan if there is one. 531 * 532 * @return {boolean} Did pending scan exist. 533 */ 534 var maybeRunPendingRescan = function() { 535 if (this.pendingRescan_) { 536 this.rescanSoon(refresh); 537 this.pendingRescan_ = false; 538 return true; 539 } 540 return false; 541 }.bind(this); 542 543 var onFinished = function() { 544 dirContents.removeEventListener('scan-completed', onSuccess); 545 dirContents.removeEventListener('scan-updated', updatedCallback); 546 dirContents.removeEventListener('scan-failed', onFailure); 547 dirContents.removeEventListener('scan-cancelled', cancelledCallback); 548 }; 549 550 var onSuccess = function() { 551 onFinished(); 552 553 // Record metric for Downloads directory. 554 if (!dirContents.isSearch()) { 555 var locationInfo = 556 this.volumeManager_.getLocationInfo(dirContents.getDirectoryEntry()); 557 if (locationInfo.volumeInfo.volumeType === 558 VolumeManagerCommon.VolumeType.DOWNLOADS && 559 locationInfo.isRootEntry) { 560 metrics.recordMediumCount('DownloadsCount', 561 dirContents.fileList_.length); 562 } 563 } 564 565 this.runningScan_ = null; 566 successCallback(); 567 this.scanFailures_ = 0; 568 maybeRunPendingRescan(); 569 }.bind(this); 570 571 var onFailure = function() { 572 onFinished(); 573 574 this.runningScan_ = null; 575 this.scanFailures_++; 576 failureCallback(); 577 578 if (maybeRunPendingRescan()) 579 return; 580 581 if (this.scanFailures_ <= 1) 582 this.rescanLater(refresh); 583 }.bind(this); 584 585 var onCancelled = function() { 586 onFinished(); 587 cancelledCallback(); 588 }; 589 590 this.runningScan_ = dirContents; 591 592 dirContents.addEventListener('scan-completed', onSuccess); 593 dirContents.addEventListener('scan-updated', updatedCallback); 594 dirContents.addEventListener('scan-failed', onFailure); 595 dirContents.addEventListener('scan-cancelled', onCancelled); 596 dirContents.scan(refresh); 597}; 598 599/** 600 * @param {DirectoryContents} dirContents DirectoryContents instance. This must 601 * be a different instance from this.currentDirContents_. 602 * @private 603 */ 604DirectoryModel.prototype.replaceDirectoryContents_ = function(dirContents) { 605 console.assert(this.currentDirContents_ !== dirContents, 606 'Give directory contents instance must be different from current one.'); 607 cr.dispatchSimpleEvent(this, 'begin-update-files'); 608 this.updateSelectionAndPublishEvent_(this.fileListSelection_, function() { 609 var selectedEntries = this.getSelectedEntries_(); 610 var selectedIndices = this.fileListSelection_.selectedIndexes; 611 612 // Restore leadIndex in case leadName no longer exists. 613 var leadIndex = this.fileListSelection_.leadIndex; 614 var leadEntry = this.getLeadEntry_(); 615 616 var previousDirContents = this.currentDirContents_; 617 this.currentDirContents_ = dirContents; 618 this.currentDirContents_.replaceContextFileList(); 619 previousDirContents.dispose(); 620 621 this.setSelectedEntries_(selectedEntries); 622 this.fileListSelection_.leadIndex = leadIndex; 623 this.setLeadEntry_(leadEntry); 624 625 // If nothing is selected after update, then select file next to the 626 // latest selection 627 var forceChangeEvent = false; 628 if (this.fileListSelection_.selectedIndexes.length == 0 && 629 selectedIndices.length != 0) { 630 var maxIdx = Math.max.apply(null, selectedIndices); 631 this.selectIndex(Math.min(maxIdx - selectedIndices.length + 2, 632 this.getFileList().length) - 1); 633 forceChangeEvent = true; 634 } 635 return forceChangeEvent; 636 }.bind(this)); 637 638 cr.dispatchSimpleEvent(this, 'end-update-files'); 639}; 640 641/** 642 * Callback when an entry is changed. 643 * @param {util.EntryChangedKind} kind How the entry is changed. 644 * @param {Array.<Entry>} entries The changed entries. 645 */ 646DirectoryModel.prototype.onEntriesChanged = function(kind, entries) { 647 // TODO(hidehiko): We should update directory model even the search result 648 // is shown. 649 var rootType = this.getCurrentRootType(); 650 if ((rootType === VolumeManagerCommon.RootType.DRIVE || 651 rootType === VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME || 652 rootType === VolumeManagerCommon.RootType.DRIVE_RECENT || 653 rootType === VolumeManagerCommon.RootType.DRIVE_OFFLINE) && 654 this.isSearching()) 655 return; 656 657 switch (kind) { 658 case util.EntryChangedKind.CREATED: 659 var parentPromises = []; 660 for (var i = 0; i < entries.length; i++) { 661 parentPromises.push(new Promise(function(resolve, reject) { 662 entries[i].getParent(resolve, reject); 663 })); 664 } 665 Promise.all(parentPromises).then(function(parents) { 666 var entriesToAdd = []; 667 for (var i = 0; i < parents.length; i++) { 668 if (!util.isSameEntry(parents[i], this.getCurrentDirEntry())) 669 continue; 670 var index = this.findIndexByEntry_(entries[i]); 671 if (index >= 0) { 672 this.getFileList().replaceItem( 673 this.getFileList().item(index), entries[i]); 674 } else { 675 entriesToAdd.push(entries[i]); 676 } 677 } 678 this.partialUpdate_(entriesToAdd, []); 679 }.bind(this)).catch(function(error) { 680 console.error(error.stack || error); 681 }); 682 break; 683 684 case util.EntryChangedKind.DELETED: 685 // This is the delete event. 686 this.partialUpdate_([], util.entriesToURLs(entries)); 687 break; 688 689 default: 690 console.error('Invalid EntryChangedKind: ' + kind); 691 break; 692 } 693}; 694 695/** 696 * @param {Entry} entry The entry to be searched. 697 * @return {number} The index in the fileList, or -1 if not found. 698 * @private 699 */ 700DirectoryModel.prototype.findIndexByEntry_ = function(entry) { 701 var fileList = this.getFileList(); 702 for (var i = 0; i < fileList.length; i++) { 703 if (util.isSameEntry(fileList.item(i), entry)) 704 return i; 705 } 706 return -1; 707}; 708 709/** 710 * Called when rename is done successfully. 711 * Note: conceptually, DirectoryModel should work without this, because entries 712 * can be renamed by other systems anytime and Files.app should reflect it 713 * correctly. 714 * TODO(hidehiko): investigate more background, and remove this if possible. 715 * 716 * @param {Entry} oldEntry The old entry. 717 * @param {Entry} newEntry The new entry. 718 * @param {function()=} opt_callback Called on completion. 719 */ 720DirectoryModel.prototype.onRenameEntry = function( 721 oldEntry, newEntry, opt_callback) { 722 this.currentDirContents_.prefetchMetadata([newEntry], true, function() { 723 // If the current directory is the old entry, then quietly change to the 724 // new one. 725 if (util.isSameEntry(oldEntry, this.getCurrentDirEntry())) 726 this.changeDirectoryEntry(newEntry); 727 728 // Replace the old item with the new item. 729 // If the entry doesn't exist in the list, it has been updated from 730 // outside (probably by directory rescan) and is just ignored. 731 this.getFileList().replaceItem(oldEntry, newEntry); 732 733 // Run callback, finally. 734 if (opt_callback) 735 opt_callback(); 736 }.bind(this)); 737}; 738 739/** 740 * Creates directory and updates the file list. 741 * 742 * @param {string} name Directory name. 743 * @param {function(DirectoryEntry)} successCallback Callback on success. 744 * @param {function(FileError)} errorCallback Callback on failure. 745 * @param {function()} abortCallback Callback on abort (cancelled by user). 746 */ 747DirectoryModel.prototype.createDirectory = function(name, 748 successCallback, 749 errorCallback, 750 abortCallback) { 751 // Obtain and check the current directory. 752 var entry = this.getCurrentDirEntry(); 753 if (!entry || this.isSearching()) { 754 errorCallback(util.createDOMError( 755 util.FileError.INVALID_MODIFICATION_ERR)); 756 return; 757 } 758 759 var sequence = this.changeDirectorySequence_; 760 761 new Promise(entry.getDirectory.bind( 762 entry, name, {create: true, exclusive: true})). 763 764 then(function(newEntry) { 765 // Refresh the cache. 766 this.metadataCache_.clear([newEntry], '*'); 767 return new Promise(function(onFulfilled, onRejected) { 768 this.metadataCache_.getOne(newEntry, 769 'filesystem', 770 onFulfilled.bind(null, newEntry)); 771 }.bind(this)); 772 }.bind(this)). 773 774 then(function(newEntry) { 775 // Do not change anything or call the callback if current 776 // directory changed. 777 if (this.changeDirectorySequence_ !== sequence) { 778 abortCallback(); 779 return; 780 } 781 782 // If target directory is already in the list, just select it. 783 var existing = this.getFileList().slice().filter( 784 function(e) { return e.name === name; }); 785 if (existing.length) { 786 this.selectEntry(newEntry); 787 successCallback(existing[0]); 788 } else { 789 this.fileListSelection_.beginChange(); 790 this.getFileList().splice(0, 0, newEntry); 791 this.selectEntry(newEntry); 792 this.fileListSelection_.endChange(); 793 successCallback(newEntry); 794 } 795 }.bind(this), function(reason) { 796 errorCallback(reason); 797 }); 798}; 799 800/** 801 * Changes the current directory to the directory represented by 802 * a DirectoryEntry or a fake entry. 803 * 804 * Dispatches the 'directory-changed' event when the directory is successfully 805 * changed. 806 * 807 * Note : if this is called from UI, please consider to use DirectoryModel. 808 * activateDirectoryEntry instead of this, which is higher-level function and 809 * cares about the selection. 810 * 811 * @param {DirectoryEntry|Object} dirEntry The entry of the new directory to 812 * be opened. 813 * @param {function()=} opt_callback Executed if the directory loads 814 * successfully. 815 */ 816DirectoryModel.prototype.changeDirectoryEntry = function( 817 dirEntry, opt_callback) { 818 // Increment the sequence value. 819 this.changeDirectorySequence_++; 820 this.clearSearch_(); 821 822 this.directoryChangeQueue_.run(function(sequence, queueTaskCallback) { 823 this.fileWatcher_.changeWatchedDirectory( 824 dirEntry, 825 function() { 826 if (this.changeDirectorySequence_ !== sequence) { 827 queueTaskCallback(); 828 return; 829 } 830 831 var newDirectoryContents = this.createDirectoryContents_( 832 this.currentFileListContext_, dirEntry, ''); 833 if (!newDirectoryContents) { 834 queueTaskCallback(); 835 return; 836 } 837 838 var previousDirEntry = 839 this.currentDirContents_.getDirectoryEntry(); 840 this.clearAndScan_( 841 newDirectoryContents, 842 function(result) { 843 // Calls the callback of the method when successful. 844 if (result && opt_callback) 845 opt_callback(); 846 847 // Notify that the current task of this.directoryChangeQueue_ 848 // is completed. 849 setTimeout(queueTaskCallback); 850 }); 851 852 // For tests that open the dialog to empty directories, everything 853 // is loaded at this point. 854 util.testSendMessage('directory-change-complete'); 855 856 var event = new Event('directory-changed'); 857 event.previousDirEntry = previousDirEntry; 858 event.newDirEntry = dirEntry; 859 this.dispatchEvent(event); 860 }.bind(this)); 861 }.bind(this, this.changeDirectorySequence_)); 862}; 863 864/** 865 * Activates the given directory. 866 * This method: 867 * - Changes the current directory, if the given directory is the current 868 * directory. 869 * - Clears the selection, if the given directory is the current directory. 870 * 871 * @param {DirectoryEntry|Object} dirEntry The entry of the new directory to 872 * be opened. 873 * @param {function()=} opt_callback Executed if the directory loads 874 * successfully. 875 */ 876DirectoryModel.prototype.activateDirectoryEntry = function( 877 dirEntry, opt_callback) { 878 var currentDirectoryEntry = this.getCurrentDirEntry(); 879 if (currentDirectoryEntry && 880 util.isSameEntry(dirEntry, currentDirectoryEntry)) { 881 // On activating the current directory, clear the selection on the filelist. 882 this.clearSelection(); 883 } else { 884 // Otherwise, changes the current directory. 885 this.changeDirectoryEntry(dirEntry, opt_callback); 886 } 887}; 888 889/** 890 * Clears the selection in the file list. 891 */ 892DirectoryModel.prototype.clearSelection = function() { 893 this.setSelectedEntries_([]); 894}; 895 896/** 897 * Creates an object which could say whether directory has changed while it has 898 * been active or not. Designed for long operations that should be cancelled 899 * if the used change current directory. 900 * @return {Object} Created object. 901 */ 902DirectoryModel.prototype.createDirectoryChangeTracker = function() { 903 var tracker = { 904 dm_: this, 905 active_: false, 906 hasChanged: false, 907 908 start: function() { 909 if (!this.active_) { 910 this.dm_.addEventListener('directory-changed', 911 this.onDirectoryChange_); 912 this.active_ = true; 913 this.hasChanged = false; 914 } 915 }, 916 917 stop: function() { 918 if (this.active_) { 919 this.dm_.removeEventListener('directory-changed', 920 this.onDirectoryChange_); 921 this.active_ = false; 922 } 923 }, 924 925 onDirectoryChange_: function(event) { 926 tracker.stop(); 927 tracker.hasChanged = true; 928 } 929 }; 930 return tracker; 931}; 932 933/** 934 * @param {Entry} entry Entry to be selected. 935 */ 936DirectoryModel.prototype.selectEntry = function(entry) { 937 var fileList = this.getFileList(); 938 for (var i = 0; i < fileList.length; i++) { 939 if (fileList.item(i).toURL() === entry.toURL()) { 940 this.selectIndex(i); 941 return; 942 } 943 } 944}; 945 946/** 947 * @param {Array.<string>} entries Array of entries. 948 */ 949DirectoryModel.prototype.selectEntries = function(entries) { 950 // URLs are needed here, since we are comparing Entries by URLs. 951 var urls = util.entriesToURLs(entries); 952 var fileList = this.getFileList(); 953 this.fileListSelection_.beginChange(); 954 this.fileListSelection_.unselectAll(); 955 for (var i = 0; i < fileList.length; i++) { 956 if (urls.indexOf(fileList.item(i).toURL()) >= 0) 957 this.fileListSelection_.setIndexSelected(i, true); 958 } 959 this.fileListSelection_.endChange(); 960}; 961 962/** 963 * @param {number} index Index of file. 964 */ 965DirectoryModel.prototype.selectIndex = function(index) { 966 // this.focusCurrentList_(); 967 if (index >= this.getFileList().length) 968 return; 969 970 // If a list bound with the model it will do scrollIndexIntoView(index). 971 this.fileListSelection_.selectedIndex = index; 972}; 973 974/** 975 * Handles update of VolumeInfoList. 976 * @param {Event} event Event of VolumeInfoList's 'splice'. 977 * @private 978 */ 979DirectoryModel.prototype.onVolumeInfoListUpdated_ = function(event) { 980 // When the volume where we are is unmounted, fallback to the default volume's 981 // root. If current directory path is empty, stop the fallback 982 // since the current directory is initializing now. 983 if (this.getCurrentDirEntry() && 984 !this.volumeManager_.getVolumeInfo(this.getCurrentDirEntry())) { 985 this.volumeManager_.getDefaultDisplayRoot(function(displayRoot) { 986 this.changeDirectoryEntry(displayRoot); 987 }.bind(this)); 988 } 989}; 990 991/** 992 * Creates directory contents for the entry and query. 993 * 994 * @param {FileListContext} context File list context. 995 * @param {DirectoryEntry} entry Current directory. 996 * @param {string=} opt_query Search query string. 997 * @return {DirectoryContents} Directory contents. 998 * @private 999 */ 1000DirectoryModel.prototype.createDirectoryContents_ = 1001 function(context, entry, opt_query) { 1002 var query = (opt_query || '').trimLeft(); 1003 var locationInfo = this.volumeManager_.getLocationInfo(entry); 1004 if (!locationInfo) 1005 return null; 1006 var canUseDriveSearch = this.volumeManager_.getDriveConnectionState().type !== 1007 VolumeManagerCommon.DriveConnectionType.OFFLINE && 1008 locationInfo.isDriveBased; 1009 1010 if (query && canUseDriveSearch) { 1011 // Drive search. 1012 return DirectoryContents.createForDriveSearch(context, entry, query); 1013 } else if (query) { 1014 // Local search. 1015 return DirectoryContents.createForLocalSearch(context, entry, query); 1016 } if (locationInfo.isSpecialSearchRoot) { 1017 // Drive special search. 1018 var searchType; 1019 switch (locationInfo.rootType) { 1020 case VolumeManagerCommon.RootType.DRIVE_OFFLINE: 1021 searchType = 1022 DriveMetadataSearchContentScanner.SearchType.SEARCH_OFFLINE; 1023 break; 1024 case VolumeManagerCommon.RootType.DRIVE_SHARED_WITH_ME: 1025 searchType = 1026 DriveMetadataSearchContentScanner.SearchType.SEARCH_SHARED_WITH_ME; 1027 break; 1028 case VolumeManagerCommon.RootType.DRIVE_RECENT: 1029 searchType = 1030 DriveMetadataSearchContentScanner.SearchType.SEARCH_RECENT_FILES; 1031 break; 1032 default: 1033 // Unknown special search entry. 1034 throw new Error('Unknown special search type.'); 1035 } 1036 return DirectoryContents.createForDriveMetadataSearch( 1037 context, 1038 entry, 1039 searchType); 1040 } else { 1041 // Local fetch or search. 1042 return DirectoryContents.createForDirectory(context, entry); 1043 } 1044}; 1045 1046/** 1047 * Performs search and displays results. The search type is dependent on the 1048 * current directory. If we are currently on drive, server side content search 1049 * over drive mount point. If the current directory is not on the drive, file 1050 * name search over current directory will be performed. 1051 * 1052 * @param {string} query Query that will be searched for. 1053 * @param {function(Event)} onSearchRescan Function that will be called when the 1054 * search directory is rescanned (i.e. search results are displayed). 1055 * @param {function()} onClearSearch Function to be called when search state 1056 * gets cleared. 1057 * TODO(olege): Change callbacks to events. 1058 */ 1059DirectoryModel.prototype.search = function(query, 1060 onSearchRescan, 1061 onClearSearch) { 1062 this.clearSearch_(); 1063 var currentDirEntry = this.getCurrentDirEntry(); 1064 if (!currentDirEntry) { 1065 // Not yet initialized. Do nothing. 1066 return; 1067 } 1068 1069 this.changeDirectorySequence_++; 1070 this.directoryChangeQueue_.run(function(sequence, callback) { 1071 if (this.changeDirectorySequence_ !== sequence) { 1072 callback(); 1073 return; 1074 } 1075 1076 if (!(query || '').trimLeft()) { 1077 if (this.isSearching()) { 1078 var newDirContents = this.createDirectoryContents_( 1079 this.currentFileListContext_, 1080 currentDirEntry); 1081 this.clearAndScan_(newDirContents, 1082 callback); 1083 } else { 1084 callback(); 1085 } 1086 return; 1087 } 1088 1089 var newDirContents = this.createDirectoryContents_( 1090 this.currentFileListContext_, currentDirEntry, query); 1091 if (!newDirContents) { 1092 callback(); 1093 return; 1094 } 1095 1096 this.onSearchCompleted_ = onSearchRescan; 1097 this.onClearSearch_ = onClearSearch; 1098 this.addEventListener('scan-completed', this.onSearchCompleted_); 1099 this.clearAndScan_(newDirContents, 1100 callback); 1101 }.bind(this, this.changeDirectorySequence_)); 1102}; 1103 1104/** 1105 * In case the search was active, remove listeners and send notifications on 1106 * its canceling. 1107 * @private 1108 */ 1109DirectoryModel.prototype.clearSearch_ = function() { 1110 if (!this.isSearching()) 1111 return; 1112 1113 if (this.onSearchCompleted_) { 1114 this.removeEventListener('scan-completed', this.onSearchCompleted_); 1115 this.onSearchCompleted_ = null; 1116 } 1117 1118 if (this.onClearSearch_) { 1119 this.onClearSearch_(); 1120 this.onClearSearch_ = null; 1121 } 1122}; 1123