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

import type {EntryLocation} from '../../../background/js/entry_location_impl.js';
import type {VolumeManager} from '../../../background/js/volume_manager.js';
import type {ArrayDataModel} from '../../../common/js/array_data_model.js';
import {isTeamDriveRoot} from '../../../common/js/entry_utils.js';
import {getIcon, isEncrypted} from '../../../common/js/file_type.js';
import type {FilesAppEntry} from '../../../common/js/files_app_entry_types.js';
import {isDlpEnabled, isDriveFsBulkPinningEnabled} from '../../../common/js/flags.js';
import {getEntryLabel, str, strf} from '../../../common/js/translations.js';
import type {FileListModel} from '../file_list_model.js';
import type {MetadataItem} from '../metadata/metadata_item.js';
import type {MetadataModel} from '../metadata/metadata_model.js';

import type {A11yAnnounce} from './a11y_announce.js';
import {DragSelector} from './drag_selector.js';
import type {FileGridSelectionController} from './file_grid.js';
import type {FileListSelectionModel} from './file_list_selection_model.js';
import type {FileTable} from './file_table.js';
import {FileTapHandler, TapEvent} from './file_tap_handler.js';
import {List} from './list.js';
import type {ListItem} from './list_item.js';
import {ListSelectionController} from './list_selection_controller.js';
import type {ListSelectionModel} from './list_selection_model.js';
import {TableList} from './table/table_list.js';

// Group Heading height, align with CSS #list-container .group-heading.
const GROUP_HEADING_HEIGHT = 57;

type OnMergeItemsCallback = (beginIndex: number, endIndex: number) => void;

/**
 * File table list.
 */
export class FileTableList extends TableList {
  private onMergeItems_: null|OnMergeItemsCallback = null;
  shouldStartDragSelection: null|((e: MouseEvent) => boolean) = null;

  override initialize() {
    this.setAttribute('aria-multiselectable', 'true');
    this.setAttribute('aria-describedby', 'more-actions-info');
    this.onMergeItems_ = null;
  }

  override get table(): FileTable {
    return super.table as FileTable;
  }

  override get dataModel(): FileListModel {
    return super.dataModel as FileListModel;
  }

  override set dataModel(value: FileListModel) {
    super.dataModel = value;
  }

  /**
   * Returns the height of group heading.
   */
  private getGroupHeadingHeight_(): number {
    return GROUP_HEADING_HEIGHT;
  }

  /**
   * @param onMergeItems callback called from `mergeItems` with the
   *     parameters `beginIndex` and `endIndex`.
   */
  setOnMergeItems(onMergeItems: OnMergeItemsCallback) {
    assert(!this.onMergeItems_);
    this.onMergeItems_ = onMergeItems;
  }

  override mergeItems(beginIndex: number, endIndex: number) {
    super.mergeItems(beginIndex, endIndex);

    const fileListModel = this.dataModel;
    const groupBySnapshot =
        fileListModel ? fileListModel.getGroupBySnapshot() : [];
    const startIndexToGroupLabel = new Map(groupBySnapshot.map(group => {
      return [group.startIndex, group];
    }));

    // Make sure that list item's selected attribute is updated just after
    // the mergeItems operation is done. This prevents checkmarks on
    // selected items from being animated unintentionally by redraw.
    for (let i = beginIndex; i < endIndex; i++) {
      const item = this.getListItemByIndex(i);
      if (!item) {
        continue;
      }
      const isSelected = !!this.selectionModel?.getIndexSelected(i);
      if (item.selected !== isSelected) {
        item.selected = isSelected;
      }
      // Check if index i is the start of a new group.
      if (startIndexToGroupLabel.has(i)) {
        // For first item in each group, we add a title div before the
        // element.
        const title = document.createElement('div');
        title.setAttribute('role', 'heading');
        title.innerText = startIndexToGroupLabel.get(i)!.label;
        title.classList.add(
            'group-heading', `group-by-${fileListModel.groupByField}`);
        this.insertBefore(title, item);
      }
    }

    if (this.onMergeItems_) {
      this.onMergeItems_(beginIndex, endIndex);
    }
  }

  override createSelectionController(sm: ListSelectionModel):
      FileListSelectionController {
    assert(sm);
    return new FileListSelectionController(sm, this);
  }

  get a11y(): A11yAnnounce {
    return this.table.a11y!;
  }

  /**
   * @param index Index of the list item.
   */
  getItemLabel(index: number): string {
    return this.table.getItemLabel(index);
  }

  /**
   * Given a index, return how many group headings are there before this
   * index. Note: not include index itself.
   */
  private getGroupHeadingCountBeforeIndex_(index: number): number {
    const groupBySnapshot = this.dataModel.getGroupBySnapshot();
    let count = 0;
    for (const group of groupBySnapshot) {
      // index - 1 because we don't want to include index itself.
      if (group.startIndex <= index - 1) {
        count++;
      } else {
        break;
      }
    }
    return count;
  }

  /**
   * Given a index, return how many group headings are there after this
   * index. Note: not include index itself.
   */
  private getGroupHeadingCountAfterIndex_(index: number): number {
    const groupBySnapshot = this.dataModel.getGroupBySnapshot();
    if (groupBySnapshot.length > 0) {
      const countBeforeIndex = this.getGroupHeadingCountBeforeIndex_(index + 1);
      return groupBySnapshot.length - countBeforeIndex;
    }
    return 0;
  }

  /**
   * Given a offset (e.g. scrollTop), return how many items can be included
   * within this height. Override here because previously we just need to
   * use the total height (offset) to divide the item height, now we also
   * need to consider the potential group headings included in these items.
   */
  protected override getIndexForListOffset_(offset: number) {
    const fileListModel = this.dataModel;
    const groupBySnapshot = fileListModel.getGroupBySnapshot();
    const itemHeight = this.getDefaultItemHeight_();

    // Without heading the original logic suffices.
    if (groupBySnapshot.length === 0 || !itemHeight) {
      return super.getIndexForListOffset_(offset);
    }

    // Loop through all the groups, calculate the accumulated height for all
    // items (item height + group heading height), until the total height
    // reaches "offset", then we know how many items can be included in this
    // offset.
    let currentHeight = 0;
    for (const group of groupBySnapshot) {
      const groupHeight = this.getGroupHeadingHeight_() +
          (group.endIndex - group.startIndex + 1) * itemHeight;

      if (currentHeight + groupHeight > offset) {
        // Current offset falls into the current group. Calculates how many
        // items in the offset within the group.
        const remainingOffsetInGroup =
            Math.max(0, offset - this.getGroupHeadingHeight_() - currentHeight);
        return group.startIndex +
            Math.floor(remainingOffsetInGroup / itemHeight);
      }
      currentHeight += groupHeight;
    }
    return fileListModel.length - 1;
  }

  /**
   * Given an index, return the height (top) of all items before this index.
   * Override here because previously we just need to use the index to
   * multiply the item height, now we also need to add up the potential
   * group heading heights included in these items.
   *
   * Note: for group start item, technically its height should be "all
   * heights above it + current group heading height", but here we don't add
   * the current group heading height (logic in
   * getGroupHeadingCountBeforeIndex_), that's because it will break the
   * "beforeFillerHeight" logic in the redraw of list.js.
   */
  override getItemTop(index: number) {
    const itemHeight = this.getDefaultItemHeight_();
    const countOfGroupHeadings = this.getGroupHeadingCountBeforeIndex_(index);
    return index * itemHeight +
        countOfGroupHeadings * this.getGroupHeadingHeight_();
  }

  /**
   * Given an index, return the height of all items after this index.
   * Override here because previously we just need to use the remaining
   * index to multiply the item height, now we also need to add up the
   * potential group heading heights included in these items.
   */
  override getAfterFillerHeight(lastIndex: number) {
    if (lastIndex === 0) {
      // A special case handled in the parent class, delegate it back to
      // parent.
      return super.getAfterFillerHeight(lastIndex);
    }
    const itemHeight = this.getDefaultItemHeight_();
    const countOfGroupHeadings =
        this.getGroupHeadingCountAfterIndex_(lastIndex);
    const length = this.dataModel?.length ?? 0;
    return (length - lastIndex) * itemHeight +
        countOfGroupHeadings * this.getGroupHeadingHeight_();
  }

  /**
   * Returns whether the drag event is inside a file entry in the list (and not
   * the background padding area).
   * @param event Drag start event.
   * @return True if the mouse is over an element in the list, False if it is in
   *     the background.
   */
  hasDragHitElement(event: MouseEvent): boolean {
    const pos = DragSelector.getScrolledPosition(this, event)!;
    return this.getHitElements(pos.x, pos.y).length !== 0;
  }

  /**
   * Obtains the index list of elements that are hit by the point or the
   * rectangle.
   *
   * @param _x X coordinate value.
   * @param y Y coordinate value.
   * @param _width Width of the coordinate.
   * @param height Height of the coordinate.
   * @return Index list of hit elements.
   */
  override getHitElements(
      _x: number, y: number, _width?: number, height?: number): number[] {
    const fileListModel = this.dataModel;
    const groupBySnapshot =
        fileListModel ? fileListModel.getGroupBySnapshot() : [];
    const startIndexToGroupLabel = new Map(groupBySnapshot.map(group => {
      return [group.startIndex, group];
    }));

    const currentSelection = [];
    const startHeight = y;
    const endHeight = y + (height || 0);
    const length = this.selectionModel?.length ?? 0;
    for (let i = 0; i < length; i++) {
      const itemMetrics = this.getHeightsForIndex(i);
      // For group start item, we need to explicitly add group height because
      // its top doesn't take that into consideration. (check notes in
      // getItemTop())
      const itemTop = itemMetrics.top +
          (startIndexToGroupLabel.has(i) ? this.getGroupHeadingHeight_() : 0);
      if (itemTop < endHeight && itemTop + itemMetrics.height >= startHeight) {
        currentSelection.push(i);
      }
    }
    return currentSelection;
  }
}

/**
 * Selection controller for the file table list.
 */
class FileListSelectionController extends ListSelectionController {
  private readonly tapHandler_ = new FileTapHandler();


  /**
   * @param selectionModel The selection model to
   *     interact with.
   */
  constructor(
      selectionModel: ListSelectionModel, private tableList_: FileTableList) {
    super(selectionModel);
  }

  override handlePointerDownUp(e: PointerEvent, index: number) {
    handlePointerDownUp.call(this, e, index);
  }

  override handleTouchEvents(e: TouchEvent, index: number) {
    if (this.tapHandler_.handleTouchEvents(e, index, handleTap.bind(this))) {
      // If a tap event is processed, FileTapHandler cancels the event to
      // prevent triggering click events. Then it results not moving the focus
      // to the list. So we do that here explicitly.
      focusParentList(e);
    }
  }

  override handleKeyDown(e: KeyboardEvent) {
    handleKeyDown.call(this, e);
  }

  get filesView(): FileTableList {
    return this.tableList_;
  }
}

/**
 * Common item decoration for table's and grid's items.
 * @param li List item.
 * @param entry The entry.
 * @param metadataModel Cache to
 *     retrieve metadata.
 * @param volumeManager Used to retrieve VolumeInfo.
 */
export function decorateListItem(
    li: ListItem, entry: Entry|FilesAppEntry, metadataModel: MetadataModel,
    volumeManager: VolumeManager) {
  li.classList.add(entry.isDirectory ? 'directory' : 'file');
  // The metadata may not yet be ready. In that case, the list item will be
  // updated when the metadata is ready via updateListItemsMetadata. For
  // files not on an external backend, externalProps is not available.
  const externalProps = metadataModel.getCache([entry], [
    'hosted',
    'availableOffline',
    'customIconUrl',
    'shared',
    'isMachineRoot',
    'isExternalMedia',
    'pinned',
    'syncStatus',
    'progress',
    'syncCompletedTime',
    'contentMimeType',
    'shortcut',
    'canPin',
    'isDlpRestricted',
    'syncCompletedTime',
  ])[0]!;
  updateListItemExternalProps(li, entry, externalProps, isTeamDriveRoot(entry));

  // Overriding the default role 'list' to 'listbox' for better
  // accessibility on ChromeOS.
  li.setAttribute('role', 'option');
  const disabled = isDlpBlocked(entry, metadataModel, volumeManager);
  li.toggleAttribute('disabled', disabled);
  if (disabled) {
    li.setAttribute('aria-disabled', 'true');
  } else {
    li.removeAttribute('aria-disabled');
  }
}

/**
 * Returns whether `entry` is blocked by DLP.
 *
 * Relies on the fact that volumeManager.isDisabled() can only be true for
 * dirs in file-saveas dialogs, while metadata.isRestrictedForDestination
 * can only be true for files in other types of select dialogs.
 * @param entry The entry.
 * @param metadataModel Used to retrieve isRestrictedForDestination value.
 * @param volumeManager Used to retrieve VolumeInfo and check if it's disabled.
 */
export function isDlpBlocked(
    entry: Entry|FilesAppEntry, metadataModel: MetadataModel,
    volumeManager: VolumeManager): boolean {
  if (!isDlpEnabled()) {
    return false;
  }
  // TODO(b/259184588): Properly handle case when VolumeInfo is not
  // available. E.g. for Crostini we might not have VolumeInfo before it's
  // mounted.
  const volumeInfo = volumeManager.getVolumeInfo(entry);
  if (volumeInfo && volumeManager.isDisabled(volumeInfo.volumeType)) {
    return true;
  }
  const metadata =
      metadataModel.getCache([entry], ['isRestrictedForDestination'])[0];
  if (metadata && !!metadata.isRestrictedForDestination) {
    return true;
  }
  return false;
}

/**
 * Render the type column of the detail table.
 * @param doc Owner document.
 * @param entry The Entry object to render.
 * @param mimeType Optional mime type for the file.
 * @return Created element.
 */
export function renderFileTypeIcon(
    doc: Document, entry: Entry, locationInfo: null|EntryLocation,
    mimeType?: string): HTMLDivElement {
  const icon = doc.createElement('div');
  icon.className = 'detail-icon';
  const rootType = locationInfo?.rootType;
  icon.setAttribute('file-type-icon', getIcon(entry, mimeType, rootType));
  return icon;
}

/**
 * Renders a div beside the row icon that is used to surface badges for
 * individual items in the grid and list view.
 * @param doc Owner document.
 */
export function renderIconBadge(doc: Document): HTMLDivElement {
  const divElement = doc.createElement('div');
  divElement.classList.add('icon-badge');
  return divElement;
}

/**
 * Render filename label for grid and list view.
 * @param doc Owner document.
 * @param entry The Entry object to render.
 * @return The label element.
 */
export function renderFileNameLabel(
    doc: Document, entry: Entry|FilesAppEntry,
    locationInfo: null|EntryLocation): HTMLDivElement {
  // Filename need to be in a '.filename-label' container for correct
  // work of inplace renaming.
  const box = doc.createElement('div');
  box.className = 'filename-label';
  const fileName = doc.createElement('span');
  fileName.className = 'entry-name';
  fileName.textContent = getEntryLabel(locationInfo, entry);
  box.appendChild(fileName);

  return box;
}

/**
 * Updates grid item or table row for the externalProps.
 * @param li List item.
 * @param entry The entry.
 * @param externalProps Metadata.
 * @param isTeamDriveRoot Whether the entry is a team drive root.
 */
export function updateListItemExternalProps(
    li: ListItem, entry: Entry|FilesAppEntry, externalProps: MetadataItem,
    isTeamDriveRoot: boolean) {
  if (li.classList.contains('file')) {
    li.classList.toggle('dim-hosted', !!externalProps.hosted);
    if (externalProps.contentMimeType) {
      li.classList.toggle(
          'dim-encrypted', isEncrypted(entry, externalProps.contentMimeType));
    }
    const dlpIcon = li.querySelector('.dlp-managed-icon');
    if (dlpIcon) {
      dlpIcon.classList.toggle(
          'is-dlp-restricted', externalProps.isDlpRestricted);
    }
  }

  li.classList.toggle('shortcut', !!externalProps.shortcut);

  const iconDiv = li.querySelector<HTMLElement>('.detail-icon');
  if (!iconDiv) {
    return;
  }
  iconDiv.style.backgroundImage = '';

  if (li.classList.contains('directory')) {
    iconDiv.classList.toggle('shared', !!externalProps.shared);
    iconDiv.classList.toggle('team-drive-root', !!isTeamDriveRoot);
    iconDiv.classList.toggle('computers-root', !!externalProps.isMachineRoot);
    iconDiv.classList.toggle(
        'external-media-root', !!externalProps.isExternalMedia);
  }

  updateInlineStatus(li, externalProps);
}

/**
 * Handles tap events on file list to change the selection state.
 *
 * @param e The browser mouse event.
 * @param index The index that was under the mouse pointer, -1 if none.
 * @return True if conducted any action. False when if did nothing special for
 *     tap.
 */
export function handleTap(
    this: FileListSelectionController|FileGridSelectionController,
    e: TouchEvent, index: number, eventType: TapEvent) {
  const sm = this.selectionModel as FileListSelectionModel;
  const a11y = this.filesView.a11y!;

  if (eventType === TapEvent.TWO_FINGER_TAP) {
    // Prepare to open the context menu in the same manner as the right
    // click. If the target is any of the selected files, open a one for
    // those files. If the target is a non-selected file, cancel current
    // selection and open context menu for the single file. Otherwise (when
    // the target is the background), for the current folder.
    if (index === -1) {
      // Two-finger tap outside the list should be handled here because it
      // does not produce mousedown/click events.
      a11y.speakA11yMessage(str('SELECTION_ALL_ENTRIES'));
      sm.unselectAll();
    } else {
      const indexSelected = sm.getIndexSelected(index);
      if (!indexSelected) {
        // Prepare to open context menu of the new item by selecting only
        // it.
        if (sm.getCheckSelectMode()) {
          // Unselect all items once to ensure that the check-select mode is
          // terminated.
          sm.unselectAll();
        }
        sm.beginChange();
        sm.selectedIndex = index;
        sm.endChange();
      }
    }

    // Context menu will be opened for the selected files by the following
    // 'contextmenu' event.
    return false;
  }

  if (index === -1) {
    return false;
  }

  const target = e.target as HTMLElement;
  // Single finger tap.
  const isTap = eventType === TapEvent.TAP || eventType === TapEvent.LONG_TAP;
  // Revert to click handling for single tap on the checkmark or rename
  // input. Single tap on the item checkmark should toggle select the item.
  // Single tap on rename input should focus on input.
  const isCheckmark = target.classList.contains('detail-checkmark') ||
      target.classList.contains('detail-icon');
  const isRename = target.localName === 'input';
  if (eventType === TapEvent.TAP && (isCheckmark || isRename)) {
    return false;
  }

  if (sm.multiple && sm.getCheckSelectMode() && isTap && !e.shiftKey) {
    // toggle item selection. Equivalent to mouse click on checkbox.
    sm.beginChange();

    const name = this.filesView.getItemLabel(index);
    const msgId = sm.getIndexSelected(index) ? 'SELECTION_ADD_SINGLE_ENTRY' :
                                               'SELECTION_REMOVE_SINGLE_ENTRY';
    a11y.speakA11yMessage(strf(msgId, name));

    sm.setIndexSelected(index, !sm.getIndexSelected(index));
    // Toggle the current one and make it anchor index.
    sm.leadIndex = index;
    sm.anchorIndex = index;
    sm.endChange();
    return true;
  } else if (sm.multiple && (eventType === TapEvent.LONG_PRESS)) {
    sm.beginChange();
    if (!sm.getCheckSelectMode()) {
      // Make sure to unselect the leading item that was not the touch
      // target.
      sm.unselectAll();
      sm.setCheckSelectMode(true);
    }
    sm.setIndexSelected(index, true);
    sm.leadIndex = index;
    sm.anchorIndex = index;
    sm.endChange();
    return true;
    // Do not toggle selection yet, so as to avoid unselecting before drag.
  } else if (eventType === TapEvent.TAP && !sm.getCheckSelectMode()) {
    // Single tap should open the item with default action.
    // Select the item, so that MainWindowComponent will execute action of
    // it.
    sm.beginChange();
    sm.unselectAll();
    sm.setIndexSelected(index, true);
    sm.leadIndex = index;
    sm.anchorIndex = index;
    sm.endChange();
  }
  return false;
}

/**
 * Handles mouseup/mousedown events on file list to change the selection
 * state.
 *
 * Basically the content of this function is identical to
 * ListSelectionController's handlePointerDownUp(), but following
 * handlings are inserted to control the check-select mode.
 *
 * 1) When checkmark area is clicked, toggle item selection and enable the
 *    check-select mode.
 * 2) When non-checkmark area is clicked in check-select mode, disable the
 *    check-select mode.
 *
 * @param e The browser mouse event.
 * @param index The index that was under the mouse pointer, -1 if
 *     none.
 */
export function handlePointerDownUp(
    this: FileListSelectionController|FileGridSelectionController,
    e: MouseEvent, index: number) {
  const sm = this.selectionModel as FileListSelectionModel;
  const anchorIndex = sm.anchorIndex;
  const isDown = (e.type === 'mousedown');

  const target = e.target as HTMLElement;
  const isTargetCheckmark = target.classList.contains('detail-checkmark') ||
      target.classList.contains('checkmark');
  // If multiple selection is allowed and the checkmark is clicked without
  // modifiers(Ctrl/Shift), the click should toggle the item's selection.
  // (i.e. same behavior as Ctrl+Click)
  const isClickOnCheckmark =
      (isTargetCheckmark && sm.multiple && index !== -1 && !e.shiftKey &&
       !e.ctrlKey && e.button === 0);

  sm.beginChange();

  const a11y = this.filesView.a11y!;
  if (index === -1) {
    a11y.speakA11yMessage(str('SELECTION_CANCELLATION'));
    sm.leadIndex = sm.anchorIndex = -1;
    sm.unselectAll();
  } else {
    if (sm.multiple && (e.ctrlKey || isClickOnCheckmark) && !e.shiftKey) {
      // Selection is handled at mouseUp.
      if (!isDown) {
        // 1) When checkmark area is clicked, toggle item selection and
        // enable
        //    the check-select mode.
        if (isClickOnCheckmark) {
          // If Files app enters check-select mode by clicking an item's
          // icon, existing selection should be cleared.
          if (!sm.getCheckSelectMode()) {
            sm.unselectAll();
          }
        }
        // Always enables check-select mode when the selection is updated by
        // Ctrl+Click or Click on an item's icon.
        sm.setCheckSelectMode(true);

        // Toggle the current one and make it anchor index.
        const name = this.filesView.getItemLabel(index);
        const msgId = sm.getIndexSelected(index) ?
            'SELECTION_REMOVE_SINGLE_ENTRY' :
            'SELECTION_ADD_SINGLE_ENTRY';
        a11y.speakA11yMessage(strf(msgId, name));
        sm.setIndexSelected(index, !sm.getIndexSelected(index));
        sm.leadIndex = index;
        sm.anchorIndex = index;
      }
    } else if (e.shiftKey && anchorIndex !== -1 && anchorIndex !== index) {
      // Shift is done in mousedown.
      if (isDown) {
        sm.unselectAll();
        sm.leadIndex = index;
        if (sm.multiple) {
          sm.selectRange(anchorIndex, index);
          const nameStart = this.filesView.getItemLabel(anchorIndex);
          const nameEnd = this.filesView.getItemLabel(index);
          const count = Math.abs(index - anchorIndex) + 1;
          const msg = strf('SELECTION_ADD_RANGE', count, nameStart, nameEnd);
          a11y.speakA11yMessage(msg);
        } else {
          sm.setIndexSelected(index, true);
        }
      }
    } else {
      // Right click for a context menu needs to not clear the selection.
      const isRightClick = e.button === 2;

      // If the index is selected this is handled in mouseup.
      const indexSelected = sm.getIndexSelected(index);
      if ((indexSelected && !isDown || !indexSelected && isDown) &&
          !(indexSelected && isRightClick)) {
        // 2) When non-checkmark area is clicked in check-select mode,
        // disable
        //    the check-select mode.
        if (sm.getCheckSelectMode()) {
          // Unselect all items once to ensure that the check-select mode is
          // terminated.
          sm.endChange();
          sm.unselectAll();
          sm.beginChange();
        }
        sm.selectedIndex = index;
      }
    }
  }
  sm.endChange();
}

/**
 * Handles key events on file list to change the selection state.
 *
 * Basically the content of this function is identical to
 * ListSelectionController's handleKeyDown(), but following handlings is
 * inserted to control the check-select mode.
 *
 * 1) When pressing direction key results in a single selection, the
 *    check-select mode should be terminated.
 *
 * @param e The keydown event.
 */
export function handleKeyDown(
    this: FileListSelectionController|FileGridSelectionController,
    e: KeyboardEvent) {
  const target = e.target as HTMLElement;
  const tagName = target.tagName;

  // If focus is in an input field of some kind, only handle navigation keys
  // that aren't likely to conflict with input interaction (e.g., text
  // editing, or changing the value of a checkbox or select).
  if (tagName === 'INPUT') {
    const inputType = (target as HTMLInputElement).type;
    // Just protect space (for toggling) for checkbox and radio.
    if (inputType === 'checkbox' || inputType === 'radio') {
      if (e.key === ' ') {
        return;
      }
      // Protect all but the most basic navigation commands in anything
      // else.
    } else if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
      return;
    }
  }
  // Similarly, don't interfere with select element handling.
  if (tagName === 'SELECT') {
    return;
  }

  const sm = this.selectionModel as FileListSelectionModel;
  assert(sm);
  let newIndex = -1;
  const leadIndex = sm.leadIndex;
  let prevent = true;

  const a11y = this.filesView.a11y!;

  // Ctrl/Meta+A. Use keyCode=65 to use the same shortcut key regardless of
  // keyboard layout.
  const pressedKeyA = e.keyCode === 65 || e.key === 'a';
  if (sm.multiple && pressedKeyA && e.ctrlKey) {
    a11y.speakA11yMessage(str('SELECTION_ALL_ENTRIES'));
    sm.setCheckSelectMode(true);
    sm.selectAll();
    e.preventDefault();
    return;
  }

  // Esc
  if (e.key === 'Escape' && !e.ctrlKey && !e.shiftKey) {
    a11y.speakA11yMessage(str('SELECTION_CANCELLATION'));
    sm.unselectAll();
    e.preventDefault();
    return;
  }

  // Space: Note ChromeOS and ChromeOS on Linux can generate KeyDown Space
  // events differently the |key| attribute might be set to 'Unidentified'.
  if (e.code === 'Space' || e.key === ' ') {
    if (leadIndex !== -1) {
      const selected = sm.getIndexSelected(leadIndex);
      if (e.ctrlKey) {
        sm.beginChange();

        // Force selecting if it's the first item selected, otherwise flip
        // the "selected" status.
        if (selected && sm.selectedIndexes.length === 1 &&
            !sm.getCheckSelectMode()) {
          // It needs to go back/forth to trigger the 'change' event.
          sm.setIndexSelected(leadIndex, false);
          sm.setIndexSelected(leadIndex, true);
          const name = this.filesView.getItemLabel(leadIndex);
          a11y.speakA11yMessage(strf('SELECTION_SINGLE_ENTRY', name));
        } else {
          // Toggle the current one and make it anchor index.
          sm.setIndexSelected(leadIndex, !selected);
          const name = this.filesView.getItemLabel(leadIndex);
          const msgId = selected ? 'SELECTION_REMOVE_SINGLE_ENTRY' :
                                   'SELECTION_ADD_SINGLE_ENTRY';
          a11y.speakA11yMessage(strf(msgId, name));
        }

        // Force check-select, FileListSelectionModel.onChangeEvent_ resets
        // it if needed.
        sm.setCheckSelectMode(true);
        sm.endChange();

        // Prevents space to opening quickview.
        e.stopPropagation();
        e.preventDefault();
        return;
      }
    }
  }

  switch (e.key) {
    case 'Home':
      newIndex = this.getFirstIndex();
      break;
    case 'End':
      newIndex = this.getLastIndex();
      break;
    case 'ArrowUp':
      newIndex = leadIndex === -1 ? this.getLastIndex() :
                                    this.getIndexAbove(leadIndex);
      break;
    case 'ArrowDown':
      newIndex = leadIndex === -1 ? this.getFirstIndex() :
                                    this.getIndexBelow(leadIndex);
      break;
    case 'ArrowLeft':
    case 'MediaTrackPrevious':
      newIndex = leadIndex === -1 ? this.getLastIndex() :
                                    this.getIndexBefore(leadIndex);
      break;
    case 'ArrowRight':
    case 'MediaTrackNext':
      newIndex = leadIndex === -1 ? this.getFirstIndex() :
                                    this.getIndexAfter(leadIndex);
      break;
    default:
      prevent = false;
  }

  if (newIndex >= 0 && newIndex < sm.length) {
    sm.beginChange();

    sm.leadIndex = newIndex;
    if (e.shiftKey) {
      const anchorIndex = sm.anchorIndex;
      if (sm.multiple) {
        sm.unselectAll();
      }
      if (anchorIndex === -1) {
        sm.setIndexSelected(newIndex, true);
        sm.anchorIndex = newIndex;
      } else {
        const nameStart = this.filesView.getItemLabel(anchorIndex);
        const nameEnd = this.filesView.getItemLabel(newIndex);
        const count = Math.abs(newIndex - anchorIndex) + 1;
        const msg = strf('SELECTION_ADD_RANGE', count, nameStart, nameEnd);
        a11y.speakA11yMessage(msg);
        sm.selectRange(anchorIndex, newIndex);
      }
    } else if (e.ctrlKey) {
      // While Ctrl is being held, only leadIndex and anchorIndex are moved.
      sm.anchorIndex = newIndex;
    } else {
      // 1) When pressing direction key results in a single selection, the
      //    check-select mode should be terminated.
      sm.setCheckSelectMode(false);

      if (sm.multiple) {
        sm.unselectAll();
      }
      sm.setIndexSelected(newIndex, true);
      sm.anchorIndex = newIndex;
    }

    sm.endChange();

    if (prevent) {
      e.preventDefault();
    }
  }
}

/**
 * Focus on the file list that contains the event target.
 * @param event the touch event.
 */
export function focusParentList(event: Event) {
  let element = event.target;
  while (element && !(element instanceof List)) {
    element = (element as HTMLElement).parentElement;
  }
  if (element) {
    element.focus();
  }
}

/**
 * Update the item's inline status when it's restored from List's cache..
 * @param restoredItem Item being restored from the List cache.
 * @param dataModel Data model corresponding to the item.
 * @param metadataModel Cache to retrieve metadata.
 */
export function updateCacheItemInlineStatus(
    restoredItem: ListItem, dataModel: ArrayDataModel|null,
    metadataModel: MetadataModel) {
  if (!dataModel || !metadataModel) {
    console.error('dataModel or metadataModel unavailable.');
    return;
  }

  const entry = dataModel.item(restoredItem.listIndex);

  const metadata = metadataModel.getCache([entry], [
    'availableOffline',
    'pinned',
    'canPin',
    'syncStatus',
    'progress',
    'syncCompletedTime',
  ])[0]!;

  updateInlineStatus(restoredItem, metadata);
}

/**
 * Update status icon for file or directory entry.
 * @param li The grid item.
 * @param metadata Metadata.
 */
export function updateInlineStatus(
    li: HTMLLIElement, metadata: null|MetadataItem) {
  const inlineStatus = li.querySelector('xf-inline-status');

  if (!metadata || !inlineStatus) {
    return;
  }

  const {
    pinned,
    availableOffline,
    canPin,
    progress,
    syncStatus,
    syncCompletedTime,
  } = metadata;

  if (isDriveFsBulkPinningEnabled()) {
    const cantPin = canPin === false;
    li.classList.toggle('cant-pin', cantPin);
    inlineStatus.toggleAttribute('cant-pin', cantPin);
  }

  // Directories are always displayed as available offline.
  const dimOffline =
      li.classList.contains('file') && availableOffline === false;
  li.classList.toggle('dim-offline', dimOffline);
  li.classList.toggle('pinned', pinned);
  inlineStatus.toggleAttribute('available-offline', pinned && !dimOffline);

  let actualSyncStatus = syncStatus;
  let actualProgress = progress;
  // Force sync status as completed if it has been less than 300ms since
  // the file has completed syncing.
  if (Date.now() - (syncCompletedTime ?? 0) < 300) {
    actualSyncStatus = chrome.fileManagerPrivate.SyncStatus.COMPLETED;
    actualProgress = 1;
  }
  inlineStatus.setAttribute('sync-status', String(actualSyncStatus));
  inlineStatus.setAttribute('progress', String(actualProgress));
}