chromium/ui/file_manager/file_manager/state/ducks/navigation.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 {isOneDrive, isOneDriveId} from '../../common/js/entry_utils.js';
import type {EntryList, FilesAppEntry, VolumeEntry} from '../../common/js/files_app_entry_types.js';
import {isSkyvaultV2Enabled} from '../../common/js/flags.js';
import {VolumeType} from '../../common/js/volume_manager_types.js';
import {Slice} from '../../lib/base_store.js';
import {type AndroidApp, DialogType, type NavigationKey, type NavigationRoot, NavigationSection, NavigationType, type State, type Volume} from '../../state/state.js';
import {getMyFiles} from '../ducks/all_entries.js';
import {driveRootEntryListKey, oneDriveFakeRootKey, recentRootKey, trashRootKey} from '../ducks/volumes.js';
import {getEntry} from '../store.js';

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

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

const sections = new Map<VolumeType, NavigationSection>();
// My Files.
sections.set(VolumeType.DOWNLOADS, NavigationSection.MY_FILES);
// Cloud.
sections.set(VolumeType.DRIVE, NavigationSection.CLOUD);
sections.set(VolumeType.SMB, NavigationSection.CLOUD);
sections.set(VolumeType.PROVIDED, NavigationSection.CLOUD);
sections.set(VolumeType.DOCUMENTS_PROVIDER, NavigationSection.CLOUD);
// Removable.
sections.set(VolumeType.REMOVABLE, NavigationSection.REMOVABLE);
sections.set(VolumeType.MTP, NavigationSection.REMOVABLE);
sections.set(VolumeType.ARCHIVE, NavigationSection.REMOVABLE);

/** Returns the entry for the volume's top-most prefix or the volume itself. */
function getPrefixEntryOrEntry(state: State, volume: Volume): VolumeEntry|
    EntryList|null {
  if (volume.prefixKey) {
    const entry = getEntry(state, volume.prefixKey);
    return entry as VolumeEntry | EntryList | null;
  }
  if (volume.volumeType === VolumeType.DOWNLOADS) {
    return getMyFiles(state)!.myFilesEntry;
  }

  const entry = getEntry(state, volume.rootKey!);
  return entry as VolumeEntry | EntryList | null;
}

/**
 * Create action to refresh all navigation roots. This will clear all existing
 * navigation roots in the store and regenerate them with the current state
 * data.
 *
 * Navigation roots' Entries/Volumes will be ordered as below:
 *  1. Recents.
 *  2. Shortcuts.
 *  3. "My-Files" (grouping), actually Downloads volume.
 *  4. Google Drive.
 *  5. ODFS.
 *  6. SMBs
 *  7. Other FSP (File System Provider) (when mounted).
 *  8. Other volumes (MTP, ARCHIVE, REMOVABLE).
 *  9. Android apps.
 *  10. Trash.
 */
export const refreshNavigationRoots =
    slice.addReducer('refresh-roots', refreshNavigationRootsReducer);

function refreshNavigationRootsReducer(currentState: State): State {
  const {
    navigation: {roots: previousRoots},
    folderShortcuts,
    androidApps,
    materializedViews,
  } = currentState;

  /** Roots in the desired order. */
  const roots: NavigationRoot[] = [];
  /** Set to avoid adding the same entry multiple times. */
  const processedEntryKeys = new Set<NavigationKey>();

  // Add the Recent/Materialized view root.
  const recentRoot = previousRoots.find(root => root.key === recentRootKey);
  if (recentRoot) {
    roots.push(recentRoot);
    processedEntryKeys.add(recentRootKey);
  } else {
    const recentEntry =
        getEntry(currentState, recentRootKey) as FilesAppEntry | null;
    if (recentEntry) {
      roots.push({
        key: recentRootKey,
        section: NavigationSection.TOP,
        separator: false,
        type: NavigationType.RECENT,
      });
      processedEntryKeys.add(recentRootKey);
    }
  }

  // Add Starred files.
  for (const view of materializedViews) {
    if (view.isRoot) {
      roots.push({
        key: view.key,
        section: NavigationSection.TOP,
        separator: false,
        type: NavigationType.MATERIALIZED_VIEW,
      });
    }
  }

  // Add the Shortcuts.
  // TODO: Since Shortcuts are only for Drive, do we need to remove shortcuts
  // if Drive isn't available anymore?
  folderShortcuts.forEach(shortcutKey => {
    const shortcutEntry =
        getEntry(currentState, shortcutKey) as FilesAppEntry | null;
    if (shortcutEntry) {
      roots.push({
        key: shortcutKey,
        section: NavigationSection.TOP,
        separator: false,
        type: NavigationType.SHORTCUT,
      });
      processedEntryKeys.add(shortcutKey);
    }
  });

  // MyFiles.
  const {myFilesEntry, myFilesVolume} = getMyFiles(currentState);
  if (myFilesEntry) {
    roots.push({
      key: myFilesEntry.toURL(),
      section: NavigationSection.MY_FILES,
      // Only show separator if this is not the first navigation item.
      separator: processedEntryKeys.size > 0,
      type: myFilesVolume ? NavigationType.VOLUME : NavigationType.ENTRY_LIST,
    });
    processedEntryKeys.add(myFilesEntry.toURL());
  }

  // Add Google Drive - the only Drive.
  // When drive pref changes from enabled to disabled, we remove the drive root
  // key from the `state.uiEntries` immediately, but the drive root entry itself
  // is removed asynchronously, so here we need to check both, if the key
  // doesn't exist any more, we shouldn't render Drive item even if the drive
  // root entry is still available.
  const driveEntryKeyExist =
      currentState.uiEntries.includes(driveRootEntryListKey);
  const driveEntry =
      getEntry(currentState, driveRootEntryListKey) as EntryList | null;
  if (driveEntryKeyExist && driveEntry) {
    roots.push({
      key: driveEntry.toURL(),
      section: NavigationSection.GOOGLE_DRIVE,
      separator: true,
      type: NavigationType.DRIVE,
    });
    processedEntryKeys.add(driveEntry.toURL());
  }

  // Add OneDrive placeholder if needed.
  // OneDrive is always added directly below Drive.
  if (isSkyvaultV2Enabled()) {
    const oneDriveUIEntryExists =
        currentState.uiEntries.includes(oneDriveFakeRootKey);
    const oneDriveVolumeExists =
        Object.values<Volume>(currentState.volumes).find(v => isOneDrive(v)) !==
        undefined;
    if (oneDriveUIEntryExists && !oneDriveVolumeExists) {
      roots.push({
        key: oneDriveFakeRootKey,
        section: NavigationSection.ODFS,
        separator: true,
        type: NavigationType.VOLUME,
      });
      processedEntryKeys.add(oneDriveFakeRootKey);
    }
  }

  // Other volumes.
  const volumesOrder: Partial<Record<VolumeType, number>> = {
    // ODFS is a PROVIDED volume type but is a special case to be directly
    // below Drive.
    // ODFS : 0
    [VolumeType.SMB]: 1,
    [VolumeType.PROVIDED]: 2,  // FSP.
    [VolumeType.DOCUMENTS_PROVIDER]: 3,
    [VolumeType.REMOVABLE]: 4,
    [VolumeType.ARCHIVE]: 5,
    [VolumeType.MTP]: 6,
  };
  // Filter volumes based on the volumeInfoList in volumeManager.
  const {volumeManager} = window.fileManager;
  const filteredVolumes =
      Object.values<Volume>(currentState.volumes).filter(volume => {
        const volumeEntry =
            getEntry(currentState, volume.rootKey!) as VolumeEntry;
        return volumeManager.isAllowedVolume(volumeEntry.volumeInfo);
      });

  function getVolumeOrder(volume: Volume) {
    if (isOneDriveId(volume.providerId)) {
      return 0;
    }
    return volumesOrder[volume.volumeType] ?? 999;
  }

  const volumes = filteredVolumes
                      .filter((v) => {
                        return (
                            // Only display if the entry is resolved.
                            v.rootKey &&
                            // MyFiles and Drive is already displayed above.
                            // MediaView volumeType isn't displayed.
                            !(v.volumeType === VolumeType.DOWNLOADS ||
                              v.volumeType === VolumeType.DRIVE ||
                              v.volumeType === VolumeType.MEDIA_VIEW));
                      })
                      .sort((v1, v2) => {
                        const v1Order = getVolumeOrder(v1);
                        const v2Order = getVolumeOrder(v2);
                        return v1Order - v2Order;
                      });
  let lastSection: NavigationSection|null = null;
  for (const volume of volumes) {
    // Some volumes might be nested inside another volume or entry list, e.g.
    // Multiple partition removable volumes can be nested inside a EntryList, or
    // GuestOS/Crostini/Android volumes will be nested inside MyFiles, for these
    // volumes, we only need to add its parent volume in the navigation roots.
    const volumeEntry = getPrefixEntryOrEntry(currentState, volume);

    if (volumeEntry && !processedEntryKeys.has(volumeEntry.toURL())) {
      let section =
          sections.get(volume.volumeType) ?? NavigationSection.REMOVABLE;
      if (isOneDriveId(volume.providerId)) {
        section = NavigationSection.ODFS;
      }
      const isSectionStart = section !== lastSection;
      roots.push({
        key: volumeEntry.toURL(),
        section,
        separator: isSectionStart,
        type: NavigationType.VOLUME,
      });
      processedEntryKeys.add(volumeEntry.toURL());
      lastSection = section;
    }
  }

  // Android Apps.
  Object.values(androidApps as Record<string, AndroidApp>)
      .forEach((app, index) => {
        roots.push({
          key: app.packageName,
          section: NavigationSection.ANDROID_APPS,
          separator: index === 0,
          type: NavigationType.ANDROID_APPS,
        });
        processedEntryKeys.add(app.packageName);
      });

  // Trash.
  // Trash should only show when Files app is open as a standalone app. The ARC
  // file selector, however, opens Files app as a standalone app but passes a
  // query parameter to indicate the mode. As Trash is a fake volume, it is
  // not filtered out in the filtered volume manager so perform it here
  // instead.
  const {dialogType} = window.fileManager;
  const shouldShowTrash = dialogType === DialogType.FULL_PAGE &&
      !volumeManager.getMediaStoreFilesOnlyFilterEnabled();
  // When trash pref changes from enabled to disabled, we remove the trash root
  // key from the `state.uiEntries` immediately, but the trash entry itself is
  // removed asynchronously, so here we need to check both, if the key doesn't
  // exist any more, we shouldn't render Trash item even if the trash entry is
  // still available.
  const trashEntryKeyExist = currentState.uiEntries.includes(trashRootKey);
  const trashEntry =
      getEntry(currentState, trashRootKey) as FilesAppEntry | null;
  if (shouldShowTrash && trashEntryKeyExist && trashEntry) {
    roots.push({
      key: trashRootKey,
      section: NavigationSection.TRASH,
      separator: true,
      type: NavigationType.TRASH,
    });
    processedEntryKeys.add(trashRootKey);
  }

  return {
    ...currentState,
    navigation: {
      roots,
    },
  };
}