• 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.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