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 = 1000; 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.fileFilter_ = fileFilter; 36 this.fileFilter_.addEventListener('changed', 37 this.onFilterChanged_.bind(this)); 38 39 this.currentFileListContext_ = new FileListContext( 40 fileFilter, metadataCache); 41 this.currentDirContents_ = 42 DirectoryContents.createForDirectory(this.currentFileListContext_, null); 43 44 this.volumeManager_ = volumeManager; 45 this.volumeManager_.volumeInfoList.addEventListener( 46 'splice', this.onVolumeInfoListUpdated_.bind(this)); 47 48 this.fileWatcher_ = fileWatcher; 49 this.fileWatcher_.addEventListener( 50 'watcher-directory-changed', 51 this.onWatcherDirectoryChanged_.bind(this)); 52} 53 54/** 55 * Fake entry to be used in currentDirEntry_ when current directory is 56 * unmounted DRIVE. TODO(haruki): Support "drive/root" and "drive/other". 57 * @type {Object} 58 * @const 59 * @private 60 */ 61DirectoryModel.fakeDriveEntry_ = { 62 fullPath: RootDirectory.DRIVE + '/' + DriveSubRootDirectory.ROOT, 63 isDirectory: true, 64 rootType: RootType.DRIVE 65}; 66 67/** 68 * Fake entry representing a psuedo directory, which contains Drive files 69 * available offline. This entry works as a trigger to start a search for 70 * offline files. 71 * @type {Object} 72 * @const 73 * @private 74 */ 75DirectoryModel.fakeDriveOfflineEntry_ = { 76 fullPath: RootDirectory.DRIVE_OFFLINE, 77 isDirectory: true, 78 rootType: RootType.DRIVE_OFFLINE 79}; 80 81/** 82 * Fake entry representing a pseudo directory, which contains shared-with-me 83 * Drive files. This entry works as a trigger to start a search for 84 * shared-with-me files. 85 * @type {Object} 86 * @const 87 * @private 88 */ 89DirectoryModel.fakeDriveSharedWithMeEntry_ = { 90 fullPath: RootDirectory.DRIVE_SHARED_WITH_ME, 91 isDirectory: true, 92 rootType: RootType.DRIVE_SHARED_WITH_ME 93}; 94 95/** 96 * Fake entry representing a pseudo directory, which contains Drive files 97 * accessed recently. This entry works as a trigger to start a metadata search 98 * implemented as DirectoryContentsDriveRecent. 99 * DirectoryModel is responsible to start the search when the UI tries to open 100 * this fake entry (e.g. changeDirectory()). 101 * @type {Object} 102 * @const 103 * @private 104 */ 105DirectoryModel.fakeDriveRecentEntry_ = { 106 fullPath: RootDirectory.DRIVE_RECENT, 107 isDirectory: true, 108 rootType: RootType.DRIVE_RECENT 109}; 110 111/** 112 * List of fake entries for special searches. 113 * 114 * @type {Array.<Object>} 115 * @const 116 */ 117DirectoryModel.FAKE_DRIVE_SPECIAL_SEARCH_ENTRIES = [ 118 DirectoryModel.fakeDriveSharedWithMeEntry_, 119 DirectoryModel.fakeDriveRecentEntry_, 120 DirectoryModel.fakeDriveOfflineEntry_ 121]; 122 123/** 124 * DirectoryModel extends cr.EventTarget. 125 */ 126DirectoryModel.prototype.__proto__ = cr.EventTarget.prototype; 127 128/** 129 * Disposes the directory model by removing file watchers. 130 */ 131DirectoryModel.prototype.dispose = function() { 132 this.fileWatcher_.dispose(); 133}; 134 135/** 136 * @return {cr.ui.ArrayDataModel} Files in the current directory. 137 */ 138DirectoryModel.prototype.getFileList = function() { 139 return this.currentFileListContext_.fileList; 140}; 141 142/** 143 * Sort the file list. 144 * @param {string} sortField Sort field. 145 * @param {string} sortDirection "asc" or "desc". 146 */ 147DirectoryModel.prototype.sortFileList = function(sortField, sortDirection) { 148 this.getFileList().sort(sortField, sortDirection); 149}; 150 151/** 152 * @return {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} Selection 153 * in the fileList. 154 */ 155DirectoryModel.prototype.getFileListSelection = function() { 156 return this.fileListSelection_; 157}; 158 159/** 160 * @return {RootType} Root type of current root. 161 */ 162DirectoryModel.prototype.getCurrentRootType = function() { 163 var entry = this.currentDirContents_.getDirectoryEntry(); 164 return PathUtil.getRootType(entry ? entry.fullPath : ''); 165}; 166 167/** 168 * @return {string} Root path. 169 */ 170DirectoryModel.prototype.getCurrentRootPath = function() { 171 var entry = this.currentDirContents_.getDirectoryEntry(); 172 return entry ? PathUtil.getRootPath(entry.fullPath) : ''; 173}; 174 175/** 176 * @return {string} Filesystem URL representing the mountpoint for the current 177 * contents. 178 */ 179DirectoryModel.prototype.getCurrentMountPointUrl = function() { 180 var rootPath = this.getCurrentRootPath(); 181 // Special search roots are just showing a search results from DRIVE. 182 if (PathUtil.getRootType(rootPath) == RootType.DRIVE || 183 PathUtil.isSpecialSearchRoot(rootPath)) 184 return util.makeFilesystemUrl(RootDirectory.DRIVE); 185 186 return util.makeFilesystemUrl(rootPath); 187}; 188 189/** 190 * @return {boolean} on True if offline. 191 */ 192DirectoryModel.prototype.isDriveOffline = function() { 193 var connection = this.volumeManager_.getDriveConnectionState(); 194 return connection.type == util.DriveConnectionType.OFFLINE; 195}; 196 197/** 198 * TODO(haruki): This actually checks the current root. Fix the method name and 199 * related code. 200 * @return {boolean} True if the root for the current directory is read only. 201 */ 202DirectoryModel.prototype.isReadOnly = function() { 203 return this.isPathReadOnly(this.getCurrentRootPath()); 204}; 205 206/** 207 * @return {boolean} True if the a scan is active. 208 */ 209DirectoryModel.prototype.isScanning = function() { 210 return this.currentDirContents_.isScanning(); 211}; 212 213/** 214 * @return {boolean} True if search is in progress. 215 */ 216DirectoryModel.prototype.isSearching = function() { 217 return this.currentDirContents_.isSearch(); 218}; 219 220/** 221 * @param {string} path Path to check. 222 * @return {boolean} True if the |path| is read only. 223 */ 224DirectoryModel.prototype.isPathReadOnly = function(path) { 225 // TODO(hidehiko): Migrate this into VolumeInfo. 226 switch (PathUtil.getRootType(path)) { 227 case RootType.REMOVABLE: 228 var volumeInfo = this.volumeManager_.getVolumeInfo(path); 229 // Returns true if the volume is actually read only, or if an error 230 // is found during the mounting. 231 // TODO(hidehiko): Remove "error" check here, by removing error'ed volume 232 // info from VolumeManager. 233 return volumeInfo && (volumeInfo.isReadOnly || !!volumeInfo.error); 234 case RootType.ARCHIVE: 235 return true; 236 case RootType.DOWNLOADS: 237 return false; 238 case RootType.DRIVE: 239 // TODO(haruki): Maybe add DRIVE_OFFLINE as well to allow renaming in the 240 // offline tab. 241 return this.isDriveOffline(); 242 default: 243 return true; 244 } 245}; 246 247/** 248 * Updates the selection by using the updateFunc and publish the change event. 249 * If updateFunc returns true, it force to dispatch the change event even if the 250 * selection index is not changed. 251 * 252 * @param {cr.ui.ListSelectionModel|cr.ui.ListSingleSelectionModel} selection 253 * Selection to be updated. 254 * @param {function(): boolean} updateFunc Function updating the selection. 255 * @private 256 */ 257DirectoryModel.prototype.updateSelectionAndPublishEvent_ = 258 function(selection, updateFunc) { 259 // Begin change. 260 selection.beginChange(); 261 262 // If dispatchNeeded is true, we should ensure the change event is 263 // dispatched. 264 var dispatchNeeded = updateFunc(); 265 266 // Check if the change event is dispatched in the endChange function 267 // or not. 268 var eventDispatched = function() { dispatchNeeded = false; }; 269 selection.addEventListener('change', eventDispatched); 270 selection.endChange(); 271 selection.removeEventListener('change', eventDispatched); 272 273 // If the change event have been already dispatched, dispatchNeeded is false. 274 if (dispatchNeeded) { 275 var event = new Event('change'); 276 // The selection status (selected or not) is not changed because 277 // this event is caused by the change of selected item. 278 event.changes = []; 279 selection.dispatchEvent(event); 280 } 281}; 282 283/** 284 * Invoked when a change in the directory is detected by the watcher. 285 * @private 286 */ 287DirectoryModel.prototype.onWatcherDirectoryChanged_ = function() { 288 this.rescanSoon(); 289}; 290 291/** 292 * Invoked when filters are changed. 293 * @private 294 */ 295DirectoryModel.prototype.onFilterChanged_ = function() { 296 this.rescanSoon(); 297}; 298 299/** 300 * Returns the filter. 301 * @return {FileFilter} The file filter. 302 */ 303DirectoryModel.prototype.getFileFilter = function() { 304 return this.fileFilter_; 305}; 306 307/** 308 * @return {DirectoryEntry} Current directory. 309 */ 310DirectoryModel.prototype.getCurrentDirEntry = function() { 311 return this.currentDirContents_.getDirectoryEntry(); 312}; 313 314/** 315 * @return {string} URL of the current directory. or null if unavailable. 316 */ 317DirectoryModel.prototype.getCurrentDirectoryURL = function() { 318 var entry = this.currentDirContents_.getDirectoryEntry(); 319 if (!entry) 320 return null; 321 if (entry === DirectoryModel.fakeDriveOfflineEntry_) 322 return util.makeFilesystemUrl(entry.fullPath); 323 return entry.toURL(); 324}; 325 326/** 327 * @return {string} Path for the current directory, or empty string if the 328 * current directory is not yet set. 329 */ 330DirectoryModel.prototype.getCurrentDirPath = function() { 331 var entry = this.currentDirContents_.getDirectoryEntry(); 332 return entry ? entry.fullPath : ''; 333}; 334 335/** 336 * @return {Array.<string>} File paths of selected files. 337 * @private 338 */ 339DirectoryModel.prototype.getSelectedPaths_ = function() { 340 var indexes = this.fileListSelection_.selectedIndexes; 341 var fileList = this.getFileList(); 342 if (fileList) { 343 return indexes.map(function(i) { 344 return fileList.item(i).fullPath; 345 }); 346 } 347 return []; 348}; 349 350/** 351 * @param {Array.<string>} value List of file paths of selected files. 352 * @private 353 */ 354DirectoryModel.prototype.setSelectedPaths_ = function(value) { 355 var indexes = []; 356 var fileList = this.getFileList(); 357 358 var safeKey = function(key) { 359 // The transformation must: 360 // 1. Never generate a reserved name ('__proto__') 361 // 2. Keep different keys different. 362 return '#' + key; 363 }; 364 365 var hash = {}; 366 367 for (var i = 0; i < value.length; i++) 368 hash[safeKey(value[i])] = 1; 369 370 for (var i = 0; i < fileList.length; i++) { 371 if (hash.hasOwnProperty(safeKey(fileList.item(i).fullPath))) 372 indexes.push(i); 373 } 374 this.fileListSelection_.selectedIndexes = indexes; 375}; 376 377/** 378 * @return {string} Lead item file path. 379 * @private 380 */ 381DirectoryModel.prototype.getLeadPath_ = function() { 382 var index = this.fileListSelection_.leadIndex; 383 return index >= 0 && this.getFileList().item(index).fullPath; 384}; 385 386/** 387 * @param {string} value The name of new lead index. 388 * @private 389 */ 390DirectoryModel.prototype.setLeadPath_ = function(value) { 391 var fileList = this.getFileList(); 392 for (var i = 0; i < fileList.length; i++) { 393 if (fileList.item(i).fullPath === value) { 394 this.fileListSelection_.leadIndex = i; 395 return; 396 } 397 } 398}; 399 400/** 401 * Schedule rescan with short delay. 402 */ 403DirectoryModel.prototype.rescanSoon = function() { 404 this.scheduleRescan(SHORT_RESCAN_INTERVAL); 405}; 406 407/** 408 * Schedule rescan with delay. Designed to handle directory change 409 * notification. 410 */ 411DirectoryModel.prototype.rescanLater = function() { 412 this.scheduleRescan(SIMULTANEOUS_RESCAN_INTERVAL); 413}; 414 415/** 416 * Schedule rescan with delay. If another rescan has been scheduled does 417 * nothing. File operation may cause a few notifications what should cause 418 * a single refresh. 419 * @param {number} delay Delay in ms after which the rescan will be performed. 420 */ 421DirectoryModel.prototype.scheduleRescan = function(delay) { 422 if (this.rescanTime_) { 423 if (this.rescanTime_ <= Date.now() + delay) 424 return; 425 clearTimeout(this.rescanTimeoutId_); 426 } 427 428 this.rescanTime_ = Date.now() + delay; 429 this.rescanTimeoutId_ = setTimeout(this.rescan.bind(this), delay); 430}; 431 432/** 433 * Cancel a rescan on timeout if it is scheduled. 434 * @private 435 */ 436DirectoryModel.prototype.clearRescanTimeout_ = function() { 437 this.rescanTime_ = null; 438 if (this.rescanTimeoutId_) { 439 clearTimeout(this.rescanTimeoutId_); 440 this.rescanTimeoutId_ = null; 441 } 442}; 443 444/** 445 * Rescan current directory. May be called indirectly through rescanLater or 446 * directly in order to reflect user action. Will first cache all the directory 447 * contents in an array, then seamlessly substitute the fileList contents, 448 * preserving the select element etc. 449 * 450 * This should be to scan the contents of current directory (or search). 451 */ 452DirectoryModel.prototype.rescan = function() { 453 this.clearRescanTimeout_(); 454 if (this.runningScan_) { 455 this.pendingRescan_ = true; 456 return; 457 } 458 459 var dirContents = this.currentDirContents_.clone(); 460 dirContents.setFileList([]); 461 462 var successCallback = (function() { 463 this.replaceDirectoryContents_(dirContents); 464 cr.dispatchSimpleEvent(this, 'rescan-completed'); 465 }).bind(this); 466 467 this.scan_(dirContents, 468 successCallback, function() {}, function() {}, function() {}); 469}; 470 471/** 472 * Run scan on the current DirectoryContents. The active fileList is cleared and 473 * the entries are added directly. 474 * 475 * This should be used when changing directory or initiating a new search. 476 * 477 * @param {DirectoryContentes} newDirContents New DirectoryContents instance to 478 * replace currentDirContents_. 479 * @param {function()=} opt_callback Called on success. 480 * @private 481 */ 482DirectoryModel.prototype.clearAndScan_ = function(newDirContents, 483 opt_callback) { 484 if (this.currentDirContents_.isScanning()) 485 this.currentDirContents_.cancelScan(); 486 this.currentDirContents_ = newDirContents; 487 this.clearRescanTimeout_(); 488 489 if (this.pendingScan_) 490 this.pendingScan_ = false; 491 492 if (this.runningScan_) { 493 if (this.runningScan_.isScanning()) 494 this.runningScan_.cancelScan(); 495 this.runningScan_ = null; 496 } 497 498 var onDone = function() { 499 cr.dispatchSimpleEvent(this, 'scan-completed'); 500 if (opt_callback) 501 opt_callback(); 502 }.bind(this); 503 504 var onFailed = function() { 505 cr.dispatchSimpleEvent(this, 'scan-failed'); 506 }.bind(this); 507 508 var onUpdated = function() { 509 cr.dispatchSimpleEvent(this, 'scan-updated'); 510 }.bind(this); 511 512 var onCancelled = function() { 513 cr.dispatchSimpleEvent(this, 'scan-cancelled'); 514 }.bind(this); 515 516 // Clear the table, and start scanning. 517 cr.dispatchSimpleEvent(this, 'scan-started'); 518 var fileList = this.getFileList(); 519 fileList.splice(0, fileList.length); 520 this.scan_(this.currentDirContents_, 521 onDone, onFailed, onUpdated, onCancelled); 522}; 523 524/** 525 * Perform a directory contents scan. Should be called only from rescan() and 526 * clearAndScan_(). 527 * 528 * @param {DirectoryContents} dirContents DirectoryContents instance on which 529 * the scan will be run. 530 * @param {function()} successCallback Callback on success. 531 * @param {function()} failureCallback Callback on failure. 532 * @param {function()} updatedCallback Callback on update. Only on the last 533 * update, {@code successCallback} is called instead of this. 534 * @param {function()} cancelledCallback Callback on cancel. 535 * @private 536 */ 537DirectoryModel.prototype.scan_ = function( 538 dirContents, 539 successCallback, failureCallback, updatedCallback, cancelledCallback) { 540 var self = this; 541 542 /** 543 * Runs pending scan if there is one. 544 * 545 * @return {boolean} Did pending scan exist. 546 */ 547 var maybeRunPendingRescan = function() { 548 if (this.pendingRescan_) { 549 this.rescanSoon(); 550 this.pendingRescan_ = false; 551 return true; 552 } 553 return false; 554 }.bind(this); 555 556 var onSuccess = function() { 557 // Record metric for Downloads directory. 558 if (!dirContents.isSearch()) { 559 var locationInfo = 560 this.volumeManager_.getLocationInfo(dirContents.getDirectoryEntry()); 561 if (locationInfo.volumeInfo.volumeType === util.VolumeType.DOWNLOADS && 562 locationInfo.isRootEntry) { 563 metrics.recordMediumCount('DownloadsCount', 564 dirContents.fileList_.length); 565 } 566 } 567 568 this.runningScan_ = null; 569 successCallback(); 570 this.scanFailures_ = 0; 571 maybeRunPendingRescan(); 572 }.bind(this); 573 574 var onFailure = function() { 575 this.runningScan_ = null; 576 this.scanFailures_++; 577 failureCallback(); 578 579 if (maybeRunPendingRescan()) 580 return; 581 582 if (this.scanFailures_ <= 1) 583 this.rescanLater(); 584 }.bind(this); 585 586 this.runningScan_ = dirContents; 587 588 dirContents.addEventListener('scan-completed', onSuccess); 589 dirContents.addEventListener('scan-updated', updatedCallback); 590 dirContents.addEventListener('scan-failed', onFailure); 591 dirContents.addEventListener('scan-cancelled', cancelledCallback); 592 dirContents.scan(); 593}; 594 595/** 596 * @param {DirectoryContents} dirContents DirectoryContents instance. 597 * @private 598 */ 599DirectoryModel.prototype.replaceDirectoryContents_ = function(dirContents) { 600 cr.dispatchSimpleEvent(this, 'begin-update-files'); 601 this.updateSelectionAndPublishEvent_(this.fileListSelection_, function() { 602 var selectedPaths = this.getSelectedPaths_(); 603 var selectedIndices = this.fileListSelection_.selectedIndexes; 604 605 // Restore leadIndex in case leadName no longer exists. 606 var leadIndex = this.fileListSelection_.leadIndex; 607 var leadPath = this.getLeadPath_(); 608 609 this.currentDirContents_ = dirContents; 610 dirContents.replaceContextFileList(); 611 612 this.setSelectedPaths_(selectedPaths); 613 this.fileListSelection_.leadIndex = leadIndex; 614 this.setLeadPath_(leadPath); 615 616 // If nothing is selected after update, then select file next to the 617 // latest selection 618 var forceChangeEvent = false; 619 if (this.fileListSelection_.selectedIndexes.length == 0 && 620 selectedIndices.length != 0) { 621 var maxIdx = Math.max.apply(null, selectedIndices); 622 this.selectIndex(Math.min(maxIdx - selectedIndices.length + 2, 623 this.getFileList().length) - 1); 624 forceChangeEvent = true; 625 } 626 return forceChangeEvent; 627 }.bind(this)); 628 629 cr.dispatchSimpleEvent(this, 'end-update-files'); 630}; 631 632/** 633 * Callback when an entry is changed. 634 * @param {util.EntryChangedKind} kind How the entry is changed. 635 * @param {Entry} entry The changed entry. 636 */ 637DirectoryModel.prototype.onEntryChanged = function(kind, entry) { 638 // TODO(hidehiko): We should update directory model even the search result 639 // is shown. 640 var rootType = this.getCurrentRootType(); 641 if ((rootType === RootType.DRIVE || 642 rootType === RootType.DRIVE_SHARED_WITH_ME || 643 rootType === RootType.DRIVE_RECENT || 644 rootType === RootType.DRIVE_OFFLINE) && 645 this.isSearching()) 646 return; 647 648 if (kind == util.EntryChangedKind.CREATED) { 649 entry.getParent(function(parentEntry) { 650 if (this.getCurrentDirEntry().fullPath != parentEntry.fullPath) { 651 // Do nothing if current directory changed during async operations. 652 return; 653 } 654 this.currentDirContents_.prefetchMetadata([entry], function() { 655 if (this.getCurrentDirEntry().fullPath != parentEntry.fullPath) { 656 // Do nothing if current directory changed during async operations. 657 return; 658 } 659 660 var index = this.findIndexByEntry_(entry); 661 if (index >= 0) 662 this.getFileList().splice(index, 1, entry); 663 else 664 this.getFileList().push(entry); 665 }.bind(this)); 666 }.bind(this)); 667 } else { 668 // This is the delete event. 669 var index = this.findIndexByEntry_(entry); 670 if (index >= 0) 671 this.getFileList().splice(index, 1); 672 } 673}; 674 675/** 676 * @param {Entry} entry The entry to be searched. 677 * @return {number} The index in the fileList, or -1 if not found. 678 * @private 679 */ 680DirectoryModel.prototype.findIndexByEntry_ = function(entry) { 681 var fileList = this.getFileList(); 682 for (var i = 0; i < fileList.length; i++) { 683 if (util.isSameEntry(fileList.item(i), entry)) 684 return i; 685 } 686 return -1; 687}; 688 689/** 690 * Called when rename is done successfully. 691 * Note: conceptually, DirectoryModel should work without this, because entries 692 * can be renamed by other systems anytime and Files.app should reflect it 693 * correctly. 694 * TODO(hidehiko): investigate more background, and remove this if possible. 695 * 696 * @param {Entry} oldEntry The old entry. 697 * @param {Entry} newEntry The new entry. 698 * @param {function()} opt_callback Called on completion. 699 */ 700DirectoryModel.prototype.onRenameEntry = function( 701 oldEntry, newEntry, opt_callback) { 702 this.currentDirContents_.prefetchMetadata([newEntry], function() { 703 // If the current directory is the old entry, then quietly change to the 704 // new one. 705 if (util.isSameEntry(oldEntry, this.getCurrentDirEntry())) 706 this.changeDirectory(newEntry.fullPath); 707 708 // Look for the old entry. 709 // If the entry doesn't exist in the list, it has been updated from 710 // outside (probably by directory rescan). 711 var index = this.findIndexByEntry_(oldEntry); 712 if (index >= 0) { 713 // Update the content list and selection status. 714 var wasSelected = this.fileListSelection_.getIndexSelected(index); 715 this.updateSelectionAndPublishEvent_(this.fileListSelection_, function() { 716 this.fileListSelection_.setIndexSelected(index, false); 717 this.getFileList().splice(index, 1, newEntry); 718 if (wasSelected) { 719 // We re-search the index, because splice may trigger sorting so that 720 // index may be stale. 721 this.fileListSelection_.setIndexSelected( 722 this.findIndexByEntry_(newEntry), true); 723 } 724 return true; 725 }.bind(this)); 726 } 727 728 // Run callback, finally. 729 if (opt_callback) 730 opt_callback(); 731 }.bind(this)); 732}; 733 734/** 735 * Creates directory and updates the file list. 736 * 737 * @param {string} name Directory name. 738 * @param {function(DirectoryEntry)} successCallback Callback on success. 739 * @param {function(FileError)} errorCallback Callback on failure. 740 */ 741DirectoryModel.prototype.createDirectory = function(name, successCallback, 742 errorCallback) { 743 var entry = this.getCurrentDirEntry(); 744 if (!entry) { 745 errorCallback(util.createFileError(FileError.INVALID_MODIFICATION_ERR)); 746 return; 747 } 748 749 var tracker = this.createDirectoryChangeTracker(); 750 tracker.start(); 751 752 var onSuccess = function(newEntry) { 753 // Do not change anything or call the callback if current 754 // directory changed. 755 tracker.stop(); 756 if (tracker.hasChanged) 757 return; 758 759 var existing = this.getFileList().slice().filter( 760 function(e) {return e.name == name;}); 761 762 if (existing.length) { 763 this.selectEntry(newEntry); 764 successCallback(existing[0]); 765 } else { 766 this.fileListSelection_.beginChange(); 767 this.getFileList().splice(0, 0, newEntry); 768 this.selectEntry(newEntry); 769 this.fileListSelection_.endChange(); 770 successCallback(newEntry); 771 } 772 }; 773 774 this.currentDirContents_.createDirectory(name, onSuccess.bind(this), 775 errorCallback); 776}; 777 778/** 779 * Changes directory. Causes 'directory-change' event. 780 * 781 * The directory will not be changed, if another request is started before it is 782 * finished. The error callback will not be called, and the event for the first 783 * request will not be invoked. 784 * 785 * @param {string} path New current directory path. 786 * @param {function(FileError)=} opt_errorCallback Executed if the change 787 * directory failed. 788 */ 789DirectoryModel.prototype.changeDirectory = function(path, opt_errorCallback) { 790 this.changeDirectorySequence_++; 791 792 if (PathUtil.isSpecialSearchRoot(path)) { 793 this.specialSearch(path, ''); 794 return; 795 } 796 797 this.resolveDirectory( 798 path, 799 function(sequence, directoryEntry) { 800 if (this.changeDirectorySequence_ === sequence) 801 this.changeDirectoryEntry(directoryEntry); 802 }.bind(this, this.changeDirectorySequence_), 803 function(error) { 804 console.error('Error changing directory to ' + path + ': ', error); 805 if (opt_errorCallback) 806 opt_errorCallback(error); 807 }); 808}; 809 810/** 811 * Resolves absolute directory path. Handles Drive stub. If the drive is 812 * mounting, callbacks will be called after the mount is completed. 813 * 814 * @param {string} path Path to the directory. 815 * @param {function(DirectoryEntry)} successCallback Success callback. 816 * @param {function(FileError)} errorCallback Error callback. 817 */ 818DirectoryModel.prototype.resolveDirectory = function( 819 path, successCallback, errorCallback) { 820 if (PathUtil.getRootType(path) == RootType.DRIVE) { 821 if (!this.volumeManager_.getVolumeInfo(RootDirectory.DRIVE)) { 822 errorCallback(util.createFileError(FileError.NOT_FOUND_ERR)); 823 return; 824 } 825 } 826 827 var onError = function(error) { 828 // Handle the special case, when in offline mode, and there are no cached 829 // contents on the C++ side. In such case, let's display the stub. 830 // The INVALID_STATE_ERR error code is returned from the drive filesystem 831 // in such situation. 832 // 833 // TODO(mtomasz, hashimoto): Consider rewriting this logic. 834 // crbug.com/253464. 835 if (PathUtil.getRootType(path) == RootType.DRIVE && 836 error.code == FileError.INVALID_STATE_ERR) { 837 successCallback(DirectoryModel.fakeDriveEntry_); 838 return; 839 } 840 errorCallback(error); 841 }.bind(this); 842 843 // TODO(mtomasz): Use Entry instead of a path. 844 this.volumeManager_.resolveAbsolutePath( 845 path, 846 function(entry) { 847 if (entry.isFile) { 848 onError(util.createFileError(FileError.TYPE_MISMATCH_ERR)); 849 return; 850 } 851 successCallback(entry); 852 }, 853 onError); 854}; 855 856/** 857 * @param {DirectoryEntry} dirEntry The absolute path to the new directory. 858 * @param {function()=} opt_callback Executed if the directory loads 859 * successfully. 860 * @private 861 */ 862DirectoryModel.prototype.changeDirectoryEntrySilent_ = function(dirEntry, 863 opt_callback) { 864 var onScanComplete = function() { 865 if (opt_callback) 866 opt_callback(); 867 // For tests that open the dialog to empty directories, everything 868 // is loaded at this point. 869 chrome.test.sendMessage('directory-change-complete'); 870 }; 871 this.clearAndScan_( 872 DirectoryContents.createForDirectory(this.currentFileListContext_, 873 dirEntry), 874 onScanComplete.bind(this)); 875}; 876 877/** 878 * Change the current directory to the directory represented by a 879 * DirectoryEntry. 880 * 881 * Dispatches the 'directory-changed' event when the directory is successfully 882 * changed. 883 * 884 * @param {DirectoryEntry} dirEntry The absolute path to the new directory. 885 * @param {function()=} opt_callback Executed if the directory loads 886 * successfully. 887 */ 888DirectoryModel.prototype.changeDirectoryEntry = function( 889 dirEntry, opt_callback) { 890 this.fileWatcher_.changeWatchedDirectory(dirEntry, function(sequence) { 891 if (this.changeDirectorySequence_ !== sequence) 892 return; 893 var previous = this.currentDirContents_.getDirectoryEntry(); 894 this.clearSearch_(); 895 this.changeDirectoryEntrySilent_(dirEntry, opt_callback); 896 897 var e = new Event('directory-changed'); 898 e.previousDirEntry = previous; 899 e.newDirEntry = dirEntry; 900 this.dispatchEvent(e); 901 }.bind(this, this.changeDirectorySequence_)); 902}; 903 904/** 905 * Creates an object which could say whether directory has changed while it has 906 * been active or not. Designed for long operations that should be cancelled 907 * if the used change current directory. 908 * @return {Object} Created object. 909 */ 910DirectoryModel.prototype.createDirectoryChangeTracker = function() { 911 var tracker = { 912 dm_: this, 913 active_: false, 914 hasChanged: false, 915 916 start: function() { 917 if (!this.active_) { 918 this.dm_.addEventListener('directory-changed', 919 this.onDirectoryChange_); 920 this.active_ = true; 921 this.hasChanged = false; 922 } 923 }, 924 925 stop: function() { 926 if (this.active_) { 927 this.dm_.removeEventListener('directory-changed', 928 this.onDirectoryChange_); 929 this.active_ = false; 930 } 931 }, 932 933 onDirectoryChange_: function(event) { 934 tracker.stop(); 935 tracker.hasChanged = true; 936 } 937 }; 938 return tracker; 939}; 940 941/** 942 * @param {Entry} entry Entry to be selected. 943 */ 944DirectoryModel.prototype.selectEntry = function(entry) { 945 var fileList = this.getFileList(); 946 for (var i = 0; i < fileList.length; i++) { 947 if (fileList.item(i).toURL() === entry.toURL()) { 948 this.selectIndex(i); 949 return; 950 } 951 } 952}; 953 954/** 955 * @param {Array.<string>} entries Array of entries. 956 */ 957DirectoryModel.prototype.selectEntries = function(entries) { 958 // URLs are needed here, since we are comparing Entries by URLs. 959 var urls = util.entriesToURLs(entries); 960 var fileList = this.getFileList(); 961 this.fileListSelection_.beginChange(); 962 this.fileListSelection_.unselectAll(); 963 for (var i = 0; i < fileList.length; i++) { 964 if (urls.indexOf(fileList.item(i).toURL()) >= 0) 965 this.fileListSelection_.setIndexSelected(i, true); 966 } 967 this.fileListSelection_.endChange(); 968}; 969 970/** 971 * @param {number} index Index of file. 972 */ 973DirectoryModel.prototype.selectIndex = function(index) { 974 // this.focusCurrentList_(); 975 if (index >= this.getFileList().length) 976 return; 977 978 // If a list bound with the model it will do scrollIndexIntoView(index). 979 this.fileListSelection_.selectedIndex = index; 980}; 981 982/** 983 * Called when VolumeInfoList is updated. 984 * 985 * @param {Event} event Event of VolumeInfoList's 'splice'. 986 * @private 987 */ 988DirectoryModel.prototype.onVolumeInfoListUpdated_ = function(event) { 989 var driveVolume = this.volumeManager_.getVolumeInfo(RootDirectory.DRIVE); 990 if (driveVolume && !driveVolume.error) { 991 var currentDirEntry = this.getCurrentDirEntry(); 992 if (currentDirEntry) { 993 if (currentDirEntry === DirectoryModel.fakeDriveEntry_) { 994 // Replace the fake entry by real DirectoryEntry silently. 995 this.volumeManager_.resolveAbsolutePath( 996 DirectoryModel.fakeDriveEntry_.fullPath, 997 function(entry) { 998 // If the current entry is still fake drive entry, replace it. 999 if (this.getCurrentDirEntry() === DirectoryModel.fakeDriveEntry_) 1000 this.changeDirectoryEntrySilent_(entry); 1001 }, 1002 function(error) {}); 1003 } else if (PathUtil.isSpecialSearchRoot(currentDirEntry.fullPath)) { 1004 for (var i = 0; i < event.added.length; i++) { 1005 if (event.added[i].volumeType == util.VolumeType.DRIVE) { 1006 // If the Drive volume is newly mounted, rescan it. 1007 this.rescan(); 1008 break; 1009 } 1010 } 1011 } 1012 } 1013 } 1014 1015 // When the volume where we are is unmounted, fallback to 1016 // DEFAULT_MOUNT_POINT. If current directory path is empty, stop the fallback 1017 // since the current directory is initializing now. 1018 // TODO(mtomasz): DEFAULT_MOUNT_POINT is deprecated. Use VolumeManager:: 1019 // getDefaultVolume() after it is implemented. 1020 if (this.getCurrentDirPath() && 1021 !this.volumeManager_.getVolumeInfo(this.getCurrentDirPath())) 1022 this.changeDirectory(PathUtil.DEFAULT_MOUNT_POINT); 1023}; 1024 1025/** 1026 * Check if the root of the given path is mountable or not. 1027 * 1028 * @param {string} path Path. 1029 * @return {boolean} Return true, if the given path is under mountable root. 1030 * Otherwise, return false. 1031 */ 1032DirectoryModel.isMountableRoot = function(path) { 1033 var rootType = PathUtil.getRootType(path); 1034 switch (rootType) { 1035 case RootType.DOWNLOADS: 1036 return false; 1037 case RootType.ARCHIVE: 1038 case RootType.REMOVABLE: 1039 case RootType.DRIVE: 1040 return true; 1041 default: 1042 throw new Error('Unknown root type!'); 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 query = query.trimLeft(); 1063 1064 this.clearSearch_(); 1065 1066 var currentDirEntry = this.getCurrentDirEntry(); 1067 if (!currentDirEntry) { 1068 // Not yet initialized. Do nothing. 1069 return; 1070 } 1071 1072 if (!query) { 1073 if (this.isSearching()) { 1074 var newDirContents = DirectoryContents.createForDirectory( 1075 this.currentFileListContext_, 1076 this.currentDirContents_.getLastNonSearchDirectoryEntry()); 1077 this.clearAndScan_(newDirContents); 1078 } 1079 return; 1080 } 1081 1082 this.onSearchCompleted_ = onSearchRescan; 1083 this.onClearSearch_ = onClearSearch; 1084 1085 this.addEventListener('scan-completed', this.onSearchCompleted_); 1086 1087 // If we are offline, let's fallback to file name search inside dir. 1088 // A search initiated from directories in Drive or special search results 1089 // should trigger Drive search. 1090 var newDirContents; 1091 if (!this.isDriveOffline() && 1092 PathUtil.isDriveBasedPath(currentDirEntry.fullPath)) { 1093 // Drive search is performed over the whole drive, so pass drive root as 1094 // |directoryEntry|. 1095 newDirContents = DirectoryContents.createForDriveSearch( 1096 this.currentFileListContext_, 1097 currentDirEntry, 1098 this.currentDirContents_.getLastNonSearchDirectoryEntry(), 1099 query); 1100 } else { 1101 newDirContents = DirectoryContents.createForLocalSearch( 1102 this.currentFileListContext_, currentDirEntry, query); 1103 } 1104 this.clearAndScan_(newDirContents); 1105}; 1106 1107/** 1108 * Performs special search and displays results. e.g. Drive files available 1109 * offline, shared-with-me files, recently modified files. 1110 * @param {string} path Path string representing special search. See fake 1111 * entries in PathUtil.RootDirectory. 1112 * @param {string=} opt_query Query string used for the search. 1113 */ 1114DirectoryModel.prototype.specialSearch = function(path, opt_query) { 1115 var query = opt_query || ''; 1116 1117 this.clearSearch_(); 1118 1119 this.onSearchCompleted_ = null; 1120 this.onClearSearch_ = null; 1121 1122 var onDriveDirectoryResolved = function(sequence, driveRoot) { 1123 if (this.changeDirectorySequence_ !== sequence) 1124 return; 1125 if (!driveRoot || driveRoot == DirectoryModel.fakeDriveEntry_) { 1126 // Drive root not available or not ready. onVolumeInfoListUpdated_() 1127 // handles the rescan if necessary. 1128 driveRoot = null; 1129 } 1130 1131 var specialSearchType = PathUtil.getRootType(path); 1132 var searchOption; 1133 var dirEntry; 1134 if (specialSearchType == RootType.DRIVE_OFFLINE) { 1135 dirEntry = DirectoryModel.fakeDriveOfflineEntry_; 1136 searchOption = 1137 DriveMetadataSearchContentScanner.SearchType.SEARCH_OFFLINE; 1138 } else if (specialSearchType == RootType.DRIVE_SHARED_WITH_ME) { 1139 dirEntry = DirectoryModel.fakeDriveSharedWithMeEntry_; 1140 searchOption = 1141 DriveMetadataSearchContentScanner.SearchType.SEARCH_SHARED_WITH_ME; 1142 } else if (specialSearchType == RootType.DRIVE_RECENT) { 1143 dirEntry = DirectoryModel.fakeDriveRecentEntry_; 1144 searchOption = 1145 DriveMetadataSearchContentScanner.SearchType.SEARCH_RECENT_FILES; 1146 } else { 1147 // Unknown path. 1148 throw new Error('Unknown path for special search.'); 1149 } 1150 1151 var newDirContents = DirectoryContents.createForDriveMetadataSearch( 1152 this.currentFileListContext_, 1153 dirEntry, driveRoot, query, searchOption); 1154 var previous = this.currentDirContents_.getDirectoryEntry(); 1155 this.clearAndScan_(newDirContents); 1156 1157 var e = new Event('directory-changed'); 1158 e.previousDirEntry = previous; 1159 e.newDirEntry = dirEntry; 1160 this.dispatchEvent(e); 1161 }.bind(this, this.changeDirectorySequence_); 1162 1163 this.resolveDirectory(DirectoryModel.fakeDriveEntry_.fullPath, 1164 onDriveDirectoryResolved /* success */, 1165 function() {} /* failed */); 1166}; 1167 1168/** 1169 * In case the search was active, remove listeners and send notifications on 1170 * its canceling. 1171 * @private 1172 */ 1173DirectoryModel.prototype.clearSearch_ = function() { 1174 if (!this.isSearching()) 1175 return; 1176 1177 if (this.onSearchCompleted_) { 1178 this.removeEventListener('scan-completed', this.onSearchCompleted_); 1179 this.onSearchCompleted_ = null; 1180 } 1181 1182 if (this.onClearSearch_) { 1183 this.onClearSearch_(); 1184 this.onClearSearch_ = null; 1185 } 1186}; 1187