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

// Copyright 2014 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, assertNotReached} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import {isFolderDialogType} from '../../common/js/dialog_type.js';
import type {FilesAppDirEntry, FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {recordEnum} from '../../common/js/metrics.js';
import {str} from '../../common/js/translations.js';
import {recordViewingNavigationSurfaceUma, recordViewingVolumeTypeUma} from '../../common/js/uma.js';
import {testSendMessage, UserCanceledError} from '../../common/js/util.js';
import {AllowedPaths, RootTypesForUMA} from '../../common/js/volume_manager_types.js';
import {DialogType} from '../../state/state.js';
import {getStore} from '../../state/store.js';

import type {FileFilter} from './directory_contents.js';
import type {DirectoryModel} from './directory_model.js';
import {EventType, type FileSelectionHandler} from './file_selection.js';
import type {LaunchParam} from './launch_param.js';
import type {NamingController} from './naming_controller.js';
import type {Command} from './ui/command.js';
import type {DialogFooter} from './ui/dialog_footer.js';

interface SelectFilesAndCloseParams {
  urls: string[];
  multiple: boolean;
  filterIndex?: number;
}

/**
 * Controller for handling behaviors of the Files app opened as a file/folder
 * selection dialog.
 */
export class DialogActionController {
  private fileTypes_: LaunchParam['typeList'];
  private allowedPaths_: AllowedPaths;
  /**
   * Bound function for onCancel_.
   */
  private onCancelBound_ = this.processCancelAction_.bind(this);
  private newFolderCommand_: Command =
      document.querySelector<Command>('#new-folder')!;

  /**
   * @param dialogType Dialog type.
   * @param dialogFooter Dialog footer.
   * @param directoryModel Directory model.
   * @param volumeManager Volume manager.
   * @param fileFilter File filter model.
   * @param namingController Naming controller.
   * @param fileSelectionHandler Initial file selection.
   * @param launchParam Whether the dialog should return local path or not.
   */
  constructor(
      private dialogType_: DialogType, private dialogFooter_: DialogFooter,
      private directoryModel_: DirectoryModel,
      private volumeManager_: VolumeManager, private fileFilter_: FileFilter,
      private namingController_: NamingController,
      private fileSelectionHandler_: FileSelectionHandler,
      launchParam: LaunchParam) {
    /**
     * List of acceptable file types for open dialog.
     */
    this.fileTypes_ = launchParam.typeList || [];
    this.allowedPaths_ = launchParam.allowedPaths;

    this.dialogFooter_.okButton.addEventListener(
        'click', this.processOkAction_.bind(this));
    this.dialogFooter_.cancelButton.addEventListener(
        'click', this.onCancelBound_);
    this.dialogFooter_.newFolderButton.addEventListener(
        'click', this.processNewFolderAction_.bind(this));
    this.dialogFooter_.fileTypeSelector?.addEventListener(
        'change', this.onFileTypeFilterChanged_.bind(this));
    this.dialogFooter_.filenameInput.addEventListener(
        'input', this.updateOkButton_.bind(this));
    this.fileSelectionHandler_.addEventListener(
        EventType.CHANGE_THROTTLED, this.onFileSelectionChanged_.bind(this));
    this.volumeManager_.addEventListener(
        'drive-connection-changed', this.updateOkButton_.bind(this));

    this.dialogFooter_.initFileTypeFilter(
        this.fileTypes_, launchParam.includeAllFiles);
    this.onFileTypeFilterChanged_();

    this.newFolderCommand_.addEventListener(
        'disabledChange', this.updateNewFolderButton_.bind(this));
  }

  /**
   */
  private async processOkActionForSaveDialog_() {
    const selection = this.fileSelectionHandler_.selection;

    // If OK action is clicked when a directory is selected, open the directory.
    if (selection.directoryCount === 1 && selection.fileCount === 0) {
      this.directoryModel_.changeDirectoryEntry(
          selection.entries[0] as DirectoryEntry | FilesAppDirEntry);
      return;
    }

    // Save-as doesn't require a valid selection from the list, since
    // we're going to take the filename from the text input.
    this.updateExtensionForSelectedFileType_(true);
    const filename = this.dialogFooter_.filenameInput.value;
    if (!filename) {
      console.warn('Missing filename');
      return;
    }

    try {
      const url =
          await this.namingController_.validateFileNameForSaving(filename);

      this.selectFilesAndClose_({
        urls: [url],
        multiple: false,
        filterIndex: this.dialogFooter_.selectedFilterIndex,
      });
    } catch (error) {
      if (!(error instanceof UserCanceledError)) {
        console.warn(error);
      }
    }
  }

  /**
   * Handle a click of the ok button.
   *
   * The ok button has different UI labels depending on the type of dialog, but
   * in code it's always referred to as 'ok'.
   *
   */
  private processOkAction_() {
    if (this.dialogFooter_.okButton.disabled) {
      console.warn('okButton Disabled');
      return;
    }
    if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE) {
      this.processOkActionForSaveDialog_();
      return;
    }

    const files = [];
    const selectedIndexes =
        this.directoryModel_.getFileListSelection().selectedIndexes;

    if (isFolderDialogType(this.dialogType_) && selectedIndexes.length === 0) {
      const url = this.directoryModel_.getCurrentDirEntry()!.toURL();
      const singleSelection = {
        urls: [url],
        multiple: false,
        filterIndex: this.dialogFooter_.selectedFilterIndex,
      };
      this.selectFilesAndClose_(singleSelection);
      return;
    }

    // All other dialog types require at least one selected list item.
    // The logic to control whether or not the ok button is enabled should
    // prevent us from ever getting here, but we sanity check to be sure.
    if (!selectedIndexes.length) {
      console.warn('Nothing selected in the file list');
      return;
    }

    const dm = this.directoryModel_.getFileList();
    for (let i = 0; i < selectedIndexes.length; i++) {
      const index = selectedIndexes[i];
      const entry = index === undefined ? null : dm.item(index);
      if (!entry) {
        console.warn('Error locating selected file at index: ' + i);
        continue;
      }

      files.push(entry.toURL());
    }

    // Multi-file selection has no other restrictions.
    if (this.dialogType_ === DialogType.SELECT_OPEN_MULTI_FILE) {
      const multipleSelection = {
        urls: files,
        multiple: true,
      };
      this.selectFilesAndClose_(multipleSelection);
      return;
    }

    // Everything else must have exactly one.
    if (files.length > 1) {
      console.warn('Too many files selected');
      return;
    }

    const selectedEntry = dm.item(selectedIndexes[0] ?? -1)!;

    if (isFolderDialogType(this.dialogType_)) {
      if (!selectedEntry.isDirectory) {
        console.warn('Selected entry is not a folder');
        return;
      }
    } else if (this.dialogType_ === DialogType.SELECT_OPEN_FILE) {
      if (!selectedEntry.isFile) {
        console.warn('Selected entry is not a file');
        return;
      }
    }

    const singleSelection: SelectFilesAndCloseParams = {
      urls: [files[0]!],
      multiple: false,
      filterIndex: this.dialogFooter_.selectedFilterIndex,
    };
    this.selectFilesAndClose_(singleSelection);
  }

  /**
   * Cancels file selection and closes the file selection dialog.
   */
  private processCancelAction_() {
    chrome.fileManagerPrivate.cancelDialog();
    window.close();
  }

  /**
   * Creates a new folder using new-folder command.
   */
  private processNewFolderAction_() {
    this.newFolderCommand_.canExecuteChange(this.dialogFooter_.newFolderButton);
    this.newFolderCommand_.execute(this.dialogFooter_.newFolderButton);
  }

  /**
   * Handles disabledChange event to update the new-folder button's
   * avaliability.
   */
  private updateNewFolderButton_() {
    this.dialogFooter_.newFolderButton.disabled =
        this.newFolderCommand_.disabled;
  }

  /**
   * Tries to close this modal dialog with some files selected.
   * Performs preprocessing if needed (e.g. for Drive).
   * @param selection Contains urls, filterIndex and multiple fields.
   */
  private selectFilesAndClose_(selection: SelectFilesAndCloseParams) {
    const currentRootType = this.directoryModel_.getCurrentRootType();
    const onFileSelected = () => {
      if (!chrome.runtime.lastError) {
        // Call next method on a timeout, as it's unsafe to
        // close a window from a callback.
        setTimeout(window.close.bind(window), 0);
      }
    };
    // Record the root types of chosen files in OPEN dialog.
    if (this.dialogType_ === DialogType.SELECT_OPEN_FILE ||
        this.dialogType_ === DialogType.SELECT_OPEN_MULTI_FILE) {
      recordEnum('OpenFiles.RootType', currentRootType, RootTypesForUMA);
    }

    const state = getStore().getState();
    for (const url of selection.urls) {
      recordViewingVolumeTypeUma(state, url);
      // Recorded per file.
      recordViewingNavigationSurfaceUma(state);
    }

    if (selection.multiple) {
      chrome.fileManagerPrivate.selectFiles(
          selection.urls, this.allowedPaths_ === AllowedPaths.NATIVE_PATH,
          onFileSelected);
    } else {
      chrome.fileManagerPrivate.selectFile(
          selection.urls[0]!, selection.filterIndex!,
          this.dialogType_ !== DialogType.SELECT_SAVEAS_FILE /* for opening */,
          this.allowedPaths_ === AllowedPaths.NATIVE_PATH, onFileSelected);
    }
  }

  /**
   * Returns the regex to match against files for the current filter.
   */
  private regexpForCurrentFilter_(): RegExp|null {
    // Note selectedFilterIndex indexing is 1-based. (0 is "all files").
    const selectedIndex = this.dialogFooter_.selectedFilterIndex;
    if (selectedIndex < 1) {
      return null;  // No specific filter selected.
    }
    return new RegExp(
        '\\.(' + this.fileTypes_[selectedIndex - 1]!.extensions.join('|') +
            ')$',
        'i');
  }

  /**
   * Updates the file input field to agree with the current filter.
   * @param forConfirm The update is for the final confirm step.
   */
  private updateExtensionForSelectedFileType_(forConfirm: boolean) {
    const regexp = this.regexpForCurrentFilter_();
    if (!regexp) {
      return;  // No filter selected.
    }

    let filename = this.dialogFooter_.filenameInput.value;
    if (!filename || regexp.test(filename)) {
      return;  // Filename empty or already satisfies filter.
    }

    const selectedIndex = this.dialogFooter_.selectedFilterIndex;
    assert(selectedIndex > 0);  // Otherwise there would be no regex.
    const newExtension = this.fileTypes_[selectedIndex - 1]!.extensions[0];
    if (!newExtension) {
      return;  // No default extension.
    }

    const extensionIndex = filename.lastIndexOf('.');
    if (extensionIndex < 0) {
      // No current extension.
      if (!forConfirm) {
        return;  // Add one later.
      }
      filename = `${filename}.${newExtension}`;
    } else {
      if (forConfirm) {
        return;  // Keep the current user choice.
      }
      filename = `${filename.substr(0, extensionIndex)}.${newExtension}`;
    }

    this.dialogFooter_.filenameInput.value = filename;
    this.dialogFooter_.selectTargetNameInFilenameInput();
  }

  /**
   * Filters file according to the selected file type.
   */
  private onFileTypeFilterChanged_() {
    this.fileFilter_.removeFilter('fileType');
    const regexp = this.regexpForCurrentFilter_();
    if (!regexp) {
      return;
    }

    const filter = (entry: Entry|FilesAppEntry) =>
        entry.isDirectory || regexp.test(entry.name);
    this.fileFilter_.addFilter('fileType', filter);

    // In save dialog, update the destination name extension.
    if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE) {
      this.updateExtensionForSelectedFileType_(false);
    }
  }

  /**
   * Handles selection change.
   */
  private onFileSelectionChanged_() {
    // If this is a save-as dialog, copy the selected file into the filename
    // input text box.
    const selection = this.fileSelectionHandler_.selection;
    if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE &&
        selection.totalCount === 1 && selection.entries[0]!.isFile &&
        this.dialogFooter_.filenameInput.value !== selection.entries[0]!.name) {
      this.dialogFooter_.filenameInput.value = selection.entries[0]!.name;
    }

    this.updateOkButton_();
    if (!this.dialogFooter_.okButton.disabled) {
      testSendMessage('dialog-ready');
    }
  }

  /**
   * Updates the Ok button enabled state.
   */
  private updateOkButton_() {
    const selection = this.fileSelectionHandler_.selection;

    if (this.dialogType_ === DialogType.FULL_PAGE) {
      // No "select" buttons on the full page UI.
      this.dialogFooter_.okButton.disabled = false;
      return;
    }

    if (isFolderDialogType(this.dialogType_)) {
      // In SELECT_FOLDER mode, we allow to select current directory
      // when nothing is selected.
      this.dialogFooter_.okButton.disabled =
          selection.directoryCount > 1 || selection.fileCount !== 0;
      return;
    }

    if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE) {
      if (selection.directoryCount === 1 && selection.fileCount === 0) {
        this.dialogFooter_.okButtonLabel.textContent = str('OPEN_LABEL');
        this.dialogFooter_.okButton.disabled =
            this.fileSelectionHandler_.isDlpBlocked();
      } else {
        this.dialogFooter_.okButtonLabel.textContent = str('SAVE_LABEL');
        this.dialogFooter_.okButton.disabled =
            this.directoryModel_.isReadOnly() ||
            this.directoryModel_.isDlpBlocked() ||
            !this.dialogFooter_.filenameInput.value ||
            !this.fileSelectionHandler_.isAvailable();
      }
      return;
    }

    if (this.dialogType_ === DialogType.SELECT_OPEN_FILE) {
      this.dialogFooter_.okButton.disabled = selection.directoryCount !== 0 ||
          selection.fileCount !== 1 ||
          !this.fileSelectionHandler_.isAvailable() ||
          this.fileSelectionHandler_.isDlpBlocked();
      return;
    }

    if (this.dialogType_ === DialogType.SELECT_OPEN_MULTI_FILE) {
      this.dialogFooter_.okButton.disabled = selection.directoryCount !== 0 ||
          selection.fileCount === 0 ||
          !this.fileSelectionHandler_.isAvailable() ||
          this.fileSelectionHandler_.isDlpBlocked();
      return;
    }

    assertNotReached('Unknown dialog type.');
  }
}