• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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