chromium/ui/file_manager/file_manager/state/ducks/ui_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 {isSameEntry, isVolumeEntry} from '../../common/js/entry_utils.js';
import type {EntryList, FakeEntry} from '../../common/js/files_app_entry_types.js';
import {RootType} from '../../common/js/volume_manager_types.js';
import type {ActionsProducerGen} from '../../lib/actions_producer.js';
import {Slice} from '../../lib/base_store.js';
import type {FileKey, State} from '../../state/state.js';
import {cacheEntries, getMyFiles, readSubDirectories} from '../ducks/all_entries.js';
import {getEntry, getStore} from '../store.js';

/**
 * @fileoverview UI entries slice of the store.
 *
 * UI entries represents entries shown on UI only (aka FakeEntry, e.g.
 * Recents/Trash/Google Drive wrapper), they don't have a real entry backup in
 * the file system.
 */


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

const uiEntryRootTypesInMyFiles = new Set([
  RootType.ANDROID_FILES,
  RootType.CROSTINI,
  RootType.GUEST_OS,
]);


/** Create action to add an UI entry to the store. */
export const addUiEntryInternal = slice.addReducer('add', addUiEntryReducer);

function addUiEntryReducer(currentState: State, payload: {
  entry: FakeEntry|EntryList,
}): State {
  // Cache entries, so the reducers can use any entry from `allEntries`.
  cacheEntries(currentState, [payload.entry]);

  const {entry} = payload;
  const key = entry.toURL();
  const uiEntries = [...currentState.uiEntries, key];

  return {
    ...currentState,
    uiEntries,
  };
}

/**
 * Add UI entry to the store and re-scan MyFiles if the newly added UI entry is
 * under MyFiles.
 */
export async function*
    addUiEntry(entry: FakeEntry|EntryList): ActionsProducerGen {
  const state = getStore().getState();
  const exists = state.uiEntries.find(key => key === entry.toURL());
  if (exists) {
    return;
  }

  // If the UI entry to be added is under MyFiles, we also need to update
  // MyFiles's UI children.
  let isVolumeEntryInMyFiles = false;
  if (entry.rootType && uiEntryRootTypesInMyFiles.has(entry.rootType)) {
    const {myFilesEntry} = getMyFiles(state);
    if (!myFilesEntry) {
      // TODO(aidazolic): Add separately.
      return;
    }
    const children = myFilesEntry.getUiChildren();
    // Check if the the ui entry already has a corresponding volume entry.
    isVolumeEntryInMyFiles = !!children.find(
        childEntry =>
            isVolumeEntry(childEntry) && childEntry.name === entry.name);
    const isUiEntryInMyFiles =
        !!children.find(childEntry => isSameEntry(childEntry, entry));
    // We only add the UI entry here if:
    // 1. it does not exist in MyFiles entry's UI children.
    // 2. its corresponding volume (which ui entry is a placeholder for) does
    // not exist in MyFiles entry's UI children.
    const shouldAddUiEntry = !isUiEntryInMyFiles && !isVolumeEntryInMyFiles;
    if (shouldAddUiEntry) {
      myFilesEntry.addEntry(entry);
      yield addUiEntryInternal({entry});
      // Get MyFiles again from the latest state after yield because yield pause
      // the execution of this function and between the pause MyFiles might
      // change from EntryList to Volume (e.g. MyFiles volume mounts during the
      // pause).
      const {myFilesEntry: updatedMyFiles} = getMyFiles(getStore().getState());
      // Trigger a re-scan for MyFiles to make FileData.children in the store
      // has this newly added children.
      if (!updatedMyFiles) {
        return;
      }
      for await (const action of readSubDirectories(updatedMyFiles.toURL())) {
        yield action;
      }
      return;
    }
  }
  if (!isVolumeEntryInMyFiles) {
    yield addUiEntryInternal({entry});
  }
}

/** Create action to remove an UI entry from the store. */
const removeUiEntryInternal = slice.addReducer('remove', removeUiEntryReducer);

function removeUiEntryReducer(currentState: State, payload: {
  key: FileKey,
}): State {
  const {key} = payload;
  const uiEntries = currentState.uiEntries.filter(k => k !== key);

  return {
    ...currentState,
    uiEntries,
  };
}

/**
 * Remove UI entry from the store and re-scan MyFiles if the removed UI entry is
 * under MyFiles.
 */
export async function* removeUiEntry(key: FileKey): ActionsProducerGen {
  const state = getStore().getState();
  const exists = state.uiEntries.find(uiEntryKey => uiEntryKey === key);
  if (!exists) {
    return;
  }

  yield removeUiEntryInternal({key});

  const entry = getEntry(state, key) as FakeEntry | EntryList | null;
  // We also need to remove it from the children of MyFiles if it's existed
  // there.
  if (entry?.rootType && uiEntryRootTypesInMyFiles.has(entry.rootType)) {
    // Get MyFiles from the latest state after yield because yield pause
    // the execution of this function and between the pause MyFiles might
    // change.
    const {myFilesEntry} = getMyFiles(getStore().getState());
    if (!myFilesEntry) {
      return;
    }
    const children = myFilesEntry.getUiChildren();
    const isUiEntryInMyFiles =
        !!children.find(childEntry => isSameEntry(childEntry, entry));
    if (isUiEntryInMyFiles) {
      myFilesEntry.removeChildEntry(entry);
      // Trigger a re-scan for MyFiles to make FileData.children in the store
      // removes this children.
      for await (const action of readSubDirectories(myFilesEntry.toURL())) {
        yield action;
      }
    }
  }
}