chromium/ui/file_manager/file_manager/foreground/js/main_window_component.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 {assertInstanceof} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import {isFolderDialogType} from '../../common/js/dialog_type.js';
import {getFocusedTreeItem, getKeyModifiers} from '../../common/js/dom_utils.js';
import {isDirectoryEntry, isRecentRootType, isSameEntry, isTrashEntry} from '../../common/js/entry_utils.js';
import {recordEnum} from '../../common/js/metrics.js';
import {str} from '../../common/js/translations.js';
import type {TrashEntry} from '../../common/js/trash.js';
import {RootType} from '../../common/js/volume_manager_types.js';
import {changeDirectory} from '../../state/ducks/current_directory.js';
import {DialogType} from '../../state/state.js';
import {getStore} from '../../state/store.js';

import type {AppStateController} from './app_state_controller.js';
import type {DirectoryChangeEvent, DirectoryModel} from './directory_model.js';
import type {FileSelectionHandler} from './file_selection.js';
import type {NamingController} from './naming_controller.js';
import type {TaskController} from './task_controller.js';
import {Command} from './ui/command.js';
import type {FileManagerUI} from './ui/file_manager_ui.js';
import {FileTapHandler, TapEvent} from './ui/file_tap_handler.js';
import {EventType, ListType, ListTypesForUMA} from './ui/list_container.js';

/**
 * Component for the main window.
 *
 * The class receives UI events from UI components that does not have their own
 * controller, and do corresponding action by using models/other controllers.
 *
 * The class also observes model/browser API's event to update the misc
 * components.
 */
export class MainWindowComponent {
  /**
   * True while a user is pressing <Tab>.
   * This is used for identifying the trigger causing the filelist to
   * be focused.
   */
  private pressingTab_ = false;

  private tapHandler_: FileTapHandler = new FileTapHandler();

  constructor(
      private dialogType_: DialogType, private ui_: FileManagerUI,
      private volumeManager_: VolumeManager,
      private directoryModel_: DirectoryModel,
      private selectionHandler_: FileSelectionHandler,
      private namingController_: NamingController,
      private appStateController_: AppStateController,
      private taskController_: TaskController) {
    // Register events.
    this.ui_.listContainer.element.addEventListener(
        'keydown', this.onListKeyDown_.bind(this));
    this.ui_.directoryTree?.addEventListener(
        'keydown', this.onDirectoryTreeKeyDown_.bind(this));
    this.ui_.listContainer.element.addEventListener(
        EventType.TEXT_SEARCH, this.onTextSearch_.bind(this));
    this.ui_.listContainer.table.list.addEventListener(
        'dblclick', this.onDoubleClick_.bind(this));
    this.ui_.listContainer.grid.addEventListener(
        'dblclick', this.onDoubleClick_.bind(this));
    this.ui_.listContainer.table.list.addEventListener(
        'touchstart', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.grid.addEventListener(
        'touchstart', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.table.list.addEventListener(
        'touchend', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.grid.addEventListener(
        'touchend', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.table.list.addEventListener(
        'touchmove', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.grid.addEventListener(
        'touchmove', this.handleTouchEvents_.bind(this));
    this.ui_.listContainer.table.list.addEventListener(
        'focus', this.onFileListFocus_.bind(this));
    this.ui_.listContainer.grid.addEventListener(
        'focus', this.onFileListFocus_.bind(this));
    /**
     * We are binding both click/keyup event here because "click" event will
     * be triggered multiple times if the Enter/Space key is being pressed
     * without releasing (because the focus is always on the button).
     */
    this.ui_.toggleViewButton.addEventListener(
        'click', this.onToggleViewButtonClick_.bind(this));
    this.ui_.toggleViewButton.addEventListener(
        'keyup', this.onToggleViewButtonClick_.bind(this));
    this.directoryModel_.addEventListener(
        'directory-changed',
        this.onDirectoryChanged_.bind(this) as
            EventListenerOrEventListenerObject);
    this.volumeManager_.addEventListener(
        'drive-connection-changed', this.onDriveConnectionChanged_.bind(this));
    this.onDriveConnectionChanged_();
    document.addEventListener('keydown', this.onKeyDown_.bind(this));
    document.addEventListener('keyup', this.onKeyUp_.bind(this));
    window.addEventListener('focus', this.onWindowFocus_.bind(this));
    addIsFocusedMethod();
  }

  /**
   * Handles touch events.
   */
  private handleTouchEvents_(event: TouchEvent) {
    // We only need to know that a tap happens somewhere in the list.
    // Also the 2nd parameter of handleTouchEvents is just passed back to the
    // callback. Therefore we can pass a dummy value -1.
    this.tapHandler_.handleTouchEvents(event, -1, (_e, _index, eventType) => {
      if (eventType === TapEvent.TAP) {
        const target = event.target as HTMLElement;
        // Taps on the checkmark should only toggle select the item.
        if (target.classList.contains('detail-checkmark') ||
            target.classList.contains('detail-icon')) {
          return false;
        }

        return this.handleOpenDefault_(event);
      }

      return false;
    });
  }

  /**
   * File list focus handler. Used to select the top most element on the list
   * if nothing was selected.
   *
   */
  private onFileListFocus_() {
    // If the file list is focused by <Tab>, select the first item if no item
    // is selected.
    if (this.pressingTab_) {
      const selection = this.selectionHandler_.selection;
      if (selection && selection.totalCount === 0) {
        const selectionModel = this.directoryModel_.getFileListSelection();
        const targetIndex =
            selectionModel.anchorIndex && selectionModel.anchorIndex !== -1 ?
            selectionModel.anchorIndex :
            0;
        this.directoryModel_.selectIndex(targetIndex);
      }
    }
  }

  /**
   * Handles a double click event.
   *
   * @param event The dblclick event.
   */
  private onDoubleClick_(event: MouseEvent) {
    this.handleOpenDefault_(event);
  }

  /**
   * Opens the selected item by the default command.
   * If the item is a directory, change current directory to it.
   * Otherwise, accepts the current selection.
   *
   * @param event The dblclick event.
   * @return true if successfully opened the item.
   */
  private handleOpenDefault_(event: MouseEvent|TouchEvent): boolean {
    if (this.namingController_.isRenamingInProgress()) {
      // Don't pay attention to clicks or taps during a rename.
      return false;
    }

    // It is expected that the target item should have already been selected
    // by previous touch or mouse event processing.
    const node = 'touchedElement' in event ?
        event.touchedElement as HTMLElement :
        event.srcElement as HTMLElement;
    const listItem = this.ui_.listContainer.findListItemForNode(node);
    const selection = this.selectionHandler_.selection;
    if (!listItem || !listItem.selected || selection.totalCount !== 1) {
      return false;
    }
    const trashEntries = selection.entries.filter(isTrashEntry);
    if (trashEntries.length > 0) {
      this.showFailedToOpenTrashItemDialog_(trashEntries);
      return false;
    }
    // If the selection is blocked by DLP restrictions, we don't allow to change
    // directory or the default action.
    if (this.selectionHandler_.isDlpBlocked()) {
      return false;
    }
    const entry = selection.entries[0];
    if (entry && isDirectoryEntry(entry)) {
      this.directoryModel_.changeDirectoryEntry(entry);
      return false;
    }

    return this.acceptSelection_();
  }

  /**
   * Accepts the current selection depending on the files app dialog mode.
   * @return true if successfully accepted the current selection.
   */
  private acceptSelection_(): boolean {
    if (this.dialogType_ === DialogType.FULL_PAGE) {
      // Files within the trash root should not have default tasks. They should
      // be restored first.
      if (this.directoryModel_.getCurrentRootType() === RootType.TRASH) {
        const selection = this.selectionHandler_.selection;
        if (!selection) {
          return true;
        }
        const trashEntries = selection.entries.filter(isTrashEntry);
        this.showFailedToOpenTrashItemDialog_(trashEntries);
        return true;
      }
      this.taskController_.getFileTasks()
          .then(tasks => {
            tasks.executeDefault();
          })
          .catch(error => {
            if (error) {
              console.warn(error.stack || error);
            }
          });
      return true;
    }

    if (!this.ui_.dialogFooter.okButton.disabled) {
      this.ui_.dialogFooter.okButton.click();
      return true;
    }

    return false;
  }

  /**
   * Show a confirm dialog that shows whether the current selection can't be
   * opened and offer to restore instead.
   * @param trashEntries The current selection.
   */
  private showFailedToOpenTrashItemDialog_(trashEntries: TrashEntry[]) {
    let msgTitle = str('OPEN_TRASHED_FILE_ERROR_TITLE');
    let msgDesc = str('OPEN_TRASHED_FILE_ERROR_DESC');
    if (trashEntries.length > 1) {
      msgTitle = str('OPEN_TRASHED_FILES_ERROR_TITLE');
      msgDesc = str('OPEN_TRASHED_FILES_ERROR_DESC');
    }
    const restoreCommand = document.getElementById('restore-from-trash');
    assertInstanceof(restoreCommand, Command);
    this.ui_.restoreConfirmDialog.showWithTitle(msgTitle, msgDesc, () => {
      restoreCommand.canExecuteChange(this.ui_.listContainer.currentList);
      restoreCommand.execute(this.ui_.listContainer.currentList);
    });
  }

  /**
   * Handles click/keyup event on the toggle-view button.
   * @param event Click or keyup event.
   */
  private onToggleViewButtonClick_(event: Event) {
    /**
     * This callback can be triggered by both mouse click and Enter/Space key,
     * so we explicitly check if the "click" event is triggered by keyboard
     * or not, if so, do nothing because this callback will be triggered
     * again by "keyup" event when users release the Enter/Space key.
     */
    if (event.type === 'click') {
      const pointerEvent = event as PointerEvent;
      if (pointerEvent.detail === 0) {  // Click is triggered by keyboard.
        return;
      }
    }
    if (event.type === 'keyup') {
      const keyboardEvent = event as KeyboardEvent;
      if (keyboardEvent.code !== 'Space' && keyboardEvent.code !== 'Enter') {
        return;
      }
    }
    const listType =
        this.ui_.listContainer.currentListType === ListType.DETAIL ?
        ListType.THUMBNAIL :
        ListType.DETAIL;
    this.ui_.setCurrentListType(listType);
    const msgId = listType === ListType.DETAIL ?
        'FILE_LIST_CHANGED_TO_LIST_VIEW' :
        'FILE_LIST_CHANGED_TO_LIST_THUMBNAIL_VIEW';
    this.ui_.speakA11yMessage(str(msgId));
    this.appStateController_.saveViewOptions();

    // The aria-label of toggleViewButton has been updated, we need to
    // explicitly show the tooltip.
    const toggleViewButton = this.ui_.toggleViewButton as HTMLElement;
    this.ui_.filesTooltip.updateTooltipText(toggleViewButton);
    recordEnum('ToggleFileListType', listType, ListTypesForUMA);
  }

  /**
   * KeyDown event handler for the document.
   * @param event Key event.
   */
  private onKeyDown_(event: KeyboardEvent) {
    if (event.keyCode === 9) {  // Tab
      this.pressingTab_ = true;
    }

    if (event.srcElement === this.ui_.listContainer.renameInput) {
      // Ignore keydown handler in the rename input box.
      return;
    }

    switch (getKeyModifiers(event) + event.key) {
      case 'Escape':  // Escape => Cancel dialog.
      case 'Ctrl-w':  // Ctrl+W => Cancel dialog.
        if (this.dialogType_ !== DialogType.FULL_PAGE) {
          // If there is nothing else for ESC to do, then cancel the dialog.
          event.preventDefault();
          this.ui_.dialogFooter.cancelButton.click();
        }
        break;
    }
  }

  /**
   * KeyUp event handler for the document.
   * @param event Key event.
   */
  private onKeyUp_(event: KeyboardEvent) {
    if (event.keyCode === 9) {  // Tab
      this.pressingTab_ = false;
    }
  }

  /**
   * KeyDown event handler for the directory tree element.
   * @param event Key event.
   */
  private onDirectoryTreeKeyDown_(event: KeyboardEvent) {
    // Enter => Change directory or perform default action.
    if (getKeyModifiers(event) + event.key === 'Enter') {
      const focusedItem = getFocusedTreeItem(this.ui_.directoryTree);
      if (!focusedItem) {
        return;
      }
      focusedItem.selected = true;
      if (this.dialogType_ !== DialogType.FULL_PAGE &&
          !focusedItem.hasAttribute('renaming') &&
          isSameEntry(
              this.directoryModel_.getCurrentDirEntry(),
              (focusedItem as any).entry) &&
          !this.ui_.dialogFooter.okButton.disabled) {
        this.ui_.dialogFooter.okButton.click();
      }
    }
  }

  /**
   * KeyDown event handler for the div#list-container element.
   * @param event Key event.
   */
  private onListKeyDown_(event: KeyboardEvent) {
    switch (getKeyModifiers(event) + event.key) {
      case 'Backspace':  // Backspace => Up one directory.
        event.preventDefault();
        const store = getStore();
        const state = store.getState();
        const components = state.currentDirectory?.pathComponents;
        if (!components || components.length < 2) {
          break;
        }
        const parent = components[components.length - 2]!;
        store.dispatch(changeDirectory({toKey: parent.key}));
        break;

      case 'Enter':  // Enter => Change directory or perform default action.
                     // If the selection is blocked by DLP restrictions, we
                     // don't allow to
        // change directory or the default action.
        if (this.selectionHandler_.isDlpBlocked()) {
          break;
        }
        const selection = this.selectionHandler_.selection;
        if (selection.totalCount === 1 &&
            isDirectoryEntry(selection.entries[0]!) &&
            !isFolderDialogType(this.dialogType_) &&
            !selection.entries.some(isTrashEntry)) {
          const item = this.ui_.listContainer.currentList.getListItemByIndex(
              selection.indexes[0]!);
          // If the item is in renaming process we don't allow to change
          // directory.
          if (item && !item.hasAttribute('renaming')) {
            event.preventDefault();
            this.directoryModel_.changeDirectoryEntry(selection.entries[0]!);
          }
          break;
        }
        if (this.acceptSelection_()) {
          event.preventDefault();
        }
        break;
    }
  }

  /**
   * Performs a 'text search' - selects a first list entry with name
   * starting with entered text (case-insensitive).
   */
  private onTextSearch_() {
    const text = this.ui_.listContainer.textSearchState.text;
    const dm = this.directoryModel_.getFileList();
    for (let index = 0; index < dm.length; ++index) {
      const name = dm.item(index)!.name;
      if (name.substring(0, text.length).toLowerCase() === text) {
        const selectionModel =
            this.ui_.listContainer.currentList.selectionModel;
        if (selectionModel) {
          selectionModel.selectedIndexes = [index];
        }
        return;
      }
    }

    this.ui_.listContainer.textSearchState.text = '';
  }

  /**
   * Update the UI when the current directory changes.
   *
   * @param event The directory-changed event.
   */
  private onDirectoryChanged_(_event: DirectoryChangeEvent) {
    // Update unformatted volume status.
    const newVolumeInfo = this.directoryModel_.getCurrentVolumeInfo();
    const unformatted = !!(newVolumeInfo?.error);
    this.ui_.element.toggleAttribute('unformatted', unformatted);

    // Updates UI.
    if (this.dialogType_ === DialogType.FULL_PAGE) {
      const label = this.directoryModel_.getCurrentDirName();
      document.title = `${str('FILEMANAGER_APP_NAME')} - ${label}`;
    }
  }

  private onDriveConnectionChanged_() {
    const connection = this.volumeManager_.getDriveConnectionState();
    this.ui_.dialogContainer.setAttribute('connection', connection.type);
  }

  private onWindowFocus_() {
    // When the window have got a focus while the current directory is Recent
    // root, refresh the contents.
    if (isRecentRootType(this.directoryModel_.getCurrentRootType())) {
      this.directoryModel_.rescan(true /* refresh */);
      // Do not start the spinner here to silently refresh the contents.
    }
  }
}

/** Adds an isFocused method to the current window object.  */
const addIsFocusedMethod = () => {
  let focused = true;

  window.addEventListener('focus', () => {
    focused = true;
  });

  window.addEventListener('blur', () => {
    focused = false;
  });

  /**
   * @return True if focused.
   */
  window.isFocused = () => {
    return focused;
  };
};