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