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

// Copyright 2013 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 {ProgressCenterItem} from '../../../common/js/progress_center_common.js';
import {PolicyErrorType, type ProgressItemExtraButton, ProgressItemState, ProgressItemType} from '../../../common/js/progress_center_common.js';
import {secondsToRemainingTimeString, str, strf} from '../../../common/js/translations.js';
import type {DisplayPanel} from '../../elements/xf_display_panel.js';
import {PanelType, type UserData} from '../../elements/xf_panel_item.js';

/**
 * Progress center panel.
 */
export class ProgressCenterPanel {
  /**
   * Reference to the feedback panel host.
   */
  private feedbackHost_ =
      document.querySelector<DisplayPanel>('#progress-panel')!;

  /**
   * Items that are progressing, or completed.
   * Key is item ID.
   */
  private items_: Record<string, ProgressCenterItem> = {};

  /**
   * Callback to be called with the ID of the progress item when the cancel
   * button is clicked.
   */
  cancelCallback: null|((taskId: string) => void) = null;

  /**
   * Callback to be called with the ID of the error item when user pressed
   * dismiss button of it.
   */
  dismissErrorItemCallback: null|((taskId: string) => void) = null;

  /**
   * Defer showing in progress operation to avoid displaying quick
   * operations, e.g. the notification panel only shows if the task is
   * processing longer than this time.
   */
  private pendingTimeMs_: number = 2000;
  /**
   * Timeout for removing the notification panel, e.g. the notification
   * panel will be removed after this time.
   */
  private timeoutToRemoveMs_: number = 4000;

  constructor() {
    assert(this.feedbackHost_);
    if (window.IN_TEST) {
      this.pendingTimeMs_ = 0;
    }
  }

  setTimingForTests(pendingTimeMs: number, timeoutToRemoveMs: number): void {
    this.pendingTimeMs_ = pendingTimeMs;
    this.timeoutToRemoveMs_ = timeoutToRemoveMs;
  }

  /**
   * Generate source string for display on the feedback panel.
   * @param item Item we're generating a message for.
   * @param info Cached information to use for formatting.
   * @return String formatted based on the item state.
   */
  private generateSourceString_(item: ProgressCenterItem, info: null|UserData):
      string {
    info = info || {};
    const {source, count} = info;
    switch (item.state) {
      case ProgressItemState.SCANNING:
      case ProgressItemState.PROGRESSING:
        // Single items:
        if (item.itemCount === 1) {
          if (item.type === ProgressItemType.COPY) {
            return strf('COPY_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.EXTRACT) {
            return strf('EXTRACT_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.MOVE) {
            return strf('MOVE_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.DELETE) {
            return strf('DELETE_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.TRASH) {
            return strf('MOVE_TO_TRASH_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.RESTORE_TO_DESTINATION ||
              item.type === ProgressItemType.RESTORE) {
            return strf('RESTORING_FROM_TRASH_FILE_NAME', source);
          }
          return item.message;
        }

        // Multiple items:
        if (item.type === ProgressItemType.COPY) {
          return strf('COPY_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.EXTRACT) {
          return strf('EXTRACT_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.MOVE) {
          return strf('MOVE_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.DELETE) {
          return strf('DELETE_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.TRASH) {
          return strf('MOVE_TO_TRASH_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.RESTORE_TO_DESTINATION ||
            item.type === ProgressItemType.RESTORE) {
          return strf('RESTORING_FROM_TRASH_ITEMS_REMAINING', count);
        }
        return item.message;
      case ProgressItemState.COMPLETED:
        if (count && count > 1) {
          return strf('FILE_ITEMS', count);
        }
        return source || item.message;
      case ProgressItemState.ERROR:
        return item.message;
      case ProgressItemState.CANCELED:
        return '';
      default:
        assertNotReached();
    }
  }

  /**
   * Test if we have an empty or all whitespace string.
   * @param candidate String we're checking.
   * @return true if there's content in the candidate.
   */
  private isNonEmptyString_(candidate: string|undefined|
                            null): candidate is string {
    if (!candidate || candidate.trim().length === 0) {
      return false;
    }
    return true;
  }

  /**
   * Generate primary text string for display on the feedback panel.
   * It is used for TransferDetails mode.
   * @param item Item we're generating a message for.
   * @param info Cached information to use for formatting.
   * @return String formatted based on the item state.
   */
  private generatePrimaryString_(item: ProgressCenterItem, info: null|UserData):
      string {
    info = info || {};
    const {source, destination, count} = info;
    const hasDestination = this.isNonEmptyString_(destination);
    switch (item.state) {
      case ProgressItemState.SCANNING:
      case ProgressItemState.PROGRESSING:
        // Source and primary string are the same for missing destination.
        if (!hasDestination) {
          return this.generateSourceString_(item, info);
        }
        // fall through
      case ProgressItemState.COMPLETED:
        // Single items:
        if (item.itemCount === 1) {
          if (item.type === ProgressItemType.COPY) {
            return hasDestination ?
                getStrForCopyWithDestination(item, source ?? '', destination) :
                strf('FILE_COPIED', source);
          }
          if (item.type === ProgressItemType.EXTRACT) {
            return hasDestination ?
                strf('EXTRACT_FILE_NAME_LONG', source, destination) :
                strf('FILE_EXTRACTED', source);
          }
          if (item.type === ProgressItemType.MOVE) {
            return hasDestination ?
                getStrForMoveWithDestination(item, source ?? '', destination) :
                strf('FILE_MOVED', source);
          }
          if (item.type === ProgressItemType.ZIP) {
            return strf('ZIP_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.DELETE) {
            return strf('DELETE_FILE_NAME', source);
          }
          if (item.type === ProgressItemType.TRASH) {
            return item.state === ProgressItemState.PROGRESSING ?
                strf('MOVE_TO_TRASH_FILE_NAME', source) :
                strf('UNDO_DELETE_ONE', source);
          }
          if (item.type === ProgressItemType.RESTORE_TO_DESTINATION ||
              item.type === ProgressItemType.RESTORE) {
            return item.state === ProgressItemState.PROGRESSING ?
                strf('RESTORING_FROM_TRASH_FILE_NAME', source) :
                strf('RESTORE_TRASH_FILE_NAME', source);
          }
          return item.message;
        }

        // Multiple items:
        if (item.type === ProgressItemType.COPY) {
          return hasDestination ?
              item.isDestinationDrive ?
              strf('PREPARING_ITEMS_MY_DRIVE', count, destination) :
              strf('COPY_ITEMS_REMAINING_LONG', count, destination) :
              strf('FILE_ITEMS_COPIED', source);
        }
        if (item.type === ProgressItemType.EXTRACT) {
          return item.state === ProgressItemState.PROGRESSING ?
              strf('EXTRACT_ITEMS_REMAINING', count) :
              strf('FILE_ITEMS_EXTRACTED', count);
        }
        if (item.type === ProgressItemType.MOVE) {
          return hasDestination ?
              item.isDestinationDrive ?
              strf('PREPARING_ITEMS_MY_DRIVE', count, destination) :
              strf('MOVE_ITEMS_REMAINING_LONG', count, destination) :
              strf('FILE_ITEMS_MOVED', count);
        }
        if (item.type === ProgressItemType.ZIP) {
          return strf('ZIP_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.DELETE) {
          return strf('DELETE_ITEMS_REMAINING', count);
        }
        if (item.type === ProgressItemType.TRASH) {
          return item.state === ProgressItemState.PROGRESSING ?
              strf('MOVE_TO_TRASH_ITEMS_REMAINING', count) :
              strf('UNDO_DELETE_SOME', count);
        }
        if (item.type === ProgressItemType.RESTORE_TO_DESTINATION ||
            item.type === ProgressItemType.RESTORE) {
          return item.state === ProgressItemState.PROGRESSING ?
              strf('RESTORING_FROM_TRASH_ITEMS_REMAINING', count) :
              strf('RESTORE_TRASH_MANY_ITEMS', count);
        }
        return item.message;
      case ProgressItemState.PAUSED:
        switch (item.type) {
          case ProgressItemType.COPY:
            return str('DLP_FILES_COPY_REVIEW_TITLE');
          case ProgressItemType.MOVE:
          case ProgressItemType.RESTORE_TO_DESTINATION:
            return str('DLP_FILES_MOVE_REVIEW_TITLE');
          default:
            console.error('Unexpected operation type: ' + item.type);
            return '';
        }
      case ProgressItemState.ERROR:
        if (item.policyError) {
          return getStrForPolicyError(item);
        }
        if (item.skippedEncryptedFiles !== undefined &&
            item.skippedEncryptedFiles.length > 0) {
          switch (item.type) {
            case ProgressItemType.COPY:
              return item.skippedEncryptedFiles.length === 1 ?
                  strf(
                      'COPY_SKIPPED_ENCRYPTED_SINGLE_FILE',
                      item.skippedEncryptedFiles[0]) :
                  strf(
                      'COPY_SKIPPED_ENCRYPTED_FILES',
                      item.skippedEncryptedFiles.length);
            case ProgressItemType.MOVE:
              return item.skippedEncryptedFiles.length === 1 ?
                  strf(
                      'MOVE_SKIPPED_ENCRYPTED_SINGLE_FILE',
                      item.skippedEncryptedFiles[0]) :
                  strf(
                      'MOVE_SKIPPED_ENCRYPTED_FILES',
                      item.skippedEncryptedFiles.length);
          }
        }
        // General error
        return item.message;
      case ProgressItemState.CANCELED:
        return '';
      default:
        assertNotReached();
    }

    function getStrForMoveWithDestination(
        item: ProgressCenterItem, source: string, destination: string) {
      return item.isDestinationDrive ?
          strf('PREPARING_FILE_NAME_MY_DRIVE', source, destination) :
          strf('MOVE_FILE_NAME_LONG', source, destination);
    }

    function getStrForCopyWithDestination(
        item: ProgressCenterItem, source: string, destination: string) {
      return item.isDestinationDrive ?
          strf('PREPARING_FILE_NAME_MY_DRIVE', source, destination) :
          strf('COPY_FILE_NAME_LONG', source, destination);
    }

    function getStrForPolicyError(item: ProgressCenterItem) {
      if (!item.policyError) {
        console.warn('Policy error must be supplied');
        return '';
      }
      switch (item.policyError) {
        case PolicyErrorType.DLP:
        case PolicyErrorType.ENTERPRISE_CONNECTORS:
          if (!item.policyFileCount) {
            console.warn('Policy file count missing');
            return '';
          }
          switch (item.type) {
            case ProgressItemType.COPY:
              return item.policyFileCount === 1 ?
                  str('DLP_FILES_COPY_BLOCKED_TITLE_SINGLE') :
                  strf(
                      'DLP_FILES_COPY_BLOCKED_TITLE_MULTIPLE',
                      item.policyFileCount);
            case ProgressItemType.MOVE:
              return item.policyFileCount === 1 ?
                  str('DLP_FILES_MOVE_BLOCKED_TITLE_SINGLE') :
                  strf(
                      'DLP_FILES_MOVE_BLOCKED_TITLE_MULTIPLE',
                      item.policyFileCount);
            default:
              console.warn(`Unexpected task type: ${item.type}`);
              return '';
          }
        case PolicyErrorType.DLP_WARNING_TIMEOUT:
          switch (item.type) {
            case ProgressItemType.COPY:
              return str('DLP_FILES_COPY_TIMEOUT_TITLE');
            case ProgressItemType.MOVE:
              return str('DLP_FILES_MOVE_TIMEOUT_TITLE');
            default:
              console.warn(`Unexpected task type: ${item.type}`);
              return '';
          }
        default:
          console.warn(`Unexpected security error type: ${item.policyError}`);
          return '';
      }
    }
  }

  /**
   * Generates the secondary string to display on the feedback panel.
   *
   * The string can be empty in case of errors or tasks with no
   * remaining time set. In case of data protection policy related
   * notifications, the message provides more info about the state of the task.
   * Otherwise the message shows formatted remaining task time.
   *
   * The time format in hour and minute and the durations more
   * than 24 hours also formatted in hour.
   *
   * As ICU syntax is not implemented in web ui yet (crbug/481718), the i18n
   * of time part is handled using Intl methods.
   *
   * @param item Item we're generating a message for.
   * @return Secondary string message.
   */
  private generateSecondaryString_(item: ProgressCenterItem): string {
    if (item.state === ProgressItemState.PAUSED) {
      if (!item.policyFileCount) {
        console.warn('Policy file count missing');
        return '';
      }
      if (item.policyFileCount === 1) {
        if (!item.policyFileName) {
          console.warn('Policy file name missing');
          return '';
        }
        return strf('DLP_FILES_WARN_MESSAGE_SINGLE', item.policyFileName);
      } else {
        return strf('DLP_FILES_WARN_MESSAGE_MULTIPLE', item.policyFileCount);
      }
    }

    if (item.state === ProgressItemState.ERROR) {
      if (item.skippedEncryptedFiles.length > 0) {
        return str('ENCRYPTED_DETAILS');
      }
      if (!item.policyError) {
        // General error doesn't have secondary text.
        return '';
      }
      switch (item.policyError) {
        case PolicyErrorType.DLP:
          if (!item.policyFileCount) {
            console.warn('Policy file count missing');
            return '';
          }
          if (item.policyFileCount === 1) {
            if (!item.policyFileName) {
              console.warn('Policy file name missing');
              return '';
            }
            return strf(
                'DLP_FILES_BLOCKED_MESSAGE_POLICY_SINGLE', item.policyFileName);
          } else {
            return str('DLP_FILES_BLOCKED_MESSAGE_MULTIPLE');
          }
        case PolicyErrorType.ENTERPRISE_CONNECTORS:
          if (!item.policyFileCount) {
            console.warn('Policy file count missing');
            return '';
          }
          if (item.policyFileCount === 1) {
            if (!item.policyFileName) {
              console.warn('Policy file name missing');
              return '';
            }
            return strf(
                'DLP_FILES_BLOCKED_MESSAGE_CONTENT_SINGLE',
                item.policyFileName);
          } else {
            return str('DLP_FILES_BLOCKED_MESSAGE_MULTIPLE');
          }
        case PolicyErrorType.DLP_WARNING_TIMEOUT:
          switch (item.type) {
            case ProgressItemType.COPY:
              return str('DLP_FILES_COPY_TIMEOUT_MESSAGE');
            case ProgressItemType.MOVE:
              return str('DLP_FILES_MOVE_TIMEOUT_MESSAGE');
            default:
              console.warn(`Unexpected task type: ${item.type}`);
              return '';
          }
      }
    }

    const seconds = item.remainingTime;

    // Return empty string for unsupported operation (which didn't set
    // remaining time).
    if (seconds === undefined) {
      return '';
    }

    if (item.state === ProgressItemState.SCANNING) {
      if (item.itemCount === 1) {
        return str('SCANNING_LABEL');
      } else {
        return str('SCANNING_LABEL_PLURAL');
      }
    }

    // Check if remaining time is valid (ie finite and positive).
    if (!(isFinite(seconds) && seconds > 0)) {
      // Return empty string for invalid remaining time in non progressing
      // state.
      return item.state === ProgressItemState.PROGRESSING ?
          str('PREPARING_LABEL') :
          '';
    }

    return secondsToRemainingTimeString(seconds);
  }

  /**
   * Process item updates for feedback panels.
   * @param item Item being updated.
   * @param newItem Item updating with new content.
   */
  updateFeedbackPanelItem(
      item: ProgressCenterItem, newItem: null|ProgressCenterItem) {
    let panelItem = this.feedbackHost_.findPanelItemById(item.id);
    if (newItem) {
      if (!panelItem) {
        panelItem = this.feedbackHost_.createPanelItem(item.id);
        // Show the panel only for long running operations.
        setTimeout(() => {
          this.feedbackHost_.attachPanelItem(panelItem!);
        }, this.pendingTimeMs_);
        if (item.type === ProgressItemType.FORMAT) {
          panelItem.panelType = PanelType.FORMAT_PROGRESS;
        } else if (item.type === ProgressItemType.SYNC) {
          panelItem.panelType = PanelType.SYNC_PROGRESS;
        } else {
          panelItem.panelType = PanelType.PROGRESS;
        }
        // TODO(lucmult): Remove `userData`, it's only used in
        // generatePrimaryString_() which already refers to `item`.
        panelItem.userData = {
          'source': item.sourceMessage,
          'destination': item.destinationMessage,
          'count': item.itemCount,
        };
      }

      const primaryText = this.generatePrimaryString_(item, panelItem.userData);
      panelItem.secondaryText = this.generateSecondaryString_(item);
      panelItem.primaryText = primaryText;
      panelItem.setAttribute('data-progress-id', item.id);

      // Certain visual signals have the functionality to display an extra
      // button with an arbitrary callback.
      let extraButton: ProgressItemExtraButton|null = null;

      // On progress panels, make the cancel button aria-label more useful.
      const cancelLabel = strf('CANCEL_ACTIVITY_LABEL', primaryText);
      panelItem.closeButtonAriaLabel = cancelLabel;
      panelItem.signalCallback = (signal) => {
        if (signal === 'cancel' && item.cancelCallback) {
          item.cancelCallback();
        } else if (signal === 'dismiss') {
          if (item.dismissCallback) {
            item.dismissCallback();
          }
          this.feedbackHost_.removePanelItem(panelItem!);
          this.dismissErrorItemCallback?.(item.id);
        } else if (
            signal === 'extra-button' && extraButton &&
            'callback' in extraButton) {
          extraButton.callback();
          this.feedbackHost_.removePanelItem(panelItem!);
          // The extra-button currently acts as a dismissal to invoke the
          // dismiss and error item callbacks as well.
          if (item.dismissCallback) {
            item.dismissCallback();
          }
          this.dismissErrorItemCallback?.(item.id);
        }
      };
      panelItem.progress = item.progressRateInPercent.toString();
      switch (item.state) {
        case ProgressItemState.COMPLETED:
          // Create a completed panel for copies, moves, deletes and formats.
          if (item.type === ProgressItemType.COPY ||
              item.type === ProgressItemType.EXTRACT ||
              item.type === ProgressItemType.MOVE ||
              item.type === ProgressItemType.FORMAT ||
              item.type === ProgressItemType.ZIP ||
              item.type === ProgressItemType.DELETE ||
              item.type === ProgressItemType.TRASH ||
              item.type === ProgressItemType.RESTORE_TO_DESTINATION ||
              item.type === ProgressItemType.RESTORE) {
            const donePanelItem = this.feedbackHost_.addPanelItem(item.id);
            if (item.extraButton.has(ProgressItemState.COMPLETED)) {
              extraButton = item.extraButton.get(ProgressItemState.COMPLETED)!;
              donePanelItem.dataset['extraButtonText'] = extraButton.text;
            }
            donePanelItem.id = item.id;
            donePanelItem.panelType = PanelType.DONE;
            donePanelItem.primaryText = primaryText;
            donePanelItem.secondaryText = item.isDestinationDrive ?
                str('READY_TO_SYNC_MY_DRIVE') :
                str('COMPLETE_LABEL');
            donePanelItem.fadeSecondaryText = item.isDestinationDrive;
            donePanelItem.signalCallback = (signal) => {
              if (signal === 'dismiss') {
                this.feedbackHost_.removePanelItem(donePanelItem);
                delete this.items_[donePanelItem.id];
              } else if (
                  signal === 'extra-button' && extraButton &&
                  extraButton.callback) {
                extraButton.callback();
                this.feedbackHost_.removePanelItem(donePanelItem);
                delete this.items_[donePanelItem.id];
              }
            };
            // Delete after 4 seconds, doesn't matter if it's manually deleted
            // before the timer fires, as removePanelItem handles that case.
            setTimeout(() => {
              this.feedbackHost_.removePanelItem(donePanelItem);
              delete this.items_[donePanelItem.id];
            }, this.timeoutToRemoveMs_);
          }
          // Drop through to remove the progress panel.
          /* falls through */
        case ProgressItemState.CANCELED:
          // Remove the feedback panel when complete.
          this.feedbackHost_.removePanelItem(panelItem);
          break;
        case ProgressItemState.PAUSED:
          if (item.extraButton.has(ProgressItemState.PAUSED)) {
            extraButton = item.extraButton.get(ProgressItemState.PAUSED)!;
            panelItem.dataset['extraButtonText'] = extraButton.text;
          }
          panelItem.panelType = PanelType.INFO;
          this.feedbackHost_.attachPanelItem(panelItem);
          break;
        case ProgressItemState.ERROR:
          if (item.extraButton.has(ProgressItemState.ERROR)) {
            extraButton = item.extraButton.get(ProgressItemState.ERROR)!;
            panelItem.dataset['extraButtonText'] = extraButton.text;
          }
          panelItem.panelType = PanelType.ERROR;
          // Make sure the panel is attached so it shows immediately.
          this.feedbackHost_.attachPanelItem(panelItem);
          break;
      }
    } else if (panelItem) {
      this.feedbackHost_.removePanelItem(panelItem);
    }
  }

  /**
   * Starts the item update and checks state changes.
   * @param item Item containing updated information.
   */
  private updateItemState_(item: ProgressCenterItem) {
    // Compares the current state and the new state to check if the update is
    // valid or not.
    const previousItem = this.items_[item.id];
    switch (item.state) {
      case ProgressItemState.ERROR:
        if (previousItem &&
            (previousItem.state !== ProgressItemState.PROGRESSING &&
             previousItem.state !== ProgressItemState.PAUSED &&
             previousItem.state !== ProgressItemState.SCANNING)) {
          return;
        }
        this.items_[item.id] = item.clone();
        break;

      case ProgressItemState.PROGRESSING:
      case ProgressItemState.COMPLETED:
        if ((!previousItem && item.state === ProgressItemState.COMPLETED) ||
            (previousItem &&
             previousItem.state !== ProgressItemState.PROGRESSING)) {
          return;
        }
        this.items_[item.id] = item.clone();
        break;

      case ProgressItemState.CANCELED:
        if (!previousItem ||
            (previousItem.state !== ProgressItemState.PROGRESSING &&
             previousItem.state !== ProgressItemState.PAUSED &&
             previousItem.state !== ProgressItemState.SCANNING)) {
          return;
        }
        delete this.items_[item.id];
        break;

      case ProgressItemState.SCANNING:
        // Enterprise Connectors scanning is usually triggered in the beginning
        // except when DLP files restrictions are enabled as well. In this case,
        // DLP may pause the IOTask to show a warning and the panel item is
        // dismissed when the user proceeds or cancels.
        this.items_[item.id] = item.clone();
        break;

      default:
        if (this.items_[item.id] === null) {
          console.warn(
              'ProgressCenterItem not updated: ${item.id} state: ${item.state}');
        }
        break;
    }
  }

  /**
   * Updates an item to the progress center panel.
   * @param item Item including new contents.
   */
  updateItem(item: ProgressCenterItem) {
    this.updateItemState_(item);

    // Update an open view item.
    const newItem = this.items_[item.id] || null;
    this.updateFeedbackPanelItem(item, newItem);
  }

  /**
   * Called by background page when an error dialog is dismissed.
   * @param id Item id.
   */
  dismissErrorItem(id: string) {
    delete this.items_[id];
  }
}