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

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type {CrToggleElement} from 'chrome://resources/ash/common/cr_elements/cr_toggle/cr_toggle.js';
import type {Switch} from 'chrome://resources/cros_components/switch/switch.js';
import {assertInstanceof} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import {queryRequiredElement} from '../../common/js/dom_utils.js';
import {isNonModifiable} from '../../common/js/entry_utils.js';
import {isCrosComponentsEnabled} from '../../common/js/flags.js';
import {str, strf} from '../../common/js/translations.js';
import {canBulkPinningCloudPanelShow} from '../../common/js/util.js';
import {RootType} from '../../common/js/volume_manager_types.js';
import {type State} from '../../state/state.js';
import {getStore, type Store} from '../../state/store.js';
import {XfCloudPanel} from '../../widgets/xf_cloud_panel.js';

import {ICON_TYPES} from './constants.js';
import type {DirectoryChangeEvent, DirectoryModel} from './directory_model.js';
import type {FileSelectionHandler} from './file_selection.js';
import {EventType} from './file_selection.js';
import type {A11yAnnounce} from './ui/a11y_announce.js';
import {Command} from './ui/command.js';
import type {FileListSelectionModel} from './ui/file_list_selection_model.js';
import type {ListContainer} from './ui/list_container.js';

// Helper function that extract common pattern for getting commands associated
// with the toolbar and also deals with lack of return type information in
// assertInstaneof function.
function getCommand(selector: string, body: HTMLBodyElement): Command {
  const element = queryRequiredElement(selector, body);
  assertInstanceof(element, Command);
  return element as Command;
}

/**
 * This class controls wires toolbar UI and selection model. When selection
 * status is changed, this class changes the view of toolbar. If cancel
 * selection button is pressed, this class clears the selection.
 */
export class ToolbarController {
  // HTML Elements
  private readonly cancelSelectionButton_: HTMLElement;
  private readonly filesSelectedLabel_: HTMLElement;
  private readonly deleteButton_: HTMLElement;
  private readonly moveToTrashButton_: HTMLElement;
  private readonly restoreFromTrashButton_: HTMLElement;
  private readonly sharesheetButton_: HTMLElement;
  private readonly readOnlyIndicator_: HTMLElement;
  private readonly pinnedToggleWrapper_: HTMLElement;
  private readonly pinnedToggle_: CrToggleElement|null;
  private readonly pinnedToggleJelly_: Switch|null;
  private readonly cloudButton_: HTMLElement;
  private readonly cloudStatusIcon_: HTMLElement;
  private readonly cloudButtonIcon_: HTMLElement;
  // Commands
  private readonly deleteCommand_: Command;
  readonly moveToTrashCommand: Command;
  private readonly restoreFromTrashCommand_: Command;
  private readonly refreshCommand_: Command;
  private readonly newFolderCommand_: Command;
  private readonly invokeSharesheetCommand_: Command;
  private readonly togglePinnedCommand_: Command;
  // Other
  private bulkPinningEnabled_: boolean;
  private store_: Store;

  constructor(
      private toolbar_: HTMLElement, _navigationList: HTMLElement,
      private listContainer_: ListContainer,
      private selectionHandler_: FileSelectionHandler,
      private directoryModel_: DirectoryModel,
      private volumeManager_: VolumeManager, private a11y_: A11yAnnounce) {
    // HTML elements.
    this.cancelSelectionButton_ =
        queryRequiredElement('#cancel-selection-button', this.toolbar_);
    this.filesSelectedLabel_ =
        queryRequiredElement('#files-selected-label', this.toolbar_);
    this.deleteButton_ = queryRequiredElement('#delete-button', this.toolbar_);
    this.moveToTrashButton_ =
        queryRequiredElement('#move-to-trash-button', this.toolbar_);
    this.restoreFromTrashButton_ =
        queryRequiredElement('#restore-from-trash-button', this.toolbar_);
    this.sharesheetButton_ =
        queryRequiredElement('#sharesheet-button', this.toolbar_);
    this.readOnlyIndicator_ =
        queryRequiredElement('#read-only-indicator', this.toolbar_);
    this.pinnedToggleWrapper_ =
        queryRequiredElement('#pinned-toggle-wrapper', this.toolbar_);
    if (isCrosComponentsEnabled()) {
      this.pinnedToggle_ = null;
      this.pinnedToggleJelly_ =
          queryRequiredElement('#pinned-toggle-jelly', this.toolbar_) as Switch;
    } else {
      this.pinnedToggle_ =
          queryRequiredElement('#pinned-toggle', this.toolbar_) as
          CrToggleElement;
      this.pinnedToggleJelly_ = null;
    }
    this.cloudButton_ = queryRequiredElement('#cloud-button', this.toolbar_);
    this.cloudStatusIcon_ = queryRequiredElement(
        '#cloud-button > xf-icon[slot="suffix-icon"]', this.toolbar_);
    this.cloudButtonIcon_ = queryRequiredElement(
        '#cloud-button > xf-icon[slot="prefix-icon"]', this.toolbar_);

    // Commands.
    const body = this.toolbar_.ownerDocument.body as HTMLBodyElement;
    this.deleteCommand_ = getCommand('command#delete', body);
    this.moveToTrashCommand = getCommand('#move-to-trash', body);
    this.restoreFromTrashCommand_ = getCommand('#restore-from-trash', body);
    this.refreshCommand_ = getCommand('#refresh', body);
    this.newFolderCommand_ = getCommand('#new-folder', body);
    this.invokeSharesheetCommand_ = getCommand('#invoke-sharesheet', body);
    this.togglePinnedCommand_ = getCommand('#toggle-pinned', body);

    this.bulkPinningEnabled_ = false;

    this.store_ = getStore();
    this.store_.subscribe(this);

    this.selectionHandler_.addEventListener(
        EventType.CHANGE, this.onSelectionChanged_.bind(this));

    // Using CHANGE_THROTTLED because updateSharesheetCommand_() uses async
    // API and can update the state out-of-order specially when updating to
    // an empty selection.
    this.selectionHandler_.addEventListener(
        EventType.CHANGE_THROTTLED, this.updateSharesheetCommand_.bind(this));

    chrome.fileManagerPrivate.onAppsUpdated.addListener(
        this.updateSharesheetCommand_.bind(this));

    this.cancelSelectionButton_.addEventListener(
        'click', this.onCancelSelectionButtonClicked_.bind(this));

    this.deleteButton_.addEventListener(
        'click', this.onDeleteButtonClicked_.bind(this));

    this.moveToTrashButton_.addEventListener(
        'click', this.onMoveToTrashButtonClicked_.bind(this));

    this.restoreFromTrashButton_.addEventListener(
        'click', this.onRestoreFromTrashButtonClicked_.bind(this));

    this.sharesheetButton_.addEventListener(
        'click', this.onSharesheetButtonClicked_.bind(this));

    const cloudPanel = queryRequiredElement('xf-cloud-panel') as XfCloudPanel;
    this.cloudButton_.addEventListener('click', () => {
      this.cloudButton_.toggleAttribute('menu-shown', true);
      this.cloudButton_.toggleAttribute('aria-expanded', true);
      cloudPanel.showAt(this.cloudButton_);
    });
    cloudPanel.addEventListener(XfCloudPanel.events.PANEL_CLOSED, () => {
      this.cloudButton_.toggleAttribute('menu-shown', false);
      this.cloudButton_.toggleAttribute('aria-expanded', false);
    });

    this.togglePinnedCommand_.addEventListener(
        'checkedChange', this.updatePinnedToggle_.bind(this));

    this.moveToTrashCommand.addEventListener(
        'hiddenChange', this.updateMoveToTrashCommand_.bind(this));

    this.moveToTrashCommand.addEventListener(
        'disabledChange', this.updateMoveToTrashCommand_.bind(this));

    this.togglePinnedCommand_.addEventListener(
        'disabledChange', this.updatePinnedToggle_.bind(this));

    this.togglePinnedCommand_.addEventListener(
        'hiddenChange', this.updatePinnedToggle_.bind(this));

    (this.pinnedToggleJelly_ || this.pinnedToggle_)!.addEventListener(
        'change', this.onPinnedToggleChanged_.bind(this));

    this.directoryModel_.addEventListener(
        'directory-changed',
        this.updateCurrentDirectoryButtons_.bind(this) as
            EventListenerOrEventListenerObject);
  }

  /**
   * Updates toolbar's UI elements which are related to current directory.
   */
  private updateCurrentDirectoryButtons_(event: DirectoryChangeEvent) {
    this.updateRefreshCommand_();

    this.newFolderCommand_.canExecuteChange(this.listContainer_.currentList);

    const currentDirectory = this.directoryModel_.getCurrentDirEntry();
    const locationInfo = currentDirectory &&
        this.volumeManager_.getLocationInfo(currentDirectory);
    // Normally, isReadOnly can be used to show the label. This property
    // is always true for fake volumes (eg. Google Drive root). However, "Linux
    // files" and GuestOS volumes are fake volume on first access until the VM
    // is loaded and the mount point is initialised. The volume is technically
    // read-only since the temporary fake volume can (and should) not be
    // written to. However, showing the read only label is not appropriate since
    // the volume will become read-write once all loading has completed.
    this.readOnlyIndicator_.hidden =
        !(locationInfo && locationInfo.isReadOnly &&
          locationInfo.rootType !== RootType.CROSTINI &&
          locationInfo.rootType !== RootType.GUEST_OS);

    const newDirectory = event.detail.newDirEntry;
    if (newDirectory) {
      const locationInfo = this.volumeManager_.getLocationInfo(newDirectory);
      const bodyClassList =
          this.filesSelectedLabel_.ownerDocument.body.classList;
      if (locationInfo && locationInfo.rootType === RootType.TRASH) {
        bodyClassList.add('check-select-v1');
      } else {
        bodyClassList.remove('check-select-v1');
      }
    }
  }

  private updateRefreshCommand_() {
    this.refreshCommand_.canExecuteChange(this.listContainer_.currentList);
  }

  /**
   * Handles selection's change event to update the UI.
   */
  private onSelectionChanged_() {
    const selection = this.selectionHandler_.selection;
    this.updateRefreshCommand_();

    // Update the label "x files selected." on the header.
    let text = '';
    if (selection.totalCount === 0) {
      text = '';
    } else if (selection.totalCount === 1) {
      if (selection.directoryCount === 0) {
        text = str('ONE_FILE_SELECTED');
      } else if (selection.fileCount === 0) {
        text = str('ONE_DIRECTORY_SELECTED');
      }
    } else {
      if (selection.directoryCount === 0) {
        text = strf('MANY_FILES_SELECTED', selection.fileCount);
      } else if (selection.fileCount === 0) {
        text = strf('MANY_DIRECTORIES_SELECTED', selection.directoryCount);
      } else {
        text = strf('MANY_ENTRIES_SELECTED', selection.totalCount);
      }
    }
    this.filesSelectedLabel_.textContent = text;

    // Update visibility of the delete and move to trash buttons.
    this.deleteButton_.hidden =
        (selection.totalCount === 0 ||
         !this.directoryModel_.canDeleteEntries() ||
         selection.hasReadOnlyEntry() ||
         selection.entries.some(
             entry => isNonModifiable(this.volumeManager_, entry)));
    // Show 'Move to Trash' rather than 'Delete' if possible. The
    // `moveToTrashCommand` needs to be set to hidden to ensure the
    // `canExecuteChange` invokes the `hiddenChange` event in the case where
    // Trash should be shown.
    this.moveToTrashButton_.hidden = true;
    this.moveToTrashCommand.disabled = true;
    if (!this.deleteButton_.hidden) {
      this.moveToTrashCommand.canExecuteChange(this.listContainer_.currentList);
    }

    // Update visibility of the restore-from-trash button.
    this.restoreFromTrashButton_.hidden = (selection.totalCount === 0) ||
        this.directoryModel_.getCurrentRootType() !== RootType.TRASH;

    this.togglePinnedCommand_.canExecuteChange(this.listContainer_.currentList);

    // Set .selecting class to containing element to change the view
    // accordingly.
    // TODO(fukino): This code changes the state of body, not the toolbar, to
    // update the checkmark visibility on grid view. This should be moved to a
    // controller which controls whole app window. Or, both toolbar and FileGrid
    // should listen to the FileSelectionHandler.
    if (this.directoryModel_.getFileListSelection().multiple) {
      const bodyClassList =
          this.filesSelectedLabel_.ownerDocument.body.classList;
      bodyClassList.toggle('selecting', selection.totalCount > 0);
      if (bodyClassList.contains('check-select') !==
          (this.directoryModel_.getFileListSelection() as
           FileListSelectionModel)
              .getCheckSelectMode()) {
        bodyClassList.toggle('check-select');
        bodyClassList.toggle('check-select-v1');
      }
    }
  }

  /**
   * Handles click event for cancel button to change the selection state.
   */
  private onCancelSelectionButtonClicked_() {
    this.directoryModel_.selectEntries([]);
    this.a11y_.speakA11yMessage(str('SELECTION_CANCELLATION'));
  }

  /**
   * Handles click event for delete button to execute the delete command.
   */
  private onDeleteButtonClicked_() {
    this.deleteCommand_.canExecuteChange(this.listContainer_.currentList);
    this.deleteCommand_.execute(this.listContainer_.currentList);
  }

  /**
   * Handles click event for move to trash button to execute the move to trash
   * command.
   */
  private onMoveToTrashButtonClicked_() {
    this.moveToTrashCommand.canExecuteChange(this.listContainer_.currentList);
    this.moveToTrashCommand.execute(this.listContainer_.currentList);
  }

  /**
   * Handles click event for restore from trash button to execute the restore
   * command.
   */
  private onRestoreFromTrashButtonClicked_() {
    this.restoreFromTrashCommand_.canExecuteChange(
        this.listContainer_.currentList);
    this.restoreFromTrashCommand_.execute(this.listContainer_.currentList);
  }

  /**
   * Handles click event for sharesheet button to set button background color.
   */
  private onSharesheetButtonClicked_() {
    this.sharesheetButton_.setAttribute('menu-shown', '');
    this.toolbar_.ownerDocument.body.addEventListener('focusin', () => {
      this.sharesheetButton_.removeAttribute('menu-shown');
    }, {once: true});
  }

  private updateSharesheetCommand_() {
    this.invokeSharesheetCommand_.canExecuteChange(
        this.listContainer_.currentList);
  }

  private updatePinnedToggle_() {
    this.pinnedToggleWrapper_.hidden = this.togglePinnedCommand_.hidden;
    if (isCrosComponentsEnabled()) {
      this.pinnedToggleJelly_!.selected = this.togglePinnedCommand_.checked;
      this.pinnedToggleJelly_!.disabled = this.togglePinnedCommand_.disabled;
    } else {
      this.pinnedToggle_!.checked = this.togglePinnedCommand_.checked;
      this.pinnedToggle_!.disabled = this.togglePinnedCommand_.disabled;
    }
  }

  private onPinnedToggleChanged_() {
    this.togglePinnedCommand_.execute(this.listContainer_.currentList);

    // Optimistally update the command's properties so we get notified if they
    // change back.
    this.togglePinnedCommand_.checked = isCrosComponentsEnabled() ?
        this.pinnedToggleJelly_!.selected :
        this.pinnedToggle_!.checked;
  }

  private updateMoveToTrashCommand_() {
    if (!this.deleteButton_.hidden) {
      this.deleteButton_.hidden = !this.moveToTrashCommand.disabled;
      this.moveToTrashButton_.hidden = this.moveToTrashCommand.disabled;
    }
  }

  /**
   * Checks if the cloud icon should be showing or not based on the enablement
   * of the user preferences, the feature flag and the existing stage.
   */
  onStateChanged(state: State) {
    this.updateBulkPinning_(state);
  }

  /**
   * Updates the visibility of the cloud button and the "Available offline"
   * toggle based on whether the bulk pinning is enabled or not.
   */
  private updateBulkPinning_(state: State) {
    const enabled = !!state.preferences?.driveFsBulkPinningEnabled;
    const bulkPinning = state.bulkPinning;
    const isNetworkMetered = state.drive?.connectionType ===
        chrome.fileManagerPrivate.DriveConnectionStateType.METERED;
    // If bulk-pinning is enabled, the user should not be able to toggle items
    // offline.
    if (this.bulkPinningEnabled_ !== enabled) {
      this.bulkPinningEnabled_ = enabled;
      this.togglePinnedCommand_.canExecuteChange(
          this.listContainer_.currentList);
    }

    if (!canBulkPinningCloudPanelShow(bulkPinning?.stage, enabled)) {
      this.cloudButton_.hidden = true;
      return;
    }

    this.updateBulkPinningIcon_(bulkPinning, isNetworkMetered);
    this.cloudButton_.hidden = false;
  }

  /**
   * Encapsulates the logic to update the bulk pinning cloud icon and the sub
   * icons that indicate the current stage it is in.
   */
  private updateBulkPinningIcon_(
      progress: chrome.fileManagerPrivate.BulkPinProgress|undefined,
      isNetworkMetered: boolean) {
    if (isNetworkMetered) {
      this.cloudButton_.ariaLabel = str('BULK_PINNING_BUTTON_LABEL_PAUSED');
      this.cloudButtonIcon_.setAttribute('type', ICON_TYPES.CLOUD);
      this.cloudStatusIcon_.setAttribute('type', ICON_TYPES.CLOUD_PAUSED);
      this.cloudStatusIcon_.removeAttribute('size');
      return;
    }

    switch (progress?.stage) {
      case chrome.fileManagerPrivate.BulkPinStage.SYNCING:
        this.cloudButtonIcon_.setAttribute('type', ICON_TYPES.CLOUD);
        if (progress.bytesToPin === 0 ||
            progress.pinnedBytes / progress.bytesToPin === 1) {
          this.cloudButton_.ariaLabel = str('BULK_PINNING_FILE_SYNC_ON');
          this.cloudStatusIcon_.setAttribute('type', ICON_TYPES.BLANK);
        } else {
          this.cloudButton_.ariaLabel =
              str('BULK_PINNING_BUTTON_LABEL_SYNCING');
          this.cloudStatusIcon_.setAttribute('type', ICON_TYPES.CLOUD_SYNC);
        }
        break;
      case chrome.fileManagerPrivate.BulkPinStage.NOT_ENOUGH_SPACE:
        this.cloudButton_.ariaLabel = str('BULK_PINNING_BUTTON_LABEL_ISSUE');
        this.cloudButtonIcon_.setAttribute('type', ICON_TYPES.CLOUD);
        this.cloudStatusIcon_.setAttribute('type', ICON_TYPES.CLOUD_ERROR);
        break;
      case chrome.fileManagerPrivate.BulkPinStage.PAUSED_OFFLINE:
        this.cloudButton_.ariaLabel = str('BULK_PINNING_BUTTON_LABEL_OFFLINE');
        this.cloudButtonIcon_.setAttribute(
            'type', ICON_TYPES.BULK_PINNING_OFFLINE);
        this.cloudStatusIcon_.removeAttribute('type');
        this.cloudStatusIcon_.removeAttribute('size');
        break;
      case chrome.fileManagerPrivate.BulkPinStage.PAUSED_BATTERY_SAVER:
        this.cloudButton_.ariaLabel = str('BULK_PINNING_BUTTON_LABEL_PAUSED');
        this.cloudButtonIcon_.setAttribute(
            'type', ICON_TYPES.BULK_PINNING_BATTERY_SAVER);
        this.cloudStatusIcon_.removeAttribute('type');
        this.cloudStatusIcon_.removeAttribute('size');
        break;
      default:
        this.cloudButton_.ariaLabel = str('BULK_PINNING_FILE_SYNC_ON');
        this.cloudButtonIcon_.setAttribute('type', ICON_TYPES.CLOUD);
        this.cloudStatusIcon_.setAttribute('type', ICON_TYPES.BLANK);
        this.cloudStatusIcon_.removeAttribute('size');
        break;
    }
  }
}