chromium/ui/file_manager/file_manager/foreground/js/directory_model.ts

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {dispatchSimpleEvent} from 'chrome://resources/ash/common/cr_deprecated.js';
import {assert} from 'chrome://resources/js/assert.js';

import type {VolumeInfo} from '../../background/js/volume_info.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import type {SpliceEvent} from '../../common/js/array_data_model.js';
import {Aggregator, AsyncQueue} from '../../common/js/async_util.js';
import {convertURLsToEntries, entriesToURLs, getRootType, isFakeEntry, isGuestOs, isNativeEntry, isOneDrive, isOneDriveId, isOneDrivePlaceholder, isRecentRootType, isSameEntry, urlToEntry} from '../../common/js/entry_utils.js';
import type {FakeEntry, FilesAppDirEntry, FilesAppEntry, GuestOsPlaceholder, UniversalDirectory} from '../../common/js/files_app_entry_types.js';
import {type CustomEventMap, FilesEventTarget} from '../../common/js/files_event_target.js';
import {isDlpEnabled, isDriveFsBulkPinningEnabled, isSkyvaultV2Enabled} from '../../common/js/flags.js';
import {recordMediumCount} from '../../common/js/metrics.js';
import {getEntryLabel} from '../../common/js/translations.js';
import {testSendMessage} from '../../common/js/util.js';
import {FileSystemType, getVolumeTypeFromRootType, isNative, RootType, Source, VolumeType} from '../../common/js/volume_manager_types.js';
import {getMyFiles} from '../../state/ducks/all_entries.js';
import {changeDirectory} from '../../state/ducks/current_directory.js';
import {clearSearch, getDefaultSearchOptions, updateSearch} from '../../state/ducks/search.js';
import type {FileData, FileKey, SearchData} from '../../state/state.js';
import {EntryType, PropStatus, SearchLocation, type SearchOptions, type State, type Volume, type VolumeId} from '../../state/state.js';
import {getFileData, getStore, getVolume, type Store} from '../../state/store.js';

import {CROSTINI_CONNECT_ERR, DLP_METADATA_PREFETCH_PROPERTY_NAMES, LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES} from './constants.js';
import type {ContentScanner, DirContentsScanFailedEvent, DirContentsScanUpdatedEvent, FileFilter} from './directory_contents.js';
import {CrostiniMounter, DirectoryContents, DirectoryContentScanner, DriveMetadataSearchContentScanner, EmptyContentScanner, FileListContext, GuestOsMounter, MediaViewContentScanner, RecentContentScanner, SearchV2ContentScanner, StoreScanner, TrashContentScanner} from './directory_contents.js';
import {FileListModel} from './file_list_model.js';
import {FileWatcher, type WatcherDirectoryChangedEvent} from './file_watcher.js';
import type {MetadataKey} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import {FileListSelectionModel, FileListSingleSelectionModel} from './ui/file_list_selection_model.js';
import type {ListSelectionModel} from './ui/list_selection_model.js';
import type {ListSingleSelectionModel} from './ui/list_single_selection_model.js';

/**
 * Used to track asynchronous directory change use like:
 * const tracker = directoryModel.createDirectoryChangeTracker();
 * tracker.start();
 * try {
 *    ... async code here ...
 *    if (tracker.hasChanged) {
 *      // This code shouldn't continue anymore.
 *    }
 * } finally {
 *     tracker.stop();
 * }
 */
export interface DirectoryChangeTracker {
  start(): void;
  stop(): void;
  // Indicates that another directory change has occurred since start(). The
  // change tracker that sees a `hasChanged=true` should not proceed to change
  // the directory, because they became stale.
  hasChanged: boolean;
}

// If directory files changes too often, don't rescan directory more than once
// per specified interval
const SIMULTANEOUS_RESCAN_INTERVAL = 500;
// Used for operations that require almost instant rescan.
const SHORT_RESCAN_INTERVAL = 100;

/**
 * Helper function that can decide if the scan of the given entry should be
 * performed by recent scanner or other (search) scanner. In transition period
 * between V1 and V2 versions of search, when the user searches in Recent, and
 * uses Recent as location, we reuse the Recent scanner. Otherwise, the true
 * search scanner is used.
 */
function isRecentScan(
    entry: DirectoryEntry|FilesAppEntry,
    options?: SearchOptions): entry is FakeEntry {
  if (isRecentRootType(getRootType(entry))) {
    // Potential search in Recents. However, if options are present and are
    // indicating that the user wishes to scan current entry, still use Recent
    // scanner.
    if (!options || options.location === SearchLocation.THIS_FOLDER) {
      return true;
    }
  }
  return false;
}

/**
 * Helper function that determines the category of files we are looking for
 * based on the fake entry, query and options.
 */
function getFileCategory(
    entry: FakeEntry, query: string|undefined,
    options: SearchOptions|undefined) {
  if (query) {
    if (options) {
      return options.fileCategory;
    }
  }
  return entry.fileCategory;
}

export type DirectoryChangeEvent = CustomEvent<{
  previousDirEntry?: DirectoryEntry | FilesAppDirEntry,
  previousFileKey?: FileKey,
  newDirEntry?: DirectoryEntry | FilesAppDirEntry,
  newFileKey?: FileKey, volumeChanged: boolean,
}>;

export type CurDirScanFailedEvent = CustomEvent<{error: DOMError}>;
export type CurDirScanUpdatedEvent = CustomEvent<{
  /** Whether the content scanner was based in the store. */
  isStoreBased: boolean,
}>;
export type EmptyEvent = CustomEvent<undefined>;

interface DirectoryModelEventMap extends CustomEventMap {
  'directory-changed': DirectoryChangeEvent;
  'cur-dir-rescan-completed': EmptyEvent;
  'cur-dir-scan-completed': EmptyEvent;
  'cur-dir-scan-started': EmptyEvent;

  'cur-dir-scan-failed': CurDirScanFailedEvent;
  'cur-dir-scan-canceled': EmptyEvent;
  'cur-dir-scan-updated': CurDirScanUpdatedEvent;
}

/**
 * Data model for the current directory for Files app.
 *
 * It encapsulates the current directory, the file selection, directory scanner,
 * etc.
 */
export class DirectoryModel extends FilesEventTarget<DirectoryModelEventMap> {
  private fileListSelection_: FileListSingleSelectionModel|
      FileListSelectionModel;
  private runningScan_: DirectoryContents|null = null;
  private pendingScan_: boolean|null = null;
  private pendingRescan_: boolean|null = null;
  private rescanTime_: number|null = null;
  private rescanTimeoutId_?: number;
  private changeDirectorySequence_ = 0;
  private cachedSearch_: SearchData|undefined|{} = {};
  private scanFailures_ = 0;
  private onSearchCompleted_: ((e: Event) => void)|null = null;
  private ignoreCurrentDirectoryDeletion_ = false;
  private directoryChangeQueue_ = new AsyncQueue();
  /**
   * Number of running directory change trackers.
   */
  private numChangeTrackerRunning_ = 0;
  private rescanAggregator_ =
      new Aggregator(this.rescanSoon.bind(this, true), 500);
  private currentFileListContext_: FileListContext;
  private currentDirContents_: DirectoryContents;
  private emptyFileList_: FileListModel;
  private fileWatcher_ = new FileWatcher();
  private lastSearchQuery_ = '';
  private volumes_: (Record<VolumeId, Volume>)|null = null;
  private store_: Store;

  /**
   * @param singleSelection True if only one file could be selected at the time.
   */
  constructor(
      singleSelection: boolean, private fileFilter_: FileFilter,
      private metadataModel_: MetadataModel,
      private volumeManager_: VolumeManager) {
    super();

    this.fileListSelection_ = singleSelection ?
        new FileListSingleSelectionModel() :
        new FileListSelectionModel();

    this.fileFilter_.addEventListener(
        'changed', this.onFilterChanged_.bind(this));

    this.currentFileListContext_ = new FileListContext(
        this.fileFilter_, this.metadataModel_, this.volumeManager_);
    this.currentDirContents_ = new DirectoryContents(
        this.currentFileListContext_, false, undefined, undefined, () => {
          return new DirectoryContentScanner(undefined);
        });

    /**
     * Empty file list which is used as a dummy for inactive view of file list.
     */
    this.emptyFileList_ = new FileListModel(this.metadataModel_);

    this.volumeManager_.volumeInfoList.addEventListener(
        'splice', this.onVolumeInfoListUpdated_.bind(this) as EventListener);

    this.fileWatcher_.addEventListener(
        'watcher-directory-changed',
        this.onWatcherDirectoryChanged_.bind(this));
    // For non-watchable directories (e.g. FakeEntry) and volumes (MTP) we need
    // to subscribe to the IOTask and manually refresh.
    chrome.fileManagerPrivate.onIOTaskProgressStatus.addListener(
        this.updateFileListAfterIoTask_.bind(this));

    this.store_ = getStore();
    this.store_.subscribe(this);
  }

  onStateChanged(state: State) {
    this.handleDirectoryState_(state);
    this.handleSearchState_(state);
  }

  /**
   * Handles the current directory slice of the store's state.
   * @param state latest state from the store.
   */
  private handleDirectoryState_(state: State) {
    const currentURL = this.getCurrentFileKey();
    const newURL = state.currentDirectory ? state.currentDirectory.key : null;

    // Observe volume changes.
    if (this.volumes_ !== state.volumes) {
      this.onStateVolumeChanged_(state);
      this.volumes_ = state.volumes;
    }

    // If the directory is the same or the newURL is null, ignore it.
    if (currentURL === newURL || !newURL) {
      return;
    }

    // When something changed the current directory status to STARTED, Here we
    // initiate the actual change and will update to SUCCESS at the end.
    if (state.currentDirectory?.status === PropStatus.STARTED) {
      const fileData = getFileData(state, newURL);
      if (fileData?.type === EntryType.MATERIALIZED_VIEW) {
        this.changeDirectoryFileData(fileData);
        return;
      }

      const entry = fileData?.entry;
      if (!entry) {
        // TODO(lucmult): Fix potential race condition in this await/then.
        urlToEntry(newURL)
            .then((entry) => {
              if (!entry) {
                throw new Error(
                    `Failed to find the new directory key ${newURL}`);
              }
              // Initiate the directory change.
              this.changeDirectoryEntry(entry as DirectoryEntry);
            })
            .catch((error) => {
              console.warn(error);
              this.store_.dispatch(
                  changeDirectory({toKey: newURL, status: PropStatus.ERROR}));
            });
        return;
      }

      // Initiate the directory change.
      this.changeDirectoryEntry(entry as DirectoryEntry);
    }
  }

  /**
   * Reacts to changes in the search state of the store. If the search changed
   * and the query is not empty, this method triggers a new directory search.
   */
  private handleSearchState_(state: State) {
    const currentEntry = this.getCurrentDirEntry();
    // Do not handle any search state until we have the current directory set.
    // Requests to handle current search state may be triggered by the files app
    // before it is fully started.
    if (!currentEntry) {
      return;
    }
    const search = state.search;
    if (this.cachedSearch_ === search) {
      // Bail out early if the search part of the state has not changed.
      return;
    }

    // Cache the last received search state for future comparisons.
    const lastSearch = this.cachedSearch_;
    this.cachedSearch_ = search;

    // We change the search state (STARTED, SUCCESS, etc.) so only trigger
    // a new search if the query or the options have changed.
    if (!search) {
      return;
    }
    if (!lastSearch || (lastSearch as SearchData).query !== search.query ||
        (lastSearch as SearchData).options !== search.options) {
      this.search_(
          search.query || '', search.options || getDefaultSearchOptions());
    }
  }

  /**
   * Disposes the directory model by removing file watchers.
   */
  dispose(): void {
    this.fileWatcher_.dispose();
  }

  /**
   * @return Files in the current directory.
   */
  getFileList(): FileListModel {
    return this.currentFileListContext_.fileList;
  }

  /**
   * @return File list which is always empty.
   */
  getEmptyFileList(): FileListModel {
    return this.emptyFileList_;
  }

  /**
   * @return Selection in the fileList.
   */
  getFileListSelection(): FileListSelectionModel|FileListSingleSelectionModel {
    return this.fileListSelection_;
  }

  /**
   * Obtains current volume information.
   */
  getCurrentVolumeInfo(): VolumeInfo|null {
    const entry = this.getCurrentDirEntry();
    if (!entry) {
      return null;
    }
    return this.volumeManager_.getVolumeInfo(entry);
  }

  /**
   * @return Root type of current root, or null if not found.
   */
  getCurrentRootType(): RootType|null {
    const entry = this.currentDirContents_.getDirectoryEntry();
    if (!entry) {
      return null;
    }

    const locationInfo = this.volumeManager_.getLocationInfo(entry);
    if (!locationInfo) {
      return null;
    }

    return locationInfo.rootType;
  }

  /**
   * Metadata property names that are expected to be Prefetched.
   */
  getPrefetchPropertyNames(): MetadataKey[] {
    return this.currentFileListContext_.prefetchPropertyNames;
  }

  /**
   * @return True if the current directory is read only. If there is no entry
   *     set, then returns true.
   */
  isReadOnly(): boolean {
    const currentDirEntry = this.getCurrentDirEntry();
    if (currentDirEntry) {
      const locationInfo = this.volumeManager_.getLocationInfo(currentDirEntry);
      if (locationInfo) {
        return locationInfo.isReadOnly;
      }
    }
    return true;
  }

  /**
   * @return True if entries in the current directory can be deleted. Similar to
   *     !isReadOnly() except that we allow items in the read-only Trash root to
   *     be deleted. If there is no entry set, then returns false.
   */
  canDeleteEntries(): boolean {
    const currentDirEntry = this.getCurrentDirEntry();
    if (currentDirEntry && getRootType(currentDirEntry) === RootType.TRASH) {
      return true;
    }
    return !this.isReadOnly();
  }

  /**
   * @return True if the a scan is active.
   */
  isScanning(): boolean {
    return this.currentDirContents_.isScanning();
  }

  /**
   * @return True if search is in progress.
   */
  isSearching(): boolean {
    return this.currentDirContents_.isSearch();
  }

  /**
   * @return True if it's on Drive.
   */
  isOnDrive(): boolean {
    return this.isCurrentRootVolumeType_(VolumeType.DRIVE);
  }

  /**
   * @return True if the current volume is provided by FuseBox.
   */
  isOnFuseBox(): boolean {
    const info = this.getCurrentVolumeInfo();
    return info ? info.diskFileSystemType === FileSystemType.FUSEBOX : false;
  }

  /**
   * @return True if it's on a Linux native volume.
   */
  isOnNative(): boolean {
    const rootType = this.getCurrentRootType();
    return rootType !== null && !isRecentRootType(rootType) &&
        isNative(getVolumeTypeFromRootType(rootType));
  }

  /**
   * @return True if the current volume is blocked by DLP.
   */
  isDlpBlocked(): boolean {
    if (!isDlpEnabled()) {
      return false;
    }
    const info = this.getCurrentVolumeInfo();
    return info ? this.volumeManager_.isDisabled(info.volumeType) : false;
  }

  /**
   * @param volumeType Volume Type
   * @return True if current root volume type is equal to specified volume type.
   */
  private isCurrentRootVolumeType_(volumeType: VolumeType): boolean {
    const rootType = this.getCurrentRootType();
    return rootType !== null && !isRecentRootType(rootType) &&
        getVolumeTypeFromRootType(rootType) === volumeType;
  }

  /**
   * Updates the selection by using the updateFunc and publish the change event.
   * If updateFunc returns true, it force to dispatch the change event even if
   * the selection index is not changed.
   *
   * @param selection Selection to be updated.
   * @param updateFunc Function updating the selection.
   */
  private updateSelectionAndPublishEvent_(
      selection: ListSelectionModel|ListSingleSelectionModel,
      updateFunc: () => boolean) {
    // Begin change.
    selection.beginChange();

    // If dispatchNeeded is true, we should ensure the change event is
    // dispatched.
    let dispatchNeeded = updateFunc();

    // Check if the change event is dispatched in the endChange function
    // or not.
    const eventDispatched = () => {
      dispatchNeeded = false;
    };
    selection.addEventListener('change', eventDispatched);
    selection.endChange();
    selection.removeEventListener('change', eventDispatched);

    // If the change event have been already dispatched, dispatchNeeded is
    // false.
    if (dispatchNeeded) {
      // The selection status (selected or not) is not changed because
      // this event is caused by the change of selected item.
      const event = new CustomEvent('change', {detail: {changes: []}});
      selection.dispatchEvent(event);
    }
  }

  /**
   * Sets to ignore current directory deletion. This method is used to prevent
   * going up to the volume root with the deletion of current directory by
   * rename operation in directory tree.
   * @param value True to ignore current directory deletion.
   */
  setIgnoringCurrentDirectoryDeletion(value: boolean) {
    this.ignoreCurrentDirectoryDeletion_ = value;
  }

  /**
   * Invoked when a change in the directory is detected by the watcher.
   * @param event Event object.
   */
  private onWatcherDirectoryChanged_(event: WatcherDirectoryChangedEvent) {
    const directoryEntry = this.getCurrentDirEntry();

    if (!this.ignoreCurrentDirectoryDeletion_ && directoryEntry) {
      // If the change is deletion of currentDir, move up to its parent
      // directory.
      directoryEntry.getDirectory(
          directoryEntry.fullPath, {create: false}, () => {}, async () => {
            assert(directoryEntry);
            const volumeInfo =
                this.volumeManager_.getVolumeInfo(directoryEntry);
            if (volumeInfo) {
              const displayRoot = await volumeInfo.resolveDisplayRoot();
              this.changeDirectoryEntry(displayRoot);
            }
          });
    }

    if (event.detail?.changedFiles) {
      const addedOrUpdatedFileUrls: string[] = [];
      let deletedFileUrls: string[] = [];
      event.detail.changedFiles.forEach(change => {
        if (change.changes.length === 1 && change.changes[0] === 'delete') {
          deletedFileUrls.push(change.url);
        } else {
          addedOrUpdatedFileUrls.push(change.url);
        }
      });

      convertURLsToEntries(addedOrUpdatedFileUrls)
          .then(result => {
            deletedFileUrls = deletedFileUrls.concat(result.failureUrls);

            // Passing the resolved entries and failed URLs as the removed
            // files. The URLs are removed files and they chan't be resolved.
            this.partialUpdate_(result.entries, deletedFileUrls);
          })
          .catch(error => {
            console.warn(
                'Error in proceeding the changed event.', error,
                'Fallback to force-refresh');
            this.rescanAggregator_.run();
          });
    } else {
      // Invokes force refresh if the detailed information isn't provided.
      // This can occur very frequently (e.g. when copying files into Downloads)
      // and rescan is heavy operation, so we keep some interval for each
      // rescan.
      this.rescanAggregator_.run();
    }
  }

  /**
   * Invoked when filters are changed.
   */
  private async onFilterChanged_() {
    const currentDirectory = this.getCurrentDirEntry();
    if (currentDirectory && isNativeEntry(currentDirectory) &&
        !this.fileFilter_.filter(currentDirectory)) {
      // If the current directory should be hidden in the new filter setting,
      // change the current directory to the current volume's root.
      const volumeInfo = this.volumeManager_.getVolumeInfo(currentDirectory);
      if (volumeInfo) {
        const displayRoot = await volumeInfo.resolveDisplayRoot();
        this.changeDirectoryEntry(displayRoot);
      }
    } else {
      this.rescanSoon(false);
    }
  }

  /**
   * Invoked when volumes have been modified in the state.
   * @param state latest state from the store.
   */
  private onStateVolumeChanged_(state: State) {
    if (!state.currentDirectory) {
      return;
    }
    for (const volume of Object.values(state.volumes)) {
      // Navigate out of ODFS if it got disabled and the current directory is
      // under ODFS.
      const isOdfs = isOneDriveId(volume.providerId);
      if (!(isOdfs && volume.isDisabled)) {
        continue;
      }
      const currentDirectoryFileData =
          getFileData(state, state.currentDirectory.key);
      const currentDirectoryOnOdfs =
          isOneDriveId(getVolume(state, currentDirectoryFileData)?.providerId);
      if (currentDirectoryOnOdfs) {
        const tracker = this.createDirectoryChangeTracker();
        tracker.start();
        // Normally the default root is MyFiles, however with SkyVault, this
        // is the volume in the Cloud (OneDrive or GoogleDrive).
        this.volumeManager_.getDefaultDisplayRoot().then((displayRoot) => {
          if (displayRoot && !tracker.hasChanged) {
            this.changeDirectoryEntry(displayRoot);
          }
        });
        tracker.stop();
      }
    }
  }

  getFileFilter(): FileFilter {
    return this.fileFilter_;
  }

  getCurrentDirEntry(): DirectoryEntry|FakeEntry|FilesAppDirEntry|undefined {
    return this.currentDirContents_.getDirectoryEntry();
  }

  getCurrentDirName(): string {
    const fileData = this.getCurrentFileData();
    if (fileData) {
      return fileData.label;
    }

    const dirEntry = this.getCurrentDirEntry();
    if (!dirEntry) {
      return '';
    }

    const locationInfo = this.volumeManager_.getLocationInfo(dirEntry);
    return getEntryLabel(locationInfo, dirEntry);
  }

  getCurrentFileKey(): FileKey|undefined {
    return this.currentDirContents_.getFileKey();
  }

  getCurrentFileData(): FileData|undefined {
    const fileKey = this.getCurrentFileKey();
    if (!fileKey) {
      return;
    }

    return getFileData(this.store_.getState(), fileKey) ?? undefined;
  }

  /**
   * @return Array of selected entries.
   */
  private getSelectedEntries_(): Array<Entry|FilesAppEntry> {
    const indexes = this.fileListSelection_.selectedIndexes;
    const fileList = this.getFileList();
    if (fileList) {
      return indexes.map(i => fileList.item(i)!);
    }
    return [];
  }

  /**
   * @param value List of selected entries.
   */
  private setSelectedEntries_(value: Array<Entry|FilesAppEntry>) {
    const indexes = [];
    const fileList = this.getFileList();
    const urls = entriesToURLs(value);

    for (let i = 0; i < fileList.length; i++) {
      if (urls.indexOf(fileList.item(i)!.toURL()) !== -1) {
        indexes.push(i);
      }
    }
    this.fileListSelection_.selectedIndexes = indexes;
  }

  /**
   * @return Lead entry.
   */
  private getLeadEntry_(): Entry|FilesAppEntry|null {
    const index = this.fileListSelection_.leadIndex;
    return index >= 0 ? this.getFileList().item(index)! : null;
  }

  /**
   * @param value The new lead entry.
   */
  private setLeadEntry_(value: Entry|FilesAppEntry|null) {
    const fileList = this.getFileList();
    for (let i = 0; i < fileList.length; i++) {
      if (isSameEntry(fileList.item(i), value)) {
        this.fileListSelection_.leadIndex = i;
        return;
      }
    }
  }

  /**
   * Schedule rescan with short delay.
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate the backend scanning result
   *     cache. This param only works if the corresponding backend scanning
   *     supports cache.
   */
  rescanSoon(refresh: boolean, invalidateCache: boolean = false) {
    this.scheduleRescan(SHORT_RESCAN_INTERVAL, refresh, invalidateCache);
  }

  /**
   * Schedule rescan with delay. Designed to handle directory change
   * notification.
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate the backend scanning result
   *     cache. This param only works if the corresponding backend scanning
   *     supports cache.
   */
  rescanLater(refresh: boolean, invalidateCache: boolean = false) {
    this.scheduleRescan(SIMULTANEOUS_RESCAN_INTERVAL, refresh, invalidateCache);
  }

  /**
   * Schedule rescan with delay. If another rescan has been scheduled does
   * nothing. File operation may cause a few notifications what should cause
   * a single refresh.
   * @param delay Delay in ms after which the rescan will be performed.
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate the backend scanning result
   *     cache. This param only works if the corresponding backend scanning
   *     supports cache.
   */
  scheduleRescan(
      delay: number, refresh: boolean, invalidateCache: boolean = false) {
    if (this.rescanTime_) {
      if (this.rescanTime_ <= Date.now() + delay) {
        return;
      }
      clearTimeout(this.rescanTimeoutId_);
    }

    const sequence = this.changeDirectorySequence_;

    this.rescanTime_ = Date.now() + delay;
    this.rescanTimeoutId_ = setTimeout(() => {
      this.rescanTimeoutId_ = undefined;
      if (sequence === this.changeDirectorySequence_) {
        this.rescan(refresh, invalidateCache);
      }
    }, delay);
  }

  /**
   * Cancel a rescan on timeout if it is scheduled.
   */
  private clearRescanTimeout_() {
    this.rescanTime_ = null;
    if (this.rescanTimeoutId_) {
      clearTimeout(this.rescanTimeoutId_);
      this.rescanTimeoutId_ = undefined;
    }
  }

  /**
   * Rescan current directory. May be called indirectly through rescanLater or
   * directly in order to reflect user action. Will first cache all the
   * directory contents in an array, then seamlessly substitute the fileList
   * contents, preserving the select element etc.
   *
   * This should be to scan the contents of current directory (or search).
   *
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate the backend scanning result
   *     cache. This param only works if the corresponding backend scanning
   *     supports cache.
   */
  rescan(refresh: boolean, invalidateCache: boolean = false) {
    this.clearRescanTimeout_();
    if (this.runningScan_) {
      this.pendingRescan_ = true;
      return;
    }

    const dirContents = this.currentDirContents_.clone();
    dirContents.setFileList(new FileListModel(this.metadataModel_));
    dirContents.setMetadataSnapshot(
        this.currentDirContents_.createMetadataSnapshot());

    const sequence = this.changeDirectorySequence_;

    const successCallback = () => {
      if (sequence === this.changeDirectorySequence_) {
        this.replaceDirectoryContents_(dirContents);
        this.dispatchEvent(new CustomEvent('cur-dir-rescan-completed'));
      }
    };

    this.scan_(
        dirContents, refresh, invalidateCache, successCallback, () => {},
        () => {}, () => {});
  }

  /**
   * Run scan on the current DirectoryContents. The active fileList is cleared
   * and the entries are added directly.
   *
   * This should be used when changing directory or initiating a new search.
   *
   * @param newDirContents New DirectoryContents instance to replace
   *     `currentDirContents_`.
   * @param callback Callback with result. True if the scan is completed
   *     successfully, false if the scan is failed.
   */
  private clearAndScan_(
      newDirContents: DirectoryContents, callback: (result: boolean) => void) {
    if (this.currentDirContents_.isScanning()) {
      this.currentDirContents_.cancelScan();
    }
    this.currentDirContents_ = newDirContents;
    this.clearRescanTimeout_();

    if (this.pendingScan_) {
      this.pendingScan_ = false;
    }

    if (this.runningScan_) {
      if (this.runningScan_.isScanning()) {
        this.runningScan_.cancelScan();
      }
      this.runningScan_ = null;
    }

    const sequence = this.changeDirectorySequence_;
    let cancelled = false;

    const onDone = () => {
      if (cancelled) {
        return;
      }

      this.dispatchEvent(new CustomEvent('cur-dir-scan-completed'));
      callback(true);
    };

    const onFailed = (error: DOMError) => {
      if (cancelled) {
        return;
      }

      this.dispatchEvent(
          new CustomEvent('cur-dir-scan-failed', {detail: {error}}));
      callback(false);
    };

    const onUpdated = (event: DirContentsScanUpdatedEvent) => {
      if (cancelled) {
        return;
      }

      if (this.changeDirectorySequence_ !== sequence) {
        cancelled = true;
        this.dispatchEvent(new CustomEvent('cur-dir-scan-canceled'));
        callback(false);
        return;
      }

      const newEvent = new CustomEvent('cur-dir-scan-updated', {
        detail: {
          isStoreBased: event.detail.isStoreBased,
        },
      });
      this.dispatchEvent(newEvent);
    };

    const onCancelled = () => {
      if (cancelled) {
        return;
      }

      cancelled = true;
      this.dispatchEvent(new CustomEvent('cur-dir-scan-canceled'));
      callback(false);
    };

    // Clear metadata information for the old (no longer visible) items in the
    // file list.
    const fileList = this.getFileList();
    const removedUrls = [];
    for (let i = 0; i < fileList.length; i++) {
      removedUrls.push(fileList.item(i)!.toURL());
    }
    this.metadataModel_.notifyEntriesRemoved(removedUrls);

    // Retrieve metadata information for the newly selected directory.
    const currentEntry = this.currentDirContents_.getDirectoryEntry();
    if (currentEntry) {
      const locationInfo = this.volumeManager_.getLocationInfo(currentEntry);
      // When bulk pinning is enabled, this call is made with more frequency in
      // the UI delegate as hosted documents receive the available offline tick
      // when they are both explicitly pinned and heuristically cached.
      if (locationInfo && locationInfo.isDriveBased &&
          !isDriveFsBulkPinningEnabled()) {
        chrome.fileManagerPrivate.pollDriveHostedFilePinStates();
      }
      if (!isFakeEntry(currentEntry)) {
        this.metadataModel_.get([currentEntry], [
          ...LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES,
          ...DLP_METADATA_PREFETCH_PROPERTY_NAMES,
        ]);
      }
    }

    // Clear the table, and start scanning.
    fileList.splice(0, fileList.length);
    this.dispatchEvent(new CustomEvent('cur-dir-scan-started'));
    this.scan_(
        this.currentDirContents_, false, true, onDone, onFailed, onUpdated,
        onCancelled);
  }

  /**
   * Similar to clearAndScan_() but instead of passing a `newDirContents`, it
   * uses the `currentDirContents_`.
   */
  clearCurrentDirAndScan() {
    const sequence = ++this.changeDirectorySequence_;
    this.directoryChangeQueue_.run(callback => {
      if (this.changeDirectorySequence_ !== sequence) {
        callback();
        return;
      }
      const currentDirEntry = this.getCurrentDirEntry()!;
      assert(currentDirEntry);
      const newDirContents = this.createDirectoryContents_(
          this.currentFileListContext_, currentDirEntry,
          currentDirEntry.toURL(), this.lastSearchQuery_);
      this.clearAndScan_(newDirContents, callback);
    });
  }

  /**
   * Adds/removes/updates items of file list.
   * @param changedEntries Entries of updated/added files.
   * @param removedUrls URLs of removed files.
   */
  private partialUpdate_(changedEntries: Entry[], removedUrls: string[]) {
    // This update should be included in the current running update.
    if (this.pendingScan_) {
      return;
    }

    if (this.runningScan_) {
      // Do update after the current scan is finished.
      const previousScan = this.runningScan_;
      const onPreviousScanCompleted = () => {
        previousScan.removeEventListener(
            'dir-contents-scan-completed', onPreviousScanCompleted);
        // Run the update asynchronously.
        Promise.resolve().then(() => {
          this.partialUpdate_(changedEntries, removedUrls);
        });
      };
      previousScan.addEventListener(
          'dir-contents-scan-completed', onPreviousScanCompleted);
      return;
    }

    const onFinish = () => {
      this.runningScan_ = null;

      this.currentDirContents_.removeEventListener(
          'dir-contents-scan-completed', onCompleted);
      this.currentDirContents_.removeEventListener(
          'dir-contents-scan-failed', onFailure);
      this.currentDirContents_.removeEventListener(
          'dir-contents-scan-canceled', onCancelled);
    };

    const onCompleted = () => {
      onFinish();
      this.dispatchEvent(new CustomEvent('cur-dir-rescan-completed'));
    };

    const onFailure = () => {
      onFinish();
    };

    const onCancelled = () => {
      onFinish();
    };

    this.runningScan_ = this.currentDirContents_;
    this.currentDirContents_.addEventListener(
        'dir-contents-scan-completed', onCompleted);
    this.currentDirContents_.addEventListener(
        'dir-contents-scan-failed', onFailure);
    this.currentDirContents_.addEventListener(
        'dir-contents-scan-canceled', onCancelled);
    this.currentDirContents_.update(changedEntries, removedUrls);
  }

  /**
   * Perform a directory contents scan. Should be called only from rescan() and
   * clearAndScan_().
   *
   * @param dirContents DirectoryContents instance on which the scan will be
   *     run.
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate scanning result cache.
   * @param successCallback Callback on success.
   * @param failureCallback Callback on failure.
   * @param updatedCallback Callback on update. Only on the last update,
   *     `successCallback` is called instead of this.
   * @param cancelledCallback Callback on cancel.
   */
  private scan_(
      dirContents: DirectoryContents, refresh: boolean,
      invalidateCache: boolean, successCallback: VoidCallback,
      failureCallback: (error: DOMError) => void,
      updatedCallback: (event: DirContentsScanUpdatedEvent) => void,
      cancelledCallback: VoidCallback) {
    /**
     * Runs pending scan if there is one.
     * @return Did pending scan exist.
     */
    const maybeRunPendingRescan = (): boolean => {
      if (this.pendingRescan_) {
        this.rescanSoon(refresh);
        this.pendingRescan_ = false;
        return true;
      }
      return false;
    };

    const onFinished = () => {
      dirContents.removeEventListener('dir-contents-scan-completed', onSuccess);
      dirContents.removeEventListener(
          'dir-contents-scan-updated', updatedCallback);
      dirContents.removeEventListener('dir-contents-scan-failed', onFailure);
      dirContents.removeEventListener(
          'dir-contents-scan-canceled', cancelledCallback);
    };

    const onSuccess = () => {
      onFinished();

      // Record metric for Downloads directory.
      const dirEntry = dirContents.getDirectoryEntry();
      if (dirEntry && !dirContents.isSearch()) {
        const locationInfo = this.volumeManager_.getLocationInfo(dirEntry);
        const volumeInfo = locationInfo && locationInfo.volumeInfo;
        if (volumeInfo && volumeInfo.volumeType === VolumeType.DOWNLOADS &&
            locationInfo.isRootEntry) {
          recordMediumCount('DownloadsCount', dirContents.getFileListLength());
        }
      }

      this.runningScan_ = null;
      successCallback();
      this.scanFailures_ = 0;
      maybeRunPendingRescan();
    };

    const onFailure = (event: DirContentsScanFailedEvent) => {
      onFinished();

      this.runningScan_ = null;
      this.scanFailures_++;
      failureCallback(event.detail.error);

      if (maybeRunPendingRescan()) {
        return;
      }

      // Do not rescan for Guest OS (including Crostini) errors.
      // TODO(crbug/1293229): Guest OS currently reuses the Crostini error
      // string, but once it gets its own strings this needs to include both.
      if (event.detail.error.name === CROSTINI_CONNECT_ERR) {
        return;
      }

      if (this.scanFailures_ <= 1) {
        this.rescanLater(refresh);
      }
    };

    const onCancelled = () => {
      onFinished();
      cancelledCallback();
    };

    this.runningScan_ = dirContents;

    dirContents.addEventListener('dir-contents-scan-completed', onSuccess);
    dirContents.addEventListener('dir-contents-scan-updated', updatedCallback);
    dirContents.addEventListener('dir-contents-scan-failed', onFailure);
    dirContents.addEventListener('dir-contents-scan-canceled', onCancelled);
    dirContents.scan(refresh, invalidateCache);
  }

  /**
   * @param dirContents DirectoryContents instance. This must be a different
   *     instance from this.currentDirContents_.
   */
  private replaceDirectoryContents_(dirContents: DirectoryContents) {
    console.assert(
        this.currentDirContents_ !== dirContents,
        'Give directory contents instance must be different from current one.');
    dispatchSimpleEvent(this, 'begin-update-files');
    this.updateSelectionAndPublishEvent_(this.fileListSelection_, () => {
      const selectedEntries = this.getSelectedEntries_();
      const selectedIndices = this.fileListSelection_.selectedIndexes;

      // Restore leadIndex in case leadName no longer exists.
      const leadIndex = this.fileListSelection_.leadIndex;
      const leadEntry = this.getLeadEntry_();
      const isCheckSelectMode = this.fileListSelection_.getCheckSelectMode();

      this.currentDirContents_ = dirContents;
      this.currentDirContents_.replaceContextFileList();

      this.setSelectedEntries_(selectedEntries);
      this.fileListSelection_.leadIndex = leadIndex;
      this.setLeadEntry_(leadEntry);

      // If nothing is selected after update, then select file next to the
      // latest selection
      let forceChangeEvent = false;
      if (this.fileListSelection_.selectedIndexes.length === 0 &&
          selectedIndices.length !== 0) {
        const maxIdx = Math.max.apply(null, selectedIndices);
        this.selectIndex(
            Math.min(
                maxIdx - selectedIndices.length + 2,
                this.getFileList().length) -
            1);
        forceChangeEvent = true;
      } else if (isCheckSelectMode) {
        // Otherwise, ensure check select mode is retained if it was previously
        // active.
        this.fileListSelection_.setCheckSelectMode(true);
      }
      return forceChangeEvent;
    });

    dispatchSimpleEvent(this, 'end-update-files');
  }

  /**
   * Called when rename is done successfully.
   * Note: conceptually, DirectoryModel should work without this, because
   * entries can be renamed by other systems anytime and the Files app should
   * reflect it correctly.
   * TODO(hidehiko): investigate more background, and remove this if possible.
   *
   * @param oldEntry The old entry.
   * @param newEntry The new entry.
   * @return Resolves on completion.
   */
  onRenameEntry(oldEntry: Entry|FilesAppEntry, newEntry: Entry|FilesAppEntry):
      Promise<void> {
    return new Promise(resolve => {
      this.currentDirContents_.prefetchMetadata([newEntry], true, () => {
        // If the current directory is the old entry, then quietly change to
        // the new one.
        if (isSameEntry(oldEntry, this.getCurrentDirEntry())) {
          this.changeDirectoryEntry(
              newEntry as DirectoryEntry | FilesAppDirEntry);
        }

        // Replace the old item with the new item. oldEntry instance itself may
        // have been removed/replaced from the list during the async process, we
        // find an entry which should be replaced by checking toURL().
        const list = this.getFileList();
        let oldEntryExist = false;
        let newEntryExist = false;
        const oldEntryUrl = oldEntry.toURL();
        const newEntryUrl = newEntry.toURL();

        for (let i = 0; i < list.length; i++) {
          const item = list.item(i)!;
          const url = item.toURL();
          if (url === oldEntryUrl) {
            list.replaceItem(item, newEntry);
            oldEntryExist = true;
            break;
          }

          if (url === newEntryUrl) {
            newEntryExist = true;
          }
        }

        // When both old and new entries don't exist, it may be in the middle of
        // update process. In DirectoryContent.update deletion is executed at
        // first and insertion is executed as a async call. There is a chance
        // that this method is called in the middle of update process.
        if (!oldEntryExist && !newEntryExist) {
          list.push(newEntry);
        }

        resolve();
      });
    });
  }

  /**
   * Updates data model and selects new directory.
   * @param newDirectory Directory entry to be selected.
   * @return A promise which is resolved when new directory is selected. If
   *     current directory has changed during the operation, this will be
   *     rejected.
   */
  async updateAndSelectNewDirectory(newDirectory: DirectoryEntry):
      Promise<void> {
    // Refresh the cache.
    this.metadataModel_.notifyEntriesCreated([newDirectory]);
    const dirContents = this.currentDirContents_;
    const sequence = this.changeDirectorySequence_;
    await new Promise(resolve => {
      dirContents.prefetchMetadata([newDirectory], false, resolve);
    });

    // If current directory has changed during the prefetch, do not try to
    // select new directory.
    if (sequence !== this.changeDirectorySequence_) {
      return Promise.reject();
    }

    // If target directory is already in the list, just select it.
    const existing =
        this.getFileList().slice().filter(e => e.name === newDirectory.name);
    if (existing.length) {
      this.selectEntry(newDirectory);
    } else {
      this.fileListSelection_.beginChange();
      this.getFileList().splice(0, 0, newDirectory);
      this.selectEntry(newDirectory);
      this.fileListSelection_.endChange();
    }
  }

  /**
   * Gets the current MyFilesEntry.
   */
  getMyFiles(): null|FilesAppDirEntry {
    const {myFilesEntry} = getMyFiles(getStore().getState());
    return myFilesEntry;
  }

  async changeDirectoryFileData(fileData: FileData): Promise<boolean> {
    if (fileData.entry) {
      const result = await new Promise<boolean>(resolve => {
        this.changeDirectoryEntry(
            fileData.entry as DirectoryEntry, (ret) => resolve(ret));
      });
      return result;
    }

    // Increment the sequence value.
    const sequence = ++this.changeDirectorySequence_;
    this.stopActiveSearch_();

    // If there is on-going scan, cancel it.
    if (this.currentDirContents_.isScanning()) {
      this.currentDirContents_.cancelScan();
    }

    const unlock = await this.directoryChangeQueue_.lock();
    try {
      // Remove the watcher for the previous entry.
      await this.fileWatcher_.resetWatchedEntry();
      if (this.changeDirectorySequence_ !== sequence) {
        return false;
      }

      const newDirectoryContents = this.createDirectoryContents_(
          this.currentFileListContext_, undefined, fileData.key, '');
      if (!newDirectoryContents) {
        return false;
      }

      const previousDirEntry = this.currentDirContents_.getDirectoryEntry();
      const previousFileKey = this.currentDirContents_.getFileKey();
      const r = await new Promise<boolean>(
          resolve => this.clearAndScan_(newDirectoryContents, resolve));
      if (!r) {
        return r;
      }

      this.finalizeChangeDirectory_(
          previousDirEntry, previousFileKey, undefined, fileData.key);
    } catch (error) {
    } finally {
      unlock();
    }

    return true;
  }

  /**
   * Changes the current directory to the directory represented by
   * a DirectoryEntry or a fake entry.
   *
   * Dispatches the 'directory-changed' event when the directory is successfully
   * changed.
   *
   * Note : if this is called from UI, please consider to use DirectoryModel.
   * activateDirectoryEntry instead of this, which is higher-level function and
   * cares about the selection.
   *
   * @param dirEntry The entry of the new directory to be opened.
   * @param callback Executed if the directory loads successfully.
   */
  changeDirectoryEntry(
      dirEntry: DirectoryEntry|FilesAppDirEntry,
      callback?: (result: boolean) => void) {
    // Increment the sequence value.
    const sequence = ++this.changeDirectorySequence_;
    this.stopActiveSearch_();

    // When switching to MyFiles volume, we should use a FilesAppEntry if
    // available because it returns UI-only entries too, like Linux files and
    // Play files.
    const locationInfo = this.volumeManager_.getLocationInfo(dirEntry);
    if (locationInfo && locationInfo.rootType === RootType.DOWNLOADS &&
        locationInfo.isRootEntry) {
      dirEntry = this.getMyFiles()!;
    }

    // If there is on-going scan, cancel it.
    if (this.currentDirContents_.isScanning()) {
      this.currentDirContents_.cancelScan();
    }

    this.directoryChangeQueue_.run(async queueTaskCallback => {
      await this.fileWatcher_.changeWatchedDirectory(dirEntry);
      if (this.changeDirectorySequence_ !== sequence) {
        queueTaskCallback();
        return;
      }

      const newDirectoryContents = this.createDirectoryContents_(
          this.currentFileListContext_, dirEntry, dirEntry.toURL(), '');
      if (!newDirectoryContents) {
        queueTaskCallback();
        return;
      }

      const previousDirEntry = this.currentDirContents_.getDirectoryEntry();
      const previousFileKey = this.currentDirContents_.getFileKey();
      this.clearAndScan_(newDirectoryContents, result => {
        // Calls the callback of the method and inform it about success or lack
        // of thereof.
        if (callback) {
          callback(result);
        }
        // Notify that the current task of this.directoryChangeQueue_
        // is completed.
        setTimeout(queueTaskCallback, 0);
      });

      this.finalizeChangeDirectory_(
          previousDirEntry, previousFileKey, dirEntry, dirEntry.toURL());
    });
  }

  private async finalizeChangeDirectory_(
      previousDirEntry: UniversalDirectory|undefined,
      previousFileKey: FileKey|undefined,
      newDirectory: UniversalDirectory|undefined, fileKey: FileKey) {
    // For tests that open the dialog to empty directories, everything
    // is loaded at this point.
    testSendMessage('directory-change-complete');
    const previousVolumeInfo = previousDirEntry ?
        this.volumeManager_.getVolumeInfo(previousDirEntry) :
        null;
    // VolumeInfo for dirEntry.
    const currentVolumeInfo = this.getCurrentVolumeInfo();
    const event = new CustomEvent('directory-changed', {
      detail: {
        previousDirEntry,
        previousFileKey: previousFileKey,
        newDirEntry: newDirectory,
        newFileKey: fileKey,
        volumeChanged: (previousVolumeInfo !== currentVolumeInfo),
      },
    });
    await currentVolumeInfo?.resolveDisplayRoot();
    this.dispatchEvent(event);
    if (previousDirEntry) {
      // If we changed from a directory to another directory always clear
      // search and search query on directory change. When the Files app is
      // started previousDirEntry is undefined. For that case we must not
      // clear the search query as it may be part of the starting parameters.
      this.cachedSearch_ = {};
      this.store_.dispatch(clearSearch());
      this.clearLastSearchQuery();
    }
    // Notify the Store that the new directory has successfully changed.
    this.store_.dispatch(changeDirectory({
      to: newDirectory,
      toKey: fileKey,
      status: PropStatus.SUCCESS,
    }));
  }

  /**
   * Activates the given directory.
   * This method:
   *  - Changes the current directory, if the given directory is not the current
   * directory.
   *  - Clears the selection, if the given directory is the current directory.
   *
   * @param dirEntry The entry of the new directory to be opened.
   * @param callback Executed if the directory loads successfully.
   */
  activateDirectoryEntry(
      dirEntry: DirectoryEntry|FilesAppDirEntry, callback?: VoidCallback) {
    const currentDirectoryEntry = this.getCurrentDirEntry();
    if (currentDirectoryEntry && isSameEntry(dirEntry, currentDirectoryEntry)) {
      // On activating the current directory, clear the selection on the
      // filelist.
      this.clearSelection();
    } else {
      // Otherwise, changes the current directory.
      this.changeDirectoryEntry(dirEntry, callback);
    }
  }

  /**
   * Clears the selection in the file list.
   */
  clearSelection() {
    this.setSelectedEntries_([]);
  }

  /**
   * Creates an object which could say whether directory has changed while it
   * has been active or not. Designed for long operations that should be
   * cancelled if the used change current directory.
   * @return Created object.
   */
  createDirectoryChangeTracker(): DirectoryChangeTracker {
    const tracker = {
      dm_: this,
      active_: false,
      hasChanged: false,

      start: function() {
        if (!this.active_) {
          this.dm_.numChangeTrackerRunning_++;
          this.dm_.addEventListener(
              'directory-changed', this.onDirectoryChange_);
          this.active_ = true;
          this.hasChanged = false;
        }
      },

      stop: function() {
        if (this.active_) {
          this.dm_.numChangeTrackerRunning_--;
          this.dm_.removeEventListener(
              'directory-changed', this.onDirectoryChange_);
          this.active_ = false;
        }
      },

      onDirectoryChange_: function(_event: Event) {
        tracker.stop();
        tracker.hasChanged = true;
      },
    };
    return tracker;
  }

  /**
   * @param entry Entry to be selected.
   */
  selectEntry(entry: Entry) {
    const fileList = this.getFileList();
    for (let i = 0; i < fileList.length; i++) {
      if (fileList.item(i)!.toURL() === entry.toURL()) {
        this.selectIndex(i);
        return;
      }
    }
  }

  /**
   * @param entries Array of entries.
   */
  selectEntries(entries: Entry[]) {
    // URLs are needed here, since we are comparing Entries by URLs.
    const urls = entriesToURLs(entries);
    const fileList = this.getFileList();
    this.fileListSelection_.beginChange();
    this.fileListSelection_.unselectAll();
    for (let i = 0; i < fileList.length; i++) {
      if (urls.indexOf(fileList.item(i)!.toURL()) >= 0) {
        this.fileListSelection_.setIndexSelected(i, true);
      }
    }
    this.fileListSelection_.endChange();
  }

  /**
   * @param index Index of file.
   */
  selectIndex(index: number) {
    if (index >= this.getFileList().length) {
      return;
    }

    // If a list bound with the model it will do scrollIndexIntoView(index).
    this.fileListSelection_.selectedIndex = index;
  }

  /**
   * Handles update of VolumeInfoList.
   * @param event Event of VolumeInfoList's 'splice'.
   */
  private onVolumeInfoListUpdated_(event: SpliceEvent) {
    const spliceEventDetail = event.detail;
    // Fallback to the default volume's root if the current volume is unmounted.
    if (this.hasCurrentDirEntryBeenUnmounted_(spliceEventDetail.removed)) {
      this.volumeManager_.getDefaultDisplayRoot().then((displayRoot) => {
        if (displayRoot) {
          this.changeDirectoryEntry(displayRoot);
        }
      });
    }

    // If a volume within My files or removable root is mounted/unmounted rescan
    // its contents.
    const currentDir = this.getCurrentDirEntry();
    const affectedVolumes =
        spliceEventDetail.added.concat(spliceEventDetail.removed);
    for (const volume of affectedVolumes) {
      if (isSameEntry(currentDir, volume.prefixEntry)) {
        this.rescan(false);
        break;
      }
    }

    // If the current directory is the Drive placeholder and the real Drive is
    // mounted, switch to it.
    if (this.getCurrentRootType() === RootType.DRIVE_FAKE_ROOT) {
      for (const newVolume of spliceEventDetail.added) {
        if (newVolume.volumeType === VolumeType.DRIVE) {
          newVolume.resolveDisplayRoot().then((displayRoot: DirectoryEntry) => {
            this.changeDirectoryEntry(displayRoot);
          });
        }
      }
    }

    // If the current directory is the OneDrive placeholder and the real
    // OneDrive is mounted, switch to it.
    if (isSkyvaultV2Enabled() && currentDir &&
        isOneDrivePlaceholder(currentDir)) {
      for (const newVolume of spliceEventDetail.added) {
        if (isOneDrive(newVolume)) {
          newVolume.resolveDisplayRoot().then((displayRoot: DirectoryEntry) => {
            this.changeDirectoryEntry(displayRoot);
          });
        }
      }
    }

    if (spliceEventDetail.added.length !== 1) {
      return;
    }
    // Redirect to newly mounted volume when:
    // * There is no directory currently selected, meaning it's the first volume
    //   to appear.
    // * A new file backed provided volume is mounted, then redirect to it in
    //   the focused window, because this means a zip file has been mounted.
    //   Note, that this is a temporary solution for https://crbug.com/427776.
    // * Crostini is mounted, redirect if it is the currently selected dir.
    if (!currentDir ||
        (window.isFocused && window.isFocused() &&
         spliceEventDetail.added[0].volumeType === VolumeType.PROVIDED &&
         spliceEventDetail.added[0].source === Source.FILE) ||
        (spliceEventDetail.added[0].volumeType === VolumeType.CROSTINI &&
         this.getCurrentRootType() === RootType.CROSTINI) ||
        // TODO(crbug/1293229): Don't redirect if the user is looking at a
        // different Guest OS folder.
        (isGuestOs(spliceEventDetail.added[0].volumeType) &&
         this.getCurrentRootType() === RootType.GUEST_OS)) {
      // Resolving a display root on FSP volumes is instant, despite the
      // asynchronous call.
      spliceEventDetail.added[0].resolveDisplayRoot().then(
          (_displayRoot: DirectoryEntry) => {
            // Only change directory if "currentDir" hasn't changed during the
            // display root resolution and if there isn't a directory change in
            // progress, because other part of the system will eventually change
            // the directory.
            if (currentDir === this.getCurrentDirEntry() &&
                this.numChangeTrackerRunning_ === 0) {
              this.changeDirectoryEntry(spliceEventDetail.added[0].displayRoot);
            }
          });
    }
  }

  /**
   * Returns whether the current directory entry has been unmounted.
   *
   * @param removedVolumes The removed volumes.
   */
  private hasCurrentDirEntryBeenUnmounted_(removedVolumes: VolumeInfo[]):
      boolean {
    const entry = this.getCurrentDirEntry();
    if (!entry) {
      return false;
    }

    if (!isFakeEntry(entry)) {
      return !this.volumeManager_.getVolumeInfo(entry);
    }

    const rootType = this.getCurrentRootType();
    for (const volume of removedVolumes) {
      if (rootType && volume.fakeEntries[rootType]) {
        return true;
      }
      // The removable root is selected and one of its child partitions has been
      // unmounted.
      if (volume.prefixEntry === entry) {
        return true;
      }
    }
    return false;
  }

  /**
   * Returns true if directory search should be used for the entry and query.
   *
   * @param entry Directory entry.
   * @param query Search query string.
   * @return True if directory search should be used for the entry and query.
   */
  isSearchDirectory(entry?: DirectoryEntry|FilesAppEntry, query?: string):
      boolean {
    if (!entry) {
      return !!query;
    }
    const rootType = getRootType(entry);
    if (isRecentRootType(rootType) || rootType === RootType.CROSTINI ||
        rootType === RootType.DRIVE_FAKE_ROOT) {
      return true;
    }
    if (rootType === RootType.MY_FILES) {
      return false;
    }

    if ((query || '').trimStart()) {
      return true;
    }

    const locationInfo = this.volumeManager_.getLocationInfo(entry);
    if (locationInfo &&
        (locationInfo.rootType === RootType.MEDIA_VIEW ||
         locationInfo.isSpecialSearchRoot)) {
      return true;
    }
    return false;
  }

  /**
   * Creates scanner factory for the entry and query.
   *
   * @param entry Directory entry.
   * @param query Search query string.
   * @param options search options.
   * @return The factory to create ContentScanner instance.
   */
  createScannerFactory(
      fileKey: FileKey, entry?: DirectoryEntry|FilesAppEntry, query?: string,
      options?: SearchOptions): () => ContentScanner {
    if (!entry && !!fileKey) {
      // Store-based scanner, it doesn't use Entry. E.g: Materialized View.
      return () => {
        return new StoreScanner(fileKey);
      };
    }

    assert(entry);

    const sanitizedQuery = (query || '').trimStart();
    const locationInfo = this.volumeManager_.getLocationInfo(entry);

    if (isRecentScan(entry, options)) {
      return () => {
        return new RecentContentScanner(
            sanitizedQuery, 30, this.volumeManager_, entry.sourceRestriction,
            getFileCategory(entry, sanitizedQuery, options));
      };
    }
    // TODO(b/271485133): Make sure the entry here is a fake entry, not real
    // volume entry.
    const rootType = getRootType(entry);
    if (rootType === RootType.CROSTINI) {
      return () => {
        return new CrostiniMounter();
      };
    }
    if (rootType === RootType.GUEST_OS) {
      return () => {
        return new GuestOsMounter((entry as GuestOsPlaceholder).guest_id);
      };
    }
    if (rootType === RootType.MY_FILES) {
      return () => {
        return new DirectoryContentScanner(entry as FilesAppDirEntry);
      };
    }
    if (rootType === RootType.DRIVE_FAKE_ROOT) {
      return () => {
        return new EmptyContentScanner();
      };
    }
    if (rootType === RootType.TRASH) {
      return () => {
        return new TrashContentScanner(this.volumeManager_);
      };
    }
    if (isOneDrivePlaceholder(entry)) {
      return () => {
        return new EmptyContentScanner();
      };
    }
    if (sanitizedQuery) {
      return () => {
        return new SearchV2ContentScanner(
            this.volumeManager_,
            // TODO(b/289003444): Fix this cast.
            entry as DirectoryEntry | FilesAppDirEntry, sanitizedQuery,
            options || getDefaultSearchOptions());
      };
    }
    if (locationInfo && locationInfo.rootType === RootType.MEDIA_VIEW) {
      return () => {
        return new MediaViewContentScanner(entry as DirectoryEntry);
      };
    }
    if (locationInfo && locationInfo.isRootEntry &&
        locationInfo.isSpecialSearchRoot) {
      // Drive special search.
      let searchType: chrome.fileManagerPrivate.SearchType;
      switch (locationInfo.rootType) {
        case RootType.DRIVE_OFFLINE:
          searchType = chrome.fileManagerPrivate.SearchType.OFFLINE;
          break;
        case RootType.DRIVE_SHARED_WITH_ME:
          searchType = chrome.fileManagerPrivate.SearchType.SHARED_WITH_ME;
          break;
        case RootType.DRIVE_RECENT:
          searchType = chrome.fileManagerPrivate.SearchType.EXCLUDE_DIRECTORIES;
          break;
        default:
          // Unknown special search entry.
          throw new Error('Unknown special search type.');
      }
      return () => {
        return new DriveMetadataSearchContentScanner(searchType);
      };
    }
    // Local fetch or search.
    return () => {
      return new DirectoryContentScanner(entry as DirectoryEntry);
    };
  }

  /**
   * Creates directory contents for the entry and query.
   *
   * @param context File list context.
   * @param entry Current directory.
   * @param query Search query string.
   * @param options Search options.
   * @return Directory contents.
   */
  private createDirectoryContents_(
      context: FileListContext, entry?: DirectoryEntry|FilesAppDirEntry,
      fileKey?: FileKey, query?: string,
      options?: SearchOptions): DirectoryContents {
    if (!entry && !fileKey) {
      console.error('`fileKey` or `entry` must be provided');
    }

    const isSearch = this.isSearchDirectory(entry, query);
    const key = fileKey ?? entry!.toURL();
    const scannerFactory =
        this.createScannerFactory(key, entry, query, options);
    return new DirectoryContents(context, isSearch, entry, key, scannerFactory);
  }

  /**
   * Gets the last search query.
   * @return the last search query.
   */
  getLastSearchQuery(): string {
    return this.lastSearchQuery_;
  }

  /**
   * Clears the last search query with the empty string.
   */
  clearLastSearchQuery() {
    this.lastSearchQuery_ = '';
  }

  /**
   * Performs search and displays results. The search type is dependent on the
   * current directory. If we are currently on drive, server side content search
   * over drive mount point. If the current directory is not on the drive, file
   * name search over current directory will be performed.
   *
   * @param query Query that will be searched for.
   * @param options Search options, such as file type, etc.
   */
  private search_(query: string, options: SearchOptions) {
    this.lastSearchQuery_ = query;
    this.stopActiveSearch_();
    const currentDirEntry = this.getCurrentDirEntry();
    if (!currentDirEntry) {
      // Not yet initialized. Do nothing.
      return;
    }

    const sequence = ++this.changeDirectorySequence_;
    this.directoryChangeQueue_.run(callback => {
      if (this.changeDirectorySequence_ !== sequence) {
        callback();
        return;
      }

      assert(currentDirEntry);
      if (!(query || '').trimStart()) {
        if (this.isSearching()) {
          const newDirContents = this.createDirectoryContents_(
              this.currentFileListContext_, currentDirEntry,
              currentDirEntry.toURL());
          this.clearAndScan_(newDirContents, callback);
        } else {
          callback();
        }
        return;
      }

      const newDirContents = this.createDirectoryContents_(
          this.currentFileListContext_, currentDirEntry,
          currentDirEntry.toURL(), query, options);
      if (!newDirContents) {
        callback();
        return;
      }

      this.store_.dispatch(updateSearch(
          {query: query, status: PropStatus.STARTED, options: undefined}));
      this.onSearchCompleted_ = () => {
        // Notify the store-aware parts.
        this.store_.dispatch(updateSearch({
          status: PropStatus.SUCCESS,
          query: undefined,
          options: undefined,
        }));
      };
      this.addEventListener('cur-dir-scan-completed', this.onSearchCompleted_);
      this.clearAndScan_(newDirContents, callback);
    });
  }

  /**
   * In case the search was active, remove listeners and send notifications on
   * its canceling.
   */
  private stopActiveSearch_() {
    if (!this.isSearching()) {
      return;
    }

    if (this.onSearchCompleted_) {
      this.removeEventListener(
          'cur-dir-scan-completed', this.onSearchCompleted_);
      this.onSearchCompleted_ = null;
    }
  }

  /**
   * Update the file list when certain IO task is finished. To keep the file
   * list refresh for non-watchable fake directory entries and volumes, we need
   * to explicitly subscribe to the IO task status event, and manually refresh.
   */
  private updateFileListAfterIoTask_(
      event: chrome.fileManagerPrivate.ProgressStatus) {
    let rescan = false;
    const fakeDirectoryEntryRootTypes = new Set([
      RootType.RECENT,
      RootType.TRASH,
    ]);
    const currentRootType = this.getCurrentRootType();
    const currentVolumeInfo = this.getCurrentVolumeInfo();
    if (currentRootType && fakeDirectoryEntryRootTypes.has(currentRootType)) {
      // Refresh if non-watchable fake directory entry.
      rescan = true;
    } else if (currentVolumeInfo && !currentVolumeInfo.watchable) {
      // Refresh if non-watchable volume.
      rescan = true;
    }
    if (!rescan) {
      return;
    }
    const isIOTaskFinished =
        event.state === chrome.fileManagerPrivate.IoTaskState.SUCCESS;
    if (isIOTaskFinished) {
      this.rescanLater(/* refresh= */ false, /* invalidateCache= */ true);
    }
  }
}