chromium/ui/file_manager/file_manager/state/ducks/all_entries.ts

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

import {assert} from 'chrome://resources/js/assert.js';

import type {EntryLocation} from '../../background/js/entry_location_impl.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import {getParentEntry} from '../../common/js/api.js';
import {canHaveSubDirectories, isDirectoryEntry, isDriveRootEntryList, isEntryScannable, isEntrySupportUiChildren, isFakeEntryInDrive, isGrandRootEntryInDrive, isInsideDrive, isVolumeEntry, isVolumeFileData, readEntries, shouldSupportDriveSpecificIcons, sortEntries, supportsUiChildren, urlToEntry} from '../../common/js/entry_utils.js';
import {getIcon} from '../../common/js/file_type.js';
import type {FilesAppDirEntry, FilesAppEntry, VolumeEntry} from '../../common/js/files_app_entry_types.js';
import {EntryList} from '../../common/js/files_app_entry_types.js';
import {isSkyvaultV2Enabled} from '../../common/js/flags.js';
import {recordInterval, recordSmallCount, startInterval} from '../../common/js/metrics.js';
import {getEntryLabel, str} from '../../common/js/translations.js';
import {iconSetToCSSBackgroundImageValue} from '../../common/js/util.js';
import {COMPUTERS_DIRECTORY_PATH, RootType, SHARED_DRIVES_DIRECTORY_PATH, shouldProvideIcons, Source, VolumeType} from '../../common/js/volume_manager_types.js';
import {ACTIONS_MODEL_METADATA_PREFETCH_PROPERTY_NAMES, DLP_METADATA_PREFETCH_PROPERTY_NAMES, FILE_SELECTION_METADATA_PREFETCH_PROPERTY_NAMES, ICON_TYPES, LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES} from '../../foreground/js/constants.js';
import type {MetadataItem} from '../../foreground/js/metadata/metadata_item.js';
import type {ActionsProducerGen} from '../../lib/actions_producer.js';
import {isDebugStoreEnabled, Slice} from '../../lib/base_store.js';
import {keepLatest, keyedKeepLatest} from '../../lib/concurrency_models.js';
import {type CurrentDirectory, EntryType, type FileData, type MaterializedView, type State, type Volume, type VolumeMap} from '../../state/state.js';
import type {FileKey} from '../file_key.js';
import {getEntry, getFileData, getStore, getVolume} from '../store.js';

import {hasDlpDisabledFiles} from './current_directory.js';
import {driveRootEntryListKey, myFilesEntryListKey, recentRootKey} from './volumes.js';

/**
 * @fileoverview Entries slice of the store.
 */

const slice = new Slice<State, State['allEntries']>('allEntries');
export {slice as allEntriesSlice};

/**
 * Create action to scan `allEntries` and remove its stale entries.
 */
export const clearCachedEntries =
    slice.addReducer('clear-stale-cache', clearCachedEntriesReducer);

function clearCachedEntriesReducer(state: State): State {
  const entries = state.allEntries;
  const currentDirectoryKey = state.currentDirectory?.key;
  const entriesToKeep = new Set<string>();

  if (currentDirectoryKey) {
    entriesToKeep.add(currentDirectoryKey);

    for (const component of state.currentDirectory!.pathComponents) {
      entriesToKeep.add(component.key);
    }

    for (const key of state.currentDirectory!.content.keys) {
      entriesToKeep.add(key);
    }
  }
  const selectionKeys = state.currentDirectory?.selection.keys ?? [];
  if (selectionKeys) {
    for (const key of selectionKeys) {
      entriesToKeep.add(key);
    }
  }

  for (const volume of Object.values<Volume>(state.volumes)) {
    if (!volume.rootKey) {
      continue;
    }
    entriesToKeep.add(volume.rootKey);
    if (volume.prefixKey) {
      entriesToKeep.add(volume.prefixKey);
    }
  }

  for (const key of state.uiEntries) {
    entriesToKeep.add(key);
  }

  for (const key of state.folderShortcuts) {
    entriesToKeep.add(key);
  }

  for (const root of state.navigation.roots) {
    entriesToKeep.add(root.key);
  }

  // For all expanded entries, we need to keep them and all their direct
  // children.
  for (const fileData of Object.values(entries)) {
    if (fileData.expanded) {
      if (fileData.children) {
        for (const child of fileData.children) {
          entriesToKeep.add(child);
        }
      }
    }
  }

  // For all kept entries, we also need to keep their children so we can decide
  // if we need to show the expand icon or not.
  for (const key of entriesToKeep) {
    const fileData = entries[key];
    if (fileData?.children) {
      for (const child of fileData.children) {
        entriesToKeep.add(child);
      }
    }
  }

  for (const view of state.materializedViews) {
    entriesToKeep.add(view.key);
  }

  const isDebugStore = isDebugStoreEnabled();
  for (const key of Object.keys(entries)) {
    if (entriesToKeep.has(key)) {
      continue;
    }

    delete entries[key];
    if (isDebugStore) {
      console.log(`Clear entry: ${key}`);
    }
  }

  return state;
}

/**
 * Schedules the routine to remove stale entries from `allEntries`.
 */
function scheduleClearCachedEntries() {
  if (clearCachedEntriesRequestId === 0) {
    // For unittest force to run at least at 50ms to avoid flakiness on slow
    // bots (msan).
    const options = window.IN_TEST ? {timeout: 50} : {};
    clearCachedEntriesRequestId = requestIdleCallback(startClearCache, options);
  }
}

/** ID for the current scheduled `clearCachedEntries`. */
let clearCachedEntriesRequestId = 0;

/** Starts the action CLEAR_STALE_CACHED_ENTRIES.  */
function startClearCache() {
  const store = getStore();
  store.dispatch(clearCachedEntries());
  clearCachedEntriesRequestId = 0;
}

const prefetchPropertyNames = Array.from(new Set([
  ...LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES,
  ...ACTIONS_MODEL_METADATA_PREFETCH_PROPERTY_NAMES,
  ...FILE_SELECTION_METADATA_PREFETCH_PROPERTY_NAMES,
  ...DLP_METADATA_PREFETCH_PROPERTY_NAMES,
]));

/** Get the icon for an entry. */
function getEntryIcon(
    entry: Entry|FilesAppEntry, locationInfo: EntryLocation|null,
    volumeType: VolumeType|null): FileData['icon'] {
  const url = entry.toURL();

  // Pre-defined icons based on the URL.
  const urlToIconPath: Record<FileKey, string> = {
    [recentRootKey]: ICON_TYPES.RECENT,
    [myFilesEntryListKey]: ICON_TYPES.MY_FILES,
    [driveRootEntryListKey]: ICON_TYPES.SERVICE_DRIVE,
  };

  if (urlToIconPath[url]) {
    return urlToIconPath[url]!;
  }

  // Handle icons for grand roots ("Shared drives" and "Computers") in Drive.
  // Here we can't just use `fullPath` to check if an entry is a grand root or
  // not, because normal directory can also have the same full path. We also
  // need to check if the entry is a direct child of the drive root entry list.
  const grandRootPathToIconMap = {
    [COMPUTERS_DIRECTORY_PATH]: ICON_TYPES.COMPUTERS_GRAND_ROOT,
    [SHARED_DRIVES_DIRECTORY_PATH]: ICON_TYPES.SHARED_DRIVES_GRAND_ROOT,
  };
  if (volumeType === VolumeType.DRIVE &&
      grandRootPathToIconMap[entry.fullPath]) {
    return grandRootPathToIconMap[entry.fullPath]!;
  }

  // For grouped removable devices, its parent folder is an entry list, we
  // should use USB icon for it.
  if ('rootType' in entry && entry.rootType === RootType.REMOVABLE) {
    return ICON_TYPES.USB;
  }

  if (isVolumeEntry(entry) && entry.volumeInfo) {
    switch (entry.volumeInfo.volumeType) {
      case VolumeType.DOWNLOADS:
        return ICON_TYPES.MY_FILES;
      case VolumeType.SMB:
        return ICON_TYPES.SMB;
      case VolumeType.PROVIDED:
      // Fallthrough
      case VolumeType.DOCUMENTS_PROVIDER: {
        // Only return IconSet if there's valid background image generated.
        const iconSet = entry.volumeInfo.iconSet;
        if (iconSet) {
          const backgroundImage =
              iconSetToCSSBackgroundImageValue(entry.volumeInfo.iconSet);
          if (backgroundImage !== 'none') {
            return iconSet;
          }
        }
        // If no background is generated from IconSet, set the icon to the
        // generic one for certain volume type.
        if (volumeType && shouldProvideIcons(volumeType)) {
          return ICON_TYPES.GENERIC;
        }
        return '';
      }
      case VolumeType.MTP:
        return ICON_TYPES.MTP;
      case VolumeType.ARCHIVE:
        return ICON_TYPES.ARCHIVE;
      case VolumeType.REMOVABLE:
        // For sub-partition from a removable volume, its children icon should
        // be UNKNOWN_REMOVABLE.
        return entry.volumeInfo.prefixEntry ? ICON_TYPES.UNKNOWN_REMOVABLE :
                                              ICON_TYPES.USB;
      case VolumeType.DRIVE:
        return ICON_TYPES.DRIVE;
    }
  }

  return getIcon(entry as Entry, undefined, locationInfo?.rootType);
}

/**
 * Given fileData, check if its loading children should be delayed.
 * We are doing this for SMB to avoid potentially hanging whilst scanning a
 * large SMB file share and causing performance issues.
 */
export function shouldDelayLoadingChildren(
    fileData: FileData, state: State): boolean {
  // When this function is triggered when mounting new volumes, volumeInfo is
  // not available in the VolumeManager yet, we need to get volumeInfo from the
  // entry itself.
  const volume: Volume|VolumeInfo|undefined = isVolumeFileData(fileData) ?
      // TODO: Confirm how to remove the usage of entry here.
      (fileData.entry as VolumeEntry).volumeInfo :
      state!.volumes[fileData.volumeId!];
  return isVolumeSlowToScan(volume);
}

function isVolumeSlowToScan(volume?: Volume|VolumeInfo|null): boolean {
  return volume?.source === Source.NETWORK &&
      volume.volumeType === VolumeType.SMB;
}

function convertViewToFileData(view: MaterializedView): FileData {
  const metadata: MetadataItem = {};
  const fileData: FileData = {
    key: view.key,
    fullPath: new URL(view.key).pathname,
    icon: view.icon,
    type: EntryType.MATERIALIZED_VIEW,
    isDirectory: true,
    label: view.label,
    volumeId: null,
    rootType: null,
    metadata,
    expanded: false,
    disabled: false,
    isRootEntry: view.isRoot,
    canExpand: true,
    isEjectable: false,
    children: [],
  };
  return fileData;
}

/**
 * Converts the entry to the Store representation of an Entry: FileData.
 */
export function convertEntryToFileData(entry: Entry|FilesAppEntry): FileData {
  const {volumeManager, metadataModel} = window.fileManager;
  // When this function is triggered when mounting new volumes, volumeInfo is
  // not available in the VolumeManager yet, we need to get volumeInfo from the
  // entry itself.
  const volumeInfo = isVolumeEntry(entry) ? entry.volumeInfo :
                                            volumeManager.getVolumeInfo(entry);
  const locationInfo = volumeManager.getLocationInfo(entry);
  const label = getEntryLabel(locationInfo, entry);
  // For FakeEntry, we need to read from entry.volumeType because it doesn't
  // have volumeInfo in the volume manager.
  const volumeType = 'volumeType' in entry && entry.volumeType ?
      entry.volumeType as VolumeType :
      (volumeInfo?.volumeType || null);
  const volumeId = volumeInfo?.volumeId || null;
  const icon = getEntryIcon(entry, locationInfo, volumeType);

  /**
   * Update disabled attribute if entry supports disabled attribute and has a
   * non-null volumeType.
   */
  if ('disabled' in entry && volumeType) {
    entry.disabled = volumeManager.isDisabled(volumeType);
  }

  const metadata = metadataModel ?
      metadataModel.getCache([entry as FileEntry], prefetchPropertyNames)[0]! :
      {} as MetadataItem;

  const fileData: FileData = {
    key: entry.toURL(),
    fullPath: entry.fullPath,
    entry,
    icon,
    type: getEntryType(entry),
    isDirectory: entry.isDirectory,
    label,
    volumeId,
    rootType: locationInfo?.rootType ?? null,
    metadata,
    expanded: false,
    disabled: 'disabled' in entry ? entry.disabled as boolean : false,
    isRootEntry: !!locationInfo?.isRootEntry,
    canExpand: false,
    // `isEjectable` is determined by its corresponding volume, will be updated
    // when volume is added.
    isEjectable: false,
    children: [],
  };
  // For slow volumes, we always mark the root and directories as canExpand, to
  // avoid scanning to determine if it has sub-directories.
  fileData.canExpand = isVolumeSlowToScan(volumeInfo);
  return fileData;
}

function appendView(state: State, view: MaterializedView) {
  const allEntries = state.allEntries || {};
  const key = view.key;
  const fileData = convertViewToFileData(view)!;
  const existingFileData: Partial<FileData> = allEntries[key] || {};

  allEntries[key] = {
    ...fileData,
    expanded: existingFileData.expanded ?? fileData.expanded,
    isEjectable: existingFileData.isEjectable ?? fileData.isEjectable,
    canExpand: existingFileData.canExpand ?? fileData.canExpand,
    // Keep children to prevent sudden removal of the children items on the UI.
    children: existingFileData.children ?? fileData.children,
    key,
  };

  state.allEntries = allEntries;
}

/**
 * Converts an EntryData object from FileManagerPrivate API to the store
 * representation of an Entry: FileData.
 */
export async function convertEntryDataToFileData(
    entryData: chrome.fileManagerPrivate.EntryData): Promise<FileData> {
  const nativeEntry = await urlToEntry(entryData.entryUrl);
  // TODO(b/328564447): This function should only rely on `entryData`, so
  // gradually update the returned FileData to only use fields from `entryData`.
  const nativeEntryFileData = convertEntryToFileData(nativeEntry);
  return {
    key: entryData.entryUrl,
    fullPath: nativeEntryFileData.fullPath,
    entry: nativeEntry,
    icon: nativeEntryFileData.icon,
    label: nativeEntryFileData.label,
    volumeId: nativeEntryFileData.volumeId,
    rootType: nativeEntryFileData.rootType,
    metadata: nativeEntryFileData.metadata,
    isDirectory: nativeEntryFileData.isDirectory,
    type: EntryType.FS_API,
    isRootEntry: nativeEntryFileData.isRootEntry,
    isEjectable: false,
    canExpand: nativeEntryFileData.canExpand,
    children: [],
    expanded: false,
    disabled: nativeEntryFileData.disabled,
  };
}

/**
 * Appends the entry to the Store.
 */
function appendEntry(state: State, entry: Entry|FilesAppEntry) {
  const allEntries = state.allEntries || {};
  const key = entry.toURL();
  const existingFileData: Partial<FileData> = allEntries[key] || {};

  // Some client code might dispatch actions based on
  // `volume.resolveDisplayRoot()` which is a DirectoryEntry instead of a
  // VolumeEntry. It's safe to ignore this entry because the data will be the
  // same as `existingFileData` and we don't want to convert from VolumeEntry to
  // DirectoryEntry.
  if (existingFileData.type === EntryType.VOLUME_ROOT &&
      getEntryType(entry) !== EntryType.VOLUME_ROOT) {
    return;
  }

  const fileData = convertEntryToFileData(entry)!;

  allEntries[key] = {
    ...fileData,
    // For existing entries already in the store, we want to keep the existing
    // value for the following fields. For example, for "expanded" entries with
    // expanded=true, we don't want to override it with expanded=false derived
    // from `convertEntryToFileData` function above.
    expanded: existingFileData.expanded ?? fileData.expanded,
    isEjectable: existingFileData.isEjectable ?? fileData.isEjectable,
    canExpand: existingFileData.canExpand ?? fileData.canExpand,
    // Keep children to prevent sudden removal of the children items on the UI.
    children: existingFileData.children ?? fileData.children,
    key,
  };

  state.allEntries = allEntries;
}

/**
 * Updates `FileData` from a `FileKey`.
 *
 * Note: the state will be updated in place.
 */
export function updateFileDataInPlace(
    state: State, key: FileKey, changes: Partial<FileData>): FileData|
    undefined {
  if (!state.allEntries[key]) {
    console.warn(`Entry FileData not found in the store: ${key}`);
    return;
  }
  const newFileData = {
    ...state.allEntries[key]!,
    ...changes,
    key,
  };
  state.allEntries[key] = newFileData;
  return newFileData;
}

/** Caches the Action's entry in the `allEntries` attribute. */
export function cacheEntries(
    currentState: State, entries: Array<Entry|FilesAppEntry>) {
  scheduleClearCachedEntries();
  for (const entry of entries) {
    appendEntry(currentState, entry);
  }
}

export function cacheMaterializedViews(
    currentState: State, views: MaterializedView[]) {
  scheduleClearCachedEntries();
  for (const entry of views) {
    appendView(currentState, entry);
  }
}


function getEntryType(entry: Entry|FilesAppEntry): EntryType {
  // Entries from FilesAppEntry have the `typeName` property.
  if (!('typeName' in entry)) {
    return EntryType.FS_API;
  }

  switch (entry.typeName) {
    case 'EntryList':
      return EntryType.ENTRY_LIST;
    case 'VolumeEntry':
      return EntryType.VOLUME_ROOT;
    case 'FakeEntry':
      switch (entry.rootType) {
        case RootType.RECENT:
          return EntryType.RECENT;
        case RootType.TRASH:
          return EntryType.TRASH;
        case RootType.DRIVE_FAKE_ROOT:
          return EntryType.ENTRY_LIST;
        case RootType.CROSTINI:
        case RootType.ANDROID_FILES:
          return EntryType.PLACEHOLDER;
        case RootType.DRIVE_OFFLINE:
        case RootType.DRIVE_SHARED_WITH_ME:
          // TODO(lucmult): This isn't really Recent but it's the closest.
          return EntryType.RECENT;
        case RootType.PROVIDED:
          return EntryType.PLACEHOLDER;
      }
      console.warn(`Invalid fakeEntry.rootType='${entry.rootType} rootType`);
      return EntryType.PLACEHOLDER;
    case 'GuestOsPlaceholder':
      return EntryType.PLACEHOLDER;
    case 'TrashEntry':
      return EntryType.TRASH;
    case 'OneDrivePlaceholder':
      return EntryType.PLACEHOLDER;
    default:
      console.warn(`Invalid entry.typeName='${entry.typeName}`);
      return EntryType.FS_API;
  }
}

export interface EntryMetadata {
  entry: Entry|FilesAppEntry;
  metadata: MetadataItem;
}

/** Create action to update entries metadata. */
export const updateMetadata =
    slice.addReducer('update-metadata', updateMetadataReducer);

function updateMetadataReducer(currentState: State, payload: {
  metadata: EntryMetadata[],
}): State {
  // Cache entries, so the reducers can use any entry from `allEntries`.
  cacheEntries(currentState, payload.metadata.map(m => m.entry));

  for (const entryMetadata of payload.metadata) {
    const key = entryMetadata.entry.toURL();
    const fileData = currentState.allEntries[key]!;
    const metadata = {...fileData.metadata, ...entryMetadata.metadata};
    currentState.allEntries[key] = {
      ...fileData,
      metadata,
      key,
    };
  }
  if (!currentState.currentDirectory) {
    console.warn('Missing `currentDirectory`');
    return currentState;
  }
  const currentDirectory: CurrentDirectory = {
    ...currentState.currentDirectory,
    hasDlpDisabledFiles: hasDlpDisabledFiles(currentState),
  };

  return {
    ...currentState,
    currentDirectory,
  };
}

function findVolumeByType(volumes: VolumeMap, volumeType: VolumeType): Volume|
    null {
  return Object.values<Volume>(volumes).find(v => {
    // If the volume isn't resolved yet, we just ignore here.
    return v.rootKey && v.volumeType === volumeType;
  }) ??
      null;
}

/**
 * Returns the MyFiles entry and volume, the entry can either be a fake one
 * (EntryList) or a real one (VolumeEntry) depending on if the MyFiles volume is
 * mounted or not, and returns null if local files are disabled by policy.
 * Note: it will create a fake EntryList in the store if there's no
 * MyFiles entry in the store (e.g. no EntryList and no VolumeEntry), but local
 * files are enabled.
 */
export function getMyFiles(state: State):
    {myFilesVolume: null|Volume, myFilesEntry: null|VolumeEntry|EntryList} {
  const localFilesAllowed = state.preferences?.localUserFilesAllowed !== false;
  if (!isSkyvaultV2Enabled()) {
    // Return null for TT version.
    // For GA version we show local files in read-only mode, if present.
    if (!localFilesAllowed) {
      return {
        myFilesEntry: null,
        myFilesVolume: null,
      };
    }
  }

  const {volumes} = state;
  const myFilesVolume = findVolumeByType(volumes, VolumeType.DOWNLOADS);
  const myFilesVolumeEntry = myFilesVolume ?
      getEntry(state, myFilesVolume.rootKey!) as VolumeEntry | null :
      null;
  let myFilesEntryList =
      getEntry(state, myFilesEntryListKey) as EntryList | null;
  if (localFilesAllowed && !myFilesVolumeEntry && !myFilesEntryList) {
    myFilesEntryList =
        new EntryList(str('MY_FILES_ROOT_LABEL'), RootType.MY_FILES);
    appendEntry(state, myFilesEntryList);
    state.uiEntries = [...state.uiEntries, myFilesEntryList.toURL()];
  }

  return {
    myFilesEntry: myFilesVolumeEntry || myFilesEntryList!,
    myFilesVolume,
  };
}


/**  Create action to add child entries to a parent entry. */
export const addChildEntries =
    slice.addReducer('add-children', addChildEntriesReducer);

function addChildEntriesReducer(currentState: State, payload: {
  parentKey: FileKey,
  entries: Array<Entry|FilesAppEntry>,
}): State {
  // Cache entries, so the reducers can use any entry from `allEntries`.
  cacheEntries(currentState, payload.entries);

  const {parentKey, entries} = payload;
  const {allEntries} = currentState;
  // The corresponding parent entry item has been removed somehow, do nothing.
  if (!allEntries[parentKey]) {
    return currentState;
  }

  const newEntryKeys = entries.map(entry => entry.toURL());
  // Add children to the parent entry item.
  const parentFileData: FileData = {
    ...allEntries[parentKey]!,
    children: newEntryKeys,
    // Update canExpand according to the children length.
    canExpand: newEntryKeys.length > 0,
  };

  return {
    ...currentState,
    allEntries: {
      ...allEntries,
      [parentKey]: parentFileData,
    },
  };
}

/**
 * Read sub directories for a given entry.
 */
export async function*
    readSubDirectoriesInternal(
        fileKey: FileKey, recursive: boolean = false,
        metricNameForTracking: string = ''): ActionsProducerGen {
  let state = getStore().getState();
  let fileData = getFileData(state, fileKey);
  if (!fileData) {
    console.debug(`failed to find FileData for ${fileKey}`);
    console.warn(`readSubDirectoriesInternal: failed to find FileData`);
    return;
  }
  if (!canHaveSubDirectories(fileData)) {
    return;
  }

  // Track time for reading sub directories if metric for tracking is passed.
  if (metricNameForTracking) {
    startInterval(metricNameForTracking);
  }

  const childEntriesToReadDeeper: Array<Entry|FilesAppEntry> = [];
  const entry = fileData.entry;
  if (fileKey === driveRootEntryListKey) {
    assert(entry);
    if (!isDriveRootEntryList(entry)) {
      console.warn(
          `ERROR: ${fileKey} didn't return a EntryList from the Store`);
      return;
    }
    for await (const action of readSubDirectoriesForDriveRootEntryList(entry)) {
      yield action;
      if (action) {
        childEntriesToReadDeeper.push(...action.payload.entries);
      }
    }
  } else if (entry && isEntryScannable(entry)) {
    const childEntries = await readChildEntriesByFullScan(entry);
    // Only dispatch directories.
    const subDirectories =
        childEntries.filter(childEntry => childEntry.isDirectory);
    yield addChildEntries({parentKey: fileKey, entries: subDirectories});
    childEntriesToReadDeeper.push(...subDirectories);
    // Fetch metadata if the entry supports Drive specific share icon.
    state = getStore().getState();
    const parentFileData = getFileData(state, fileKey);
    if (parentFileData && isInsideDrive(parentFileData)) {
      const entriesNeedMetadata = subDirectories.filter(subDirectory => {
        const subDirFileData = getFileData(state, subDirectory.toURL());
        return subDirFileData &&
            shouldSupportDriveSpecificIcons(subDirFileData);
      });
      if (entriesNeedMetadata.length > 0) {
        window.fileManager.metadataModel.get(entriesNeedMetadata, [
          ...LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES,
          ...DLP_METADATA_PREFETCH_PROPERTY_NAMES,
        ]);
      }
    }
  } else {
    // TODO(b/327534506): Add support for Materialize Views.
    console.warn(`readSubDirectories not supported for ${fileKey}`);
  }

  // Track time for reading sub directories if metric for tracking is passed.
  if (metricNameForTracking) {
    recordInterval(metricNameForTracking);
  }

  // Read sub directories for children when recursive is true.
  if (!recursive) {
    return;
  }

  // Refresh the fileData from the store because it might have changed during
  // the async operations above.
  state = getStore().getState();
  fileData = getFileData(state, fileKey);

  // We only read deeper if the parent entry is expanded in the tree.
  if (!fileData?.expanded) {
    return;
  }

  // Recursive scan.
  for (const childEntry of childEntriesToReadDeeper) {
    const state = getStore().getState();
    const childFileData = getFileData(state, childEntry.toURL());
    if (!childFileData) {
      continue;
    }

    if (childFileData.expanded) {
      // If child item is expanded, we need to do a full scan for it.
      for await (const action of readSubDirectories(
          childEntry.toURL(), /* recursive= */ true)) {
        yield action;
      }
    } else if (childFileData?.canExpand) {
      // If we already know the child item can be expanded, no partial scan is
      // required.
      continue;
    } else {
      // If the child item is not expanded, we do a partial scan to check if
      // it has children or not (so we know if we need to show expand icon
      // or not).
      for await (const action of readSubDirectoriesToCheckDirectoryChildren(
          childEntry.toURL())) {
        yield action;
      }
    }
  }
}

/**
 * When there are multiple `readSubDirectories` actions with the same key being
 * dispatched at the same time, we only keep the latest one.
 */
export const readSubDirectories = keyedKeepLatest(
    readSubDirectoriesInternal,
    (fileKey, recursive?, _metricNameForTracking?) =>
        `${fileKey}${recursive ? '-recursive' : ''}`);

/**
 * Read entries for Drive root entry list (aka "Google Drive"), there are some
 * differences compared to the `readSubDirectoriesForDirectoryEntry()`:
 * * We don't need to call readEntries() to get its child entries. Instead, all
 * its children are from its entry.getUiChildren().
 * * For fake entries children (e.g. Shared with me and Offline), we only show
 * them based on the dialog type.
 * * For certain children (e.g. team drives and computers grand root), we only
 * show them when there's at least one child entry inside. So we need to read
 * their children (grand children of drive fake root) first before we can decide
 * if we need to show them or not.
 */
async function*
    readSubDirectoriesForDriveRootEntryList(entry: EntryList):
        ActionsProducerGen {
  const metricNameMap = {
    [SHARED_DRIVES_DIRECTORY_PATH]: 'TeamDrivesCount',
    [COMPUTERS_DIRECTORY_PATH]: 'ComputerCount',
  };

  const driveChildren = entry.getUiChildren();
  /**
   * Store the filtered children, for fake entries or grand roots we might need
   * to hide them based on curtain conditions.
   */
  const filteredChildren: Array<Entry|FilesAppEntry> = [];

  for (const childEntry of driveChildren) {
    // For fake entries ("Shared with me" and "Offline").
    if (isFakeEntryInDrive(childEntry)) {
      filteredChildren.push(childEntry);
      continue;
    }
    // For non grand roots (also not fake entries), we put them in the children
    // directly and dispatch an action to read the it later.
    if (!isGrandRootEntryInDrive(childEntry)) {
      filteredChildren.push(childEntry);
      continue;
    }
    // For grand roots ("Shared drives" and "Computers") inside Drive, we only
    // show them when there's at least one child entries inside.
    const grandChildEntries = await readChildEntriesByFullScan(childEntry);
    recordSmallCount(
        metricNameMap[childEntry.fullPath]!, grandChildEntries.length);
    if (grandChildEntries.length > 0) {
      filteredChildren.push(childEntry);
    }
  }
  yield addChildEntries({parentKey: entry.toURL(), entries: filteredChildren});
}

/**
 * Read a given directory entry to get all child entries.
 *
 * @param entry The parent directory entry to read.
 */
async function readChildEntriesByFullScan(
    entry: DirectoryEntry|
    FilesAppDirEntry): Promise<Array<Entry|FilesAppEntry>> {
  const childEntries = [];
  for await (const partialEntries of readEntries(entry)) {
    childEntries.push(...partialEntries);
  }
  return sortEntries(entry, childEntries);
}

/**
 * Read a given directory entry to check if it has directory child entries or
 * not. It won't do full scanning, the scan stops immediately after finding a
 * child directory entry.
 *
 * @param entry The parent directory entry to read.
 */
async function checkDirectoryChildByPartialScan(
    entry: DirectoryEntry|FilesAppDirEntry): Promise<boolean> {
  const {directoryModel} = window.fileManager;
  const fileFilter = directoryModel.getFileFilter();
  const isDirectoryChild = (childEntry: Entry|FilesAppEntry): boolean =>
      childEntry.isDirectory && fileFilter.filter(childEntry);
  for await (const partialEntries of readEntries(entry)) {
    if (partialEntries.some(isDirectoryChild)) {
      return true;
    }
  }
  return false;
}

/**
 * Read sub directories for a given entry to check if it has directory children
 * or not.
 */
async function*
    readSubDirectoriesToCheckDirectoryChildrenInternal(fileKey: FileKey|null):
        ActionsProducerGen {
  if (!fileKey) {
    return;
  }

  const state = getStore().getState();
  const fileData = getFileData(state, fileKey);
  if (!fileData) {
    console.warn(`No file data: ${fileKey}`);
    return;
  }
  if (!canHaveSubDirectories(fileData)) {
    return;
  }

  // Do nothing because we already know it has children.
  if (fileData.children.length > 0 || fileData.canExpand) {
    return;
  }

  const {directoryModel} = window.fileManager;
  const fileFilter = directoryModel.getFileFilter();
  const isDirectoryChild = (childEntry: Entry|FilesAppEntry): boolean =>
      childEntry.isDirectory && fileFilter.filter(childEntry);
  // The entry has UIChildren but has no FileData.children, we know it can be
  // expanded.
  if (supportsUiChildren(fileData) && fileData.entry) {
    const entry = fileData.entry;
    assert(isEntrySupportUiChildren(entry));
    const uiChildrenDirectories =
        entry.getUiChildren().filter(isDirectoryChild);
    if (uiChildrenDirectories.length > 0) {
      yield updateFileData({key: fileKey, partialFileData: {canExpand: true}});
    }
  }

  // TODO(lucmult): Add support to Materialize Views.
  const entry = fileData.entry;
  if (!entry) {
    console.warn(`No entry: ${fileKey}`);
    return;
  }
  if (isDirectoryEntry(entry)) {
    const hasDirectoryChild = await checkDirectoryChildByPartialScan(entry);
    if (hasDirectoryChild) {
      yield updateFileData({key: fileKey, partialFileData: {canExpand: true}});
    }
  }
}

/**
 * When there are multiple `readSubDirectoriesToCheckDirectoryChildren`
 * actions with the same key being dispatched at the same time, we only keep
 * the latest one.
 */
export const readSubDirectoriesToCheckDirectoryChildren = keyedKeepLatest(
    readSubDirectoriesToCheckDirectoryChildrenInternal,
    (fileKey) => fileKey ?? '');

/**
 * Read child entries for the newly renamed directory entry.
 * We need to read its parent's children first before reading its own
 * children, because the newly renamed entry might not be in the store yet
 * after renaming.
 */
export async function*
    readSubDirectoriesForRenamedEntry(newEntry: Entry|FilesAppEntry):
        ActionsProducerGen {
  const parentDirectory = await getParentEntry(newEntry);
  // Read the children of the parent first to make sure the newly added entry
  // appears in the store.
  for await (const action of readSubDirectories(parentDirectory.toURL())) {
    yield action;
  }
  // Read the children of the newly renamed entry.
  for await (const action of readSubDirectories(
      newEntry.toURL(), /* recursive= */ true)) {
    yield action;
  }
}

/**
 * Traverse each entry in the `pathEntryKeys`: if the entry doesn't exist in
 * the store, read sub directories for its parent. After all entries exist in
 * the store, expand all parent entries.
 *
 * @param pathEntryKeys An array of FileKey starts from ancestor to child,
 *     e.g. [A, B, C] A is the parent entry of B, B is the parent entry of C.
 */
export async function*
    traverseAndExpandPathEntriesInternal(pathEntryKeys: FileKey[]):
        ActionsProducerGen {
  if (pathEntryKeys.length === 0) {
    return;
  }
  const childEntryKey = pathEntryKeys[pathEntryKeys.length - 1]!;
  const state = getStore().getState();
  const childEntryFileData = getFileData(state, childEntryKey);
  if (!childEntryFileData) {
    console.warn(`Can not find the child entry: ${childEntryKey}`);
    return;
  }
  const volume = getVolume(state, childEntryFileData);
  if (!volume) {
    console.warn(
        `Can not find the volume root for the child entry: ${childEntryKey}`);
    return;
  }
  const volumeEntry = getEntry(state, volume.rootKey!);
  if (!volumeEntry) {
    console.warn(`Can not find the volume root entry: ${volume.rootKey}`);
    return;
  }

  for (let i = 1; i < pathEntryKeys.length; i++) {
    // We need to getStore() for each loop because the below `yield action`
    // will add new entries to the store.
    const state = getStore().getState();
    const currentEntryKey = pathEntryKeys[i]!;
    const parentEntryKey = pathEntryKeys[i - 1]!;
    const parentFileData = getFileData(state, parentEntryKey)!;
    const fileData = getFileData(state, currentEntryKey);
    // Read sub directories if the child entry doesn't exist or it's not in
    // parent entry's children.
    if (!fileData || !parentFileData.children.includes(currentEntryKey)) {
      let foundCurrentEntry = false;
      for await (const action of readSubDirectories(parentEntryKey)) {
        yield action;
        const childEntries: Array<Entry|FilesAppEntry> =
            action?.payload.entries || [];
        foundCurrentEntry =
            !!childEntries.find(entry => entry.toURL() === currentEntryKey);
        if (foundCurrentEntry) {
          break;
        }
      }
      if (!foundCurrentEntry) {
        console.warn(`Failed to find entry "${
            currentEntryKey}" from its parent "${parentEntryKey}"`);
        return;
      }
    }
  }
  // Now all entries on `pathEntryKeys` are found, we can expand all of them
  // now. Note: if any entry on the path can't be found, we don't expand
  // anything because we don't want to expand half-way, e.g. if `pathEntryKeys
  // = [entryA, entryB, entryC]` but somehow entryB doesn't exist, we don't
  // want to expand `entryA`.
  for (let i = 0; i < pathEntryKeys.length - 1; i++) {
    yield updateFileData(
        {key: pathEntryKeys[i]!, partialFileData: {expanded: true}});
  }
}

/**
 * `traverseAndExpandPathEntries` is mainly used to traverse and expand the
 * `pathComponent` for current directory, if concurrent requests happen (e.g.
 * current directory changes too quickly while we are still resolving the
 * previous one), we just ditch the previous request and only keep the latest.
 */
export const traverseAndExpandPathEntries =
    keepLatest(traverseAndExpandPathEntriesInternal);

/** Create action to update FileData for a given entry. */
export const updateFileData =
    slice.addReducer('update-file-data', updateFileDataReducer);

function updateFileDataReducer(currentState: State, payload: {
  key: FileKey,
  partialFileData: Partial<FileData>,
}): State {
  const {key, partialFileData} = payload;
  const fileData = getFileData(currentState, key);
  if (!fileData) {
    return currentState;
  }

  currentState.allEntries[key] = {
    ...fileData,
    ...partialFileData,
    key,
  };
  return {...currentState};
}