chromium/ui/file_manager/file_manager/foreground/js/ui/file_manager_ui.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 '../../elements/icons.html.js';

import {assertInstanceof} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../../background/js/volume_manager.js';
import {crInjectTypeAndInit} from '../../../common/js/cr_ui.js';
import {queryDecoratedElement, queryRequiredElement} from '../../../common/js/dom_utils.js';
import type {FilesAppEntry} from '../../../common/js/files_app_entry_types.js';
import {isDlpEnabled} from '../../../common/js/flags.js';
import {str, strf} from '../../../common/js/translations.js';
import {AllowedPaths} from '../../../common/js/volume_manager_types.js';
import {BreadcrumbContainer} from '../../../containers/breadcrumb_container.js';
import {CloudPanelContainer} from '../../../containers/cloud_panel_container.js';
import type {DirectoryTreeContainer} from '../../../containers/directory_tree_container.js';
import {NudgeContainer} from '../../../containers/nudge_container.js';
import {SearchContainer} from '../../../containers/search_container.js';
import {DialogType} from '../../../state/state.js';
import type {XfCloudPanel} from '../../../widgets/xf_cloud_panel.js';
import type {XfConflictDialog} from '../../../widgets/xf_conflict_dialog.js';
import type {XfDlpRestrictionDetailsDialog} from '../../../widgets/xf_dlp_restriction_details_dialog.js';
import type {XfPasswordDialog} from '../../../widgets/xf_password_dialog.js';
import {XfSplitter} from '../../../widgets/xf_splitter.js';
import type {XfTree} from '../../../widgets/xf_tree.js';
import type {FilesFormatDialog} from '../../elements/files_format_dialog.js';
import type {FilesToast} from '../../elements/files_toast.js';
import type {FilesTooltip} from '../../elements/files_tooltip.js';
import type {BannerController} from '../banner_controller.js';
import type {LaunchParam} from '../launch_param.js';
import type {ProvidersModel} from '../providers_model.js';

import {ActionsSubmenu} from './actions_submenu.js';
import {ComboButton} from './combobutton.js';
import {contextMenuHandler} from './context_menu_handler.js';
import {DefaultTaskDialog} from './default_task_dialog.js';
import {DialogFooter} from './dialog_footer.js';
import {BaseDialog} from './dialogs.js';
import type {FileGrid} from './file_grid.js';
import type {FileTable} from './file_table.js';
import {FilesAlertDialog} from './files_alert_dialog.js';
import {FilesConfirmDialog} from './files_confirm_dialog.js';
import {FilesMenuItem} from './files_menu.js';
import {GearMenu} from './gear_menu.js';
import {ImportCrostiniImageDialog} from './import_crostini_image_dialog.js';
import {InstallLinuxPackageDialog} from './install_linux_package_dialog.js';
import {ListContainer, ListType} from './list_container.js';
import {Menu} from './menu.js';
import {MenuItem} from './menu_item.js';
import {MultiMenu} from './multi_menu.js';
import {MultiMenuButton} from './multi_menu_button.js';
import {ProgressCenterPanel} from './progress_center_panel.js';
import {ProvidersMenu} from './providers_menu.js';



/**
 * The root of the file manager's view managing the DOM of the Files app.
 */
// eslint-disable-next-line @typescript-eslint/naming-convention
export class FileManagerUI {
  /**
   * Dialog type.
   */
  private dialogType_: DialogType;

  /**
   * Alert dialog. Overrides ActionModelUI declaration.
   */
  readonly alertDialog: FilesAlertDialog;

  /**
   * List container. Overrides ActionModelUI declaration.
   */
  private listContainer_: ListContainer|null = null;

  /**
   * Confirm dialog.
   */
  readonly confirmDialog: FilesConfirmDialog;

  /**
   * Confirm dialog for delete.
   */
  readonly deleteConfirmDialog: FilesConfirmDialog;

  /**
   * Confirm dialog for emptying the trash.
   */
  readonly emptyTrashConfirmDialog: FilesConfirmDialog;

  /**
   * Restore dialog when trying to open files that are in the trash
   */
  readonly restoreConfirmDialog: FilesConfirmDialog;

  /**
   * Confirm dialog for file move operation.
   */
  readonly moveConfirmDialog: FilesConfirmDialog;

  /**
   * Confirm dialog for file copy operation.
   */
  readonly copyConfirmDialog: FilesConfirmDialog;

  /**
   * Default task picker.
   * TODO(b:289003444): Make it readonly after fixing tests.
   */
  /* readonly */ defaultTaskPicker: DefaultTaskDialog;

  /**
   * Dialog for installing .deb files
   * TODO(b:289003444): Make it readonly after fixing tests.
   */
  /* readonly */ installLinuxPackageDialog: InstallLinuxPackageDialog;

  /**
   * Dialog for import Crostini Image Files (.tini)
   * TODO(b:289003444): Make it readonly after fixing tests.
   */
  /* readonly */ importCrostiniImageDialog: ImportCrostiniImageDialog;

  /**
   * Dialog for formatting
   */
  readonly formatDialog: FilesFormatDialog;

  /**
   * Dialog for password prompt
   */
  private passwordDialog_: XfPasswordDialog|null = null;

  /**
   * Dialog for resolving file conflicts.
   */
  private xfConflictDialog_: XfConflictDialog|null = null;

  /**
   * Dialog for DLP (Data Leak Prevention) restriction details.
   */
  private dlpRestrictionDetailsDialog_: XfDlpRestrictionDetailsDialog|null =
      null;

  protected cloudPanelContainer_: CloudPanelContainer|null = null;

  /**
   * Breadcrumb controller.
   */
  protected breadcrumbContainer_: BreadcrumbContainer|null = null;

  /**
   * The container element of the dialog.
   */
  dialogContainer: HTMLDialogElement;

  /**
   * Context menu for texts.
   */
  readonly textContextMenu: Menu;

  directoryTreeContainer: DirectoryTreeContainer|null = null;

  /**
   * The toolbar which contains controls.
   */
  readonly toolbar: HTMLElement;

  /**
   * The tooltip element.
   */
  filesTooltip: FilesTooltip;

  /**
   * The actionbar which contains buttons to perform actions on selected
   * file(s).
   */
  readonly actionbar: HTMLElement;

  /**
   * The navigation list.
   */
  readonly dialogNavigationList: HTMLElement;

  /**
   * Toggle-view button.
   */
  readonly toggleViewButton: HTMLElement;

  /**
   * The button to sort the file list.
   */
  readonly sortButton: MultiMenuButton;

  /**
   * The button to open gear menu.
   */
  readonly gearButton: MultiMenuButton;

  readonly gearMenu: GearMenu;

  /**
   * The button to open context menu in the check-select mode.
   */
  readonly selectionMenuButton: MultiMenuButton;

  /**
   * Directory tree.
   */
  directoryTree: XfTree|null = null;

  /**
   * Progress center panel.
   */
  readonly progressCenterPanel: ProgressCenterPanel;

  /**
   * Activity feedback panel.
   */
  readonly activityProgressPanel: HTMLElement;

  readonly fileContextMenu: MultiMenu;

  defaultTaskMenuItem: FilesMenuItem;

  readonly tasksSeparator: MenuItem;

  /**
   * The combo button to specify the task.
   */
  readonly taskMenuButton: ComboButton;

  /**
   * Banners in the file list.
   */
  banners: BannerController|null = null;

  /**
   * Dialog footer.
   */
  dialogFooter: DialogFooter;

  readonly providersMenu: ProvidersMenu;

  readonly actionsSubmenu: ActionsSubmenu;

  /**
   * The container that maintains the lifetime of nudges.
   */
  readonly nudgeContainer: NudgeContainer;

  readonly toast: FilesToast;

  /**
   * Container of file-type filter buttons.
   */
  readonly fileTypeFilterContainer: HTMLElement;

  /**
   * Empty folder element inside the file list container.
   */
  readonly emptyFolder: HTMLElement;

  /**
   * A hidden div that can be used to announce text to screen
   * reader/ChromeVox.
   */
  private a11yMessage_: HTMLElement;

  searchContainer: SearchContainer|null = null;

  a11yAnnounces: string[]|null = null;

  /**
   * True while FilesApp is in the process of a drag and drop. Set to true on
   * 'dragstart', set to false on 'dragend'. If CrostiniEvent
   * 'drop_failed_plugin_vm_directory_not_shared' is received during drag, we
   * show the move-to-windows-files dialog.
   */
  dragInProcess: boolean = false;

  /**
   * @param providersModel Model for providers.
   * @param element Top level element of the Files app.
   * @param launchParam Launch param.
   */
  constructor(
      providersModel: ProvidersModel, public element: HTMLElement,
      launchParam: LaunchParam) {
    // Initialize the dialog label. This should be done before constructing
    // dialog instances.
    BaseDialog.okLabel = str('OK_LABEL');
    BaseDialog.cancelLabel = str('CANCEL_LABEL');

    this.dialogType_ = launchParam.type;

    this.alertDialog = new FilesAlertDialog(this.element);

    this.confirmDialog = new FilesConfirmDialog(this.element);

    this.deleteConfirmDialog = new FilesConfirmDialog(this.element);
    this.deleteConfirmDialog.setOkLabel(str('DELETE_BUTTON_LABEL'));
    this.deleteConfirmDialog.focusCancelButton = true;

    this.emptyTrashConfirmDialog = new FilesConfirmDialog(this.element);
    this.emptyTrashConfirmDialog.setOkLabel(str('EMPTY_TRASH_DELETE_FOREVER'));

    this.restoreConfirmDialog = new FilesConfirmDialog(this.element);
    this.restoreConfirmDialog.setOkLabel(str('RESTORE_ACTION_LABEL'));

    this.moveConfirmDialog = new FilesConfirmDialog(this.element);
    this.moveConfirmDialog.setOkLabel(str('CONFIRM_MOVE_BUTTON_LABEL'));

    this.copyConfirmDialog = new FilesConfirmDialog(this.element);
    this.copyConfirmDialog.setOkLabel(str('CONFIRM_COPY_BUTTON_LABEL'));

    this.defaultTaskPicker = new DefaultTaskDialog(this.element);

    this.installLinuxPackageDialog =
        new InstallLinuxPackageDialog(this.element);

    this.importCrostiniImageDialog =
        new ImportCrostiniImageDialog(this.element);

    this.formatDialog =
        queryRequiredElement('#format-dialog') as FilesFormatDialog;

    this.dialogContainer =
        queryRequiredElement('.dialog-container', this.element) as
        HTMLDialogElement;

    this.textContextMenu = queryDecoratedElement('#text-context-menu', Menu);

    this.toolbar = queryRequiredElement('.dialog-header', this.element);

    this.filesTooltip = document.querySelector<FilesTooltip>('files-tooltip')!;

    this.actionbar = queryRequiredElement('#action-bar', this.toolbar);

    this.dialogNavigationList =
        queryRequiredElement('.dialog-navigation-list', this.element);

    this.toggleViewButton = queryRequiredElement('#view-button', this.element);

    this.sortButton = queryDecoratedElement('#sort-button', MultiMenuButton);

    this.gearButton = queryDecoratedElement('#gear-button', MultiMenuButton);

    this.gearMenu = new GearMenu(this.gearButton.menu!);

    this.selectionMenuButton =
        queryDecoratedElement('#selection-menu-button', MultiMenuButton);

    this.progressCenterPanel = new ProgressCenterPanel();

    this.activityProgressPanel =
        queryRequiredElement('#progress-panel', this.element);

    this.fileContextMenu =
        queryDecoratedElement('#file-context-menu', MultiMenu);

    this.defaultTaskMenuItem =
        queryRequiredElement('#default-task-menu-item', this.fileContextMenu) as
        FilesMenuItem;

    this.tasksSeparator =
        queryRequiredElement('#tasks-separator', this.fileContextMenu) as
        MenuItem;

    this.taskMenuButton = queryDecoratedElement('#tasks', ComboButton);
    this.taskMenuButton.showMenu = function(shouldSetFocus) {
      // Prevent the empty menu from opening.
      if (!this.menu?.length) {
        return;
      }
      ComboButton.prototype.showMenu.call(this, shouldSetFocus);
    };

    this.dialogFooter = DialogFooter.findDialogFooter(
        this.dialogType_, this.element.ownerDocument);

    this.providersMenu = new ProvidersMenu(
        providersModel, queryDecoratedElement('#providers-menu', Menu));

    this.actionsSubmenu = new ActionsSubmenu(this.fileContextMenu);

    this.nudgeContainer = new NudgeContainer();

    this.toast = document.querySelector<FilesToast>('files-toast')!;

    this.fileTypeFilterContainer =
        queryRequiredElement('#file-type-filter-container', this.element);

    this.emptyFolder = queryRequiredElement('#empty-folder', this.element);

    this.a11yMessage_ = queryRequiredElement('#a11y-msg', this.element);

    if (window.IN_TEST) {
      this.a11yAnnounces = [];
    }

    // Initialize attributes.
    this.element.setAttribute('type', this.dialogType_);
    if (launchParam.allowedPaths !== AllowedPaths.ANY_PATH_OR_URL) {
      this.element.setAttribute('block-hosted-docs', '');
      this.element.setAttribute('block-encrypted', '');
    }

    // Modify UI default behavior.
    this.element.addEventListener(
        'click', this.onExternalLinkClick_.bind(this));
    this.element.addEventListener('drop', e => {
      e.preventDefault();
    });
    this.element.addEventListener('contextmenu', e => {
      e.preventDefault();
    });
  }

  get listContainer(): ListContainer {
    return this.listContainer_!;
  }

  /**
   * Gets password dialog.
   */
  get passwordDialog(): HTMLElement {
    if (this.passwordDialog_) {
      return this.passwordDialog_;
    }
    this.passwordDialog_ = document.createElement('xf-password-dialog');
    this.element.appendChild(this.passwordDialog_);
    return this.passwordDialog_;
  }

  /**
   * Gets conflict dialog.
   */
  get conflictDialog(): XfConflictDialog {
    if (this.xfConflictDialog_) {
      return this.xfConflictDialog_;
    }
    this.xfConflictDialog_ = document.createElement('xf-conflict-dialog');
    this.element.appendChild(this.xfConflictDialog_);
    return this.xfConflictDialog_;
  }

  /**
   * Gets the DlpRestrictionDetails dialog.
   */
  get dlpRestrictionDetailsDialog(): null|XfDlpRestrictionDetailsDialog {
    if (!isDlpEnabled()) {
      return null;
    }
    if (this.dlpRestrictionDetailsDialog_) {
      return this.dlpRestrictionDetailsDialog_;
    }
    this.dlpRestrictionDetailsDialog_ =
        document.createElement('xf-dlp-restriction-details-dialog') as
        XfDlpRestrictionDetailsDialog;
    this.element.appendChild(this.dlpRestrictionDetailsDialog_);
    return this.dlpRestrictionDetailsDialog_;
  }

  /**
   * Initializes here elements, which are expensive or hidden in the beginning.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  initAdditionalUI(
      table: FileTable, grid: FileGrid, volumeManager: VolumeManager) {
    // List container.
    this.listContainer_ = new ListContainer(
        queryRequiredElement('#list-container', this.element), table, grid,
        this.dialogType_);

    // Breadcrumb container.
    this.breadcrumbContainer_ = new BreadcrumbContainer(
        queryRequiredElement('#location-breadcrumbs', this.element));

    // Splitter.
    const splitterContainer =
        queryRequiredElement('#navigation-list-splitter', this.element);
    splitterContainer.addEventListener(
        XfSplitter.events.SPLITTER_DRAGMOVE, this.relayout.bind(this));


    /**
     * Search container, which controls search UI elements.
     */
    this.searchContainer = new SearchContainer(
        volumeManager, queryRequiredElement('#search-wrapper', this.element),
        queryRequiredElement('#search-options-container', this.element),
        queryRequiredElement('#path-display-container', this.element),
        /*a11y=*/ this);

    this.cloudPanelContainer_ = new CloudPanelContainer(
        queryRequiredElement('xf-cloud-panel', this.element) as XfCloudPanel);

    // Init context menus.
    contextMenuHandler.setContextMenu(grid, this.fileContextMenu);
    contextMenuHandler.setContextMenu(table.list, this.fileContextMenu);
    contextMenuHandler.setContextMenu(
        queryRequiredElement('.drive-welcome.page'), this.fileContextMenu);

    // Add window resize handler.
    document.defaultView?.addEventListener('resize', this.relayout.bind(this));

    // Add global pointer-active handler.
    const rootElement = document.documentElement;
    let pointerActive = ['pointerdown', 'pointerup', 'dragend', 'touchend'];
    if (window.IN_TEST) {
      pointerActive = pointerActive.concat(['mousedown', 'mouseup']);
    }
    pointerActive.forEach((eventType) => {
      document.addEventListener(eventType, (e) => {
        if (/down$/.test(e.type) === false) {
          rootElement.classList.toggle('pointer-active', false);
        } else if ((e as PointerEvent).pointerType !== 'touch') {
          // http://crbug.com/1311472
          rootElement.classList.toggle('pointer-active', true);
        }
      }, true);
    });

    // Add global drag-drop-active handler.
    let activeDropTarget: EventTarget|null = null;
    ['dragenter', 'dragleave', 'drop'].forEach((eventType) => {
      document.addEventListener(eventType, (event) => {
        const dragDropActive = 'drag-drop-active';
        if (event.type === 'dragenter') {
          rootElement.classList.add(dragDropActive);
          activeDropTarget = event.target;
        } else if (activeDropTarget === event.target) {
          rootElement.classList.remove(dragDropActive);
          activeDropTarget = null;
        }
      });
    });

    document.addEventListener('dragstart', () => {
      this.dragInProcess = true;
    });
    document.addEventListener('dragend', () => {
      this.dragInProcess = false;
    });
  }

  /**
   * Initializes the focus.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  initUIFocus() {
    // Set the initial focus. When there is no focus, the active element is the
    // <body>.
    let targetElement = null;
    if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE) {
      targetElement = this.dialogFooter.filenameInput;
    } else if (this.listContainer.currentListType !== ListType.UNINITIALIZED) {
      targetElement = this.listContainer.currentList;
    }

    if (targetElement) {
      targetElement.focus();
    }
  }

  /**
   * TODO(hirono): Merge the method into initAdditionalUI.
   */
  initDirectoryTree(directoryTree: DirectoryTreeContainer) {
    this.directoryTreeContainer = directoryTree;
    this.directoryTree = this.directoryTreeContainer.tree;

    this.directoryTreeContainer.contextMenuForRootItems =
        queryDecoratedElement('#roots-context-menu', Menu);
    this.directoryTreeContainer.contextMenuForSubitems =
        queryDecoratedElement('#directory-tree-context-menu', Menu);
    this.directoryTreeContainer.contextMenuForDisabledItems =
        queryDecoratedElement('#disabled-context-menu', Menu);
  }

  /**
   * TODO(mtomasz): Merge the method into initAdditionalUI if possible.
   */
  initBanners(banners: BannerController) {
    this.banners = banners;
    this.banners.addEventListener('relayout', this.relayout.bind(this));
  }

  /**
   * Attaches files tooltip.
   */
  attachFilesTooltip() {
    this.filesTooltip.addTargets(document.querySelectorAll('[has-tooltip]'));
  }

  /**
   * Initialize files menu items. This method must be called after all files
   * menu items are decorated as MenuItem.
   */
  decorateFilesMenuItems() {
    const filesMenuItems =
        document.querySelectorAll('cr-menu.files-menu > cr-menu-item');

    for (const filesMenuItem of filesMenuItems) {
      assertInstanceof(filesMenuItem, MenuItem);
      crInjectTypeAndInit(filesMenuItem, FilesMenuItem);
    }
  }

  /**
   * Relayouts the UI.
   */
  relayout() {
    // May not be available during initialization.
    if (this.listContainer.currentListType !== ListType.UNINITIALIZED) {
      this.listContainer.currentView.relayout();
    }
  }

  /**
   * Sets the current list type.
   * @param listType New list type.
   */
  setCurrentListType(listType: ListType) {
    this.listContainer.setCurrentListType(listType);

    const isListView = (listType === ListType.DETAIL);
    this.toggleViewButton.classList.toggle('thumbnail', isListView);

    const label = isListView ? str('CHANGE_TO_THUMBNAILVIEW_BUTTON_LABEL') :
                               str('CHANGE_TO_LISTVIEW_BUTTON_LABEL');
    this.toggleViewButton.setAttribute('aria-label', label);
    this.relayout();
  }

  /**
   * Overrides default handling for clicks on hyperlinks.
   * In a packaged apps links with target='_blank' open in a new tab by
   * default, other links do not open at all.
   *
   * @param event Click event.
   */
  private onExternalLinkClick_(event: Event) {
    const target = event.target;
    if (!(target instanceof HTMLElement) || target.tagName !== 'A' ||
        !('href' in target)) {
      return;
    }

    if (this.dialogType_ !== DialogType.FULL_PAGE) {
      this.dialogFooter.cancelButton.click();
    }
  }

  /**
   * Mark |element| with "loaded" attribute to indicate that File Manager has
   * finished loading.
   */
  addLoadedAttribute() {
    this.element.setAttribute('loaded', '');
  }

  /**
   * Sets up and shows the alert to inform a user the task is opened in the
   * desktop of the running profile.
   *
   * @param entries List of opened entries.
   */
  showOpenInOtherDesktopAlert(entries: Array<Entry|FilesAppEntry>) {
    if (!entries.length) {
      return;
    }
    chrome.fileManagerPrivate.getProfiles(
        (response: chrome.fileManagerPrivate.ProfilesResponse) => {
          //  Find strings.
          let displayName;
          for (const profile of response.profiles) {
            if (profile.profileId === response.currentProfileId) {
              displayName = profile.displayName;
              break;
            }
          }
          if (!displayName) {
            console.warn('Display name is not found.');
            return;
          }

          const title = entries.length > 1 ?
              entries[0]!.name + '\u2026' /* ellipsis */ :
              entries[0]!.name;
          const message = strf(
              entries.length > 1 ? 'OPEN_IN_OTHER_DESKTOP_MESSAGE_PLURAL' :
                                   'OPEN_IN_OTHER_DESKTOP_MESSAGE',
              displayName, response.currentProfileId);

          // Show the dialog.
          this.alertDialog.showWithTitle(title, message);
        });
  }

  /**
   * Shows confirmation dialog and handles user interaction.
   * @param isMove true if the operation is move. false if copy.
   * @param messages The messages to show in the dialog.
   *     box.
   */
  showConfirmationDialog(isMove: boolean, messages: string[]):
      Promise<boolean> {
    const dialog = isMove ? this.moveConfirmDialog : this.copyConfirmDialog;
    return new Promise<boolean>((resolve) => {
      dialog.show(
          messages.join(' '),
          () => {
            resolve(true);
          },
          () => {
            resolve(false);
          });
    });
  }

  /**
   * Send a text to screen reader/Chromevox without displaying the text in the
   * UI.
   * @param text Text to be announced by screen reader, which should be
   * already translated.
   */
  speakA11yMessage(text: string) {
    // Screen reader only reads if the content changes, so clear the content
    // first.
    this.a11yMessage_.textContent = '';
    this.a11yMessage_.textContent = text;
    // this.a11yAnnounces is not null only during tests; see constructor.
    if (this.a11yAnnounces) {
      this.a11yAnnounces.push(text);
    }
  }
}