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

// Copyright 2023 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 {VolumeInfo} from '../../background/js/volume_info.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {isModal} from '../../common/js/dialog_type.js';
import {getFocusedTreeItem} from '../../common/js/dom_utils.js';
import {getTreeItemEntry, isFakeEntry, isInteractiveVolume, isSameEntry, isSameVolume, isTeamDriveRoot, isTeamDrivesGrandRoot, isTrashRootType} from '../../common/js/entry_utils.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import type {State} from '../../state/state.js';
import {getFileData} from '../../state/store.js';
import {isTreeItem} from '../../widgets/xf_tree_util.js';

import type {CommandHandlerDeps} from './command_handler.js';
import type {DirectoryModel} from './directory_model.js';
import type {FileSelection} from './file_selection.js';
import type {MetadataKey} from './metadata/metadata_item.js';
import type {CanExecuteEvent, Command, CommandEvent} from './ui/command.js';
import {List} from './ui/list.js';
import type {Menu} from './ui/menu.js';
import type {MenuItem} from './ui/menu_item.js';

/**
 * The IDs of elements that can trigger share action.
 */
enum SharingActionElementId {
  CONTEXT_MENU = 'file-list',
  SHARE_SHEET = 'sharesheet-button',
}

function isList(element: EventTarget|null): element is List {
  if (element && 'selectedItems' in element) {
    return true;
  }
  return false;
}

function isMenu(element: EventTarget|null): element is Menu {
  if (element && 'contextElement' in element) {
    return true;
  }
  return false;
}

function isMenuItem(element: EventTarget|null): element is MenuItem {
  if (element && 'parentElement' in element &&
      isMenu(element.parentElement as EventTarget)) {
    return true;
  }
  return false;
}


/**
 * Helper function that for the given event returns the launch source of the
 * sharesheet. If the source cannot be determined, this function returns
 * chrome.fileManagerPrivate.SharesheetLaunchSource.UNKNOWN.
 */
export function getSharesheetLaunchSource(event: Event) {
  const id = (event.target as Command).id;
  switch (id) {
    case SharingActionElementId.CONTEXT_MENU:
      return chrome.fileManagerPrivate.SharesheetLaunchSource.CONTEXT_MENU;
    case SharingActionElementId.SHARE_SHEET:
      return chrome.fileManagerPrivate.SharesheetLaunchSource.SHARESHEET_BUTTON;
    default: {
      console.error('Unrecognized event.target.id for sharesheet launch', id);
      return chrome.fileManagerPrivate.SharesheetLaunchSource.UNKNOWN;
    }
  }
}

/**
 * Extracts entry on which command event was dispatched.
 */
export function getCommandEntry(
    fileManager: CommandHandlerDeps, element: EventTarget|null): Entry|
    FilesAppEntry|undefined {
  const entries = getCommandEntries(fileManager, element);
  return entries.length === 0 ? undefined : entries[0]!;
}

/**
 * Extracts entries on which command event was dispatched.
 */
export function getCommandEntries(
    fileManager: CommandHandlerDeps,
    element: EventTarget|null): Array<Entry|FilesAppEntry> {
  if (isTreeItem(element)) {
    const entry = getTreeItemEntry(element);
    if (entry) {
      return [entry];
    }
  }

  // DirectoryTree has the focused item.
  const focusedItem = getFocusedTreeItem(element);
  const entry = getTreeItemEntry(focusedItem);
  if (entry) {
    return [entry];
  }

  const htmlElement = element as HTMLElement;
  // The event target could still be a descendant of a legacy TreeItem element
  // (e.g. the eject button).
  // Handle eject button in the new directory tree.
  if (htmlElement.classList.contains('root-eject')) {
    const treeItem = htmlElement.closest('xf-tree-item');
    const entry = treeItem && getTreeItemEntry(treeItem);
    if (entry) {
      return [entry];
    }
  }

  // File list (List).
  if (isList(element) && element.selectedItems.length) {
    const entries = element.selectedItems as Array<Entry|FilesAppEntry>;
    // Check if it is Entry or not by checking for toURL().
    return entries.filter(entry => ('toURL' in entry));
  }

  // Commands in the action bar can only act in the currently selected files.
  if (fileManager.ui.actionbar.contains(htmlElement)) {
    return fileManager.getSelection().entries;
  }

  // Context Menu: redirect to the element the context menu is displayed for.
  if (isMenu(element) && element.contextElement) {
    return getCommandEntries(fileManager, element.contextElement);
  }

  // Context Menu Item: redirect to the element the context menu is displayed
  // for.
  if (isMenuItem(element)) {
    const menu = element.parentElement as Menu;
    if (menu.contextElement) {
      return getCommandEntries(fileManager, menu.contextElement);
    }
  }

  return [];
}

/**
 * Extracts a directory which contains entries on which command event was
 * dispatched.
 */
export function getParentEntry(
    element: EventTarget, directoryModel: DirectoryModel) {
  const focusedItem = getFocusedTreeItem(element);

  const parentItem = focusedItem?.parentItem;
  if (isTreeItem(parentItem) && getTreeItemEntry(parentItem)) {
    // DirectoryTree has the focused item.
    return getTreeItemEntry(parentItem);
  }

  if (element instanceof List) {
    return directoryModel ? directoryModel.getCurrentDirEntry() : null;
  }

  return null;
}

/**
 * Returns VolumeInfo from the current target for commands, based on |element|.
 * It can be from directory tree (clicked item or selected item), or from file
 * list selected items; or null if can determine it.
 */
export function getElementVolumeInfo(
    element: EventTarget|null, fileManager: CommandHandlerDeps): VolumeInfo|
    null|undefined {
  if (element && 'volumeInfo' in element) {
    return element.volumeInfo as VolumeInfo;
  }
  const entry = getCommandEntry(fileManager, element);
  return entry && fileManager.volumeManager.getVolumeInfo(entry);
}

/**
 * Sets the command as visible only when the current volume is drive and it's
 * running as a normal app, not as a modal dialog.
 * NOTE: This doesn't work for directory tree menu, because user can right-click
 * on any visible volume.
 */
export function canExecuteVisibleOnDriveInNormalAppModeOnly(
    event: CanExecuteEvent, fileManager: CommandHandlerDeps) {
  const enabled = fileManager.directoryModel.isOnDrive() &&
      !isModal(fileManager.dialogType);
  event.canExecute = enabled;
  event.command.setHidden(!enabled);
}

/**
 * Sets the default handler for the commandId and prevents handling
 * the keydown events for this command. Not doing that breaks relationship
 * of original keyboard event and the command. WebKit would handle it
 * differently in some cases.
 */
export function forceDefaultHandler(node: HTMLElement, commandId: string) {
  const doc = node.ownerDocument!;
  const command =
      doc.body.querySelector<Command>('command[id="' + commandId + '"]')!;
  node.addEventListener('keydown', e => {
    if (command.matchesEvent(e)) {
      e.stopPropagation();
    }
  });
  node.addEventListener('command', (event: CommandEvent) => {
    if (event.detail.command.id !== commandId) {
      return;
    }
    document.execCommand(event.detail.command.id);
    event.cancelBubble = true;
  });
  node.addEventListener(
      'canExecute',
      ((event: CanExecuteEvent) => {
        if (event.command.id !== commandId || event.target !== node) {
          return;
        }
        event.canExecute = document.queryCommandEnabled(event.command.id);
        event.command.setHidden(false);
      }) as EventListener);
}

/**
 * Returns a directory entry when only one entry is selected and it is
 * directory. Otherwise, returns null.
 * @param selection Instance of FileSelection.
 * @return Directory entry which is selected alone.
 */
export function getOnlyOneSelectedDirectory(selection: FileSelection):
    DirectoryEntry|null {
  if (!selection) {
    return null;
  }
  if (selection.totalCount !== 1) {
    return null;
  }
  if (!selection.entries[0]!.isDirectory) {
    return null;
  }
  return selection.entries[0] as DirectoryEntry;
}

/**
 * Returns true if the given entry is the root entry of the volume.
 * @param volumeManager
 * @param entry Entry or a fake entry.
 * @return True if the entry is a root entry.
 */
export function isRootEntry(
    volumeManager: VolumeManager, entry: Entry|FilesAppEntry) {
  if (!volumeManager || !entry) {
    return false;
  }

  const volumeInfo = volumeManager.getVolumeInfo(entry);
  return !!volumeInfo && isSameEntry(volumeInfo.displayRoot, entry);
}

/**
 * Returns true if the given event was triggered by the selection menu button.
 * @param event Command event.
 * @return True if the event was triggered by the selection menu button.
 */
export function isFromSelectionMenu(event: Event) {
  return (event.target as HTMLElement).id === 'selection-menu-button';
}

/**
 * If entry is fake/invalid/non-interactive/root, we don't show menu items
 * intended for regular entries.
 * @param volumeManager
 * @param entry Entry or a fake entry.
 * @return True if we should show the menu items for regular entries.
 */
export function shouldShowMenuItemsForEntry(
    volumeManager: VolumeManager, entry: Entry|FilesAppEntry) {
  // If the entry is fake entry, hide context menu entries.
  if (isFakeEntry(entry)) {
    return false;
  }

  // If the entry is not a valid entry, hide context menu entries.
  if (!volumeManager) {
    return false;
  }

  const volumeInfo = volumeManager.getVolumeInfo(entry);
  if (!volumeInfo) {
    return false;
  }

  // If the entry belongs to a non-interactive volume, hide context menu
  // entries.
  if (!isInteractiveVolume(volumeInfo)) {
    return false;
  }

  // If the entry is root entry of its volume (but not a team drive root),
  // hide context menu entries.
  if (isRootEntry(volumeManager, entry) && !isTeamDriveRoot(entry)) {
    return false;
  }

  if (isTeamDrivesGrandRoot(entry)) {
    return false;
  }

  return true;
}

/**
 * Returns whether all of the given entries have the given capability.
 *
 * @param fileManager CommandHandlerDeps.
 * @param entries List of entries to check capabilities for.
 * @param capability Name of the capability to check for.
 */
export function hasCapability(
    fileManager: CommandHandlerDeps, entries: Array<Entry|FilesAppEntry>,
    capability: MetadataKey) {
  if (entries.length === 0) {
    return false;
  }

  // Check if the capability is true or undefined, but not false. A capability
  // can be undefined if the metadata is not fetched from the server yet (e.g.
  // if we create a new file in offline mode), or if there is a problem with the
  // cache and we don't have data yet. For this reason, we need to allow the
  // functionality even if it's not set.
  // TODO(crbug.com/41392991): Store restrictions instead of capabilities.
  const metadata = fileManager.metadataModel.getCache(entries, [capability]);
  return metadata.length === entries.length &&
      metadata.every(item => item[capability] !== false);
}

/**
 * Checks if the handler should ignore the current event, eg. since there is
 * a popup dialog currently opened.
 *
 * @return True if the event should be ignored, false otherwise.
 */
export function shouldIgnoreEvents(doc: Document) {
  // Do not handle commands, when a dialog is shown. Do not use querySelector
  // as it's much slower, and this method is executed often.
  const dialogs = doc.getElementsByClassName('cr-dialog-container');
  if (dialogs.length !== 0 && dialogs[0]!.classList.contains('shown')) {
    return true;
  }

  return false;  // Do not ignore.
}

/**
 * Returns true if all entries is inside Drive volume, which includes all Drive
 * parts (Shared Drives, My Drive, Shared with me, etc).
 */
export function isDriveEntries(
    entries: Array<Entry|FilesAppEntry>, volumeManager: VolumeManager) {
  if (!entries.length) {
    return false;
  }

  const volumeInfo = volumeManager.getVolumeInfo(entries[0]!);
  if (!volumeInfo) {
    return false;
  }

  if (volumeInfo.volumeType === VolumeType.DRIVE &&
      isSameVolume(entries, volumeManager)) {
    return true;
  }

  return false;
}

/**
 * Returns true if all entries descend from the My Drive root (e.g. not located
 * within Shared with me or Shared drives).
 */
export function isOnlyMyDriveEntries(
    entries: Array<Entry|FilesAppEntry>, state: State): boolean {
  if (!entries.length) {
    return false;
  }

  for (const entry of entries) {
    const fileData = getFileData(state, entry.toURL());
    if (!fileData) {
      return false;
    }
    if (fileData.rootType !== RootType.DRIVE) {
      return false;
    }
  }
  return true;
}

/**
 * Returns true if the current root is Trash. Items in Trash are a fake
 * representation of a file + its metadata. Some actions are infeasible and
 * items should be restored to enable these actions.
 */
export function isOnTrashRoot(fileManager: CommandHandlerDeps) {
  const currentRootType = fileManager.directoryModel.getCurrentRootType();
  if (!currentRootType) {
    return false;
  }
  return isTrashRootType(currentRootType);
}

/**
 * Extracts entry on which command event was dispatched.
 */
export function getEventEntry(event: Event, fileManager: CommandHandlerDeps):
    Entry|FilesAppEntry|undefined {
  let entry;
  const htmlElement = event.target as HTMLElement;
  if (fileManager.ui.directoryTree!.contains(htmlElement)) {
    // The command is executed from the directory tree context menu.
    entry = getCommandEntry(fileManager, htmlElement);
  } else {
    // The command is executed from the gear menu.
    entry = fileManager.directoryModel.getCurrentDirEntry();
  }
  return entry;
}

/**
 * Returns true if the current volume is interactive.
 */
export function currentVolumeIsInteractive(fileManager: CommandHandlerDeps) {
  const volumeInfo = fileManager.directoryModel.getCurrentVolumeInfo();
  if (!volumeInfo) {
    return true;
  }
  return isInteractiveVolume(volumeInfo);
}

/**
 * Returns true if any entry belongs to a non-interactive volume.
 */
export function containsNonInteractiveEntry(
    entries: Array<Entry|FilesAppEntry>, fileManager: CommandHandlerDeps) {
  return entries.some(entry => {
    const volumeInfo = fileManager.volumeManager.getVolumeInfo(entry);
    if (!volumeInfo) {
      return false;
    }
    return isInteractiveVolume(volumeInfo);
  });
}