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

import type {Crostini} from '../../background/js/crostini.js';
import type {ProgressCenter} from '../../background/js/progress_center.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {crInjectTypeAndInit} from '../../common/js/cr_ui.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import type {FilesAppState} from '../../common/js/files_app_state.js';
import {recordEnum} from '../../common/js/metrics.js';
import type {DialogType} from '../../state/state.js';

import type {ActionsController} from './actions_controller.js';
import {DEFAULT_BRUSCHETTA_VM, DEFAULT_CROSTINI_VM, PLUGIN_VM} from './constants.js';
import type {FileFilter} from './directory_contents.js';
import type {DirectoryModel} from './directory_model.js';
import type {DirectoryTreeNamingController} from './directory_tree_naming_controller.js';
import {BrowserBackCommand, ConfigureCommand, CutCopyCommand, DefaultTaskCommand, DeleteCommand, DlpRestrictionDetailsCommand, DriveBuyMoreSpaceCommand, DriveGoToDriveCommand, DriveSyncSettingsCommand, EmptyTrashCommand, EraseDeviceCommand, ExtractAllCommand, FilesSettingsCommand, FocusActionBarCommand, FormatCommand, GetInfoCommand, GoToFileLocationCommand, GuestOsManagingSharingCommand, GuestOsManagingSharingGearCommand, GuestOsShareCommand, InspectConsoleCommand, InspectElementCommand, InspectNormalCommand, InvokeSharesheetCommand, ManageInDriveCommand, ManageMirrorsyncCommand, NewFolderCommand, NewWindowCommand, OpenGearMenuCommand, OpenWithCommand, PasteCommand, PasteIntoCurrentFolderCommand, PasteIntoFolderCommand, PinFolderCommand, RefreshCommand, RenameCommand, RestoreFromTrashCommand, SearchCommand, SelectAllCommand, SendFeedbackCommand, SetWallpaperCommand, ShowProvidersSubmenuCommand, SortByDateCommand, SortByNameCommand, SortBySizeCommand, SortByTypeCommand, ToggleHiddenAndroidFoldersCommand, ToggleHiddenFilesCommand, ToggleHoldingSpaceCommand, TogglePinnedCommand, UnmountCommand, UnpinFolderCommand, VolumeHelpCommand, VolumeStorageCommand, VolumeSwitchCommand, ZipSelectionCommand, ZoomInCommand, ZoomOutCommand, ZoomResetCommand} from './file_manager_commands.js';
import {shouldIgnoreEvents} from './file_manager_commands_util.js';
import type {FileSelection, FileSelectionHandler} from './file_selection.js';
import type {FileTransferController} from './file_transfer_controller.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import type {NamingController} from './naming_controller.js';
import type {ProvidersModel} from './providers_model.js';
import type {SpinnerController} from './spinner_controller.js';
import type {TaskController} from './task_controller.js';
import type {CanExecuteEvent} from './ui/command.js';
import {Command, type CommandEvent} from './ui/command.js';
import {contextMenuHandler, type HideEvent, type ShowEvent} from './ui/context_menu_handler.js';
import type {FileManagerUI} from './ui/file_manager_ui.js';

/**
 * Interface on which `CommandHandler` depends.
 */
export interface CommandHandlerDeps {
  actionsController: ActionsController;
  dialogType: DialogType;
  directoryModel: DirectoryModel;
  directoryTreeNamingController: DirectoryTreeNamingController;
  document: Document;
  fileFilter: FileFilter;
  fileTransferController: FileTransferController|null;
  selectionHandler: FileSelectionHandler;
  namingController: NamingController;
  progressCenter: ProgressCenter;
  providersModel: ProvidersModel;
  spinnerController: SpinnerController;
  taskController: TaskController;
  ui: FileManagerUI;
  volumeManager: VolumeManager;
  metadataModel: MetadataModel;
  crostini: Crostini;
  guestMode: boolean;
  trashEnabled: boolean;

  getCurrentDirectoryEntry(): DirectoryEntry|FilesAppEntry|null|undefined;
  getSelection(): FileSelection;
  launchFileManager(appState?: FilesAppState): void;
}

/**
 * Name of a command (for UMA).
 */
export enum MenuCommandsForUma {
  HELP = 'volume-help',
  DRIVE_HELP = 'volume-help-drive',
  DRIVE_BUY_MORE_SPACE = 'drive-buy-more-space',
  DRIVE_GO_TO_DRIVE = 'drive-go-to-drive',
  HIDDEN_FILES_SHOW = 'toggle-hidden-files-on',
  HIDDEN_FILES_HIDE = 'toggle-hidden-files-off',
  MOBILE_DATA_ON = 'drive-sync-settings-enabled',
  MOBILE_DATA_OFF = 'drive-sync-settings-disabled',
  DEPRECATED_SHOW_GOOGLE_DOCS_FILES_OFF = 'drive-hosted-settings-disabled',
  DEPRECATED_SHOW_GOOGLE_DOCS_FILES_ON = 'drive-hosted-settings-enabled',
  HIDDEN_ANDROID_FOLDERS_SHOW = 'toggle-hidden-android-folders-on',
  HIDDEN_ANDROID_FOLDERS_HIDE = 'toggle-hidden-android-folders-off',
  SHARE_WITH_LINUX = 'share-with-linux',
  MANAGE_LINUX_SHARING = 'manage-linux-sharing',
  MANAGE_LINUX_SHARING_TOAST = 'manage-linux-sharing-toast',
  MANAGE_LINUX_SHARING_TOAST_STARTUP = 'manage-linux-sharing-toast-startup',
  SHARE_WITH_PLUGIN_VM = 'share-with-plugin-vm',
  MANAGE_PLUGIN_VM_SHARING = 'manage-plugin-vm-sharing',
  MANAGE_PLUGIN_VM_SHARING_TOAST = 'manage-plugin-vm-sharing-toast',
  MANAGE_PLUGIN_VM_SHARING_TOAST_STARTUP =
      'manage-plugin-vm-sharing-toast-startup',
  PIN_TO_HOLDING_SPACE = 'pin-to-holding-space',
  UNPIN_FROM_HOLDING_SPACE = 'unpin-from-holding-space',
  SHARE_WITH_BRUSCHETTA = 'share-with-bruschetta',
  MANAGE_BRUSCHETTA_SHARING = 'manage-bruschetta-sharing',
  MANAGE_BRUSCHETTA_SHARING_TOAST = 'manage-bruschetta-sharing-toast',
  MANAGE_BRUSCHETTA_SHARING_TOAST_STARTUP =
      'manage-bruschetta-sharing-toast-startup',
}

const cutCopyCommand = new CutCopyCommand();
const deleteCommand = new DeleteCommand();

const crostiniSettings = 'crostini/sharedPaths';
const pluginVmSettings = 'app-management/pluginVm/sharedPaths';
const bruschettaSettings = 'bruschetta/sharedPaths';

/**
 * A map of FilesCommand to the ID that is used in the DOM to reference them.
 */
const FilesCommands = {
  'focus-action-bar': new FocusActionBarCommand(),
  'open-gear-menu': new OpenGearMenuCommand(),
  'inspect-element': new InspectElementCommand(),
  'inspect-console': new InspectConsoleCommand(),
  'inspect-normal': new InspectNormalCommand(),
  'sort-by-date': new SortByDateCommand(),
  'sort-by-type': new SortByTypeCommand(),
  'sort-by-size': new SortBySizeCommand(),
  'sort-by-name': new SortByNameCommand(),
  'zoom-reset': new ZoomResetCommand(),
  'zoom-out': new ZoomOutCommand(),
  'zoom-in': new ZoomInCommand(),
  'unpin-folder': new UnpinFolderCommand(),
  'pin-folder': new PinFolderCommand(),
  'manage-mirrorsync': new ManageMirrorsyncCommand(),
  'manage-in-drive': new ManageInDriveCommand(),
  'zip-selection': new ZipSelectionCommand(),
  'extract-all': new ExtractAllCommand(),
  'toggle-pinned': new TogglePinnedCommand(),
  'search': new SearchCommand(),
  'dlp-restriction-details': new DlpRestrictionDetailsCommand(),
  'get-info': new GetInfoCommand(),
  'go-to-file-location': new GoToFileLocationCommand(),
  'toggle-holding-space': new ToggleHoldingSpaceCommand(),
  'invoke-sharesheet': new InvokeSharesheetCommand(),
  'open-with': new OpenWithCommand(),
  'default-task': new DefaultTaskCommand(),
  'drive-go-to-drive': new DriveGoToDriveCommand(),
  'drive-buy-more-space': new DriveBuyMoreSpaceCommand(),
  'send-feedback': new SendFeedbackCommand(),
  'volume-help': new VolumeHelpCommand(),
  'files-settings': new FilesSettingsCommand(),
  'rename': new RenameCommand(),
  'cut': cutCopyCommand,
  'copy': cutCopyCommand,
  'paste-into-folder': new PasteIntoFolderCommand(),
  'paste-into-current-folder': new PasteIntoCurrentFolderCommand(),
  'paste': new PasteCommand(),
  'empty-trash': new EmptyTrashCommand(),
  'restore-from-trash': new RestoreFromTrashCommand(),
  'delete': deleteCommand,
  'move-to-trash': deleteCommand,
  'drive-sync-settings': new DriveSyncSettingsCommand(),
  'toggle-hidden-android-folders': new ToggleHiddenAndroidFoldersCommand(),
  'toggle-hidden-files': new ToggleHiddenFilesCommand(),
  'select-all': new SelectAllCommand(),
  'new-window': new NewWindowCommand(),
  'new-folder': new NewFolderCommand(),
  'erase-device': new EraseDeviceCommand(),
  'format': new FormatCommand(),
  'unmount': new UnmountCommand(),
  'browser-back': new BrowserBackCommand(),
  'configure': new ConfigureCommand(),
  'refresh': new RefreshCommand(),
  'set-wallpaper': new SetWallpaperCommand(),
  'volume-storage': new VolumeStorageCommand(),
  'show-providers-submenu': new ShowProvidersSubmenuCommand(),
  'volume-switch-1': new VolumeSwitchCommand(1),
  'volume-switch-2': new VolumeSwitchCommand(2),
  'volume-switch-3': new VolumeSwitchCommand(3),
  'volume-switch-4': new VolumeSwitchCommand(4),
  'volume-switch-5': new VolumeSwitchCommand(5),
  'volume-switch-6': new VolumeSwitchCommand(6),
  'volume-switch-7': new VolumeSwitchCommand(7),
  'volume-switch-8': new VolumeSwitchCommand(8),
  'volume-switch-9': new VolumeSwitchCommand(9),
  'share-with-linux': new GuestOsShareCommand(
      DEFAULT_CROSTINI_VM, 'CROSTINI', crostiniSettings,
      MenuCommandsForUma.MANAGE_LINUX_SHARING_TOAST,
      MenuCommandsForUma.SHARE_WITH_LINUX),
  'share-with-plugin-vm': new GuestOsShareCommand(
      PLUGIN_VM, 'PLUGIN_VM', pluginVmSettings,
      MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING_TOAST,
      MenuCommandsForUma.SHARE_WITH_PLUGIN_VM),
  'share-with-bruschetta': new GuestOsShareCommand(
      DEFAULT_BRUSCHETTA_VM, 'BRUSCHETTA', bruschettaSettings,
      MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING_TOAST,
      MenuCommandsForUma.SHARE_WITH_BRUSCHETTA),
  'manage-linux-sharing-gear': new GuestOsManagingSharingGearCommand(
      DEFAULT_CROSTINI_VM, crostiniSettings,
      MenuCommandsForUma.MANAGE_LINUX_SHARING),
  'manage-plugin-vm-sharing-gear': new GuestOsManagingSharingGearCommand(
      PLUGIN_VM, pluginVmSettings, MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING),
  'manage-bruschetta-sharing-gear': new GuestOsManagingSharingGearCommand(
      DEFAULT_BRUSCHETTA_VM, bruschettaSettings,
      MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING),
  'manage-linux-sharing': new GuestOsManagingSharingCommand(
      DEFAULT_CROSTINI_VM, crostiniSettings,
      MenuCommandsForUma.MANAGE_LINUX_SHARING),
  'manage-plugin-vm-sharing': new GuestOsManagingSharingCommand(
      PLUGIN_VM, pluginVmSettings, MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING),
  'manage-bruschetta-sharing': new GuestOsManagingSharingCommand(
      DEFAULT_BRUSCHETTA_VM, bruschettaSettings,
      MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING),
};

/**
 * Strongly type any places which can ONLY provide a key that directly maps to a
 * files command above.
 */
export type FilesCommandId = keyof typeof FilesCommands;

/**
 * Handle of the command events.
 */
export class CommandHandler {
  /**
   * Command elements.
   */
  private commands_: Record<string, Command> = {};
  private lastFocusedElement_: HTMLElement|null = null;

  /**
   * @param fileManager_ Classes |CommandHandler| depends.
   */
  constructor(private fileManager_: CommandHandlerDeps) {
    // Decorate command tags in the document.
    const commands =
        this.fileManager_.document.querySelectorAll<Command>('command');

    for (const command of commands) {
      crInjectTypeAndInit(command, Command);
      this.commands_[command.id] = command;
    }

    // Register events.
    this.fileManager_.document.addEventListener(
        'command', this.onCommand_.bind(this) as EventListener);
    this.fileManager_.document.addEventListener(
        'canExecute', this.onCanExecute_.bind(this) as EventListener);

    contextMenuHandler.addEventListener(
        'show', this.onContextMenuShow_.bind(this));
    contextMenuHandler.addEventListener(
        'hide', this.onContextMenuHide_.bind(this));
  }

  private onContextMenuShow_(event: ShowEvent) {
    this.lastFocusedElement_ = document.activeElement as HTMLElement;
    const menu = event.detail.menu;
    // Set focus asynchronously to give time for menu "show" event to finish and
    // have all items set up before focusing.
    setTimeout(() => {
      if (!menu.hidden) {
        menu.focusSelectedItem();
      }
    }, 0);
  }

  private onContextMenuHide_(_event: HideEvent) {
    if (this.lastFocusedElement_) {
      const activeElement = document.activeElement;
      if (activeElement && activeElement.tagName === 'BODY') {
        this.lastFocusedElement_.focus();
      }
      this.lastFocusedElement_ = null;
    }
  }

  /**
   * Handles command events.
   */
  private onCommand_(event: CommandEvent) {
    assert(this.fileManager_.document);
    if (shouldIgnoreEvents(this.fileManager_.document)) {
      return;
    }
    const commandId = event.detail.command.id as FilesCommandId;
    const handler = FilesCommands[commandId];
    handler.execute(event, this.fileManager_);
  }

  /**
   * Handles canExecute events.
   */
  private onCanExecute_(event: CanExecuteEvent) {
    assert(this.fileManager_.document);
    if (shouldIgnoreEvents(this.fileManager_.document)) {
      return;
    }
    const commandId = event.command.id as FilesCommandId;
    const handler = FilesCommands[commandId]!;
    handler.canExecute(event, this.fileManager_);
  }

  /**
   * Returns command handler by name.
   */
  static getCommand<K extends FilesCommandId>(name: K):
      typeof FilesCommands[K] {
    return FilesCommands[name];
  }
}

/**
 * Keep the order of this in sync with FileManagerMenuCommands in
 * tools/metrics/histograms/enums.xml.
 * The array indices will be recorded in UMA as enum values. The index for each
 * root type should never be renumbered nor reused in this array.
 */
export const ValidMenuCommandsForUma = [
  MenuCommandsForUma.HELP,
  MenuCommandsForUma.DRIVE_HELP,
  MenuCommandsForUma.DRIVE_BUY_MORE_SPACE,
  MenuCommandsForUma.DRIVE_GO_TO_DRIVE,
  MenuCommandsForUma.HIDDEN_FILES_SHOW,
  MenuCommandsForUma.HIDDEN_FILES_HIDE,
  MenuCommandsForUma.MOBILE_DATA_ON,
  MenuCommandsForUma.MOBILE_DATA_OFF,
  MenuCommandsForUma.DEPRECATED_SHOW_GOOGLE_DOCS_FILES_OFF,
  MenuCommandsForUma.DEPRECATED_SHOW_GOOGLE_DOCS_FILES_ON,
  MenuCommandsForUma.HIDDEN_ANDROID_FOLDERS_SHOW,
  MenuCommandsForUma.HIDDEN_ANDROID_FOLDERS_HIDE,
  MenuCommandsForUma.SHARE_WITH_LINUX,
  MenuCommandsForUma.MANAGE_LINUX_SHARING,
  MenuCommandsForUma.MANAGE_LINUX_SHARING_TOAST,
  MenuCommandsForUma.MANAGE_LINUX_SHARING_TOAST_STARTUP,
  MenuCommandsForUma.SHARE_WITH_PLUGIN_VM,
  MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING,
  MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING_TOAST,
  MenuCommandsForUma.MANAGE_PLUGIN_VM_SHARING_TOAST_STARTUP,
  MenuCommandsForUma.PIN_TO_HOLDING_SPACE,
  MenuCommandsForUma.UNPIN_FROM_HOLDING_SPACE,
  MenuCommandsForUma.SHARE_WITH_BRUSCHETTA,
  MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING,
  MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING_TOAST,
  MenuCommandsForUma.MANAGE_BRUSCHETTA_SHARING_TOAST_STARTUP,
];
console.assert(
    Object.keys(MenuCommandsForUma).length === ValidMenuCommandsForUma.length,
    'Members in ValidMenuCommandsForUma do not match those in ' +
        'MenuCommandsForUma.');

/**
 * Records the menu item as selected in UMA.
 */
export function recordMenuItemSelected(menuItem: MenuCommandsForUma) {
  recordEnum('MenuItemSelected', menuItem, ValidMenuCommandsForUma);
}