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

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {ImageLoaderClient} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/image_loader_client.js';
import type {LoadImageResponse} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/load_image_request.js';
import {createForUrl, LoadImageResponseStatus} from 'chrome-extension://pmfjbimdmchhbnneeidfognadeopoehp/load_image_request.js';
import {assert} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import {isModal} from '../../common/js/dialog_type.js';
import {isSameEntry} from '../../common/js/entry_utils.js';
import {parseActionId} from '../../common/js/file_tasks.js';
import {getType} from '../../common/js/file_type.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {getEntryLabel, str} from '../../common/js/translations.js';
import {VolumeType} from '../../common/js/volume_manager_types.js';
import type {DialogType} from '../../state/state.js';
import type {FilesQuickView} from '../elements/files_quick_view.js';
import type {FilesTooltip} from '../elements/files_tooltip.js';

import {CommandHandler, type CommandHandlerDeps} from './command_handler.js';
import type {FileSelectionHandler} from './file_selection.js';
import {EventType} from './file_selection.js';
import type {FileTasks} from './file_tasks.js';
import type {MetadataItem} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import type {MetadataBoxController} from './metadata_box_controller.js';
import type {QuickViewModel} from './quick_view_model.js';
import type {QuickViewUma} from './quick_view_uma.js';
import {WayToOpen} from './quick_view_uma.js';
import type {TaskController} from './task_controller.js';
import {THUMBNAIL_MAX_HEIGHT, THUMBNAIL_MAX_WIDTH} from './thumbnail_loader.js';
import type {CommandEvent} from './ui/command.js';
import type {FileListSelectionModel, FileListSingleSelectionModel} from './ui/file_list_selection_model.js';
import {FilesConfirmDialog} from './ui/files_confirm_dialog.js';
import type {ListContainer} from './ui/list_container.js';
import type {MultiMenuButton} from './ui/multi_menu_button.js';

/**
 * Controller for QuickView.
 */
export class QuickViewController {
  private quickView_: FilesQuickView|null = null;

  /**
   * Delete confirm dialog.
   */
  private deleteConfirmDialog_: FilesConfirmDialog|null = null;

  /**
   * Current selection of selectionHandler.
   */
  private entries_: Array<Entry|FilesAppEntry> = [];

  /**
   * The tasks for the current entry shown in quick view.
   */
  private tasks_: FileTasks|null = null;

  /**
   * The current selection index of this.entries_.
   */
  private currentSelection_ = 0;

  /**
   * Stores whether we are in check-select mode or not.
   */
  private checkSelectMode_ = false;

  constructor(
      private fileManager_: CommandHandlerDeps,
      private metadataModel_: MetadataModel,
      private selectionHandler_: FileSelectionHandler,
      private listContainer_: ListContainer,
      selectionMenuButton: MultiMenuButton,
      private quickViewModel_: QuickViewModel,
      private taskController_: TaskController,
      private fileListSelectionModel_: FileListSelectionModel|
      FileListSingleSelectionModel,
      private quickViewUma_: QuickViewUma,
      private metadataBoxController_: MetadataBoxController,
      private dialogType_: DialogType, private volumeManager_: VolumeManager,
      dialogDom: HTMLElement) {
    this.selectionHandler_.addEventListener(
        EventType.CHANGE,
        this.onFileSelectionChanged_.bind(this) as EventListener);
    this.listContainer_.element.addEventListener(
        'keydown', this.onKeyDownToOpen_.bind(this));

    // Selection menu command can be triggered with focus outside of file list
    // or button e.g.: from the directory tree.
    dialogDom.addEventListener(
        'command', this.onCommad_.bind(this, WayToOpen.SELECTION_MENU));
    this.listContainer_.element.addEventListener(
        'command', this.onCommad_.bind(this, WayToOpen.CONTEXT_MENU));
    selectionMenuButton.addEventListener(
        'command', this.onCommad_.bind(this, WayToOpen.SELECTION_MENU));
  }

  private onCommad_(wayToOpen: WayToOpen, event: CommandEvent) {
    if (event.detail.command.id === 'get-info') {
      event.stopPropagation();
      this.display_(wayToOpen);
    }
  }

  /**
   * Initialize the controller with quick view which will be lazily loaded.
   */
  private init_(quickView: FilesQuickView) {
    this.quickView_ = quickView;
    this.quickView_.isModal = isModal(this.dialogType_);

    this.quickView_.setAttribute('files-ng', '');

    this.metadataBoxController_.init(quickView);

    document.body.addEventListener(
        'keydown', this.onQuickViewKeyDown_.bind(this));

    // Prevent selected file from being copied when quick view is open and
    // instead allow any selected "General info" text to be copied.
    document.body.addEventListener('copy', event => {
      if (this.quickView_!.isOpened()) {
        // Stop 'copy' event propagation to FileTransferController and allow
        // default copy event behaviour.
        event.stopPropagation();
      }
    });

    this.quickView_.addEventListener('close', () => {
      this.listContainer_.focus();
    });

    this.quickView_.onOpenInNewButtonClick =
        this.onOpenInNewButtonClick_.bind(this);

    this.quickView_.onDeleteButtonClick = this.onDeleteButtonClick_.bind(this);

    const toolTipElements =
        this.quickView_.shadowRoot!.querySelector<HTMLDivElement>('#toolbar')!
            .querySelectorAll('[has-tooltip]');
    this.quickView_.shadowRoot!.querySelector<FilesTooltip>('files-tooltip')!
        .addTargets(toolTipElements);
  }

  /**
   * Create quick view element.
   */
  private createQuickView_() {
    return document.querySelector<FilesQuickView>('#quick-view')!;
  }

  /**
   * Handles open-in-new button tap.
   */
  private onOpenInNewButtonClick_() {
    this.tasks_ && this.tasks_.executeDefault();
    this.quickView_!.close();
  }

  /**
   * Handles delete button tap.
   */
  private onDeleteButtonClick_() {
    this.deleteSelectedEntry_();
  }

  /**
   * Handles key event on listContainer if it's relevant to quick view.
   */
  private onKeyDownToOpen_(event: KeyboardEvent) {
    if (event.key === ' ') {
      event.preventDefault();
      event.stopImmediatePropagation();
      if (this.entries_.length > 0) {
        this.display_(WayToOpen.SPACE_KEY);
      }
    }
  }

  /**
   * Handles key event on quick view.
   */
  private onQuickViewKeyDown_(event: KeyboardEvent) {
    if (this.quickView_ && this.quickView_.isOpened()) {
      switch (event.key) {
        case ' ':
        case 'Escape':
          event.preventDefault();
          // Prevent the open dialog from closing.
          event.stopImmediatePropagation();
          this.quickView_.close();
          break;
        case 'ArrowRight':
        case 'ArrowDown':
          if (this.fileListSelectionModel_.getCheckSelectMode()) {
            this.changeCheckSelectModeSelection_(true);
          } else {
            this.changeSingleSelectModeSelection_(true);
          }
          break;
        case 'ArrowLeft':
        case 'ArrowUp':
          if (this.fileListSelectionModel_.getCheckSelectMode()) {
            this.changeCheckSelectModeSelection_();
          } else {
            this.changeSingleSelectModeSelection_();
          }
          break;
        case 'Delete':
          this.deleteSelectedEntry_();
          break;
      }
    }
  }

  /**
   * Changes the currently selected entry when in single-select mode.  Sets
   * the models |selectedIndex| to indirectly trigger onFileSelectionChanged_
   * and populate |this.entries_|.
   */
  private changeSingleSelectModeSelection_(down = false) {
    let index;

    if (down) {
      index = this.fileListSelectionModel_.selectedIndex + 1;
      if (index >= this.fileListSelectionModel_.length) {
        index = 0;
      }
    } else {
      index = this.fileListSelectionModel_.selectedIndex - 1;
      if (index < 0) {
        index = this.fileListSelectionModel_.length - 1;
      }
    }

    this.fileListSelectionModel_.selectedIndex = index;
  }

  /**
   * Changes the currently selected entry when in multi-select mode (file
   * list calls this "check-select" mode).
   */
  private changeCheckSelectModeSelection_(down = false) {
    if (down) {
      this.currentSelection_ = this.currentSelection_ + 1;
      if (this.currentSelection_ >=
          this.fileListSelectionModel_.selectedIndexes.length) {
        this.currentSelection_ = 0;
      }
    } else {
      this.currentSelection_ = this.currentSelection_ - 1;
      if (this.currentSelection_ < 0) {
        this.currentSelection_ =
            this.fileListSelectionModel_.selectedIndexes.length - 1;
      }
    }

    this.onFileSelectionChanged_();
  }

  /**
   * Delete the currently selected entry in quick view.
   */
  private deleteSelectedEntry_() {
    const entry = this.entries_[this.currentSelection_];

    // Create a delete confirm dialog if needed.
    if (!this.deleteConfirmDialog_) {
      const dialogElement = document.createElement('dialog');
      this.quickView_!.shadowRoot!.appendChild(dialogElement);
      dialogElement.id = 'delete-confirm-dialog';

      this.deleteConfirmDialog_ = new FilesConfirmDialog(dialogElement);
      this.deleteConfirmDialog_.setOkLabel(str('DELETE_BUTTON_LABEL'));
      this.deleteConfirmDialog_.focusCancelButton = true;

      dialogElement.addEventListener('keydown', event => {
        event.stopPropagation();
      });

      this.deleteConfirmDialog_.showModalElement = () => {
        dialogElement.showModal();
      };

      this.deleteConfirmDialog_.doneCallback = () => {
        dialogElement.close();
      };
    }

    this.checkSelectMode_ = this.fileListSelectionModel_.getCheckSelectMode();

    // Delete the entry if the entry can be deleted.
    const deleteCommand = CommandHandler.getCommand('delete');
    deleteCommand.deleteEntries(
        [entry!], this.fileManager_, /*permanentlyDelete=*/ false,
        this.deleteConfirmDialog_);
  }

  /**
   * Returns true if the entry can be deleted.
   */
  private async canDeleteEntry_(entry: Entry|FilesAppEntry) {
    const deleteCommand = CommandHandler.getCommand('delete');
    return deleteCommand.canDeleteEntries([entry], this.fileManager_);
  }

  /**
   * Display quick view.
   */
  private async display_(wayToOpen: WayToOpen) {
    // On opening Quick View, always reset the current selection index.
    this.currentSelection_ = 0;

    this.checkSelectMode_ = this.fileListSelectionModel_.getCheckSelectMode();

    await this.updateQuickView_();
    if (!this.quickView_!.isOpened()) {
      this.quickView_!.open();
      if (this.entries_[0]) {
        this.quickViewUma_.onOpened(this.entries_[0], wayToOpen);
      }
    }
  }

  /**
   * Update quick view on file selection change.
   */
  private onFileSelectionChanged_(event?: Event&
                                  {target: FileSelectionHandler}) {
    if (event) {
      this.entries_ = event.target?.selection.entries;

      if (!this.entries_ || !this.entries_.length) {
        if (this.quickView_ && this.quickView_.isOpened()) {
          this.quickView_.close();
        }
        return;
      }

      if (this.currentSelection_ >= this.entries_.length) {
        this.currentSelection_ = this.entries_.length - 1;
      } else if (this.currentSelection_ < 0) {
        this.currentSelection_ = 0;
      }

      const checkSelectModeExited = this.checkSelectMode_ !==
          this.fileListSelectionModel_.getCheckSelectMode();
      if (checkSelectModeExited) {
        if (this.quickView_ && this.quickView_.isOpened()) {
          this.quickView_.close();
          return;
        }
      }
    }

    if (this.quickView_ && this.quickView_.isOpened() &&
        this.entries_[this.currentSelection_]) {
      const entry = this.entries_[this.currentSelection_]!;
      if (!isSameEntry(entry, this.quickViewModel_.getSelectedEntry()!)) {
        this.updateQuickView_();
      }
    }
  }

  /**
   * Update quick view using current entries.
   */
  private async updateQuickView_(): Promise<void> {
    if (!this.quickView_) {
      try {
        const quickView = this.createQuickView_();
        this.init_(quickView);
        return this.updateQuickView_();
      } catch (error) {
        console.warn(error);
        return;
      }
    }

    assert(this.entries_.length >= this.currentSelection_);
    const entry = this.entries_[this.currentSelection_]!;
    this.quickViewModel_.setSelectedEntry(entry);
    this.tasks_ = null;

    requestIdleCallback(() => {
      this.quickViewUma_.onEntryChanged(entry);
    });

    try {
      const values = await Promise.all([
        this.metadataModel_.get(
            [entry], ['thumbnailUrl', 'modificationTime', 'contentMimeType']),
        this.taskController_.getEntryFileTasks(entry),
        this.canDeleteEntry_(entry),
      ]);

      const items = values[0];
      const tasks = values[1];
      const canDelete = values[2];
      return this.onMetadataLoaded_(entry, items, tasks, canDelete);
    } catch (error: any) {
      if (error) {
        console.warn(error.stack || error);
      }
    }
  }

  /**
   * Update quick view for |entry| from its loaded metadata and tasks.
   *
   * Note: fast-typing users can change the active selection while the |entry|
   * metadata and tasks were being async fetched. Bail out in that case.
   */
  private async onMetadataLoaded_(
      entry: Entry|FilesAppEntry, items: MetadataItem[], fileTasks: FileTasks,
      canDelete: boolean) {
    const tasks = fileTasks.getAnnotatedTasks();

    const params =
        await this.getQuickViewParameters_(entry, items, tasks, canDelete);
    if (this.quickViewModel_.getSelectedEntry() !== entry) {
      return;  // Bail: there's no point drawing a stale selection.
    }

    const emptySourceContent = {
      data: null,
      dataType: '',
    };

    this.quickView_!.setProperties({
      type: params.type || '',
      subtype: params.subtype || '',
      filePath: params.filePath || '',
      hasTask: params.hasTask || false,
      canDelete: params.canDelete || false,
      sourceContent: params.sourceContent || emptySourceContent,
      videoPoster: params.videoPoster || emptySourceContent,
      audioArtwork: params.audioArtwork || emptySourceContent,
      autoplay: params.autoplay || false,
      browsable: params.browsable || false,
    });

    if (params.hasTask) {
      this.tasks_ = fileTasks;
    }
  }

  private async getQuickViewParameters_(
      entry: FileEntry|Entry|FilesAppEntry, items: MetadataItem[],
      tasks: chrome.fileManagerPrivate.FileTask[],
      canDelete: boolean): Promise<Partial<QuickViewParams>> {
    const firstItem = items[0];
    const typeInfo = getType(entry, firstItem?.contentMimeType);
    const type = typeInfo.type;
    const locationInfo = this.volumeManager_.getLocationInfo(entry);
    const label = getEntryLabel(locationInfo, entry);
    const entryIsOnDrive = locationInfo && locationInfo.isDriveBased;
    const thumbnailUrl = firstItem ? firstItem.thumbnailUrl : undefined;
    const modificationTime = firstItem ? firstItem.modificationTime : undefined;

    const params: Partial<QuickViewParams> = {
      type: type,
      subtype: typeInfo.subtype,
      filePath: label,
      hasTask: tasks.length > 0,
      canDelete: canDelete,
    };

    const volumeInfo = this.volumeManager_.getVolumeInfo(entry);
    let localFile =
        volumeInfo && LOCAL_VOLUME_TYPES_.indexOf(volumeInfo.volumeType) >= 0;

    // Treat certain types on Drive as if they were local (try auto-play etc).
    if (entryIsOnDrive && (type === 'audio' || type === 'video') &&
        !typeInfo.encrypted) {
      localFile = true;
    }

    if (!localFile) {
      // Drive files: Try to fetch their thumbnail or fallback to read the whole
      // file.
      if (thumbnailUrl) {
        const result =
            await this.loadThumbnailFromDrive_(thumbnailUrl, modificationTime);
        if (result.status === LoadImageResponseStatus.SUCCESS) {
          if (params.type === 'video') {
            params.videoPoster = {
              data: result.data,
              dataType: 'url',
            };
          } else if (params.type === 'image') {
            params.sourceContent = {
              data: result.data,
              dataType: 'url',
            };
          } else {
            params.type = 'image';
            params.sourceContent = {
              data: result.data,
              dataType: 'url',
            };
          }
          return params;
        } else {
          console.warn(`Failed to fetch thumbnail: ${result.status}`);
        }
      }
      if (typeInfo.encrypted) {
        params.type = 'encrypted';
        return params;
      }
    }

    if (type === 'raw') {
      // RAW files: fetch their ImageLoader thumbnail.
      try {
        const result =
            await this.loadRawFileThumbnailFromImageLoader_(entry as FileEntry);
        if (result.status === LoadImageResponseStatus.SUCCESS) {
          params.type = 'image';
          params.sourceContent = {
            data: result.data,
            dataType: 'url',
          };
        } else {
          console.warn(`Failed to fetch thumbnail: ${result.status}`);
        }
        return params;
      } catch (error) {
        console.warn(error);
      }
      return params;
    }

    if (type === '.folder') {
      return Promise.resolve(params);
    }

    try {
      const file =
          await new Promise((resolve: (file: File) => void, reject) => {
            if ('file' in entry) {
              entry.file(resolve, reject);
              return;
            }
            reject(new Error('entry not a file type'));
          });

      switch (type) {
        case 'image':
          if (UNSUPPORTED_IMAGE_SUBTYPES_.indexOf(typeInfo.subtype) === -1) {
            params.sourceContent = {
              data: file,
              dataType: 'blob',
            };
          }
          return params;
        case 'video':
          params.sourceContent = {
            data: file,
            dataType: 'blob',
          };
          params.autoplay = true;
          if (thumbnailUrl) {
            params.videoPoster = {
              data: thumbnailUrl,
              dataType: 'url',
            };
          }
          return params;
        case 'audio':
          params.sourceContent = {
            data: file,
            dataType: 'blob',
          };
          params.autoplay = true;
          const itemsContentThumnbnail =
              await this.metadataModel_.get([entry], ['contentThumbnailUrl']);
          const item = itemsContentThumnbnail[0];
          if (item?.contentThumbnailUrl) {
            params.audioArtwork = {
              data: item.contentThumbnailUrl,
              dataType: 'url',
            };
          }
          return params;
        case 'document':
          if (typeInfo.subtype === 'HTML') {
            params.sourceContent = {
              data: file,
              dataType: 'blob',
            };
            return params;
          }
          break;
        case 'text':
          if (typeInfo.subtype === 'TXT') {
            try {
              const text = await file.text();  // Convert file content to utf-8.
              const blob =
                  await new Blob([text], {type: 'text/plain;charset=utf-8'});
              params.sourceContent = {
                data: blob,
                dataType: 'blob',
              };
              params.browsable = true;
            } catch (error) {
              console.warn(error);
            }
            return params;
          }
          break;
      }

      params.browsable = tasks.some(task => {
        return ['view-in-browser', 'view-pdf'].includes(
            parseActionId(task.descriptor.actionId));
      });

      if (params.browsable) {
        params.sourceContent = {
          data: file,
          dataType: 'blob',
        };
      }
    } catch (error) {
      console.warn(error);
    }
    return params;
  }

  /**
   * Loads a thumbnail from Drive.
   */
  private async loadThumbnailFromDrive_(url: string, modificationTime?: Date):
      Promise<LoadImageResponse> {
    const client = ImageLoaderClient.getInstance();
    const request = createForUrl(url);
    request.cache = true;
    request.timestamp =
        modificationTime ? modificationTime.valueOf() : undefined;
    return new Promise(resolve => client.load(request, resolve));
  }

  /**
   * Loads a RAW image thumbnail from ImageLoader. Resolve the file entry first
   * to get its |lastModified| time. ImageLoaderClient uses that to work out if
   * its cached data for |entry| is up-to-date or otherwise call ImageLoader to
   * refresh the cached |entry| data with the most recent data.
   */
  private async loadRawFileThumbnailFromImageLoader_(entry: FileEntry):
      Promise<LoadImageResponse> {
    return new Promise((resolve, reject) => {
      entry.file((file) => {
        const request = createForUrl(entry.toURL());
        request.maxWidth = THUMBNAIL_MAX_WIDTH;
        request.maxHeight = THUMBNAIL_MAX_HEIGHT;
        request.timestamp = file.lastModified;
        request.cache = true;
        request.priority = 0;
        ImageLoaderClient.getInstance().load(request, resolve);
      }, reject);
    });
  }
}

/**
 * List of local volume types.
 *
 * In this context, "local" means that files in that volume are directly
 * accessible from the Chrome browser process by Linux VFS paths. In this
 * regard, media views are NOT local even though files in media views are
 * actually stored in the local disk.
 *
 * Due to access control of WebView, non-local files can not be previewed
 * with Quick View unless thumbnails are provided (which is the case with
 * Drive).
 */
const LOCAL_VOLUME_TYPES_ = [
  VolumeType.ARCHIVE,
  VolumeType.DOWNLOADS,
  VolumeType.REMOVABLE,
  VolumeType.ANDROID_FILES,
  VolumeType.CROSTINI,
  VolumeType.GUEST_OS,
  VolumeType.MEDIA_VIEW,
  VolumeType.DOCUMENTS_PROVIDER,
  VolumeType.SMB,
];

/**
 * List of unsupported image subtypes excluded from being displayed in
 * QuickView. An "unsupported type" message is shown instead.
 */
const UNSUPPORTED_IMAGE_SUBTYPES_ = [
  'TIFF',  // crbug.com/624109
];