chromium/ui/file_manager/file_manager/state/ducks/current_directory.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 {getFileTasks, readMaterializedView} from '../../common/js/api.js';
import {getNativeEntry} from '../../common/js/entry_utils.js';
import {INSTALL_LINUX_PACKAGE_TASK_DESCRIPTOR, annotateTasks, getDefaultTask} from '../../common/js/file_tasks.js';
import type {FakeEntry, FilesAppDirEntry, FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {descriptorEqual} from '../../common/js/util.js';
import {RootType} from '../../common/js/volume_manager_types.js';
import {DEFAULT_CROSTINI_VM} from '../../foreground/js/constants.js';
import {PathComponent} from '../../foreground/js/path_component.js';
import type {ActionsProducerGen} from '../../lib/actions_producer.js';
import {Slice, isInvalidationError} from '../../lib/base_store.js';
import {keyedKeepFirst} from '../../lib/concurrency_models.js';
import {combine1Selector} from '../../lib/selector.js';
import {DialogType, EntryType, PropStatus, type CurrentDirectory, type DirectoryContent, type FileData, type FileKey, type FileTask, type FileTasks, type Selection, type State} from '../../state/state.js';
import {getFileData, getStore} from '../store.js';

import {cacheEntries} from './all_entries.js';

/**
 * @fileoverview Current directory slice of the store.
 */

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

function getEmptySelection(keys: FileKey[] = []): Selection {
  return {
    keys,
    dirCount: 0,
    fileCount: 0,
    // hostedCount might be updated to undefined in the for loop below.
    hostedCount: 0,
    // offlineCachedCount might be updated to undefined in the for loop below.
    offlineCachedCount: 0,
    fileTasks: {
      tasks: [],
      defaultTask: undefined,
      policyDefaultHandlerStatus: undefined,
      status: PropStatus.STARTED,
    },
  };
}

/**
 * Returns true if any of the entries in `currentDirectory` are DLP disabled,
 * and false otherwise.
 */
export function hasDlpDisabledFiles(currentState: State): boolean {
  const content = currentState.currentDirectory?.content;
  if (!content) {
    return false;
  }
  for (const key of content!.keys) {
    const fileData = currentState.allEntries[key];
    if (!fileData) {
      console.warn(`Missing entry: ${key}`);
      continue;
    }
    if (fileData!.metadata.isRestrictedForDestination) {
      return true;
    }
  }
  return false;
}

/** Create action to change the Current Directory. */
export const changeDirectory = slice.addReducer('set', changeDirectoryReducer);

function changeDirectoryReducer(currentState: State, payload: {
  to?: DirectoryEntry|FilesAppDirEntry, toKey: FileKey,
  status?: PropStatus,
}): State {
  // Cache entries, so the reducers can use any entry from `allEntries`.
  if (payload.to) {
    cacheEntries(currentState, [payload.to]);
  }

  const {to, toKey} = payload;
  const key = toKey || to!.toURL();
  const status = payload.status || PropStatus.STARTED;

  const fileData = currentState.allEntries[key];

  let selection = currentState.currentDirectory?.selection;
  // Use an empty selection when a selection isn't defined or it's navigating to
  // a new directory.
  if (!selection || currentState.currentDirectory?.key !== key) {
    selection = {
      keys: [],
      dirCount: 0,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 0,
      fileTasks: {
        tasks: [],
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        status: PropStatus.SUCCESS,
      },
    };
  }

  let content = currentState.currentDirectory?.content;
  let hasDlpDisabledFiles =
      currentState.currentDirectory?.hasDlpDisabledFiles || false;
  // Use empty content when it isn't defined or it's navigating to a new
  // directory. The content will be updated again after a successful scan.
  if (!content || currentState.currentDirectory?.key !== key) {
    content = {
      keys: [],
      status: PropStatus.SUCCESS,
    };
    hasDlpDisabledFiles = false;
  }

  let currentDirectory: CurrentDirectory = {
    key,
    status,
    pathComponents: [],
    content: content,
    rootType: undefined,
    selection,
    hasDlpDisabledFiles: hasDlpDisabledFiles,
  };

  // The new directory might not be in the allEntries yet, this might happen
  // when starting to change the directory for a entry that isn't cached.
  // At the end of the change directory, DirectoryContents will send an Action
  // with the Entry to be cached.
  if (fileData) {
    if (fileData.type === EntryType.MATERIALIZED_VIEW) {
      currentDirectory.pathComponents = [{
        name: fileData.label,
        label: fileData.label,
        key: fileData.key,
      }];
    } else {
      const {volumeManager} = window.fileManager;
      if (!volumeManager) {
        console.debug(`VolumeManager not available yet.`);
        currentDirectory = currentState.currentDirectory || currentDirectory;
      } else {
        const components = PathComponent.computeComponentsFromEntry(
            fileData.entry!, volumeManager);
        currentDirectory.pathComponents = components.map(c => {
          return {
            name: c.name,
            label: c.name,
            key: c.getKey(),
          };
        });

        const locationInfo = volumeManager.getLocationInfo(fileData.entry!);
        currentDirectory.rootType = locationInfo?.rootType;
      }
    }
  }

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

/** Create action to update currently selected files/folders. */
export const updateSelection =
    slice.addReducer('set-selection', updateSelectionReducer);

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

  const updatingToEmpty =
      (payload.entries.length === 0 && payload.selectedKeys.length === 0);
  if (!currentState.currentDirectory) {
    if (!updatingToEmpty) {
      console.warn('Missing `currentDirectory`');
      console.debug('Dropping action:', payload);
    }
    return currentState;
  }

  if (!currentState.currentDirectory.content) {
    if (!updatingToEmpty) {
      console.warn('Missing `currentDirectory.content`');
      console.debug('Dropping action:', payload);
    }
    return currentState;
  }

  const selectedKeys = payload.selectedKeys;
  const contentKeys = new Set(currentState.currentDirectory!.content!.keys);
  const missingKeys = selectedKeys.filter(k => !contentKeys.has(k));

  if (missingKeys.length > 0) {
    console.warn(
        'Got selected keys that are not in current directory, ' +
        'continuing anyway');
    console.debug(`Missing keys: ${missingKeys.join('\n')} \nexisting keys:\n ${
        (currentState.currentDirectory?.content?.keys ?? []).join('\n')}`);
  }

  const selection = getEmptySelection(selectedKeys);

  for (const key of selectedKeys) {
    const fileData = currentState.allEntries[key];
    if (!fileData) {
      console.warn(`Missing entry: ${key}`);
      continue;
    }
    if (fileData.isDirectory) {
      selection.dirCount++;
    } else {
      selection.fileCount++;
    }

    const metadata = fileData.metadata;

    // Update hostedCount to undefined if any entry doesn't have the metadata
    // yet.
    const isHosted = metadata?.hosted;
    if (isHosted === undefined) {
      selection.hostedCount = undefined;
    } else {
      if (selection.hostedCount !== undefined && isHosted) {
        selection.hostedCount++;
      }
    }

    // If no availableOffline property, then assume it's available.
    const isOfflineCached =
        (metadata?.availableOffline === undefined ||
         metadata?.availableOffline);
    if (isOfflineCached) {
      selection.offlineCachedCount++;
    }
  }

  const currentDirectory: CurrentDirectory = {
    ...currentState.currentDirectory,
    selection,
  } as CurrentDirectory;

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

/** Create action to update FileTasks for the current selection. */
export const updateFileTasks =
    slice.addReducer('set-file-tasks', updateFileTasksReducer);

function updateFileTasksReducer(
    currentState: State, payload: FileTasks): State {
  const initialSelection =
      currentState.currentDirectory?.selection ?? getEmptySelection();

  // Apply the changes over the current selection.
  const fileTasks: FileTasks = {
    ...initialSelection.fileTasks,
    ...payload,
  };

  // Update the selection and current directory objects.
  const selection: Selection = {
    ...initialSelection,
    fileTasks,
  };
  const currentDirectory: CurrentDirectory = {
    ...currentState.currentDirectory,
    selection,
  } as CurrentDirectory;

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

/** Create action to update the current directory's content. */
export const updateDirectoryContent =
    slice.addReducer('update-content', updateDirectoryContentReducer);

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

  if (!currentState.currentDirectory) {
    console.warn('Missing `currentDirectory`');
    return currentState;
  }

  const initialContent: DirectoryContent =
      currentState.currentDirectory?.content ?? {keys: []};
  const status = payload.status;
  const keys = (payload.entries ?? []).map(e => e.toURL());
  const content: DirectoryContent = {
    ...initialContent,
    keys,
    status,
  };

  let currentDirectory: CurrentDirectory = {
    ...currentState.currentDirectory,
    content,
  };
  const newState: State = {
    ...currentState,
    currentDirectory,
  };
  currentDirectory = {
    ...currentDirectory,
    hasDlpDisabledFiles: hasDlpDisabledFiles(newState),
  };

  return {
    ...newState,
    currentDirectory,
  };
}

/**
 * Linux package installation is currently only supported for a single file
 * which is inside the Linux container, or in a shareable volume.
 * TODO(timloh): Instead of filtering these out, we probably should show a
 * dialog with an error message, similar to when attempting to run Crostini
 * tasks with non-Crostini entries.
 */
function allowCrostiniTask(filesData: FileData[]) {
  if (filesData.length !== 1) {
    return false;
  }
  const fileData = filesData[0]!;
  const rootType = (fileData.entry as FakeEntry).rootType;
  if (rootType !== RootType.CROSTINI) {
    return false;
  }
  const crostini = window.fileManager.crostini;
  return crostini.canSharePath(
      DEFAULT_CROSTINI_VM, (fileData.entry as Entry),
      /*persiste=*/ false);
}

const emptyAction = (status: PropStatus) => updateFileTasks({
  tasks: [],
  policyDefaultHandlerStatus: undefined,
  defaultTask: undefined,
  status,
});

export async function*
    fetchFileTasksInternal(filesData: FileData[]): ActionsProducerGen {
  // Filters out the non-native entries.
  filesData = filesData.filter(getNativeEntry);
  const state = getStore().getState();
  const currentRootType = state.currentDirectory?.rootType;
  const dialogType = window.fileManager.dialogType;
  const shouldDisableTasks = (
      // File Picker/Save As doesn't show the "Open" button.
      dialogType !== DialogType.FULL_PAGE ||
      // The list of available tasks should not be available to trashed items.
      currentRootType === RootType.TRASH || filesData.length === 0);
  if (shouldDisableTasks) {
    yield emptyAction(PropStatus.SUCCESS);
    return;
  }
  const selectionHandler = window.fileManager.selectionHandler;
  const selection = selectionHandler.selection;
  await selection.computeAdditional(window.fileManager.metadataModel);
  yield;
  try {
    const resultingTasks = await getFileTasks(
        filesData.map(fd => fd.entry!),
        filesData.map(fd => fd.metadata.sourceUrl || ''));
    if (!resultingTasks || !resultingTasks.tasks) {
      return;
    }
    yield;
    if (filesData.length === 0 || resultingTasks.tasks.length === 0) {
      yield emptyAction(PropStatus.SUCCESS);
      return;
    }
    if (!allowCrostiniTask(filesData)) {
      resultingTasks.tasks = resultingTasks.tasks.filter(
          (task: chrome.fileManagerPrivate.FileTask) => !descriptorEqual(
              task.descriptor, INSTALL_LINUX_PACKAGE_TASK_DESCRIPTOR));
    }
    const tasks = annotateTasks(resultingTasks.tasks, filesData);
    resultingTasks.tasks = tasks;
    // TODO: Migrate TaskHistory to the store.
    const taskHistory = window.fileManager.taskController.taskHistory;
    const defaultTask =
        getDefaultTask(
            tasks, resultingTasks.policyDefaultHandlerStatus, taskHistory) ??
        undefined;
    yield updateFileTasks({
      tasks: tasks as FileTask[],
      policyDefaultHandlerStatus: resultingTasks.policyDefaultHandlerStatus,
      defaultTask: defaultTask as FileTask,
      status: PropStatus.SUCCESS,
    });
  } catch (error) {
    yield emptyAction(PropStatus.ERROR);
  }
}

/** Generates key based on each FileKey (entry.toURL()). */
function getSelectionKey(filesData: FileData[]): string {
  return filesData.map(f => f.key).join('|');
}

export const fetchFileTasks =
    keyedKeepFirst(fetchFileTasksInternal, getSelectionKey);


export const directoryContentSelector = combine1Selector(
    (currentDir?: CurrentDirectory) => currentDir?.content, slice.selector);

// TODO(lucmult): Add concurrency model, the latest should prevail.
export async function*
    fetchDirectoryContents(fileKey: FileKey): ActionsProducerGen {
  // Mark as started.
  yield updateDirectoryContent({status: PropStatus.STARTED});

  try {
    const store = getStore();
    const state = store.getState();
    const fileData = getFileData(state, fileKey);
    if (!fileData) {
      throw new Error(`FileData not found for key ${fileKey}`);
    }

    // TODO(lucmult): Add this to concurrency model.
    if (store.getState().currentDirectory?.key !== fileKey) {
      // User navigated to another directory.
      return;
    }

    // NOTE: Only implemented for Materialized view for now.
    if (fileData.type !== EntryType.MATERIALIZED_VIEW) {
      throw new Error(`Fetch not supported for entry type: ${fileData.type}`);
    }

    const entries = await readMaterializedView(fileKey);
    yield updateDirectoryContent({entries, status: PropStatus.SUCCESS});
  } catch (error: any) {
    if (isInvalidationError(error)) {
      // Not an actual error, just stopping the actions producer.
      throw error;
    }
    console.warn(error);
    yield updateDirectoryContent({status: PropStatus.ERROR});
  }
}